1. 一份 Codex 会话日志到底能看出什么:Agent 上下文消耗结构拆解
Codex 每跑一轮对话,都会在本地落一份 rollout 日志,格式是 JSONL,里面逐条记录了真实的 LLM 请求、token 用量、function call 和返回值。这份日志最大的价值在于:它把"Agent 的上下文到底花在哪了"从听说层面变成了可对账的结构化数据。你不需要猜,直接读事件就行。
这篇要解决的问题很具体:一次 Codex Agent 会话里,system prompt、tool search、MCP 返回、历史轮次各占多少 token?哪些是固定开销,哪些是滚雪球?怎么用一份可复制的解析脚本把账单拆出来?以及,调整 tool search 策略前后,上下文占用到底差多少?
适合谁看:正在用 Codex 做长期编码任务、发现上下文窗口掉得飞快、想搞清楚钱花在哪的开发者。也适合接了多个 MCP server、想判断"渐进式工具发现"到底省不省的人。
我试过把一份四轮对话的日志逐条对着response_item、function_call、token_count三类事件核对,结论比想象中清晰:系统提示词看着长,但 prompt cache 会把边际成本压到接近零;真正烧窗口的是没被复用的工具调用和高强度推理。下面按可跟做的顺序拆开讲。
核心检索词先摆出来:Codex Agent 上下文消耗结构,指的是单次会话中固定上下文(系统提示词、技能目录、环境信息)与动态上下文(历史轮次、工具调用、MCP 返回、reasoning token)各自的 token 占比。搞清这个结构,才能判断该优化哪里。
2. TaoToken 统一 Key 通道前置配置:让日志里的 token 账单可归因
在拆日志之前,得先保证你的请求走的是统一通道,否则 token 账单会散落在多个 Key、多个 endpoint 上,根本没法归因。TaoToken 在这里的作用是:用一个 Base URL + 一个 Key 覆盖多家模型,Codex 的每次请求都能落到同一份账单里,日志里的 token_count 才能和实际消耗对上。
官网入口:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=
API 地址(不带 UTM):https://taotoken.net/api
先拿 Key。进控制台创建 API Key,路径是 console 页面下的 api-keys:
- 模型对话(验证模型是否通):https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite
- Coding Plan(长期编码/Agent 任务):https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite
- 控制台:https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite
- API Keys:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite
- 接入文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite
- Claude Code / Anthropic 接入:https://taotoken.net/claude-code-anthropic?utm_source=taotoken_aicg_blog_end&utm_content=claude_code&utm_campaign=rewrite
拿到 Key 之后,Codex 侧的配置要写全三件套:Base URL、Key、Model ID。少任何一个,请求要么 401,要么模型名不识别。Codex 的配置通常落在~/.codex/config.toml或项目级配置里,下面给一份可复制的 TOML 片段:
# ~/.codex/config.toml model = "gpt-5-codex" model_provider = "taotoken" [model_providers.taotoken] name = "TaoToken" base_url = "https://taotoken.net/api" env_key = "TAOTOKEN_API_KEY" wire_api = "responses"对应的环境变量:
export TAOTOKEN_API_KEY="sk-你的Key"如果你用的是 Codex 的 auth.json 方式(部分版本走~/.codex/auth.json),结构大致如下,注意 Base URL 和 Key 都要对上:
{ "OPENAI_API_KEY": "sk-你的Key", "OPENAI_BASE_URL": "https://taotoken.net/api" }这里有个容易踩的点:wire_api要和你实际调用的接口形态一致。Codex 走 Responses API 时填responses,走 Chat Completions 时填chat。填错会报reading choices之类的解析错误,因为返回体结构对不上。
配置完成后,先做一次最小验证请求,确认通道是通的,再去看日志。验证命令:
curl https://taotoken.net/api/v1/models \ -H "Authorization: Bearer $TAOTOKEN_API_KEY"返回模型列表就说明 Key 和 Base URL 没问题。这一步不做,后面日志里出现 401 你会分不清是通道问题还是 Codex 配置问题。
为什么强调"统一 Key 通道"?因为 Codex 一次会话会触发多次真实调用(工具调用、追加推理都会拆成独立请求),如果这些请求走了不同 Key,token_count 事件里的累计值就没法对应到单一账单。统一通道之后,日志里的total_tokens和 TaoToken 控制台的用量才能一一对上,归因才成立。
3. 可复制的日志解析脚本:逐段标注 system prompt、tool search、MCP 与历史轮次
Codex 的 rollout 日志是 JSONL,每行一个事件。要拆 token 账单,核心是抓三类事件:response_item(对话内容,含 system/developer/user/assistant)、function_call/function_call_output(工具调用与返回)、token_count(每次真实调用的用量)。
先看日志长什么样。开场固定上下文通常拆成四块:
| 来源 | 位置 | 装的是什么 |
|---|---|---|
| base_instructions | session_meta.payload.base_instructions.text | 人格设定、工程准则、编辑约束、格式规则 |
| skills_instructions | 第 2 条 response_item,role: developer | 本机已安装技能的一句话描述 + 路径 |
| AGENTS.md + environment_context | 第 3 条 response_item | 项目规则、cwd/shell/timezone/沙箱权限 |
| 会话历史 | 逐轮累积 | 用户提问、助手回答、reasoning、工具调用与返回 |
下面这份 Python 脚本可以直接跑,把每类事件的 token 估算和累计值打出来:
import json from collections import defaultdict LOG_PATH = "rollout.jsonl" def approx_tokens(text: str) -> int: # 中英混排粗略折算:约 3.5 字符/token return max(1, int(len(text) / 3.5)) buckets = defaultdict(int) token_events = [] with open(LOG_PATH, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue evt = json.loads(line) etype = evt.get("type") if etype == "response_item": payload = evt.get("payload", {}) role = payload.get("role", "unknown") text = json.dumps(payload, ensure_ascii=False) buckets[f"response_item:{role}"] += approx_tokens(text) elif etype == "function_call": buckets["function_call"] += approx_tokens(json.dumps(evt, ensure_ascii=False)) elif etype == "function_call_output": buckets["function_call_output"] += approx_tokens(json.dumps(evt, ensure_ascii=False)) elif etype == "token_count": info = evt.get("payload", {}) token_events.append(info) print("=== 按事件类型估算 token ===") for k, v in sorted(buckets.items(), key=lambda x: -x[1]): print(f"{k:40s} {v:>8d}") print("\n=== 每次真实调用的 token_count ===") for i, info in enumerate(token_events, 1): print(f"调用 {i}: {json.dumps(info, ensure_ascii=False)}")跑完之后你会拿到两张表:一张是按事件类型的估算分布,一张是每次真实调用的官方 token_count。两者对照,就能看出"文本记录里找不到来源"的那部分——也就是工具 JSON Schema 定义。
实测一份四轮对话的数据,model_context_window是 258,400,四轮触发 7 次真实调用,累计 169,479 tokens,吃掉窗口的 65.6%。逐次拆开:
| 触发点 | 本次 input | 本次 cached | 命中率 | reasoning | 累计 total |
|---|---|---|---|---|---|
| 第 1 轮首次调用 | 16,147 | 4,480 | 27.7% | 531 | 16,335 |
| 第 2 轮首次调用 | 16,316 | 15,744 | 96.5% | 120 | 32,862 |
| 第 2 轮读取 SKILL.md 后 | 28,895 | 15,744 | 54.5% | 660 | 63,018 |
| 第 3 轮首次调用 | 18,291 | 15,744 | 85.9% | 177 | 81,587 |
| 第 3 轮再次读取同一 SKILL.md | 25,745 | 17,792 | 69.1% | 871 | 107,455 |
| 第 3 轮追加推理 | 22,044 | 17,792 | 80.7% | 201 | 129,552 |
| 第 3 轮生成最终长回答 | 38,415 | 21,888 | 57.0% | 743 | 169,479 |
三个结论直接从这个表里读出来。
第一,系统提示词文本本身没那么大。base_instructions 18,501 字符 + 技能目录 14,585 字符 + AGENTS.md/环境 1,376 字符,按 3.5 字符/token 折算约 9K tokens,但第一轮实测 input 是 16,147。多出来的近 7K tokens 在文本记录里找不到来源,最合理的解释是工具的 JSON Schema 定义(shell_command、apply_patch、tool_search 以及 MCP 工具目录条目)。这部分不进对话记录,却实打实占预算,是分析 Agent 上下文时最容易漏的一块。
第二,系统提示词的边际成本会迅速趋近于零。第 1 轮 cache 命中率 27.7%,第 2 轮首次调用直接跳到 96.5%。只要系统提示词 + 技能目录 + 历史消息这个前缀不变,后续每轮几乎免费。占用大不等于成本大。
第三,真正烧窗口的是没被复用的工具调用和高强度推理。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。
脚本里可以再加一段,专门统计同一份文件被重复读取的次数:
read_counts = defaultdict(int) with open(LOG_PATH, "r", encoding="utf-8") as f: for line in f: evt = json.loads(line) if evt.get("type") == "function_call": args = json.dumps(evt.get("payload", {}).get("arguments", {}), ensure_ascii=False) if "SKILL.md" in args or "read_file" in args: read_counts[args[:80]] += 1 print("=== 重复读取统计 ===") for k, v in read_counts.items(): if v > 1: print(f"重复 {v} 次: {k}")这段跑出来,你就能定位"同一份参考资料要不要每轮都重新读"这个线性增长点。
4. 验证请求与成功结果:tool search 前后各跑一次,比对上下文占用
理论讲完,得用真实调用验证。tool search 的机制分三层:目录层(deferred,会话开始只给命名空间/能力簇的高层描述)、检索层(tool_search,模型主动发起检索)、加载层(inject,命中的完整 schema 追加注入到上下文末尾)。没被命中的工具,完整定义自始至终不进上下文。
日志里抓到的一次真实 tool_search 是这样的。用户问"wikimcp 支持写入功能吗",这是一个不在 skills 目录里的内部 MCP server,只能通过 tool search 发现。第一次调用产出:
{ "type": "tool_search_call", "call_id": "call_wxcmxC3Zr9kcihFw607MWsv2", "status": "completed", "execution": "client", "arguments": { "query": "wiki mcp write update create page", "limit": 10 } }execution: client对应客户端执行模式:模型只发出 tool_search_call,由应用自己完成检索,再返回 tool_search_output。query 是模型自己生成的,主动把 write/update/create 塞了进去,因为用户问的是"支不支持写入"。query 质量完全取决于模型怎么构造,这是实际用起来容易忽略的细节。
客户端返回:
{ "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,这个标志位更像供 UI/日志标注"通过发现机制拿到"的元数据,不是加载状态开关。
加载后新增多少成本?拿到 tool_search_output 后模型直接生成最终回答,没再调用任何 wiki_* 工具。第二次调用last_input_tokens: 16,656,last_cached_tokens: 15,744(94.5%)。对照第一次 input=16,145,从发起检索到带着 3 个工具定义再问一次,新增的、没被缓存命中的部分只有约 900 tokens。这 900 tokens 打包了上一轮 reasoning 摘要、tool_search_call 本身、以及 3 个工具完整的 name/description/parameters schema。
现在做对照验证。同一任务,调整 tool search 策略前后各跑一次:
# 策略 A:默认,允许 tool search 按需加载 codex --config tool_search.enabled=true "帮我查一下 wikimcp 支持写入吗" # 策略 B:关闭 tool search,工具全量注入 codex --config tool_search.enabled=false "帮我查一下 wikimcp 支持写入吗"跑完各取一份 rollout 日志,用第 3 节的脚本对比token_count累计值。预期结果:策略 A 的首次 input 更低(工具定义没全量进上下文),但会多一次 tool_search 往返;策略 B 首次 input 更高(所有工具 schema 一次性注入),但少一次检索往返。哪个更省,取决于你接了多少个 MCP server——接十几个时,策略 A 的固定预算优势会非常明显。
顺带一个能证明"没有幻觉"的细节:用户问"支持写入吗",检索结果里确实没有任何写类工具,mcp__wiki命名空间只注册了三个只读工具,模型最终如实回答"不支持写入"。这不是模型"知道"这个 MCP 该有哪些工具,而是它真搜了一遍、看到返回结果里没有写类工具才据实回答。这正是渐进式发现相对于训练时记忆的关键区别:它反映的是当前会话里 MCP server 实际注册了什么。
5. 本篇常见错排查:401、local proxy failed、reading choices、OAuth 对照
配置和验证过程中,下面几类报错最常见,逐个对照。
401 Unauthorized。九成是 Key 没生效。检查三处:环境变量TAOTOKEN_API_KEY是否 export 成功(echo $TAOTOKEN_API_KEY看有没有值);config.toml 里env_key写的名字和实际环境变量名是否一致;auth.json 里OPENAI_API_KEY有没有拼错。还有一种情况是 Key 复制时带了空格或换行,肉眼看不出来,用cat -A检查。
local proxy failed。这个报错通常出现在 Codex 尝试走本地代理但代理没起来的时候。如果你没配代理,检查 config.toml 里有没有残留的 proxy 字段;如果有,删掉,让请求直连 Base URL。注意:这里说的是本地代理进程配置问题,不是让你去搞什么网络工具,纯粹是配置文件里多了一行。
reading choices 报错。这是返回体结构和预期不符。Codex 走 Responses API 时,返回体里没有choices字段,如果你把wire_api填成了chat,解析器就会去找choices然后报错。反过来,走 Chat Completions 却填了responses,也会出问题。对照第 2 节的 TOML,确认wire_api和实际接口形态一致。
OAuth 相关报错。部分 Codex 版本默认走 OAuth 登录流程,如果你用的是 API Key 通道,需要在配置里显式关闭 OAuth。检查 config.toml 里有没有preferred_auth_method之类的字段,设成apikey。auth.json 方式下,确认文件里没有残留的 OAuth token 字段,否则会优先走 OAuth 然后失败。
token_count 对不上。日志里的累计值和 TaoToken 控制台用量对不上,通常是两个原因:一是请求走了不同 Key(没统一通道),二是日志里的total_tokens是累计值不是单次值,看的时候要区分。用第 3 节脚本打印每次调用的明细,逐次对。
tool_search 不触发。模型该检索却没检索,检查两点:tool_search 是否 enabled;query 构造是否太窄。query 是模型自己生成的,如果它只写了"wiki"没写"write",可能命中不到写类工具。这是模型行为,不是配置问题,但可以通过在提问里明确关键词来引导。
排查顺序建议:先 curl 验证通道(排除 401),再看 config.toml 的 wire_api 和 env_key(排除 reading choices),最后看日志明细(排除归因错误)。三步走完,基本能定位。
6. 把上下文账单变成可优化的工程动作
拆完这份日志,最该记住的不是某个具体数字,而是三个可操作的判断。
系统提示词前缀稳定时,prompt cache 会把边际成本压到接近零,别在这上面花优化精力。真正该盯的是"同一份参考资料要不要每轮重复读"和"reasoning_effort 要不要一直开 high"——这两个是线性甚至超线性增长的开销点。用第 3 节的重复读取统计脚本,跑一次就能定位。
MCP 工具不是全量塞进上下文的。渐进式发现把"目录"和"完整定义"拆成两层,命中后再注入,实测一次命中带来的边际成本只有几百 token。接的 MCP server 越多,这个机制的价值越大。用第 4 节的对照验证,跑一次策略 A/B 就知道你的场景省多少。
tool search 的两种执行模式(hosted / client-executed)和 MCP 本身是正交的两个机制:MCP 解决接入,tool search 解决发现与加载。具体检索算法这层,官方文档不做限定,也不该轻信模型自己嘴上说的"用的是 BM25"——没有真实调用做交叉验证之前,那只是一个待验证的说法。
想把上面这些验证动作跑起来,先把统一 Key 通道配好,再拿日志脚本对账。模型对话入口用来验证模型是否通,Coding Plan 适合长期编码和 Agent 任务:
- 模型对话:https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite
- Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite
- API Keys:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite
- 接入文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite
最后留一个实用技巧:把第 3 节的脚本存成codex_token_audit.py,每次跑完长会话就执行一次,把输出重定向到带日期的文件里。跑上一周,你就能看出自己的 Agent 会话里,哪类开销在稳定增长、哪类是一次性的。这比任何通用建议都准。