【共创稿事节】HarmonyOS 7 Agent 开发实战 02:Agent 工程脚手架与对话 UI 骨架

上篇把云端大模型接进了 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 落库"的整条链路验证了。

七、跑起来:三步

  1. DevEco Studio 26 打开工程,右键 ohosTest → Run,确认 19 条断言全绿。
  2. 打开 pages/Index.ets,把 buildAgent() 里的 baseUrl / apiKey / model 换成自己的云端网关配置。
  3. 连真机或 HarmonyOS 7 模拟器运行,输入框打字、点发送,即可看到多轮对话气泡------历史会带着系统提示词一起发给模型,Agent 记得住上文。

以下是 V 哥真机测试截图:

算是跑起来了^^

八、五个高频坑(搭脚手架前先看)

  1. 系统提示词别写死在页面里 :收进 ConversationStore,new Agent 时统一传入,换页面不丢人设。
  2. 多轮历史会撑爆上下文窗口 :设 maxMessages 自动裁剪最旧对话,系统消息常驻。
  3. 气泡状态不建模,UI 一团乱 :用 ChatUIMessage 区分 pending / done / error,别在组件里写散装 if。
  4. @State 数组直接 push 不刷新 :改完重新赋值 this.messages = [...this.messages]。
  5. 每个页面都 new 一个客户端 :Agent 复用同一个 LlmClient 实例,配置只写一处。

参考与出处

  • 上篇《HarmonyOS 7 Agent 开发实战 01:云端大模型 API 接入底座》(本工程复用其 LlmClient 抽象)
  • 华为开发者官网 · ArkTS 声明式 UI 开发指南(@Component / @State / List / ForEach)
  • 华为开发者官网 · 应用测试框架 Hypium(ohosTest 单测)

最后一句:脚手架不是 scaffolding 的花架子,而是让每一行新代码都有明确的归宿;Agent 的能力可以慢慢长,骨架先要站得直。

相关推荐
打工仔折腾 AI2 小时前
鸿蒙平板远控电脑实测:ToDesk蓝牙键鼠、双模式鼠标与跨端剪贴板落地记录
人工智能·后端·python·性能优化·计算机外设·电脑·harmonyos
打工仔折腾 AI2 小时前
鸿蒙端ToDesk远控电脑实测:蓝牙键鼠、双模式鼠标与跨端剪贴板实践
后端·python·性能优化·计算机外设·电脑·harmonyos·ai agent 实战
OH_TPC2 小时前
【鸿蒙优选三方库】@ohos/axios:前端转鸿蒙,网络层几乎不用改
前端·华为·harmonyos·鸿蒙
m0_738185822 小时前
Flutter 鸿蒙化实战:simple_circular_progress_bar 适配 OpenHarmony,环形进度条
flutter·华为·harmonyos·鸿蒙
jsky_studio2 小时前
鸿蒙应用图标圆角与透明像素:上架驳回的排查与修法
harmonyos·鸿蒙
垆边人似月.14 小时前
华为机试题 :和为 K 的子数组个数
算法·leetcode·华为
程序猿追18 小时前
一次右尾概率实验:statrs4cj 的 CJMP 鸿蒙接入与双端实测
华为·harmonyos
大雷神19 小时前
从一道24点到同屏双人派对:用鸿蒙开发助手起步,复用已有App开发手机元服务
华为·智能手机·harmonyos
song50119 小时前
React Native for OpenHarmony 实战:三方库 react-native-parallax-scroll-view 的鸿蒙化适配指南
分布式·react native·react.js·transformer·harmonyos