说实话,真正上手做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): Promise<string> { 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(): Promise<void> { 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 / failed
AI请求的状态不是简单的“请求中”和“请求完成”,它至少包含:等待首包、流式生成中、完整结束、失败、已取消五种。不把这几个状态显式建模,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→降级”这条链路完整跑起来,再回来优化每一段。链路通了,换什么模型都是换一个环节的事。