雨天小六

读懂 Codex(10.1):Event-driven Runtime 的必要复杂度

· 更新于 2026-08-03 · 专栏:读懂 Codex

#Codex#Agent Runtime#软件架构#系统设计

具体问题与边界

最小 Agent 循环常被写成 while True: response = model(prompt)。一旦同时存在流式文本、工具并发、 审批回答、Interrupt 和后台进程,这个函数就无法回答两个问题:新输入应由谁接收,已经产生的部分 结果又由谁声明终态?Codex 用 OpEventMsg 把控制面和观察面拆开,复杂度主要来自真实并发, 不是事件类型本身。

本节只判断事件驱动结构何时必要,不主张其他 Agent Runtime 复制 Codex 的全部枚举。

协议、状态与所有权

对象所有者生命周期合同
Submission/Op调用方创建,Session 消费单次输入表达意图,不冒充模型消息
活动 Turn TaskSessionTurn可被 Interrupt 定位并取消
Event/EventMsgSession 产生,适配器消费瞬时描述已经发生的状态变化
ResponseItemContext/Rollout可跨 Turn/进程完整模型语义单元
terminal eventTurn owner每 Turn 一次Completed、Aborted、Failed 互斥

正常路径

输入操作、活动 Turn、工具任务和输出事件之间的事件驱动运行时正常路径
图 10.1-1:单一输入分派器保持控制顺序,活动 Turn 和工具任务异步推进,Event 只报告已提交的状态。
  1. 调用方把用户输入封装为 Submission;Session 的唯一分派循环顺序解释 Op。
  2. User Turn 由独立 Task 持有,Session 因而能继续接收 Interrupt、审批回答和关闭操作。
  3. 模型流产生 Delta 和完整 Item。Delta 只进入观察事件;完整 Item 才进入 History 与 Rollout。
  4. 工具调用可并发执行,但结果在同一 Turn 所属的协调点完成配对。
  5. 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 策略,但保留唯一分派者、活动所有权、等待器旁路和 单一终态四条合同。

失败、取消与恢复

事件驱动运行时在取消、慢消费者和终态竞争下的状态图
图 10.1-2:取消是状态转换,不是任意位置抛出的异常;终态竞争必须在 Turn owner 处收敛。
故障已发生状态错误实现必要处理
慢事件消费者Turn 仍在产生事件无界缓存直至耗尽内存有界 Queue 施加反压
Interrupt 与模型完成竞态两个 Future 同时就绪分别发送 Aborted/CompletedTurn 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,配置也不能只捕获一次。详细源码映射由研究仓库中的配套索引维护。

阅读导航

上一节:第十章总览 · 下一节:10.2

评论


← 返回文章列表