在实际编码代理(coding agent)项目里,工具调用是最容易消耗 token 的环节。一个中等难度的代码修复任务可能只触发 36 次工具调用,但这 36 次往返会让 prompt 侧 token 不断累积,最终账单比预想中高出近一倍。很多团队一开始以为是模型输出太长,真正统计 usage 后才发现,大头全在每次工具调用前都要重新发送的那段上下文里。
这篇文章要解决的是同一类问题:当工具调用次数已经固定、无法从业务层面继续减少时,如何通过压缩工具定义、精简工具返回结果、合理使用上下文缓存和并行工具调用,让总 token 消耗接近原来的一半。文中会先讲清楚 token 在工具调用链路上如何累积,再给出可落地的优化步骤、可复现的测算方法,以及生产环境里常见的踩坑点。
1. 编码代理的工具调用循环,为什么 tokens 总是悄悄增加
1.1 一次工具调用发生了什么
编码代理和普通聊天机器人的最大区别,是它需要反复执行工具,把执行结果重新交回大模型,再决定下一步动作。通用流程如下:
- 用户输入任务描述。
- 编码代理把系统提示词、历史消息、工具定义和当前任务组装成请求,发送给大模型。
- 大模型返回一段文本或一个结构化的工具调用请求。
- 执行器运行这个工具,例如读取文件、执行命令、搜索代码。
- 工具执行结果作为新的消息追加到对话历史中。
- 代理带着更新后的历史再次发送请求,直到任务完成。
这里的第 6 步是关键:每次发起新的请求,都要带上之前所有消息。假设一个任务需要 36 次工具调用,那么从第 36 次请求的角度看,它携带的上下文包含了前 35 次工具调用的输入、输出和大模型中间回复。虽然单个工具结果可能只有几百 token,累计到第 36 次就是几万 token。
1.2 token 消耗的四个去向
要优化,先要弄清楚 token 花在哪里。一次工具调用请求的 token 通常包含四个部分:
| 去向 | 说明 | 典型特征 |
|---|---|---|
| 系统提示词 | 代理角色、行为规范、输出格式 | 固定不变,但每次请求都会出现 |
| 工具定义 | schema、描述、参数说明 | 固定不变,但可能非常长 |
| 历史消息 | 用户输入、模型回复、工具结果 | 随轮次递增,增长最快 |
| 模型输出 | 推理内容、最终回复、工具调用参数 | 每次轮次独立产生 |
很多团队只关注模型输出,也就是 completion_tokens,忽略了 prompt_tokens 里反复出现的系统提示词和工具定义。当一个任务需要多次工具调用时,prompt_tokens 会成倍放大。
1.3 为什么同样 36 次调用,消耗可以差一半
“同样的 36 次工具调用”并不是说模型输出完全相同。差距主要来自上下文管理策略:
- 一种做法是每轮都发送完整的历史记录,包括第一次读取到的整份源代码文件。
- 另一种做法是只保留当前任务相关的代码片段、已完成的总结和待办列表。
这两者的 token 差可以非常悬殊。工具调用次数相同不代表上下文大小相同。优化的本质,是让每一轮请求携带的信息量尽量逼近“当前任务真正需要的最小集合”,而不是把已经完成的工作反复重新读一遍。
2. 先建立可观测的令牌账本,不要凭感觉优化
2.1 把每次调用的 usage 记录下来
优化 token 消耗之前,必须先能准确看到每一轮请求的 token 使用量。大多数大模型 API 会在响应里返回 usage 字段,例如:
{ "id": "chatcmpl_example", "usage": { "prompt_tokens": 28421, "completion_tokens": 1532, "total_tokens": 29953 } }在代理框架里,不能只记录最终一次响应的 usage,而要按 trace_id 把每一次工具调用的 usage 都写到结构化日志中。建议至少记录以下字段:
{ "trace_id": "task_20260801_001", "turn_index": 8, "tool_count_so_far": 8, "model": "coding-agent-model", "prompt_tokens": 28421, "completion_tokens": 1532, "total_tokens": 29953, "cached_tokens": 0, "tool_name": "read_file", "event_time": "2026-08-01T10:00:00Z" }有了这个日志表,才能回答“36 次工具调用到底花了多少 token”和“每次增长主要来自哪里”这两个问题。
2.2 用累计曲线定位增长源
把每轮的 prompt_tokens 画成折线图,会看到两种典型形态:
- prompt_tokens 线性快速增长,说明历史消息没有做摘要或裁剪,每轮都在累积。
- prompt_tokens 在前几轮暴增后不再下降,说明某次工具返回了一大段内容,后续每一轮都要重新携带。
优化前,把 36 次工具调用的 usage 日志导出,按以下维度汇总:
| 汇总维度 | 作用 |
|---|---|
| 每轮 prompt_tokens | 判断增长速度 |
| 每轮 completion_tokens | 判断模型推理开销 |
| 单次工具返回 token 数 | 找出超大返回 |
| 系统提示词长度 | 判断静态部分是否可压缩 |
| 工具定义长度 | 判断 schema 是否冗余 |
2.3 建立基线后再动手
基线记录建议包含以下信息:
- 总工具调用次数,例如 36 次。
- 总 prompt_tokens 和总 completion_tokens。
- 每次工具调用的平均 prompt_tokens。
- top 5 超长工具返回。
基线不需要覆盖几百个任务,选 10 个有代表性的任务即可。优化后的每一次改动,都用同一批任务重新测算。只有同一批任务的前后对比,才能证明优化确实起效。
注意:比较 token 消耗时,要让模型参数、温度、top_p 保持一致。模型版本变化会导致 token 统计失真。
3. 第一刀:压缩系统提示词与工具定义,静态内容是最容易省的部分
3.1 系统提示词不是越长越好
很多编码代理把大量行为规范写在系统提示词里,例如“你是资深工程师”、“先阅读文件再提出方案”、“不要删除用户代码”、“输出要简洁”。这些内容不是完全没用,但每轮都会重复计费。
系统提示词建议分成两层:
- 核心身份和绝对红线,保留较短版本。
- 可以动态注入的规则,只在需要时拼接到当前请求。
例如,把一个 1200 token 的系统提示词压缩到 400 token 是可行的。压缩的原则是去掉形容词、合并同类规则、删除示例中的长代码,只保留判断条件。
3.2 工具定义的常见冗余
工具定义是 prompt_tokens 里的另一大块。一个工具定义通常包含 name、description、parameters,其中 description 很容易被写成一大段话。
示例,原始定义:
{ "name": "read_file", "description": "Read the content of a file in the repository. This tool is used to read source code, configuration files, markdown documents, log files, and any other text-based file. The file path should be relative to the project root. If the file does not exist, an error will be returned. You should only read files that are relevant to the current task, and avoid reading large binary files.", "parameters": { "type": "object", "properties": { "file_path": { "type": "string", "description": "The absolute or relative path to the file to be read." } }, "required": ["file_path"] } }这段定义大约 120 token。如果代理注册了 10 个工具,每轮光是工具定义就是 1200 token。36 轮下来,就是 43200 token 的固定开销,而且在 36 轮中一模一样地重复出现。
压缩后的定义:
{ "name": "read_file", "description": "Read a text file by path.", "parameters": { "type": "object", "properties": { "file_path": { "type": "string" } }, "required": ["file_path"] } }只有 30 token 左右。省掉的描述性文字不会影响模型理解工具用途,因为有经验的模型可以通过工具名和参数结构推断语义。
3.3 工具描述与参数注释的取舍
工具描述中的“should only read files that are relevant”这类行为约束,更适合放在系统提示词或执行前的规则过滤器里,而不是放在每个工具描述里。系统提示词只需要声明一次,工具描述则要在每个工具上重复。
推荐做法:
- name 用动词加名词的明确格式,例如
read_file、search_symbol。 - description 控制在 10 到 20 个英文单词或一句中文。
- 参数只保留类型、必填、简短说明。
- 是否允许修改文件、是否限制路径范围等约束放到执行层校验,不放进大模型可见的工具定义。
3.4 使用 JSON Schema 的紧凑写法
如果工具框架支持 JSON Schema,可以进一步压缩:
{ "name": "patch_file", "description": "Apply a diff patch to a file.", "parameters": { "type": "object", "properties": { "path": {"type": "string"}, "patch": {"type": "string"} }, "required": ["path", "patch"] } }这里要避免把工具的输出示例、常见错误信息、权限说明全部塞进 description。这些内容对模型理解工具没有帮助,反而会占用每一轮的 prompt 额度。
4. 第二刀:工具返回结果瘦身,避免每个循环都反复携带整份文件
4.1 读全文件为什么最贵
编码代理常见的工具调用是read_file。如果每次读取都返回整个文件内容,文件越大,后续每一轮请求携带的历史消息就越重。
假设一个文件有 2000 行,约 15000 token。代理第一次读取它需要 15000 token 写入历史。之后如果代理又调用了 5 次工具,每次请求都带着这 15000 token,光这个文件就会在 6 轮中产生 90000 token。这明显不合理。
优化思路有两个方向:
- 让工具只返回文件的相关片段。
- 让历史消息在后续轮次中不再携带完整文件内容。
4.2 在调用前做预取和裁剪
如果代理需要先了解项目结构,可以提供一个search_symbol或grep工具,而不是直接读取整个文件。示例:
search_symbol("class UserService")工具内部执行类似 grep 的操作,只返回匹配的符号和所在行,而不是整个文件内容。这样一次返回可能只有 200 token。
当确实需要读取某个文件时,先按行号范围读取,而不是一次性读取全量:
{ "name": "read_file_lines", "description": "Read a line range of a file.", "parameters": { "type": "object", "properties": { "path": {"type": "string"}, "start_line": {"type": "integer"}, "end_line": {"type": "integer"} }, "required": ["path", "start_line", "end_line"] } }模型可以先用 grep 定位函数位置,再只读取该函数对应的小范围行号。这个模式在很多编码代理中非常有效。
4.3 把工具返回结果压缩成摘要
工具执行结果也可能来自命令输出,例如npm test的 500 行日志。原始日志不应该直接全部进入上下文。
推荐做法是在工具执行器和模型之间加一层“结果处理器”:
def compact_test_output(raw_output: str, max_tokens: int = 600) -> str: lines = raw_output.splitlines() if total_tokens(raw_output) <= max_tokens: return raw_output failures = [line for line in lines if "FAIL" in line or "Error" in line] summary_tail = lines[-20:] return "\n".join(failures + ["...", *summary_tail])这个处理器可以放在代理框架的 tool executor 里。模型只看到失败摘要和最后若干行,既保留了排错所需的信息,又避免把 500 行日志重复发送 10 轮。
4.4 对历史消息做分层裁剪
当工具调用轮次很多时,早期轮次的完整内容往往已不再重要。可以设定规则:
- 保留最近 N 轮的原始内容。
- 更早的轮次转成总结文本。
- 已经被应用或验证过的补丁不再保留原始 diff。
示例消息结构:
messages = [ 系统提示词, 用户原始任务, turn 1-10 总结: "读取了 config.py,发现端口配置在 environment.py;已修改 redis 连接参数...", 最近 3 轮原始消息... ]这样既保留任务的连续性,又大幅压缩 prompt_tokens。
5. 第三刀:合并工具调用,用更少的轮次完成同样的工作
5.1 并行工具调用减少往返开销
部分大模型接口支持一个响应里包含多个 tool call。例如,代理需要读取三个文件,可以一次返回三个read_file请求,而不是三次循环。
支持并行的请求示例:
{ "tool_calls": [ {"id": "call_a", "type": "function", "function": {"name": "read_file", "arguments": "{\"path\":\"a.py\"}"}}, {"id": "call_b", "type": "function", "function": {"name": "read_file", "arguments": "{\"path\":\"b.py\"}"}}, {"id": "call_c", "type": "function", "function": {"name": "read_file", "arguments": "{\"path\":\"c.py\"}"}} ] }执行器可以并行执行这三个调用,并把三个结果统一返回。工具的调用次数如果按“单个工具执行”计数,仍然是一次一次算,但模型往返次数减少了。Prompt 侧不再需要为这次读取单独产生多轮中间回复。
5.2 用执行计划替代盲目试错
编码代理经常在“读文件->看报错->再读文件”之间来回切换。这里的工具调用次数并没有减少,但每轮之间的信息重复度很高。
优化做法是在代理前端增加一个“规划器”,在一次请求中输出执行步骤,而不是每步都询问模型:
{ "tasks": [ {"tool": "search_symbol", "params": {"name": "UserService"}}, {"tool": "read_file_lines", "params": {"path": "UserService.java", "start_line": 20, "end_line": 120}}, {"tool": "run_test", "params": {"filter": "UserServiceTest"}} ] }规划器输出的任务列表会进入工具执行器,执行器按顺序执行并把结果汇总。这样原本需要 36 次独立模型往返的工具调用,可以压缩成若干批次,省去模型在轮与轮之间重复生成“好的,我去看看”这类回复。
5.3 什么时候不要合并
并行和规划并非所有场景都适合。以下情况要谨慎:
| 情况 | 风险 |
|---|---|
| 后一个工具依赖前一个工具的输出 | 必须串行,不能并行 |
| 工具输出非常大 | 并行后一次性返回过多内容会撑爆上下文 |
| 工具本身有副作用 | 并行执行可能产生重复写入或竞态 |
| API 不支持并行 tool call | 服务端会忽略或报错 |
合并的目的是减少往返开销和 prompt 累积,而不是为了追求“模型一次输出多个调用”这个形式。
6. 上下文缓存与状态保留:让静态内容只计费一次
6.1 Prompt Caching 的适用条件
很多大模型服务提供 prompt cache 机制,对重复前缀按缓存价格计费。编码代理的系统提示词和工具定义通常位于请求的最前面,只要保持不变,就能命中缓存。
要让缓存命中率高,应把请求前缀设计成稳定结构:
- 系统提示词固定不变。
- 工具定义固定不变。
- 可变内容从用户消息或更靠后的位置开始。
如果每次请求都要动态修改系统提示词,例如把当前时间、随机批次号塞进去,缓存就失去了作用。常见错误是把当前仓库名、分支名、随机 request_id 放到系统提示词顶部,造成的后果是每一轮都不同,无法复用缓存。
6.2 用滑动窗口限制历史长度
即使有缓存,上下文仍然有长度上限。编码代理运行 36 个轮次后,历史消息可能接近上限。滑动窗口策略可以这样设计:
- 保留用户原始任务,不剪。
- 保留最近 6 轮原始消息。
- 中间的轮次转成压缩摘要。
- 最旧的工具输出直接丢弃。
这个过程可以由代理框架在每次请求前执行:
def slim_context(messages, keep_turns=6, max_old_turns_summary=20): ...压缩摘要的生成本身也有 token 成本,所以不要每轮都重写摘要。可以每 5 轮生成一次,或者当总 token 超过阈值时才触发。
6.3 把代理状态抽到运行时
另一种做法是不把所有状态都放进模型上下文。编码代理可以将“当前任务进度”维护在运行时对象中,例如:
{ "task": "fix redis connection", "completed_steps": [ "read config.py", "checked env vars", "updated timeout settings" ], "next_steps": [ "run test", "verify connection" ], "current_files": ["src/config.py"] }每次请求时,代理只发送这个紧凑状态,而不是发送“已经读过的 config.py 全文”。这比把所有工具结果都塞进历史消息更可控。
注意:只要代理还需要模型回忆起具体报错内容,摘要不能把关键报错全部删除。压缩摘要要保留错误关键字、文件路径、行号和修改结论。
7. 案例测算:同样 36 次工具调用,如何接近减半
7.1 优化前的基线估算
下面用一个常见编码任务做估算。假设某个任务完成需要 36 次工具调用,具体构成如下:
| 项目 | 优化前估算 |
|---|---|
| 系统提示词每轮 | 800 token |
| 工具定义每轮 | 1500 token |
| 平均每轮新增历史消息 | 900 token |
| 平均每轮 completion | 800 token |
| 36 次合计 prompt | 36 × (800+1500+累积历史) ≈ 72000 token |
| 36 次合计 completion | 36 × 800 = 28800 token |
| 总计 | 约 100800 token |
之所以 prompt 达到 72000,是因为历史消息从第 5 轮开始快速累积,到第 36 轮时单轮 prompt 已经接近 4000 token。
7.2 优化后的估算
采用压缩系统提示词、精简工具定义、工具结果裁剪、滑动窗口摘要和并行工具调用后:
| 项目 | 优化后估算 |
|---|---|
| 系统提示词每轮 | 300 token |
| 工具定义每轮 | 600 token |
| 被压缩的历史消息平均每轮 | 400 token |
| 平均每轮 completion | 500 token |
| 36 次合计 prompt | 36 × (300+600+400) ≈ 46800 token |
| 36 次合计 completion | 36 × 500 = 18000 token |
| 总计 | 约 64800 token |
如果再启用 prompt cache,静态部分不计费或只计部分费用,总费用还能进一步下降。相同 36 次工具调用,总 token 从约 100k 降到约 65k,接近减少三分之一到二分之一。实际项目还会因为文件大小、任务复杂度和模型回复长度产生浮动,但这个数量级是可信的。
7.3 优化后需要观察什么
优化不是一步到位。每次改动后,除了看总 token,还要观察:
- 工具调用失败率是否上升。
- 代理完成同样功能所需轮次是否增加。
- 模型是否因为摘要缺失而反复读取同一个文件。
有一种副作用要特别注意:如果压缩导致模型信息不足,它会用更多工具调用去补足上下文。工具调用次数可能从 36 涨到 50,总 token 反而没降。因此优化的目标是“同样的 36 次调用更省”,而不是“为了省 token 让模型多次返工”。
8. 常见坑与排查路径:优化后效果不明显时,先查这几项
8.1 排查表
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 总 token 没有下降 | 只压缩了 completion,没有压缩 prompt | 查看 usage 中的 prompt_tokens 趋势 | 重点处理工具定义和工具返回 |
| prompt_tokens 在后半段仍暴涨 | 某次工具返回了超大文件或完整日志 | 定位 prompt_tokens 最大的 turn | 给工具返回加截断或摘要 |
| 配置了 prompt cache 但命中率低 | 系统提示词或工具定义每次请求都有变化 | 对比多轮请求的前缀是否一致 | 把变化字段移到用户消息或独立消息位置 |
| 工具调用次数增加 | 摘要丢掉了关键信息 | 对比优化前后日志中的工具名序列 | 在摘要中保留错误关键字和文件路径 |
| 模型开始重复读取同一文件 | 上下文里没有保留读取过的文件信息 | 搜索日志中重复的 read_file 调用 | 增加状态对象,记录已读文件和已修改内容 |
| 并行工具调用没有生效 | API 不支持或框架配置关闭 | 查看请求响应中的 tool_calls 数量 | 用简单任务验证并行能力再启用 |
8.2 token 计数口径不一致
不同服务对 token 的统计口径可能有差异。有的把工具定义算作 prompt_tokens,有的把缓存命中的 token 单独列出来,有的把模型推理内容分两部分统计。
排查时不要跨平台比较原始数值,统一用总 token 和计费 token 两个维度记录。如果服务方没有提供 tokenizer,可以用字符数估算,但要记住中文字符和英文字符的 token 差异。
8.3 压缩过度导致模型能力下降
工具返回结果压缩过度时,模型可能看不到关键的测试失败原因,只能靠猜测继续修改,于是又触发更多工具调用。这种情况在日志里体现为:
- read_file 被反复调用,且读取范围差不多。
- run_test 连续执行多次,但没有任何参数变化。
- 模型在多个轮次中生成相同的修改方案。
解决方式是给工具结果处理器设置保底字段:至少保留最后一个错误摘要、退出码、最近 10 行输出和涉及文件路径。
8.4 只调 token 不调轮次,效果被放大或缩小
同一个编码任务,如果模型从 36 次工具调用变成了 30 次,总 token 下降可能来自轮次减少,而不是上下文优化。做对比时,要控制工具调用次数一致,或者分别统计“每次工具调用的平均 token”,才能判断优化是否真正有效。
建议维护一张对比表:
| 任务编号 | 优化前总 token | 优化后总 token | 优化前调用次数 | 优化后调用次数 | 每调用平均 token 变化 |
|---|
9. 生产环境落地建议:从跑通到稳定运行
9.1 设置 Prompt 和 Completion 预算
编码代理在运行前应该有一个 token 预算。例如:
agent: max_total_tokens: 120000 max_prompt_tokens_per_turn: 20000 max_completion_tokens_per_turn: 4000 max_tool_calls: 60 context_slim_threshold: 30000当单轮 prompt_tokens 超过阈值时,触发上下文裁剪;当 completion_tokens 连续超过预算时,可以提醒模型“请尽量不要输出长篇计划,直接给出修改动作”。
9.2 监控指标
生产环境至少要监控以下指标:
- 每任务总 token。
- 每任务工具调用次数。
- prompt_tokens 每轮增速。
- tool call 成功率。
- prompt cache 命中率。
- 平均完成时长。
这些指标可以上报到 Prometheus 或内部日志系统。出现总 token 突增时,自动拉取该任务的 usage 日志。
9.3 灰度和回退
上下文压缩、工具返回截断、并行调用这些改动都可能影响代理行为。建议通过配置开关灰度:
features: slim_system_prompt: true slim_tool_definition: true compact_tool_result: true parallel_tool_calls: false context_summary: true灰度期间保留优化前的执行路径。如果工具调用失败率超过阈值,立即关闭对应开关,回到完整上下文模式。生产环境可以同时部署两套代理配置,一套保守,一套激进,按任务目录分配。
9.4 用回归任务验证质量
优化 token 不能只看数字。每个编码代理项目都应该准备一批回归任务,任务完成后检查:
- 是否生成了正确补丁。
- 是否运行了测试。
- 是否修改了预期文件。
- 是否出现破坏性改动。
只有回归任务通过,优化才算有效。如果回归任务不通过,优先回退,而不是继续调 token 参数。
10. 实操清单:把这篇文章讲的方法一次性落地
下面是一份可直接复制的优化清单:
- 记录 10 个典型任务的 usage 日志,统计总 token、prompt_tokens、completion_tokens 和工具调用次数。
- 压缩系统提示词,删除形容词和重复规则,保留身份、输出格式和红线。
- 精简工具定义,description 控制在 10 到 20 个词,参数只保留必要字段。
- 给工具返回结果增加截断和摘要处理器,至少保留最后一个错误摘要、退出码和最近 10 行。
- 增加
read_file_lines和search_symbol等细粒度工具,避免整文件读入。 - 启用上下文滑动窗口,保留最近 6 轮原始消息,更早内容转为摘要。
- 检查工具定义和系统提示词前缀是否稳定,确保 prompt cache 能命中。
- 在 API 支持时开启并行工具调用,把独立读取合并到一次返回。
- 每个优化点都用同一批任务做前后对比,控制工具调用次数一致。
- 生产环境通过配置开关灰度,保证失败时能快速回退。
这 10 条并不需要一次性全部完成。优先做第 3 条和第 4 条,因为它们对 prompt_tokens 的影响最直接。做完后再次运行同一批任务,观察每轮平均 prompt_tokens 是否下降。只要方向正确,36 次工具调用消耗几乎一半 token 是完全可实现的。真正关键的是不要为了省 token 牺牲模型的上下文判断能力,让每次工具调用都携带“当前步骤真正需要的信息”,而不是把所有历史都原样搬运一遍。