第 5 章 · Streaming 与实时交互

05 · 实战:事件流让黑箱变透明

用两个对比任务展示事件系统的效果:只读任务轻量流过,多步骤任务实时展示每一步。

从黑箱到实时交互

前四节分别实现了事件类型、事件发射器、终端渲染器,并把它们串进了 agent 循环。这一节用真实任务跑一遍,看事件流带来的体验变化。

任务一:只读问答——轻量事件流

给 agent 一个简单的只读任务:

> src/agent.ts 的 runAgent 函数签名是什么?

终端实时输出:

>> 开始处理: src/agent.ts 的 runAgent 函数签名是什么?
  ... 思考中
  > read_file: src/agent.ts
  + read_file: 文件: src/agent.ts (247 行)
  >  执行操作
  = 完成 [2次模型调用, 1次工具调用]

事件流拆解:

事件渲染输出说明
agent_start>> 开始处理任务开始,渲染器拿到 task
model_call (thinking)... 思考中第一次模型调用
tool_call_start> read_file: src/agent.ts模型决定读取文件
tool_call_result+ read_file: 文件: src/agent.ts (247 行)工具结果第一行预览
model_call (acting)> 执行操作第二次模型调用
agent_done= 完成 [2次, 1次]任务结束,统计输出

6 个事件,终端输出 5 行。模型只调用了 1 次工具就给出了回答。read_file 的结果预览显示了文件名和行数——这是 TerminalRenderer 的截断策略,只取结果第一行的前 80 字符。

任务二:多步骤任务——完整的事件流

换一个需要多次工具调用的任务:

> 帮我看看 src/tools/ 下有哪些工具,每个工具的作用是什么

终端实时输出:

>> 开始处理: 帮我看看 src/tools/ 下有哪些工具,每个工具的作用是什么
  ... 思考中
  > glob: src/tools/**
  + glob: src/tools/create-todos.ts
  >  执行操作
  > read_file: src/tools/index.ts
  + read_file: 文件: src/tools/index.ts (42 行)
  >  执行操作
  > read_file: src/tools/search.ts
  + read_file: 文件: src/tools/search.ts (94 行)
  > read_file: src/tools/glob.ts
  + read_file: 文件: src/tools/glob.ts (99 行)
  > read_file: src/tools/read-file.ts
  + read_file: 文件: src/tools/read-file.ts (90 行)
  > read_file: src/tools/write-file.ts
  + read_file: 文件: src/tools/write-file.ts (64 行)
  > read_file: src/tools/patch-file.ts
  + read_file: 文件: src/tools/patch-file.ts (140 行)
  >  执行操作
  > read_file: src/tools/run-command.ts
  + read_file: 文件: src/tools/run-command.ts (138 行)
  > read_file: src/tools/git-status.ts
  + read_file: 文件: src/tools/git-status.ts (44 行)
  > read_file: src/tools/git-diff.ts
  + read_file: src/tools/git-diff.ts (69 行)
  > read_file: src/tools/create-todos.ts
  + read_file: 文件: src/tools/create-todos.ts (43 行)
  > read_file: src/tools/update-todo.ts
  + read_file: 文件: src/tools/update-todo.ts (52 行)
  >  执行操作
  = 完成 [5次模型调用, 12次工具调用]

关键观察点:

模型分批读取文件。 模型先调用 glob 了解文件列表,然后分三批读取了 10 个工具文件。每批之间有一次 model_call 事件——模型看到上一批的文件内容后,决定继续读取剩余文件。

> 执行操作 标记了模型调用。 每当出现这行,说明模型收到了工具结果并开始了新一轮推理。在这个例子中出现了 4 次,对应 5 次模型调用(第一次是 ... 思考中)。

工具调用统计精确。 = 完成 [5次模型调用, 12次工具调用] — 1 次 glob + 11 次 read_file = 12 次工具调用,5 次模型调用。

渲染器的截断策略

看渲染器中几个关键的截断处理:

/** 任务描述超过 60 字符就截断 */
const display = task.length > 60 ? `${task.slice(0, 60)}...` : task;

/** 工具结果只显示第一行,超过 80 字符截断 */
const firstLine = result.split("\n")[0] ?? "";
const preview = firstLine.length > 80 ? `${firstLine.slice(0, 80)}...` : firstLine;

/** 命令超过 60 字符截断 */
const cmd = String(args.command ?? "");
return cmd.length > 60 ? `${cmd.slice(0, 60)}...` : cmd;

这些截断保证每行终端输出不超过 ~100 字符,在标准 80-120 列的终端中不会换行。工具的完整结果保存在对话历史中供模型使用,渲染器只展示预览。

事件和渲染器的分工

agent.ts                    events.ts              renderer.ts
─────────                   ────────               ────────────
emit("agent_start")
    ──────────────────────> AgentEvent ──────────> renderStart()
                                                    ">> 开始处理"

emit("model_call")
    ──────────────────────> AgentEvent ──────────> renderModelCall()
                                                    "... 思考中"

emit("tool_call_start")
    ──────────────────────> AgentEvent ──────────> renderToolCallStart()
                                                    "> read_file: ..."

emit("tool_call_result")
    ──────────────────────> AgentEvent ──────────> renderToolCallResult()
                                                    "+ read_file: ..."

emit("agent_done")
    ──────────────────────> AgentEvent ──────────> renderDone()
                                                    "= 完成 [x次, y次]"

agent 只负责发出事件——emit?.emit({...})。它不知道也不关心谁在监听。渲染器只负责处理事件——根据事件类型决定输出什么。两者通过 AgentEvent 类型解耦。

这就是为什么 emit 用可选链:不传 emitter 时,agent 的行为和第 4 章完全一样,只是没有终端输出。

这一章的代码结构

完成第 5 章后,项目结构变为:

mini-coding-agent/
├── src/
│   ├── types.ts            # 类型定义(新增 AgentEvent 等事件类型)
│   ├── model.ts            # 模型层(不变)
│   ├── todo.ts             # Todo 管理器(不变)
│   ├── permissions/        # 权限守卫(不变)
│   ├── ui/                 # 事件与渲染(新增目录)
│   │   ├── events.ts       # 事件发射器 AgentEmitter
│   │   └── renderer.ts     # 终端渲染器 TerminalRenderer
│   ├── agent.ts            # Agent Loop(升级:事件发射)
│   ├── main.ts             # CLI 入口(升级:emitter + renderer 连接)
│   └── tools/              # 工具集(不变)
├── test/
│   ├── ui/                 # UI 测试(新增)
│   │   ├── events.test.ts  # 事件发射器测试
│   │   └── renderer.test.ts # 终端渲染器测试
│   └── ...
├── package.json
└── tsconfig.json

这一章做了什么

回顾第 5 章的成果:

  • AgentEvent 类型:9 种事件类型,覆盖 agent 的完整生命周期
  • AgentEmitter 类:简单的发布-订阅模式,注册/移除/发出事件
  • TerminalRenderer 类:处理每种事件的终端输出,截断策略保证可读性
  • agent 集成:所有 emit 用可选链,不传 emitter 则静默跳过
  • main.ts 连接:一行 emitter.on(renderer.handleEvent) 完成胶水

从第 4 章的"黑箱执行"到第 5 章的"实时可见"——用户不再需要盯着空白终端等待,每一步都在眼前展开。下一章会给 agent 加上记忆,让它跨轮次记住之前的操作。

登录以继续阅读

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

立即登录

On this page