崩溃可能留下只有 Call 或只有 Output 的尾部;Codex 在生成模型 Prompt 的副本上补 aborted Output、删除孤儿 Output并剥离不支持媒体,但不把这些推断反写成真实 Rollout。
本节解决的不是“把对象存一下”这种抽象问题,而是把问题限定在:研究 ContextManager::for_prompt 的规范化顺序与 stable synthetic ID。输入:恢复后的原始 Context History 与目标模型 input modalities;状态所有者:ContextManager clone 的 Prompt view;成功结果:满足模型协议配对且能力兼容的 ResponseItem 数组。先把这些边界钉住,后面的顺序、失败和恢复才不会混成一句“持久化失败后重试”。
状态边界
| 问题 | 本节答案 |
|---|---|
| 输入 | 恢复后的原始 Context History 与目标模型 input modalities |
| 状态所有者 | ContextManager clone 的 Prompt view |
| 成功产物 | 满足模型协议配对且能力兼容的 ResponseItem 数组 |
| 研究范围 | 研究 ContextManager::for_prompt 的规范化顺序与 stable synthetic ID |
正常路径:先看顺序点
这条路径可以压缩成五步:
- clone ContextManager snapshot。
- 为缺失 Output 的 Call 生成占位。
- 删除无 Call 的客户端 Output。
- 按模型能力替换 image/audio。
- 仅把规范化副本交给模型。
图中的箭头不是“可能调用”的依赖图,而是源码中决定可见性和所有权转移的先后关系。前一步没有确认时,后一步不能替它作出更强的成功承诺。
源码机制拆解
先补缺失 Output 再删孤儿
第一遍收集已有 output IDs;Function/Custom/ToolSearch/LocalShell Call 缺结果时,在 Call 后插入合成结果。第二遍收集 Call IDs,删除没有匹配调用的 Output。顺序保证新插入项不会被误删。
不同调用类型有兼容输出形状
Function 与 LocalShell 使用 FunctionCallOutput(“aborted”);Custom 使用 CustomToolCallOutput;客户端 ToolSearch 生成 completed/empty tools 的 ToolSearchOutput。server-executed search output 可以没有本地 Call,策略保留。
合成 ID 必须跨重试稳定
源 Call 有 item id 时,用固定 UUID v5 namespace 和 prefix:item_id 派生 ResponseItemId。重复 for_prompt/Resume 不会制造不同 ID,从而保护 prompt cache key;旧 Call 无 id 则保持 None 兼容。
媒体能力也是 Prompt-only 变换
目标模型不支持 Image/Audio 时,消息和工具 Output 中相应内容替换为文字占位;ImageGeneration result 清空。原始 Context History/Rollout 不被永久降级,之后切回多模态模型仍能读取原记录。
修复协议,不伪造执行事实
“aborted” 表示该调用没有可用结果,使下一请求的 call/output 结构合法;它不意味着工具真的执行过一次,也不应被 append 到 canonical Rollout。
Python 风格伪代码
下面的伪代码只保留设计职责、状态和失败顺序;它不逐行翻译 Rust,也不借 Python 语法虚构源码中不存在的事务:
def history_for_prompt(raw_history, modalities):
items = deep_copy(raw_history.items)
output_ids = collect_output_call_ids(items)
insertions = []
for index, item in enumerate(items):
if is_call(item) and item.call_id not in output_ids:
insertions.append((index + 1, aborted_output_for(item)))
for index, output in reversed(insertions):
items.insert(index, output) # reverse order avoids shifting indexes
call_ids = collect_compatible_call_ids(items)
items = [item for item in items if not is_orphan_client_output(item, call_ids)]
replace_unsupported_images_with_text(items, modalities)
replace_unsupported_audio_with_text(items, modalities)
return items
def aborted_output_for(call):
stable_id = uuid5(FIXED_NAMESPACE, f'{kind_prefix(call)}:{call.item_id}') if call.item_id else None
return compatible_output(call, text='aborted', item_id=stable_id)
阅读时要特别看三处:哪个对象拥有可变状态,哪一个 await 是可观察屏障,以及失败后保留的是已提交前缀、未提交后缀,还是完全独立的外部副作用。
失败、取消与恢复
| 故障点 | 已留下的状态 | 可观察结果 | 恢复责任 |
|---|---|---|---|
| FunctionCall 尾部无 Output | 模型协议不完整 | 插入 aborted FunctionCallOutput | 仅存在于 prompt clone |
| Output 没有匹配 Call | 可能来自截断/旧 bug | 客户端 output 被删除 | server search 特例保留 |
| 重复规范化产生随机 ID | Prompt cache key 每次变化 | 缓存命中下降 | 固定 namespace 的 UUIDv5 |
| 文本模型收到旧图片 | provider 不支持 modality | 请求失败或行为未定义 | 用稳定占位文本替换副本 |
这里没有统一的“回滚一切”。内存状态、日志行、SQLite 投影、父子拓扑和工具造成的文件/网络变化分别有自己的提交点。恢复代码只能根据已经存在的权威证据继续,不能用较弱的投影替较强的事实背书。
必须保持的不变量
- 最终 prompt 中每个客户端 Call 有兼容 Output
- 每个客户端 Output 有兼容 Call
- 规范化不修改 canonical Rollout
- 同一带 item ID 的缺失 Output 每次派生相同 synthetic ID
这些不变量比“最终能 Resume”更严格:正常路径要成立,Writer 竞争、任务取消、坏尾行、投影落后和旧格式兼容时也必须成立。
设计取舍
Prompt-only 修复让不完整历史继续可用,却承认它是兼容性推断;把推断限制在请求副本能保住审计真实性。
源码可以直接证明字段、分支、调用顺序和测试期望;“为什么这样设计”的表述是基于这些事实作出的工程归纳,不把它包装成未公开的产品承诺。
Mini Codex 复刻
normalize(copy(history)),绝不 normalize-and-save;为四种 call family 建配对表,并用固定 namespace 派生 synthetic output ID。
复刻时先验证协议不变量,再补性能优化。一个能在故障注入下说明“留下了什么”的小实现,比一个只在正常路径调用 save() 的演示更接近真实 Runtime。
源码导航
- codex-rs/core/src/context_manager/history.rs:for_prompt 克隆规范化与执行顺序
- codex-rs/core/src/context_manager/normalize.rs:补 Output、删孤儿、stable ID 和媒体替换
相邻测试也很重要:
- codex-rs/core/src/context_manager/history_tests.rs:四类 Call 配对、孤儿删除、stable ID、图片/音频能力
- codex-rs/core/src/session/rollout_reconstruction_tests.rs:不完整 Turn 重建后交给 Prompt 层修复
本节结论
崩溃可能留下只有 Call 或只有 Output 的尾部;Codex 在生成模型 Prompt 的副本上补 aborted Output、删除孤儿 Output并剥离不支持媒体,但不把这些推断反写成真实 Rollout。
评论
登录后即可评论