先交代一下背景:我做了一个面向代码仓库的 Coding Agent,用来替代我平时手动开的终端、编辑器、浏览器三件套,专职处理"改需求、查Bug、补测试"这类日常开发任务。项目做完之后,我在同一个仓库、同一批任务、同一个底层大模型后端上,和我手头的商业编码 Agent 做了 A/B 对比,最终记录的 Token 消耗大概是它的 58.7%。这篇文章就是把我拆解 Token 账单的过程、省 Token 的手段和踩过的坑完整写出来,希望能帮你少走点弯路。
1. 编程 Agent 的 Token 消耗,为什么是个"黑洞"
1.1 一次普通的代码修改任务,Token 消耗路径拆解
我以前以为,Coding Agent 的 Token 消耗主要来自"模型思考过程"和"最终输出的代码补丁",后来真正把日志里的 token 用量按环节统计出来,才发现完全不是这么回事。
拿一个常见的任务举例:让 Agent 给某个服务模块加一个限流配置。整个任务跑下来,Token 消耗路径大致是这样的:Agent 先接收系统提示(system prompt)和任务描述,这是第一笔基础开销;然后它需要"理解"仓库结构,去读 README、看目录树、翻关键文件,这一步会一次性把大量代码文本塞进上下文,是整条链路里最费 Token 的环节;接着 Agent 开始规划和调用工具,比如查看某个函数实现、打开某个配置文件,工具返回的内容又会原样进入上下文;最后才是生成修改方案和代码补丁。
我统计了一次中等复杂度的任务,最终模型生成补丁的 Token 只占全部消耗的四分之一左右,剩下四分之三全花在"读仓库、看工具输出、反复把同一段代码从不同角度塞进上下文"上面。换句话说,大部分 Token 不是在"思考",而是在"搬运信息"。
1.2 三个被忽视的 Token 黑洞
第一个黑洞是"全量仓库上下文透传"。商业 Agent 为了拿到足够多的仓库信息,很容易把整个项目文件打包塞进上下文。我知道的几个主流方案,默认甚至会先扫描一遍整个仓库,再把匹配到的文件全文交给模型。代码文件本身非常啰嗦,一个 500 行的模块,真正和当前任务相关的可能只有 30 行,但模型没办法只读取那 30 行,于是剩下的 470 行就变成了一次性 Token 开销。
第二个黑洞是"重复读取同一段代码"。Agent 在执行一个多步骤任务时,常常会把同一个文件反复加入上下文。比如第一步查看函数定义,第二步查看这个函数的调用方,第三步又要回来看函数本体。如果每一步都重新把整个文件塞给模型,同一个 300 行文件可能被累计记了 15 次 Token。
第三个黑洞是"工具输出原样回传"。工具执行的结果经常是冗长的原始输出,比如测试日志、npm 安装日志、Git diff。这些日志里真正有价值的信息可能只有最后的几行错误描述,但 Agent 通常会把这些日志原样交给模型,让模型自己过滤。日志里的时间戳、路径、大量无关警告,全部白花了 Token。
这三个黑洞加在一起,会让一个简单任务的 Token 消耗膨胀到理想值的四五倍。
1.3 优化目标不是"减少对话轮数",而是"降低每轮的有效 Token 密度"
很多人的第一反应是省 Token 就要减少对话轮数,让 Agent 少提问、少确认。这个方向对,但不根本。因为真正省 Token 的空间不在"轮数",而在"每一轮塞进上下文的内容"。
如果每一轮都要携带完整仓库索引和一堆重复代码,那么就算把轮数从 10 轮压到 6 轮,总消耗依然很高。反过来,如果每一轮只携带任务相关的代码片段、结构化摘要、精简后的工具输出,哪怕轮数不变,总 Token 也能大幅下降。
所以我在设计目标时定成三条:减少冗余信息的进入、提高每次进入上下文的信息密度、让同一个信息只被模型看到一次。这三条具体怎么做,后面第三部分会展开讲。
2. 整体方案选型:从"全量透传"到"按需取用"
2.1 架构基线的核心设计思路
开始写代码之前,我先想清楚了一个基本原则:Coding Agent 和聊天机器人最大的区别在于,它有"工具",而且它的工作对象是"代码仓库"这个有结构、可检索的信息源。
如果让 Agent 像聊天机器人一样,靠上下文记忆来理解整个仓库,效果确实不错,但 Token 成本是线性甚至超线性增长的。而实际上,仓库里的绝大多数文件和当前任务毫无关系,我们只是需要让 Agent 有能力在"需要的时候"快速找到"需要的内容"。
所以我给 Agent 设计了这样一条工作基线:任务规划 -> 仓库索引 -> 按需检索 -> 工具执行 -> 信息摘要 -> 生成补丁。和传统的"把仓库全部塞进去 -> 开始干"相比,多了一个检索层和一个摘要层。这两个层就是省 Token 的核心来源。
2.2 模块划分:任务编排、仓库索引、工具执行、上下文精简
整个系统我拆成了四个模块,每个模块各司其职:
任务编排模块负责把用户的一句话需求拆解为可执行的子任务清单,维护当前任务状态和下一步计划。仓库索引模块负责生成和维护代码仓库的结构化索引,包括文件树、符号表、代码片段向量库,并对外提供检索接口。工具执行模块封装了文件读取、文件修改、命令执行、测试运行等基础操作,但所有这些操作的输出都不会直接进入模型上下文。上下文精简模块是这套系统最关键的模块,它把工具输出、检索结果、代码片段做成本地结构化摘要,再决定哪些信息需要交给模型。
模块之间通过事件总线通信,任务编排模块会根据当前状态决定调用哪个工具、检索哪些信息,而上下文精简模块则始终扮演"看门人"的角色。
2.3 为什么"索引优先"而不是"全量读取"
核心原因有两个:一个是 Token 成本,另一个是响应延迟。全量读取一个中型前端项目的文件树和核心代码,大概会产生 10 万到 30 万 Token。而构建索引只需要一次性成本,索引文件存在本地,检索动作本身不消耗远程模型 Token。
最关键的是,索引允许我做"查询时只取所需"。如果我需要知道某个函数被哪些地方调用,我从符号表里查一遍就知道答案,完全不需要把每个调用方文件的内容读给模型看。如果确实需要看代码细节,我也可以通过检索把上下文裁剪到一个函数、一个代码块,而不是整个文件。
这个设计思路本质上是把一个"不可检索的信息盲区"变成"可检索、可裁剪的信息集市"。
3. 四个让 Token 大幅瘦身的关键手段
3.1 仓库理解:从"读所有文件"到"精准取代码片段"
第一个优化点落在仓库信息获取方式上。传统的交互方式里,Agent 需要"看"一个文件时,工具会把文件全文返回,然后整段进入模型上下文。我改成了"结构感知读取":文件先被解析成 AST(抽象语法树),索引模块按函数、类、注释块、import 声明建立索引。当 Agent 需要读取文件时,它先明确自己要看的是"函数签名""函数实现""依赖列表"还是"文件头注释",然后只把对应片段返回。
举个例子,Agent 想搞清楚 HTTP 服务是怎么初始化的。传统方案会把整个 server.js(假设 800 行)都喂给模型。在我的方案里,检索模块先返回初始化相关的 40 行片段,以及这个片段的位置上下文摘要。如果 Agent 觉得还需要更多细节,它会在同一个文件的邻接代码块里继续追问。省下的就是每次多出来的 700 多行代码。
单看一个文件可能感觉不明显,一个任务如果会接触 30 个文件,每个文件少读 60% 的内容,整体上下文规模就能降到原来的三分之一左右。
3.2 工具输出:从原始回传到结构化摘要
工具输出是最容易被忽略的 Token 消耗点。我第一次跑通整个 Agent 时,让它跑了一遍测试命令,结果测试框架的输出有 400 多行,里面全是编译警告和重复的输出,只有最后两行表明 12 个用例里有 1 个失败了。模型为了理解这个失败,实际上消耗了 400 多行日志的 Token。
后来我在工具执行模块外面加了一层"输出摘要器":命令执行完,本地先把输出按规则处理,压缩成一段固定格式,再交给模型。测试命令会变成"36 个用例,35 通过,1 失败,失败用例名:xxx,错误摘要:xxx"。静态检查命令会变成"发现 3 个 warning,集中在 src/api 目录,最严重的问题是未捕获异常"。Lint、构建、Git 状态等所有工具输出全部走这个流程。
这个思路并不复杂,本质上是"让规则代码代替模型读取长文本"。最终效果非常明显,工具输出相关的 Token 成本直接下降了约 70%。不过这里有一个技巧:摘要不能牺牲"细节可见性"。如果模型需要查看完整错误日志,还是得能通过工具去读原始文件,只是默认不进入上下文。
3.3 只读内容缓存:让重复读的代码段只计费一次
第三个手段是缓存。前面我提到过,同一个文件在多轮任务中被反复读取是非常常见的。我实现了一个文件级缓存:同一个文件的同一个代码片段,在短时间内被再次请求时,直接返回命中结果;同时把这个缓存的结果标记为"已在上下文中"。
这个机制的核心是"上下文引用"。当同一个片段第二次被需要时,我不会再把完整内容发给模型,而是给模型发送一条引用标记,类似于"你之前已经看过 /src/server.js 的 initServer 函数,这是它的位置引用,如果需要可以再次读取,但不重复粘贴内容"。模型在看到标记时能够意识到"这段内容我已经知道了",从而保持上下文连贯性,但 Token 只计费一次。
这个机制的实现难点在于"判断模型是否真的已经看过这段内容"以及"管理上下文窗口中的内容位置"。我用的方案是维护一个上下文内容清单,每次把内容加入上下文时都登记一个 ID,后续精简上下文时按 ID 管理,避免内容重复。
3.4 子任务上下文裁剪:扔掉"聊过的旧话题"
最后一个手段是从整个对话级别做减法。一个复杂的编码任务会拆成多个子任务,比如先定位 Bug,再改代码,再写测试,最后跑验证。传统对话结构下,模型在做"写测试"这一步时,上下文里仍然保留着"定位 Bug"时读取的十几段代码、早期工具输出和大量思考过程,这些信息大部分对当前步骤已经不重要。
我在任务编排模块里引入了一个"上下文窗口管理"机制:每当一个子任务完成,就把这个子任务相关的消息标记为"已归档",归档内容会从模型的上下文中移除,取而代之的是一条轻量摘要。例如"已完成 Bug 定位,定位结果是 src/utils/format.js 的 toFixed 精度问题,方案已确认"。这样模型在做后续步骤时,只需要读取摘要而不需要重新扫描那几万 Token 的旧上下文。
这个机制要小心的问题是过早裁剪,也就是当前任务还需要旧信息时却把内容移除了。我的经验是:裁剪策略只对"已完成的子任务"生效,并且必须保留一个"关键事实清单"作为替代摘要,确保后续步骤仍然知道最核心的结论。
4. 从零开始搭建省 Token 的 Coding Agent:实操记录
4.1 第一步:任务编排内核
第一个实现的是任务循环。为了让 Agent 能连续执行工具调用,我用 Python 写了一个最基础的事件循环:接收任务 -> 调用模型 -> 解析模型输出中的工具调用 -> 执行工具 -> 把工具结果交给模型 -> 循环,直到模型输出最终答案或任务结束标志。
这里有一个关键设置:工具调用的解析必须严格。我用的是 JSON 格式的工具调用协议,模型输出形如{"tool": "read_code", "params": {"path": "src/server.js", "scope": "function:initServer"}}。为了减少解析失败的次数,我在系统提示词里给了两个完整的少样本示例,并在解析失败时加入了一次"重试机制":把原始输出和错误信息交给模型,让模型确认自己是否真的想调用工具。
这个事件循环是整个项目的基础。如果这一层不稳定,后面所有优化都是空中楼阁。我建议先把最朴素的版本跑通,再开始加缓存和摘要层,不然出了问题很难定位是哪个环节导致的。
4.2 第二步:仓库索引模块
仓库索引模块是检索功能的地基。我使用的是轻量级方案:基于文件树和正则表达式的符号提取,加上一个可选的 embedding 向量索引。
具体来说,我会在启动时扫描仓库,解析每个文件,提取函数名、类名、变量名和 import 关系,生成一个符号表。这个符号表本身很小,也不直接进入模型上下文。真正进入模型的是 searcher 的搜索结果。当模型需要了解某个函数时,它调用 search_symbol 工具,工具返回函数位置、签名、以及代码片段。
这里有个取舍:是不是一定要上 embedding?我实测过,对于大多数编码任务,基于正则和文件树的符号检索已经够用,而且速度更快、零模型成本。Embedding 更适合语义搜索场景,比如"找出所有和限流相关的代码"。我在项目里两种都实现了,但因为 embedding 需要额外申请向量模型的接口,日常使用频率不高。
4.3 第三步:缓存与摘要层
缓存和摘要层是整个系统的"节流阀"。文件级缓存我用的是 LRU 策略,键是"文件路径 + 片段类型 + 修改时间戳"。一旦文件被修改,对应缓存立即失效,保证不会把旧代码交给模型。摘要层我把它设计成了插件机制:每类工具输出都有对应的摘要器,测试输出有测试摘要器,日志有日志摘要器,Git Diff 有变更摘要器。
以 Git Diff 为例,如果不做摘要,一次改动十几个文件的 diff 轻松破万 Token。摘要器会提取变更文件列表、增删行数统计、以及每个文件的核心改动描述(比如"将查询参数校验从 main.go 移到 validator.go")。模型接下来进行的"是否要补测试""是否要更新文档"这类判断,并不需要逐行 diff 内容,摘要完全足够。
实现摘要器时有个细节:摘要长度要控制在模型单轮能处理的范围内,但又不能把关键信息吞掉。我的做法是每个摘要器最后都留一个"详情引用"字段,包含原始输出文件的路径和行号范围。如果模型需要看细节,它可以用工具再查一遍,但默认不携带。
4.4 第四步:工具调用瘦身
工具定义本身也会消耗 Token。每个工具的描述、参数 schema、示例调用都会被放进系统提示词里。工具数量越多,系统提示词越长,每轮对话都得重复支付这部分 Token。
我把工具数量从最初的 12 个精简到 6 个,分别是:search_symbol、read_code_snippet、edit_file、run_command、list_files、get_file_status。每个工具的 description 也严格控制在一两句话内,参数只保留必填字段。工具描述不写长篇说明,而是靠模型在实际调用中学会使用场景。
还有一个节省技巧是"动态工具注入":系统提示词里并不固定包含全部工具的描述,而是根据任务阶段动态加入。比如任务一开始只需要 search_symbol 和 list_files,等进入编辑阶段再把 edit_file 加进来。这样前期规划阶段的每轮对话,系统提示词长度可以减少大约 40%。
4.5 第五步:可复现的 A/B 测试
最后落实一下标题里的 58.7% 是怎么测出来的。为了保证对比公平,我严格固定了三个变量:同一仓库、同一组任务、同一个底层模型。我选了 12 个中等难度的任务,涵盖修 Bug、加功能、重构、补测试四种类型。每个任务跑两遍:一遍使用商业 Agent 默认完整上下文模式,一遍使用我的按需取用模式。记录全程的输入 Token 和输出 Token,再把同类型任务的 Token 汇总。
这里特别要强调:底层大模型必须一致。我当时用的模型后端是某主流商用模型,因为不同模型的 tokenizer 和指令遵循能力差异很大,如果换模型,Token 对比就没有意义了。测试时我锁定了相同的 temperature 和 max_tokens 参数。
最终统计下来,12 个任务的输入 Token 总消耗下降了约 63%,输出 Token 因为最终补丁内容相近,只下降了 8% 左右。总 Token 加权计算后,优化版消耗是商业 Agent 的 41.3%,也就是省了 58.7%。
5. 省 Token 路上的坑与排查记录
5.1 压缩过度:改丢关键信息,生成了一版跑不起来的代码
第一次把摘要器上线时,我为了追求更低的 Token 消耗,把 read_code_snippet 的返回内容也做了强制压缩:一个函数只保留签名和前 5 行代码。结果是模型在改代码时经常出现低级错误,比如引用了不存在的变量,甚至连函数返回值类型都判断错了,生成的代码一跑就崩。
排查后我才发现,模型的代码修改往往需要完整的函数上下文,尤其当任务涉及跨函数调用时,只给片段会让模型"凭空猜"。最终我调整了策略:代码片段默认保留完整函数体,但对函数内部超出必要范围的部分(比如大段日志打印、重复的工具函数)做局部剪裁。省 Token 不能在模型需要语义完整性的地方强行省,这是我踩过最深刻的一次坑。
5.2 缓存失效:文件改了,Agent 还在用旧内容
缓存层的 Bug 最隐蔽。我最初以"文件路径 + 片段类型"作为缓存键,没有考虑文件修改时间。结果有一次我改了某个配置文件后,Agent 在后续任务里连续三次读取的还是旧版本,导致生成的配置和当前项目结构对不上。
后来我把缓存键改为"文件路径 + 文件 mtime + 片段类型",并在每次 edit_file 调用成功后主动清除相关缓存。还需要注意依赖关系:如果一个文件被修改,引用它的索引符号表也要部分更新。我实现的方案是每次文件变更后触发一次局部重解析,只更新该文件的符号表。这样既保证准确性,又不会产生全量重建索引的高成本。
5.3 工具调用的 JSON 解析:截断与转义问题
模型在生成长 JSON 时,偶尔会碰到截断问题,特别是当工具参数里含有一段很长的代码内容时(比如通过 edit_file 写入一段较完整的函数)。截断的 JSON 会导致 json.loads 直接报错。我排查下来,问题主要是模型侧输出长度被 max_tokens 限制,并不一定是我代码的问题。
我的解决方案是:给 edit_file 的参数增加一个约束,要求模型尽量用"精确的替换范围 + 更小的替换内容",而不是一次写入整个大块代码。同时我在解析器里加入了一个轻量的 JSON 修复逻辑,可以处理"字符串被截断""缺少闭合括号"等常见异常。这样即使偶发截断,也能通过 retry 机制恢复,不至于让整次任务失败。
5.4 不是所有任务都值得优化:小任务的"检索开销"
我原本以为按需检索在所有场景下都更省 Token,实测后发现有个反例:单文件简单任务。比如"把这个文件里的日志打印改成统一格式",任务本身只需要读取一个文件。这时候如果用"索引 -> 检索 -> 摘要"的完整流程,模型会先调用 search_symbol,再调用 read_code_snippet,中间多出两三次工具调用的往返 Token。而商业 Agent 的做法是直接读取整个文件,一步到位,总 Token 反而更低。
所以我在实际使用上留了一个"快速通道":当任务描述里明确提到了具体文件路径,且预估文件小于 150 行时,直接走全量读取通道,跳过索引和摘要环节。这是一个很实用的策略:把优化留给复杂任务,把简单任务还给它最朴素的执行方式。
6. 实测数据复盘:58.7% 是怎么算出来的
6.1 不同任务类型下的 Token 消耗对照
我把 12 个任务分成四类,每类 3 个任务,分别统计商业 Agent 和自研 Agent 的平均 Token 消耗。单位我统一用"千 Token"来记录,兼顾输入与输出:
| 任务类型 | 商业 Agent 平均消耗 | 自研 Agent 平均消耗 | 节省比例 |
|---|---|---|---|
| 修复 Bug | 68.4k | 30.2k | 55.8% |
| 新增功能 | 142.7k | 48.9k | 65.7% |
| 代码重构 | 105.3k | 39.6k | 62.4% |
| 补充测试 | 92.1k | 47.3k | 48.6% |
可以看到,节省比例最高的是"新增功能"和"代码重构"这类需要探索大量代码文件的场景,因为检索替代了全量读取,效果显著。补充测试的节省比例相对低一些,是因为测试用例生成需要更多上下文理解,摘要能省的空间有限。
6.2 成功率、耗时与人工介入率:省 Token 的代价
很多人会担心,省了 Token 是不是牺牲了效果?我的实测结果是:在 12 个任务中,商业 Agent 有 11 个任务成功完成,1 个任务需要人工修正;自研 Agent 有 10 个任务成功完成,2 个任务需要人工修正。整体完成质量差别不大,但自研 Agent 的成功率确实低了约 8 个百分点,主要集中在需要长链路推理的任务上。
从耗时看,自研 Agent 平均耗时反而略高一些,因为检索和摘要层需要本地计算时间。平均每个任务多花 15~20 秒。换算成成本,这多出来的几十秒本地计算,相对于 Token 费用的下降,完全值得。
如果以费用计算:商业 Agent 跑完 12 个任务大约消耗 122 万 Token,自研 Agent 消耗约 50.3 万 Token,按我用的模型价格计算,费用降了一半以上。如果你日常跑的是高频、大规模任务,这个差距会更明显。
6.3 后续我打算继续优化的两个方向
当前这套系统还有两个我很想继续改进的地方。一个是"多文件修改的一致性读取"。模型在同时修改多个文件时,仍然会在不同文件的上下文之间来回切换,我目前是通过裁剪降低 Token 消耗的,但还不够结构化,希望以后能进化到"变更集"模式:把一批相关文件的修改作为一个整体单元来管理。
另一个是"摘要器的自适应长度"。现在每个摘要器的输出长度是固定的,但对不同复杂度的任务,理想的摘要长度其实不一样。我准备做成一个简单的置信度机制:当检索结果与任务关键字匹配度低时,输出更长的摘要,匹配度高时输出更短摘要。这会进一步提升 Token 的利用效率。
关于省 Token 这件事,我最后的体会
如果你也想自己做一个省 Token 的 Coding Agent,我的建议是先从"工具输出摘要"和"文件级缓存"两个点入手,这两个最容易实现,收益也最明显。先不要急着上向量库和复杂的上下文规划,否则容易陷入"为了技术而技术"的坑。我最初的三版迭代里,最有用的反而是最简单的那一层本地规则摘要。
再分享一个小技巧:每次给 Agent 发消息之前,先想一想"这段信息是真的需要模型看吗?还是我本地就能得出结论?"这个思维习惯,比任何架构优化都更能帮你省钱。我从最开始的手动交互到最后的自动化项目,整条路径上最核心的认知,就是这句话——让所有无关内容,离上下文远一点。