M07. 事件:运行时如何向外部汇报#
Agent 运行时会产生大量变化:文本在流式增长、工具开始和结束、turn 切换、队列变化、压缩开始和结束。若每个模块互相调用,任何新 UI 或新观测需求都会侵入核心 Loop。事件系统把“发生了什么”与“谁关心”分开。
1. 先分清两条管道#
Pi 里至少要区分两类使用方式:
session.subscribe():SDK 使用者观察会话事件,适合 UI、日志、指标和业务投影。pi.on():扩展订阅生命周期或操作事件,某些钩子可以等待、修改或阻断行为。
它们可能看到相近的事实,但责任不同。观察者不应偷偷变成策略执行器;策略钩子也不应承担整个 UI 的渲染。
2. 事件名表达的是时间点#
常见事件可以按时间线理解:
agent_start
→ turn_start
→ message_start / message_update / message_end
→ tool_execution_start / update / end
→ turn_end
→ agent_end队列更新、自动重试和 compaction 会在这条主线上插入自己的生命周期事件。不要只监听最终 agent_end:那样你无法知道任务是在模型、工具还是压缩阶段耗时。
3. 订阅者应该做 projection#
事件本身是内部协议,前端通常需要的是更小的公共协议:
type UiEvent =
| { type: "answer_delta"; text: string }
| { type: "tool_state"; name: string; state: "running" | "done" | "error" }
| { type: "run_state"; state: "idle" | "working" | "compacting" };把 Pi 事件投影成自己的 UiEvent 有两个好处:一是避免内部字段泄漏到产品 API,二是允许不同版本的 Pi 在 adapter 中兼容。未知事件应被记录或忽略,而不是让整个 UI 崩溃。
4. 什么时候要等待#
日志和指标通常应该快速返回;权限检查、用户确认和工具前置策略可能需要等待。事件处理器一旦变慢,就会影响后续运行时,所以每个监听器都要明确自己属于哪一类。
一个实用原则是:
- 观察:复制事件、做轻量转换、异步投递。
- 干预:只在有明确阻断/修改语义的钩子里等待。
- 副作用:不要在每个事件上做不可幂等写入。
5. 事件与持久化的关系#
流式 delta 不等于已提交消息。适合恢复的持久化时机通常是 message_end 或 session manager 明确追加 entry 的时刻。这样,断电时不会把半句话误认为完整 assistant 回复。
同时,事件也不是 session file 的替代品。事件只描述这次运行发生了什么;session file 保存之后重启需要重建的历史。
6. 事件钩子中的递归风险#
如果 turn_end 处理器无条件调用 followUp(),每一轮都会制造下一轮;如果 tool_execution_end 触发一个会再次调用该工具的动作,就可能形成隐蔽递归。所有自动续跑都需要计数、预算、停止条件和可观测原因。
7. 设计事件协议的检查表#
- 事件是否有稳定的
type判别字段? - 增量事件是否能被丢弃后从最终状态重建?
- 事件里是否含有不该出现在日志中的原始参数?
- 监听器失败会不会阻断 Agent?
- 事件顺序是否有文档和测试?
- 断线重连后是补发事件,还是从 session 重建?
8. 事件系统的可靠性边界#
事件通常是瞬时通知,不应被当成唯一事实来源。可重建的状态应来自 session 或运行时快照;事件只负责让外部尽快得到变化。这样,事件重复时可以幂等处理,事件丢失时可以重新投影,事件乱序时也不会改写已经提交的历史。对无法恢复的缺口,系统应显示“未知”,而不是猜一个看似完整的状态。
资料
本文依据 Pi SDK 文档、Extensions 文档和 Agent 核心源码重新创作。