青雲的博客
拆开 Codex 第二部:一次 Turn 怎样进入模型 第 08 章

模型收到的指令从哪里来

沿着 rust-v0.144.6 的源码,拆开一次 Turn 里 base instructions、开发者指令、用户指令、AGENTS.md、personality 与运行时上下文如何汇入 prompt。

源码版本
rust-v0.144.6
验证日期
Commit
5d1fbf26c43abc65a203928b2e31561cb039e06d

在一次 Turn 真正发给模型之前,Codex 要处理的不只是当前输入框里的文字。模型自己的基础指令、调用方追加的 developer instructions、Codex home 下的用户级说明、仓库里的 AGENTS.md、personality,以及权限和技能等运行时片段,会从不同入口到达同一个 prompt。它们的来源、角色、注入时机和刷新条件都不相同。

如果只在最终请求里搜索某一句话,很容易把几件事混到一起:文本在配置里存在,不代表已经进入模型上下文;文本进入 prompt,也不代表它拥有运行时强制力;某个片段在本轮被计算出来,也不代表 TurnContext 本身已经成为 durable history。本章先建立一份指令账本,再顺着组装路径核对这些边界。下一章才继续追踪 history 如何裁剪成 prompt,第 10 章再落到 Responses 请求的 wire 结构。

Instruction ledger

sourcemodel-visible roleinjection timededuplication rule
Base instructionstop-level instructions for ordinary Responses; developer input item for Responses Litesession construction chooses the text; each prompt carries it through the dedicated base fieldone base value is selected by precedence: config override, then session-history metadata, then current model default
Developer instructionsdevelopera non-empty value is added while building the full initial context, before fresh turn inputthe explicit steady-state settings diff list does not include developer_instructions; this does not promise automatic replacement
Host-provided user instructionsuser, inside the marked user-instructions fragmentroot runtime snapshots the UserInstructionsProvider; loaded before project documents are assembledprovider chooses one global file, preferring AGENTS.override.md over AGENTS.md; it is kept as a separate host entry
AGENTS.md project docsuser, inside the marked user-instructions fragmentdiscovered for the environment selection and injected through the turn’s context updatesat most one file per directory; candidate filenames are deduplicated, but document contents are not globally deduplicated
Personality specdeveloperadded during turn context assembly only when the feature is enabled and personality is not baked into base instructionsthe separately generated fragment is omitted when personality is already baked into the selected base instructions
Runtime context fragments / turn inputmixed: context fragments use their declared roles; fresh turn input is userrun_turn records context updates first, then fresh input, then other injection items, and only afterwards clones history for the prompteach fragment follows its own world-state/context rule; identical AGENTS state is skipped, changed state replaces it, and removed state emits removal

这张表有意不写一条统一的“优先级链”。这些文本不都在同一个数组里互相覆盖:base instructions 是 Prompt 的独立字段,developer 与 user 片段则以不同角色成为输入项;同一来源的刷新规则也可能发生在 provider、manager cache 或 world-state 三个不同层面。真正可靠的读法,是先问它来自哪里,再问它何时被转换成模型可见的 ResponseItem

一张图:两条汇入路径

ledger 里六个来源不是挤进同一个数组,而是分两条路径到达 Prompt:base instructions 走独立字段,其余按各自 role 先记录成 ResponseItem,等 run_turn 记录完 context、skills/plugins、hooks 与 injection items,才 clone history 汇入。

flowchart TB
  accTitle: 六个指令来源经两条路径汇入一次 Prompt
  accDescr: base instructions 由 session 按 override、history、默认的优先级选定,走 Prompt 的独立 base_instructions 字段;developer、host user instructions、AGENTS.md、personality 和运行时 context 片段按各自 role 记录成 ResponseItem;run_turn 依次记录 context updates、skills 与 plugins、hooks 与 fresh input、injection items,再 clone_history().for_prompt() 汇入 Prompt,最后由 client 映射成 wire 请求。
  BASE["Base instructions\nsession 按 override→history→默认 选一份"] --> BFIELD["Prompt.base_instructions\n独立字段"]
  DEV["Developer instructions\ndeveloper role"] --> REC
  HOST["Host user instructions\nuser role"] --> REC
  AGENTS["AGENTS.md 项目文档\nuser role"] --> REC
  PERS["Personality\ndeveloper role(未 baked 时)"] --> REC
  CTX["运行时 context 片段 / fresh input\n混合 role,fresh input 为 user"] --> REC
  REC["run_turn 记录顺序\ncontext updates → skills/plugins\n→ hooks + fresh input → injection items"] --> HIST["conversation history\nResponseItem"]
  HIST --> FP["clone_history().for_prompt()"]
  BFIELD --> PROMPT["Prompt"]
  FP --> PROMPT
  PROMPT --> WIRE["client wire 映射(第 10 章)\n顶层 instructions 或 Lite developer item"]

Base instructions:先选值,再进入独立字段

会话创建时,Codex 先决定本次会话采用哪一份 base instructions。session/mod.rs 的分支顺序很明确:显式配置的 override 优先;没有 override 时,如果从已有 session history 恢复且 metadata 带有 instructions,就沿用那一份;两者都没有,才读取当前 model family 的默认 instructions。这里解决的是“选哪份文本”,还没有把所有其他指令揉进来。

这个选择需要和 Prompt 的数据结构一起看。turn.rs 里的 Prompt 有单独的 base_instructions 字段,当前输入与历史项则在另一个输入集合中。也就是说,base 并不是通过伪造一条普通 user message 进入 prompt 的。这个结构边界也解释了为什么恢复旧会话时会优先使用 metadata 中保存的 instructions:如果模型目录或本地默认值后来变化,旧会话仍能保持它原本使用的基础契约。

到了 client 层,普通 Responses 请求会把这份字段映射为顶层 instructions。Responses Lite 的协议形状不同:client 会把它转换成一条 developer-role input item。这是传输适配,不代表上游选择规则发生了变化。第 10 章会继续拆开这层 wire mapping;本章只需要记住,源码里的“独立 base field”和线上请求里的“顶层字段”并非对所有 transport 都一一对应。

这也给调试划出第一条边界:看到模型回复遵循某句 instruction,只能说明某段模型可见文本可能生效;不能据此倒推出它来自 model default、会话 metadata,还是本轮配置 override。要确认来源,必须回到 session construction 的选择分支。

两种 user instructions:host 快照与项目文档

名字最容易误导人的地方,是 user_instructions 并不只指输入框里的新消息。本章讨论的 host-provided user instructions,是 root runtime 创建时从 UserInstructionsProvider 取得的快照。global Codex home provider 会按顺序尝试:优先取非空的 AGENTS.override.md;否则继续尝试 AGENTS.md。这是按候选顺序逐个检查,不是把两份文件串起来。

随后 LoadedAgentsMd 才把 host 内容和项目发现结果放到同一个装载结果里,但仍保留二者的来源边界。组装文本时,host entry 在前,project entries 在后;对应的 source metadata 也按这个次序产生。这里的“在前”是可由专门测试证明的装载顺序,不应该扩大解释成完整 prompt 中所有 instruction 的全局优先级。

项目文档走另一条路径。发现过程从项目 root 朝当前工作目录前进,不会越过 root。每一层目录都按候选名顺序查找:override、AGENTS.md,再到配置的 fallback names;找到第一个实际文件后就 break,因此一层最多贡献一份文档。同一目录采用第一个匹配的候选文件。越靠近 cwd 的文件只是出现在后面,源码没有在这里声明一句笼统的 “nearest wins”。后面的内容是否影响模型行为,仍然取决于模型如何理解这一串 user-role 指令。

这里还有一个常见误读:candidate_filenames 的去重针对的是候选文件名。例如 fallback 配置里重复写了 AGENTS.md,不应让同一目录检查两次同名文件。它并不会比较不同目录中文档的正文,更不会在全局按内容 hash 消除重复。两个层级的文件恰好写了相同段落时,它们仍是两个有独立 source 的 project entries。

缓存命中不等于永不刷新

AgentsMdManager 用 environment selection 作为 cache key。相同 selection 再次请求时直接复用已有结果,不重新读取磁盘。这里的 selection 包含工作目录等会影响发现范围的环境选择,因此切换环境会重新加载并覆盖当前缓存;仅仅在同一路径下编辑文件,并不会自然让同一个 key 失效。

固定版本里有一个窄而明确的刷新点:启用 DeferredExecutor 时,step capture 会调用 manager 的 refresh 检查;如果 environment selection 相同,manager 立即返回,只有 selection 改变才重载。因此编辑同一路径文件不会自动重读。不能把这件事写成“每个 Turn 都重读 AGENTS.md”,也不能写成“启动后永远不会变化”:准确说法是,deferred step capture 会触发检查,而重载仍受 selection cache 约束。

片段的 role 决定它怎样被看见

AGENTS.md 装载结果不会神奇地变成一条 system message。UserInstructions 实现了 ContextualUserFragment,它明确返回 user role,并用 # AGENTS.md instructions</INSTRUCTIONS> 包住正文。因此测试或日志里看到这段文本时,正确的描述是“user-role 的上下文片段”,不是顶层 developer message。Responses 顶层 instructions 字段不承载全部指令;它只承载被选中的 base instructions。

所以,AGENTS.md 不是顶层 developer message。它当然可以影响模型的下一步判断,但这件事发生在模型解释 user-role 内容之后;文件本身没有越过角色边界取得运行时权限。把它重写成“仓库 system prompt”会同时丢掉 source provenance 和 role 信息。

world state:相同就不重发,变化就替换

文件发现和片段注入之间还有一层 world state。AgentsMdState 保存的是当前模型可见的 AGENTS 内容快照,快照里只有 directory 与 text,不把文件系统 provenance 当成持久化文本的一部分。渲染 diff 时,如果 previous snapshot 与 current 完全相同,直接返回 None,本轮不再注入;如果有旧内容且新内容存在,会先放一条 replacement notice,再放新的正文;如果旧内容被移除,则发 removal notice。

这解释了 ledger 里“去重”一栏为什么不能只写 deduplicate all text。manager 的缓存是在文件加载层按 environment selection 复用;world state 的相等判断是在模型可见片段层决定是否再次发出 update。两者都减少重复工作,却处理不同的对象。一个 selection 命中 cache,并不等于一个已经记录过的 world-state fragment 永远有效。

TurnContext 保存配置,ResponseItem 才留下痕迹

TurnContext 是一次 turn 所需的运行时容器。它同时持有 developer_instructionspersonality、approval policy、permission profile、available models、dynamic tools 和 turn_skills 等字段;在构造阶段,这些值从 session configuration 和本轮环境快照复制进来。第 7 章出现的 thread-settings overrides 已在 handler 层先更新 session settings,本章只观察更新后进入 TurnContext 的结果;output schema 仍保存在 TurnContext,到第 10 章才进入 Prompt.output_schema。这个结构回答“本轮计算时有哪些输入”,不直接回答“模型历史里有哪些消息”。

构造函数的对应区间显示了另一个细节:developer_instructions 从 session configuration clone,personality 和 permission profile 也一并进入新的 TurnContext。这意味着它们可以作为本轮上下文组装的原料,但仍要经过 context update builder 才会成为模型可见的 ResponseItem

这里要避免一个看似顺手的笼统结论:并非所有 context fragment 都“不持久化”。TurnContext 和 request-scoped StepContext 本身不是 durable history;ResponseItemrecord 记录后进入 conversation history,但 durable state 不止这一类,TurnContextItem 等 metadata 还有另一条持久化路径。部分 context updates 会被记录下来,后续 prompt 也可能从 history 读到它们。把运行时容器、conversation history、metadata 三层合并成一个对象,会让 compaction 和 replacement 的行为都无法解释。

developer instructions、personality 与权限不是一回事

build_initial_context_with_world_state_and_mcp 在普通 initial-context 分支先准备 developer sections。若配置允许,permission profile 会被渲染成 PermissionsInstructions,非 guardian reviewer 路径上的非空 developer_instructions 也会加入这组 sections;guardian 路径则在后面单独生成一个 developer item。它们在模型看来是 developer text,但这只是提示层的可见性。PermissionsInstructions 提供的是 model-visible permission context;真实的 permission profile 还会在执行文件系统或网络操作前生成 runtime policy,后者才属于 execution enforcement。instruction text 在 prompt 中不等于 runtime 权限。

personality 也遵守一个明确条件。只有 Personality feature 开启且 turn context 有 personality 时,代码才继续判断当前 base instructions 是否已经 baked in;如果 model 支持 baked personality 且选中的 base 正好包含它,就不再单独追加。否则才构造 PersonalitySpecInstructions。这不是运行时权限开关,也不是对历史消息的全文替换。

权限提示因此应当被当作解释性 developer text 来审计:它可以告诉模型什么操作需要批准,却不能绕过 executor 的 enforcement。真正的 profile、sandbox policy 和 approval handling 属于后面的执行边界,本章不提前把 prompt 语言当成授权结果。

Skills 只追到 catalog 与 mention boundary

技能在本章只到 metadata/catalog。可用技能的目录片段由 AvailableSkillsInstructions 渲染成 developer-role fragment,里面有技能名、路径和使用说明;它告诉模型“有哪些可选能力”,并不等于所有技能正文已经注入。技能正文什么时候展开,留给后面的专项章节。

build_skills_and_plugins 收集的输入范围也很窄:它只从这一轮原始 TurnInput::UserInput 中取 content,忽略已经是 ResponseItem 的历史项和 inter-agent communication。于是,用户在当前输入里明确提到某个 skill,才可能触发显式 body injection;仅仅因为旧 history 里出现过同名词,不会在每一轮扫描全文后再次注入。这里是 mention boundary,不是完整的 skills discovery/injection 说明。

输入路径mention extractionhistory / sampling
首次 input每次 run_turn 都用该次调用的 input 参数执行 build_skills_and_plugins;首次调用的内容参与 mention extractionhooks 未阻断时记录 fresh input,供本次首个 sampling
steer / pending inputRegularTask 以空 Vec 重入时仍调用 build_skills_and_plugins;pending 内容不作为该函数的 input由当前 inner loop drain,或只在 run_turn 返回后重入再 drain;hooks 未阻断时进入 history,供后续 sampling

完整机制放在第 20 章:Skills 怎样发现并注入正文。本章只需要保留两个事实:catalog 是 developer context,explicit mention 才是本轮正文注入的入口之一。不要把 catalog 行误报成已经加载了每个 SKILL.md

run_turn 的顺序:先记录,再取 prompt

预采样 compact 完成后,run_turn 才进入首个 sampling step:先 capture 当前 step context,并通过 record_context_updates_and_set_reference_context_item 把 world-state/context updates 记录下来;随后调用 build_skills_and_plugins,再执行 hooks 并记录本轮 fresh input。之后,显式 injection items 逐项以 ResponseItem 记录进 conversation history。顺序决定了哪些更新会被当前请求看到,也决定了后续 turn 有哪些可恢复的记录。

在采样前的窄窗口里,代码才调用 sess.clone_history().for_prompt(...)。这一步把已经记录的 history 转成当前模型支持的 prompt 输入;它不是把 TurnContextStepContext 自动序列化进去。也因此,context fragment 是否会在下一轮出现,要看它是否被转换成可记录的 conversation item,以及 history/compaction 如何处理它。

这条顺序也解释了为什么不能用“本轮算过”替代“本轮可见”。一个 fragment 可能先在 step capture 中被计算,再因为 snapshot 相同而不产生新 item;另一个 fragment 可能在 hooks 后才被记录,最终随 history 进入 sampling input。调试时应同时看 update event、recorded ResponseItem 和最终 request,而不是只看 TurnContext 的 debug 输出。

每次 run_turn 都会先用自己的 input 参数调用 build_skills_and_plugins。首次调用拿到原始 input;RegularTaskVec 重入时函数仍然执行,但 pending 内容不在这次 input 里,要等后面的 inner loop drain。

第一条路径是在当前 inner loop 里 drain:run_turn 从 queue 取出 pending input,交给 hooks 与 record。第二条路径只在 run_turn 返回后发生:task 边界仍看见 pending 时,RegularTask 以空 Vec 重入同一 TurnContext,随后再由 inner loop drain。在 hooks 未阻断的正常路径上,两者才进入 history,供后续 sampling;区别只在由哪一层继续持有执行权。

这里的“进入 history”有一个严格的对象边界:ResponseItemrecord 记录后进入 conversation history;TurnContext 和 request-scoped StepContext 本身只是 runtime object,而 TurnContextItem 等 metadata 另有 durable state 路径。TurnContextItem 记录 cwd、时间、权限、模型、personality 与协作模式等本轮元数据,不包含 skills snapshot;它不会因为被 clone 或被传给 sampling 函数就自动变成 conversation history。反过来,已经记录的 ResponseItem 可能在下一轮由 history clone 读回,也可能在 compaction 时被裁剪,所以“曾经记录”与“当前请求可见”仍然是两个问题。

一个小实验能证明什么

实验从第二部导读创建的 disposable archive 运行;固定 checkout 只负责提供已校验的源码对象:

set -euo pipefail
: "${ARCHIVE_CODEX_RS:?先执行第二部导读的 archive 准备脚本}"
cd "$ARCHIVE_CODEX_RS"
CODEX_TEST_ENVIRONMENT=local just test --locked -p codex-core instruction_sources_include_global_before_agents_md_docs
PASS codex-core agents_md_tests::instruction_sources_include_global_before_agents_md_docs
Summary: 1 test run, 1 passed

这个实验只证明 global/host-provided instruction 内容和它的 source 排在 project AGENTS.md 文档之前;它不证明完整 prompt 的所有 ordering,也不证明 Responses wire 层最终会怎样编码。测试名里的 instruction_sources_include_global_before_agents_md_docs 是一个窄的 source-order assertion,不应被包装成“整个 instruction assembly 已经端到端验证”。

读完本章后的边界

现在可以回答“模型收到的指令从哪里来”,但还不能只凭这张 ledger 预测模型一定怎样行动。来源决定 provenance,role 决定模型可见语义,注入时机决定本轮是否看见,deduplication 决定是否重复发送;runtime permission 则由另一条执行链负责。TurnContext 在本章只提供 per-turn config raw material;WorldState 只用来解释 AGENTS.md snapshot 的相同、替换与移除。

第 9 章不再重复 instruction source assembly。它从本章已经记录的 context items 与 conversation history 出发,继续追 history normalization,直到 build_prompt 形成 Prompt.input第 9 章:History 怎样投影成 Prompt input

本章最值得留下的不是一条新的优先级口诀,而是一种查法:先定位 source,再看 role 和转换点,最后沿 ResponseItem 与 history 验证模型究竟看到了什么。剩下的部分,必须交给下一章的 history 实现来回答。