在鸿蒙上跑一个端侧大模型——不用连云端数据全在本地

发布时间:2026/5/22 1:24:17

在鸿蒙上跑一个端侧大模型——不用连云端数据全在本地 在鸿蒙上跑一个端侧大模型——不用连云端数据全在本地用大模型做智能问答大多数人第一反应就是调用云端 API——把用户的问题发到服务器上服务器上的模型处理后把答案返回来。这个方案能用但有两个问题一是你的用户数据要上云涉及到隐私安全二是你得自己搭服务或者付 API 费用运维成本不低。华为在 HarmonyOS 里搞了一个叫端侧问答模型的东西思路完全不一样——模型直接跑在你的设备上问答全程不需要跟云端交互。这篇文章就带你用这个能力做一个本地的 AI 问答助手。端侧问答模型是什么“端侧这个词你可能听说过意思是在终端设备上”。端侧问答模型就是说大语言模型不是部署在云服务器上而是部署在你的手机、平板或者电脑上直接在本地完成推理和生成。这样做有几个明显的好处数据不出端用户的问题和模型的回答都在设备本地处理不会传到任何云端服务器。对于金融、医疗、政务这类对数据安全要求高的场景这个特性非常关键。不用管云端运维不需要买 GPU 服务器、不用配置模型服务、不用担心高并发扩缩容省了一大笔钱和精力。响应快没有网络延迟的问题在硬件条件足够的情况下响应速度很稳定。离线可用在没网的环境下飞机上、地下室照样能用。目前端侧问答模型的接口属于Data Augmentation Kit数据增强服务起始版本是 HarmonyOS 6.0.0(20)。默认使用的模型是Qwen2.5-7B-Instruct也就是通义千问的 70 亿参数版本这个规模的模型在端侧场景中是推理性能和生成质量比较平衡的选择。不过有个限制得提前说清楚当前端侧问答模型只支持 PC 和 2in1 设备手机和平板暂时用不了。原因是 7B 参数的模型需要比较大的内存和算力手机硬件还扛不太住。还有一个前置条件这个接口需要申请白名单才能用。你需要去华为开发者联盟的在线提单页面提交申请问题分类选HarmonyOS NEXT 系统 Data Augmentation Kit。申请的时候需要提供应用名称、bundleName、AppID 等信息。目前优先处理企业方应用的申请。用到哪几个 API端侧问答模型的 API 设计非常简洁核心就两个接口接口作用init()初始化端侧问答模型拉起模型管理应用chat(info, config, callback)跟端侧模型对话实现问答功能对就两个。init()负责把模型准备好chat()负责问答交互。整个调用链路就是先 init再 chat完事了。模型资源来自华为的 Matrix 模型库首次调用init()的时候会弹出隐私声明用户同意之后自动下载默认模型。非首次使用的话可以手动去设置 系统 本地AI模型管理下载或更新模型。权限配置因为问答过程中端侧模型跟 LLM 需要通过 HTTP 请求交互所以你的应用需要有网络权限。打开module.json5在requestPermissions里加上网络权限{module:{requestPermissions:[{name:ohos.permission.INTERNET}]}}就这一条权限没了。完整代码接下来直接看完整的代码。这段代码来自华为官方文档实现了一个具备流式/非流式问答切换、聊天记录显示、自动滚动等功能的端侧问答助手页面。import { BusinessError } from kit.BasicServicesKit; import { localChatModel } from kit.DataAugmentationKit type MessageRole system | user | assistant; interface ChatMessage { role: MessageRole; content: string; } Entry Component struct Index { State title: string 端侧大模型问答助手; State isStreamMode: boolean true; State messages: ChatMessage[] []; State inputText: string ; State initFlag: boolean false; State isProcessing: boolean false; State assistantContent: string ; State chatCounter: number 0; // 页面加载时拉起模型管理应用 onPageShow() { console.info(modelChat onPageShow); this.initModel(); } private scroller: Scroller new Scroller(); private scrollToBottom() { setTimeout(() { this.scroller.scrollEdge(Edge.Bottom); }, 50); } private addMessage(role: MessageRole, content: string): void { const newMessage: ChatMessage { role: role, content: content, }; this.messages [...this.messages, newMessage]; } private async initModel(): Promisevoid { try { await localChatModel.init(); this.initFlag true; this.addMessage(system, 模型初始化完成); } catch (err) { const error err as BusinessError; this.initFlag false; this.addMessage(system, 模型初始化出错: ${error.message}); } } private async DoChat(questionId: number): Promisevoid { if (!this.inputText.trim() || this.isProcessing) { return; } const userQuestion this.inputText.trim(); if (!userQuestion) { return; } this.inputText ; this.addMessage(user, userQuestion); this.assistantContent 思考中...; this.isProcessing true; const questionInfo: localChatModel.QuestionInfo { questionId: questionId, content: userQuestion }; const localConfig: localChatModel.Config { isStream: this.isStreamMode }; const localChatCallback async (err: BusinessError, ans: localChatModel.Answer): Promisevoid { this.scrollToBottom(); if (err) { if (this.assistantContent 思考中...) { this.assistantContent ; this.isProcessing false; } // 模型运行相关错误码 console.error(modelChat Callback failed:, err.message); this.addMessage(system, localChatCallback: error code is ${err.code}, ${err.message}); this.scrollToBottom(); } if (ans.content ans.content.trim() ! ) { if (this.assistantContent 思考中...) { this.assistantContent ; } this.assistantContent ans.content; this.scrollToBottom(); } this.scrollToBottom(); if (ans.isFinished) { console.log(modelChat finished); this.addMessage(assistant, this.assistantContent); this.isProcessing false; } }; try { console.log(modelChat Starting chat...); localChatModel.chat(questionInfo, localConfig, localChatCallback); } catch (err) { // 入参相关错误码 const error err as BusinessError; console.error(modelChat Chat failed:, error.message); this.addMessage(system, chat: error code is ${error.code}, ${error.message}); this.isProcessing false; } } private clearChat(): void { this.messages []; } build() { Stack({ alignContent: Alignment.Top }) { Column() { Row() { Text(this.title) .fontSize(20) .fontWeight(FontWeight.Bold) .fontColor(#1a73e8) .margin({ left: 12 }) Circle() .width(10) .height(10) .margin({ left: 12 }) .fill(this.initFlag ? #0f0 : #f00) .opacity(0.8) Text(this.initFlag ? 已就绪 : 未就绪) .margin({ left: 6 }) .fontSize(12) .fontColor(#666) Blank() Row() { Button(this.isStreamMode ? 流式 : 非流式) .width(70) .height(25) .fontSize(12) .margin({ right: 20 }) .backgroundColor(Color.Gray) .fontColor(Color.White) .borderRadius(12.5) .onClick(() { this.isStreamMode !this.isStreamMode; this.addMessage(system, 已切换至 ${this.isStreamMode ? 流式问答 : 非流式问答} 模式); }) } .margin({ right: 12 }) } .width(100%) .height(50) .backgroundColor(Color.White) .borderRadius(16) .shadow({ radius: 4, color: #1a73e888, offsetX: 0, offsetY: 2 }) .margin({ bottom: 12 }) // 聊天区域 Scroll(this.scroller) { Column() { ForEach(this.messages, (msg: ChatMessage, index: number) { if (msg.role system) { Row() { Text(msg.content) .fontSize(14) .fontColor(#666) .textAlign(TextAlign.Center) .padding(8) } .width(100%) .justifyContent(FlexAlign.Center) .margin({ top: index 0 ? 0 : 12 }) } else if (msg.role user) { Row() { Blank() Text(msg.content) .fontSize(16) .fontColor(Color.White) .padding(10) .backgroundColor(#1a73e8) .borderRadius(12) } .width(100%) .margin({ top: 12 }) .justifyContent(FlexAlign.End) } else if (msg.role assistant) { Row() { Column() { Text(msg.content) .fontSize(16) .fontColor(#333) .lineHeight(20) .padding(10) .backgroundColor(Color.White) .borderRadius(12) } .borderRadius(12) .margin({ left: 8 }) Blank() } .width(100%) .margin({ top: 12 }) .justifyContent(FlexAlign.Start) } }, (msg: ChatMessage) msg.toString()) // 加载指示器 if (this.isProcessing) { Row() { Column() { Text(this.assistantContent) .fontSize(16) .fontColor(#333) .lineHeight(20) .padding(10) .backgroundColor(Color.White) .borderRadius(12) } .borderRadius(12) .margin({ left: 8 }) Blank() } .width(100%) .margin({ top: 12 }) } } .padding(12) .width(100%) } .width(100%) .layoutWeight(1) .margin({ bottom: 12 }) // 输入区域 Column() { Row() { TextInput({ text: this.inputText, placeholder: 请输入您的问题... }) .flexGrow(1) .height(42) .fontSize(16) .padding(8) .backgroundColor(Color.White) .borderRadius(21) .width(85%) .onChange((value: string) { this.inputText value; }) .onSubmit(() { if (!this.isProcessing this.inputText.trim() ! ) { const chatId this.chatCounter; this.DoChat(chatId); } }) Button(发送) .width(72) .height(42) .fontSize(16) .margin({ left: 8 }) .backgroundColor(#1a73e8) .fontColor(Color.White) .borderRadius(21) .onClick(() { if (!this.isProcessing this.inputText.trim() ! ) { const chatId this.chatCounter; this.DoChat(chatId); } }) .opacity(this.isProcessing || this.inputText.trim() ? 0.6 : 1) Button(清空) .width(72) .height(42) .fontSize(16) .margin({ left: 8 }) .fontColor(#fff) .backgroundColor(#ea4335) .borderRadius(18) .onClick(() { this.clearChat(); }) } .width(100%) .justifyContent(FlexAlign.SpaceBetween) .alignItems(VerticalAlign.Center) } .width(100%) .padding(8) .backgroundColor(Color.White) .borderRadius(16) .shadow({ radius: 4, color: #1a73e888, offsetX: 0, offsetY: 2 }) } .width(100%) .height(100%) .padding(12) .backgroundColor(#f0f5ff) } .width(100%) .height(100%) } }代码量看着不少但其实大部分都是 UI 布局的代码真正的核心逻辑只有两个方法initModel()和DoChat()。接下来我们一段一段拆开看。导入模块import { BusinessError } from kit.BasicServicesKit; import { localChatModel } from kit.DataAugmentationKit这两行 import 分别导入了BusinessError业务错误类型用于错误处理。当init()或chat()调用失败的时候抛出的错误对象就是这个类型里面有code和message属性。localChatModel端侧问答模型的入口对象所有操作都通过它来调用。它属于DataAugmentationKit数据增强服务。定义类型和状态变量type MessageRole system | user | assistant; interface ChatMessage { role: MessageRole; content: string; }这里定义了两个类型MessageRole消息角色的联合类型只有三种值——system系统消息、user用户消息、assistant助手回答。这是大模型对话的标准格式跟 OpenAI 的 API 设计是一样的。ChatMessage一条聊天消息的数据结构包含role角色和content内容。再看组件的状态变量State title: string 端侧大模型问答助手; State isStreamMode: boolean true; State messages: ChatMessage[] []; State inputText: string ; State initFlag: boolean false; State isProcessing: boolean false; State assistantContent: string ; State chatCounter: number 0;变量类型作用titlestring页面标题isStreamModeboolean是否为流式问答模式默认 truemessagesChatMessage[]所有聊天记录的数组inputTextstring输入框的当前文本initFlagboolean模型是否初始化成功isProcessingboolean是否正在生成回答防止重复提交assistantContentstring正在生成中的回答内容流式模式下逐步拼接chatCounternumber问答计数器每次发送问题时递增作为 questionId这些状态变量配合State装饰器任何一个变化都会触发 UI 刷新。比如isProcessing变成true的时候思考中…的提示就会显示出来messages数组更新的时候聊天区域的列表就会自动刷新。模型初始化initModel()private async initModel(): Promisevoid { try { await localChatModel.init(); this.initFlag true; this.addMessage(system, 模型初始化完成); } catch (err) { const error err as BusinessError; this.initFlag false; this.addMessage(system, 模型初始化出错: ${error.message}); } }这个方法做了几件事调用localChatModel.init()这是一个异步方法返回一个 Promise。它会拉起本地的 AI 模型管理应用。如果是第一次调用系统会弹出隐私声明界面用户同意后开始下载默认模型Qwen2.5-7B-Instruct。模型文件有好几个 GB下载需要一些时间。初始化成功后把initFlag设为true标题栏旁边的状态指示灯会变成绿色同时添加一条系统消息模型初始化完成。如果初始化失败比如没申请白名单、或者网络不通把initFlag设为false状态灯变红同时把错误信息显示在聊天区域。这个方法在onPageShow()生命周期里被调用也就是页面每次显示的时候都会触发。核心DoChat() 问答方法这是整个案例最核心的方法处理用户发送问题到模型生成回答的完整流程private async DoChat(questionId: number): Promisevoid { if (!this.inputText.trim() || this.isProcessing) { return; } const userQuestion this.inputText.trim(); if (!userQuestion) { return; } this.inputText ; this.addMessage(user, userQuestion); this.assistantContent 思考中...; this.isProcessing true;方法开头做了一些前置检查如果输入框是空的或者正在处理上一个问题isProcessing为true直接返回防止重复提交获取用户输入的问题文本清空输入框把用户的消息添加到聊天记录设置assistantContent为思考中…这个文本会在聊天区域显示为一个加载指示把isProcessing设为true接下来构建问答参数const questionInfo: localChatModel.QuestionInfo { questionId: questionId, content: userQuestion }; const localConfig: localChatModel.Config { isStream: this.isStreamMode };QuestionInfo问题信息包含questionId问题编号用于标识一次问答和content问题的具体内容。Config配置信息目前只设置了isStream决定是流式模式还是非流式模式。然后是回调函数const localChatCallback async (err: BusinessError, ans: localChatModel.Answer): Promisevoid { this.scrollToBottom(); if (err) { if (this.assistantContent 思考中...) { this.assistantContent ; this.isProcessing false; } console.error(modelChat Callback failed:, err.message); this.addMessage(system, localChatCallback: error code is ${err.code}, ${err.message}); this.scrollToBottom(); } if (ans.content ans.content.trim() ! ) { if (this.assistantContent 思考中...) { this.assistantContent ; } this.assistantContent ans.content; this.scrollToBottom(); } this.scrollToBottom(); if (ans.isFinished) { console.log(modelChat finished); this.addMessage(assistant, this.assistantContent); this.isProcessing false; } };这个回调函数是chat()接口的核心——它不是一次性返回完整答案的而是在生成过程中被多次调用错误处理如果err不为空说明出错了。清除思考中…的提示把错误信息添加到聊天记录。内容拼接如果ans.content不为空说明模型生成了一段新的文本。把这段文本拼接到assistantContent后面。在流式模式下这个回调会被调用很多次每次返回一小段文本就像打字机效果一样。完成判断当ans.isFinished为true的时候说明模型已经生成完了整个回答。这时候把assistantContent已经拼接好的完整回答作为一条assistant角色的消息添加到聊天记录然后把isProcessing设为false。最后调用chat()接口try { console.log(modelChat Starting chat...); localChatModel.chat(questionInfo, localConfig, localChatCallback); } catch (err) { const error err as BusinessError; console.error(modelChat Chat failed:, error.message); this.addMessage(system, chat: error code is ${error.code}, ${error.message}); this.isProcessing false; } }localChatModel.chat()的三个参数分别是问题信息、配置、回调函数。如果调用本身抛出异常比如入参有问题在外层的 catch 里处理。流式 vs 非流式代码里有一个isStreamMode状态变量可以通过标题栏的按钮来切换。两种模式的区别流式模式isStream: true模型的回答是分块返回的每次回调返回一小段文本UI 上表现为文字一个字一个字地蹦出来打字机效果。用户不需要等整个回答生成完就能开始阅读体验更好。默认就是这个模式。非流式模式isStream: false模型的回答是一次性返回的用户需要等到整个回答生成完毕才能看到。适合批量处理或者不需要实时反馈的场景。从代码的角度来说两种模式的处理逻辑是完全一样的——回调函数都会被多次调用ans.content每次返回一段内容ans.isFinished标记结束。区别只是底层模型输出的节奏不同。UI 结构整个页面的布局分三块标题栏、聊天区域、输入区域。标题栏显示页面标题、一个状态指示灯绿色表示模型就绪红色表示未就绪、一个流式/非流式的切换按钮。聊天区域一个Scroll容器里面放ForEach循环渲染消息列表。三种消息类型有不同的样式system居中显示的灰色文字用于系统提示user靠右对齐的蓝色气泡就是用户的问题assistant靠左对齐的白色气泡就是模型的回答底部还有一个加载指示器——当isProcessing为true的时候显示思考中…。输入区域一个圆角输入框 发送按钮 清空按钮。发送按钮在处理中或输入为空的时候会变半透明。支持按回车键直接发送。整体背景色是#f0f5ff浅蓝灰标题栏和输入区域有白色的圆角背景加上阴影看起来比较干净清爽。你可能遇到的问题1. 调用 init() 报错最可能的原因是没有申请白名单。前面说过这个接口需要去华为开发者联盟提单申请。如果你还没申请init()会直接报错。2. 模型下载很慢Qwen2.5-7B 的模型文件有好几个 GB下载时间取决于你的网络速度。首次使用的时候耐心等一下下载好了之后就不用再下了。3. 在手机上运行不了前面也说了当前只支持 PC 和 2in1 设备。手机和平板暂时用不了这是硬件算力的限制。4. 发送问题后没有回答检查一下initFlag是不是true模型是否初始化成功isProcessing是不是不小心卡在true了可以重启 App查看日志有没有错误输出err.code和err.message5. 回调里的 ans.content 有时候是空的这个是正常的。流式模式下有些回调可能只是心跳或者状态更新不包含实际内容。代码里已经做了ans.content.trim() ! 的判断空内容会被跳过。总结一下这个案例的核心就三步localChatModel.init()—— 初始化模型localChatModel.chat()—— 发送问题通过回调接收回答把回答显示到 UI 上虽然端侧问答模型目前还有设备限制只支持 PC和需要申请白名单的门槛但它代表了一个重要的方向——AI 推理能力正在从云端向终端迁移。以后随着端侧硬件的升级和模型的优化手机上跑大模型也会成为常态。提前了解这套 API对你做鸿蒙开发是有帮助的。

相关新闻