在这里插入图片描述

引言

先来看一段对话:

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

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框架调用方便,但仍是"被动式"
第三阶段7Agent智能体框架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记住上文“短期记忆”
ToolAgent可以调用的工具函数,如查询数据库、调用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_LITEPANGU_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:这是实际开发中必须考虑的问题。

内存占用

状态内存占用说明
模型未加载0MBAgent未初始化
模型加载中约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的魅力!


相关链接

更多推荐