
上篇把云端大模型接进了 HarmonyOS 7(API 26),底座能稳定返回一段文本。但真实业务里的 Agent 是多轮、有记忆、有界面的:用户问一句,Agent 结合上下文答一句;历史越长,上下文管理越要精细。不少团队的第一步,是把消息数组、系统提示词、网络调用全混进 Index.ets------改一处崩三处,第二个人接手根本不敢动。
本文继续用可开箱即跑的 DevEco 工程,把这一层收敛成一套 Agent 工程脚手架 :ConversationStore 管对话记忆与上下文裁剪,Agent 管编排,ChatUIMessage 管界面状态映射,最后用一套聊天页骨架把三者串起来。底层复用上篇的 LlmClient(华为云盘古 / OpenAI 兼容),本篇代码聚焦"Agent 该怎么组织",纯逻辑全部配了可脱离设备运行的 Hypium 单测。
一、整体架构:三层拆分,各管各的
脚手架的价值在于拆:把易变的界面、稳定的编排、可替换的底座分成三层,每层只依赖下一层的抽象。
- UI 层 :聊天页骨架 +
ChatUIMessage视图模型,只管把状态画出来。 - Agent 编排层 :
Agent持有系统提示词和ConversationStore,一次send()完成"落用户消息 → 调底座 → 落助手回复"。 - 云端底座层 :复用上篇
LlmClient抽象,超时、重试、解析已收口,本篇零改动直接复用。

这样拆完,换界面不影响编排,换模型厂商不影响记忆管理------三层各自独立演进,这就是脚手架该有的样子。
二、对话记忆容器:系统消息常驻,历史自动裁剪
ConversationStore 是脚手架里最容易被写错的部分。两个关键点:系统消息必须永远留在发给模型的消息序列里 (丢了它,Agent 的角色设定就失效);多轮历史不能无限堆(上下文窗口撑爆,费用也跟着涨)。
ts
// feature/agent/ConversationStore.ets(节选)
export class ConversationStore {
private messages: ChatMessage[] = [];
private systemPrompt: string;
// 最多保留的对话消息条数(不含系统消息),超出从头部丢弃最旧的
private maxMessages: number;
constructor(systemPrompt: string = '', maxMessages: number = 40) {
this.systemPrompt = systemPrompt;
this.maxMessages = maxMessages;
if (systemPrompt.length > 0) {
this.messages.push({ role: Role.SYSTEM, content: systemPrompt });
}
}
addUser(text: string): void {
this.messages.push({ role: Role.USER, content: text });
this.trim();
}
addAssistant(text: string): void {
this.messages.push({ role: Role.ASSISTANT, content: text });
this.trim();
}
// 系统消息常驻;对话消息超出上限时,丢弃最旧的部分
private trim(): void {
const conv = this.messages.filter(m => m.role !== Role.SYSTEM);
if (conv.length <= this.maxMessages) return;
const keepConv = conv.slice(conv.length - this.maxMessages);
this.messages = [
...(this.systemPrompt.length > 0 ? [{ role: Role.SYSTEM, content: this.systemPrompt }] : []),
...keepConv
];
}
clear(): void {
this.messages = this.systemPrompt.length > 0
? [{ role: Role.SYSTEM, content: this.systemPrompt }] : [];
}
}
getMessages() 返回完整序列(系统 + 对话)直接喂给底座;getConversation() 只返回对话消息,给 UI 渲染用。裁剪只动对话消息,系统消息雷打不动------这个细节用单测锁死。
三、Agent 编排:一次 send 走通完整往返
有了记忆容器,Agent 本身出乎意料地薄------它只做一件事:把"组织请求 → 调底座 → 回写记忆"的固定顺序封装成 send()。
ts
// feature/agent/Agent.ets
export class Agent {
readonly store: ConversationStore;
private client: LlmClient;
private model: string;
private temperature: number;
constructor(client: LlmClient, systemPrompt: string,
model: string, temperature: number = 0.7) {
this.client = client;
this.store = new ConversationStore(systemPrompt);
this.model = model;
this.temperature = temperature;
}
async send(userText: string): Promise<string> {
this.store.addUser(userText);
const req: ChatRequest = {
model: this.model,
messages: this.store.getMessages(),
temperature: this.temperature
};
const resp: ChatResponse = await this.client.chat(req);
this.store.addAssistant(resp.text);
return resp.text;
}
}
注意 send() 里发出去的是 store.getMessages()------即带系统提示词的完整历史,而不是只有当前这句。很多"Agent 记不住上文"的 bug,根子就在这里只发了单句。
四、UI 视图模型:把领域消息翻译成界面状态
领域层的 ChatMessage 只有角色和内容,但界面关心更多:这条是谁说的、发出去没有、是成功还是失败、几点发的。ChatUIMessage 负责这层翻译,避免在 ArkUI 组件里散落一堆判断。
ts
// feature/agent/ChatUIMessage.ets(节选)
export enum MsgStatus { PENDING = 'pending', DONE = 'done', ERROR = 'error' }
export class ChatUIMessage {
readonly id: string;
readonly role: BubbleRole;
text: string;
status: MsgStatus;
readonly createdAt: number;
static fromDomain(m: ChatMessage): ChatUIMessage {
const role = m.role === Role.USER ? BubbleRole.USER
: m.role === Role.ASSISTANT ? BubbleRole.ASSISTANT : BubbleRole.SYSTEM;
return new ChatUIMessage(role, m.content, MsgStatus.DONE, 0, '');
}
isUser(): boolean { return this.role === BubbleRole.USER; }
isPending(): boolean { return this.status === MsgStatus.PENDING; }
isError(): boolean { return this.status === MsgStatus.ERROR; }
formattedTime(): string { // HH:mm,UI 直接展示
const d = new Date(this.createdAt);
return d.getHours().toString().padStart(2, '0') + ':' +
d.getMinutes().toString().padStart(2, '0');
}
}
PENDING 状态对应"思考中..."的占位气泡,ERROR 对应红色的失败提示------状态先建模,界面才有章法。
五、对话 UI 骨架:气泡列表 + 输入区 + 发送状态机
页面骨架分两块:消息列表和输入区。发送逻辑是一个小状态机------先落两个气泡(用户气泡立即完成,助手气泡置 PENDING),请求回来更新助手气泡,失败标 ERROR,全程用 sending 锁住按钮防重复提交。
ts
// pages/Index.ets(节选)
private async handleSend(): Promise<void> {
const text = this.inputText.trim();
if (text.length === 0 || this.sending) return;
this.inputText = '';
const userMsg = new ChatUIMessage(BubbleRole.USER, text, MsgStatus.DONE);
const botMsg = new ChatUIMessage(BubbleRole.ASSISTANT, '', MsgStatus.PENDING);
this.messages = [...this.messages, userMsg, botMsg]; // 重新赋值触发刷新
this.sending = true;
try {
const reply = await this.agent.send(text);
botMsg.text = reply;
botMsg.status = MsgStatus.DONE;
} catch (e) {
botMsg.text = '出错了:' + (e as Error).message;
botMsg.status = MsgStatus.ERROR;
} finally {
this.messages = [...this.messages];
this.sending = false;
}
}
气泡渲染用 List + ForEach,用户消息靠右蓝底、助手消息靠左灰底,@Builder Bubble 一个模板搞定两种角色。唯一的 ArkUI 惯例要记住:@State 数组直接 push 不一定触发刷新,改完重新赋值 this.messages = [...this.messages] 最稳。
六、纯逻辑单测:不联网也能绿
脚手架的三块核心全是纯逻辑,Hypium 单测不需要网络、不需要 API Key,模拟器上秒过。
ts
// ohosTest/ets/test/LlmClient.test.ets(节选)
it('超出上限裁剪最旧对话,系统消息保留', 0, () => {
const s = new ConversationStore('sys', 2);
s.addUser('1'); s.addAssistant('A');
s.addUser('2'); s.addAssistant('B');
s.addUser('3'); s.addAssistant('C');
expect(s.getMessages()[0].role).assertEqual(Role.SYSTEM);
expect(s.getConversation().length).assertEqual(2);
expect(s.getConversation()[1].content).assertEqual('C');
});
it('send 落库用户/助手并走通底座', 0, async () => {
const mock = new MockLlmClient('假回复'); // 继承 LlmClient,sendOnce 返回固定 JSON
const agent = new Agent(mock, 'sys', 'm', 0.7);
const reply = await agent.send('你好');
expect(reply).assertEqual('假回复');
const all = agent.store.getMessages();
expect(all[1].role).assertEqual(Role.USER);
expect(all[2].role).assertEqual(Role.ASSISTANT);
});
工程里共 19 条断言:底座复用 9 条(重试 6 + 解析 3),新增 ConversationStore 4 条、Agent 2 条(含错误传播)、ChatUIMessage 4 条,全部不发起网络请求。Agent 的测试通过继承 LlmClient 写了一个 MockLlmClient,sendOnce 直接返回固定 JSON,顺带把"底座解析 → Agent 落库"的整条链路验证了。
七、跑起来:三步
- DevEco Studio 26 打开工程,右键
ohosTest → Run,确认 19 条断言全绿。 - 打开
pages/Index.ets,把buildAgent()里的baseUrl/apiKey/model换成自己的云端网关配置。 - 连真机或 HarmonyOS 7 模拟器运行,输入框打字、点发送,即可看到多轮对话气泡------历史会带着系统提示词一起发给模型,Agent 记得住上文。
以下是 V 哥真机测试截图:

算是跑起来了^^
八、五个高频坑(搭脚手架前先看)
- 系统提示词别写死在页面里 :收进
ConversationStore,new Agent时统一传入,换页面不丢人设。 - 多轮历史会撑爆上下文窗口 :设
maxMessages自动裁剪最旧对话,系统消息常驻。 - 气泡状态不建模,UI 一团乱 :用
ChatUIMessage区分pending / done / error,别在组件里写散装 if。 @State数组直接 push 不刷新 :改完重新赋值this.messages = [...this.messages]。- 每个页面都 new 一个客户端 :
Agent复用同一个LlmClient实例,配置只写一处。

参考与出处
- 上篇《HarmonyOS 7 Agent 开发实战 01:云端大模型 API 接入底座》(本工程复用其
LlmClient抽象) - 华为开发者官网 · ArkTS 声明式 UI 开发指南(@Component / @State / List / ForEach)
- 华为开发者官网 · 应用测试框架 Hypium(ohosTest 单测)
最后一句:脚手架不是 scaffolding 的花架子,而是让每一行新代码都有明确的归宿;Agent 的能力可以慢慢长,骨架先要站得直。