雨天小六

读懂 Codex(四):模型眼中的世界——提示词、上下文与历史

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

#Codex#Agent Runtime#Prompt#上下文工程#软件架构

假设用户对 Codex 说:

修好资料页的保存按钮。不要改后端接口,只运行前端测试。

模型要正确完成这件事,不能只看到这一句话。它还得知道当前工作目录、项目规范、此前读过的文件、刚才的测试结果、哪些命令需要审批,以及这一轮可以调用哪些工具。

这些信息也不能在会话开始时一次性写死。Codex 可能切换到另一个工作目录,权限可能变化,工具可能在下一次采样前刷新;与此同时,已经完成的工具调用和用户刚补充的要求又必须留在历史中。

因此,模型每次采样前看到的内容不是一份静态“提示词模板”,而是 Runtime 根据当前 Session、历史和 StepContext 编译出来的一次结构化请求。本章沿着这条编译路径,回答三个问题:请求由哪些部分组成,动态状态怎样更新,历史在发送前为何还要再整理一次。

Prompt 不是一段拼接好的长字符串

许多简单 Agent 会这样构造输入:

系统提示词 + 项目规则 + 对话历史 + 工具说明 + 用户问题

这种写法适合建立直觉,却不足以描述 Codex 的实际请求。Codex Core 内部的 Prompt 至少包含以下字段:

字段内容主要来源
base_instructions模型的顶层基础指令SessionConfiguration
input有类型的上下文和对话历史ContextManager
tools本次采样允许模型调用的工具规格当前 StepContext 的 ToolRouter
parallel_tool_calls是否允许并行提出工具调用当前模型能力
output_schema最终回答需要满足的结构TurnContext

这里最重要的不是字段数量,而是它们没有被提前压成同一段文本。工具规格仍然是 ToolSpec,历史仍然是 ResponseItem 列表,输出约束仍然是 Schema。到了模型客户端一层,它们才会被映射成具体 API 所需的请求格式。

这也解释了为什么“模型看到的 Prompt”不能只理解为界面上显示的系统提示词。更准确的说法是:Prompt 是一次采样所需的完整输入包,文字指令只是其中一部分。

Base Instructions 走一条独立通道

base_instructions 不在普通对话历史里。Session 创建时按三层优先级选择它:

  1. 配置显式提供的覆盖值;
  2. 恢复会话保存的 Base Instructions;
  3. 当前模型自带的默认指令。

恢复记录排在当前模型默认值之前,是为了让继续执行的 Session 保持原来的基础行为,而不是仅因进程重启就悄悄换掉底层规则。选定以后,指令由 SessionConfiguration 持有;每次构造请求时,采样路径再从 Session 读取。

模型切换和 Personality 变化还需要另一层处理。若当前模型与此前模型不同,World State 可以生成一条带边界标记的模型切换说明;Personality 可能已经被编入顶层模型指令,也可能作为单独的上下文更新注入。于是同一个概念会经过两条不同的数据线:

  • 顶层 Base Instructions 确定本 Session 的基础行为;
  • 历史中的模型或 Personality 更新告诉模型当前状态发生了什么变化。

如果把两者混成一段“系统消息”,恢复、切换和差异更新的语义都会变得含糊。

History 保存的是有类型的项目

ContextManager 保存的不是聊天窗口文本,而是一列从旧到新的 ResponseItem。其中既有 Developer、User 和 Assistant 消息,也有 Reasoning、工具调用、工具结果以及压缩相关项目。

例如,一次“先搜索再回答”的过程,在历史中更接近下面的结构:

Message(role=user, "保存按钮为什么失效?")
Reasoning(...)
FunctionCall(call_id="call_17", name="search_files", ...)
FunctionCallOutput(call_id="call_17", output="...")
Message(role=assistant, "问题出在表单提交条件。")

调用和结果通过 call_id 建立关系。这个关系不仅方便 Runtime 找到工具结果,也是模型理解因果顺序的必要条件:先有一次工具意图,后有该意图对应的观察结果。

项目规则、权限和环境信息同样会进入 ResponseItem,但它们并不是普通用户发言。Codex 用 ContextualUserFragment 表示这类 Runtime 注入片段。每个片段自己声明角色、正文、起止标记,以及是否必须单独成为一条消息。

标记的作用是让 Runtime 以后能认出“这段文字由哪类上下文生成”。没有这种边界,历史压缩或版本迁移时只能用模糊的文本匹配,很容易把用户恰好说过的相似句子误当成系统状态。相邻、同角色且允许合并的片段可以装进同一条消息;要求隔离的片段仍保持独立。

第一次采样先建立完整世界

一个新 Session 还没有上下文基线。第一次真实采样前,Runtime 会先收集 Developer Instructions、Skills 目录、扩展贡献和完整 World State,再按片段角色写入历史。用户的实际任务随后与这些上下文一起进入模型输入。

World State 可以理解为“这次采样中,模型需要知道的当前运行状态”。它由多个有稳定 ID 的分区组成,常见内容包括:

分区告诉模型什么
Model / Personality当前模型身份以及必要的行为切换说明
AGENTS.md当前项目和目录范围内适用的开发规则
Permissions可访问范围、审批策略和执行限制
Collaboration Mode当前协作方式和工作流程约束
Environments工作目录、执行环境和可用能力
Apps / Plugins / Tools当前可见的扩展能力或延迟工具
Context Window Guidance在有限上下文预算下应遵循的行为

这些分区不是配置文件目录的镜像,而是模型可见状态的边界。每个分区负责保存足够比较的 Snapshot,并负责把变化渲染成带角色的自然语言片段。

下面的数据流图把首次构造和后续采样放在同一张图中。

Codex 将 Base Instructions、规范化历史、Step World State、工具规格和输出约束组装为 Prompt 的数据流
图 4-1:World State 的完整或增量片段先进入 History,History 再按模型能力规范化;Base Instructions、工具规格和输出约束沿独立通道进入同一个 Prompt。移动端可横向滑动,点击可查看 SVG 原图。

图中有一条容易忽略的约束:World State 和工具规格都来自同一份 StepContext。模型得到“当前允许哪些操作”的文字说明时,它看到的工具列表和 Runtime 随后执行调用所用的 ToolRouter 也属于同一个采样快照。不能用旧权限说明配新工具表,也不能在请求发出后临时换掉路由。

AGENTS.md 是有目录作用域的动态状态

项目规则是 World State 中最容易观察的一类。Codex 从当前工作目录向上寻找项目根,默认用 .git 等根标记确定边界;然后从项目根到当前目录逐层寻找规则文件。

同一层如果存在 AGENTS.override.md,它优先于 AGENTS.md。项目还可以配置后备文件名。最终内容按“根目录规则在前、靠近当前目录的规则在后”的顺序组合,并受总字节预算限制。搜索不会越过项目根。

这套规则解决了两个不同问题:

  • 仓库根规则可以约束整个项目;
  • 子目录规则可以补充当前组件、语言或测试方式。

加载结果由 AgentsMdManager 缓存。环境选择和工作目录没有变化时,可以复用已有结果;选择变化后会重新发现,再把结果放入新的 StepContext。因此,不能把它描述成“每次采样都无条件重读磁盘”,也不能把它当作 Session 启动后永远不变的常量。

当适用的项目规则变化时,Codex 不只是再追加一份新文本。AgentsMdState 会明确告诉模型:新内容替换此前的 AGENTS.md 指令。如果当前范围不再有规则,它会明确撤销旧指令。否则,模型可能同时保留两套互相冲突的目录规则,却不知道哪套已经失效。

后续 Step 只发送变化

完整注入能建立正确起点,但不能在每次采样前原样重复。项目规则、权限和工具说明可能很长,重复发送既占上下文,也会破坏稳定的请求前缀。

World State 为此保留一份紧凑 Snapshot。后续 Step 构造新状态后,Runtime 按分区比较前后快照:

  • 分区没有变化:不生成模型消息;
  • 分区新增:生成首次说明;
  • 分区变化:生成更新或替换说明;
  • 分区消失:生成撤销说明。

与此同时,当前 Snapshot 会与旧 Snapshot 计算一份 RFC 7386 JSON Merge Patch,用于 Rollout 持久化。这里有一个必须分清的边界:模型收到的是各分区渲染出的上下文片段,Rollout 保存的是推进状态基线的结构化 Patch。JSON Patch 本身不是发给模型阅读的 Prompt。

更新顺序也有意固定。Codex 先把模型可见变化记录到 History,再持久化描述该变化的 World State Patch。这样恢复过程不会先看到“基线已经改变”,却找不到让模型知道这一变化的上下文消息。

Codex World State 从完整注入、无变化去重到增量更新,以及历史替换后重建基线的时序
图 4-2:首次采样建立完整基线;状态不变时不追加任何内容;状态变化时先写模型可见片段,再持久化 Patch。History 整体重写后旧基线失效,下一边界重新建立完整上下文。

把实现压缩成接近 Python 的伪代码,可以看得更清楚:

async def prepare_prompt(turn: TurnContext) -> Prompt:
    step = await capture_step_context(turn)
    current = await build_world_state(step)

    fragments, patch = history.diff_world_state(current)
    if fragments:
        history.record(merge_by_role(fragments))
    if patch is not None:
        await rollout.append(patch)

    model_input = history.snapshot().for_prompt(
        input_modalities=turn.model.input_modalities
    )
    return Prompt(
        base_instructions=session.base_instructions,
        input=model_input,
        tools=step.tool_router.model_visible_specs(),
        parallel_tool_calls=turn.model.supports_parallel_tool_calls,
        output_schema=turn.final_output_schema,
    )

实际实现还区分首次 Turn、同一 Turn 内的后续 Step 和压缩后的新上下文窗口,但核心顺序不变:先捕获一致的 Step,更新模型可见状态,再从 History 生成请求快照。

发送前还要修复 History 的结构

History 已经按顺序记录,为什么不能直接交给模型?因为持久化历史允许出现对恢复有意义、却不一定满足模型 API 契约的边界状态。

最典型的情况是工具调用被中断。历史里可能已有 FunctionCall,进程却在写入结果前停止。如果下一次请求只带这半对记录,模型和 API 都无法判断调用是否仍在执行。

ContextManager.for_prompt() 会在发送副本上执行规范化:

  1. 调用缺少结果时,紧跟调用插入一个内容为 aborted 的合成结果;
  2. 结果找不到对应调用时,移除这条孤立结果;
  3. 裁剪历史时,调用和结果作为一对处理;
  4. 当前模型不支持图片或音频时,按输入能力剥离或替换相关内容。

这些合成修复只服务于本次 Prompt,不会反向伪造持久化历史。合成结果的 ID 又由原调用 ID 稳定派生,所以同一份历史在重试或恢复时会生成同一个修复项。这不仅维持结构正确,也减少了请求前缀无意义变化对 Prompt Cache 的影响。

大型工具输出还会在记录历史时按模型策略截断。对 Coding Agent 而言,这一点很实际:一次测试可能输出几万行日志,模型通常只需要错误附近的部分。如果每次都把完整 stdout 带回后续 Step,真正重要的项目规则和用户意图反而会被挤出上下文窗口。

压缩不是简单删除旧消息

上下文窗口终究有限。Codex 会结合 Base Instructions 和 History 估算当前占用,并在达到相应边界时进入压缩流程。压缩可以把较长历史替换成摘要和保留项目,但它也改变了此前用于差异比较的前缀。

因此,ContextManager 只要整体替换 History,就会提升 history_version 并清空 World State 基线。新的上下文窗口会插入规范的初始上下文,或者由下一次正常 Turn 在发现无引用基线后进行完整重注入。回滚如果裁掉了建立基线的混合上下文,也必须采取同样做法。

这个选择看起来保守,却比继续使用过期基线安全。假如摘要里已经没有旧权限说明,而 Runtime 仍认为模型“早就知道”,后续又只发送一个很小的差异,模型实际看到的状态就会缺一块。重建完整上下文要多花一些 Token,但能重新对齐模型视图和 Runtime 视图。

增量更新与稳定合成 ID 对缓存友好,压缩和回滚则会主动牺牲一部分前缀稳定性来换取正确性。两者并不矛盾:缓存只能优化一份语义完整的 Prompt,不能成为保留错误基线的理由。

调试 Prompt 要沿同一条编译链

检查 Prompt 时,只打印用户最后一句话没有意义。Codex 内部的调试入口会创建临时 Session,捕获 StepContext,记录初始上下文和用户输入,再调用 for_prompt()build_prompt()。它复用了正常请求的关键准备链路,而不是另写一个“看起来差不多”的拼接器。

遇到模型行为异常时,也可以沿这条链逐层定位:

现象优先检查
项目规则没有生效当前环境选择、工作目录、AGENTS.md 发现结果和替换消息
模型仍按旧权限行动StepContext 是否刷新,World State 是否生成权限差异
工具结果像是丢失History 中调用与结果的 call_id,发送前是否被规范化
压缩后模型忘记运行环境History 替换后是否重建引用基线和完整 World State
模型能看到工具却无法执行ToolSpec 与 ToolRouter 是否来自同一个 StepContext

最后一种现象已经跨到下一章和再下一章的边界:本章只确认工具规格怎样进入 Prompt,还没有解释模型请求怎样通过网络发出,也没有解释返回的工具意图怎样变成真实操作。

上下文编译把“记忆”与“现状”分开

现在可以重新理解模型眼中的世界:

  • Base Instructions 提供 Session 级基础行为;
  • History 保存已经发生的消息、推理、调用和观察;
  • World State 描述当前 Step 仍然成立的动态事实;
  • ToolSpec 与输出 Schema 保留结构化协议;
  • ContextManager 在发送前保证历史满足模型输入契约。

History 回答“此前发生了什么”,World State 回答“现在仍然是什么”。前者主要追加,后者必须能够替换和撤销。把它们分开后,Codex 才能在长期会话中既保留行动轨迹,又不会让过期的项目规则、权限和环境永久生效。

到这里,总览层的请求内容已经准备完成。细节层 4.25—4.27 还会继续追到缓存键、Previous Response 增量约束和 Responses Request 字段映射;下一章再展开 SSE 与 WebSocket 怎样传回事件,半完成响应、重试和连接复用怎样影响一次采样的边界。

本章细节导航

延伸阅读

评论


← 返回文章列表