第 5 章 · Streaming 与实时交互

02 · 事件系统设计与实现

定义 Agent 事件类型,实现一个轻量的 AgentEmitter 事件总线。

事件从哪里来

上一节确定了方向:agent 在关键节点发出事件,外部监听并渲染。现在要回答的问题是——具体要发哪些事件?每个事件带什么数据?

回到 agent 循环,梳理一下关键节点:

时刻事件携带信息
开始处理任务agent_start任务描述
调用模型model_call当前阶段(思考/执行/验证)
模型返回内容model_response模型的文本输出
开始执行工具tool_call_start工具名、参数
工具返回结果tool_call_result工具名、结果、是否被批准
权限请求确认permission_ask工具名、参数、原因
权限确认结果permission_result工具名、是否批准
计划更新todo_update当前列表
任务完成agent_done最终答案、统计数据

这些事件覆盖了 agent 执行的完整生命周期。不需要更多——保持事件种类精简,每个事件都有明确的语义。

注意:permission_askpermission_resultmodel_response 已在类型中定义,渲染器也处理了这些事件,但在当前实现中 agent 还不会主动发出它们。权限确认目前通过 main.ts 中的确认回调直接处理。这三个事件是为将来更丰富的 UI 集成预留的——当权限确认需要脱离终端回调、改由事件驱动时,只需在 agent 循环中添加对应的 emit 调用即可。

事件类型定义

src/ui/events.ts 中定义事件类型:

/** Agent 执行阶段 */
export type AgentPhase = 'thinking' | 'acting' | 'verifying' | 'done';

/** 所有事件类型的联合类型 */
export type AgentEvent =
  | { type: 'agent_start'; task: string }
  | { type: 'model_call'; phase: AgentPhase }
  | { type: 'model_response'; content: string }
  | { type: 'tool_call_start'; tool: string; args: Record<string, unknown> }
  | {
      type: 'tool_call_result';
      tool: string;
      result: string;
      approved: boolean;
    }
  | {
      type: 'permission_ask';
      tool: string;
      args: Record<string, unknown>;
      reason: string;
    }
  | { type: 'permission_result'; tool: string; approved: boolean }
  | {
      type: 'todo_update';
      todos: Array<{ id: string; description: string; status: string }>;
    }
  | {
      type: 'agent_done';
      answer: string;
      stats: { modelCalls: number; toolCallCount: number; hitLimit: boolean };
    };

设计要点:

用联合类型(Discriminated Union)。 每个事件都有 type 字段作为判别标识,消费者可以用 switch (event.type) 安全地处理不同事件,TypeScript 会自动收窄类型。

阶段枚举(AgentPhase)。 模型调用事件带一个 phase 字段,表示当前 agent 处于哪个阶段。"思考"是第一次模型调用,"执行"是收到工具结果后的再次调用。渲染器可以根据阶段显示不同的图标。

approved 字段。 tool_call_result 事件带一个 approved: boolean,表示这个工具调用是被放行还是被拒绝。这样渲染器可以对拒绝的操作显示不同样式。

AgentEmitter 实现

有了事件类型,接下来实现事件发射器。这是一个经典的观察者模式——不依赖 Node.js 的 EventEmitter,用纯 TypeScript 实现:

export type EventListener = (event: AgentEvent) => void;

export class AgentEmitter {
  private listeners: EventListener[] = [];

  /** 注册监听器 */
  on(listener: EventListener): void {
    this.listeners.push(listener);
  }

  /** 移除监听器 */
  off(listener: EventListener): void {
    this.listeners = this.listeners.filter((l) => l !== listener);
  }

  /** 发出事件 */
  emit(event: AgentEvent): void {
    for (const listener of this.listeners) {
      listener(event);
    }
  }

  /** 清空所有监听器 */
  clear(): void {
    this.listeners = [];
  }
}

这个实现非常简单,但恰好满足需求:

  • on:注册监听器。返回 void,不支持链式调用,保持简单。
  • off:按引用移除监听器。用 filter 创建新数组,不影响正在遍历的数组。
  • emit:同步地通知所有监听器。同步意味着事件发出时,所有监听器会在当前调用栈中执行完毕。这对我们的场景足够——不需要异步事件。
  • clear:批量移除。用于测试或重置。

为什么不用 Node.js EventEmitter

Node.js 自带了 EventEmitter,功能更丰富(once、prepend、maxListeners 等)。但选择自己实现有几个原因:

  1. 教学清晰。 20 行代码,学员一眼就能看懂事件系统的核心原理。
  2. 类型安全。 自定义实现可以用 TypeScript 联合类型精确约束事件载荷,Node.js EventEmitter 的 on("event", callback) 类型推断不如联合类型精确。
  3. 零依赖。 如果将来 agent 要在浏览器或 Deno 中运行,不依赖 Node.js API 会更方便。

测试事件系统

测试事件系统很简单——注册一个 mock 监听器,发出事件,检查监听器是否收到了正确的事件:

it('on 注册的监听器能收到事件', () => {
  const emitter = new AgentEmitter();
  const listener = vi.fn();
  emitter.on(listener);

  const event: AgentEvent = { type: 'agent_start', task: '测试任务' };
  emitter.emit(event);

  expect(listener).toHaveBeenCalledWith(event);
});

还可以测试多个监听器、off 移除、clear 清空等边界情况。完整的测试文件在 test/ui/events.test.ts 中,覆盖了注册、移除、多监听器、无监听器时不报错等场景。

事件的插入点

定义了事件类型和发射器后,下一步是找到 agent 代码中每个事件的插入点——在哪个位置发出哪个事件。

回到 src/agent.ts,每个插入点都用一行 emit?.emit(...) 搞定:

  • 任务开始前:emit?.emit({ type: "agent_start", task: state.task })
  • 第一次模型调用前:emit?.emit({ type: "model_call", phase: "thinking" })
  • 工具执行前:emit?.emit({ type: "tool_call_start", ... })
  • 工具执行后:emit?.emit({ type: "tool_call_result", ... })
  • 计划更新后:emit?.emit({ type: "todo_update", ... })
  • 循环内模型调用前:emit?.emit({ type: "model_call", phase: "acting" })
  • 任务完成时:emit?.emit({ type: "agent_done", ... })

使用可选链 emit?.emit(...) 确保:如果没传 emitter,所有 emit 调用都是空操作,agent 行为不受影响。这保证了向后兼容——第 1-4 章的调用方式不需要任何修改。

RunAgentOptions 的变化

和第 4 章加入 permissionGuard 一样,emitter 也通过 options 传入:

export interface RunAgentOptions {
  maxIterations?: number;
  permissionGuard?: PermissionGuard;
  /** 事件发射器,如果不传则不发出任何事件(向后兼容) */
  emitter?: AgentEmitter;
}

调用方可以选择传入 emitter 来获取实时事件,也可以不传——这时 agent 的行为和之前完全一样。

下一节会实现终端渲染器,把这些事件变成用户能看懂的终端输出。

登录以继续阅读

解锁完整文档、代码示例及更多高级功能。

立即登录

On this page