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 加上记忆,让它跨轮次记住之前的操作。
登录以继续阅读
解锁完整文档、代码示例及更多高级功能。