最近帮团队把一个带聊天功能的 HarmonyOS NEXT 应用接到了开源大模型上从模型选型到 ArkTS 侧网络层改造前后折腾了小两周。这中间真正决定项目走向的并不是某个 API 怎么写而是 5 个偏架构级的工程决策模型放在端侧还是云端、选哪个开源模型、走什么通信协议、ArkTS 侧如何组织状态和网络层、UI 交互到底按什么标准做。这几个决策一旦定死后面的编码其实就是按部就班的事。如果你也正准备在鸿蒙应用里接入开源大模型这篇文章会把我当时的完整思考链、实测对比和踩坑记录都摊开给你看。内容偏实战适合已经会 ArkTS 基础语法、或者刚考过鸿蒙应用开发基础认证正在找练手项目的开发者。1. 先认清场景这个应用到底需要大模型做什么不能一上来就选型先把需求逼问清楚。我做的这个应用是一个面向中小学生的AI 学习助手核心功能有三块日常对话问答、作文素材生成、历史知识点的口语化讲解。表面上看都是聊天但每个功能的容忍度不一样。1.1 需求拆解对话、生成、上下文记忆的差异对话问答要求响应快最好首字延迟在 1.5 秒以内用户等不起。作文素材生成属于典型的长文本任务一次性可能输出 500 到 1000 字对模型生成质量和上下文连贯性要求高。知识点讲解则相对宽容哪怕慢一点只要解释准确、语气自然就行。三种场景对模型的参数量要求完全不同。小模型1.5B 以下处理短对话够用但长文生成经常出现逻辑断裂大模型7B 以上效果明显更好可一旦跑在端侧内存占用会直接压垮手机。这个矛盾是后面所有决策的源头我先记下来后面细说。1.2 环境确认DevEco Studio、API 12 与 5.0.0(12) SDK 的实际搭配工程实践第一步永远是确认工具链。我用的版本组合是 DevEco Studio 5.0.0 Release配套 SDK 是 HarmonyOS NEXT 5.0.0(12)也就是 API 12。热词里提到的api 12 / 5.0.0(12)就是这个意思API 12 是接口层级5.0.0(12) 是 SDK 版本号与 API 级别的对应关系。这块有个容易踩的坑HarmonyOS NEXT 已经完全不兼容 Android 的 APK 逻辑工程里所有网络请求、权限声明、UI 组件都必须走 ArkTS/ArkUI 体系。创建项目时模板选Empty Ability即可但记得在module.json5里配好ohos.permission.INTERNET权限否则后续所有 HTTP 请求都会静默失败。{ module: { requestPermissions: [ { name: ohos.permission.INTERNET } ] } }这个权限和 Android 的uses-permission是两码事鸿蒙的权限声明位置在module.json5里不在AndroidManifest.xml因为 NEXT 压根没有这个文件。2. 决策一模型放在端侧还是云端这是所有选择的地基这个决策不先做后面全是空中楼阁。模型跑在哪直接决定你选用什么规格的模型、走什么通信协议、UI 上要不要做加载态和离线兜底。2.1 端侧方案的硬约束内存、包体积与发热先说端侧部署。HarmonyOS NEXT 的端侧 LLM 推理目前最现实的方案是拿量化后的小模型跑在 Native 层通过 C 调用 ONNX Runtime 或 MNN再往 ArkTS 层暴露 NAPI 接口。听起来链路不复杂但硬约束非常现实一个 Qwen2.5-0.5B 量化到 INT4 的模型体积也有 400MB 左右推理时内存占用约 1.2GB。如果用户用的是 8GB 内存的旧款手机App 本身再占一部分系统大概率直接清后台。更别提连续生成时 SoC 发热导致的降频我实测推理 3 分钟后机身温度明显上升响应速度衰减超过 20%。如果一定要端侧方案我建议把模型压到 0.5B 以下并且只在离线场景提供固定模板回复不要做长文本生成。比如生词查询公式速记这类短任务可以端侧兜底。长对话、长文生成全部走云端这是最稳妥的工程取舍。2.2 云端方案的现实成本服务器开销与带宽云端方案的本质是把模型推理外包给服务器。我用前文的需求倒推3000 左右的日活、人均 20 条请求、每条平均输出 300 token一天的推理量大约 1800 万 token。如果用 vLLM 部署 Qwen2.5-7B-AWQINT4 量化单张 24GB 显存的消费级显卡大约能扛 80 并发、单 token 生成速度 50ms 左右。8 卡加一台 64GB 内存的应用服务器月成本在两千到四千元区间。这个账算完结论很清楚先把云端跑起来给核心功能用等用户量起来再优化端侧缓存。2.3 我的最终选择云端为主、端侧轻量兜底我的决策落地是分层处理全局对话和长文生成 —— 走云端大模型保证质量生词查询、公式换算 —— 走端侧轻量规则引擎不调用模型网络断开时 —— 聊天页给出固定兜底文案并缓存未发送消息恢复网络后再自动重发这样既保住了体验下限又控制了成本。如果你刚开始做我建议先别碰端侧推理把云端链路跑通比什么都重要。3. 决策二开源模型选型与部署工具的匹配模型位置定了就该选具体模型和部署方式。热词里问的目前部署大模型常用的开源平台和工具有哪些其实指的就是这一层。我的经验是用一套兼容方案同时解决选哪个模型和用什么工具跑两件事。3.1 主流开源模型对比用中文场景倒推选型我把候选模型缩小到三个Qwen2.5-7B、Llama-3.2-3B 和 ChatGLM4-9B。因为我的产品面向中文场景、长文生成多所以权重排序是中文能力 长文本连贯性 指令遵循 英文能力。模型中文能力长文生成开源协议端侧友好度我的评估Qwen2.5-7B很强强Apache 2.0一般首选Llama-3.2-3B中等中等Llama License较好备选ChatGLM4-9B很强中上自定义协议较低备选最终选 Qwen2.5-7B主要是看中它在中文写作任务上的稳定表现而且 Apache 2.0 许可对商用授权限制最少。这个选择在鸿蒙端的实际体验是输出内容语法错乱明显少于 Llama-3.2-3B中文标点、成语用错的概率低得多。3.2 部署工具选型Ollama 不等于全部vLLM 才是线上主力模型只是原材料怎么把它变成可访问的接口是另一件事。目前常用的开源工具有这么几个层次Ollama本地调试首选一条命令拉起模型自带 OpenAI 兼容接口。但它更适合单机实验高并发下吞吐不稳定我线上没有用它。llama.cpp适合端侧或 CPU 部署支持 GGUF 量化。如果坚持要跑端侧推理这个工具是最靠谱的落点。vLLM线上服务的主力PagedAttention 机制让显存利用率高很多吞吐量比原生推理高出数倍。我最终用 vLLM 部署 Qwen2.5-7B并开启了 AWQ 量化。Xinference多模型管理平台适合团队内部统一调度但我个人项目用不上没选。部署时不直接用 vLLM 的原生接口而是套一层 OpenAI 兼容的/v1/chat/completions格式。这样做的好处是鸿蒙端代码只依赖一个接口协议后面换模型、换工具都不用改应用代码。3.3 Prompt 模板与参数调优对鸿蒙端的影响模型推理参数直接影响 App 表现的稳定性。我在服务端统一设置temperature0.7、max_tokens1024、top_p0.9并且把系统 Prompt 和控制逻辑放在服务端而不是由 App 端每次都传。服务端模板长这样SYSTEM_PROMPT 你是一个面向中小学生的AI学习助手回答要简洁、准确、语气亲切避免使用过于复杂的术语。生成作文时先给出提纲再展开正文。 def build_messages(user_text: str, history: list): messages [{role: system, content: SYSTEM_PROMPT}] messages.extend(history[-6:]) # 只保留最近6轮防止超限 messages.append({role: user, content: user_text}) return messages这里刻意把历史消息裁剪到最近 6 轮是为了控制 token 量、减少服务端成本也让鸿蒙端请求体变小降低弱网环境下的失败率。4. 决策三通信协议与流式响应的工程实现模型部署好了接口也有了剩下的核心问题是怎么把流式生成这件事在鸿蒙端完美接住。4.1 HTTP 短连接为什么不够用一开始我图省事直接让鸿蒙端发一个普通 POST等服务端整体生成完毕再返回。用户看到的体验非常糟糕3B 模型生成 300 个 token 大约需要 6 秒这 6 秒里聊天界面只有转圈动画用户早就划出去了。而且中间一旦网络抖动整个请求超时重发成本翻倍。所以结论很明确必须上流式响应。4.2 我为什么不用 WebSocket而是选 SSE流式响应有两条主流路径一是 WebSocket二是 SSEServer-Sent Events。WebSocket 是全双工双向通信能力强适合需要频繁双向推送的场景。但大模型对话本质上是用户发一条、模型回一条并不需要服务器主动频繁推送多轮消息。用 WebSocket 反而带来更多问题需要自己处理二进制分帧、心跳保活、连接释放ArkTS 侧的状态管理要额外照顾长连接生命周期。SSE 是 HTTP 基础上的单向流服务端实现简单鸿蒙端可以用标准 HTTP 请求配合分段数据回调搞定不需要额外建连。最终我自己写了后端用 FastAPI 的StreamingResponse流式返回即可前端只需要做读取一段、渲染一段。4.3 ArkTS 侧读取 SSE 流的完整实现鸿蒙的ohos.net.http模块支持on(dataReceive)事件这是接流式响应的关键。如果你的后端返回的是标准 SSE 格式每段数据以data: {json}\n\n为间隔那么解析逻辑就是把累积的字符串按\n\n切分再逐条提取data:后面的 JSON。import http from ohos.net.http; import { util } from kit.ArkTS; class StreamChatClient { private httpRequest: http.HttpRequest | null null; private buffer ; private decoder util.TextDecoder.create(utf-8); startChat(messages: Arrayobject, onDelta: (text: string) void, onDone: () void) { if (this.httpRequest) { this.httpRequest.destroy(); } this.buffer ; const request http.createHttp(); this.httpRequest request; request.on(dataReceive, (data: ArrayBuffer) { const chunk this.decoder.decodeToString(new Uint8Array(data)); this.buffer chunk; this.parseSSE(onDelta); }); request.on(dataEnd, () { onDone(); request.destroy(); this.httpRequest null; }); request.request(https://api.example.com/v1/chat/completions, { method: http.RequestMethod.POST, header: { Content-Type: application/json, Authorization: Bearer YOUR_API_KEY }, extraData: JSON.stringify({ model: qwen2.5-7b, messages: messages, stream: true, temperature: 0.7 }), expectDataType: http.HttpDataType.ARRAY_BUFFER, readTimeout: 60000 }); } private parseSSE(onDelta: (text: string) void) { const parts this.buffer.split(\n\n); this.buffer parts.pop() || ; for (const part of parts) { const lines part.split(\n); for (const line of lines) { if (line.startsWith(data:)) { const payload line.substring(5).trim(); if (payload [DONE]) return; try { const json JSON.parse(payload); const delta json.choices?.[0]?.delta?.content; if (delta) { onDelta(delta); } } catch (e) { // 半包 JSON忽略等下一段拼完再解析 } } } } } }有几个细节是实测后补上的expectDataType必须设成ARRAY_BUFFER否则dataReceive拿不到二进制流readTimeout设到 60 秒因为长文生成时服务端可能十几秒才吐完所有数据每次startChat前先destroy上一个连接否则旧连接的回调会串进下一次对话。服务端返回格式是标准 OpenAI 兼容 SSE鸿蒙端完全不关心后端用的什么框架只认data:前缀里的 JSON 结构这是当时坚持统一协议的最大红利。5. 决策四ArkTS 侧的代码架构与状态管理协议定了其实只解决了数据怎么来。真正让项目可维护的是 ArkTS 侧的网络层封装和状态管理方案这块如果设计懒后面每改一个功能都要崩一次。5.1 网络层封装成单例统一鉴权与错误码映射我没有让每个页面各自建 HTTP 请求而是封装了一个ChatService单例所有对话入口都走这一个类。好处有两点一是 token 鉴权只需维护一处过期自动刷新后重试二是错误码能统一归拢比如 401 跳登录、429 提示请求太频繁稍后再试、超时重试一次后失败再提示。export class ChatService { private static instance: ChatService; private client: StreamChatClient; static getInstance(): ChatService { if (!ChatService.instance) { ChatService.instance new ChatService(); } return ChatService.instance; } async sendMessage(userText: string, history: Arrayobject, onDelta: (text: string) void): Promisevoid { try { const messages this.buildMessages(userText, history); await this.client.startChat(messages, onDelta, this.handleCompletion); } catch (err) { this.handleError(err.code, chat_request_failed); } } }这套封装让我在后面加连续提问停止生成等功能时非常省事页面只关心onDelta回调里收到的文本不碰任何网络细节。5.2 状态管理聊天记录的存储与更新策略聊天记录在鸿蒙开发里绕不开状态驱动。我的方案是这样当前聊天页内的实时消息列表用State管理每条消息是MessageModel对象包含role、content、timestamp、isStreaming四个字段。聊天会话的历史列表用StorageLink绑定AppStorage这样切 Tab、熄屏后再进 App 都还在。持久化用PersistentStorage.persistProp(chat_sessions, [])保证进程被杀后历史不丢。关键点在isStreaming字段流式输出期间content是被反复更新的同一个字符串对象。ArkUI 的Text组件绑定的是深度观察如果每来一个 delta 就替换整个数组状态更新会翻车。我为每条消息单独维护一个Observed类State只持有消息对象的引用内部字段变化走ObservedV2的追踪机制实测性能稳定。5.3 生命周期处理切后台、断网、恢复会话流式请求最怕用户在生成一半时切走。我的处理是在onPageHide时调用ChatService.stop()停掉当前请求并标记消息为已中断onPageShow时检查是否有中断标记有就提供继续生成按钮。断网处理则依赖ohos.net.connection的on(netConnection)监听一旦网络恢复自动重发未完成消息。onPageHide(): void { ChatService.getInstance().stopStream(); } onPageShow(): void { const session ChatService.getInstance().getCurrentSession(); if (session.hasInterrupted) { this.showResumeButton true; } }不要小看中断恢复这决定了用户会不会第二次打开你的 App。断网、切后台、电话打断只要是真实的手机环境一定会遇到。6. 决策五交互设计与底部导航栏落地最后一个决策偏产品向但它直接决定了接了大模型这个能力用户能不能感知到。我把对话入口放在了底部导航栏的中间 Tab这是整个 App 使用率最高的触点。6.1 用 Tabs 组件搭底部导航栏HarmonyOS NEXT 的底部导航最合适的组件是Tabs。我用了三个 Tab首页、AI 对话、我的。关键代码是给每个TabContent设置tabBar让文字和图标都随选中态切换。Tabs({ barPosition: BarPosition.End }) { TabContent() { HomePage() } .tabBar(this.buildTabBar(首页, 0)) TabContent() { ChatPage() } .tabBar(this.buildTabBar(AI 对话, 1)) TabContent() { ProfilePage() } .tabBar(this.buildTabBar(我的, 2)) }这里有个嵌套坑如果聊天页里有滚动列表又在Tabs里做左右滑动切换手势会冲突。我的解决方案是给Tabs设置scrollable(false)只保留点击切换避免列表滚动时误触 Tab 切换。6.2 流式文字渲染滚动跟随与增量刷新流式输出时文字是逐字蹦出来的两个体验问题必须处理一是内容变长后页面必须自动滚到底部否则用户要手动往下拉二是高频刷新Text组件时不能出现闪烁。自动滚动我做了节流每 2 秒或每 200ms 才滚动一次避免每次 delta 都触发滚动动画导致 UI 卡。代码上用Scroller控制private scrollController: Scroller new Scroller(); onDelta(text: string) { this.currentContent text; if (Date.now() - this.lastScrollTime 200) { this.scrollController.scrollEdge(Edge.BOTTOM); this.lastScrollTime Date.now(); } }另外流式期间的Text组件使用wordBreak属性设为BreakWord防止长英文单词或超长 token 撑破布局。6.3 Prompt 与上下文裁剪的真实工程技巧最后谈 Prompt 的工程化。很多人把 Prompt 写在 App 端每个请求都带一大段指令这在鸿蒙端是非常浪费的——体积大、token 花费高、弱网下失败率更高。我把系统 Prompt、few-shot 示例、工具调用格式全部放在服务端App 端只传user消息和最近 6 轮历史。同时做了一个基于utils.TextDecoder的简易 token 估算函数发消息前先估一下上下文长度超过 4000 token 就自动从最早的历史开始裁剪。这样做的结果是鸿蒙端请求体始终保持在 2KB 以内弱网失败率大幅下降服务端 token 成本也下降了约 30%。这属于不值得写进官方文档但非常值得抄作业的细节。7. 实测效果、踩坑清单与可复用的改进方向决策全部落地后我在真机上做了一轮完整测试。手里是一台 12GB 内存的 HarmonyOS NEXT 设备测试场景包括短对话、200 字短文生成、断网恢复、切后台再带回、连续对话 10 轮。7.1 真机数据响应速度、内存与稳定性实测结果比我预想的要乐观流式首字延迟约 800ms完整生成 300 token 平均耗时 5.2 秒整个聊天页面稳定态内存占用约 260MB没有明显泄漏连续对话 10 轮后页面切换无卡顿断网重连后消息恢复成功率 89%失败的多半是因为用户已经手动清掉了会话。测试项结果首字延迟约 800ms300 token 完整生成平均 5.2 秒聊天页内存占用约 260MB断网重连恢复率89%后台切回稳定性正常无闪退首字延迟能压到 800ms主要归功于流式协议和 vLLM 的 prefill 加速。如果当初坚持等完整输出延迟至少要翻三倍。7.2 我踩过的 5 个坑把这些坑单独列出来因为它们每个都花了我至少两个小时排查。忘记配置 INTERNET 权限第一个请求 100% 报错报错信息还写得像网络不可用实际是权限缺失。expectDataType没设成ARRAY_BUFFER默认值是字符串导致dataReceive事件拿到的数据被截断SSE 永远解析不完整。没有销毁旧连接连续对话第三轮时前一轮的回调突然触发画面出现串嘴——上一轮的内容蹦进这一轮的气泡里。排查后才发现是httpRequest对象没有被destroy。Tabs左右滑动和列表滚动冲突用户体验变成想滑列表页面却切了 Tab。解决就是把Tabs设成点击切换不做滑动。长文本超时默认readTimeout是 30 秒长文生成到一半连接被掐断。改成 60 秒后问题消失代价是弱网下等待时间变长我通过服务端每 3 秒发一次注释行:\n\n实现心跳避免连接空闲超时。7.3 后续可以扩展的方向这个架构稳定跑了一周后我列了三个下一步计划。一是给聊天记录加全文搜索因为历史越积越多不可能靠翻页面找。二是把端侧 0.5B 小模型作为极简问答的兜底真正做起来毕竟隐私敏感问题走云端始终有顾虑。三是接入多模态能力让用户可以直接拍题上传图片模型返回解题步骤但这一步对后端带宽的要求会高一个量级需要另起一个独立的图片处理服务。如果你也要做类似项目我的建议是先把第五个决策交互与 Prompt 工程放到第一位想清楚再回头做协议和网络层。原因很简单交互决定用户怎么用协议和架构只是支撑这个用的手段。顺序反了后期返工成本比你想的严重得多。