Skip to content

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 }

三个层级

这些事件可以归纳为三个层级

  1. Agent 级agent_start / agent_end —— 一次完整运行(一个 prompt 引发的所有 turn)。
  2. Turn 级turn_start / turn_end —— 一次"调模型 + 执行工具"的完整回合。
  3. 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 内部用 processEventsagent.ts:540)消费事件并同步更新 this.state

事件对 state 的影响
message_start设置 streamingMessage
message_update更新 streamingMessage
message_end清空 streamingMessage,推入 messages
tool_execution_startpendingToolCalls.add(id)
tool_execution_endpendingToolCalls.delete(id)
agent_end清空 streamingMessage

所以除了订阅事件,你也可以直接读 agent.statemessagesisStreamingpendingToolCallserrorMessage)来观察状态。

事件 → 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
  • subscribepackages/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 才算真正结束"。

下一步:2.4 队列:steer 与 followUp