ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

鸿蒙端侧大模型落地:5个关键工程决策与性能优化实践

鸿蒙端侧大模型落地:5个关键工程决策与性能优化实践 1. 为什么要在鸿蒙应用里塞进一个大模型去年年底我开始接手一个鸿蒙原生应用的改造项目需求很明确把原来跑在云端的智能问答能力搬到端侧来。当时团队里争论挺大有人觉得直接调云端接口最省事有人担心延迟和隐私问题。最后拍板做端侧推理理由其实很朴素——用户输入的内容里有不少是个人笔记和日程信息走云端总归让人心里不踏实而且地铁、电梯这些弱网场景下云端方案的体验基本等于不可用。这个决策直接引出了后面一连串的工程问题。HarmonyOS NEXT 用的是 ArkTS 作为主力开发语言它的运行时和 Android 那套完全不是一回事NDK 的边界、线程模型、内存管理都有自己的脾气。开源大模型那边呢动辄几个 G 的权重文件推理时内存占用轻松上 G这对移动端来说是个不小的挑战。我前后试了三套方案踩了不少坑最后跑通的这套组合在麒麟芯片的机器上能做到首 token 延迟 800ms 左右生成速度大概 12 tokens/s日常问答够用了。这篇文章想聊的不是怎么调 API这种层面的事而是把整个接入过程中真正需要做判断的五个工程决策摊开来讲。每个决策背后都有取舍我会把当时的考量、实测数据、以及事后复盘觉得可以做得更好的地方都写出来。如果你也在做鸿蒙端侧 AI 相关的开发或者正在评估要不要把大模型塞进 App 里这些经验应该能帮你少走点弯路。2. 决策一模型选型——不是越小越好也不是越大越强2.1 参数量与端侧硬件的匹配逻辑选模型这件事我一开始的想法很简单找个 1B 左右的量化版本塞进去能跑就行。实际测下来发现这个思路有问题。1B 级别的模型在中文问答上的表现说实话有点勉强稍微复杂一点的问题就开始胡言乱语。但直接上 7B 呢内存又扛不住。这里需要算一笔账。以 FP16 精度为例模型推理时的内存占用大致是参数量乘以 2 字节再加上 KV Cache 和中间激活值。一个 7B 模型光权重就要 14GB这显然不现实。即使用 INT4 量化权重压到 3.5GB 左右加上 KV Cache 和运行时开销峰值内存也要 5GB 上下。而目前主流鸿蒙旗舰机的可用内存给到应用侧的安全水位大概在 2GB 到 3GB 之间。所以我的结论是端侧模型选型的第一约束是内存第二是算力第三才是效果。这三个因素的优先级不能颠倒否则做出来的东西要么跑不起来要么跑起来卡成幻灯片。2.2 量化方案的实际取舍量化是绕不开的。我试过 INT8、INT4 和混合量化三种方案实测数据如下量化方案模型体积峰值内存首token延迟生成速度中文问答可用性FP1614GB超限---INT87GB约 4.2GB1.8s6 tokens/s良好INT43.5GB约 2.6GB1.1s11 tokens/s可接受混合量化4.2GB约 3.1GB1.3s9 tokens/s较好混合量化指的是对注意力层的 QKV 投影保留 INT8其余层用 INT4。这个方案在效果和资源之间取得了比较好的平衡但实现复杂度也最高需要自己改量化脚本。如果团队没有专门的推理优化人手我建议直接用成熟的 INT4 方案省下来的时间花在提示词工程上收益更大。注意量化后的模型在数学计算和代码生成任务上退化明显如果你的应用场景涉及这两类任务建议保留一个云端兜底通道。2.3 我最终选了什么最后落地的是 Qwen2.5-1.5B 的 INT4 量化版本配合一个经过微调的 0.5B 小模型做意图分类。大模型负责生成小模型负责路由这样大部分简单请求根本不会触发大模型推理整体功耗和延迟都降下来了。这个组合的好处是灵活。意图分类模型只有 300MB 左右常驻内存压力小大模型按需加载用户不触发问答就不占资源。实测下来日常使用中大模型的实际激活时间占比不到 15%对续航的影响基本可以忽略。3. 决策二推理框架——原生 NDK 还是跨平台方案3.1 三种技术路线的对比鸿蒙端侧跑大模型推理框架的选择直接决定了开发效率和最终性能。我调研了三条路线第一条是纯 ArkTS 实现。理论上可行但 ArkTS 的数值计算性能跟 C 差着数量级矩阵乘法这种操作用 ArkTS 写基本等于自杀。这条路我试了三天就放弃了单层注意力计算就要几百毫秒完全不可用。第二条是通过 NDK 调用 C 推理库。这是最主流的路子把 llama.cpp 或者 MNN 这类推理框架编译成鸿蒙的动态库ArkTS 层通过 NAPI 调用。性能最好但开发成本高需要处理跨语言内存管理、线程调度、异常传递等一系列问题。第三条是使用鸿蒙生态内已有的 AI 框架。比如 MindSpore Lite 的鸿蒙版本或者某些厂商提供的端侧推理 SDK。这类方案上手快但灵活度受限支持的模型格式和算子类型都有约束。3.2 NAPI 调用的关键细节我最终选了第二条路用 llama.cpp 做推理后端。这里有几个坑值得单独说。首先是线程模型。llama.cpp 默认会起多个线程做并行计算但鸿蒙的 NAPI 调用默认跑在 JS 线程上直接在里面做重计算会阻塞 UI。我的做法是在 C 侧起一个独立的工作线程ArkTS 层通过回调接收结果。这里要注意线程安全推理过程中的状态变量必须加锁保护。其次是内存分配。鸿蒙对应用的内存管理比较严格大块内存分配建议走malloc而不是new因为前者更容易被系统回收。另外模型加载完成后要及时释放临时缓冲区不然峰值内存会很难看。// 简化的推理线程启动逻辑 static void* InferenceThread(void* arg) { InferenceContext* ctx static_castInferenceContext*(arg); while (ctx-running) { pthread_mutex_lock(ctx-mutex); if (ctx-hasTask) { // 执行推理 ctx-result RunInference(ctx-input); ctx-hasTask false; // 回调通知 ArkTS 层 napi_call_function(ctx-env, ctx-callback, ...); } pthread_mutex_unlock(ctx-mutex); usleep(1000); } return nullptr; }3.3 编译与集成的实操记录把 llama.cpp 编译成鸿蒙可用的 so 库需要配置 CMake 工具链。鸿蒙的 NDK 路径一般在$OHOS_SDK/native下面CMake 配置里要指定OHOS_STLc_shared不然链接会报错。编译命令大概长这样cmake -B build -G Ninja \ -DCMAKE_TOOLCHAIN_FILE$OHOS_SDK/native/build/cmake/ohos.toolchain.cmake \ -DOHOS_ARCHarm64-v8a \ -DOHOS_STLc_shared \ -DCMAKE_BUILD_TYPERelease \ -DLLAMA_CURLOFF编译出来的 so 文件放到libs/arm64-v8a/目录下然后在build-profile.json5里配置nativeLib的 abiFilters。这里有个细节鸿蒙目前对 x86_64 模拟器的支持有限如果要在模拟器上调试需要单独编译 x86_64 版本但性能会差很多建议直接上真机。实操心得编译时把LLAMA_CURL关掉不然会引入一堆网络相关的依赖在鸿蒙上根本用不到还会增加包体积。4. 决策三模型文件的分发与加载策略4.1 模型文件放哪里一个 3.5GB 的模型文件怎么塞进应用里是个大问题。鸿蒙的应用包有大小限制直接打包进 HAP 里肯定不现实。我的方案是首次启动时从服务器下载下载完成后存到应用沙箱的files目录下。这里涉及几个工程细节断点续传3.5GB 的文件下载过程中断的概率不低必须支持断点续传。我用的是 HTTP Range 请求把文件切成 4MB 的分片每下载完一片就记录偏移量。完整性校验下载完成后要做 SHA256 校验防止文件损坏导致推理时崩溃。存储空间检查下载前要检查设备剩余空间至少留出模型体积 1.5 倍的空间因为解压和加载过程中会有临时文件。4.2 加载时机与内存预热模型加载本身就要花几秒钟如果每次用户提问都重新加载体验会非常糟糕。我的做法是在应用启动后延迟加载等首页渲染完成、用户开始交互时在后台线程悄悄把模型加载到内存里。但这里有个矛盾模型常驻内存会占用大量资源可能被系统回收。鸿蒙的内存回收机制比较激进后台应用很容易被杀。我的解决方案是分级加载场景加载策略内存占用响应速度应用启动只加载意图分类小模型约 300MB快用户进入问答页加载大模型权重约 2.6GB中等用户离开问答页 5 分钟释放大模型保留小模型约 300MB快系统内存紧张全部释放按需重建接近 0慢这个策略的核心思路是用时间换空间。用户进入问答页时多等一两秒总比应用被系统杀掉强。4.3 模型文件的版本管理模型不是一成不变的后续可能要更新版本。我的做法是在沙箱目录下用版本号做子目录比如models/v1/、models/v2/。新版本下载完成后更新一个指向文件下次加载时读这个文件决定用哪个版本。旧版本在确认新版本可用后延迟删除避免更新失败导致应用不可用。// 版本指针文件的内容 { activeVersion: v2, versions: { v1: { path: models/v1/model.bin, size: 3670016000 }, v2: { path: models/v2/model.bin, size: 3720016000 } } }5. 决策四ArkTS 与 C 的职责边界怎么划5.1 哪些逻辑放 ArkTS哪些放 C这个问题我纠结了很久。一开始想把所有逻辑都塞到 C 里ArkTS 只做 UI 展示。后来发现这样调试太痛苦了C 的日志要经过 NAPI 传到 ArkTS 才能看到排查问题效率极低。最后的划分原则是计算密集型的放 C业务逻辑和状态管理放 ArkTS。具体来说C 侧负责模型加载、推理执行、tokenizer 的编解码、采样策略ArkTS 侧负责对话历史管理、提示词模板拼接、UI 渲染、用户输入处理这样划分的好处是C 侧的接口可以做得非常薄只暴露loadModel、inference、releaseModel三个方法参数和返回值都是简单的字符串或数字跨语言传递的开销最小。5.2 流式输出的实现大模型生成是逐 token 输出的如果等全部生成完再返回用户要盯着屏幕等好几秒。流式输出是必须的。实现方式是在 C 侧每生成一个 token 就通过 NAPI 回调通知 ArkTS 层。这里要注意回调频率如果每个 token 都触发一次 JS 调用高频生成时会把 JS 线程打满。我的做法是攒够 4 个 token 或者间隔超过 50ms 再回调一次这样既保证了流式效果又不会给 JS 线程太大压力。// ArkTS 侧接收流式结果 const callback (partialText: string, isFinished: boolean) { this.currentResponse partialText; if (isFinished) { this.saveToHistory(this.currentResponse); } }; nativeModule.startInference(prompt, callback);5.3 异常处理与降级跨语言调用的异常处理是个容易被忽视的地方。C 侧抛出的异常如果没被正确捕获会直接导致应用崩溃。我的做法是在 NAPI 边界做一层包装所有 C 异常都转换成错误码返回ArkTS 侧根据错误码决定是重试还是降级到云端。常见的错误码包括模型未加载、内存不足、推理超时、输入过长。每种错误对应的降级策略不同比如内存不足就释放缓存后重试推理超时就缩短生成长度。注意NAPI 回调里不要做耗时操作否则会阻塞 JS 线程。如果需要做复杂处理把数据丢到 ArkTS 的 TaskPool 里异步执行。6. 决策五性能与功耗的平衡术6.1 推理参数的调优大模型推理有一堆参数可以调每个都影响性能和效果。我重点调了三个max_tokens控制单次生成的最大长度。设太大浪费算力设太小回答不完整。我的经验值是 256覆盖 90% 的问答场景。超过这个长度的需求引导用户开启深度回答模式这时候才放开到 512。temperature控制生成的随机性。端侧模型本身能力有限temperature 设太高容易胡言乱语设太低又显得死板。实测 0.7 是个比较平衡的值。top_p核采样参数跟 temperature 配合使用。我一般设 0.9保留一定的多样性。这三个参数的组合我试了十几种最后固化成预设用户不需要自己调。如果要做成可配置的建议只暴露严谨/平衡/创意三档底层映射到不同的参数组合。6.2 功耗控制的几个手段端侧推理是耗电大户不加控制的话手机烫得能煎鸡蛋。我用了这几个手段动态线程数根据设备温度动态调整推理线程数。温度正常时用 4 线程温度超过阈值降到 2 线程虽然慢一点但能避免过热降频。推理间隔控制连续对话时两次推理之间强制间隔 200ms给 CPU 一个喘息的机会。屏幕状态感知检测到屏幕关闭时暂停所有推理任务等屏幕亮起再恢复。电量阈值电量低于 20% 时自动切换到云端推理如果有网络或者提示用户开启省电模式。6.3 实测性能数据在麒麟 9000S 的机器上我做了几组对比测试场景首token延迟生成速度功耗增量机身温度冷启动首次推理2.3s8 tokens/s高4.2°C热启动推理0.8s12 tokens/s中2.1°C连续对话10轮0.9s11 tokens/s中高3.8°C省电模式1.5s6 tokens/s低1.2°C冷启动那 2.3 秒主要是模型加载和内存分配的开销所以前面说的预热策略很重要。热启动后的数据就比较理想了日常使用基本感知不到明显延迟。7. 踩过的坑与排查实录7.1 模型加载失败的那些原因现象应用启动后调用loadModel返回失败日志显示invalid model file。排查过程先检查文件是否存在确认路径没问题。然后校验文件 SHA256发现跟预期值不一致。最后定位到是下载过程中断导致文件不完整但断点续传的逻辑有 bug把不完整的文件当成了完整文件。解决在下载完成后强制做一次完整性校验校验不通过就删除重新下载。另外下载过程中的临时文件用.tmp后缀下载完成后再重命名为正式文件避免不完整文件被误用。7.2 推理过程中的内存泄漏现象连续对话 20 轮后应用内存占用从 2.6GB 涨到 4.1GB最终被系统杀掉。排查过程用鸿蒙的 DevEco Profiler 抓内存快照发现每次推理后都有一块 50MB 左右的内存没有释放。定位到是 KV Cache 的清理逻辑有问题多轮对话时历史 KV 没有及时回收。解决在每轮推理结束后检查 KV Cache 的使用量超过阈值就清理最早的历史记录。另外C 侧的内存分配全部改用智能指针管理避免手动 free 遗漏。7.3 常见问题速查表问题现象可能原因排查方法解决方案应用启动崩溃模型文件损坏校验 SHA256重新下载推理结果乱码tokenizer 配置错误对比编码解码结果检查 tokenizer 文件生成速度突然变慢设备过热降频监控 CPU 频率降低线程数回调不触发NAPI 线程问题检查线程绑定确保回调在 JS 线程执行内存持续增长KV Cache 未释放内存快照对比定期清理历史首次推理特别慢模型未预热检查加载时机提前在后台加载7.4 几个容易被忽视的细节文件路径的坑鸿蒙的沙箱路径在不同版本上可能有差异不要硬编码路径用context.filesDir动态获取。权限问题访问网络下载模型需要ohos.permission.INTERNET权限别忘了在module.json5里声明。ABI 兼容编译 so 库时要注意目标 ABIarm64-v8a 是必须的armeabi-v7a 看情况x86_64 主要用于模拟器调试。日志级别Release 版本要把 C 侧的日志级别调高不然大量日志输出会影响性能。8. 后续可以继续优化的方向这套方案跑通之后我又陆续做了一些优化。比如把意图分类模型换成了更轻量的版本从 0.5B 压到 0.3B效果基本没降但内存省了 200MB。还试了投机采样用一个小模型做 draft大模型做 verify理论上能提升生成速度但实际测下来在端侧收益不明显因为小模型的推理开销也不低。另外一个值得尝试的方向是模型分片加载。把大模型按层切分只加载当前需要的层用完就释放。这样峰值内存能进一步降低代价是推理速度会慢一些。这个方案适合内存特别紧张的设备旗舰机没必要。如果你也在做类似的事情我的建议是先把最小可用版本跑通别一上来就追求极致性能。端侧推理的变量太多设备型号、系统版本、模型版本都会影响最终效果快速迭代比一次性做到完美更重要。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进