模型收到的指令从哪里来
沿着 rust-v0.144.6 的源码,拆开一次 Turn 里 base instructions、开发者指令、用户指令、AGENTS.md、personality 与运行时上下文如何汇入 prompt。
在一次 Turn 真正发给模型之前,Codex 要处理的不只是当前输入框里的文字。模型自己的基础指令、调用方追加的 developer instructions、Codex home 下的用户级说明、仓库里的 AGENTS.md、personality,以及权限和技能等运行时片段,会从不同入口到达同一个 prompt。它们的来源、角色、注入时机和刷新条件都不相同。
如果只在最终请求里搜索某一句话,很容易把几件事混到一起:文本在配置里存在,不代表已经进入模型上下文;文本进入 prompt,也不代表它拥有运行时强制力;某个片段在本轮被计算出来,也不代表 TurnContext 本身已经成为 durable history。本章先建立一份指令账本,再顺着组装路径核对这些边界。下一章才继续追踪 history 如何裁剪成 prompt,第 10 章再落到 Responses 请求的 wire 结构。
Instruction ledger
| source | model-visible role | injection time | deduplication rule |
|---|---|---|---|
| Base instructions | top-level instructions for ordinary Responses; developer input item for Responses Lite | session construction chooses the text; each prompt carries it through the dedicated base field | one base value is selected by precedence: config override, then session-history metadata, then current model default |
| Developer instructions | developer | a non-empty value is added while building the full initial context, before fresh turn input | the explicit steady-state settings diff list does not include developer_instructions; this does not promise automatic replacement |
| Host-provided user instructions | user, inside the marked user-instructions fragment | root runtime snapshots the UserInstructionsProvider; loaded before project documents are assembled | provider chooses one global file, preferring AGENTS.override.md over AGENTS.md; it is kept as a separate host entry |
| AGENTS.md project docs | user, inside the marked user-instructions fragment | discovered for the environment selection and injected through the turn’s context updates | at most one file per directory; candidate filenames are deduplicated, but document contents are not globally deduplicated |
| Personality spec | developer | added during turn context assembly only when the feature is enabled and personality is not baked into base instructions | the separately generated fragment is omitted when personality is already baked into the selected base instructions |
| Runtime context fragments / turn input | mixed: context fragments use their declared roles; fresh turn input is user | run_turn records context updates first, then fresh input, then other injection items, and only afterwards clones history for the prompt | each 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_instructions、personality、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;ResponseItem 经 record 记录后进入 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 extraction | history / sampling |
|---|---|---|
| 首次 input | 每次 run_turn 都用该次调用的 input 参数执行 build_skills_and_plugins;首次调用的内容参与 mention extraction | hooks 未阻断时记录 fresh input,供本次首个 sampling |
| steer / pending input | RegularTask 以空 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 输入;它不是把 TurnContext 或 StepContext 自动序列化进去。也因此,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;RegularTask 空 Vec 重入时函数仍然执行,但 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”有一个严格的对象边界:ResponseItem 经 record 记录后进入 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 实现来回答。