第 7 章:事件驱动 —— Agent 的神经系统
前六章里,事件反复出现却始终没深入:Agent Loop 每做一步都发事件让 UI 实时更新,工具执行时发出 tool_execution_start / update / end。事件到底怎么从 Agent 内部传到外部?谁在监听?为什么 Agent 发完事件后要"等"监听器处理完?本章打开 Agent 的神经系统。
为什么需要事件系统
假设你要给 Agent 加"工具调用日志"功能。不用事件系统:得改 Agent 源码,在 tool.execute() 前后加 console.log,然后每次 merge 上游更新都可能冲突。用事件系统:
session.subscribe((event) => {
if (event.type === "tool_execution_end") {
console.log(`[LOG] 调用了 ${event.toolName},结果:${event.isError ? "失败" : "成功"}`);
}
});
六行代码,不碰 Agent 一行源码。这就是事件驱动的核心价值:把"发生了什么"和"谁关心什么"彻底分离。 直接调用是"我亲自找你"(Agent 需要知道所有消费者);发布-订阅是"我对着空气喊了一声,谁听到算谁的"(新功能只需 subscribe,Agent 不需要知道)。Agent 内核代码里没有任何一行 updateTerminal() 或 appendToFile()——它甚至不知道 TUI 和文件存储的存在。
10 种事件,4 层嵌套
Agent 内核定义了 10 种 AgentEvent,构成 Agent 运行的完整"脉搏":
export type AgentEvent =
// 第1层:Agent 生命周期(整个运行)
| { type: "agent_start" }
| { type: "agent_end"; messages: AgentMessage[] }
// 第2层:Turn 生命周期(一轮模型调用 + 工具执行)
| { type: "turn_start" }
| { type: "turn_end"; message: AgentMessage; toolResults: ToolResultMessage[] }
// 第3层:Message 生命周期(一条消息)
| { type: "message_start"; message: AgentMessage }
| { type: "message_update"; message: AgentMessage; assistantMessageEvent: AssistantMessageEvent }
| { type: "message_end"; message: AgentMessage }
// 第4层:Tool Execution 生命周期(一次工具执行)
| { type: "tool_execution_start"; toolCallId: string; toolName: string; args: any }
| { type: "tool_execution_update"; ...; partialResult: any }
| { type: "tool_execution_end"; ...; result: any; isError: boolean };
规律很清楚——4 层嵌套的生命周期,每层都是"开始 → 更新(×N)→ 结束"配对:
agent_start
└── Turn 1
├── turn_start
├── message_start → message_update ×N → message_end ← 流式逐 token
├── tool_execution_start → update ×N → end ← 工具进度
└── turn_end
└── agent_end
为什么要 4 层? 不同消费者关心不同粒度:TUI 要逐 token 渲染,订阅 message_update;Session 管理器只关心一轮对话结束没有,只看 turn_end。4 层嵌套让每种消费者在刚刚好的粒度上响应。
emit 不是"通知",是"同步屏障"
如果你熟悉 Node.js 的 EventEmitter,你习惯的 emit 是同步、fire-and-forget 的——发了就走。但 Pi 的 Agent Loop 里每次 emit 都带着 await:
await emit({ type: "agent_start" });
await emit({ type: "message_start", ... });
await emit({ type: "message_update", ... });
每个 await 都在说:"等这个事件被完全处理完,再继续。" emit 的实体是 Agent 类的 processEvents,做三件事:
private async processEvents(event: AgentEvent): Promise<void> {
// 第一步:根据事件类型更新内部状态
switch (event.type) {
case "message_start":
this._state.streamingMessage = event.message; // 开始追踪流式消息
break;
case "message_end":
this._state.streamingMessage = undefined; // 清空临时工位
this._state.messages.push(event.message); // 搬入正式档案
break;
}
// 第二步:拿 AbortSignal
const signal = this.activeRun?.abortController.signal;
// 第三步:同步等待所有监听器完成
for (const listener of this.listeners) {
await listener(event, signal); // ← 一个一个等!
}
}
关键在第三步:按订阅顺序逐一 await 所有监听器。 this.listeners 是外部的 Set,Agent 不知道里面是谁,只负责"遍历并等待"。
为什么非要 await? 假设不 await:TUI 还没处理完 message_start,message_update 就来了,UI 可能显示空消息或过时内容——状态不一致。同步屏障保证 Agent 在监听器返回前不会发出下一个事件。await 不是为了"通知",而是为了"同步协商"——确保所有消费者都跟上了,Agent 才走下一步。 代价是性能(等最慢的消费者),换来的是正确性(状态永远一致)。
一个例外:tool_execution_update 不等
工具执行可能输出大量进度(Bash 每一行输出),逐条 await 太慢。Pi 的做法是先收集、后批量等待:
const updateEvents: Promise<void>[] = [];
let acceptingUpdates = true;
const result = await tool.execute(id, args, signal, (partialResult) => {
if (!acceptingUpdates) return; // 工具已结束,丢弃迟到 update
updateEvents.push(emit({ type: "tool_execution_update", ... })); // 不 await,先收集
});
acceptingUpdates = false; // 关闭闸门
await Promise.all(updateEvents); // 一次性等所有 update 处理完
这不矛盾——同步屏障规则不松,但对"高频、低价值、可合并"的进度更新开了口子(多发一条少发一条不影响最终状态);生命周期事件(start/end)"低频、高价值",必须逐条等。acceptingUpdates 闸门防止工具 resolve 后残留的异步回调在 tool_execution_end 之后又发出 update,避免"工具已结束却还在更新"的错乱序列。
错误处理:监听器异常直接冒泡
processEvents 的监听器循环里没有 try-catch。某个监听器抛异常,会一路冒泡触发整个 Agent 运行失败——一个 UI 渲染 bug 能把 Agent 干掉。 为什么不包?因为 Pi 的哲学是:监听器出错,运行就停下来,问题立刻可见。 静默吞掉异常的话,Agent 看起来"正常",但 UI 已乱套,调试时根本找不到问题——就像电路里的保险丝,烧断了你立刻知道哪里出了问题。
实践建议:基于 Pi 写自己的 UI/扩展监听器,务必在 listener 里自己 try-catch——Agent 不替你兜底。例外是扩展系统:第三方扩展的回调由框架做 try-catch 隔离,单个扩展崩溃不拖垮 session。原则——对内层受信任的代码让异常直接暴露,对外层不受信任的代码由框架隔离。
你能用事件系统做什么
机制讲完,看几个代表性场景——它们的共同特点是新增任何功能都不需要修改 Agent 内核:
实时观测:订阅 tool_execution_start / tool_execution_end,打印每个工具调用的名称、参数和成败——Pi 的 TUI 本身就是通过订阅事件实现的观测面板,你看到的所有终端输出都来自事件消费。
工具调用拦截:通过扩展系统的 tool_call 事件返回 { block: true, reason: "生产环境禁止删除操作" },工具就不会执行——第 5 章五步管道的第 3 步 beforeToolCall 就由这个机制实现。
上下文预处理:扩展可以在 LLM 调用前修改消息列表(注入当前时间、Git 状态、上一轮摘要)——第 6 章的 transformContext 钩子由此落地。
流式转发到 Web 前端:Agent 跑在服务器上,订阅 message_update 把增量文本通过 SSE 推给浏览器,agent_end 时结束响应——这是 Web 集成的核心模式:
session.subscribe((event) => {
if (event.type === "message_update") {
res.write(`data: ${JSON.stringify({ type: "delta", text: extractText(event.message) })}\n\n`);
}
if (event.type === "agent_end") res.end();
});
事件驱动架构的真正威力不是"通知机制",而是开放扩展机制——你只需 subscribe,然后在回调里做想做的事。
案例:一个 text_delta 的完整旅程
假设 LLM 正在生成"你好",一个"你"字从产生到显示经历 5 步:
LLM SSE 网络流:data: {"type":"text_delta","delta":"你"}
▼ AI 层 EventStream.push():AssistantMessageEvent { type: "text_delta" }
▼ Agent Loop 事件转换:text_delta → message_update(原始事件经 assistantMessageEvent 字段透传)
▼ Agent.processEvents():更新 streamingMessage,await 所有 listeners(同步屏障)
▼ AgentSession._handleAgentEvent():通知扩展系统 → 分发 Session 监听器 → 持久化
▼ TUI 监听器:提取 delta → 渲染到终端
每一层只关心自己的事,层与层之间唯一的通信协议就是"事件",没有任何一层直接调用另一层的内部方法。注意一个数据变换:AI 层的多种 delta(text/thinking/toolcall)被统一映射为 Agent 层的 message_update,原始事件通过 assistantMessageEvent 字段保留透传——Agent Loop 不关心 delta 类型(只管"消息更新了"),但关心的消费者仍能拿到细节。
Session 层扩展了什么
Agent 内核只有 10 种事件,但产品会话层 AgentSession 要处理的事多得多:上下文压缩、自动重试、队列管理——这些概念内核里不存在,就像 Linux 内核里没有"蓝牙已连接"通知。AgentSession 用联合类型扩展出 17 种事件:
AgentSessionEvent = 基础 10 种(agent_end 重载,增加 willRetry 字段)
+ 新增 7 种:queue_update(steering/followUp 队列变化)、
compaction_start/end(上下文压缩)、auto_retry_start/end(失败重试)、
session_info_changed(会话改名)、thinking_level_changed(思考深度切换)
两层事件的设计思路:内核只管生命周期,产品层按需扩展用户体验。 判断标准很简单:去掉某个事件后内核还能正常运行,它就属于外层。
总结:三个设计决策
- 同步屏障:
processEventsawait 所有监听器,保证状态一致;tool_execution_update用"先收集后批量等待"缓解高频性能问题。 - 异常直接暴露:监听器循环不加 try-catch,问题立刻可见;第三方扩展由框架隔离。
- 两层事件:内核 10 种生命周期事件,Session 层扩展 7 种产品级事件。
事件系统让 Agent 和外部世界彻底解耦——UI、日志、持久化、扩展全部通过订阅工作,新增功能永远不需要改内核。接下来两章转向与 transformContext 密切相关的议题:对话越来越长、超出上下文窗口时怎么办?第 8 章先看上下文工程全景,第 9 章深入最核心的压缩算法。
本章关键源码索引:
agent/src/types.ts:413-428(10 种 AgentEvent)、agent.ts:509-556(processEvents 同步屏障)、agent-loop.ts:628-669(update 特殊处理)、coding-agent/src/core/agent-session.ts:126-150(17 种 AgentSessionEvent)。本文改编自 CC-BY-SA-4.0 许可的开源教程。