持久执行
目的
为什么智能体必须在行动之前,先记录下自己即将做什么?
长时间运行的智能体,其运行寿命会超过启动它的机器。主机会崩溃,工作进程会被抢占,部署会重启进程,而为了等待人工批准而暂停的轨迹,可能需要等待数天才能得到答复。当这种情况发生在第四十轮时,从原始提示重新开始并不能恢复任务,因为每次模型调用都会产生一个新的样本,重新运行会沿着不同的路径推进;而且,前四十轮中的某些轮次已经通过发送消息、合并分支或配置服务器改变了外部世界,重新运行会再次造成这些改变。能够保留下来的工作,恰好就是运行时在崩溃之前按正确顺序记录下来的工作。持久执行是一套维护这种记录的方法,使轨迹能够恢复执行、为调试而回放,或迁移到另一台机器,同时避免重复产生已经造成的效果;而同一份记录随后也会成为评估和训练所读取的证据。从 H·S·A 的视角看,长时间跨度使状态成为持久性问题,而已使用的权限则成为恢复过程中不能重复偿付的债务。
学习目标
- 设计轨迹日志,使其中的事件(消息、工具调用 ID 及结果、模型与采样元数据、压缩事件、批准记录)足以重建轨迹记录。
- 应用“先记录意图、后产生效果”的顺序约束,将崩溃后的每项已记录操作归类为未发送、已确定或状态存疑。
- 根据检查点成本和故障率,选择以轮次为单位的检查点间隔,并说明检查点必须捕获哪些日志无法捕获的内容。
- 解释恢复执行的轨迹如何重建上下文、重新预填充会产生哪些成本,以及恢复执行如何处理待批准事项、迟到的工具结果和模型、提示或工具模式的漂移。
- 构建使用桩替代模型和工具的回放模式,并将其用于时间旅行调试、反事实分支和智能体运行框架回归测试。
- 诊断重新运行为何偏离其记录,区分模型侧、智能体运行框架侧和环境侧的原因。
- 设计保留策略,在将体积庞大的工具输出转移到外部存储的同时,保留轨迹的因果记录。
轨迹日志
一条轨迹正处于云迁移任务的第四十轮。智能体运行框架刚刚发送了创建数据库副本的请求,主机便在响应到达之前断电。当另一台主机接手这条轨迹时,三个问题决定了恢复是有益还是有害。截至第三十九轮,运行时提出、授权和观察到了什么?创建副本的请求是否可能已经离开这台机器?如果已经发出,它是否生效?轨迹记录 中的轨迹记录无法回答其中任何一个问题,因为它存放在发生故障的主机内存中。在较长的时间跨度内,轨迹所携带的状态会成为持久性问题(H·S·A 暴露面),因为轨迹的运行寿命通常超过启动它们的机器(时间跨度拉伸:从纳秒指令到千秒级轨迹)。
最直观的恢复方法是将原始提示重新输入模型,但这种方法行不通。每次模型调用都会采样,而且即使温度为零,模型服务系统也不保证两次生成相同的词元(自回归生成),因此重新运行可能在第三轮选择不同的工具,并在第四轮选择不同的一组文件。更糟的是,重新运行无法知道前四十轮中哪些轮次已经改变了外部世界。第二次创建副本的请求会创建第二个副本。因此,恢复需要一份由运行时在崩溃之前写下的记录,逐轮说明发生了什么。
以日志为事实依据
运行时将这份记录保存为只追加的轨迹日志,它是一个由带类型的事件构成的有序序列,运行时从不对其进行原地修改:
\[\mathcal{E} = [e_1, e_2, \dots, e_t]\]
每个事件记录轨迹的一个事实,例如上下文被组装、模型作出回复、运行时授权一次工具调用,或结果返回。智能体运行框架 中的实时轨迹记录(状态、预算、待批准事项、当前计划)不再独立存储。它是智能体运行框架通过对日志执行折叠运算计算出的投影:从初始记录 \(R_0\) 开始,对每个事件应用一个确定性的更新函数:
\[R_t = \text{fold}(R_0, \mathcal{E}) = \text{apply}(\dots\text{apply}(\text{apply}(R_0, e_1), e_2)\dots, e_t)\]
图 1 展示了这两个部分。更新函数是普通的智能体运行框架代码,不包含 I/O,因此对同一份日志执行折叠运算,总会得到同一份记录。崩溃后的恢复过程读取日志,并再次执行折叠运算。它不调用模型,也不调用工具,因为所需的每个模型回复和工具结果都已经记录为事件(原则 13)。这种设计模式称为事件溯源,在数据系统中已有悠久历史 (Kleppmann 2016年)。它尤其适合智能体,因为唯一无法重新计算其输出的组件——模型——恰好就是日志保存其输出的组件。
同样的划分也将日志与上下文窗口区分开来。上下文是下一次模型调用所读取的内容。它由日志组装而成,并且有意允许信息损失。压缩(上下文压缩)会对较早的轮次进行摘要,截断(观测流截断)会裁剪较长的工具输出。日志保留了上下文丢弃的内容。将上下文视为轨迹记录,是丢失轨迹记录最常见的原因,因为事后分析所需的证据,恰恰就是压缩过程中移除的内容。
日志记录什么
只有每个事件都携带后续读取者所需的信息,日志才能用于恢复。表 1 列出了单智能体轨迹所需的事件类型,以及每种类型存在的原因。
| 事件 | 写入时机 | 关键字段 | 恢复或重放如何使用该事件 |
|---|---|---|---|
TaskStarted |
仅一次,在第一轮之前 | 任务规范、模型标识符及版本、采样参数、提示模板版本、工具模式版本、工作区快照 ID | 固定恢复或重放必须匹配的版本,并定义初始工作区 |
ContextBuilt |
每次模型调用之前 | 按顺序排列的消息引用、检索项、组装后上下文的哈希值 | 使恢复过程能够重新构建完全相同的上下文,并检测重新组装是否会产生差异 |
ModelReplied |
每次模型调用返回之后 | 原样回复、工具调用及其工具调用 ID、停止原因、token 数量、实际提供服务的模型版本 | 保存无法重新生成的采样结果的唯一副本 |
Authorized |
策略和预算检查之后 | 工具调用 ID、决策(允许、拒绝、等待审批)、策略版本、剩余预算 | 重建预算,并说明某项动作获准执行的原因 |
ApprovalRequested, ApprovalResolved |
调用被暂缓时,以及人工作出答复时 | 工具调用 ID、审批人、决策、失效时间 | 使暂停的轨迹能够跨越等待期,并依据同一决策恢复执行 |
ToolIntent |
授权之后、派发之前,以持久方式写入 | 工具调用 ID、工具名称、参数、幂等键 | 标记某种外部效果可能已经发生,使恢复过程能够查明并处理,而不必猜测 |
ToolResult |
工具返回时 | 工具调用 ID、状态、输出(或其哈希值及指向完整内容的指针)、耗时 | 在重放时替代工具执行,并使意图闭环 |
Compacted |
上下文被摘要时 | 涵盖的轮次、实际生成的摘要文本 | 使恢复过程能够复用已记录的摘要,而不是生成不同的摘要 |
Checkpoint |
检查点完成持久化之后 | 涵盖的日志位置、记录快照 ID、工作区快照 ID | 限定重启时必须读取的日志量 |
这一模式的大部分价值来自三个设计选择。工具调用 ID 将同一动作贯穿于提议、授权、意图和结果,使恢复过程能够针对任意动作检查这四个事件中哪些已经存在。版本字段将“模型”和“提示”从不断变化的对象转化为已记录的事实,而恢复和重放都依赖这些事实。Compacted 事件记录实际生成的摘要,因为对相同轮次再次进行摘要就是另一次采样,会使恢复后的轨迹对自身过去形成不同的记忆。
有了日志,在不变量闭包(原则 不变闭包原则)的约束下,运行时不再依赖模型对自身历史的任何陈述。发生了什么,以日志的记载为准。这一断言只有在外界能够依据事件采取行动之前,每个事件都已写入持久存储时才成立;对于其中一种事件,运行时写入与行动的先后顺序决定了恢复是否安全。
意图先于效果
回到第四十轮的资源配置请求。智能体运行框架已经授权该请求,现在必须做两件事:写入一条记录,表明请求即将发出;通过网络发送该请求。如果先派发请求,而主机在记录被持久刷新到非易失性存储之前断电,外部资源就会在实际云环境中完成分配,但日志中完全没有相关记载。重启或迁移后,恢复的智能体运行框架检查日志,发现一个已授权的调用,却找不到任何派发迹象,于是认定该步骤从未执行,并再次发出资源配置请求。
幽灵效果
智能体的工具执行在外界造成了外部状态变更,却没有任何持久日志记录描述这一变更,因为主机在网络派发之后、磁盘屏障将意图持久化之前发生了故障。
防止这一故障模式的基础不变量是预写日志(WAL)纪律,它是意图先于效果原则(13)在系统中的实现。在授权并明确指定该动作的 ToolIntent 事件被持久提交到非易失性介质之前,任何外部变更操作都不得越过主机边界。形式化地说,对于每一次改变外界状态的工具执行,运行时都强制遵循以下严格的因果顺序:
\[e_{\text{prop}} \prec e_{\text{auth}} \prec e_{\text{flush}} \prec a_{\text{ext}} \prec e_{\text{obs}} \tag{1}\]
其中,\(e_{\text{prop}}\) 表示从模型生成内容中解析出的候选工具调用,\(e_{\text{auth}}\) 记录监督器对策略和能力的审批,\(e_{\text{flush}}\) 标记物理存储屏障的完成,确认数据已持久落盘,\(a_{\text{ext}}\) 表示向外部工具或沙箱发送网络 RPC,\(e_{\text{obs}}\) 则记录返回的观测结果。
屏障与因果转换的物理执行顺序如 图 2 所示。
公式 1 中的关键约束是存储屏障 \(e_{\text{flush}} \prec a_{\text{ext}}\)。在操作系统编程中,调用 write() 仅会将序列化后的字节从用户空间复制到内核的易失性页缓存中。如果字节仍驻留在易失性 DRAM 中时发生断电,脏页将永久丢失。要保证持久性,必须通过 fdatasync() 或直接 I/O 显式执行硬件屏障,相关对比如 POSIX I/O 持久化接口与持久性保证 所示。
| 接口 | 缓存语义 | 内核返回条件 | 返回时的 NVMe 状态 | 对 \(e_{\text{flush}} \prec a_{\text{ext}}\) 是否安全? | 典型延迟(\(\mu\text{s}\)) |
|---|---|---|---|---|---|
write() |
标准页缓存 | 已复制到内核 DRAM | 易失/尚未写入 | ❌ 不安全(崩溃时数据丢失) | \(1 - 2\) |
write() + O_SYNC |
同步页缓存 | 已写入驱动器缓存 | 控制器 DRAM/未知 | ⚠️ 有条件安全(取决于 FUA) | \(150 - 500\) |
write() + fdatasync() |
显式刷写 | 驱动器已确认 | 已提交至闪存单元 | ✅ 安全(元数据开销最小) | \(80 - 150\) |
write() + fsync() |
显式完全同步 | 驱动器已确认 | 已提交至闪存单元 | ✅ 安全(更新全部 inode 属性) | \(100 - 250\) |
组提交(fdatasync) |
批量向量化刷写 | 整批已获确认 | 已提交至闪存单元 | ✅ 安全(按事件摊销) | \(\frac{85}{k} + \frac{W}{2}\) |
崩溃后的三种状态
公式 1 中的顺序约束限制了崩溃后可能留存的记录组合,保证恢复过程发现的每个改变外部世界的动作都处于三种确定状态之一(已记录动作的恢复状态)。
| 日志中的记录 | 状态 | 恢复操作 |
|---|---|---|
没有持久化的 ToolIntent |
未发送 | 调用从未离开主机。该轮次可以重新执行;如果已记录模型回复,则从该回复开始执行。 |
ToolIntent 和 ToolResult |
已结算 | 结果已知。重放与恢复执行使用已记录的结果,绝不重新分发调用。 |
有 ToolIntent,无 ToolResult |
存疑 | 请求可能从未到达,也可能已提交但只有回复丢失。任何重试之前都必须先完成结算。 |
预写顺序无法神奇地消除第三种状态,即存疑动作。它所保证的是,存疑状态会显式可见。重试前先结算(原则 9)利用 幂等动作执行 中的机制解决这一问题:
- 幂等键:恢复管理器使用存储在
ToolIntent事件记录中的同一个幂等随机数,重新发出工具请求。如果工具服务器已经处理过先前的请求,就会返回缓存的结果,而不会重复产生副作用。 - 对账探测:与不具备原生幂等性支持的外部 API 交互时,恢复管理器会执行读取副作用状态的探测(例如,查询数据库副本是否存在),以确定变更是否已经发生。
- 监督升级处理:如果环境状态仍无法确定,监督器会停止执行并升级至人工审查(审批关卡),而不是冒险盲目重试。
持久性的代价:组提交摊销
在每次工具执行的关键路径上强制设置同步 fdatasync() 屏障,会引入非易失性介质延迟。虽然本地闪存上的一次 NVMe 刷新耗时介于 \(80\) 与 \(150\ \mu\text{s}\) 之间,但网络附加块存储卷(如 AWS EBS 或 Google Persistent Disk)每次屏障操作的耗时为 \(2\) 至 \(15\ \text{ms}\)。在共享节点上承载数十或数百条并发智能体轨迹的多智能体环境中,串行执行刷新会导致严重的排队竞争。
解决这一瓶颈的标准系统机制是组提交 (DeWitt 等 1984年),详见 表 2。并发智能体工作线程不再各自独立执行 fdatasync(),而是将已授权的事件记录加入无锁多生产者环形缓冲区,并在用于完成通知的 futex 上挂起等待。
| 流水线阶段 | 运行机制 | 延迟特征 | 数据结构与 I/O 原语 |
|---|---|---|---|
| 1. 接收 | 并发智能体线程将动作提案(\(e_{\text{prop}}, e_{\text{auth}}\))序列化并推入内存暂存区;线程在完成通知 promise 上挂起等待。 | 接近零(\(\le 5\ \mu\text{s}\)) | 无锁多生产者环形缓冲区(MPMC) |
| 2. 聚合 | 当批次大小达到 \(B_{\max}\)(例如 64 条记录)或超时时限 \(W\)(例如 2 ms)到期时,专用刷新协调器排空待处理队列。 | \(0.1 - 2.0\ \text{ms}\) | 向量化分散—聚集内存数组 |
| 3. 向量化写入 | 通过一次分散—聚集系统调用,将聚合后的事件记录写入存储设备。 | \(0.2 - 0.8\ \text{ms}\) | POSIX writev() / Linux io_uring 提交 |
| 4. 存储屏障 | 主机内核强制磁盘控制器将控制器缓存中的脏块提交至非易失性闪存介质。 | \(2.0 - 15.0\ \text{ms}\) | fdatasync() / NVMe 刷新命令 |
| 5. 屏障释放 | 协调器通知所有用于挂起等待的完成通知 promise;等待中的智能体线程并行恢复运行,以派发工具调用。 | 接近零(\(\le 10\ \mu\text{s}\)) | 原子 futex 广播 / 条件变量唤醒 |
我们对每轮的有效存储同步延迟 \(t_{\text{eff}}\) 建模,假设授权事件服从总到达率为 \(\lambda\) 的泊松到达过程。若组提交协调器采用聚合窗口 \(W\) 和最大批次容量 \(K_{\max}\),则每次屏障分摊的事件数量的期望值由 公式 2 给出:
\[\bar{k} = \min\left(K_{\max},\, \max\left(1,\, \lambda W\right)\right) \tag{2}\]
单个智能体线程所经历的有效延迟,包括缓冲区中的预期排队延迟,以及由 \(\bar{k}\) 个事件分摊的物理屏障耗时,其形式化表达见 公式 3:
\[t_{\text{eff}} = \frac{W}{2} + \frac{t_{\text{writev}}(\bar{k}) + t_{\text{sync}}}{\bar{k}} \tag{3}\]
随着轨迹并发度提升(\(\lambda \to \infty\)),窗口 \(W\) 可逐步缩小至接近零,从而将物理同步开销 \(t_{\text{sync}}\) 分摊到 \(K_{\max}\) 条轨迹上,使每轮的屏障开销降至可忽略的水平,同时不违反任何单个智能体的因果预写不变量。
Checkpoint 0.1: 预写顺序
继续阅读之前,请检查自己能否回答以下问题:
检查点
一个编程智能体正处于第三百轮。其日志记载:在第 212 轮,编辑工具重写了 parser.py;在第 250 轮,shell 工具删除了一个构建目录。对日志进行折叠处理可以重建轨迹记录,却无法重建 parser.py。日志保存了工具调用及其结果;这些调用生成的文件存放在沙箱工作区(智能体工作区)中,如果承载该工作区的主机因硬件故障而崩溃,工作区也会随之丢失。重放日志同样无法重新创建修改后的文件,因为重放会有意以桩实现替代工具执行。
检查点捕获的内容:全量编码与增量差分编码
检查点是一份持久化快照,涵盖仅凭日志无法以低成本重建的所有内容,并绑定到其所对应的精确日志位置。它由三个部分组成:
- 记录快照:序列化的轨迹记录 \(R_k\),对应第 \(k\) 轮,使重启时无需从 \(R_0\) 开始归并整个日志。
- 工作区快照:第 \(k\) 轮的沙箱文件系统,通过写时复制快照机制捕获(智能体工作区)。
Checkpoint事件:仅在两个快照均已持久保存到非易失性介质后,才追加到日志中。
架构上的权衡在于监督器采用完整状态快照还是增量差分快照。在长时程任务中,随着代码差异和执行轨迹不断累积,上下文缓冲区单调增长。如果由结构化词元和元数据构成的上下文达到 \(128\text{ KiB}\),每五轮提交一次完整快照,在一条 500 轮的轨迹中就会迫使存储引擎写入:
\[\text{Total Bytes Written} = \sum_{k=1}^{100} k \cdot \Delta_{\text{turn}} \approx 50 \cdot 100 \cdot 128\text{ KiB} \approx 640\text{ MiB}\]
写放大系数(\(\text{WAF}_{\text{snap}}\)): \[\text{WAF}_{\text{snap}} = \frac{\text{Bytes Written to Disk}}{\text{Unique State Changes}}\] 对于单调增长轨迹上的完整快照,\(\text{WAF}_{\text{snap}} \propto t\);而对于增量差分编码,\(\text{WAF}_{\text{snap}} \approx 1\)。
增量差分编码通过仅序列化自上一个检查点以来发生的差异性变更来缓解这种写放大,其形式化定义见 公式 4:
\[\Delta_t = \mathbf{S}_t \ominus \mathbf{S}_{t - \tau} \tag{4}\]
其中,\(\tau\) 为检查点步长。运行时无需重新序列化整个提示缓冲区,而是仅记录新追加的消息词元、更新后的预算寄存器和修改过的文件系统 inode(表 3)。
| 架构维度 | 完整状态快照 | 增量差分快照 | 写时复制(CoW)内存分叉 |
|---|---|---|---|
| 序列化开销 | 高;随上下文大小线性增长 \(\mathcal{O}(\Vert \mathbf{S}_t \Vert)\) | 低;严格受窗口内变更量限制 \(\mathcal{O}(\Vert \Delta_t \Vert)\) | 暂停时间接近零;复制页表 \(\mathcal{O}(\text{RSS})\) |
| 存储放大 | 在长轨迹中呈现严重的二次增长 | 极小;存储独有的状态转换 | 中等;与脏内存页总量成正比 |
| 崩溃恢复时间 | 极短 \(\mathcal{O}(1)\);单次读取并即时解包到内存 | 中等 \(\mathcal{O}(\tau)\);需要从基准状态开始应用差分链 | 极短 \(\mathcal{O}(1)\);重新映射后备存储或页文件 |
| 日志压缩影响 | 简单;可裁剪 \(t\) 之前的所有历史快照 | 复杂;需要定期合并基准状态以裁剪日志 | 中等;需要后台页面合并与清理 |
| 隔离屏障 | 高;与运行中进程的内存空间完全解耦 | 中等;依赖持续的重放引擎验证 | 受进程约束;与本地主机内核的内存语义绑定 |
检查点开销与重新执行恢复时间之间的权衡如 图 3 所示。
崩溃的代价与检查点的保存频率
有了检查点,恢复过程会恢复最新的检查点,并仅归并其后的日志后缀。局限于沙箱内的效果,例如差异已记录在日志中的文件编辑,可以根据记录的结果重新应用。对于日志无法重现的效果,则必须做出选择:回滚到检查点并重做局部轮次,或升级处理。
这一代价决定了检查点的保存间隔。设 \(\delta\) 为保存检查点导致轨迹暂停的时间,\(M\) 为主机或工作进程的平均故障间隔。每 \(T\) 秒保存一次检查点,会将运行时间的 \(\delta / T\) 用于保存检查点,并在每次故障时平均损失 \(T/2\) 的工作量;经典的一阶 Young 最优公式对二者进行平衡 (Young 1974年):
\[T^{\ast} \approx \sqrt{2\,\delta\,M} \tag{5}\]
考虑二阶重启开销 \(\delta_{\text{rec}}\) 后,Daly 的高阶公式 (Daly 2006年) 对 公式 5 进行修正,其中引入 公式 6:
\[T_{\text{opt}}^{\text{Daly}} = \sqrt{2 \cdot \delta \cdot \left(M + \delta_{\text{rec}}\right)} + \delta \cdot \left(\frac{1}{3}\sqrt{\frac{2\delta}{M}} - 1\right) \tag{6}\]
粗略估算 0.1: 选择以轮次计的检查点间隔
间隔:当 \(\delta\) = 0.5 秒且 \(M\) = 14,400 秒时,由 公式 5 可得 \(T^{\ast} = \sqrt{2\,\delta\,M}\) = 120 秒,即 10 轮。
开销:运行期间因暂停和丢失工作而耗费的时间占比为 \(\delta/T + T/(2M)\)。在最优间隔下,该占比为 0.83 percent。每轮创建一次检查点会使其升至 4.2 percent,其中几乎全部来自暂停;每 100 轮创建一次检查点也会产生 4.2 percent 的开销,其中几乎全部来自丢失的工作。
崩溃成本:任何一次恢复都需要对重建的上下文执行一次冷预填充,成本约为 $0.12。除此之外,一次崩溃平均会丢失半个检查点间隔的工作。在最优间隔下,这相当于 5 轮,约 1 分钟,以及在前缀缓存已预热的情况下 $0.09 的词元成本。如果改为每 100 轮创建一次检查点,一次崩溃会丢失 50 轮,约 10 分钟,成本为 $0.86,而且每个重做的轮次都是一次新的采样,可能偏离日志所记录的路径。
系统层面的洞见:开销曲线在最小值附近较为平坦,而在两端较为陡峭,因此间隔只需控制在最优值的约两倍或一半范围内即可。决定间隔变化的是检查点成本与故障率之比。较慢的快照存储会增大 \(\delta\),使最优间隔延长;更不稳定的工作节点池会缩短 \(M\),使最优间隔缩短。一次崩溃的金钱成本很小;重做轮次更大的代价在于它们是新的采样,这也是日志保留模型已经生成的每条回复的原因。
无论公式给出什么结果,以下四个时刻都需要创建检查点,因为它们都是轨迹之后可能需要返回、或绝不能丢失的节点:
恢复轨迹
本章开头的轨迹现在将在一个新的工作节点上恢复。原来的主机可能已经崩溃、被抢占,或因部署而被清空任务;也可能只是轨迹暂停了两天,等待某个人批准一项生产环境变更(审批关卡)。无论哪种情况,接续轨迹的工作节点启动时都只有日志、最新的检查点,以及不得重复执行某个效应的规则。恢复 是将这些转化为运行中轨迹的协议:
- 取得所有权。 使用隔离令牌获取轨迹的租约,确保其他工作节点无法代表该轨迹执行操作(见下文)。
- 还原。 加载最新检查点中的记录和工作区快照,然后将日志后缀归并到记录中。
- 结清。 按照 节 2 所述,在派发任何新操作之前,解决所有状态未决的意图。
- 重建上下文,根据日志重建上下文并执行预填充。
- 继续,在下一个轮次边界继续循环,并采用记录所指示的轨迹状态。
步骤 2 和 3 复用了前两节的机制。步骤 1 和 4,以及使这两个步骤变得复杂的情形,则是恢复过程特有的。
重建上下文
模型在调用之间不保留任何信息,因此恢复运行的工作进程必须向它提供上下文。工作进程使用轨迹此前采用的相同组装规则,从日志中重新推导出该上下文(编排下一次调用):已记录的消息、按交付时的原样记录的工具结果,以及替代相应轮次的已记录 Compacted 摘要。ContextBuilt 哈希使运行框架能够确认,重新组装的上下文与模型在崩溃前最后看到的上下文一致。不一致意味着组装规则或其输入发生了变化,这属于漂移(见下文),而非恢复。
随后,必须再次对重建的上下文执行预填充。旧模型服务会话持有的键值状态由令牌前缀推导而来,并未保留下来(原则 5);新的调用也可能被分配到一台前缀缓存从未见过该轨迹的服务器上(跨轮次前缀缓存)。因此,恢复需要承担一次完整上下文的冷预填充开销。对于长上下文,这意味着数秒的延迟和数美分的输入令牌费用(加速器服务延迟;注意力状态的大小);相较于重做各轮次,这些开销很小,后者需要数分钟的模型调用和数美元的费用,并使每个结果尚未确定的外部影响都面临风险。两种做法可以降低这一开销。将恢复的轨迹路由到仍持有其前缀的服务器,可将冷预填充转化为缓存命中;逐字节按原样重新组装上下文,则可保持前缀完全一致,使任何仍然存在的缓存条目都能被利用。
暂停、审批与迟到的结果
为等待审批而暂停,是运行框架预先计划的一次恢复。当调用被挂起时(审批关卡),运行框架写入 ApprovalRequested,创建检查点,并完全释放工作进程及其模型服务状态,因为将二者保留数小时会造成资源浪费。答复到达后,运行时写入 ApprovalResolved,并通过相同的五个步骤恢复轨迹。审批关卡自身的规则,例如有效期以及在派发前重新检查操作的前置条件,会在恢复后执行,依据的是当前的外部环境状态,而非发起请求时的状态。
长时间运行的工具会产生相反的情况。通过异步工具启动的作业(事件驱动的恢复),可能在启动它的工作进程终止后才完成。因此,其结果必须进入以工具调用 ID 为键的持久收件箱,而不能进入工作进程的内存。恢复时,运行框架将每个等待处理的结果与日志中的意图进行匹配,并写入相应的 ToolResult。如果某个结果的工具调用 ID 在日志中没有对应意图,或者结果到达时其所属轨迹已被取消,记录的状态守卫(轨迹状态)就会拒绝该结果,而不会将其交付给模型。
迁移轨迹
迁移轨迹与隔离令牌
由于节点排空、热节流或抢占式实例被回收而在工作进程之间迁移轨迹,是一次计划内的恢复。迁出工作进程允许当前轮次完成,创建检查点,然后释放其租约;迁入工作进程恢复检查点并继续执行。
图 4 展示了运行中轨迹交接的各个顺序阶段。
迁移期间的关键故障风险是脑裂执行困境:遭遇短暂网络分区或内核冻结的工作进程可能未意识到自己的租约已经过期,因而继续执行步骤并发出会修改外部状态的工具操作,与新迁入的工作进程同时执行。运行时通过单调递增的隔离令牌 \(\gamma\) 防止这种情况:
- 当分布式租约服务将轨迹 \(\tau\) 的所有权授予迁入工作进程 \(B\) 时,它会递增纪元计数器:\(\gamma_B = \gamma_A + 1\)。
- 工作进程派发的每个工具调用载荷都必须携带分配给它的隔离令牌:\(a = \langle \text{tool}, \text{args}, \gamma \rangle\)。
- 工具执行网关和外部资源代理实施单调性检查:任何携带 \(\gamma < \gamma_{\text{current}}\) 到达的派发请求,都会被无条件丢弃,并返回授权错误。
如果过期的工作进程 \(A\) 从短暂暂停中恢复,并尝试发出外部工具调用,网关会因 \(\gamma_A < \gamma_B\) 而拒绝该调用,从而防止幽灵状态修改和双重执行。
漂移情况下的恢复
在崩溃与恢复之间,或在长时间等待审批期间,轨迹所处的系统可能发生变化。同一名称背后提供服务的模型可能已更新版本,提示词模板可能已被编辑,工具的模式也可能新增字段或重命名字段。由于 TaskStarted 和每个 ModelReplied 都记录了所用版本,智能体运行框架能够检测漂移,而不必等到出现异常行为才发现它,并且有三种选择:
- 固定所记录的版本并使用这些版本恢复,但这只有在这些版本仍然可用时才可行。
- 在下一个轮次边界切换到新版本,并写入记录新旧版本的漂移事件。已记录的轮次保留其已记录的输出,上下文按新模板重新组装,而前缀缓存从第一个发生变化的 token 起不再命中。
- 拒绝并上报;当工具模式的变化会使未决意图中已记录的参数被重新解释时,这是正确的处理方式。
发生切换的轨迹从轮次 \(k\) 起就属于一个不同的系统。评估时,不应将其切换前后的行为合并处理(智能体评估),而漂移事件使评估者能够将两者分开。
恢复利用日志继续执行轨迹。同一套机制也能重建轨迹的历史而不继续执行,工程师正是通过这种方式查明出了什么问题。
重放
夜间,一个智能体在清理任务的第 23 轮删除了一个发布分支。第二天早上,工程师需要知道模型在选择该操作时看到了什么:向它展示的是哪份列表,上下文压缩后的指令说了什么,以及授权检查是否依据正确的策略执行。重新运行任务无法回答这些问题,因为重新运行会采样一条新轨迹,而且如果指向真实代码仓库,还可能删除其他内容。
重放能够回答这些问题。它让智能体运行框架基于日志运行,同时断开两个新信息来源。模型客户端被替换为桩实现,按顺序返回已记录的 ModelReplied 事件;工具传输通道被断开,并返回已记录的 ToolResult 事件。智能体运行框架的代码实际执行,组装上下文、检查策略并更新记录,因此工程师检查的就是运行框架实际做过的事情。图 5 对比了这两种模式,表 4 则将重放与它所替代的重新运行并列展示。
| 维度 | 实时执行 | 从提示词重新运行 | 从日志重放 |
|---|---|---|---|
| 模型 | 被调用 | 再次调用,产生新采样 | 使用桩实现,返回已记录的回复 |
| 工具 | 被分发执行 | 再次分发执行,重复产生影响 | 断开连接,返回已记录的结果 |
| 结果 | 该轨迹 | 一条不同的轨迹 | 逐步重建同一条轨迹 |
| 成本 | token、费用、实际影响 | token、费用、重复产生的实际影响 | 仅智能体运行框架的计算开销 |
| 用途 | 执行任务 | 探索替代方案 | 恢复、调试、审计、测试 |
回放是严格的。如果运行框架在回放过程中尝试调用日志未涵盖的模型或工具,回放就会失败。未记录的调用意味着,运行框架的代码在录制之后发生了变化,或者某个输入未经日志记录就进入了实时轨迹;这两种情况都是需要查明的问题。这一思路源于整机的确定性回放:要复现一次执行,就必须记录每一个非确定性输入 (King 等 2005年)。对于智能体,非确定性输入数量不多,且定义明确,即模型回复、工具结果,以及运行框架自身从环境中读取的任何内容(节 6)。
时间旅行与反事实分支
由于回放可以在任意轮次停止,日志便成为工程师可以沿其移动的时间线。
Definition 0.1: 时间旅行回放
时间旅行回放 是指重建轨迹在过去任意轮次 \(t\) 的精确状态,方法是加载位于 \(t\) 或其之前的最近检查点,再回放日志的后续部分,直至 \(t\),同时以桩实现替代模型和工具。
- 意义:工程师可以检查轨迹出错轮次中模型实际收到的上下文、预算状态以及每一项授权决策,而成本仅为运行框架的计算开销。
- 区别:与从提示词开始重新运行不同,它复现的是已记录的轨迹,而非采样一条新轨迹;与阅读原始日志不同,它展示的是运行框架在该轮次的派生状态(组装后的上下文和记录)。
- 常见陷阱:让回放调用真实工具,从而在当前世界中重复执行过去的操作。
对于被删除的分支,工程师回放到第 23 轮,并查看模型收到的上下文。假设展示给模型的分支列表在标明发布分支受保护的那一行之前就被截断了。那么,缺陷就在观测截断机制中,而非模型中。跨多个事件进行这种归因,将模型故障与运行框架故障区分开来,正是 事件取证复盘 所讨论的主题。回放为这种归因提供证据。
反事实分支则更进一步。工程师在第 22 轮为轨迹创建分支,改变一项因素(截断规则、提示词模板、工具模式或模型),然后让它从该点开始继续实时运行。分叉之前的所有内容都通过回放复现,因此前面的轮次无需付出模型或工具调用成本,且保持完全一致。分叉之后的所有内容则构成一条新轨迹,必须在沙箱中运行,并将外部工具替换为桩实现或指向测试系统,因为它是实时运行的,其影响也是真实的。将一个分支与一份录制记录进行比较,只能说明修复可以改变结果。要证明它能可靠地改变结果,就需要运行许多分支并进行测量,这将在 智能体评估 中讨论。
运行框架的回归测试
回放也能测试运行框架本身。对运行框架的每一项修改,例如更严格的工具参数解析器、新的压缩规则或修订后的策略检查,都可能破坏生产轨迹所依赖的行为。使用实时模型测试这类修改既慢、成本又高,而且结果不稳定,因为测试可能仅仅由于模型采样出了不同的措辞而失败。
让归档轨迹在修改后的运行框架中回放,可以消除这三个问题。记录下来的模型回复是固定的测试输入,因此测试衡量的只有运行框架。假设某个团队收紧了从工具调用中提取文件编辑内容的解析器。使用新解析器回放数百份生产日志,就会发现某条轨迹中,在第 14 轮,旧解析器接受了包裹在 Markdown 代码围栏中的补丁,而新解析器拒绝了它;回放在该轮次停止,并保留两种解析结果供检查。该测试仅消耗运行框架的计算资源,在数秒内完成,并准确定位了对应的轮次和载荷。
这种测试的范围被刻意限定得很窄。它能表明新运行框架是否像旧运行框架一样处理已记录的模型输出,或指出两者在哪些地方存在差异。它无法表明新运行框架是否让智能体更容易成功,因为模型后续的回复会根据新运行框架作出响应。回答这个问题需要新的轨迹,以及 智能体评估 中的统计方法。同一批日志经过验证后,也会成为训练的原始材料(轨迹整理)。
回放之所以有效,是因为它从不向模型发出任何请求。一旦工程师通过重新运行提示词或继续运行反事实分支向模型发出请求,轨迹就可能偏离其录制记录,而了解原因是有价值的。
回放为何会发生偏离
一名工程师在调查智能体第 42 轮的故障时,使用相同的模型,将温度设为零,并基于处于同一 Git 提交的代码仓库副本重新运行轨迹。重运行在第 6 轮发生偏离,选择了完全不同的工具调用,始终未触发该故障。并没有任何组件损坏。自主智能体的重运行存在多个相互独立的偏离来源。
四种偏离来源
回放偏离的来源 将非确定性进入智能体轨迹的架构层次划分为四类。
| 来源 | 引入方式 | 运行时的处理方式 |
|---|---|---|
| 模型调用 | 非零温度下的采样;\(T=0\) 时并行归约内核中浮点运算的非结合性 | 记录每次回复;不要期望重运行能够复现某次回复 |
| 模型漂移 | 同一模型名称背后的模型版本或服务配置发生变化 | 在每次回复中记录实际提供服务的版本;先比较版本,再比较行为 |
| 运行框架输入 | 被放入上下文的时间戳、随机 ID、无序目录列表和检索结果 | 通过运行框架读取每项输入,将其记录为事件,并对没有固有顺序的内容进行排序 |
| 环境 | 自记录以来发生变化的外部服务、代码仓库和数据 | 记录每个工具结果;从日志中回放,并为反事实实验固定环境或创建环境快照 |
模型调用最令工程师意外,因为基础模型服务即使在温度为零时也不具有确定性(\(T=0\))。在 GPU 架构中,高吞吐量的通用矩阵乘法(GEMM)运算和 FlashAttention 内核会跨数千个流式多处理器核心执行并行归约。由于浮点加法不满足结合律:
\[(a + b) + c \ne a + (b + c)\]
线程块中线程束调度的变化会导致词元 logit 分布在小数点后第七位出现微小变化。当两个候选词元处于 argmax 决策边界附近时,难以察觉的调度变化就会使输出词元发生切换。由于自回归解码会将输出词元直接反馈到上下文中,第 \(t\) 轮的单个词元偏离会在后续轮次中以指数方式累积。
通过系统调用拦截实现确定性回放
为实现用于诊断调试的逐位精确回放,运行时采用环境拦截,在运行框架与底层主机之间插入一个系统调用与 I/O 适配层(图 6)。
当运行框架以回放模式执行时:
- 模型虚拟化:对模型 API 的调用会被拦截,并通过直接返回日志中记录的
ModelReplied词元载荷来满足调用。 - 环境拦截:请求当前墙上时钟时间(
gettimeofday)、进程 ID 或密码学熵(/dev/urandom)的系统调用,会返回ContextBuilt事件中捕获并记录的时间戳和种子。 - 工具模拟注入:工具分派会被拦截;适配层会注入记录的
ToolResult数据,而不执行 shell 命令或发送网络请求。
该架构将监督代码与物理层面的非确定性解耦,从而支持可复现的回归测试和细粒度的根因归因。
衡量偏离
当工程师有意重新运行模型以测试韧性时,关键诊断指标是偏离点:所选工具动作偏离已记录基线的最早轮次 \(t_{\text{div}}\)。比较 \(t_{\text{div}}\) 处的上下文哈希值即可立即定位根因:相同的上下文哈希值表明原因是模型侧的随机性或浮点运算的非确定性,而不同的哈希值则表明存在环境漂移或未排序的目录输入。
Checkpoint 0.2: 回放与偏离
在继续之前,请检查你是否能够回答以下问题:
日志保留
轨迹存储引擎
运营一个每天生成数万条长时程轨迹的企业级集群,会面临一个根本性的存储困境:系统必须在关键执行路径上,为活跃 WAL 维持亚毫秒级的同步写入屏障,同时支持用于实时监控的关系型索引,以及用于下游 RL 微调的高密度列式批处理。单一存储技术无法满足这些相互冲突的约束(表 5)。
| 存储架构 | 主要数据结构 | 追加延迟(p99) | 随机扫描吞吐量 | 典型写放大 | 最适合的智能体生命周期角色 |
|---|---|---|---|---|---|
| LSM 树(嵌入式) | MemTable + SSTables | \(0.2 - 0.8\ \text{ms}\) | 高(针对连续的 LSN 范围) | \(2 - 5\times\) | 热层:活跃 WAL、本地轮次屏障、即时崩溃重启 |
| B+ 树(关系型) | 分页磁盘块 | \(5 - 25\ \text{ms}\) | 高(多属性索引扫描) | \(10 - 30\times\) | 温层:集群范围内的协同、HITL 审批队列 |
| 列式湖仓 | Parquet / Zstd 块 | 批量导入(\(>1\ \text{s}\)) | 极高(向量化 SIMD 扫描) | \(\approx 1.0\times\) | 冷层:轨迹整理、安全审计、SFT 和 RLVR 训练 |
多层存储流水线
生产架构通过将存储组织为三层生命周期金字塔来化解这一矛盾(图 7 和 表 6):
- 热层(本地 NVMe / 嵌入式 RocksDB):在工作节点本地执行。当智能体运行框架记录
ToolIntent或ToolResult时,它通过fdatasync()直接追加到嵌入式 LSM 树中。由于写入严格按顺序在本地进行,持久化提交可在 \(0.5\ \text{ms}\) 以内完成,无需远程网络跳转即可满足 公式 1。 - 温层(集群化关系型数据库 / PostgreSQL):异步后台流式 sidecar 守护进程持续读取本地 RocksDB 日志,并将已提交的事件批次复制到集中式关系型数据库。该层提供基于索引的多智能体查询、管理端取消操作和实时仪表板监控,且不会阻塞执行路径。
- 冷层(列式对象存储 / Apache Parquet):离线批处理流水线将已完成且超过 7 天的轨迹合并为对象存储(S3/GCS)上的压缩 Apache Parquet 文件,并按租户、日期和模型版本分区。分析引擎(DuckDB、Trino)和训练流水线将这些列式数据块直接流式传输到 GPU 内存中,用于策略训练(轨迹整理)。
| 存储介质 | 存储技术 | 查询 / 访问模式 | 运维功能 | 性能与成本范围 |
|---|---|---|---|---|
| 热层 | 本地 NVMe SSD;采用预写日志的嵌入式 RocksDB | 顺序追加、通过单调递增的 LSN 进行点查询 | 实时 WAL、动作授权屏障、即时崩溃恢复 | 延迟 \(\le 0.5\ \text{ms}\),WAF \(\approx 1.0\) |
| 温层 | 采用 JSONB 索引和分区表的集群化 PostgreSQL | 基于索引的范围扫描、多智能体状态查询、关系连接 | 活跃轨迹监控、人在回路审核、集群协同 | 延迟 \(5 - 50\ \text{ms}\),99.99% 可用性 |
| 冷层 | 分布式云对象存储(S3 / GCS,采用 Zstd 压缩的 Parquet) | 向量化批量扫描(DuckDB、Trino、PyTorch 加载器) | 合规保留、安全审计、离线 SFT 和 RLVR | 扫描 \(\ge 10\ \text{GB/s}\),成本 \(\le \$0.02/\text{GB}\cdot\text{month}\) |
幂等摄取与数据去重
为在工作节点与温层之间的网络边界上保持因果一致性,关系型模式通过唯一复合键强制实施幂等插入或更新语义:
INSERT INTO trajectory_events (
trajectory_id, sequence_number, event_type,
payload, cryptographic_hash, created_at
) VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (trajectory_id, sequence_number) DO NOTHING;这确保了流式处理边车在节点故障后重启并重新传输事件批次时,重复事件会被静默丢弃,而不会破坏历史序列。
谬误与陷阱
持久执行最常见的失败原因,是工程师沿用了某种假设:它对普通服务或单次模型调用成立,却不适用于会改变外部世界的长轨迹。
谬误: 保存最终的上下文窗口,就足以审计或恢复轨迹。
上下文窗口是轨迹的有损投影。压缩对早期轮次做了摘要,截断裁剪了工具输出,而且窗口中没有记录运行时授权了哪些操作、哪些意图已持久化,以及哪些结果已到达。如果工具在第 7 轮返回了凭据,而摘要在第 12 轮将其移除,那么最终上下文中就不会留下任何泄露痕迹。恢复和审计需要 表 1 的日志;上下文可以从该日志重建,但该日志无法从上下文重建。
陷阱: 为了降低延迟,在意图持久化之前就发出会改变外部世界的调用。
节省的时间很少,因为一次本地刷盘带来的额外耗时远低于单轮耗时的 1%。代价则是:只要主机在发出调用与刷盘之间的时间窗口内发生故障,就会产生幽灵效应。恢复后的智能体运行框架找不到意图,便认定调用从未发生,进而重复付款、发送消息或请求资源配置。预写顺序(节 2)消除了这一窗口,而组提交可以弥补大部分延迟开销。
谬误: 以零温度重新运行模型,就能复现轨迹。
零温度推理服务仍无法保证逐比特精确一致,因为批次组成会改变浮点求和的顺序,得分极为接近的词元之间的选择也会翻转。稳定的模型名称背后,模型版本可能发生变化;时间戳等运行框架输入会进入上下文;环境也在变化。重新运行产生的是一条新轨迹。复现依靠回放已记录的模型回复和工具结果(节 5),而分歧分析(节 6)则解释其余差异。
陷阱: 在续行时重新生成摘要和上下文,而不是复用已记录的内容。
如果续行后的智能体运行框架重新概括旧轮次,或使用崩溃后修改过的提示词模板重新组装上下文,模型获得的自身历史记忆就会不同于它先前据以行动的记忆。轨迹于是从一个从未存在过的状态继续,前缀缓存无法命中,后续轮次也无法与先前轮次比较。续行应使用已记录的 Compacted 事件和模板版本,并将任何变化视为显式漂移(节 4)。
陷阱: 因为调用超时,就将结果未决的调用视为失败。
超时表明回复未到达,并不表明请求没有产生任何作用。在没有幂等键或核查探测的情况下,重新发送结果未决的调用,会造成预写顺序原本旨在防止的同一种重复效应,只是这一次由恢复路径自身引入。必须先查明并处理未决意图,轨迹才能继续;若无法解决,则升级处理。
陷阱: 将原始工具输出的每一个字节永久保留在活动日志中。
测试运行器和编译器每轮可能产生数兆字节的输出,其中大部分从未传递给模型。将这些输出全部保留在活动日志中,会拖慢追加写入、检查点操作以及工作进程之间的迁移,并增加存储成本,却无助于改善回放。将截断位置之后的大量内容卸载到内容寻址存储中,同时在日志中保留实际交付的内容及哈希值(节 7),即可保存回放和审计所需的全部信息。
总结
本章探讨了运行时必须记录哪些内容,才能让轨迹在经历崩溃、暂停或迁移后继续执行,而不重复已经产生的效应。答案是仅追加的轨迹日志,轨迹记录由该日志计算得出;写入时必须确保,每次会改变外部世界的调用之前,都已有持久化的意图。在这一顺序约束下,恢复过程会发现每个操作处于未发送、已确定或未决状态,并在继续之前解决第三类状态。检查点在一致的轮次边界捕获日志无法重建的内容,主要是工作区。续行将二者结合起来,根据已记录的事件重建上下文,承担一次冷预填充开销,并显式处理审批、迟到结果和版本漂移。回放则将模型和工具替换为桩实现,让运行框架基于日志运行,从而使日志成为调试器、反事实分支的来源,以及运行框架的回归测试套件,并解释重新运行为何会产生分歧。
要点回顾: 未写入日志的内容,恢复过程就无法信任
- 日志就是轨迹;记录只是视图:轨迹记录通过对模型回复、决策、意图和结果构成的仅追加日志进行折叠计算来重新生成。恢复过程读取日志,绝不调用模型,因为模型采样结果无法重新生成。
- 先写入意图,再产生效应:会改变外部世界的调用,只有在其意图持久化之后才能从主机发出。这样,恢复过程就能确定每个操作处于未发送、已确定或未决状态;对于未决操作,使用幂等键或探测来查明并处理,绝不凭假设重新发送。
- 检查点用于保存日志无法重建的内容:日志不包含工具写入的文件。检查点在轮次边界对记录和工作区一同生成快照,其频率需要在暂停时间与故障造成的轮次损失之间权衡。
- 续行就是恢复加上上下文重建:续行的轨迹复用已记录的摘要和版本,承担一次上下文冷预填充开销,并将模型、提示词或工具模式的变化视为显式漂移,而不是在变化后静默继续执行。
- 复现轨迹依靠回放,而非重新运行:将模型和工具替换为桩实现,可以让日志成为时间旅行调试器和运行框架回归测试套件,代价是运行框架的计算开销。重新运行会因采样、推理服务、模型漂移、运行框架输入和环境而产生分歧。
本章中的恢复过程绝不调用模型。轨迹记录从日志重新计算得出,预写顺序确保每个效应都发生在其意图持久化之后,而检查点则限定了重启后必须重做的工作量。这就是附带明确成本的“意图先于效应”(原则 13):每次会改变外部世界的调用都需要一次持久化写入,检查点频率则由故障率决定。这也标明了不变量闭合(原则 不变闭包原则)中机械性保障在持久性方面的边界。运行时可以保证没有效应未被记录,也不会重复任何已确定的效应,但仅凭日志无法判断未决效应是否已经发生,也无法判断已记录的效应是否正确。
