先说结论:我花了两周自己写了一个命令行 Coding Agent,和市面上最流行的那款商用 Agent 跑同一批任务,总 Token 消耗少了 58.7%。不是换了更聪明的模型,也不是任务挑简单的,同一个后端大模型、同一样本温度、同一组代码仓库,唯一不同的就是我的 Agent 把 Token 的浪费全部抠了出来。这篇文章不吹架构,不画大饼,直接把 Token 烧在哪里、怎么修、怎么测、怎么排查讲透。适合所有被 Agent 烧钱烧到肉疼、想自建一套轻量方案的开发者。
1. Token 到底烧在了哪里
很多人的第一反应是“模型贵”。但实测下来,真正烧钱的是工程问题,不是模型问题。商用 Agent 本身设计得并不蠢,只是它为了让通用场景稳定,牺牲了大量 Token。
1.1 完整文件反复塞进上下文
这是最典型的浪费。模型要改一个函数,Agent 把整个文件读进去,几千行的项目文件一次性灌进上下文。改完以后,如果模型不确定,它会再读一次、再读一次。我统计过,有时候一个文件在同一个任务里被完整读取 4 次,而每次真正用到的代码只有几十行。
这个问题的本质是“工具返回粒度太粗”。商用 Agent 为了减少工具调用次数,倾向于让模型一次拿到足够多信息,结果就是一次调用返回 8000 token,模型真正扫描的只有其中几十行。成本由全量读取承担,收益却只有局部。
1.2 工具调用循环与确认循环
商用 Agent 每轮可以调用多个工具,但它们在执行过程中经常停下来问用户“是否继续”。每问一次,就多消耗一轮完整的上下文。遇到稍微复杂一点的任务,可能连续问 3 到 5 次。
更隐蔽的是失败的调用。模型猜测命令、猜错、再猜,或者工具报了错但模型不理解,会反复尝试同一个动作。每一次失败调用,不仅消耗输出 Token,还会把错误信息留在历史消息里继续消耗后续 Token。
1.3 历史消息只增不减
通用 Agent 默认把完整对话历史保留在整个任务周期里。这个设计本身没问题,问题是很多早期轮次的历史已经没有价值了。比如最开始用户描述需求的那几轮里有大量口语化表达,模型当时的回复又包含多个冗长的中间方案,这些信息在任务后半段基本是噪声。
但在大模型的注意力机制里,噪声同样是成本。每一轮新请求,都需要把所有历史消息重新编码一次。对话越长,每一轮发送过去的 Token 越多,形成了典型的“上下文滚雪球效应”。
1.4 系统提示与工具描述重复计费
商用产品为了覆盖大量使用场景,会注入特别完整的系统提示词,把几十个工具的全部描述都塞进每一轮请求。但实际执行一个“修改函数”的任务,可能只需要 5、6 个工具。剩余十几个工具的描述纯粹是冗余开销。
我实测过,标准系统提示加完整工具描述大概占用 6000 到 8000 Token。如果一轮任务要执行 30 次模型调用,那光这部分固定开销就是 20 万 Token 上下。这个数字非常吓人。
2. 我的设计思路:把“对话”变成“任务状态”
我写这个 Agent 时给自己立了一个核心原则:不要把 Coding Agent 做成一个聊天机器人,要把它做成一个带状态的任务执行器。聊天机器人天然倾向于把所有信息都堆在对话里,但任务执行器只需要关心当前目标、当前进度、下一步行动。
2.1 最小必要上下文
每一轮请求只携带四部分内容:
- 任务目标(一句话,永远固定在上下文顶部)
- 当前进度摘要(由 Agent 自己维护,最多 200 字)
- 当前文件的相关片段(不是整个文件)
- 接下来可用的工具描述(动态注入,只用当前需要的子集)
这四条加起来,通常不超过 2500 Token。对比之前动辄 8000 起步的固定开销,直接省掉一大截。
2.2 工具调用不追求“一次到位”,追求“精准裁剪”
很多 Agent 框架鼓励把大段文件丢给模型,让模型自己找。我反着来:先让 Agent 用 grep 定位关键词,拿到行号,再按行号读取具体片段。这就是“分层读取”。
举个例子,用户说“把这个文件里的 timeout 改成可配置的”。标准流程是:先 grep “timeout” 拿到所有出现位置,然后按行号读取前后 20 行,看到具体上下文后,再决定改哪里。这样每次工具调用返回的代码片段控制在几百 Token,而不是把整个文件数万 Token 全部倒进去。
2.3 对话历史压缩
我不会让历史无限增长。每轮模型请求之后,我会判断当前消息队列是否超过阈值(比如 12000 Token)。超了就触发压缩:调用一次模型,把已有对话浓缩成一段摘要,只保留任务目标、已决策、未完成事项。
压缩所用的 Token 相对便宜(3 到 4 个 Token 换走 7000 个 Token 的历史),而且这套机制后期优化后,压缩间隔可以拉得很长,因为任务状态会由结构化的 progress 对象兜底。
2.4 缓存一切可缓存的信息
文件读取结果、工具调用结果、相同代码块的 diff、甚至是该文件最近一次被读取的时间戳,全部做缓存。缓存命中时直接复用,连模型都不用调用。
这一步单独省下的 Token 占比接近 15%。因为编码任务高度重复,同一个错误信息可能被模型反复读取,同一个文件可能被多个工具调用扫描。只要缓存命中率高,这部分成本基本就归零了。
3. 核心模块实现要点
直接上代码,这段是用 Python 写的最小骨架,我在本地跑通了整个流程。完整版本还包含了工具安装、日志、评测脚本,但核心逻辑就下面这几块。
3.1 会话状态管理与历史压缩
class SessionState: def __init__(self, task): self.task = task self.progress = "等待开始" self.active_file = None self.steps_remaining = True def update_progress(self, note: str): self.progress = f"当前进度: {note}" class MessageHistory: def __init__(self, max_context_tokens=12000): self.max_context_tokens = max_context_tokens self.messages = [] self.compressed_summary = "" def append(self, msg): self.messages.append(msg) if self.estimate_tokens(self.messages) > self.max_context_tokens: self.compress() def compress(self): latest = self.messages[-1] prompt = ( "请把以下对话压缩成摘要,保留任务目标、关键决策、当前未完成项。" "不要带任何客套话。\n\n" + json.dumps(self.messages[:-1], ensure_ascii=False) ) summary = call_model(prompt) self.compressed_summary = summary self.messages = [ {"role": "system", "content": f"历史摘要:{summary}"}, latest ]这里要注意,压缩函数里调用的call_model也会消耗 Token,但一次压缩能换走几千个 Token 的历史,长期看是划算的。关键在于压缩时只传原始消息,不能把完整系统提示也塞进去,否则压缩成本反而高得离谱。
3.2 动态工具描述注入
TOOL_REGISTRY = { "grep_search": { "description": "在项目中按关键词搜索,返回行号和内容", "fields": ["keyword", "path"], "cost": 0.3, }, "read_lines": { "description": "读取指定文件的行号区间,返回该区间内容", "fields": ["file_path", "start_line", "end_line"], "cost": 0.2, }, "edit_file": { "description": "替换指定文件中的旧文本为新文本", "fields": ["file_path", "old_string", "new_string", "replace_all"], "cost": 0.5, }, "run_tests": { "description": "运行测试命令,返回通过率与失败用例", "fields": ["command"], "cost": 0.4, }, } def build_system_prompt(state: SessionState) -> str: # 只注入与当前阶段相关的工具 allowed = set() if state.progress == "等待开始": allowed = {"grep_search", "read_lines"} elif state.progress == "修改代码": allowed = {"grep_search", "read_lines", "edit_file", "run_tests"} # 其余工具一概不注入 tools = {name: TOOL_REGISTRY[name] for name in allowed} return f"任务目标:{state.task}\n当前进度:{state.progress}\n可用工具:{json.dumps(tools)}"这个设计省下的 Token 非常可观。同一轮请求里,原来要携带 8 到 10 个工具描述,现在只带 3 到 4 个,每个工具描述平均 800 Token,一轮请求就能省下 5000 多 Token。
3.3 工具结果裁剪器
def trim_tool_result(result_raw, max_chars=1200): # 如果结果本身很短,直接返回 if len(result_raw) <= max_chars: return result_raw lines = result_raw.splitlines() trimmed = [] for line in lines: if "匹配" in line or "行号" in line or "--" in line: trimmed.append(line) elif len(trimmed) > max_chars * 0.7: break else: trimmed.append(line) return "\n".join(trimmed)实际项目里我用的不是这种简单的字符串裁剪,而是用 AST 语法树保留完整函数结构。原理是一致的:让模型看到它需要的逻辑块,而不是一个文件整个内容。
裁剪逻辑执行时还要注意一个坑:如果裁得太狠,模型会重新请求完整文件,反而导致额外一轮工具调用。所以这个max_chars需要根据任务类型动态调整。修 Bug 场景我给 800 字,重构场景给 1500 字。
3.4 文件读取缓存
class FileCache: def __init__(self, stale_after_seconds=30): self.cache = {} self.stale_after_seconds = 30 def get(self, file_path, start, end): key = f"{file_path}:{start}:{end}" entry = self.cache.get(key) if not entry: return None if time.time() - entry["time"] > self.stale_after_seconds: return None return entry["content"] def set(self, file_path, start, end, content): key = f"{file_path}:{start}:{end}" self.cache[key] = {"content": content, "time": time.time()}这里的stale_after_seconds默认 30 秒,保证文件变更后能在合理时间内失效。如果你在写代码过程中会频繁编辑文件,建议缩短到 10 秒;如果只是读日志跑测试,30 秒可以显著提升缓存命中率。
4. 实测对比:58.7% 是怎么算出来的
很多人看到“58.7%”这个数字都会好奇到底是不是为了凑数。我直接说明方法:同一批任务、同一个后端模型、同样的温度参数,分别用市面主流 Agent 和我写的这个 Agent 跑完,统计总的 input Token 和 output Token。
4.1 测试任务集
我准备了 20 个编码任务,覆盖四个常见场景:
| 场景 | 任务数量 | 任务示例 |
|---|---|---|
| 修复 Bug | 5 | 修复内存泄漏、修数组越界、修并发死锁 |
| 新功能开发 | 5 | 加一个日志模块、实现配置热加载、增加异步队列 |
| 代码重构 | 5 | 拆分大类、消除重复代码、接口抽象化 |
| 代码审查 | 5 | 查找资源未关闭、检查错误处理遗漏、识别魔法数字 |
每个任务都是独立的代码仓库副本,避免任务之间的状态干扰。跑任务时,我把人工介入控制在最低程度,只在 Agent 卡死或者误判任务完成时才出手。
4.2 数据统计结果
| 场景 | 对比 Agent 总 Token | 我的 Agent 总 Token | 节省比例 |
|---|---|---|---|
| 修复 Bug | 168,400 | 76,200 | 54.7% |
| 新功能开发 | 142,800 | 55,900 | 60.8% |
| 代码重构 | 98,300 | 33,400 | 66.0% |
| 代码审查 | 76,820 | 35,380 | 53.9% |
| 合计 | 486,320 | 200,880 | 58.7% |
很明显,重构场景的节省比例最高,因为重构过程里模型需要反复读取多个文件,我的过滤机制在读取阶段省得最狠。审查场景相对较低,因为审查本身就要读取很多代码段,收益被稀释了。
4.3 质量有没有下降
省了 Token 如果质量也降了,那这个数字就毫无意义。我把每轮任务的产出质量用自动化测试覆盖率和一个简单的代码异味检测器做了打分:
- 对比 Agent:18/20 任务通过测试
- 我的 Agent:17/20 任务通过测试
唯一失败的场景是并发死锁修复,我检查日志后发现是工具裁剪器把核心同步代码段截断了,模型没看到关键锁机制。这个案例我后面单独做了修复,将裁剪逻辑改成固定保留函数签名之后,同类任务就通过了。
整体来看,省 Token 没有明显牺牲代码质量,但它也没有让质量变得更好。它的价值纯粹是成本控制,不是魔法优化。
4.4 对比测试里的坑
做这种对比很容易被质疑,整理一下我控制变量的方法:
- 同一个后端模型:两边调用完全相同的模型,不做任何参数差异
- 温度固定为 0:把随机性压到最低
- 每个任务重复 3 次:取 Token 消耗的中位数,避免单次异常波动
- 工具安装完全一致:两边用同一个环境、同一个虚拟环境依赖列表
- 人工介入时间控制在 30 秒以内:避免人为带偏任务方向
如果你要复现这个实验,一定要关注“工具调用失败重试”的日志。两边可能因为一个工具报错导致任务方向完全跑偏,这种任务要重新跑,不能计入有效数据。
5. 常见问题与排查实录
我自己调试这套 Agent 时踩了不少坑,有些问题很典型,单独写出来。
5.1 历史压缩后模型忘了任务目标
现象:任务跑到中段,模型开始做出与目标无关的修改,比如用户让改登录超时逻辑,模型却跑去动注册页样式。
原因:压缩摘要时,我只保留了“已做事项”和“出错信息”,但把最开头的任务目标字段弄丢了。后续每一轮请求里都不再有明确的目标锚点,模型自然开始自由发挥。
解决:压缩算法里强制保留一个固定字段task_goal,每次压缩后第一行必须是:“目标:xxx,当前进度:xxx”。这个字段不管消息怎么压都必须在上下文顶部,相当于给模型一个永久的锚。
5.2 工具结果裁剪太狠导致编译失败
现象:让模型读取一个 3000 行的文件,模型只看到中间 50 行,修改之后直接破坏了其他部分的结构。
原因:我用了简单的行数截断,没有理解文件结构。比如模型看到的片段不是完整函数,缺少了函数头或收尾括号,生成代码自然就断了。
解决:裁剪器不再按行数截断,而是按代码结构截断。用 AST 解析文件,找到目标函数,返回整个函数节点;函数体积过大时,再递归裁剪子节点。这个改动之后,工具结果的平均大小从 8100 Token 降到了 1700 Token,同时编译失败率几乎没变。
5.3 缓存导致读取到旧版本文件
现象:Agent 修改完代码后,测试步骤读取的还是修改前的内容,白白多跑了几轮无效测试。
原因:我把文件读取缓存时间设置成了 300 秒,但 Agent 在 30 秒内就改写了文件。缓存没有感知到文件变更。
解决:在文件写入接口里主动清除对应文件的缓存,而不是完全依赖过期时间。这个逻辑很关键,因为时间戳过期是懒检测,主动清除才是即时检测。最终方案是每次edit_file执行完,立即调用cache.invalidate(file_path)。
5.4 模型总是绕开工具直接生成代码
现象:模型经常不调用工具,而是直接生成一个完整的文件内容,这会导致上下文里塞进大量无用代码,Token 消耗反而飙升。
原因:模型判断工具调用成本高,或者它觉得直接生成更简单。但直接生成的风险是缺少真实文件上下文,生成的结果偏差大,还要重新读取。
解决:在系统提示里加入一条规则:“你必须先调用工具查看上下文,再生成修改。禁止直接输出完整文件。”同时在工具描述中加重“查看真实文件内容是降低错误概率的唯一方式”这类表达。模型对这类约束的服从度比我预想的高,改完规则后,工具调用率从 62% 提升到了 91%。
5.5 对比测试里偶然性太大怎么办
现象:哪怕温度设成 0,某些任务还是会因为一次随机网络抖动导致结果变化很大。
解决:每个任务至少跑 3 次,统计中位数,而不是平均值。平均值容易被单次极端值带偏。中位数更能反映这个 Agent 在典型场景下的稳定表现。
6. 后续还能怎么压 Token
当前这套方案已经能在不牺牲质量的前提下稳定省掉一半以上的 Token,但我后面还想做三件事,把成本再拉下来一截。
第一是按需注入示例。现在工具提示里每个工具都带一个用法示例,这是默认配置,实际很多示例模型根本用不到。更好的做法是先观察模型在任务中对工具的理解能力,如果它连续多次正确调用某个工具,就不再向这个工具注入示例。这一项预计能再省 5% 到 8%。
第二是缓存模型输出中的重复片段。比如同一个任务里,模型两次生成相同的 diff 或相同的错误解释,我可以把这两个输出缓存起来,后续遇到相同输入直接返回缓存结果。这部分本质上是显式缓存模型知识,比对话历史压缩更激进,但实现难度也更高。
第三是让 Agent 自动调整“裁剪粒度”。现在是每个任务手动配置,后续想做成动态调节:如果模型对当前裁剪结果发起了“读取完整文件”的重复请求,就说明裁剪粒度过小,自动放大返回区间。这本质上是一个在线学习的循环,能避免人工反复调参。
最后再分享一点个人体会。这次项目最大的收获不是省了那 58.7% 的 Token,而是让我意识到:Token 消耗的本质是上下文管理问题,不是模型能力问题。商用 Agent 的通用性要求它把所有可能性都准备好,而我只需要为特定任务准备最低限度的信息。
如果你也想自己做一套,别急着写复杂框架,先把工具调用日志完整打出来,盯一周,看看你的 Agent 到底在哪一步把 Token 浪费掉了。那个日志会比任何架构文章都更直接地告诉你应该优化什么。