HarmonyOS 7 Agent 开发实战 02:Agent 工程脚手架与对话 UI 骨架

0 阅读7分钟

头图

上篇把云端大模型接进了 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 的角色设定就失效);多轮历史不能无限堆(上下文窗口撑爆,费用也跟着涨)。

// 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);
    // 用 concat 拼接(不用条件 spread),ArkTS 严格模式下写法更稳
    const next: ChatMessage[] = [];
    if (this.systemPrompt.length > 0) {
      next.push({ role: Role.SYSTEM, content: this.systemPrompt });
    }
    this.messages = next.concat(keepConv);
  }

  clear(): void {
    this.messages = this.systemPrompt.length > 0
      ? [{ role: Role.SYSTEM, content: this.systemPrompt }] : [];
  }
}

getMessages() 返回完整序列(系统 + 对话)直接喂给底座;getConversation() 只返回对话消息,给 UI 渲染用。裁剪只动对话消息,系统消息雷打不动——这个细节用单测锁死。

三、Agent 编排:一次 send 走通完整往返

有了记忆容器,Agent 本身出乎意料地薄——它只做一件事:把"组织请求 → 调底座 → 回写记忆"的固定顺序封装成 send()。

// 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 组件里散落一堆判断。

// feature/agent/ChatUIMessage.ets(节选)
export enum MsgStatus { PENDING = 'pending', DONE = 'done', ERROR = 'error' }

// @Observed:让 text / status 等属性变化能被子组件的 @ObjectLink 观察到,
// 否则发送完成后气泡不会刷新(V1 状态管理只代理数组第一层)
@Observed
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 锁住按钮防重复提交。

// 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,每条消息交给独立的 MessageBubble 子组件;子组件用 @ObjectLink 绑定 @Observed 的 ChatUIMessage,这样 assistant 气泡在"思考中 → 完成/失败"时属性一变就自动刷新,不用整体重绘列表。两个 ArkUI 惯例要记住:一是 @State 数组改完要重新赋值 this.messages = [...this.messages] 才稳;二是要精细刷新单条气泡时,用 @Observed + @ObjectLink 而不是只靠数组重赋值。

// pages/Index.ets(节选)—— 单条气泡组件
@Component
struct MessageBubble {
  @ObjectLink msg: ChatUIMessage;   // 绑定 @Observed 的视图模型

  build() {
    Row() {
      if (this.msg.isUser()) { Blank() }
      Column() {
        Text(this.msg.text.length > 0 ? this.msg.text
          : (this.msg.isPending() ? '思考中…' : ''))
          .fontSize(15).lineHeight(22)
          .fontColor(this.msg.isError() ? '#C0392B' : '#1A1A1A')
          .padding(10)
        Text(this.msg.formattedTime())
          .fontSize(10).fontColor('#9AA0A6')
          .alignSelf(this.msg.isUser() ? ItemAlign.End : ItemAlign.Start)
      }
      .backgroundColor(this.msg.isUser() ? '#ffced0d2' : '#EFF2F7')
      .borderRadius(12).constraintSize({ maxWidth: '78%' }).padding(4)
    }
    .width('100%')
    .justifyContent(this.msg.isUser() ? FlexAlign.End : FlexAlign.Start)
    .padding({ top: 4, bottom: 4 })
  }
}

六、纯逻辑单测:不联网也能绿

脚手架的三块核心全是纯逻辑,Hypium 单测不需要网络、不需要 API Key,模拟器上秒过。

// 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 记得住上文。

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

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

避坑清单

参考与出处

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

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