在这里插入图片描述

引言

先来看一段对话:

用户:“傣族有哪些传统节日?”

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匹配 -> 端侧盘古模型 -> 流式返回结果

具体来说,我们需要做以下事情:

  1. 创建Agent实例:配置系统提示词,让Agent知道自己是"民族文化助手"
  2. 注册3个Skill:民族知识问答、节日查询、民族对比
  3. 替换消息发送逻辑:从原来的云端API调用,改为Agent.sendMessage()
  4. 处理流式输出:逐Token更新UI,实现打字机效果
  5. 管理上下文:利用框架内置的上下文管理,支持多轮追问

端侧模型能力评估

在动手改造之前,还需要了解端侧盘古模型的能力边界。知道它能做什么、不能做什么,才能设计出合理的交互体验。

擅长领域

领域 能力评级 说明
知识问答 优秀 中文知识、历史、文化、地理等领域表现优秀
文本摘要 良好 能准确概括文章要点
文本润色 良好 能优化表达,使文本更流畅
简单推理 一般 简单的逻辑推理可以,复杂推理能力有限
创意写作 一般 能写简单的文案,但创意性不如云端大模型
数学计算 较弱 简单计算可以,复杂数学问题容易出错
代码生成 较弱 不是端侧模型的设计目标

对「民族图鉴」的影响

  • 民族知识问答(历史、文化、节日、习俗等):完全没问题,端侧模型表现优秀
  • 民族服饰描述、节日场景描写: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"
      }
    ]
  }
}

关键点是 compileSdkVersioncompatibleSdkVersion 都要设置为 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行代码背后,框架自动完成了以下事情:

  1. 模型加载:从系统分区加载盘古轻量化模型文件(约2GB),初始化NPU推理引擎
  2. 推理配置:设置上下文窗口4096 token,温度0.7(平衡准确性和创意性)
  3. 流式管道:建立Token级别的输出管道,每个Token生成后立即回调
  4. 对话管理:初始化上下文管理器,自动维护对话历史

配置参数详解

参数 说明 建议值
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();
    }
}

和原版的区别

  1. 新增 isAgentReady 状态:Agent模型加载需要1-2秒,加载期间显示加载中状态
  2. 新增 initError 状态:加载失败时显示错误信息
  3. aboutToAppear 中调用 initAgent() 预加载模型
  4. 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();
        }
    );
}

关键改动说明

  1. 流式更新:不再是一次性获取完整回复,而是通过 onToken 回调逐Token追加内容。每次回调后执行 this.messages = [...this.messages] 触发UI刷新。
  2. 状态标记isThinking 标记"模型正在推理中"(发送消息后、开始输出前),isStreaming 标记"正在输出中"(有Token持续到来)。
  3. 错误处理:Agent调用失败时,直接显示错误信息在消息气泡中,而不是弹出Toast。

为什么用 this.messages = [...this.messages] 而不是直接修改数组元素?

这是ArkUI状态管理的关键——@State 装饰的数组,只有整体替换(改变引用)才能触发UI刷新。如果只是修改数组内某个元素的属性(aiMsg.content += token),ArkUI不会感知到变化,UI不会更新。所以每次追加Token后,都要做一次整体替换来触发重渲染。

4.3 消息气泡的流式效果

在消息气泡组件中,需要根据 isStreamingisThinking 状态显示不同的视觉效果:

/**
 * 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;
}

流式输出的视觉设计原则

  1. 思考中:Agent收到消息后,模型推理需要200-500ms。这段时间显示"思考中" + 三点跳动动画,让用户知道系统在响应。
  2. 输出中:内容逐字出现,末尾显示闪烁光标 |。这个光标很重要——它让用户感知到"还在输出中",不会以为回答已经结束了。
  3. 输出完成:光标消失,消息进入稳定状态。
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把傣族的人口套用到了苗族)

这种情况在端侧模型中比云端模型更常见,因为端侧模型参数量小,推理能力较弱。解决方案:

  1. 在Skill中明确返回数据:当用户问人口、日期等具体数字时,优先走Skill,从本地数据库获取精确数据
  2. 在System Prompt中强调:加入"回答每个问题时独立判断,不要将上一个回答的数据错误地迁移到新问题"
  3. 适当时机清空上下文:检测到话题切换时自动清空(这个需要自己实现判断逻辑)

上下文溢出

当对话轮次过多时,早期上下文被裁剪,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,匹配困难,维护麻烦

推荐策略

  1. 按"用户意图"划分,而不是按"数据维度"划分

    • 好:节日查询、习俗查询、对比分析(按用户想做什么)
    • 不好:傣族数据、藏族数据、苗族数据(按数据对象划分)
  2. Skill总数控制在10个以内:对于大多数应用,3-5个Skill完全够用

  3. 优先级设置:高频Skill设高优先级,低频Skill设低优先级

  4. 定期分析调用日志:看哪些Skill调用频率低,考虑合并或删除

Q7:端侧模型会消耗多少电量和内存?

A:这是实际开发中必须考虑的问题。

内存占用

状态 内存占用 说明
模型未加载 0MB Agent未初始化
模型加载中 约500MB(峰值) 模型文件加载到内存
模型就绪(空闲) 约200-300MB 模型驻留在内存,等待推理
推理中 约400-600MB 推理时需要额外的临时内存

对于8GB内存的手机,端侧模型占用约5%的内存,属于可接受范围。但对于4GB内存的设备,可能会影响系统流畅度。

电量消耗

操作 电量消耗 说明
模型加载 约1-2% 一次性加载,后续不再消耗
每次推理 约0.01-0.05% 非常省电,NPU功耗极低
持续对话1小时 约3-5% 主要消耗在屏幕,模型推理占比很小

优化建议

  1. 懒加载:应用启动时不要加载模型,用户进入AI对话页时再加载
  2. 及时释放:离开AI对话页时调用 destroy() 释放模型
  3. 低内存模式:监控系统内存压力,内存紧张时主动释放模型
  4. 低电量模式:检测到低电量时,提示用户切换到云端模式
/**
 * 监听系统内存压力,自动释放模型
 */
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:这通常是因为上下文窗口被裁剪了。检查以下几点:

  1. 确认 maxContextTokens 设置合理:太小的窗口(如1024)可能只能记住2-3轮对话。
  2. 检查单轮对话是否过长:如果用户每轮都输入几百字,上下文很快就满了。建议在UI上做输入长度限制(如500字)。
  3. 利用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的魅力!


相关链接

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐