具体问题与边界
最小 Agent 循环常被写成 while True: response = model(prompt)。一旦同时存在流式文本、工具并发、
审批回答、Interrupt 和后台进程,这个函数就无法回答两个问题:新输入应由谁接收,已经产生的部分
结果又由谁声明终态?Codex 用 Op 与 EventMsg 把控制面和观察面拆开,复杂度主要来自真实并发,
不是事件类型本身。
本节只判断事件驱动结构何时必要,不主张其他 Agent Runtime 复制 Codex 的全部枚举。
协议、状态与所有权
| 对象 | 所有者 | 生命周期 | 合同 |
|---|---|---|---|
| Submission/Op | 调用方创建,Session 消费 | 单次输入 | 表达意图,不冒充模型消息 |
| 活动 Turn Task | Session | Turn | 可被 Interrupt 定位并取消 |
| Event/EventMsg | Session 产生,适配器消费 | 瞬时 | 描述已经发生的状态变化 |
| ResponseItem | Context/Rollout | 可跨 Turn/进程 | 完整模型语义单元 |
| terminal event | Turn owner | 每 Turn 一次 | Completed、Aborted、Failed 互斥 |
正常路径
- 调用方把用户输入封装为 Submission;Session 的唯一分派循环顺序解释 Op。
- User Turn 由独立 Task 持有,Session 因而能继续接收 Interrupt、审批回答和关闭操作。
- 模型流产生 Delta 和完整 Item。Delta 只进入观察事件;完整 Item 才进入 History 与 Rollout。
- 工具调用可并发执行,但结果在同一 Turn 所属的协调点完成配对。
- Turn owner 根据真实终态只发出一个 terminal event,随后释放活动所有权。
这条路径把“命令到达顺序”“任务完成顺序”和“客户端看到的事件顺序”分成三种约束。把它们压成 一个同步函数,代码更短,却会把 Interrupt 变成轮询、把审批等待变成阻塞、把增量输出变成半条历史。
Python 风格伪代码
Operation = UserInput | ApprovalAnswer | Interrupt | Shutdown
Terminal = Completed | Aborted | Failed
class Runtime:
inbox: asyncio.Queue[Submission]
events: asyncio.Queue[Event]
active: TurnTask | None
async def dispatch(self) -> None:
while submission := await self.inbox.get():
match submission.operation:
case UserInput(text) if self.active is None:
self.active = TurnTask.start(text, self.emit)
case ApprovalAnswer(call_id, decision):
self.resolve_waiter(call_id, decision)
case Interrupt() if self.active is not None:
self.active.cancel_token.cancel()
case Shutdown():
await self.finish_active_then_close()
return
async def finish_turn(self, terminal: Terminal) -> None:
assert self.active is not None
await self.emit(Event(self.active.turn_id, terminal))
self.active = None
伪代码合并了生产中的 Session handler 与 Task 策略,但保留唯一分派者、活动所有权、等待器旁路和 单一终态四条合同。
失败、取消与恢复
| 故障 | 已发生状态 | 错误实现 | 必要处理 |
|---|---|---|---|
| 慢事件消费者 | Turn 仍在产生事件 | 无界缓存直至耗尽内存 | 有界 Queue 施加反压 |
| Interrupt 与模型完成竞态 | 两个 Future 同时就绪 | 分别发送 Aborted/Completed | Turn owner 原子选择唯一终态 |
| ToolCall 已记录、工具被取消 | 历史中已有 Call | 丢弃 Result | 生成同 call_id 的失败/取消 Result |
| Sender 意外全部关闭 | 无新的显式 Shutdown | 主循环永久等待 | Channel 关闭也进入 teardown |
| UI 断连 | 规范历史可能已提交 | 把 UI 事件当权威状态 | UI 可重连,Rollout/Context 保持权威 |
设计判断
Event-driven 不等于“所有东西都是事件”。稳定配置仍应是快照,规范历史仍应是完整 Item,持久化仍需 独立写入屏障。事件适合跨所有权边界表达发生了什么;若只是一个局部同步转换,直接返回值更清楚。
引入事件驱动结构的成本包括 ID 关联、终态去重、反压、事件版本兼容和测试时序。只有当 Runtime 确实需要在活动任务期间接收控制输入或持续输出时,这些成本才有回报。
证据与复现实验
生产证据集中在 protocol/src/protocol.rs::{Op,Event,EventMsg}、core/src/session/handlers.rs 与
Turn 任务实现;2.7、2.8 和 3.11—3.16 已分别证明分派、事件分层、Interrupt 与终态收敛。
Mini Codex 用有界 asyncio.Queue 和单活动 Turn 重现这条合同:
cd examples/mini-codex
uv run pytest -q -k 'terminal_event or interrupt or backpressure'
它没有复刻 Realtime、Review、Steer 和 App Server 事件投影,因此只能证明最小事件窄腰成立。
本节边界
已经证明:并发输入、增量输出和取消要求显式所有权与唯一终态。下一节继续解释为什么即使进入同一 Turn,配置也不能只捕获一次。详细源码映射由研究仓库中的配套索引维护。
评论
登录后即可评论