History 为什么不能直接拿去做 Prompt
沿着 rust-v0.144.6 的 ContextManager、normalize 与真实 resume 测试,解释 live history 为什么要先投影成模型可接收的 Responses input。
一个会话停在工具调用和工具结果之间时,history 会出现一种很难看的状态:模型已经发出 call,但对应 output 不在记录里。下一次 resume 如果把这些 item 原样交给 Responses API,后端看到的就是一组没有配对的输入;如果为了补齐 pairing,先把一条假的 output 写回会话记录,又会把“本次请求需要的修复”伪装成“过去真实发生过的事件”。
rust-v0.144.6 里有一条集成测试,专门把这个异常压缩成最小样本。fixture 的 rollout 只有 SessionMeta 和一个 FunctionCall,没有 output。这个 call 的 id 是 fc_existing,call_id 是 missing-output-call。随后测试对同一文件连续做两次 resume。
第一次 resume 的请求里,Codex 会临时补出一条内容为 aborted 的 FunctionCallOutput。关键不只在“补了”,还在“补到哪里”:测试 430-433 直接断言 rollout 不新增 function_call_output。关闭会话、第二次 resume 后,请求里的 synthetic ID 仍然稳定,而且 UUID 版本断言表明它来自基于名字的 UUIDv5,而不是每次随机生成。
这个场景给出了本章的主问题:history 既要保留会话当前认可的事实,又要满足下一次模型请求的结构约束。两件事不能靠同一个可变数组直接完成。Codex 的处理方式,是在采样边界做一次有所有权隔离的 projection。
同一个请求工作集,先拆成三步
这里的“三步”只描述 live ContextManager 到 request input 的投影过程,不是三种持久化状态。第 25 章讨论的 live history、rollout JSONL 与 SQLite 是三个承载面;本章讨论的 record、detached projection 与 request input 都发生在其中 live working set 的采样边界。
源码里的 ContextManager 注释把它叫作 thread history 的 transcript,内部确实有一个按时间从旧到新排列的 Vec<ResponseItem>。但这个对象还带着 history_version、token 信息、turn context reference 和 world-state baseline。它是 session 当前使用的 live context 容器,不是磁盘文件的薄包装。
第一步是 record-time canonical live history。经 record_items 追加的 item 会先经过 is_api_message,不属于 API history 的 item 被过滤;留下的 item 再经过 process_item,其中 function/custom tool output 会按策略截断,然后才进入 items。但 raw_items 只是当前 ContextManager.items 在 prompt normalization 前的底层视图:replace() 可以直接替换这组 items,resume reconstruction 也会把 replacement history 原样装回。因此 raw items 不证明所有 item 都经过 record-time canonicalization,也不是原始输入或 rollout/durable record。
第二步是 prompt projection。for_prompt(self) 取得所有权并消费一个 ContextManager,在它自己的 items 上执行 normalize,最后只返回 Vec<ResponseItem>。这个签名本身并不保证 live history 安全;真正的隔离来自生产调用路径:session state 的 clone_history() 先 clone manager,异步 session 方法在锁内取出这个 clone,run_turn 再对 clone 调用 for_prompt。把这条 ownership 链缩写出来,就是 clone_history() -> for_prompt(self)。
因此可以把结果直接记成一句话:clone_history() 生成 detached prompt view,live history 保持不变。
普通采样开始时,run_turn 会从 live history 取一个 clone,再执行一次 for_prompt(...)。如果错误上升到 run_sampling_request 的完整采样重试,下一次 attempt 才会重新 clone 当前 history、重新投影并重建 Prompt。HTTP endpoint 自己的 request retry 不走这条路:它复用已经编码、准备好的 request,只重新应用认证并建立 transport stream。外层 run_turn 的后续 sampling step 也会重新取得 clone。于是 synthetic output、orphan removal 和 image stripping 始终只改 detached prompt view,不写回 live history。
第三步才是 request builder 接到的模型输入。把所有权关系画出来,会比把三者都叫 history 更清楚:
flowchart TB
accTitle: History 到 Prompt 的所有权边界
accDescr: record_items 维护 live canonical history;clone_history 取得 detached manager;for_prompt 只 normalize 副本并交给 Prompt.input,不写回 live history
RECORD["record_items / filter + process"] --> LIVE["canonical live ContextManager"]
LIVE --> CLONE["clone_history"]
CLONE --> DETACHED["detached ContextManager"]
DETACHED --> PROJECT["for_prompt(self)"]
PROJECT --> NORMALIZED["normalized Vec<ResponseItem>"]
NORMALIZED --> INPUT["Prompt.input"]
NORMALIZED -. detached projection / no write-back .-> BOUNDARY["live history remains unchanged"]
这里要留一个很容易被函数名遮住的负边界:normalize_history 本身会 mutate 传入的 manager。若有别的调用方直接在 live manager 上调用它,函数不会自动保护历史。当前生产采样路径之所以不写回,是 clone_history 与 for_prompt(self) 的 ownership 组合保证的,不是 normalize 天生只读。
normalize 修的是配对,不是过去
normalize_history 的调用顺序是固定的:
ensure_call_outputs_present -> remove_orphan_outputs -> strip_images_when_unsupported
先补 call 的缺失 output,再删没有 call 的 output,最后根据模型 input modalities 处理图片。顺序有意义:刚补出的 output 必须能通过下一步配对检查,而图片能力适配发生在结构完整之后。
缺失 output 的修复并不是一条泛化规则。普通 FunctionCall 缺结果时,会紧跟着插入 FunctionCallOutput,文本是 aborted。带 call_id 的 ToolSearchCall 缺结果时,会插入 completed/client 的空 ToolSearchOutput。代码先扫描已有 output ID,再收集待插入项,最后按反向 index 插入,避免前一次插入改变后续位置。
CustomToolCall 和带 call_id 的 LocalShellCall 更严格:缺 output 时先调用 error_or_panic,再把 synthetic output 放入待插入列表;debug build 会 panic,只有 release build 记录 error 后才继续。因此不能把“所有 call 都会静默补一条 aborted”写成统一行为。Local shell 的配对 output 还是 FunctionCallOutput,不是一个独立的 local-shell output variant。
synthetic ID 也有明确前提:source call 有非空 item ID 时,Codex 才把固定 namespace 和带类型前缀的名字交给 UUIDv5;没有 item ID 或值为空时,结果仍是 None。这正是开场测试必须给 FunctionCall.id 填入 fc_existing 的原因。稳定性服务于重复 projection 和 prompt cache,不表示 synthetic output 已经持久化。
接下来的 orphan 清理同样按 variant 配对。FunctionCallOutput 可以匹配 FunctionCall 或 LocalShellCall;custom output 只能匹配 custom call;client-side tool-search output 要匹配相同 call_id。server ToolSearchOutput 有保留例外,call_id: None 的 tool-search output 也会保留。其余孤儿项会先经过 error_or_panic,release build 才会在 retain 中把它们删掉。
图片处理也不是简单 retain。当模型不支持 image input 时,message 和 tool output 里的 image item 会被替换成一段 omission placeholder,ImageGenerationCall.result 也会清空;支持图片时函数直接返回。这个步骤改变的是本次模型可见内容,不能反过来证明 live history 里从未有过图片。
开场的跨 resume 测试完整覆盖了“缺 FunctionCall output -> prompt 补齐 -> synthetic function_call_output 不持久化 -> 再次 resume ID 稳定”。对于 orphan,证据边界更窄:production sampling path 的 orphan cleanup 只发生在 detached prompt projection。这个结论由 clone_history -> for_prompt -> remove_orphan_outputs 的 call graph 推导:清理结果不写回 live ContextManager,normalize 路径里也没有把它持久化到 rollout/durable record。orphan 没有专门 E2E,因此不能声称所有 resume 和 durable 路径都已有端到端证明。
Working set 不是另一个请求字段
第 8 章已经解释 instruction 的来源、role 和 injection 时机。本章接手的是其中已经转成 history item 的运行时 bundle;仍在独立字段里的 base instructions 不在这里重讲。这里还剩一个容易混淆的问题:StepContext、WorldState 和 snapshot 看起来都参与本轮,它们是不是在 Prompt 里还有一块独立 payload?
StepContext 是 request-scoped working set。它绑定本 step 使用的 TurnContext、environment snapshot、ready capability roots、MCP runtime snapshot、固定 MCP tool list,以及当时观察到的 AGENTS.md。mcp_tools() 的 OnceCell 还保证同一个 step 里的工具列表只初始化一次。这个对象表达的是构造请求所需的运行时工作集,本身不是模型输入 item。
Session 用这份 working set 聚合 live WorldState:它加入 AGENTS.md、apps 和 plugins section,并在配置允许时加入 environment section;extension contributor 也可以按 thread/turn 与各级 store 贡献 section。这一层仍然是 typed runtime state,不应被描述成已经发给模型的 JSON。
WorldStateSnapshot 是 typed comparison / persistence state,不是 wire field。它把各 section 的 snapshot 收进稳定 key 的 map,用来生成 RFC 7386 merge patch 或应用 patch。真正产生模型可见内容的链条只有 WorldState -> render -> fragment;snapshot 是否参与比较,取决于当前是完整注入还是 steady-state 差分。
初次或完整注入走的是另一条明确路径:build_initial_context_with_world_state_and_mcp 调用 WorldState::render_full(),把每个 fragment 按 role 收集并组装成 context items;这一步不需要 previous snapshot,也不经过 merge_contextual_fragments。
在 record_context_updates_and_set_reference_context_item 的完整分支里,当前 world_state.snapshot() 随后才被单独取得并设为 history baseline;这个 current snapshot 是比较/持久化状态,不是刚才那批 wire items。steady-state 分支则把 previous snapshot 与 retained history 交给 render_history_diff,再用 merge_contextual_fragments 按相邻 role 合并成 user/developer ResponseItem。因此 previous snapshot 只参与差分比较,current snapshot 另作 baseline/persistence;两者都不直接进入 Prompt wire。
这里够用的边界只有一条:WorldState 先 render 成 fragment;full path 随后取得 current snapshot 作为 baseline,steady path 用 previous snapshot 做 diff;snapshot 自身不进 Prompt wire。snapshot 怎样持久化、恢复及与其他 durable state 对齐,留给 会话状态的三层边界。
最后核对 Prompt 的字段就能结束猜测:它有 input、tools、parallel tool-call 开关、base instructions 和 output schema 配置;Prompt 没有独立 world-state 字段。world state 影响请求的方式,是先沿 WorldState -> render -> fragment 变成 history 里的 ResponseItem,最后随 normalized input 进入 request。
在固定版本上复现这条分叉
下面的实验只针对 rust-v0.144.6 / 5d1fbf26c43abc65a203928b2e31561cb039e06d。进入固定 checkout 的 Rust workspace:
set -euo pipefail
: "${ARCHIVE_CODEX_RS:?先执行第二部导读的 archive 准备脚本}"
cd "$ARCHIVE_CODEX_RS"
CODEX_TEST_ENVIRONMENT=local just test --locked -p codex-core normalize_adds_missing_output_for_function_call_inserts_output
第一条单元测试构造一个只有 FunctionCall 的 ContextManager,调用 normalize 后断言下一项是内容为 aborted 的 FunctionCallOutput。实际结果是:
PASS codex-core context_manager::history::tests::normalize_adds_missing_output_for_function_call_inserts_output
Summary: 1 test run, 1 passed
如果使用较短的过滤串 normalize_adds_missing_output_for_function_call,nextest 的 substring filter 实际仍命中上面这个带 _inserts_output 后缀、always-built 的测试。源码里另有一个短名完全相同的测试,但它带 #[cfg(not(debug_assertions))],debug run 不会编译那一条。为了让复现对象没有歧义,这里直接写完整测试名。
接着跑开场的 integration test:
set -euo pipefail
: "${ARCHIVE_CODEX_RS:?先执行第二部导读的 archive 准备脚本}"
cd "$ARCHIVE_CODEX_RS"
CODEX_TEST_ENVIRONMENT=local just --set rust_min_stack 8388608 test --locked -p codex-core synthetic_call_output_id_is_stable_across_resumes
关键结果在 tests/all.rs:
PASS codex-core::all suite::client::synthetic_call_output_id_is_stable_across_resumes
Summary: 1 test run, 1 passed
本机省略这个环境变量时,默认栈会在 Tokio worker 触发 stack overflow;RUST_MIN_STACK 是运行前提,不是断言失败的修复。换句话说,这个 env 解决的是测试线程栈容量,测试仍然按同一组请求和 rollout 断言判断成败。
实验副本里的 Cargo 可能把 workspace package 的版本从 0.0.0 校准成 0.144.6;固定源码 checkout 不参与这次写入。结束前检查 archive 内的 lock diff 和固定 checkout 的 git status --short,确认已知版本漂移没有进入 source evidence,也不要把 lockfile 变化误当成 normalize 的行为证据。
这组实验的负边界也必须保留:单元测试证明 FunctionCall 的缺失 output 会被补齐;integration test 证明两次 resume 的 prompt-only repair 不写回 rollout 且 ID 稳定。它们不证明所有 orphan variant 都经过同等 E2E,也不展开 rollout、SQLite 或 cold resume 怎样重建 live history。
到这里,才交给 Responses request
本章最终只交付一项直接产物:canonical live history 的 clone 经 for_prompt 修复 pairing、处理 orphan 与 image modality,得到 normalized Vec<ResponseItem>,再成为 Prompt.input。base instructions、tools、parallel_tool_calls 和 output_schema 来自其他 owner;WorldStateSnapshot 与 StepContext 也不是 wire field。
下一章 Prompt 怎样变成一份 Responses 请求 从这份 normalized input 与其他 request controls 接手,继续拆 HTTP / WebSocket wire。live ContextManager、rollout 与 SQLite 放到第 25 章;compaction 放到第 26 章;cold reconstruction、resume 和 fork 留给第 27 章。