从一份真实的 Codex 会话日志,看懂 Agent 的上下文都花在哪了

最近了解codex每轮对话到底携带了什么",于是把它的 rollout 日志(就是 Codex 每轮对话落盘的那份 JSONL,里面记录了每一次真实的 LLM 请求、token 用量、function call 和返回值)翻了一遍。翻完之后发现,光是这一份日志,就能把网上讨论得很热闹但大多停留在"听说"层面的三件事讲清楚:

  1. Agent 的系统上下文到底装了什么、占多少 token;
  2. MCP 工具是不是一次性全塞进上下文,还是按需加载;
  3. 所谓"渐进式工具发现"(tool search)底层到底是不是 RAG。

这篇东西不是把日志丢给模型让它总结一遍,而是我自己逐条对着 response_item / function_call / token_count 这几类结构化事件核对出来的,能用日志证实的和只是模型嘴上说说的,会分开写清楚。文末附了 OpenAI 官方文档链接,结论不是我编的。


一、系统提示词真的"很重"吗?拆开看看四块都是什么

打开日志的第一反应是:这也太长了。但"长"不等于"贵",得先看清楚这一堆文本里到底装了什么。

Codex 会话开场的固定上下文可以拆成四类:

来源 位置 字符数 装的是什么
base_instructions(系统提示词) session_meta.payload.base_instructions.text 18,501 Codex 的人格设定、工程判断准则、编辑约束(ASCII 输出、apply_patch 用法、禁止 git reset --hard 之类的红线)、格式规则
skills_instructions(技能目录) 第 2 条 response_itemrole: developer 14,585 本机 .codex/skills 下已安装的 30 个技能,每个只有一句话描述 + 文件路径
AGENTS.md + environment_context 第 3 条 response_item 858 + 518 仓库自定义的项目规则、cwd/shell/timezone/沙箱权限等运行环境信息
会话历史 逐轮累积 随轮次线性增长 用户提问、助手回答、隐藏的 reasoning token、工具调用与返回值

前三类是每次开新会话都会背上的"固定行李",第四类才是真正随着对话往前滚雪球的部分。

实测数据说话

日志里每次真实发生的 LLM API 调用后面都跟着一条 token_count 事件,model_context_window 是 258,400。四轮问答一共触发了 7 次真实调用(第 2、3 轮各自内部还有工具调用和追加推理,所以拆成了多次):

# 触发点 本次 input 本次 cached cache 命中率 本次 reasoning 累计 total
1 第 1 轮首次调用 16,147 4,480 27.7% 53 16,335
2 第 2 轮首次调用 16,316 15,744 96.5% 120 32,862
3 第 2 轮读取 SKILL.md 后 28,895 15,744 54.5% 660 63,018
4 第 3 轮首次调用 18,291 15,744 85.9% 177 81,587
5 第 3 轮再次读取同一份 SKILL.md 25,745 17,792 69.1% 87 107,455
6 第 3 轮追加推理 22,044 17,792 80.7% 20 129,552
7 第 3 轮生成最终长回答 38,415 21,888 57.0% 743 169,479

四轮对话结束,累计 169,479 tokens,吃掉了 258,400 上下文窗口的 65.6%。这个比例对一次只有四轮的对话来说,确实偏高了。

三个站得住的结论

第一,系统提示词的文本本身没那么大,真正吃 token 的是日志里"看不见"的部分。base_instructions(18,501 字符)加技能目录(14,585 字符)加 AGENTS.md/环境上下文(1,376 字符),按中英混排大致 3.5 字符/token 折算,理论上应该在 9K tokens 左右,但第一轮实测 input 是 16,147。多出来的这将近 7K tokens,日志的文本记录里完全找不到来源——最合理的解释是工具的 JSON Schema 定义shell_commandapply_patchtool_search 以及 MCP 工具目录条目)。OpenAI 自己的文档也印证了这一点:函数定义会被注入到系统消息里,采用模型训练时见过的语法,这意味着可调用的函数定义本身就计入模型的上下文上限,并按输入 token 计费。这部分不会出现在对话记录里,却实打实占预算,是分析 Agent 上下文时最容易漏掉的一块。

第二,系统提示词的边际成本会迅速趋近于零。 第 1 轮 cache 命中率只有 27.7%(首次请求,前缀刚写入缓存),到第 2 轮第一次调用直接跳到 96.5%。这不是巧合,OpenAI 的 prompt caching 机制就是这么设计的:当一个符合条件的请求被路由到最近处理过相同前缀的机器上时,服务会直接复用缓存结果而不用重新处理这部分内容,且对所有支持的模型自动生效。也就是说,只要系统提示词+技能目录+历史消息这个前缀保持不变,它对后续每一轮请求几乎是免费的——“占用大"不等于"成本大”。

第三,真正在烧上下文窗口的是没被复用的工具调用和高强度推理。 openai-docs/SKILL.md 只有 5,524 字符(约 1,400 tokens),却在第 2 轮和第 3 轮被完整读取了两次,每次读取都伴随一轮 reasoning,单次就能拉高 input 12K~13K tokens;再加上 reasoning_effort: high 下每次调用产生的 reasoning token 会作为历史被重新计入下一次请求。四轮对话里,第 2 轮单轮就多消耗了约 46,683 tokens,第 3 轮更是多消耗了约 106,461 tokens。如果这是个长期运行的 Agent 会话,真正该优化的不是系统提示词,而是"同一份参考资料要不要每轮都重新读一次"和"reasoning_effort 是否要一直开到 high"这两个会线性甚至超线性增长的开销点。


二、MCP 工具是全量塞进去的,还是按需加载?

这个问题日志里有直接证据。用户仓库的 AGENTS.md 里写着:

- MCP tool(when available): `codegraph_explore` 一次调用即可回答大多数代码问题……
  Name a file or symbol in the query to read its current line-numbered source.
  If it's listed but deferred, load it by name via tool search.

skills_instructions 里也有同源的设计原则:“Progressive disclosure applies to selecting relevant files, not partially reading a selected instruction file.”

这两处都指向同一件事:"能力目录"和"能力的完整实现"是分离的两层,OpenAI 管这套机制叫 tool search,官方定义是:用 tool search 来延迟加载体量较大的工具集,用命名空间把相关工具分组,只在运行时加载真正相关的函数

机制分三层

  1. 目录层(deferred):会话开始时,模型只拿到"有哪些工具命名空间/能力簇"的高层描述,不是每个工具完整的参数 JSON Schema。OpenAI 文档明确写了这个细节:对于命名空间或 MCP server,模型一开始只能看到命名空间或 server 的名字和描述,看不到内部具体函数的细节,直到 tool search 把它们加载进来;对于单个被延迟加载的函数,模型看到的粒度更细
  2. 检索层(tool_search):任务语义命中某类能力时,模型主动发起一次 tool_search,用关键词或语义查询去检索候选工具。
  3. 加载层(inject):命中的工具完整 schema 才会被追加注入到上下文末尾,模型此后才能真正对它发起 function_call。没被检索命中的工具,完整定义自始至终不会进上下文。

这套机制的收益是直接的:如果一个 Codex 实例同时接了 CodeGraph、浏览器控制、Node REPL、Excel 控制等十几个 MCP server,每个工具的 JSON Schema 通常几百到上千 token,全量塞进去光工具定义就能占掉几万 token。渐进式发现把这部分成本从"固定预算"变成了"按需预算"。

日志里抓到的一次真实调用

理论讲完了,日志里正好有一次真实发生tool_search,不是模型自述,是结构化事件,可以逐条对账。

触发场景是用户问:“wikimcp 支持写入功能吗”——这是一个内部的 Confluence Wiki MCP server,不在 skills 目录里列出,只能通过 tool search 被发现。

第一次 LLM 调用(input=16,145, cached=4,480)产出了一段 reasoning 加一个结构化的 tool_search_call

{
  "type": "tool_search_call",
  "call_id": "call_wxcmxC3Zr9kcihFw607MWsv2",
  "status": "completed",
  "execution": "client",
  "arguments": {
    "query": "wiki mcp write update create page",
    "limit": 10
  }
}

"execution": "client" 这个字段直接对应 OpenAI 文档里说的两种执行模式之一:Hosted 模式下 OpenAI 在服务端搜索请求里声明的延迟工具并在同一次响应里返回加载结果;Client-executed 模式下模型只发出一个 tool_search_call,由应用自己完成检索,再返回匹配的 tool_search_output。这次是客户端执行,检索这一步是 Codex CLI 本地跑的,不是 OpenAI 托管。

query 是模型自己生成的:“wiki mcp write update create page”,主动把 write/update/create 也塞进去了,因为用户问的是"支不支持写入"。query 的质量完全取决于模型自己怎么构造,不是系统帮你补全的,这是实际用起来很容易忽略的一个细节。

客户端返回的 tool_search_output 长这样:

{
  "type": "tool_search_output",
  "call_id": "call_wxcmxC3Zr9kcihFw607MWsv2",
  "execution": "client",
  "tools": [
    {
      "type": "namespace",
      "name": "mcp__wiki",
      "description": "Tools in the mcp__wiki namespace.",
      "tools": [
        { "name": "wiki_check_connection", "defer_loading": true },
        { "name": "wiki_search", "defer_loading": true },
        { "name": "wiki_read_page", "defer_loading": true }
      ]
    }
  ]
}

有两个结构性设计值得留意:一是返回结果按 MCP server 分层,外层是 namespace,内层才是具体工具,方便模型按 mcp__server__tool 的方式调用;二是每个工具即便被命中注入,依旧带着 "defer_loading": true——命中之后拿到的是完整的工具定义(含 description、parameters schema),但这个标志位没被清掉,更像是一个供后续 UI/日志标注"这是通过发现机制拿到的"的元数据,而不是加载状态开关。

加载后新增了多少成本

拿到 tool_search_output 后,模型直接生成了最终回答,没有再调用任何 wiki_* 工具。第二次调用的数据:last_input_tokens: 16,656last_cached_tokens: 15,744(94.5%)。对照第一次调用的 input=16,145,可以算出:从发起检索到带着 3 个工具定义再问一次模型,新增的、没被缓存命中的部分只有约 900 tokens。这 900 tokens 里打包了上一轮的 reasoning 摘要、tool_search_call 本身、以及 3 个工具完整的 name/description/parameters schema。

这个数字很好地印证了渐进式发现的核心价值——一次性把某个 MCP namespace 的能力注入上下文,成本是几百 token 级别,而不是要为"可能用到"的所有 MCP server 预先支付几万 token。

顺带说一句,这也是一个能证明"没有幻觉"的细节:用户问"支持写入吗",检索结果里确实没有任何写类工具,mcp__wiki 命名空间只注册了三个只读工具,模型最终如实回答"不支持写入"。这不是模型"知道"这个 MCP 该有哪些工具,而是它真搜了一遍、看到返回结果里没有写类工具,才据实回答的——这正是"渐进式发现"相对于"训练时记住的知识"的关键区别:它反映的是当前会话里 MCP server 实际注册了什么,而不是模型脑子里以为它该有什么。


三、tool search 底层是不是 RAG?

这个问题日志能证实一部分,也有一部分只能存疑,得分开说。

能证实的:两种执行模式,MCP 和 tool search 是两件事

上一节已经用真实调用验证了 hosted 和 client-executed 两种模式的存在。另外一个经常被搞混的点是 MCP 和 tool search 的关系——很多人以为这是同一个机制,其实是正交的两层。MCP 是一个开放协议,正在成为给 AI 模型扩展工具和知识的行业标准,远程 MCP server 可以让模型通过互联网连接到新的数据源和能力,它解决的是"接入"问题:Codex 通过 MCP 接入第三方能力,可能是本地 STDIO 进程,也可能是远程 Streamable HTTP 服务。tool search 解决的是"发现与加载"问题:当接入的 MCP server 和工具数量变多时,避免把所有工具定义一次性塞进上下文预算。两者经常被放在一起讨论,但分别对应接入层和上下文管理层,不是同一个机制。

存疑的:具体检索算法是不是 BM25

Codex 助手在日志里自己说过"这次环境用的是 BM25 关键词检索,不是生成式 RAG,也不是小模型判断"。但这个结论来自它对 tool_search 工具描述文本的复述,不是这份日志能独立验证的事实——因为这场会话里真实发生的 tool_search 只有一次,返回的只是"输入 query → 输出命中列表",检索排序逻辑对客户端调用方来说是黑盒,OpenAI 官方文档在这一层也没有强制约定实现方式,只是给出了原则性建议:如果已经在创建请求时就知道候选工具,优先用 hosted tool search;如果工具发现依赖项目状态、租户状态或应用自己掌控的其它系统状态,就用 client-executed tool search。具体检索用倒排索引、向量库还是别的,官方把这一层完全交给调用方自己实现。

要严谨确认"是不是 BM25",需要在一个真正配置了多个 deferred MCP 工具、且能拿到检索实现源码的会话环境里去看,单靠一份 rollout 日志的 function_call/function_call_output 记录是看不到排序算法内部实现的。所以这里给出的最诚实的结论是:机制的设计思想(deferred + 按需检索 + 尾部注入)在日志里有真实调用作为旁证,但"用的是哪种具体检索算法"这个实现细节,本次日志不足以下结论,只能作为模型自述转述,不能当成实测结果引用。


小结

把这三件事串起来看,其实是同一套工程哲学的三个侧面:能不复用的东西就别放进热路径,能延迟加载的东西就别提前加载

  • 系统提示词看着长,但只要前缀稳定,prompt cache 会把边际成本压到接近零;真正该盯的是"每轮要不要重复读同一份参考资料""reasoning_effort 要不要一直开到 high"这类线性甚至超线性增长的部分。
  • MCP 工具不是全量塞进上下文的,渐进式发现把"目录"和"完整定义"拆成两层,命中后再注入,实测一次命中带来的边际成本只有几百 token。
  • tool search 的两种执行模式(hosted / client-executed)和 MCP 本身是正交的两个机制;具体检索算法这层官方文档不做限定,也不该轻信模型自己嘴上说的"用的是 BM25",没有真实调用做交叉验证之前,这只是一个待验证的说法。

参考资料

Logo

葡萄城是专业的软件开发技术和低代码平台提供商,聚焦软件开发技术,以“赋能开发者”为使命,致力于通过表格控件、低代码和BI等各类软件开发工具和服务,一站式满足开发者需求,帮助企业提升开发效率并创新开发模式。

更多推荐