HarmonyOS应用《民族图鉴》开发第73篇:端侧大模型——Agent Framework Kit与本地智能问答

引言
先来看一段对话:
用户:“傣族有哪些传统节日?”
AI:“傣族有三大传统节日——泼水节(浴佛节)在公历4月中旬,关门节和开门节在傣历九月和十二月。其中泼水节最隆重,持续3到7天,人们互相泼水祈福,还有赛龙舟、放高升、点孔明灯等活动。你想了解哪个节日的细节?”
用户:“泼水节的来历是什么?”
AI:“关于泼水节的来历,傣族民间流传着一个美丽的传说。很久以前,有一个魔王…”
这段对话有三个关键特征:第一,回答专业准确,涉及民族知识的具体细节;第二,支持多轮追问,AI记住了上一轮聊的是"傣族";第三,回答逐字出现,就像真人在打字一样,体验流畅。
如果让你用传统方式实现这个功能,大概需要:接入云端大模型API、写一堆网络请求代码、处理SSE流式数据、管理对话上下文、还要处理各种异常和重试。光是基础框架就要几百行代码,更别说联网、隐私、延迟这些头疼的问题。
但在鸿蒙7(API 26 Beta)中,这一切被简化到了极致。借助 @kit.AgentFrameworkKit,你只需要大约40行代码,就能在应用中嵌入一个具备流式输出、多轮记忆、端侧推理能力的智能对话Agent。
你没看错,40行。而且不需要联网,所有推理都在端侧完成。
在「民族图鉴」项目中,我们早在第28篇就实现了AI对话页(AiChatPage),但那时的实现依赖云端服务,需要网络连接,响应也有延迟。本文就带你用Agent Framework Kit彻底改造它——把AI问答能力从云端搬到端侧,让用户在任何网络环境下都能获得流畅的智能问答体验。
本文将系统讲解Agent Framework Kit的核心概念、开发流程和实战技巧。从Agent的创建、Skill的注册,到流式输出的实现、多轮上下文的管理,再到与「民族图鉴」项目的深度集成。不管你之前有没有接触过端侧AI,读完本文都能上手开发自己的端侧Agent。
端侧AI在鸿蒙中的演进
在正式动手编码之前,值得花几分钟了解端侧AI在鸿蒙生态中的定位。这有助于你理解为什么Agent Framework Kit是"鸿蒙7最重要的AI能力之一"。
第一阶段:AI能力碎片化(鸿蒙3-4)
早期的鸿蒙AI能力分散在各个子系统模块中。语音识别在 @ohos.ai.speech,文字识别在 @ohos.ai.ocr,图像分类在 @ohos.ai.vision。每个模块有独立的API、独立的模型文件、独立的生命周期管理。开发者想用AI能力,需要分别学习每个模块的用法,模型管理也相当混乱——一个App可能同时加载了多个AI模型,占用大量内存。
第二阶段:统一AI框架(鸿蒙5-6)
鸿蒙5引入了统一的AI框架,把分散的AI能力整合到一个入口。开发者通过 @ohos.ai 统一调用各种AI能力,框架层负责模型加载和资源调度。但这个阶段的AI能力还是"被动式"的——你调用一个能力,它返回一个结果,像一个函数调用。不支持多轮对话、不支持上下文记忆、不支持流式输出。
第三阶段:Agent智能体(鸿蒙7)
这是质的飞跃。Agent Framework Kit不再只是"AI能力调用",而是"AI智能体构建"。它把大模型推理、对话管理、Skill调度、流式输出整合成一个完整的Agent框架。开发者不再需要关心"模型怎么加载"、“上下文怎么管理”、“流式输出怎么实现”——这些都被框架封装好了。
更重要的是,Agent Framework Kit依托的盘古端侧轻量化大模型,是华为在端侧AI领域的重大突破。这个模型经过专门的量化压缩和NPU加速优化,可以在手机芯片上流畅运行,同时保持较好的回答质量。对于垂直领域应用(如「民族图鉴」的民族文化问答),它的表现完全不输云端大模型。
三个阶段对比:
| 阶段 | 鸿蒙版本 | AI能力形态 | 开发者体验 |
|---|---|---|---|
| 第一阶段 | 3-4 | 分散的AI模块 | 学习成本高,模型管理混乱 |
| 第二阶段 | 5-6 | 统一AI框架 | 调用方便,但仍是"被动式" |
| 第三阶段 | 7 | Agent智能体框架 | 40行代码,端侧大模型驱动 |
这就是为什么我们选择在第73篇(鸿蒙7新特性系列)来介绍Agent Framework Kit——它代表了鸿蒙AI能力从"工具"到"助手"的质变。
学习目标
完成本文后,你将能够:
- 理解Agent Framework Kit的核心架构和设计理念
- 掌握如何使用
@kit.AgentFrameworkKit创建自定义Agent智能体 - 学会注册和实现自定义Skill,让Agent掌握特定领域知识
- 理解流式输出的原理,实现打字机效果的对话体验
- 掌握多轮对话上下文的管理策略
- 能够将现有的AI对话页改造为端侧Agent方案
- 了解端侧大模型(盘古轻量化模型)的能力边界和使用技巧
- 避开Agent开发中的常见坑:Skill冲突、上下文溢出、流式乱序等
需求分析
为什么需要端侧Agent?
在讨论技术细节之前,我们先回答一个根本问题:端侧Agent解决了什么痛点?
云端AI的三大痛点
在第28篇中,我们为「民族图鉴」实现了AI问答功能。当时的方案是:用户提问 -> 发送到云端服务 -> 云端大模型推理 -> 返回结果。这个方案能用,但存在三个明显问题:
| 痛点 | 表现 | 影响 |
|---|---|---|
| 网络依赖 | 断网/弱网环境下无法使用 | 用户在偏远地区(民族地区旅游时)无法使用AI问答 |
| 延迟高 | 网络往返 + 模型推理,通常2-5秒 | 用户体验不流畅,打字机效果不连贯 |
| 隐私风险 | 用户问题上传到云端 | 用户可能担心提问内容被收集 |
端侧Agent的解决方案
端侧Agent把大模型推理直接放在设备上运行,一举解决这三个问题:
- 离线可用:不依赖网络,随时随地提问
- 低延迟:推理在本地完成,通常200ms-500ms即可开始输出
- 隐私安全:所有数据不出设备,用户提问完全本地处理
端侧大模型的技术基础
鸿蒙7搭载了华为自研的盘古轻量化端侧大模型。这个模型经过专门优化,可以流畅运行在手机芯片(麒麟NPU)上,同时保持不错的回答质量:
| 维度 | 云端大模型 | 端侧盘古模型 |
|---|---|---|
| 参数量 | 千亿级别 | 数十亿级别(量化压缩) |
| 推理速度 | 取决于网络 | 端侧NPU加速,毫秒级 |
| 知识范围 | 广泛 | 中文优化,民族文化等领域表现优秀 |
| 隐私 | 数据上传云端 | 数据不出设备 |
| 成本 | 按Token计费 | 免费,无调用次数限制 |
对于「民族图鉴」这种垂直领域的应用,端侧模型完全够用——我们不需要通用聊天,只需要在民族文化这个特定领域提供准确、流畅的回答。
Agent Framework Kit的架构
Agent Framework Kit(@kit.AgentFrameworkKit)是鸿蒙7在API 26 Beta中新增的端侧AI开发框架。它的核心设计理念是:让开发者用最少的代码,把端侧大模型的能力嵌入到自己的应用中。
整个框架的架构可以分为三层:
Agent Framework Kit 架构
├── 应用层(你的代码)
│ ├── 创建Agent实例
│ ├── 注册自定义Skill
│ ├── 发送消息 & 接收流式回复
│ └── 管理对话界面
├── 框架层(系统提供)
│ ├── Agent管理:创建、销毁、生命周期
│ ├── Skill调度:意图匹配、Skill路由
│ ├── 对话管理:多轮上下文、记忆管理
│ ├── 流式输出:逐Token输出、中断控制
│ └── 模型推理:盘古端侧大模型调用
└── 系统层(鸿蒙系统)
├── NPU推理引擎(HiAI)
├── 模型文件管理
└── 系统资源调度
框架层做的事情非常多,但对开发者来说,只需要关心应用层——创建Agent、注册Skill、发消息收消息。剩下的模型加载、推理加速、对话管理、流式输出,全部由框架自动处理。
这也就是为什么"40行代码"能搞定——因为复杂的部分都被框架封装好了。
关键概念速览
在正式开始编码之前,先快速了解几个核心概念:
| 概念 | 说明 | 类比 |
|---|---|---|
| Agent | 智能体实例,负责管理对话、调度Skill | 一个"AI助手" |
| Skill | 智能体掌握的"技能",每种Skill对应一类问题 | 一个"专业知识模块" |
| System Prompt | 系统提示词,定义Agent的身份、能力范围、回答风格 | 一个"角色设定" |
| Streaming | 流式输出,逐Token返回结果,实现打字机效果 | “边说边想” |
| Context Window | 上下文窗口,保存最近几轮对话,让Agent记住上文 | “短期记忆” |
| Tool | Agent可以调用的工具函数,如查询数据库、调用API | “手和脚” |
「民族图鉴」的改造方案
我们的改造目标很明确:用Agent Framework Kit替换原有的云端AI服务,实现端侧智能问答。
改造前的架构:
用户提问 -> AiChatPage -> AIService(云端) -> 云端大模型 -> 返回结果
改造后的架构:
用户提问 -> AiChatPage -> Agent实例 -> Skill匹配 -> 端侧盘古模型 -> 流式返回结果
具体来说,我们需要做以下事情:
- 创建Agent实例:配置系统提示词,让Agent知道自己是"民族文化助手"
- 注册3个Skill:民族知识问答、节日查询、民族对比
- 替换消息发送逻辑:从原来的云端API调用,改为Agent.sendMessage()
- 处理流式输出:逐Token更新UI,实现打字机效果
- 管理上下文:利用框架内置的上下文管理,支持多轮追问
端侧模型能力评估
在动手改造之前,还需要了解端侧盘古模型的能力边界。知道它能做什么、不能做什么,才能设计出合理的交互体验。
擅长领域:
| 领域 | 能力评级 | 说明 |
|---|---|---|
| 知识问答 | 优秀 | 中文知识、历史、文化、地理等领域表现优秀 |
| 文本摘要 | 良好 | 能准确概括文章要点 |
| 文本润色 | 良好 | 能优化表达,使文本更流畅 |
| 简单推理 | 一般 | 简单的逻辑推理可以,复杂推理能力有限 |
| 创意写作 | 一般 | 能写简单的文案,但创意性不如云端大模型 |
| 数学计算 | 较弱 | 简单计算可以,复杂数学问题容易出错 |
| 代码生成 | 较弱 | 不是端侧模型的设计目标 |
对「民族图鉴」的影响:
- 民族知识问答(历史、文化、节日、习俗等):完全没问题,端侧模型表现优秀
- 民族服饰描述、节日场景描写:OK,文本润色能力不错
- 复杂的对比分析(如"比较苗族和彝族银饰工艺的异同"):需要通过Skill辅助,提供结构化数据让模型组织语言
- 实时数据查询(如"今天傣族地区天气怎么样"):端侧模型无法获取实时信息,需要走云端fallback
建议策略:简单知识问答走端侧Agent,复杂分析和需要实时数据的走云端大模型。两者互补,而不是二选一。
端侧推理背后的技术原理
如果你对"为什么模型能在手机上跑"感到好奇,这里简单解释一下。如果你只关心怎么用,可以跳过这部分。
盘古端侧轻量化模型之所以能在手机上运行,主要依赖三项技术:
1. 模型量化(Quantization)
云端大模型的参数通常用FP16(16位浮点数)存储,一个千亿参数的模型需要约200GB显存。端侧模型通过量化技术,把参数从FP16压缩到INT4(4位整数),体积缩小到原来的1/4。虽然精度有损失,但经过专门的量化训练后,损失可以控制在可接受范围内。
2. NPU加速
麒麟芯片内置了NPU(神经网络处理单元),专门为AI推理设计。相比CPU,NPU在矩阵运算上的效率高出数十倍。盘古模型在NPU上推理时,功耗只有CPU的1/10左右,不会导致手机发烫。
3. 稀疏化与剪枝
大模型中有大量"冗余"参数——它们对最终结果的影响微乎其微。通过稀疏化技术,这些参数被置零;通过剪枝技术,不重要的连接被直接移除。处理后的模型体积更小、推理更快,但核心能力被保留。
一句话总结:端侧大模型不是把云端模型"硬塞"进手机,而是经过专门的压缩、优化和芯片适配,使其能在移动芯片上高效运行。这是软硬件协同设计的结果。
核心实现
步骤1:环境准备与依赖导入
在开始编码之前,需要确保项目配置正确。
1.1 检查API版本
Agent Framework Kit要求API 26(Beta)及以上。在 build-profile.json5 中确认:
{
"app": {
"products": [
{
"name": "default",
"compileSdkVersion": "5.0.0(26)",
"compatibleSdkVersion": "5.0.0(26)",
"runtimeOS": "HarmonyOS"
}
]
}
}
关键点是 compileSdkVersion 和 compatibleSdkVersion 都要设置为 5.0.0(26) 或更高。
1.2 导入依赖
// 文件用途:Agent Service - 端侧智能问答核心服务
// 创建时间:2026-07-23
// 兼容环境:HarmonyOS 7 (API 26 Beta) / DevEco Studio 5.0+
// 版本:v1.0
import { agentFramework } from '@kit.AgentFrameworkKit';
import { util } from '@kit.ArkTS';
import { promptAction } from '@kit.ArkUI';
@kit.AgentFrameworkKit 提供了创建Agent、注册Skill、发送消息、接收流式回复等全部能力。注意,这个包在API 25及以下版本中不可用,如果项目需要兼容低版本,需要做条件编译。
1.3 定义消息模型
继续沿用第28篇的消息模型,但做一些适配端侧Agent的调整:
/**
* 聊天消息模型
*/
interface ChatMessage {
id: string; // 消息唯一ID
role: 'user' | 'assistant'; // 角色:用户 / AI助手
content: string; // 消息内容
timestamp: number; // 时间戳
isStreaming?: boolean; // 是否正在流式输出中(新增)
isThinking?: boolean; // 是否正在思考中(新增)
}
/**
* Agent回复片段——流式输出的基本单位
*/
interface AgentStreamChunk {
content: string; // 本次输出的文本片段
isFinished: boolean; // 是否已输出完毕
finishReason?: string; // 结束原因:stop / length / error
}
和第28篇相比,新增了两个重要字段:
isStreaming:标记这条消息是否正在流式输出中。用于UI层面显示打字光标动画。isThinking:标记Agent是否正在思考(Skill调度、模型推理)。在流式输出开始前,这个阶段可能需要几百毫秒,显示一个"思考中"的状态能提升体验。
AgentStreamChunk 是流式输出的基本单位——每次回调只带一小段文本,累积起来就是完整的回答。
步骤2:创建Agent——40行代码嵌入智能对话
这是本文最核心的部分。让我们一步步创建Agent,并看看框架帮我们做了哪些事情。
2.1 最简Agent创建(15行)
/**
* 创建民族文化问答Agent——最简版本
* 只需要定义系统提示词,框架自动处理模型加载、推理、流式输出
*/
async function createEthnicAgent(): Promise<agentFramework.Agent> {
const systemPrompt = `你是"民族图鉴"AI助手,专注于中国56个民族的文化知识问答。
你的知识领域包括:民族历史、文化习俗、传统节日、饮食服饰、建筑艺术、地理分布等。
回答要求:
1. 准确专业,引用具体数据(人口、日期、地区等)
2. 通俗易懂,用生动的语言描述,避免过于学术化
3. 友好热情,适当使用语气词,让用户感觉亲切
4. 如果用户追问,结合上下文给出更深入的回答
5. 如果不确定,诚实告知,不要编造信息`;
const agent = await agentFramework.createAgent({
systemPrompt: systemPrompt,
modelType: agentFramework.ModelType.PANGU_LITE, // 端侧盘古轻量化模型
enableStreaming: true, // 开启流式输出
maxContextTokens: 4096, // 上下文窗口大小
temperature: 0.7 // 生成温度(0=严谨,1=创意)
});
return agent;
}
这15行代码背后,框架自动完成了以下事情:
- 模型加载:从系统分区加载盘古轻量化模型文件(约2GB),初始化NPU推理引擎
- 推理配置:设置上下文窗口4096 token,温度0.7(平衡准确性和创意性)
- 流式管道:建立Token级别的输出管道,每个Token生成后立即回调
- 对话管理:初始化上下文管理器,自动维护对话历史
配置参数详解:
| 参数 | 说明 | 建议值 |
|---|---|---|
systemPrompt |
系统提示词,定义Agent的身份和行为 | 包含角色定位、知识范围、回答风格 |
modelType |
模型类型,目前仅支持PANGU_LITE | PANGU_LITE |
enableStreaming |
是否开启流式输出 | 对话场景建议开启 |
maxContextTokens |
上下文窗口大小(Token数) | 2048-4096,太大可能影响推理速度 |
temperature |
生成温度,控制回答的随机性 | 知识问答建议0.3-0.7,创意场景0.7-1.0 |
2.2 发送消息与接收流式回复(25行)
/**
* 发送消息并处理流式回复
* 整体流程:发送用户消息 -> 接收流式Token -> 逐个更新UI -> 完成
*/
async function sendMessageToAgent(
agent: agentFramework.Agent,
userInput: string,
onToken: (token: string) => void, // 每收到一个Token就回调
onComplete: (fullText: string) => void, // 全部完成后回调
onError: (error: Error) => void // 错误回调
): Promise<void> {
let fullResponse = '';
try {
// 发送消息并获取流式回复
const stream = await agent.sendMessage({
content: userInput,
streamCallback: (chunk: agentFramework.StreamChunk) => {
fullResponse += chunk.content;
onToken(chunk.content); // 逐Token回调给UI层
// 检查是否输出完毕
if (chunk.isFinished) {
onComplete(fullResponse);
}
}
});
// 等待流式输出完成
await stream.waitForCompletion();
} catch (error) {
console.error('[AgentService] 发送消息失败:', JSON.stringify(error));
onError(error as Error);
}
}
这段代码的核心是 streamCallback 回调——框架每生成一个Token,就会调用一次这个回调,传入文本片段。开发者在回调中更新UI,就能实现打字机效果。
流式输出的时序:
用户点击发送
│
▼ (约200-500ms,模型推理准备)
Agent开始输出
│
├─ Token 1: "傣" ──► onToken("傣") ──► UI更新
├─ Token 2: "族" ──► onToken("族") ──► UI更新
├─ Token 3: "有" ──► onToken("有") ──► UI更新
├─ Token 4: "三" ──► onToken("三") ──► UI更新
│ ...(更多Token)
└─ Token N: "。" ──► onToken("。") + isFinished=true ──► onComplete()
每个Token之间的间隔通常只有几十毫秒,用户感知到的是流畅的打字机效果。
2.3 整合到AgentService中
把上述代码整合成一个完整的服务类,方便在页面中调用:
/**
* 文件用途:AgentService - 端侧Agent核心服务,封装Agent创建、消息发送、流式处理
* 创建时间:2026-07-23
* 兼容环境:HarmonyOS 7 (API 26 Beta) / DevEco Studio 5.0+
* 版本:v1.0
* 风险提示:依赖@kit.AgentFrameworkKit,仅API 26+可用
*/
import { agentFramework } from '@kit.AgentFrameworkKit';
export class AgentService {
private static instance: AgentService;
private agent: agentFramework.Agent | null = null;
private isInitializing: boolean = false;
/**
* 单例模式获取实例
*/
static getInstance(): AgentService {
if (!AgentService.instance) {
AgentService.instance = new AgentService();
}
return AgentService.instance;
}
/**
* 初始化Agent(懒加载,首次使用时调用)
* 模型加载大约需要1-2秒,建议在应用启动时预加载
*/
async initialize(): Promise<void> {
if (this.agent !== null) {
return; // 已经初始化过了
}
if (this.isInitializing) {
// 正在初始化中,等待完成
await this.waitForInit();
return;
}
this.isInitializing = true;
try {
const systemPrompt = `你是"民族图鉴"AI助手,专注于中国56个民族的文化知识问答。
你的知识领域包括:民族历史起源、文化习俗、传统节日、特色饮食、民族服饰、建筑艺术、地理分布、语言文化等。
回答要求:
1. 准确专业,引用具体数据(人口、日期、地区等),时间、地点、数字要精确
2. 通俗易懂,用生动的语言描述,避免过于学术化的术语堆砌
3. 友好热情,态度亲切,适当使用语气词,让用户感觉像在和朋友聊天
4. 如果用户追问,结合对话上下文给出更深入的回答,体现你的理解能力
5. 如果不确定某个信息,诚实告知,不要编造不存在的事实
6. 回答长度适中,简单问题简短回答,复杂问题可以适当展开`;
this.agent = await agentFramework.createAgent({
systemPrompt: systemPrompt,
modelType: agentFramework.ModelType.PANGU_LITE,
enableStreaming: true,
maxContextTokens: 4096,
temperature: 0.5
});
console.info('[AgentService] Agent初始化成功');
} catch (error) {
console.error('[AgentService] Agent初始化失败:', JSON.stringify(error));
throw new Error('端侧AI模型加载失败,请确认设备支持API 26+');
} finally {
this.isInitializing = false;
}
}
/**
* 发送消息(流式)
* @param userInput - 用户输入文本
* @param onToken - 每收到一个Token的回调
* @param onComplete - 全部输出完成的回调
* @param onError - 错误回调
*/
async sendMessage(
userInput: string,
onToken: (token: string) => void,
onComplete: (fullText: string) => void,
onError: (error: Error) => void
): Promise<void> {
if (this.agent === null) {
onError(new Error('Agent未初始化,请先调用initialize()'));
return;
}
let fullResponse = '';
try {
await this.agent.sendMessage({
content: userInput,
streamCallback: (chunk: agentFramework.StreamChunk) => {
fullResponse += chunk.content;
onToken(chunk.content);
if (chunk.isFinished) {
onComplete(fullResponse);
}
}
});
} catch (error) {
console.error('[AgentService] 消息发送失败:', JSON.stringify(error));
onError(error as Error);
}
}
/**
* 清除对话上下文(开始新对话)
*/
async clearContext(): Promise<void> {
if (this.agent !== null) {
await this.agent.clearContext();
console.info('[AgentService] 对话上下文已清除');
}
}
/**
* 销毁Agent,释放资源
*/
async destroy(): Promise<void> {
if (this.agent !== null) {
await this.agent.destroy();
this.agent = null;
console.info('[AgentService] Agent已销毁,资源已释放');
}
}
/**
* 获取Agent状态
*/
isReady(): boolean {
return this.agent !== null;
}
/**
* 等待初始化完成(内部方法)
*/
private async waitForInit(): Promise<void> {
const maxWaitMs = 10000; // 最多等10秒
const startTime = Date.now();
while (this.isInitializing && (Date.now() - startTime) < maxWaitMs) {
await new Promise<void>(resolve => setTimeout(resolve, 100));
}
if (this.isInitializing) {
throw new Error('Agent初始化超时');
}
}
}
这就是完整的Agent服务层——核心逻辑不到100行。其中关键的Agent创建和消息发送,加起来也就40行左右。
步骤3:注册自定义Skill——让Agent掌握专业知识
虽然盘古端侧模型本身就有不错的知识储备,但它在某些垂直领域可能不够精确。比如,用户问"苗族的银饰工艺有什么讲究?",通用模型可能回答得比较笼统。
Skill机制就是为了解决这个问题——你可以把特定领域的知识"注入"到Agent中,让它在回答相关问题时更加精准。
3.1 Skill的设计思路
对「民族图鉴」来说,我们可以设计3个Skill,覆盖最核心的问答场景:
| Skill ID | 名称 | 触发场景 | 优先级 |
|---|---|---|---|
ethnic.festival |
节日查询 | 用户询问某个民族的节日 | 高 |
ethnic.custom |
习俗查询 | 用户询问风俗习惯、服饰、饮食 | 高 |
ethnic.comparison |
民族对比 | 用户比较两个或多个民族 | 中 |
3.2 定义Skill
每个Skill需要定义元信息:它叫什么、处理什么问题、有什么参数。这些信息帮助Agent框架判断"什么时候该调用这个Skill"。
/**
* 定义民族节日查询Skill
*/
const festivalSkill: agentFramework.SkillDefinition = {
id: 'ethnic.festival',
name: '民族节日查询',
description: `查询指定民族的传统节日信息,包括节日名称、日期、来历、庆祝方式等。
适用于用户询问"XX族有哪些节日"、"XX节是什么时候"、"XX节的来历"等问题。`,
category: '文化知识',
parameters: [
{
name: 'ethnicName',
type: 'string',
required: true,
description: '民族名称,如"傣族"、"藏族"、"蒙古族"'
},
{
name: 'festivalName',
type: 'string',
required: false,
description: '具体节日名称,如"泼水节"。不传则返回该民族所有节日'
}
]
};
/**
* 定义民族习俗查询Skill
*/
const customSkill: agentFramework.SkillDefinition = {
id: 'ethnic.custom',
name: '民族习俗查询',
description: `查询指定民族的风俗习惯、传统服饰、特色饮食、建筑风格、婚丧嫁娶等文化习俗。
适用于用户询问"XX族穿什么"、"XX族吃什么"、"XX族的婚礼什么样"等问题。`,
category: '文化知识',
parameters: [
{
name: 'ethnicName',
type: 'string',
required: true,
description: '民族名称'
},
{
name: 'customType',
type: 'string',
required: false,
description: '习俗类型:costume(服饰)、food(饮食)、wedding(婚俗)、architecture(建筑)、general(综合)'
}
]
};
/**
* 定义民族对比Skill
*/
const comparisonSkill: agentFramework.SkillDefinition = {
id: 'ethnic.comparison',
name: '民族对比',
description: `对比两个或多个民族在文化、习俗、节日、服饰等方面的异同。
适用于用户询问"XX族和XX族有什么区别"、"比较XX族和XX族的服饰"等问题。`,
category: '文化知识',
parameters: [
{
name: 'ethnicNames',
type: 'array',
required: true,
description: '要对比的民族名称列表,如["苗族", "彝族"]'
},
{
name: 'aspect',
type: 'string',
required: false,
description: '对比维度:festival(节日)、costume(服饰)、food(饮食)、general(综合)'
}
]
};
3.3 实现Skill处理器
定义好Skill的"接口"后,还需要实现具体的处理逻辑。Skill处理器是一个函数,接收参数,返回结果:
/**
* 节日查询Skill处理器
* 从本地数据库或Mock数据中查询节日信息
*/
async function handleFestivalQuery(params: Record<string, Object>): Promise<agentFramework.SkillResult> {
const ethnicName = params['ethnicName'] as string;
const festivalName = params['festivalName'] as string | undefined;
// 从本地数据源查询(实际项目中可以换成数据库查询)
const festivals = EthnicDataService.getFestivals(ethnicName);
if (festivals.length === 0) {
return {
success: false,
data: null,
message: `暂无${ethnicName}的节日信息,请确认民族名称是否正确`
};
}
if (festivalName) {
// 查询特定节日
const festival = festivals.find(f =>
f.name.includes(festivalName) || festivalName.includes(f.name)
);
if (festival) {
return {
success: true,
data: festival,
formattedText: formatFestivalDetail(ethnicName, festival)
};
}
return {
success: false,
data: null,
message: `未找到${ethnicName}的"${festivalName}"节日信息`
};
}
// 返回所有节日
return {
success: true,
data: festivals,
formattedText: formatFestivalList(ethnicName, festivals)
};
}
/**
* 习俗查询Skill处理器
*/
async function handleCustomQuery(params: Record<string, Object>): Promise<agentFramework.SkillResult> {
const ethnicName = params['ethnicName'] as string;
const customType = (params['customType'] as string) || 'general';
const customInfo = EthnicDataService.getCustomInfo(ethnicName, customType);
if (!customInfo) {
return {
success: false,
data: null,
message: `暂无${ethnicName}的${customType}相关信息`
};
}
return {
success: true,
data: customInfo,
formattedText: formatCustomInfo(ethnicName, customType, customInfo)
};
}
/**
* 民族对比Skill处理器
*/
async function handleComparison(params: Record<string, Object>): Promise<agentFramework.SkillResult> {
const ethnicNames = params['ethnicNames'] as string[];
const aspect = (params['aspect'] as string) || 'general';
if (ethnicNames.length < 2) {
return {
success: false,
data: null,
message: '至少需要两个民族才能进行对比'
};
}
const comparisonData = EthnicDataService.compareEthnics(ethnicNames, aspect);
return {
success: true,
data: comparisonData,
formattedText: formatComparison(ethnicNames, aspect, comparisonData)
};
}
3.4 注册Skill到Agent
Skill处理器写好后,注册到Agent中:
/**
* 注册所有自定义Skill到Agent
*/
async function registerAllSkills(agent: agentFramework.Agent): Promise<void> {
// 注册节日查询Skill
await agent.registerSkill({
definition: festivalSkill,
handler: handleFestivalQuery,
priority: 10 // 优先级越高,越优先匹配
});
// 注册习俗查询Skill
await agent.registerSkill({
definition: customSkill,
handler: handleCustomQuery,
priority: 10
});
// 注册民族对比Skill
await agent.registerSkill({
definition: comparisonSkill,
handler: handleComparison,
priority: 5 // 对比类优先级稍低,因为频率较低
});
console.info('[AgentService] 所有自定义Skill注册完成');
}
Skill的工作流程:
用户提问:"傣族有哪些节日?"
│
▼
Agent框架分析用户意图
│
├─ 匹配到 ethnic.festival Skill(置信度95%)
│
▼
调用 handleFestivalQuery({ ethnicName: "傣族" })
│
▼
从本地数据查询傣族节日信息
│
▼
返回 formattedText(格式化后的节日列表)
│
▼
Agent将Skill结果作为上下文,生成最终回答
│
▼
流式输出给用户
关键点:Skill返回的不是最终给用户看的文本,而是结构化数据。Agent框架会把Skill的结果作为"参考材料"喂给大模型,大模型再用自然语言重新组织,生成最终回答。这样既保证了信息准确性(来自你定义的数据),又保证了回答的自然流畅(由大模型润色)。
步骤4:改造AiChatPage——整合Agent到现有页面
有了AgentService,我们就可以改造第28篇的AiChatPage了。改造的核心是:替换消息发送逻辑,从云端API调用改为Agent流式调用。
4.1 页面状态与初始化
// 文件用途:AiChatPage - 端侧Agent智能问答页(改造版)
// 创建时间:2026-07-23
// 兼容环境:HarmonyOS 7 (API 26 Beta) / DevEco Studio 5.0+
// 版本:v2.0(从云端AI升级为端侧Agent)
import { agentFramework } from '@kit.AgentFrameworkKit';
import { AgentService } from '../services/AgentService';
@Entry
@Component
struct AiChatPage {
@State messages: ChatMessage[] = [];
@State inputText: string = '';
@State isLoading: boolean = false;
@State isAgentReady: boolean = false;
@State initError: string = '';
private agentService: AgentService = AgentService.getInstance();
private scroller: Scroller = new Scroller();
/**
* 页面初始化:加载Agent模型
*/
aboutToAppear(): void {
this.initAgent();
}
/**
* 初始化Agent(异步加载模型)
*/
private async initAgent(): Promise<void> {
try {
await this.agentService.initialize();
this.isAgentReady = true;
console.info('[AiChatPage] Agent就绪');
} catch (error) {
console.error('[AiChatPage] Agent初始化失败:', JSON.stringify(error));
this.initError = '端侧AI模型加载失败,请确认设备系统版本为HarmonyOS 7+';
this.isAgentReady = false;
}
}
/**
* 页面销毁时释放Agent资源
*/
aboutToDisappear(): void {
this.agentService.destroy();
}
}
和原版的区别:
- 新增
isAgentReady状态:Agent模型加载需要1-2秒,加载期间显示加载中状态 - 新增
initError状态:加载失败时显示错误信息 aboutToAppear中调用initAgent()预加载模型aboutToDisappear中调用destroy()释放资源
4.2 改造消息发送逻辑
这是最核心的改动。原来的 sendMessage 方法调用的是 AIService.sendMessage()(云端API),现在改为 AgentService.sendMessage()(端侧Agent):
/**
* 发送消息——端侧Agent版本
* 核心改动:流式输出,逐Token更新UI
*/
private async sendMessage(text: string): Promise<void> {
const trimmedText = text.trim();
if (trimmedText.length === 0 || this.isLoading || !this.isAgentReady) {
return;
}
// 第一步:添加用户消息
const userMsg: ChatMessage = {
id: `msg_user_${Date.now()}`,
role: 'user',
content: trimmedText,
timestamp: Date.now()
};
this.messages.push(userMsg);
this.inputText = '';
this.isLoading = true;
// 第二步:添加AI占位消息(标记为"思考中")
const aiMsg: ChatMessage = {
id: `msg_ai_${Date.now()}`,
role: 'assistant',
content: '',
timestamp: Date.now(),
isThinking: true,
isStreaming: false
};
this.messages.push(aiMsg);
this.scrollToBottom();
// 第三步:通过Agent发送消息,处理流式回复
this.agentService.sendMessage(
trimmedText,
// onToken回调:每收到一个Token,追加到消息内容
(token: string) => {
aiMsg.isThinking = false;
aiMsg.isStreaming = true;
aiMsg.content += token;
// 触发UI刷新
this.messages = [...this.messages];
this.scrollToBottom();
},
// onComplete回调:流式输出完成
(fullText: string) => {
aiMsg.isStreaming = false;
aiMsg.content = fullText;
this.isLoading = false;
this.messages = [...this.messages];
this.scrollToBottom();
},
// onError回调:处理错误
(error: Error) => {
aiMsg.isThinking = false;
aiMsg.isStreaming = false;
aiMsg.content = `抱歉,回答出错了:${error.message}`;
this.isLoading = false;
this.messages = [...this.messages];
this.scrollToBottom();
}
);
}
关键改动说明:
- 流式更新:不再是一次性获取完整回复,而是通过
onToken回调逐Token追加内容。每次回调后执行this.messages = [...this.messages]触发UI刷新。 - 状态标记:
isThinking标记"模型正在推理中"(发送消息后、开始输出前),isStreaming标记"正在输出中"(有Token持续到来)。 - 错误处理:Agent调用失败时,直接显示错误信息在消息气泡中,而不是弹出Toast。
为什么用 this.messages = [...this.messages] 而不是直接修改数组元素?
这是ArkUI状态管理的关键——@State 装饰的数组,只有整体替换(改变引用)才能触发UI刷新。如果只是修改数组内某个元素的属性(aiMsg.content += token),ArkUI不会感知到变化,UI不会更新。所以每次追加Token后,都要做一次整体替换来触发重渲染。
4.3 消息气泡的流式效果
在消息气泡组件中,需要根据 isStreaming 和 isThinking 状态显示不同的视觉效果:
/**
* AI消息气泡——支持流式输出和思考中动画
*/
@Builder
buildAssistantBubble(msg: ChatMessage): void {
Row() {
Column({ space: 8 }) {
if (msg.isThinking) {
// 思考中状态:显示三点动画
this.buildThinkingIndicator();
} else {
// 正常显示消息内容
Text(msg.content)
.fontSize(15)
.fontColor('#333333')
.lineHeight(22)
.textAlign(TextAlign.Start)
// 流式输出中,显示闪烁光标
if (msg.isStreaming) {
Text('|')
.fontSize(15)
.fontColor('#409EFF')
.opacity(this.getCursorOpacity())
}
}
}
.padding(12)
.backgroundColor('#F5F5F5')
.borderRadius({
topLeft: 4,
topRight: 12,
bottomLeft: 12,
bottomRight: 12
})
.constraintSize({ maxWidth: '75%' })
}
.width('100%')
.justifyContent(FlexAlign.Start)
.padding({ left: 16, right: 16, top: 8, bottom: 8 })
}
/**
* 思考中指示器——三点跳动动画
*/
@Builder
buildThinkingIndicator(): void {
Row({ space: 4 }) {
Text('思考中')
.fontSize(13)
.fontColor('#999999')
// 三个点依次跳动
ForEach([0, 1, 2], (index: number) => {
Text('.')
.fontSize(16)
.fontColor('#409EFF')
.fontWeight(FontWeight.Bold)
.animation({
duration: 400,
curve: Curve.EaseInOut,
delay: index * 200,
iterations: -1,
playMode: PlayMode.Alternate
})
.opacity(0.3 + (index * 0.3))
})
}
}
/**
* 获取光标闪烁透明度(用于流式输出光标动画)
*/
private getCursorOpacity(): number {
return Math.abs(Math.sin(Date.now() / 500)) * 0.8 + 0.2;
}
流式输出的视觉设计原则:
- 思考中:Agent收到消息后,模型推理需要200-500ms。这段时间显示"思考中" + 三点跳动动画,让用户知道系统在响应。
- 输出中:内容逐字出现,末尾显示闪烁光标
|。这个光标很重要——它让用户感知到"还在输出中",不会以为回答已经结束了。 - 输出完成:光标消失,消息进入稳定状态。
4.4 清空对话与多轮上下文
Agent框架自动管理上下文,但有时候用户想要"另起话题",就需要清空上下文:
/**
* 清空对话历史
*/
private async clearConversation(): Promise<void> {
try {
await this.agentService.clearContext();
this.messages = [];
console.info('[AiChatPage] 对话已清空');
} catch (error) {
console.error('[AiChatPage] 清空对话失败:', JSON.stringify(error));
}
}
clearContext() 会清空Agent的上下文记忆,之后的对话就是全新的,不会受到之前对话内容的影响。
什么时候应该清空上下文?
- 用户主动点击"清空对话"按钮
- 用户切换了话题(比如从"傣族节日"突然跳到"藏族服饰")
- 对话轮次过多(超过20轮),上下文可能已经混乱
步骤5:多轮上下文记忆——让Agent记住上文
多轮对话是Agent体验的核心——用户不需要每轮都重复上下文,Agent能自然地记住上文。
5.1 框架内置的上下文管理
Agent Framework Kit内置了上下文管理器,自动维护对话历史。开发者不需要手动拼接上下文,框架会在每次发送消息时,自动把最近的对话历史附加到请求中。
上下文窗口的工作原理:
对话历史(按时间顺序)
├── [轮次1] 用户:"傣族有哪些节日?"
├── [轮次1] Agent:"傣族有三大传统节日..."
├── [轮次2] 用户:"泼水节是什么时候?"
├── [轮次2] Agent:"泼水节在公历4月中旬..."
├── [轮次3] 用户:"那关门节呢?" ← 这里Agent能理解"关门节"是傣族的节日
└── [轮次3] Agent:"关门节在傣历九月..."
在第3轮,"那关门节呢?“中"那"字隐含了"傣族的”,Agent能从上下文中推导出来。这就是上下文记忆的价值。
Token计数与窗口管理:
上下文窗口大小由 maxContextTokens 参数控制(我们设置为4096)。当对话历史超过这个限制时,框架会自动裁剪最早的几轮对话,保留最新的内容。
总Token数: 4500(超过4096限制)
│
▼ 自动裁剪
保留轮次2-3(共3500 Token)+ 当前问题(200 Token)= 3700 Token
丢弃轮次1(800 Token)
5.2 上下文管理的注意事项
上下文污染
多轮对话可能带来"上下文污染"——早期对话中的错误信息会影响后续回答。比如:
用户:"傣族有多少人口?"
Agent:"傣族约有126万人口。"(正确)
用户:"那苗族呢?"
Agent:"苗族约有126万人口。"(错误!Agent把傣族的人口套用到了苗族)
这种情况在端侧模型中比云端模型更常见,因为端侧模型参数量小,推理能力较弱。解决方案:
- 在Skill中明确返回数据:当用户问人口、日期等具体数字时,优先走Skill,从本地数据库获取精确数据
- 在System Prompt中强调:加入"回答每个问题时独立判断,不要将上一个回答的数据错误地迁移到新问题"
- 适当时机清空上下文:检测到话题切换时自动清空(这个需要自己实现判断逻辑)
上下文溢出
当对话轮次过多时,早期上下文被裁剪,Agent会"忘记"之前聊过什么。这是正常的,但需要让用户有预期:
/**
* 检查上下文是否接近上限
*/
private checkContextHealth(): void {
const messageCount = this.messages.length;
if (messageCount > 30) {
promptAction.showToast({
message: '对话较长,建议清空历史以获得更好的回答质量',
duration: 2000
});
}
}
5.4 进阶答疑:Skill粒度、资源消耗、质量测试与设备适配
在深入下一步之前,先回答几个关于Agent开发中常见的高级问题。这些问题在实际开发中经常被问到,放在这里便于你在阅读实现步骤时一并参考。
Q6:Skill太多会不会导致匹配混乱?如何设计Skill的粒度?
A:这是一个非常好的问题。Skill的数量和粒度直接影响Agent的匹配准确率。
Skill过多的副作用:
- 匹配准确率下降:10个Skill中,Agent需要判断用户意图匹配哪一个。Skill越多,选错的可能性越大。
- 延迟增加:每次对话都要对所有Skill进行意图匹配,Skill越多,匹配耗时越长。
- 维护困难:每个Skill需要维护定义、处理器、测试用例,太多Skill会让代码变复杂。
Skill粒度的黄金法则:
| 粒度 | 示例 | 推荐度 |
|---|---|---|
| 太粗 | 一个Skill"民族知识查询"涵盖所有查询 | 不推荐:描述太宽泛,Agent不知道何时调用 |
| 适中 | 按功能分:节日查询、习俗查询、服饰查询 | 推荐:每个Skill职责清晰,容易匹配 |
| 太细 | 每个节日一个Skill:泼水节查询、藏历新年查询… | 不推荐:太多Skill,匹配困难,维护麻烦 |
推荐策略:
-
按"用户意图"划分,而不是按"数据维度"划分
- 好:节日查询、习俗查询、对比分析(按用户想做什么)
- 不好:傣族数据、藏族数据、苗族数据(按数据对象划分)
-
Skill总数控制在10个以内:对于大多数应用,3-5个Skill完全够用
-
优先级设置:高频Skill设高优先级,低频Skill设低优先级
-
定期分析调用日志:看哪些Skill调用频率低,考虑合并或删除
Q7:端侧模型会消耗多少电量和内存?
A:这是实际开发中必须考虑的问题。
内存占用:
| 状态 | 内存占用 | 说明 |
|---|---|---|
| 模型未加载 | 0MB | Agent未初始化 |
| 模型加载中 | 约500MB(峰值) | 模型文件加载到内存 |
| 模型就绪(空闲) | 约200-300MB | 模型驻留在内存,等待推理 |
| 推理中 | 约400-600MB | 推理时需要额外的临时内存 |
对于8GB内存的手机,端侧模型占用约5%的内存,属于可接受范围。但对于4GB内存的设备,可能会影响系统流畅度。
电量消耗:
| 操作 | 电量消耗 | 说明 |
|---|---|---|
| 模型加载 | 约1-2% | 一次性加载,后续不再消耗 |
| 每次推理 | 约0.01-0.05% | 非常省电,NPU功耗极低 |
| 持续对话1小时 | 约3-5% | 主要消耗在屏幕,模型推理占比很小 |
优化建议:
- 懒加载:应用启动时不要加载模型,用户进入AI对话页时再加载
- 及时释放:离开AI对话页时调用
destroy()释放模型 - 低内存模式:监控系统内存压力,内存紧张时主动释放模型
- 低电量模式:检测到低电量时,提示用户切换到云端模式
/**
* 监听系统内存压力,自动释放模型
*/
import { memory } from '@kit.PerformanceAnalysisKit';
function setupMemoryMonitor(agentService: AgentService): void {
memory.on('memoryLevel', (level: number) => {
if (level >= 3) { // 系统内存压力较大
console.warn('[AgentService] 系统内存紧张,释放Agent模型');
agentService.destroy();
promptAction.showToast({
message: '系统内存不足,AI对话功能暂时不可用',
duration: 2000
});
}
});
}
Q8:如何测试和调试Agent的回答质量?
A:端侧AI的测试与传统功能测试不同——你无法用"预期结果"来精确判断Agent的回答是否正确。以下是一套实用的测试方法:
方法1:构建测试用例集
为「民族图鉴」的AI问答场景,构建30-50个典型测试用例,覆盖:
测试用例分类
├── 节日查询类(10个)
│ ├── "傣族有哪些节日?"(基本查询)
│ ├── "泼水节是什么时候?"(具体节日)
│ ├── "藏历新年和春节有什么区别?"(对比类)
│ └── ...
├── 习俗文化类(10个)
│ ├── "苗族的银饰有什么寓意?"(服饰类)
│ ├── "蒙古族的那达慕大会做什么?"(活动类)
│ └── ...
├── 饮食类(5个)
├── 边缘情况类(10个)
│ ├── "你好"(非知识问答)
│ ├── "123456"(无意义输入)
│ ├── (空字符串)
│ └── ...
└── 多轮对话类(5个)
├── 追问类
├── 话题切换类
└── ...
方法2:建立评估维度
对每个测试用例的输出,从以下维度打分(1-5分):
| 维度 | 说明 | 评分标准 |
|---|---|---|
| 准确性 | 信息是否正确 | 5=完全正确,1=完全错误 |
| 相关性 | 是否回答了用户的问题 | 5=切题,1=答非所问 |
| 完整性 | 回答是否充分 | 5=信息充足,1=过于简略 |
| 流畅性 | 语言是否通顺自然 | 5=流畅,1=不通顺 |
| 安全性 | 是否有不当内容 | 5=安全,1=有风险内容 |
方法3:A/B对比测试
同时运行端侧Agent和云端大模型,对同一个问题对比两个回答。重点关注:
- 端侧模型的回答质量是否达到云端模型的80%以上?
- 哪些类型的问题端侧模型明显弱于云端?
- 是否存在端侧模型的"知识盲区"?
方法4:用户反馈闭环
在AI对话页加入"有帮助/无帮助"反馈按钮:
/**
* 用户反馈组件
*/
@Builder
buildFeedbackButtons(msgId: string): void {
Row({ space: 12 }) {
Button('有帮助')
.fontSize(12)
.type(ButtonType.Normal)
.backgroundColor('#F0F9EB')
.fontColor('#67C23A')
.onClick(() => {
this.submitFeedback(msgId, 'positive');
})
Button('无帮助')
.fontSize(12)
.type(ButtonType.Normal)
.backgroundColor('#FEF0F0')
.fontColor('#F56C6C')
.onClick(() => {
this.submitFeedback(msgId, 'negative');
})
}
.margin({ top: 8 })
}
收集用户反馈,定期分析哪些问题用户觉得"无帮助",针对性地优化Skill或System Prompt。
Q9:Agent Framework Kit 在不同设备上的表现差异大吗?
A:是的,差异主要体现在两个方面:
1. NPU性能差异
| 芯片型号 | NPU算力 | 推理速度 | 代表机型 |
|---|---|---|---|
| 麒麟9100 | 高 | 快(每个Token 20-40ms) | 旗舰机型 |
| 麒麟9000系列 | 中高 | 较快(每个Token 30-60ms) | 次旗舰 |
| 麒麟8000系列 | 中 | 一般(每个Token 50-100ms) | 中端机型 |
| 麒麟7000系列 | 中低 | 较慢(每个Token 80-150ms) | 入门机型 |
2. 内存差异
- 12GB以上设备:模型可以常驻内存,体验流畅
- 8GB设备:模型加载后可以正常使用,但多任务时可能被系统回收
- 6GB以下设备:模型可能频繁被回收,建议使用云端fallback
适配建议:
/**
* 根据设备能力调整Agent配置
*/
async function createAgentForDevice(): Promise<agentFramework.Agent> {
const deviceLevel = deviceInfo.deviceLevel; // 设备等级:high / medium / low
// 根据设备等级调整配置
const config = {
systemPrompt: getSystemPrompt(),
modelType: agentFramework.ModelType.PANGU_LITE,
enableStreaming: true,
maxContextTokens: deviceLevel === 'high' ? 4096 : 2048, // 低端设备减少上下文
temperature: 0.5
};
console.info(`[AgentService] 设备等级: ${deviceLevel}, 上下文窗口: ${config.maxContextTokens}`);
return await agentFramework.createAgent(config);
}
总之,在开发时要考虑"向下兼容"——中低端设备上减少上下文窗口、降低回答复杂度,确保可用性。
步骤5.5:上下文管理深入——实现智能话题切换检测
框架内置的上下文管理虽然好用,但有一个常见的体验问题:当用户突然切换话题时,Agent仍然带着旧上下文回答,导致回答偏离主题。
举个例子:
用户:"傣族有哪些节日?"
Agent:"傣族有泼水节、关门节、开门节..."
用户:"藏族的服饰有什么特点?" ← 话题切换了
Agent:"傣族的服饰..." ← 依然在说傣族!
这是因为上下文窗口里还保留着"傣族"的讨论,影响了对"藏族"的判断。解决方案是:在发送消息前,检测话题是否发生了切换,如果切换了,自动清空上下文。
/**
* 话题切换检测器
* 通过比较当前问题与上一轮对话的关键词来判断话题是否切换
*/
class TopicDetector {
private lastTopicKeywords: string[] = [];
/**
* 判断是否需要清空上下文
*/
detectTopicSwitch(currentInput: string, lastAssistantReply?: string): boolean {
const currentKeywords = this.extractKeywords(currentInput);
if (this.lastTopicKeywords.length === 0) {
this.lastTopicKeywords = currentKeywords;
return false;
}
// 检查是否有"追问"标记
const isFollowUp = /^(那|那这个|还有呢|详细说说|展开|继续|然后|还有|另外)/.test(currentInput);
if (isFollowUp) {
return false;
}
// 计算关键词重叠度
const overlap = currentKeywords.filter(k => this.lastTopicKeywords.includes(k));
const overlapRatio = overlap.length / Math.max(currentKeywords.length, 1);
if (overlapRatio < 0.3) {
console.info(`[TopicDetector] 话题切换: ${this.lastTopicKeywords.join(',')} -> ${currentKeywords.join(',')}`);
this.lastTopicKeywords = currentKeywords;
return true;
}
return false;
}
/**
* 提取关键词:民族名称和主题词
*/
private extractKeywords(text: string): string[] {
const ethnicNames = ['傣族', '藏族', '苗族', '彝族', '蒙古族', '维吾尔族', '壮族', '回族',
'满族', '朝鲜族', '白族', '哈尼族', '哈萨克族', '黎族', '侗族', '瑶族', '土家族'];
const topics = ['节日', '服饰', '饮食', '建筑', '婚俗', '音乐', '舞蹈', '历史', '人口', '分布', '语言', '文字'];
const keywords: string[] = [];
for (const name of ethnicNames) {
if (text.includes(name)) {
keywords.push(name);
}
}
for (const topic of topics) {
if (text.includes(topic)) {
keywords.push(topic);
}
}
return keywords;
}
reset(): void {
this.lastTopicKeywords = [];
}
}
在 AiChatPage 中集成话题检测:
private topicDetector: TopicDetector = new TopicDetector();
private async sendMessage(text: string): Promise<void> {
const trimmedText = text.trim();
if (trimmedText.length === 0 || this.isLoading || !this.isAgentReady) {
return;
}
// 检测话题是否切换
const lastReply = this.messages.length > 0 ?
this.messages[this.messages.length - 1].content : undefined;
const topicSwitched = this.topicDetector.detectTopicSwitch(trimmedText, lastReply);
if (topicSwitched) {
await this.agentService.clearContext();
console.info('[AiChatPage] 话题切换,已清空上下文');
}
// ... 后续发送消息逻辑不变
}
这个检测器的工作原理很简单:从用户输入中提取民族名称和主题词,与上一轮的关键词做对比。如果重叠度低于30%,就认为话题切换了。对于「民族图鉴」这种垂直领域,关键词匹配的效果非常好。
步骤5.6:流式输出的高级优化——防抖更新与渲染性能
逐Token更新UI虽然体验好,但如果每个Token都触发一次完整的UI重渲染,性能开销很大。特别是当Token频率很高时(比如每20ms一个Token),可能会导致UI卡顿。
优化策略:防抖批量更新
/**
* 流式输出管理器——防抖更新,避免过度渲染
*/
class StreamingOutputManager {
private buffer: string[] = [];
private updateTimer: number | null = null;
private readonly UPDATE_INTERVAL = 50; // 50ms更新一次UI
private onFlush: ((text: string) => void) | null = null;
constructor(onFlush: (text: string) => void) {
this.onFlush = onFlush;
}
/**
* 接收一个Token,放入缓冲区
*/
append(token: string): void {
this.buffer.push(token);
if (this.updateTimer === null) {
this.updateTimer = setTimeout(() => {
this.flush();
}, this.UPDATE_INTERVAL);
}
}
/**
* 刷新缓冲区,触发UI更新
*/
private flush(): void {
if (this.buffer.length > 0 && this.onFlush) {
const text = this.buffer.join('');
this.onFlush(text);
this.buffer = [];
}
this.updateTimer = null;
}
/**
* 强制刷新(流式结束时调用)
*/
forceFlush(): void {
if (this.updateTimer !== null) {
clearTimeout(this.updateTimer);
this.updateTimer = null;
}
this.flush();
}
destroy(): void {
if (this.updateTimer !== null) {
clearTimeout(this.updateTimer);
}
this.buffer = [];
}
}
使用方式:
// 在sendMessage中使用
const outputManager = new StreamingOutputManager((text: string) => {
aiMsg.content += text;
this.messages = [...this.messages];
this.scrollToBottom();
});
this.agentService.sendMessage(
trimmedText,
(token: string) => {
outputManager.append(token); // 不直接更新UI,放入缓冲区
},
(fullText: string) => {
outputManager.forceFlush(); // 强制刷新剩余内容
aiMsg.isStreaming = false;
this.isLoading = false;
this.messages = [...this.messages];
},
(error: Error) => {
outputManager.destroy();
// 错误处理...
}
);
这样做的好处是:原来每20ms更新一次UI,现在每50ms更新一次,UI渲染次数减少了60%,但用户感知的流畅度基本不变(50ms的延迟人眼几乎察觉不到)。
智能滚动优化
流式输出时,消息内容不断增长,频繁滚动到底部也可能导致性能问题。更好的做法是"智能滚动"——只在用户接近底部时才自动滚动:
/**
* 智能滚动:只在用户接近底部时才自动滚动
* 如果用户正在查看历史消息,不强制滚动
*/
private smartScrollToBottom(): void {
const currentOffset = this.scroller.currentOffset();
const isAtBottom = currentOffset !== null && currentOffset.yOffset >= -50;
if (isAtBottom) {
this.scroller.scrollEdge(Edge.Bottom);
}
}
这避免了"用户正在看之前的消息,突然被新消息滚动打断"的糟糕体验。
步骤6:完整集成——AgentService + AiChatPage 改造方案
为了让你对整个改造有完整认识,这里给出改造后的完整文件结构:
改造后的文件结构
├── src/main/ets/
│ ├── services/
│ │ ├── AgentService.ets # 新增:Agent核心服务
│ │ ├── EthnicDataService.ets # 新增:民族数据服务(Skill数据源)
│ │ └── SkillRegistry.ets # 新增:Skill定义与注册
│ └── pages/
│ └── AiChatPage.ets # 修改:改造为端侧Agent版本
改造对照表:
| 改动项 | 原方案(第28篇) | 新方案(本文) |
|---|---|---|
| AI服务 | AIService(云端API调用) | AgentService(端侧Agent) |
| 消息发送 | await aiService.sendMessage(text) |
agentService.sendMessage(text, onToken, onComplete, onError) |
| 回复方式 | 一次性返回完整文本 | 流式输出,逐Token更新 |
| 网络依赖 | 必须有网络 | 完全离线 |
| 模型部署 | 云端 | 端侧(盘古轻量化模型) |
| 上下文管理 | 手动拼接历史消息 | 框架自动管理 |
| 领域知识 | 依赖模型训练数据 | 自定义Skill注入 |
| 代码量 | 约200行 | 约150行(更简洁) |
常见问题与解决方案
更多高级问题(Skill粒度设计、资源消耗、质量测试、设备适配)请参考核心实现-步骤5.4 进阶答疑。
Q1:Agent初始化失败,提示"模型加载失败"
A:这是最常见的初始化问题。可能的原因和解决方案:
原因1:设备系统版本不满足要求
Agent Framework Kit要求HarmonyOS 7(API 26 Beta)及以上。在低版本设备上运行会直接报错。
解决方案:在代码中做版本检查:
import { deviceInfo } from '@kit.BasicServicesKit';
async function checkAgentSupport(): Promise<boolean> {
const osVersion = deviceInfo.osFullName;
const apiVersion = deviceInfo.sdkApiVersion;
console.info(`[AgentCheck] 系统版本: ${osVersion}, API: ${apiVersion}`);
if (apiVersion < 26) {
promptAction.showToast({
message: '端侧AI需要HarmonyOS 7及以上版本',
duration: 3000
});
return false;
}
return true;
}
原因2:设备存储空间不足
端侧模型文件约2GB,需要足够的存储空间。
解决方案:初始化前检查可用空间,不足时提示用户清理。
原因3:模型文件损坏
极少见的情况,但确实可能发生。如果模型文件损坏,初始化会失败。
解决方案:引导用户到"设置 -> 系统 -> 重置"中重置AI模型。
Q2:流式输出到一半卡住了,文字不继续出现
A:流式输出"卡住"通常有两种原因:
原因1:NPU资源被抢占
如果其他应用也在使用NPU(比如系统相机、其他AI应用),Agent的推理可能被挂起。
解决方案:在UI上显示"生成中…"提示,等待资源释放。通常几秒内会自动恢复。
原因2:生成内容过长,Token限制了
如果Agent正在生成一个很长的回答,超过了 maxContextTokens 设置的Token预算,输出会被截断。
解决方案:
- 适当调大
maxContextTokens(建议4096-8192) - 在System Prompt中加入"回答尽量简洁,控制在200字以内"的约束
- 监听
finishReason === 'length'来识别截断情况,并在UI上提示用户
streamCallback: (chunk: agentFramework.StreamChunk) => {
fullResponse += chunk.content;
onToken(chunk.content);
if (chunk.isFinished) {
if (chunk.finishReason === 'length') {
// 回答被截断,追加提示
const truncatedNote = '\n\n(回答较长,已截断。可以追问更具体的问题获取详细信息)';
fullResponse += truncatedNote;
onToken(truncatedNote);
}
onComplete(fullResponse);
}
}
Q3:Agent回答的质量不如预期,有时答非所问
A:端侧模型(盘古轻量化)参数量有限,回答质量确实不如云端大模型。但这可以通过以下方法改善:
方法1:优化System Prompt
System Prompt是影响回答质量最关键的因素。写得越具体,回答越准确。
不好的Prompt:
你是一个AI助手,回答用户的问题。
好的Prompt(我们上面用的):
你是"民族图鉴"AI助手,专注于中国56个民族的文化知识问答。
你的知识领域包括:民族历史起源、文化习俗、传统节日...
回答要求:
1. 准确专业,引用具体数据...
2. 通俗易懂,用生动的语言描述...
...
方法2:用Skill补充知识盲区
端侧模型的弱项是"精确事实"——比如某个民族的具体人口数字、某个节日的具体日期。这些可以通过Skill从本地数据库查询,然后注入到对话上下文中。
方法3:降低temperature
temperature 参数控制回答的随机性。值越低,回答越严谨(但可能乏味);值越高,回答越有创意(但可能不准确)。对于知识问答场景,建议0.3-0.5。
Q4:多轮对话中,Agent"记不住"之前的内容
A:这通常是因为上下文窗口被裁剪了。检查以下几点:
- 确认
maxContextTokens设置合理:太小的窗口(如1024)可能只能记住2-3轮对话。 - 检查单轮对话是否过长:如果用户每轮都输入几百字,上下文很快就满了。建议在UI上做输入长度限制(如500字)。
- 利用Skill做"外部记忆":对于需要长期记住的信息(如用户偏好),可以存储到本地数据库,每次对话时通过Skill读取。
// 示例:用户偏好记忆Skill
const userPreferenceSkill: agentFramework.SkillDefinition = {
id: 'user.preference',
name: '用户偏好',
description: '存储和读取用户的偏好设置,如感兴趣的民族、关注的文化领域等',
category: '用户数据',
parameters: [
{
name: 'action',
type: 'string',
required: true,
description: 'save(保存偏好)或 load(读取偏好)'
}
]
};
Q5:Agent Framework Kit和云端AI可以共存吗?
A:可以,而且推荐这样设计。端侧和云端各有优势,混合使用效果最好:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 简单知识问答 | 端侧Agent | 快速、离线、免费 |
| 复杂推理/创作 | 云端大模型 | 能力强、回答质量高 |
| 需要联网信息 | 云端大模型 | 端侧模型无法获取实时信息 |
| 隐私敏感问题 | 端侧Agent | 数据不出设备 |
实现方案:在AgentService中封装一个 useCloudFallback 开关,当端侧模型回答质量不够时,fallback到云端:
async sendMessage(
userInput: string,
onToken: (token: string) => void,
onComplete: (fullText: string) => void,
onError: (error: Error) => void,
useCloud: boolean = false
): Promise<void> {
if (useCloud && this.isNetworkAvailable()) {
// 走云端
await this.sendToCloud(userInput, onToken, onComplete, onError);
} else {
// 走端侧
await this.sendToAgent(userInput, onToken, onComplete, onError);
}
}
本章小结
核心知识点
本文系统讲解了如何使用鸿蒙7的Agent Framework Kit(@kit.AgentFrameworkKit),为「民族图鉴」App实现端侧智能问答:
1. Agent Framework Kit的核心理念
- 框架封装了模型加载、推理加速、对话管理、流式输出等复杂能力
- 开发者只需约40行代码即可嵌入智能对话
- 依托端侧盘古轻量化大模型,离线可用,隐私安全
2. Agent创建与配置
agentFramework.createAgent()创建Agent实例- 通过
systemPrompt定义Agent的身份和行为 temperature控制回答风格,maxContextTokens控制上下文窗口
3. Skill机制
- Skill是Agent的"专业知识模块",弥补端侧模型在特定领域的知识不足
- 每个Skill包含定义(元信息)和处理器(执行逻辑)
- Skill返回结构化数据,由大模型润色后输出
4. 流式输出
- 通过
streamCallback接收逐Token的文本片段 - 在UI层实现打字机效果,提升用户体验
- 流式输出中需要手动触发ArkUI状态刷新(
this.messages = [...this.messages])
5. 多轮上下文记忆
- 框架自动管理对话历史,支持多轮追问
- 上下文窗口通过
maxContextTokens控制 - 注意上下文污染和溢出问题
6. 与「民族图鉴」的集成
- 改造了第28篇的AiChatPage,从云端AI升级为端侧Agent
- 新增AgentService、EthnicDataService、SkillRegistry三个服务
- 注册了3个自定义Skill:节日查询、习俗查询、民族对比
最佳实践总结
模型加载策略
应用启动时预加载Agent模型(1-2秒)
首次使用时Agent已经就绪,用户无感知
离开页面时调用destroy()释放NPU资源
不要频繁创建/销毁Agent,保持单例
Skill设计原则
每个Skill职责单一,不要一个Skill做太多事
Skill描述要详细——Agent靠描述来匹配意图
Skill返回结构化数据,不要返回大段文本
优先使用Skill处理"精确事实"类问题
流式输出体验优化
思考阶段(200-500ms):显示"思考中"动画
输出阶段:逐Token更新 + 闪烁光标
完成阶段:光标消失,消息稳定
错误阶段:显示友好错误信息,提供重试入口
上下文管理
单次对话不要超过20轮,避免上下文混乱
话题切换时主动清空上下文
对"精确事实"类问题,走Skill而不是依赖上下文
监控上下文Token用量,接近上限时提示用户
下一步预告
在下一篇文章(第74篇)中,我们将:
- 全面了解鸿蒙视觉AI能力:端侧图像识别、物体检测、场景理解
- 理解视觉AI在端侧的优势:隐私保护、低延迟、离线可用
- 掌握视觉AI的核心API:图像分类、OCR文字识别、人脸检测
- 探索「民族图鉴」的视觉AI应用场景:民族服饰拍照识别、建筑风格识别
- 实战:接入视觉AI组件,实现民族服饰自动识别
- 解决识别率低、模型太大、推理太慢等常见问题
视觉AI是AI能力中最直观、最有趣的部分。想象一下,用户在旅游时拍一张民族服饰的照片,「民族图鉴」就能自动识别这是哪个民族的传统服装,并展示相关的文化介绍——这就是端侧AI的魅力!
相关链接
- Agent Framework Kit 官方文档: https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/agent-framework-kit
- HarmonyOS 7 API 26 新增特性: https://developer.huawei.com/consumer/cn/harmonyos/
- 盘古端侧大模型技术白皮书: 华为开发者联盟
- 「民族图鉴」项目第28篇:AI对话页: article_28_ai_chat_page.md
更多推荐




所有评论(0)