我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

鸿蒙AI原生应用开发实战:从工程思维到流式输出与异常降级

鸿蒙AI原生应用开发实战:从工程思维到流式输出与异常降级 说实话真正上手做AI原生鸿蒙应用开发之后我最大的感受不是“模型怎么接”而是整个工程的思维顺序得反过来。以前做功能是先把界面、逻辑、存储定好再考虑要不要加AI能力现在做AI原生应用得先把“AI这条链路”当成主干界面和状态管理更像是围绕它长出来的枝叶。这篇内容是我自己从搭工程、选模型、调试流式输出到被测试反馈追着做异常降级的完整记录。不是官方文档的复述基本是我踩过的坑和我认为值得走的路径。适合两类人看一是想在鸿蒙上从零孵化AI应用的开发者二是已经在做AI功能但觉得体验毛糙、想体系化打磨的人。1. AI原生到底在做什么从“加个AI按钮”到“系统级智能”1.1 “加个AI功能”和“AI原生”是两套工程思维很多团队做AI应用实际上就是在原有页面上挂一个聊天入口这叫做“加了AI功能”。AI原生不一样它的意思是把大模型的输入、推理、输出当成整个应用的主干数据流UI、状态、缓存、异常处理全都围绕这条流重新设计。我举个例子。传统应用的数据流通常是用户输入 → 业务逻辑 → 存储或服务端 → 返回结果。这个模型很稳定因为每一步都是确定性的。而AI原生应用的数据流变成多模态输入 → 上下文拼装 → 模型推理 → 流式输出 → 结果落地。这条流有三个特点会让工程结构发生明显变化状态是持续累积的。对话历史、上下文摘要、当前生成的中间内容都要全程跟着跑。错误是中途会爆发的。不是等到最后才返回一个失败码而是在生成过程中随时可能断流、超时、取消。渲染是逐字刷新的。界面不能等完整结果出来才更新必须支持增量渲染。这一点定了后面所有设计都有了解释。如果你还停留在“我调一次接口拿回结果再填到TextView”的思路里那做出来的最多算个AI皮肤谈不上原生。1.2 鸿蒙上做AI应用的三类先发场景鸿蒙生态适合做AI应用的地方不是单纯因为某台手机算力强而是设备间协同和数据流转能力比较突出。我梳理了三类适合第一版就切入的场景入坑时好有个方向第一类是系统协同类。比如手机采集语音平板或者车机继续展示处理结果或者手表接收轻量通知。AI服务在后台完成结果主动推送。这类体验在单一设备平台上很难做但放到多设备场景里就顺理成章。第二类是端侧实时类。比如拍照识物、会议录音转写强调低延迟和隐私数据尽量不出设备。鸿蒙系统对端侧推理的调度支持相对完整模型可以塞进应用沙箱里跑也可以热加载。第三类是系统代理类。比如通知摘要、日程理解、邮件分类这类功能适合和系统服务深度结合做成常驻型的AI代理而不是用户主动打开的一个页面。我最终选的是“语音问答加拍照理解”作为样板项目走了前两类的混合路线。如果你拿不定主意建议先做端侧实时类因为边界清晰调试难度相对低而且隐私优势明显审核时也好说清楚。2. 工程初始化API版本、Stage模型与模块划分2.1 Stage模型是我唯一愿意投入的方向鸿蒙应用从工程模型上有过两代方案传统的前后台模型和现在的Stage模型。我不是说旧方案完全不能用而是从做AI应用的角度Stage模型的表现力强太多了。Stage模型的核心是按模块拆分能力每个“Ability”都有明确的职责边界。比如作为入口的Ability负责页面展示而一些后台计算可以拆成单独的模块由前台触发、独立运行。AI应用天然需要“前台交互后台推理”的配合用Stage模型很好落地。以我做的语音问答为例前台Ability负责展示和录音推理过程放进一个独立的业务模块里这样即使页面因为多任务切换进入后台请求也不至于立刻被系统杀掉。旧模型在这类场景里约束很明显经常需要为了保活跟系统较劲开发成本很高。另外Stage模型对组件数据管理也有好处。每个页面和业务模块的生命周期更清晰AI推理过程中出现的状态变化不容易污染无关页面。尤其是后面要做流式输出的时候页面销毁和请求取消之间的联动非常依赖这套生命周期机制。2.2 module.json5里面最容易写错的权限声明鸿蒙工程里的module.json5说白了是你应用的名片和权限清单。我当初犯过一个很蠢的错在代码里调用麦克风没问题但忘了在配置里声明权限真机上直接提示无权限排查了半天。做一个带语音输入和拍照理解的AI应用至少要关注这几类权限能力权限说明录音麦克风权限需要在首次录音前动态申请同时在配置中声明拍照相机权限如果支持实时取景识别还需要关注前后台状态读取相册相册读取权限从相册选图时需要注意权限分级网络网络权限云端模型请求必须同时要考虑网络状态变化一个比较完整的声明片段大概长这样{ module: { name: entry, requestPermissions: [ { name: ohos.permission.MICROPHONE, reason: 用于语音问答输入, usedScene: { abilities: [EntryAbility], when: inuse } }, { name: ohos.permission.CAMERA, reason: 用于拍照识物, usedScene: { abilities: [EntryAbility], when: inuse } }, { name: ohos.permission.INTERNET, reason: 用于调用云端模型服务 } ] } }写这个文件时务必注意权限理由的措辞。应用市场审核时权限说明会和用户实际看到的隐私声明绑定理由含糊就会出现审核退回或者用户质疑。“用于AI语音识别”和“用于保存用户录音”给人的信任感完全不一样后者大概率会被追问。2.3 AI代码不要散落进pages里面我见过不少项目写着写着AI调用的代码就混进了页面组件里看起来快后期想加状态管理、想换模型几乎等于重写。工程目录在一开始就要把AI核心模块独立出来。我自己比较习惯的目录划分是entry/src/main/ets/ entryability/ // 页面与生命周期 pages/ // UI页面 components/ // 可复用组件比如气泡、加载态 aicore/ model/ // 模型配置、端侧模型加载 session/ // 会话管理上下文拼接 providers/ // 云侧服务接入端侧推理封装 tools/ // token估算、文本清洗、图片压缩这样分层的原因很简单AI能力的边界很清楚它不应该关心你页面里按钮叫什么名字页面也不应该关心模型输入的token到底怎么拼的。中间用会话层衔接谁要改都不需要动对方的代码。session我后来越用越觉得重要。它负责维护用户说了什么、模型回了什么、哪些历史已经被压缩过、当前请求是否还在进行。这些都放在一个类里管理后续无论是换模型还是加上下文记忆都是替换内部实现而不是动UI。3. 接入AI服务的主链路模型选型、参数封装与超时兜底3.1 端侧推理还是云侧API别被概念带偏选模型时最容易被忽悠的点是“必须上云才是大模型”或者“本地才能保证隐私”。真实情况取决于你要解决的问题。端侧推理的优势是延迟低、离线可用、隐私好没有按量计费的压力。比如语音转写后的意图判断、拍照后先在本地点个检、会议录音里的关键词命中这些轻量任务放在端上很合适。劣势是模型参数不能太大语义理解能力天花板明显。云侧API的优势是模型能力强上下文窗口大能做长文本总结、复杂推理、多轮对话。劣势是延迟不稳定、按调用量付费、必须联网而且涉及用户数据出设备的问题。我的建议是采用混合链路先让端侧轻量模型做意图分类和敏感信息过滤判断请求是否必须上云如果上云再走完整模型推理。这套链路有点像一个公司里先由客服判断你是不是需要见专家而不是什么人都直接推到老板桌上。维度端侧推理云侧API混合模式延迟低受网络影响端侧快复杂任务上云离线能力支持不支持部分支持效果能力偏轻量强根据场景切换数据私密性高需要脱敏可控成本一次开发持续按量付费折中第一版别搞太复杂把“端侧分类→云侧推理”这条链路跑通就够了。端侧也只需要一个小的分类模型几MB级别不要一上来就想塞一个几十GB的大家伙进应用包里。3.2 请求层设计把模型调用从UI逻辑里剥出来我在做样板项目的过程中最大的一个重构动作就是封装了一个AISession类。所有模型调用、上下文维护、取消请求、超时处理全部收敛到它内部。这其实是一个很朴素的设计原则UI不直接碰模型。你想想看用户在输入框里按了发送如果页面直接去拼prompt、调网络、解析返回那这个页面基本没救了——随便一个网络抖动都会让页面逻辑变得混乱。我第一版代码就是这样后来花了整整一天重构出下面这类结构export interface ChatMessage { role: user | assistant | system; content: string; timestamp: number; } export interface AISessionOptions { apiEndpoint: string; timeoutMs: number; maxHistoryLength: number; onPartial?: (text: string) void; } export class AISession { private history: ChatMessage[] []; private abortFlag false; constructor(private options: AISessionOptions) {} async ask(input: string): Promisestring { this.abortFlag false; const userMsg: ChatMessage { role: user, content: input, timestamp: Date.now(), }; this.history.push(userMsg); const requestBody this.buildRequestBody(); const response await this.requestWithTimeout(requestBody); if (typeof response string) { this.history.push({ role: assistant, content: response, timestamp: Date.now(), }); return response; } // 流式返回场景 let fullText ; for await (const chunk of response) { if (this.abortFlag) { throw new Error(request canceled); } fullText chunk; this.options.onPartial?.(fullText); } this.history.push({ role: assistant, content: fullText, timestamp: Date.now(), }); return fullText; } cancel(): void { this.abortFlag true; } private buildRequestBody() { // 在这里完成上下文拼接和参数封装 return { messages: this.history.map((m) ({ role: m.role, content: m.content, })), stream: true, }; } }这里的核心逻辑其实不是网络请求怎么写而是把“上下文由谁维护”“输出怎么回调”“取消怎么传播”三个问题一次性解决掉。UI只需要new一个session把用户输入丢进去然后监听onPartial刷新界面就行。3.3 鉴权、超时与重试AI请求不是普通网络请求AI请求和普通API请求有个很大的不同它可能很慢生成一段长文本可能超过10秒而且中途会持续输出内容。超时处理就不能简单设一个“5秒没返回就报错”要考虑首包超时和总体超时分开。我实际的配置思路是这样的首包超时3秒——发出请求后3秒内一个字节都没回来基本可判定服务挂了总时长上限45秒——超过就自动终止避免用户无限等待。流式过程中如果连续两轮间隔超过8秒也判定为异常主动断开。鉴权方面云端模型接口一般都用token但token有有效期。要在Session内部做好过期前的自动刷新不能让用户碰到“会话过期”还要手动重新登录。这个我在第一次联调时就忽略了结果用户挂机半小时后再问直接400体验极其糟糕。失败重试也要遵循一个原则幂等请求才能重试。如果你发送的请求是“帮我写一封邮件”重发一次顶多多写一版没问题如果请求是“帮我支付一笔订单”重发就可能出大事。重的AI应用里建议把“有副作用的请求”和“无副作用的请求”分开前者禁用自动重试只给用户手动重发的选项。4. 流式输出与多模态输入把AI体验做成“人的节奏”4.1 流式输出在ArkUI里的两个坑刷新频率与滚动锚定流式输出是AI原生应用体验的一个重要关卡。你想让用户感觉AI在“打字”而不是等半天出黑屏。但在ArkUI里做流式输出我第一次实现时踩了俩坑一个是刷新频率一个是滚动锚定。刷新频率的问题在于如果每个token都去更新UI状态界面会被刷新请求打满渲染线程几乎卡死。我一开始直接在onPartial里同步赋值给状态变量结果明显感觉到卡顿。后来改成积攒增量至少攒16个字符或者遇到换行才触发一次刷新体验立刻顺畅了。这个批量刷新策略其实是在“流畅度”和“实时性”之间做取舍let lastRenderLength 0; let buffer ; this.session.onPartial((fullText) { buffer fullText; const shouldFlush fullText.length - lastRenderLength 16 || buffer.includes(\n); if (shouldFlush) { this.renderText buffer; lastRenderLength fullText.length; } });滚动锚定是另一个隐蔽问题。当输出文本越来越长用户正在看中间的内容结果新token把列表撑高列表自动跳到最底部用户就被强行拽走了。这真的很烦。我最后的处理方法是如果用户正在上滑读历史就暂停自动跟随滚动如果用户已经翻到底部则继续自动跟随。实现不复杂但对体验提升极大。4.2 语音输入与图像理解权限、编码与数据尺寸语音输入这条链路有三个容易被忽略的细节。第一个是录音格式。很多录音器默认采集的是高保真格式但语音识别接口通常只接受压缩后的格式。要录完就压别等识别的时候再转否则用户那边会明显感觉“我说完话要等很久才变成文字”。我后来把录音参数固定成16kHz单声道识别准确率和延迟都更可控。第二个是音量反馈。用户说话时界面上最好有个实时音量指示器。这不是花架子而是让用户确认“我的声音真的进去了”。没有这个反馈用户会在嘈杂环境里一遍遍重复输入体验直线下降。第三个是语音识别结果不要直接丢给模型。识别文本经常有标点缺失、语气词多的问题最好先做一轮轻量清洗去掉“嗯”“啊”这类词对问句补上问号再进入上下文。这一步对模型回答质量的影响比想象中大很多。图像理解方面一个常见的坑是原图直接塞给模型。一张几MB的照片传到云侧又慢又贵很多端上模型还吃不下。正确的做法是先等比压缩长边控制在1280像素以内质量压缩到85%然后再转成模型能接受的格式。识别类任务不需要保留原始文件压缩后信息量足够。4.3 会话上下文窗口与历史压缩策略大模型上下文窗口不是无限大的。做多轮对话时如果不做控制早期只能保留少量对话或者干脆溢出。我试过最简单粗暴的方案——只保留最近5轮结果发现用户问“我刚刚说的高铁站是哪个”时模型完全失忆体验很割裂。后来的方案是“摘要压缩”当会话历史超过阈值比如总字符数超过6000就把最早的部分交给一次单独的压缩请求让它生成一个结构化摘要之后继续对话时携带的是“摘要最近几个完整轮次”。这就有点像你开读书笔记不可能每次都翻全文但笔记本上的摘要要够用。代码里就是在这个位置触发一次额外的处理private async maybeCompactHistory(): Promisevoid { const totalLength this.history.reduce( (sum, msg) sum msg.content.length, 0 ); if (totalLength this.maxContextLength) { return; } const olderMessages this.history.slice(0, -4); const summaryPrompt 请把下面的对话压缩为要点列表保留关键事实、地点、用户偏好...; const summary await this.model.summarize(summaryPrompt JSON.stringify(olderMessages)); this.history [ { role: system, content: 以下内容是更早对话的摘要${summary}, timestamp: Date.now(), }, ...this.history.slice(-4), ]; }这个方案的缺点是会多花一次模型调用所以我只在对话轮次确实很多时才触发而且压缩事件要在界面上提示“已为你整理之前的对话”让用户觉得这是功能而不是故障。5. 性能、状态管理与异常降级别让用户面对“AI转圈”5.1 状态机设计loading / partial / success / failedAI请求的状态不是简单的“请求中”和“请求完成”它至少包含等待首包、流式生成中、完整结束、失败、已取消五种。不把这几个状态显式建模UI很容易乱。我用一个枚举管理export enum AIRequestStatus { Idle idle, Connecting connecting, Streaming streaming, Success success, Failed failed, Canceled canceled, }页面里就根据当前状态决定渲染什么Connecting时显示“正在理解”Streaming时显示实时文本Failed时给出重试按钮和降级提示Canceled时什么都不显示但把输入框状态复位。这里要特别留意竞态问题。用户可能在流式输出还没结束时又发起了新请求如果不对旧请求做取消就很容易出现“答案还没显示完新一轮结果忽然覆盖掉”。我在ask()开头就调用了cancel()同时用自增的请求序号来识别“晚到的旧响应应该被丢弃”。5.2 端侧推理的功耗与内存问题端侧模型看起来方便但真机一跑就会遇到烫温和掉帧问题。我第一次把模型跑在端上连续推理三轮后设备背面明显发热界面帧率也下降到肉眼可见的不流畅。后来总结下来有三类经验模型文件不要常驻在内存里用的时候加载不用就释放。很多端侧推理框架支持引用计数你可以自己控制生命周期。推理放在合适的工作线程里跑不要占用UI线程。虽说现在设备性能都不差但AI推理这种重活还是交给后台线程更稳。设置好NPU和CPU的调度策略。实时性要求高的场景用NPU加速但是连续推理的场景要考虑功耗适当限频避免发热。性能工具的监测要一直开着。开发阶段不觉得有问题往往是因为连着调试线、屏幕常亮、环境温度低。真机上手持玩两分钟问题全出来了。5.3 降级链路AI不可用时应用怎么兜底“AI一定可用”是最大的错觉。我在项目测试中发现模型可能因为网络差、服务端过载、内容安全策略拦截甚至模型本身还在加载中就收到请求。如果不做降级用户面对的就是转圈圈转半天然后静默失败。我现在的降级链路分三层第一层是本地规则兜底。比如用户问“现在几点”端侧搜索一下本地时间直接返回不需要走模型。这类天气、时间、系统设置等确定性请求完全可以用规则覆盖。第二层是缓存回答。用户问过类似问题或同一问题如果上下文一致直接返回历史答案省一次模型调用速度也最快。第三层是明确的失败提示与转人工方案。模型超时或返回被拒时告诉用户“当前AI服务暂时不可用已为你保存输入可以稍后重试”并提供复制文本、重试、甚至切换到本地精简模式三个按钮。千万不要只弹一个“网络错误”。这层降级看着不起眼但它决定了用户在对AI失望后会不会再给你一次机会。至少我的测试反馈里能明确知道自己“该等还是该放弃”的用户满意度比遇到一片空白的用户高很多。6. 真机验证、隐私合规与上架收尾AI应用最容易翻车的地方6.1 真机调试清单从弱网到息屏AI应用和普通应用最大的区别是它经常跑长任务而不是用户点一下、服务端一秒内返回。所以真机调试时要专门测这些场景弱网环境3G模拟或者主动加延迟观察首包时间和流式输出节奏。网络恢复飞行模式打开再关闭AI请求能否自动恢复或者给出重试入口。前后台切换生成过程中用户去回微信再切回来内容是否还在继续。息屏端侧推理时息屏会不会被杀进程。连续提问快速连发十个请求内存是否持续上涨。界面销毁用户提问后立刻退出页面请求是否及时取消避免后台浪费资源。我自己测下来最隐蔽的问题是“界面销毁但请求未取消”。页面没了Session还在偷偷跑跑完了还试图回调更新一个已经不存在的页面轻则内存泄漏重则直接崩溃。处理办法是在页面onPageHide或销毁时调用session.cancel()并且回调前检查页面状态。6.2 隐私与数据边界AI日志里不能出现的东西涉及AI的应用隐私问题会被放大。模型调用链路里用户的语音、图像、聊天内容都可能成为数据日志的一部分。我整理了一份自查清单每次发布前都会过一遍录音原始文件不能留存转成文字后立即销毁。图片临时文件在识别完成后清空禁止写入相册或应用沙箱的持久目录。聊天记录默认保存在本地用户可一键清除。开发调试日志里不能打印完整请求体和响应体特别是包含个人信息的部分。云端模型请求前对文本内容做敏感信息过滤去除姓名、手机号、地址等字段。隐私这块不是做给审核看的而是真实的安全底线。一旦出了数据泄露用户对应用失去信任是永久的。6.3 上架前最后检查包体积、权限说明与审核素材AI应用上架时有几个容易被卡的地方提前准备好可以减少来回折腾。包体积是一道硬性门槛。如果模型文件放在应用包里一个模型动不动几百MB很多分发场景会受限。我的方案是应用包里只放必需的轻量分类模型大模型放远端用户首次用到某功能时再按需下载。这样首次安装体积控制在合理范围功能使用也不受影响只是需要处理好“模型未下载”状态时的引导。权限说明要提前准备一份专门给审核人员看的解释文档。比如“麦克风权限会录音录音仅在本地识别为文字之后立即删除”这种程度。不要用含糊的话审核人员每天看大量应用含糊表述很容易被判定为隐私风险。演示账号和录屏也要准备好。比如拍照识物功能审核时审核人员未必有合适的环境或素材最好内置一个“示例素材集”能一键展示功能和异常处理流程。这个建议我从一个做智能识图应用的朋友那里学到的被他救了很多次。最后聊几句做AI原生鸿蒙应用这几个月我最大的体会是技术栈本身其实不是最大的门槛真正的难点在于对交互节奏的理解。AI不排班、不打卡它可能3秒内出结果也可能思考15秒后给你一个长长的方案中间还可能随时反悔。你的应用要在这种不确定的节奏里依然让用户感到可控这是以前做普通App时完全不需要考虑的。如果你正准备做类似方向我建议别在模型选型上纠结太久先拿一个最轻的方案把“输入→会话→模型→Streaming→降级”这条链路完整跑起来再回来优化每一段。链路通了换什么模型都是换一个环节的事。
返回列表