第五章 LOOP:agent 的"心跳"是如何跳动的

第一章画过 agentic 主循环的流程图;第二到四章分别拆开了它两侧的协议、脚下的模型接入和手里的上下文。 本章把这些零件重新装回一起,盯着那个最小也最核心的结构——LOOP,也就是轮次内部”调用模型 → 执行行动 → 回灌结果 → 再次调用模型”的循环。 它是整个 harness 里代码最短的部分,却也是所有子系统的交汇点:上下文在这里被消费,工具在这里被执行,预算在这里被计量,中断在这里被响应,扩展在这里挂载。 理解了 LOOP 每一轮为什么这样转,就理解了 agent harness 的心跳。


5.1 十行的循环,和它周围的一千行护栏

先用伪代码看看这个循环的”裸形态”:

上下文 = 系统指令 + 用户输入
while True:
    回复 = 模型.调用(上下文, 工具清单)   # 即"采样":一次大模型推理请求
    if 回复里有工具调用:
        for 调用 in 回复.工具调用:
            结果 = 执行(调用)
            上下文.追加(调用, 结果)      # 工具结果回灌
    else:
        上下文.追加(回复.最终消息)
        break                          # 轮次结束

先扫清一个术语:什么叫”采样”? 这个词听着技术,其实它就是一次大模型调用——把上下文打包成请求发给模型,拿回一次回复。之所以叫”采样”(sampling),是因为模型生成每一个字时,本质上是在”下一个字的概率分布”上抽签取值;对 harness 来说,一次采样 = 发一次推理请求 = 模型”思考一轮并给出回应”。所以读本章时你可以直接做这样的替换:

  • “采样请求” = 发给模型的请求;
  • “一次采样” = 调用一次大模型;
  • “再采样一次” = 把工具结果喂回去后,再问模型一次。

这就是全部。Codex 内核里对循环契约的表述几乎一字不差:每次调用模型,它要么返回工具调用,要么返回一条 assistant 消息;是工具调用,就执行它、把结果喂回去、再调用一次;只有 assistant 消息,就记进历史、轮次收尾。

第一章讲过控制流倒置:传统程序的下一步由代码决定,agent 的下一步由模型根据上下文临时决定。换一个更精确的视角——这是强化学习里最经典的”策略-环境”交互结构:

  • 模型是策略函数:输入当前观察(上下文),输出动作(调工具 / 给回复);
  • harness 是环境:执行动作,把结果作为新的观察追加进去;
  • LOOP 就是这个交互本身,一轮一轮地迭代下去,直到策略选择”不再动作”。

这个视角下有一个容易被忽略、但贯穿全章的事实:循环没有程序计数器(PC)。 传统程序的”当前位置”由 PC 寄存器标记,而 agent LOOP 的”当前位置”是隐式的——它就是历史的尾部。每一轮 LOOP 都从”读完整段历史”开始,在”历史末尾追加新内容”结束。后面会看到,缓存(第 3 章)、压缩(第 4 章)、重放恢复(第 10 章)的设计全部建立在这个事实上。

裸循环只有十行,但它在真实世界里活不过一分钟:模型请求会断流、工具会失败、窗口会撑爆、用户会插话、扩展会喊停、子 agent 会来信。于是循环周围长出了上千行护栏。本章的剩余部分,就是逐个看这些护栏为什么存在、插在循环的什么位置。


5.2 一轮 LOOP 的解剖:从边界到边界

LOOP 的一次迭代(也就是第一章定义的一个步骤 step:一次模型调用 + 其触发的工具执行与结果回灌)可以切成五个阶段。请先看总图,随后逐段拆解。

flowchart TD
    A(["LOOP起点:步骤边界"]) --> B["排空待处理输入
steer 插话 / 子 agent 来信"] B --> C["用户输入 hooks
(可注入上下文 / 喊停)"] C --> D["预算与时间提醒
rollout 预算 / 当前时间"] D --> E["冻结步骤快照
模型 / 工具清单 / 环境"] E --> F["世界状态差分注入
(只注入变化的区段)"] F --> G["规范化历史
模态剥离 / 工具调用与结果配对"] G --> H["发采样请求(流式)
内部含流重试循环"] H --> I["item 成形即落历史
工具调用成形即 spawn 执行"] I --> J["流结束:按调用顺序
回收全部工具结果"] J --> K{"还要再来一轮?
needs_follow_up"} K -->|"有工具调用 / end_turn=false / 有未读输入"| L{"窗口将满或模型
主动开新窗口?"} L -->|"是"| M["中途压缩 / 滚动窗口
世界状态重建,摘要落在尾部"] M --> A L -->|"否"| N["注入 token 预算提醒(如有)"] N --> A K -->|"模型说停,且无未读输入"| O["停止 hooks 表决"] O -->|"hook 否决:注入续跑指令"| A O -->|"放行"| P(["轮次完成"]) H -.->|"致命错误"| Q["发错误事件,退出循环
线程存活,可开新轮次"]

阶段一:边界上的”交接班”

一轮 LOOP 的第一件事不是发请求,而是把上一轮结束之后世界发生的变化全部交接清楚:

  1. 排空待处理输入。 steer 插话和子 agent 来信(邮箱,第 7 章)在队列里等着,此刻被取出、写入历史(第 1 章讲过它们为什么不在采样中途注入)。有两个刻意的例外:轮次的第一轮 LOOP 不排空——用户触发本轮的原始输入必须最先被模型看到;中途压缩后的第一轮也不排空——先让模型把压缩前没干完的活续上,插话靠后。
  2. 用户输入 hooks。 对排空出来的输入也要跑一遍 hook,扩展可以注入补充上下文,甚至直接喊停这一轮。
  3. 提醒类片段。 rollout 预算提醒(整棵 agent 树共享的加权 token 预算,第 7 章)和当前时间提醒,按各自的节流策略在边界注入。
  4. 冻结步骤快照。 本轮可见的模型、工具清单、MCP 绑定、执行环境一次性定格(第 1 章的 StepContext)。一个细节:如果插话里提到了尚未启动的 MCP 服务,harness 会先把服务拉起来再冻结——保证”快照里宣称的工具”和”真正能执行的工具”严格一致。
  5. 世界状态差分注入。 对比上次模型可见的基线快照,只有变化的区段(目录、权限、AGENTS.md、模式……)才产出更新片段(第 4 章)。

阶段二:组装请求

克隆历史,做发请求前的规范化:剥离当前模型不支持的模态、补齐或移除悬空的工具调用与结果(第 4 章)。再配上步骤快照里的工具清单和系统指令,请求体就绪。注意历史用的是克隆——历史包在引用计数指针里,克隆廉价且互不干扰,压缩逻辑、扩展读快照都各拿一份。

阶段三:采样与流式执行

请求发出,流式读取响应(这一步内部还套着一个重试循环,5.7 节展开)。流上的处理遵循第 2 章的翻译层逻辑,这里只强调两个与循环直接相关的时序事实:

  • item 成形即落历史。 每个完成的 item(assistant 消息、reasoning、工具调用)在 done 的那一刻就写入历史并持久化——早于工具执行,甚至早于流结束。这样即使轮次随后被取消,历史依然完整:永远是”先有调用、后有结果”,不会出现孤儿。
  • 工具调用成形即 spawn。 工具 item 一成形,执行 future 立刻挂到一个并发队列里开跑,与模型继续输出后续内容在时间上重叠(第 2 章的”工具生命周期事件穿插在文本流里”)。

还有一个多 agent 场景的精巧设计:流式途中如果收到子 agent 来信,循环不必干等整条响应和全部工具跑完——在 commentary 消息或 reasoning item 的边界,它可以提前结束本次响应处理,标记”还需跟进”,让来信尽快进入下一轮 LOOP 被处理(第 7 章展开邮箱语义)。

阶段四:按序回收

流结束后,循环并发队列里挂着的工具 future 统一收口。这里用的是一个关键的数据结构:FuturesOrdered——future 并行执行,但结果严格按加入顺序产出,与完成先后无关。每个结果(无论成功还是失败)作为工具结果 item 写入历史。5.4 节专门展开这个设计。

阶段五:决策——再来一轮还是收工

是否再来一轮,由一个汇合信号决定(代码里叫 needs_follow_up),它有三个来源:

  • 模型这一轮调了工具,或 end_turn 标志显式为 false(第 2 章);
  • 工具被策略拒绝、需要给模型一个解释性回复;
  • 采样期间又有新的待处理输入到达(插话、来信)。

需要跟进时,先过预算闸门:窗口将满(或模型主动调用了开新窗口的工具)→ 触发中途压缩/滚动窗口,世界状态重建、交接摘要放在历史尾部(第 4 章),LOOP 直接回到起点;窗口尚可但余额偏低 → 注入 token 预算提醒。

模型说停、也没有未读输入时,还不能直接下班——要过停止 hooks 表决(5.5 节)。

如果采样本身撞上致命错误(超长、额度耗尽、非法请求、安全拦截、rollout 预算耗尽),循环发出错误事件后退出,但线程不死亡:历史保留现场,线程回到空闲,用户下一句话就能开新轮次接着处理。代码里的注释写得很直白:“let the user continue the conversation”。


5.3 历史即状态,尾部即当前

5.1 节留下一个论断:循环没有程序计数器,位置由历史尾部隐式表达。这个事实值得单独展开,因为它是一连串设计的共同支点。

其一,LOOP 可以从任意一轮重建。 每一轮的输入完全由”历史 + 步骤快照”决定,而快照又是世界状态的冻结。那么把历史持久化下来(第 10 章的 rollout 事件流),重放它就等于把 LOOP 倒带回任意一轮的起点——中断恢复、进程重启后续跑、从历史某点分叉,都是同一个机制的不同用法。

其二,缓存前缀天然稳定。 第 N 轮 LOOP 的请求 = 第 N-1 轮的请求 + 尾部追加(第 3 章)。历史只追加、不重写,请求前缀就逐字节稳定,prompt cache 才能命中。这也解释了为什么插话、状态更新都只能在步骤边界”尾部追加”式地进入历史——任何中途插入或改写都会同时破坏循环的自洽和缓存。

其三,它回答了第 4 章留下的问题:压缩摘要为什么必须放在历史最末尾? 因为模型每一轮 LOOP 开跑前读到的最后一样东西,就是它接手时看到的第一样东西。交接摘要放在尾部,它就是最新的观察、注意力的落点;放在开头,它会被随后成千上万行真实对话和工具日志淹没——模型对历史尾部的注意力远高于开头。同理,中途压缩时环境上下文插在”最后一条真实用户消息”之前、摘要保持最末位:交接文档必须放在工位上,而不是归档进档案柜。

类比:这个循环像一个”没有书签的读者”——每次重新打开书都从第一页读起,但永远只在最后一页之后动笔,所以”写到哪里”就是”读到哪里”。压缩就是把前面的章节换成内容提要,而提要必须誊抄在最后一页:读者动笔前最后看到的是它,下一位接手续写的人最先看到的也是它。

其四,确定性有了支点。 同样的历史 + 同样的步骤快照,组装出同样的请求(采样的随机性除外)。于是请求可以离线重算、行为可以复现,第 10 章的事件溯源就建立在这个前提上——循环是历史上的一个纯函数。这个确定性还有一个更贴近用户的红利:连 UI 都可以从事件流完整重建——打开旧线程、断线重连、界面快照测试,本质都是”重放事件”。5.9 节专门展开。


5.4 工具调用:并行执行,按序回灌

第一章结尾留过一个问题:轮次是串行的,为什么工具执行要做成并行?而”结果按调用顺序回灌”一旦被打乱,又会发生什么?

先看机制。工具调用 item 在流上成形的那一刻,执行 future 就被 spawn 出去,挂进 FuturesOrdered 队列;模型继续输出、后续工具继续挂入。流结束后统一收口:

sequenceDiagram
    participant M as 模型流
    participant Q as FuturesOrdered 队列
    participant T1 as 工具 A(慢,比如跑测试)
    participant T2 as 工具 B(快,比如读文件)
    participant H as 历史

    M->>Q: 工具 A 成形 → spawn A
    Q->>T1: 开始执行
    M->>Q: 工具 B 成形 → spawn B
    Q->>T2: 开始执行(与 A 并行)
    T2-->>Q: B 先完成(结果暂存,等待)
    Note over M: 流继续输出 / 结束
    T1-->>Q: A 后完成
    Q->>H: 回收 A 的结果(按加入顺序,先 A)
    Q->>H: 回收 B 的结果(后 B)
    Note over H: 历史中的回灌顺序 = 模型发出调用的顺序

为什么要并行? 工具的时间几乎全花在 I/O 等待上(等进程、等网络、等用户审批)。三个工具串行跑要等三次,并行跑只等最慢的那一次。这是轮次内部唯一被显式开启的并发(第 1 章的”串行外壳,并发内核”)。

为什么回灌必须有序? 因为历史是观察的序列(5.3),而观察的顺序就是模型感知到的因果顺序:

  • 工具调用与结果必须成对、按序出现,规范化(第 4 章)和部分模型的协议都把这当作硬约束;
  • 顺序一旦按”谁先跑完谁先进历史”随机化,同样的模型回复就会长出不同的历史 → 请求不确定 → 服务端缓存前缀被破坏(第 3 章)、离线重放对不上(第 10 章)、模型行为在不同运行间发散;
  • 并行拿到的是延迟收益,有序保证的是正确性,两者正交。FuturesOrdered 让这两个目标互不妥协:等待时间互相重叠,观察顺序确定不变。

工具错误是观察,不是异常。 这是循环里最重要的错误观:工具执行失败(命令退出码非零、文件不存在、网络报错)时,执行 future 并不返回错误,而是产出一个 success=false 的工具结果 item——错误文本就是工具的输出内容;被用户中断的工具会回灌一条”aborted by user”;被审批策略拒绝的调用会得到一条解释性回复。模型在下一轮 LOOP 读到”这个动作失败了,原因是……”,它可以换参数重试、换工具、或者向用户报告。错误处理本身就是推理的一部分,harness 不替模型决定”失败之后怎么办”。只有 harness 自身的 bug 级故障才走致命错误路径(5.2 阶段五)。

审批挂起也不破坏这个结构:需要授权的工具在 future 内部挂起在一次性等待通道上(第 1 章),安安静静地等在队列里——它不阻塞其他工具执行,更不阻塞提交循环处理中断、审批应答等 Op。第 1 章说的”串行外壳、并发内核”能无缝咬合,靠的就是这种”future 挂起、消息唤醒”的协作:串行循环只负责派发和回收,真正的等待都发生在并发任务内部。


5.5 出口是”协商”出来的:模型说停,harness 可以反对

循环什么时候终止?朴素的回答是”模型给出最终回复时”。但在 Codex 里,停止是一个协商结果,不是模型的单方声明。模型拥有”提议停止”的权力,三类对手方拥有否决权:

1. 未读输入可否决。 模型这一轮给出了最终回复、end_turn=true,但采样期间用户插了话、或子 agent 来了信——needs_follow_up 依然为真,LOOP 再来一轮把输入消费掉。模型不能在”有未读消息”时下班。这保证了 steer(第 1 章)的语义:插话哪怕晚到一步,也一定会被看见。

2. 停止 hooks 可否决。 模型停在终点时,循环不立即结束,而是运行停止 hooks(第 11 章)。扩展有三种投票方式:

  • 否决并附指令:返回”不许停”+ 一段续跑指令(例如”测试还没跑”)。指令以用户消息的形式注入历史,循环继续——对模型而言,这和用户本人说了一句话没有任何区别;
  • 强制收尾:hook 也可以主动要求停止,用于外部判定”任务已完成”的场景;
  • 弃权:不表态,模型说停就停。

两个防滥用细节值得一提:续跑请求会带上”停止 hook 已经否决过一次”的标志,hook 据此自我克制,避免把轮次变成永不收尾的死循环;光喊否决却不给续跑指令的,循环发出警告后忽略——否决权必须附带建设性内容。根线程跑的是 Stop hook,thread-spawn 子 agent 跑的是 SubagentStop,内部合成的子 agent 则不跑用户 hook(第 7 章)。

3. 压缩不是出口。 中途压缩、滚动窗口之后 LOOP 直接回到起点继续(5.2)。第 4 章说过”换一本笔记本,世界并没有变”——换笔记本当然也不是下班。

中断走的是另一条路:协作式取消沿令牌树传播(第 1 章),循环以”中止”提前返回,正在跑的工具各自体面收场——被中止的工具还会把”aborted by user”写回历史,中断本身也只追加一条中断标记。轮次进入 Interrupted 状态,这是可恢复的非终态。

这套设计的哲学是决策与制衡分离:把”任务做完了吗”的判断权交给最懂任务内容的模型——任何硬编码的停止规则都比模型更蠢;把”还有没处理的输入""还有没满足的外部约束”的否决权交给掌握全局状态的 harness 和扩展。模型提议,harness 制衡。


5.6 循环为什么不会失控:预算封顶,失败响亮

一个自然的担忧:模型会不会无限循环?反复调工具、反复说话,把预算烧光?

Codex 的回答出人意料:LOOP 里没有”最多迭代多少轮”的硬上限。真正的约束是三层:

第一层:成本有硬边界。

预算作用范围触顶时的行为
上下文窗口单个压缩窗口提醒 → 模型自助开窗口 → 自动压缩(第 4 章三道防线)
token 预算提醒单窗口余额感知剩余低于阈值注入一次提醒;余额为零时可注入兜底指令,建议模型压缩收尾
rollout 预算整棵 agent 树共享的加权 token 预算致命错误:轮次以 BudgetLimited 中止(第 1 章),可恢复,等用户追加预算

注意思路:不数”迭代轮数”,而是直接给真正稀缺的资源(token / 钱)封顶。轮数是代理指标,长任务(大重构、多 agent 协作)迭代几十轮可能完全正当,误杀代价高;预算则精确对应风险敞口。

第二层:每一轮 LOOP 都在用户的中断按钮射程内。 协作式取消随时可用,steer 随时可注入,审批点天然是人工关卡(第 8 章)。人是最后的护栏——而且这个护栏不需要轮询,循环在每个边界都主动”抬头看世界”。

第三层:失败响亮,绝不静默卡死。 把循环里可能出的错按”谁处理”归一次类:

flowchart LR
    E["一次采样中的故障"] --> A{"可重试的流错误?
断流 / 软限流 / 网络抖动"} A -->|"是"| R["流重试循环:退避重发
对 agent 循环透明(第 3 章)"] A -->|"否"| B{"工具执行失败?"} B -->|"是"| T["回灌 success=false 结果
错误作为观察交给模型"] B -->|"否"| C{"致命错误?
超长 / 额度耗尽 / 非法请求 /
安全拦截 / 预算耗尽"} C -->|"是"| F["发错误事件,轮次结束
线程存活,历史保留现场"]

这里还藏着一个重试的幂等细节:流重试时请求从持久化历史重新组装,而已经执行过的工具调用 ID 会作为元数据随请求捎带,避免模型在重试中把同一个动作再执行一遍——“这一轮重跑”在语义上是重新调用模型,但副作用不重复。这与第 2 章”已完成的 item 不丢工作”互为表里。

三条合起来的效果是:循环要么向前推进,要么响亮地报告,然后把决定权交还给用户。它不会假装无事发生地空转,也不会因为一次失败把整个会话拖进坟墓——轮次失败不等于会话失败


5.7 三个循环,别搞混

Codex 里实际上跑着三个层级的循环,初读代码时极易混淆。它们各自的生命周期、职责和失败语义完全不同:

graph TD
    subgraph L1["① 提交循环(每个线程一个,严格串行)"]
        OP["Op 队列"] --> DISP{"路由分发"}
        DISP -->|"输入类"| TURN["spawn 轮次任务"]
        DISP -->|"控制/审批类"| CTRL["立即处理:中断 / 审批应答 / 刷新配置"]
    end
    subgraph L2["② agent 循环(本章:轮次内)"]
        S1["步骤:采样 + 流式"] --> S2["工具并行执行、按序回收"]
        S2 --> S3{"needs_follow_up?"}
        S3 -->|"是"| S1
        S3 -->|"否"| END["轮次完成"]
    end
    subgraph L3["③ 流重试循环(步骤内,第 3 章)"]
        R1["建立连接,发请求"] --> R2{"流的结果"}
        R2 -->|"可重试错误"| R3["退避等待(指数退避 + 抖动)"] --> R1
        R2 -->|"成功 / 致命错误"| R4["返回给 ②"]
    end
    TURN --> L2
    S1 --> L3
① 提交循环② agent 循环③ 流重试循环
生命周期与线程同寿一个轮次一次采样请求
职责串行分发 Op,无锁处理共享状态采样 → 行动 → 回灌,驱动任务前进屏蔽瞬时网络故障
并发姿态严格串行,不被轮次阻塞步骤间串行,步骤内工具并行纯串行重试
失败语义只有关闭会话才终止致命错误结束轮次,线程存活重试耗尽才把错误上抛

嵌套关系像三层渔网:③ 的失败在多数情况下永远不会被 ② 看见(重试成功了,用户只看到一条”重连中”);② 的致命错误结束轮次,但被 ① 兜住——线程回到空闲,下一个 Op 又能开启新轮次;① 是线程的生命线,只有关闭会话才终止。中断信号自上而下穿过三层(第 12 章可观测性会看到它在 trace 上的传播路径)。

另外,第一章提到的另外两种任务——压缩代码审查——内部复用的也是 ② 这个 agent 循环:压缩任务的输入是”给接手的模型写一份交接摘要”的提示词,审查任务有自己的结构化输出约束,但”采样 → 行动 → 回灌”的节奏完全相同。harness 里只有一种思考-行动的节奏,不同任务只是给这个节奏装不同的乐谱。


5.8 接缝哲学:循环体很笨,聪明都在边界上

把全章串起来回看,会发现一个显著的模式:循环体本身笨得惊人——读历史、发请求、收结果、追加、判断要不要继续。所有”聪明”的机制都住在循环的接缝(步骤边界和生命周期点)上:

  • 冻结在边界:步骤快照(模型、工具集、环境)在 LOOP 起点定格,采样中途世界静止;
  • 注入在边界:steer、子 agent 来信、世界状态差分、时间与预算提醒,都在边界进入历史;
  • 观测与干预在边界:hook 的挂载点几乎铺满了循环的所有接缝——
hook 挂载点位置能做什么
会话开始轮次起点、压缩之后、子 agent 启动注入开场上下文,或喊停
用户输入提交每批输入(含排空的插话)补充上下文,或拦截
工具调用前每个工具执行前阻止调用、修改参数(第 8 章策略判定也挂这里)
权限请求审批决策点自动应答审批
工具调用后工具结果回灌前观察结果、注入补充上下文
停止点模型提议结束时否决续跑 / 强制收尾(5.5)
压缩前 / 后压缩任务首尾中止压缩或补充内容

采样中途没有任何”变化点”:模型在这一轮 LOOP 里看到的世界是静止的。

这解释了前面章节一系列看似分散的设计为什么是同一种约束

  • steer 为什么排队到步骤边界,而不是打断在途采样?(第 1 章)
  • WebSocket 增量请求为什么要求非输入字段逐一全等?(第 3 章)
  • 世界状态为什么按差分快照注入、而不是每轮全量重发?(第 4 章)
  • 中断为什么沿令牌树协作式传播,而不是一枪毙掉?(第 1 章)
  • 工具结果为什么宁可等待也要按序回灌?(5.4)

答案是同一个:循环体必须是一个纯函数——同样的历史加同样的快照,产生同样的请求;所有变化、所有策略、所有外部影响,都在边界处显式进出。 纯函数的循环体才可以被安全地重试(重发结果不变)、被缓存(前缀稳定);而”被重放、被测试”这两个性质在 UI 一侧还有一个孪生版本——界面本身也是事件流的纯函数,下一节 5.9 展开。模型的”自由”全部在它的输出里,而 harness 的”确定”全部在边界的纪律里。这正是第一章”快照驱动”原则在循环层面的完整含义。


5.9 事件回放:UI 也是事件流上的一道折叠

5.3 讲过 LOOP 可以从历史任意一轮重建——那是 agent 侧的回放:把事件日志重放成模型可见的上下文(第 10 章)。但”回放”还有第二个消费者,而且它离用户最近:UI

想想这些场景:打开一个三天前的旧线程、网络断开后重连、第二个客户端在对话中途加入、TUI 的自动化测试。它们看到的界面都不是”接着直播”看下来的,而是从事先存好的事件流里复盘出来的。直播和复盘是同一回事,这个事实值得单独说清楚。

界面状态 = 事件流的折叠

函数式编程里有一个经典模式叫折叠(fold / reduce):一个初始状态,加上一条事件流,把事件逐条”折叠”进状态,最终得到当前状态。

界面状态 = 折叠(初始空状态, 事件序列)
  • 直播时:事件实时到达,来一条折叠一条,界面逐帧更新;
  • 回放时:从持久化日志里取出事件序列,从头到尾折叠一遍,得到的应该是同一个界面状态

关键在于:这两次折叠用的是同一个折叠函数。服务端跟踪运行中线程的”当前轮次/当前 item”用的是它,把旧日志物化成历史结构用的也是它——直播和回放不是两套代码,否则它们迟早会算出不一样的结果。

flowchart LR
    subgraph LIVE["直播"]
        E1["实时事件流
turn/* · item/* · delta"] --> FOLD["折叠函数
(同一个)"] end subgraph REPLAY["回放"] E2["持久化事件日志
(第 10 章 rollout)"] --> FOLD end FOLD --> UI["界面状态
(同一份)"]

回放契约:什么进日志,什么不进

回放能还原出直播时的界面,靠的是前面章节埋好的五个前提:

  1. 事件陈述事实,不发布指令(第 2 章)。折叠是没有副作用的计算,同一条事件折叠两遍结果不变,重放才安全;
  2. 终态进日志,瞬时态不进。第 2 章把事件分成”权威的 item”和”易失的 delta”,这个区分在持久化层落成一条硬边界:只有 item 完成、轮次边界这类终态事件写入日志;delta、打字机动画、审批弹窗、警告条、进度提示这类”直播画面”一律不写。回放重建的是比分牌,不是比赛录像——对话内容、执行过的命令、改动的文件都在,但命令逐行滚动的过程、中途弹过的审批框不会重演;
  3. item 成形即落日志(5.2 阶段三)。每个完成的 item 在成形那一刻就写入并持久化,早于工具执行、早于流结束——即使轮次随后被取消,日志也保持完整,回放永远不会拼出半个 item;
  4. 顺序确定(5.4)。工具结果严格按调用顺序回灌,日志因此是一条全序事件流,折叠结果唯一,不会”每次复盘局面都不一样”;
  5. 循环是纯函数(5.8)。事件流本身是历史的确定产物,没有隐藏的外部状态在回放时缺失。

对照一下哪些事件直播时有、回放时无:

事件直播时回放时
轮次开始/完成、item 完成(消息、命令、改动、压缩……)实时渲染持久化,回放重建
item 开始、各类 delta(文本、推理、命令输出、补丁预览)打字机/进度效果不持久化,不重演
审批/提问请求(反向请求)弹窗等待应答历史的弹窗不重现;但仍未决的请求会重放给新连接——否则无人应答,轮次会永远挂起(第 2 章/A.10)
token 用量面板更新作为快照补发给当前连接
警告、安全缓冲、模型改道、MCP 启动进度提示条不持久化

回放的传输形式:返回事实,而不是重发通知

一个容易想错的实现是”把历史通知再发一遍”。Codex 没有这么做:历史是物化成结构化的轮次/item 数据,内联在请求响应里返回的(读线程、恢复线程时直接带上历史 turns/items,也可以分页拉取)。折叠已经在服务端做过一遍,前端拿到的是答案而不是习题。

为什么要区分?因为通知的语义是”此刻发生了一件事”,而历史是”过去的事实”。把历史当通知重发会诱发重复副作用:每重放一次审批就弹一次窗、每重放一条命令就刷一次终端。事实查询走数据返回,事件推送走通知,两条通道各管各的。唯一的例外是那两类”重放了才有意义”的东西:连接级状态快照(token 用量,补发一条通知)和仍然有效的未决交互(没被回答的审批弹窗——它不属于历史,它是”现在还在等你”的动作)。

回放的第三用途:界面快照测试

回放不只服务用户,也服务测试。TUI 的自动化测试就是事件回放:构造一串事件(用户发消息、工具执行、命令审批请求……)喂给界面,然后把整屏渲染结果截下来与基准快照逐字符比对。因为界面是事件流的纯函数,同一份事件序列必然渲染同一屏。

这和 5.3 的”同样的历史必然产生同样的请求”是同一种确定性,只是从模型侧搬到了渲染侧

模型侧(LOOP)渲染侧(UI)
输入历史 + 步骤快照事件序列
纯函数循环体(5.8)折叠函数
输出采样请求界面帧
回放的用途中断恢复、分叉、事件溯源(第 10 章)历史重建、断线重连、快照测试

直播、复盘、测试三者共用同一条折叠路径,带来一个质量红利:测试覆盖到的渲染,就是用户复盘旧线程时实际会看到的渲染——回放路径不需要专门维护,它每天被测试用例跑成百上千遍。

类比:棋谱。棋盘是状态,棋谱是事件流。直播时观众看着棋子一颗颗落下;复盘时从空棋盘按谱重摆,必然摆出同一个局面——因为每一步只依赖”之前所有步”,不依赖观众当时的心跳声。落子时棋手的手势、观众的惊呼不写进棋谱,棋谱只记”谁在何时落了哪一子”。delta 是手势,item 是落子;回放重摆棋盘,而不是重演手势。


5.10 小结:LOOP 的六条设计原则

  1. 循环极简,复杂性外移。 agent 循环的本质是十行伪代码:采样、行动、回灌,直到模型不再调用工具。重试、压缩、审批、预算、hook 全部作为护栏挂在循环周围的接缝上,而不是写进循环体。新增能力的默认位置是”边界上的一个新护栏”,不是”循环里的一个新分支”。

  2. 历史即状态,尾部即当前。 LOOP 没有程序计数器,位置由历史尾部隐式表达:每一轮从完整历史开始,在尾部追加结束。缓存前缀稳定、事件溯源重放、压缩摘要尾置、分叉恢复,全部是这一事实的推论。

  3. 并行执行,按序回灌。 工具调用成形即并行 spawn,结果严格按调用顺序回灌(FuturesOrdered):并行吃掉 I/O 延迟,按序保住因果与确定性。工具错误是回灌给模型的观察(success=false),不是中断循环的异常——错误处理是模型推理的一部分。

  4. 终止靠协商,失控靠预算。 模型提议停止,待处理输入和停止 hooks 可否决;循环不设迭代次数上限,而是给真正稀缺的资源封顶——窗口到限有压缩,rollout 预算超限是致命中止;瞬时故障重试、工具故障回灌、致命故障响亮地结束轮次但线程存活。轮次失败不等于会话失败。

  5. 变化只发生在边界。 快照冻结、输入排空、差分注入、hook 表决全在步骤边界;循环体是”历史 + 快照 → 请求”的纯函数,因此可重试、可缓存。agent 的自由在模型输出里,harness 的确定在边界纪律里。

  6. 事件流即可回放日志。 循环吐出的事件既是直播画面也是复盘棋谱:界面状态是事件流上的一道折叠,直播与回放共用同一个折叠函数;终态 item 进日志、delta 等瞬时态不进,回放因此重建的是”结果”而非”过程”;历史以数据内联返回而不是重发通知,未决审批是唯一重放的交互。同一种确定性让 UI 快照测试成为事件回放的第三用途。

留给读者思考的几个问题

  • 工具”并行执行、按序回收”意味着先调用的慢工具会让后完成的快工具结果干等。这个等待在什么情况下代价很大?如果要进一步优化,哪条设计约束会拦住你?(→ 第 6 章)
  • 停止 hooks 可否决模型的停止决定,但 harness 只给了”已否决过一次”的标志而不是硬性次数上限——为什么把克制权交给 hook 自己?什么情况下 hook 会希望连续否决多轮?(→ 第 11 章)
  • 循环不设迭代上限、只靠预算约束。如果模型陷入”反复调用同一个失败工具”的死循环,现有机制里哪些会最先触发?预算是防住这种情况的最优手段吗,还缺什么信号?(→ 第 6、8 章)
  • 一次跑到一半出错的轮次,事件流里应该留下什么,才能让用户”下一句话接着处理”成为可能?这对持久化格式提出了什么要求?(→ 第 10 章)
  • 用户按下中断时,信号要依次穿过流重试循环、agent 循环、提交循环——三层各自”看到取消”后的正确反应是什么?为什么三层都不能简单地立即死亡?(→ 第 12 章)
  • 回放历史时选择”内联返回数据”而不是”重发通知”。如果反过来做,审批这类反向请求会发生什么?又为什么 token 用量和未决审批可以、甚至必须以通知重放?(→ 第 2 章、附录 A)

下一章我们进入工具系统:模型能调用的工具从哪里来、工具清单如何在步骤快照中冻结、一次工具调用从参数到结果要经过哪些关卡(路由、审批、沙箱、hook),以及工具的 spec 如何设计才能让模型”用得对”。

分类:Agent Harness标签:#agent #harness #codex