第 5 章 · Streaming 与实时交互

04 · 把一切串起来

在 agent 循环中插入事件发射,在 main.ts 中连接渲染器,完成从黑箱到实时交互的升级。

三步改造

前三节分别实现了事件类型、事件发射器和终端渲染器。现在要把它们串起来,完成三步改造:

  1. agent.ts:在关键节点发出事件
  2. main.ts:创建 emitter 和 renderer,把两者连接
  3. 测试:验证事件在正确的时机发出

第一步:agent 中发出事件

回顾 src/agent.tsrunAgent 函数的改造。emitter 从 options 中获取,所有 emit 调用都用可选链:

export async function runAgent(
  state: AgentState,
  model: Model,
  tools: Tool[],
  options?: RunAgentOptions,
): Promise<AgentResult> {
  const emit = options?.emitter;
  // ...

  /** 任务开始 */
  emit?.emit({ type: "agent_start", task: state.task });

  /** 第一次模型调用 */
  emit?.emit({ type: "model_call", phase: "thinking" });
  let response = await model.chat(allMessages, allTools);

  for (let i = 0; i < maxIterations; i++) {
    if (!response.toolCalls || response.toolCalls.length === 0) {
      /** 正常结束 */
      emit?.emit({
        type: "agent_done",
        answer: response.content,
        stats: { modelCalls: stats.modelCalls, toolCallCount: stats.toolCallCount, hitLimit: false },
      });
      return { answer: response.content, todos: [...todoManager.getAll()], stats };
    }

    for (const toolCall of response.toolCalls) {
      // ... 权限检查 ...

      /** 工具执行前 */
      emit?.emit({ type: "tool_call_start", tool: toolCall.name, args: toolCall.arguments });

      /** 权限拒绝 */
      if (!approved) {
        emit?.emit({ type: "tool_call_result", tool: toolCall.name, result: `被拒绝: ${reason}`, approved: false });
        continue;
      }

      const result = await tool.execute(toolCall.arguments, state);
      stats.toolCallCount++;

      /** 工具执行后 */
      emit?.emit({ type: "tool_call_result", tool: toolCall.name, result, approved: true });

      /** 计划工具更新 */
      if (toolCall.name === "create_todos" || toolCall.name === "update_todo") {
        emit?.emit({ type: "todo_update", todos: [...todoManager.getAll()] });
      }
    }

    /** 循环内模型调用 */
    emit?.emit({ type: "model_call", phase: "acting" });
    response = await model.chat(allMessages, allTools);
    stats.modelCalls++;
  }

  /** 达到上限 */
  stats.hitLimit = true;
  emit?.emit({
    type: "agent_done",
    answer: response.content || "未能完成任务:达到最大迭代次数。",
    stats: { modelCalls: stats.modelCalls, toolCallCount: stats.toolCallCount, hitLimit: true },
  });
  return { answer: response.content || "未能完成任务:达到最大迭代次数。", todos: [...todoManager.getAll()], stats };
}

关键设计点:

emit 用可选链 emit?.emit(...) 不传 emitter 时,所有 emit 都是空操作。这保证了向后兼容——第 1-4 章的代码不需要任何修改。

每个退出路径都有 agent_done 正常结束和达到上限都会发出 agent_done 事件,让渲染器知道 agent 停下来了。

计划工具的特殊处理。 create_todosupdate_todo 执行后会额外发出 todo_update 事件。这样渲染器可以在计划变化时立即更新显示。

第二步:main.ts 中连接渲染器

src/main.ts 中,创建 emitter 和 renderer,把两者连起来:

import { AgentEmitter } from "./ui/events";
import { TerminalRenderer } from "./ui/renderer";

async function main() {
  // ...

  /** 创建事件发射器和渲染器 */
  const emitter = new AgentEmitter();
  const renderer = new TerminalRenderer();
  emitter.on((event) => renderer.handleEvent(event));

  // ...

  /** 传入 emitter */
  const result = await runAgent(state, model, tools, { permissionGuard, emitter });
}

连接方式非常简单:emitter.on(renderer.handleEvent.bind(renderer))——渲染器注册为 emitter 的监听器。从这一刻起,agent 发出的所有事件都会被渲染器处理并输出到终端。

因为渲染器已经通过事件系统展示了工具调用、计划状态和完成统计,main.ts 中原来手动输出这些信息的代码可以删除。只保留最终答案的输出。

第三步:运行效果

改造完成后,运行 agent 时的终端输出从:

> 帮我找到入口文件
(漫长的等待……)
入口文件是 src/main.ts

变成了:

> 帮我找到入口文件

>> 开始处理: 帮我找到入口文件
  ... 思考中
  > create_todos: 搜索入口文件,读取文件内容
  计划更新:
    [ ] #1 搜索入口文件
    [ ] #2 读取文件内容
  >  执行操作
  > update_todo: ...
  > search: 入口文件 main
  + search: 找到 src/main.ts
  > update_todo: ...
  计划更新:
    [x] #1 搜索入口文件
    [>] #2 读取文件内容
  >  执行操作
  > update_todo: ...
  > read_file: src/main.ts
  + read_file: import { Model } from "./model"
  > update_todo: ...
  计划更新:
    [x] #1 搜索入口文件
    [x] #2 读取文件内容
  = 完成 [3次模型调用, 7次工具调用]

入口文件是 src/main.ts

每一步都在实时展示——用户能看到 agent 在思考什么、调用了什么工具、每个步骤的状态。

架构图

完成后的架构:

用户输入

main.ts ── 创建 emitter + renderer
  ↓         ↓
  ↓     emitter.on(renderer.handleEvent)
  ↓         ↓
runAgent(state, model, tools, { emitter })

  ├─ emit("agent_start")     → renderer → ">> 开始处理"
  ├─ emit("model_call")      → renderer → "... 思考中"
  ├─ emit("tool_call_start") → renderer → "> search: 关键词"
  ├─ emit("tool_call_result")→ renderer → "+ search: 找到 3 个文件"
  ├─ emit("todo_update")     → renderer → "[x] #1 搜索文件"
  └─ emit("agent_done")      → renderer → "= 完成 [3次, 5次]"

agent 只负责发出事件,不关心谁在监听。renderer 只负责处理事件,不关心 agent 的内部逻辑。main.ts 作为"胶水"把两者连在一起。

版本更新

main.ts 的版本号从 v0.4.0 升级到 v0.5.0

console.log(`mca v0.5.0`);

测试覆盖

这一章新增了两个测试文件:

  • test/ui/events.test.ts:测试 AgentEmitter 的注册、移除、多监听器、clear、所有事件类型的传递
  • test/ui/renderer.test.ts:测试 TerminalRenderer 对每种事件类型的渲染输出、长文本截断、拒绝标记

加上之前章节的测试,整个项目现在有 120 个测试用例。

回顾:从脚本到工具

回到第 1 章时的 agent——一个只调用模型、返回文本的脚本。到现在,它经历了这样的演进:

章节新增能力用户视角变化
第 1 章最小 agent能回答问题
第 2 章ReAct 循环 + 计划工具能拆分任务
第 3 章完整工具箱能搜索、读写、执行命令
第 4 章权限系统安全地执行操作
第 5 章事件流 + 终端渲染实时看到执行过程

从"黑箱脚本"到"可观察的交互工具"——这就是事件流带来的变化。而且整个改造是通过事件和监听器模式实现的,agent 的核心逻辑没有被打乱,只是多了几行 emit 调用。

登录以继续阅读

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

立即登录

On this page