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

用大模型做智能问答,大多数人第一反应就是调用云端 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(): Promise<void> {
    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): Promise<void> {
    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): Promise<void> => {
      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() 调用失败的时候,抛出的错误对象就是这个类型,里面有 codemessage 属性。
  • 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;
变量 类型 作用
title string 页面标题
isStreamMode boolean 是否为流式问答模式,默认 true
messages ChatMessage[] 所有聊天记录的数组
inputText string 输入框的当前文本
initFlag boolean 模型是否初始化成功
isProcessing boolean 是否正在生成回答(防止重复提交)
assistantContent string 正在生成中的回答内容(流式模式下逐步拼接)
chatCounter number 问答计数器,每次发送问题时递增,作为 questionId

这些状态变量配合 @State 装饰器,任何一个变化都会触发 UI 刷新。比如 isProcessing 变成 true 的时候,"思考中…"的提示就会显示出来;messages 数组更新的时候,聊天区域的列表就会自动刷新。


模型初始化:initModel()

private async initModel(): Promise<void> {
  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}`);
  }
}

这个方法做了几件事:

  1. 调用 localChatModel.init(),这是一个异步方法,返回一个 Promise。它会拉起本地的 AI 模型管理应用。
  2. 如果是第一次调用,系统会弹出隐私声明界面,用户同意后开始下载默认模型(Qwen2.5-7B-Instruct)。模型文件有好几个 GB,下载需要一些时间。
  3. 初始化成功后,把 initFlag 设为 true,标题栏旁边的状态指示灯会变成绿色,同时添加一条系统消息"模型初始化完成!"。
  4. 如果初始化失败(比如没申请白名单、或者网络不通),把 initFlag 设为 false,状态灯变红,同时把错误信息显示在聊天区域。

这个方法在 onPageShow() 生命周期里被调用,也就是页面每次显示的时候都会触发。


核心:DoChat() 问答方法

这是整个案例最核心的方法,处理用户发送问题到模型生成回答的完整流程:

private async DoChat(questionId: number): Promise<void> {
  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;

方法开头做了一些前置检查:

  • 如果输入框是空的,或者正在处理上一个问题(isProcessingtrue),直接返回,防止重复提交
  • 获取用户输入的问题文本,清空输入框
  • 把用户的消息添加到聊天记录
  • 设置 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): Promise<void> => {
    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.isFinishedtrue 的时候,说明模型已经生成完了整个回答。这时候把 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:靠左对齐的白色气泡,就是模型的回答

底部还有一个加载指示器——当 isProcessingtrue 的时候显示"思考中…"。

输入区域:一个圆角输入框 + 发送按钮 + 清空按钮。发送按钮在处理中或输入为空的时候会变半透明。支持按回车键直接发送。

整体背景色是 #f0f5ff(浅蓝灰),标题栏和输入区域有白色的圆角背景加上阴影,看起来比较干净清爽。


你可能遇到的问题

1. 调用 init() 报错

最可能的原因是没有申请白名单。前面说过,这个接口需要去华为开发者联盟提单申请。如果你还没申请,init() 会直接报错。

2. 模型下载很慢

Qwen2.5-7B 的模型文件有好几个 GB,下载时间取决于你的网络速度。首次使用的时候耐心等一下,下载好了之后就不用再下了。

3. 在手机上运行不了

前面也说了,当前只支持 PC 和 2in1 设备。手机和平板暂时用不了,这是硬件算力的限制。

4. 发送问题后没有回答

检查一下:

  • initFlag 是不是 true(模型是否初始化成功)
  • isProcessing 是不是不小心卡在 true 了(可以重启 App)
  • 查看日志有没有错误输出(err.codeerr.message

5. 回调里的 ans.content 有时候是空的

这个是正常的。流式模式下,有些回调可能只是心跳或者状态更新,不包含实际内容。代码里已经做了 ans.content.trim() !== '' 的判断,空内容会被跳过。


总结一下

这个案例的核心就三步:

  1. localChatModel.init() —— 初始化模型
  2. localChatModel.chat() —— 发送问题,通过回调接收回答
  3. 把回答显示到 UI 上

虽然端侧问答模型目前还有设备限制(只支持 PC)和需要申请白名单的门槛,但它代表了一个重要的方向——AI 推理能力正在从云端向终端迁移。以后随着端侧硬件的升级和模型的优化,手机上跑大模型也会成为常态。提前了解这套 API,对你做鸿蒙开发是有帮助的。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐