Appearance
2.3 生命周期事件
Agent 的运行过程会产生一连串事件。理解这些事件,就理解了"如何观察一个 Agent"。TUI 的渲染、日志记录、扩展系统,全都是建立在这些事件之上的。
事件全景
packages/agent/src/types.ts:422 定义了完整的事件联合类型 AgentEvent:
ts
// Agent 生命周期
| { type: "agent_start" }
| { type: "agent_end"; messages: AgentMessage[] }
// 回合(Turn)生命周期 —— 一次助手回复 + 它的工具调用
| { type: "turn_start" }
| { type: "turn_end"; message: AgentMessage; toolResults: ToolResultMessage[] }
// 消息生命周期 —— 对 user / assistant / toolResult 统一适用
| { type: "message_start"; message }
| { type: "message_update"; message; assistantMessageEvent } // 仅助手消息流式时
| { type: "message_end"; message }
// 工具执行生命周期
| { type: "tool_execution_start"; toolCallId; toolName; args }
| { type: "tool_execution_update"; toolCallId; toolName; args; partialResult }
| { type: "tool_execution_end"; toolCallId; toolName; result; isError }三个层级
这些事件可以归纳为三个层级:
- Agent 级:
agent_start/agent_end—— 一次完整运行(一个 prompt 引发的所有 turn)。 - Turn 级:
turn_start/turn_end—— 一次"调模型 + 执行工具"的完整回合。 - Message / Tool 级:消息的启动/更新/结束,以及工具执行的启动/更新/结束。
它们的关系像一个俄罗斯套娃:
agent_start
└─ turn_start
├─ message_start (user)
├─ message_end (user)
├─ message_start (assistant) ─ message_update × N ─ message_end
├─ [tool_execution_start → update × N → tool_execution_end]
├─ message_start (toolResult) ─ message_end
└─ turn_end
└─ turn_start ...(若有更多工具调用)
agent_end时序细节
- 用户消息和工具结果消息一次性发出
message_start+message_end(没有流式)。 - 助手消息流式期间,
message_start后跟若干message_update,最后message_end。 message_update额外携带assistantMessageEvent,即底层@pi/ai的原始增量事件(text_delta等),方便精确渲染。
如何订阅
Agent.subscribe() 返回一个退订函数:
ts
const unsubscribe = agent.subscribe((event, signal) => {
switch (event.type) {
case "agent_start": console.log("开始"); break;
case "message_update":
// 增量渲染
break;
case "message_end":
console.log("消息结束:", event.message);
break;
case "tool_execution_start":
console.log(`调用工具 ${event.toolName}`);
break;
case "agent_end":
console.log("结束,共", event.messages.length, "条消息");
break;
}
});
// 不再需要时:
unsubscribe();重要:监听器返回的 promise 会被 await,并纳入本次运行的结算(agent.waitForIdle() 会等它们全部完成)。所以监听器里可以做异步工作(如写日志、攒 UI 帧),但要小心别阻塞。
事件与状态的关系
Agent 内部用 processEvents(agent.ts:540)消费事件并同步更新 this.state:
| 事件 | 对 state 的影响 |
|---|---|
message_start | 设置 streamingMessage |
message_update | 更新 streamingMessage |
message_end | 清空 streamingMessage,推入 messages |
tool_execution_start | pendingToolCalls.add(id) |
tool_execution_end | pendingToolCalls.delete(id) |
agent_end | 清空 streamingMessage |
所以除了订阅事件,你也可以直接读 agent.state(messages、isStreaming、pendingToolCalls、errorMessage)来观察状态。
事件 → UI 的映射
在 @pi/coding-agent 里,这些事件被映射成 TUI 组件(见第六部分)。例如:
message_start→ 创建用户消息气泡。message_update→ 更新正在打字机输出的助手消息块。tool_execution_start→ 显示"正在运行 bash…"的组件。tool_execution_end→ 显示退出码、耗时。
事件系统是"无状态内核 + 有状态 UI"这条架构线的关键 —— 内核只发事件不渲染,UI 只渲染不发事件。
小结
- 事件分三级:Agent 级、Turn 级、Message/Tool 级。
message_update携带底层增量事件,用于精确流式渲染。- 订阅者 promise 会被 await,影响运行结算。
- 事件驱动 state 更新,也驱动 UI 渲染。
真实源码位置
- 事件类型:
packages/agent/src/types.ts:422 subscribe:packages/agent/src/agent.ts:250- 事件→状态:
packages/agent/src/agent.ts:540
面试角度:为什么用事件驱动
Q1:为什么用事件驱动,而不是让 Agent 直接调用 UI? 这是无状态内核 + 有状态 UI 的解耦。内核只"发出事件,不渲染",UI 只"订阅渲染,不驱动逻辑"。这样换 UI(TUI / print / rpc / json)内核一行不改,还方便测试(测试可只订阅事件断言行为)。事件是"内核与外部世界"的唯一接口。
Q2:为什么事件要分三级(agent / turn / message-tool)? 对应三种粒度的观察需求:想知道"一次完整运行"就用 agent 级;想观察"一次调模型+工具"用 turn 级;想精确到每条消息/每个工具用 message-tool 级。分三级让订阅者按需订阅,不必监听所有细节。
Q3:为什么 message_update 额外携带底层 assistantMessageEvent? 因为上层(如 TUI)可能想精确知道"这次更新是文本增量还是 thinking 增量"(text_delta vs thinking_delta),以便不同渲染。partial 给的是"当前完整消息",而 assistantMessageEvent 给的是"触发这次更新的原始增量事件",两者互补。
Q4:为什么监听器返回的 promise 会被 await,并纳入运行结算? 因为监听器可能要做异步工作(写日志、攒 UI 帧)。如果不等它,agent_end 触发时这些异步收尾可能还没完成,导致"会话已结束但日志没写完"。waitForIdle() 等方法依赖"监听器全部 settle 才算真正结束"。