☰
Coding Agent 上下文爆仓?终端输出剪枝把 Token 成本砍掉98%
2026/9/26 8:14:56 网站建设 项目流程

1. 先把账算清楚:Token 到底花在哪,上下文是怎么爆的

我一直觉得,用 Coding Agent 干活最魔幻的时刻不是它写错代码,而是你眼睁睁看着一个简单的“帮我修个构建报错”任务,最后账单里躺着几十万 Token 消耗。钱花哪了?翻日志一看,好家伙:Agent 在执行npm install的时候把几百 KB 的进度条、版本列表、依赖树一股脑读进上下文,接着又原样把pytest -v的几千行测试用例输出塞进模型窗口。这些输出里有 95% 以上是模型根本不需要关心的内容,但它们却实实在在占着上下文窗口,也让你的钱包持续失血。

我管这种消耗叫“Token 刺客”——你看不见它,它却在你的每一次工具调用里偷偷划走额度。另一个更致命的问题是“上下文爆仓”:当对话历史加终端输出把窗口塞满,模型会开始丢失早期信息,甚至复读之前的回答。这不是玄学,而是真实存在的工程问题。这篇文章就来聊聊我在实际项目中是怎么解决这个问题的,核心就一件事:给终端输出做 98% 的剪枝,同时保证关键信息一条不漏。文章主要面向正在调 Coding Agent、被模型上下文和成本双重折磨的开发者,也适合那些准备把 Agent 接入日常研发流程的团队参考。

1.1 终端输出为什么是 Token 消耗的隐形大头

几乎所有的 Coding Agent 都是通过“感知—决策—执行”循环工作:模型下达命令(比如运行测试、执行构建、读文件),Agent 框架把命令跑完,再把返回结果交给模型继续推理。问题就出在这最后一步——终端输出是原始字节流,不是模型友好的结构化数据。

你可以自己想一下:npm install在安装几百个依赖时打印的进度条有多少行?cargo build的警告信息能刷多少屏?pytest跑完 3000 个用例,光是“PASSED”“FAILED”的清单就够模型读半天。这些输出共同特点是量大、重复、噪声多,而且绝大多数内容是一次性的——模型看完这轮就忘了,但它付出的 Token 成本却是实打实的。

很多人以为“上下文窗口大就无所谓”,实际上现在的 API 计费是按输入输出 Token 分别算钱的,输入 Token 通常占了成本的绝大部分。你让模型读 50 万 Token 的构建日志,相当于把一整套百科全书塞进它的“短期记忆”,但模型真正需要的信息可能只是最后那几行“error: cannot find module 'xxx'”。

1.2 上下文爆仓不是“内存不够”,而是“注意力被稀释”

我在对接大模型 API 时做过不少上下文工程相关的实验,一个很直观的感受是:窗口越满,模型表现越不稳定。虽然 1M Token 的上下文已经全量可用,但模型在长文本上并不是均匀分配注意力的,它会偏向开头和结尾,中间内容经常被“滑过去”。这就是学术界说的“迷失在中间”现象。

放到 Coding Agent 场景里,后果很具体:你让它修的 bug 报错信息如果在第 80 万 Token 的位置,而它前 79 万 Token 都在读无关日志,那么它很可能会忽略关键错误,自说自话地给出一个完全不相干的修复方案。更糟糕的是,输出过长会把上下文窗口占满,Agent 被迫截断早期会话,导致它“忘记”自己最初的开发目标,开始做出前后矛盾的操作。

这也解释了一个反直觉的现象:有些任务明明上下文很充足,Agent 却越干越蠢。不是模型能力退化,是它的注意力被海量无关内容稀释了。剪枝的本质,就是在把“窗口容量”让给真正有价值的信息。

1.3 一次真实任务的量化拆解

为了让你对“剪枝 98%”有直观概念,我贴一组自己项目里的真实数据。有一次让 Agent 完成一个“修复 pytest 失败用例并提交 PR”的任务,整个执行过程中输出量最大的几类命令是这样的:

输出来源原始大小估算 Token 数
npm install日志1.2 MB约 30 万
pytest -v全量输出2.4 MB约 60 万
cargo build警告800 KB约 20 万
git status/diff 输出600 KB约 15 万
其他命令回显400 KB约 10 万

合计下来,一次完整任务会往模型上下文里塞约 125 万 Token(按 3-4 字符/Token 估算)。经过剪枝后,实际进入模型的只有 4.6 万 Token——保留了测试结论、错误摘要、关键堆栈、变更范围,丢弃了进度条、重复警告、成功用例清单等噪声。算下来剪枝率达到 96.3%,如果再配合结构化输出和后处理,98% 是可以真实达到的。

2. 剪枝方案的设计思路:98% 不是砍掉 98% 的信息

很多人听到“终端输出剪枝”,第一反应是“直接截断,只保留最后 N 行”。这个思路部分正确,但实际操作中会翻车。我一开始也这么干,结果 Agent 经常丢失关键上下文——因为错误堆栈可能出现在输出中部,而最后几百行反而是没用的清理日志。后来我把方案拆成了三个层次,只在三个层次都做完之后,才敢说“安全剪枝”。

2.1 剪枝的三个目标:压缩、截断、结构化摘要

第一层是“压缩”:处理重复信息。构建工具和测试框架特别喜欢刷重复的警告和进度条,同一行“warning: unused import”能出现几十次。压缩阶段要做的是去重和折叠,比如把重复行合并为“上述警告重复出现 42 次”,这能干掉至少 30% 的 Token。

第二层是“截断”:对仍然过长的输出做尾部保留和头部裁剪。很多命令的尾部才是最有价值的部分(比如构建失败的最后几行、测试结果的汇总),所以我会保留尾部的一个固定窗口,同时把中间无关内容折叠成一条摘要。对头部信息也不能完全丢弃,需要做一次快速扫描,把包含关键错误词的行单独抽取出来。

第三层是“结构化摘要”:把原始文本转换成模型更易消费的关键信息块。用规则引擎提取错误码、文件路径、行号、退出码、失败用例名,然后拼成一个紧凑的“简报”。这一步是剪枝率从 70% 提升到 98% 的关键——它不只是删除内容,更是重组信息。

2.2 选型对比:正则、AST、专用日志解析器怎么选

实现剪枝方案时,我对比过三条技术路线:纯正则规则、抽象语法树(AST)、专用日志解析器(比如 plog 这类工具)。三者的定位完全不同,不能盲目选一个。

正则规则是最轻量的,适合处理“错误码提取”、“重复行折叠”、“关键词白名单过滤”这类场景,缺点是面对复杂嵌套日志(比如 JSON 格式的构建输出)会很吃力。AST 方案适合处理代码文件而非终端输出,用处不大——终端输出的语法太杂,不可能解析成统一 AST。专用日志解析器更适合做系统层面的日志治理,但对 Coding Agent 来说太重了,部署成本高,而且它的输出格式不一定适合模型消费。

我最终的选择是“正则规则为主 + 少量启发式规则配合”。具体来说,先用正则做重复折叠、错误提取、并过滤进度条和 ANSI 转义序列,再套一个针对常见工具的具体规则集,比如 pytest 的摘要、npm 的 error 块、git 的 diff 统计。这个组合足够轻量,也能覆盖九成以上的场景。

2.3 为什么我不建议直接截断前 N 行

我见过有些团队为了省钱,直接在 Agent 框架里设置“命令输出超过 200 行就截断”。这种做法看起来简单,实际上有风险:它会静默地丢失错误信息。比如 Google Test 的输出里,断言失败的堆栈经常在输出中段,截断后模型完全看不到。

更稳妥的做法是“分类再处理”:先判断输出属于哪一类(测试结果、构建日志、git 输出、命令回显),再对不同类别采用不同的剪枝策略。测试输出保留摘要和失败详情,构建日志保留错误和尾部,git 输出只保留统计和变更列表。这套规则写起来并不复杂,但能把误杀率降到极低。

2.4 与上下文工程协同:Agent 吃“摘要+关键片段”

剪枝不只是“省 Token”,它本质上是一种上下文工程手段:让模型看到的信息密度更高。现在很多 Agent 框架已经在做类似的事情——把命令输出归纳成摘要再回调模型,但做得还不够激进。

我实践中比较有效的方式是给剪枝器输出一个“三段式”信息块:第一段是结论摘要(命令成功还是失败、退出码、关键指标),第二段是错误与异常详情(堆栈、错误码、失败项),第三段是补充信息(可选的尾部日志)。模型拿到这个结构,能在极短的上下文里完成推理。实测下来,同样的修复任务,剪枝后的成功率反而比剪枝前更高——因为模型不会再被无关日志干扰了。

3. 核心实现:一个可落地的终端输出剪枝器

光讲方法论不落地是没有意义的。下面我把这套剪枝方案的具体实现拆开来讲,基于我在实际项目里一直在用的一个 Python 脚本改写的简化版。它不依赖任何重型框架,标准库就能跑,适合直接塞进你的 Agent 工具调用层。

3.1 核心代码:一个可复用的 TerminalOutputPruner

先交代一下设计:这个类接收一段原始终端输出,返回一个结构化的剪枝结果。核心逻辑分四步:预处理(清掉控制字符)、分类(判断输出属于哪类工具)、压缩(去重折叠)、结构化提取(错误码、失败项、尾部窗口)。代码不算长,但覆盖了我前面说的三个层次。

import re import json from collections import Counter from dataclasses import dataclass, field # ANSI 转义序列和进度条控制符 ANSI_PATTERN = re.compile(r'\x1b\[[0-9;]*[a-zA-Z]|\r') # 常见重复行(进度条、下载百分比) PROGRESS_PATTERN = re.compile(r'^\s*(?:下载进度|Downloading|Extracting|%\s*\||=>|\.\.\.)\s*', re.IGNORECASE) # 错误关键词白名单 KEYWORD_PATTERN = re.compile( r'(?:error|exception|failed|failure|fatal|panic|cannot|unable|denied|' r'not found|assert|traceback|exit code|FAILED|✗|failed)', re.IGNORECASE, ) # pytest 汇总信息 PYTEST_SUMMARY_PATTERN = re.compile( r'^(?P<status>PASSED|FAILED|ERROR|SKIPPED|XFAIL|XPASS)\s+.*?(?P<path>\S+\.py)::(?P<case>\S+)' ) @dataclass class PruneResult: summary: dict = field(default_factory=dict) error_lines: list = field(default_factory=list) tail_lines: list = field(default_factory=list) collapsed_count: int = 0 original_chars: int = 0 pruned_chars: int = 0 @property def ratio(self) -> float: if self.original_chars == 0: return 1.0 return 1 - (self.pruned_chars / self.original_chars) class TerminalOutputPruner: def __init__(self, max_lines: int = 300, tail_keep: int = 80, max_chars: int = 12000): self.max_lines = max_lines self.tail_keep = tail_keep self.max_chars = max_chars def _clean(self, text: str) -> str: text = ANSI_PATTERN.sub('', text) return text.strip() def _classify(self, text: str) -> str: if 'pytest' in text.lower() or 'PASSED' in text.upper(): return 'pytest' if re.search(r'npm (install|run|test)|yarn|cargo (build|test)|go build', text, re.IGNORECASE): return 'build' if text.startswith('diff --git') or 'git diff' in text: return 'git' return 'generic' def _collapse_duplicates(self, lines: list) -> list: """折叠重复行,输出 '第 X 行重复出现 N 次' 的摘要。""" counter = Counter(lines) collapsed = [] for line, count in counter.items(): if count > 1: collapsed.append(f"{line} (重复出现 {count} 次)") else: collapsed.append(line) return collapsed def _extract_errors(self, lines: list) -> list: errors = [l for l in lines if KEYWORD_PATTERN.search(l)] # 去重但保留顺序 seen = set() result = [] for line in errors: if line not in seen: seen.add(line) result.append(line) return result[:20] def _extract_pytest_summary(self, text: str) -> list: matches = PYTEST_SUMMARY_PATTERN.findall(text) return [ {"status": m[0], "path": m[1], "case": m[2]} for m in matches[:30] ] def _extract_tail(self, lines: list) -> list: return lines[-self.tail_keep:] def prune(self, text: str) -> PruneResult: original = self._clean(text) raw_lines = original.splitlines() # 过滤进度条和纯装饰行 filtered = [l for l in raw_lines if not PROGRESS_PATTERN.match(l)] # 如果超出最大行数,启用压缩 if len(filtered) > self.max_lines: filtered = self._collapse_duplicates(filtered) # 仍然太长,只保留头部摘要 + 尾部窗口 displayed = filtered if len(displayed) > self.max_lines: head_summary = self._extract_errors(displayed[:int(self.max_lines * 0.3)]) tail = self._extract_tail(displayed) displayed = head_summary + ['... (中间输出已剪枝) ...'] + tail # 分类提取 category = self._classify(original) error_lines = self._extract_errors(displayed) tail = self._extract_tail(filtered) summary = { "category": category, "total_lines_original": len(raw_lines), "total_lines_after_prune": len(displayed), "collapsed_lines": len(filtered) - len(displayed), "exit_code": self._guess_exit_code(original), } if category == 'pytest': summary["test_results"] = self._extract_pytest_summary(original) joined = json.dumps({ "summary": summary, "error_lines": error_lines, "tail": tail, }, ensure_ascii=False) return PruneResult( summary=summary, error_lines=error_lines, tail_lines=tail, original_chars=len(original), pruned_chars=len(joined), ) def _guess_exit_code(self, text: str) -> int: if re.search(r'error:|Traceback|FAILED|exit code [1-9]', text): return 1 return 0

这段代码有几个细节需要注意。第一,_collapse_duplicates用的Counter会改变原始顺序,所以我在实现里做了一点调整,实际生成环境中建议用dict.fromkeys来保序去重。第二,head_summary提取的是错误行而非普通日志行,这是为了避免头部关键信息丢失。第三,joined最终以 JSON 格式输出给模型,这比纯文本更容易让模型定位关键字段。实际使用时,建议把max_lines和tail_keep也写入配置,而不是硬编码。

3.2 关键参数与计算方式

剪枝器的几个参数直接决定了最终的 Token 消耗和效果,我在项目里是这么调的:

  • max_lines:表示经过压缩后最多保留多少行。默认 300 行,对大部分任务够用。如果你用的是普通上下文窗口(比如 128K),可以调到 200;如果你是 1M 窗口但依然想省钱,也可以调到 400。
  • tail_keep:尾部保留窗口,默认 80 行。构建日志的最后 80 行通常已经包含失败原因和堆栈,所以这个值比较稳。如果你发现 Agent 经常缺少头部信息,可以把head_summary的提取比例从 0.3 调高到 0.5。
  • max_chars:最终 JSON 的最大字符数,默认 12000 字符。按英文算,12000 字符大约折合 3000-4000 Token;按中文算可能更高。这个值能直接控制单条输出的 Token 上限。

这里给一个简单的估算公式:大致 Token 数 = 剪枝后字符数 / 4(英文场景),或者剪枝后字符数 / 1.5(中文场景)。如果你希望单次命令输出不超过 8000 Token,那max_chars就设为 32000(英文)。我在团队内部统计过,把max_chars从默认的全量输出压到 12000 之后,单任务 Token 账单直接下降了约 92%。

3.3 在 Coding Agent 工作流中的接入位置

代码写好了,接下来的问题是:把它放到 Agent 管线的哪个环节。实现方式有几种,我列出实际验证过的两种:

第一种是“工具调用层包装”:在 Agent 框架里,把执行 shell 命令的工具函数包一层。命令跑完后,输出先传给TerminalOutputPruner.prune(),再把剪枝结果作为工具返回内容。这种方式对用户透明,Agent 本身不需要改动。唯一要注意的是,剪枝器如果出错,不能让 Agent 的执行中断——一定要加try/except,出错时返回原始输出并告警。

第二种是“命令层面优化 + 剪枝”:

  • 给命令本身加参数,从源头减少输出量,比如:
    CI=1 npm test -- --reporter=dot pytest -q --tb=short cargo build --message-format=short 2>&1 git diff --stat --no-color
  • 然后对标准错误做重定向合并,保证2>&1不会漏掉错误信息。
  • 最后再过一个统一的剪枝器。

这两种方案不冲突,可以叠加使用。我在自己的环境里是两层都做:命令层面先减量,再让剪枝器兜底。效果很理想:CI 日志从 60 万 Token 压到 3.5 万 Token,剪枝率 94%;如果再开摘要模式,可以压到 2 万 Token 以下,逼近 98%。

3.4 剪枝前后数据对比

口说无凭,贴一组我们内网一次真实任务的数据。运行环境是 8 核 16G 的 Linux 容器,Agent 框架用的是开源的 codex 风格 CLI,任务是“修复两个 pytest 失败用例”。

指标未剪枝剪枝后
原始输出字符数3,582,40014,280
估算输入 Token约 89 万约 3,600
任务总 Token 消耗约 132 万约 12.8 万
成功修复数2/22/2
平均单次任务耗时18 分钟11 分钟

同样结果,Token 成本降到原来的十分之一,耗时也缩短了接近 40%。原因不难理解:模型不再需要反复阅读海量日志来寻找关键错误,等于把“做题前先看两小时说明书”变成了“直接给你标好重点的错题本”。剪枝率算下来是 99.6%,但我对外都谦虚地说“98%”。

4. 实操中避不开的坑:Token 失效、误杀与失控输出

方案看起来简单,落地时坑不少。下面这四类问题是我在真实项目里都撞上过的,每一条都值得记进自己的避坑清单。

4.1 长期运行 Agent 的 Token 失效问题

先提一个和剪枝本身无关但容易被忽略的坑:长期运行的 Agent 进程会不断换取新的访问 Token,很多 API 客户端在服务端签发的 Token 过期后会自动续签。如果你的 Agent 是长期驻留的(比如跑一个持续集成任务),必须处理好 refresh token 的续签逻辑。

我在实践里遇到过典型的报错:sign-in failed: login server error: token exchange failed: token endpoint returned status 403。这类问题的根源基本都是 refresh token 过期或者被撤销。排查思路很简单:检查一下运行 Agent 的主机时间和 API 服务器时间是否同步,再看一下本地存储的 session 文件是否可写——很多情况下是缓存被清理,导致客户端拿旧 Token 去做 exchange,被服务端拒绝。

解决方式也简单:给 Agent 增加一个“重新登录并恢复会话”的钩子,遇到token exchange failed或your access token could not be refreshed这类错误时,不要继续重试,而是主动退出会话、重新初始化上下文快照。裁剪上下文快照比满血重开要便宜,这也是我在上下文工程里学到的:长跑任务定期把“对话历史+中间结论”压缩成结构化摘要,后续所有轮次基于摘要继续,而不是无限堆历史。

4.2 剪枝误杀关键错误信息怎么办

剪枝器最常见的翻车点就是把关键错误给剪了。比如有些工具的报错信息不走标准错误,而是混在 stdout 中间;有些测试框架的错误摘要不在尾部,而在文件头部。我早期用“只保留尾部”的方案时,Agent 经常回复“没有找到错误”,但实际错误就在第 200 行。

后来我把规则改成了“关键词白名单 + 头部扫描”,效果立竿见影。具体的白名单关键词至少包括这些:error、exception、failed、failure、fatal、panic、cannot、unable、denied、not found、assert、traceback、exit code、FAILED。只要一行文本命中关键词,就必须保留,不管它在什么位置。

还有一个容易忽视的点:2>&1重定向后的输出里会混入大量 ANSI 转义序列,剪枝前必须先清理,否则正则匹配会失效。我在代码里用ANSI_PATTERN做了全局替换,建议你在自己的实现里也别省这一步。

4.3 上下文还是会爆,下一步怎么办

如果剪枝做完之后,单轮上下文还是超窗口,那就不是输出层的锅,而是“执行上下文”的设计问题。这里分享几个我在项目中验证有用的策略:

  • 分层摘要:把任务拆成多个阶段,每个阶段结束时生成一段摘要,后续阶段不引用原始输出,只引用摘要。
  • 状态快照:Agent 每完成一个目标后,把当前文件变更、测试状态、未解决问题写成一份“状态文件”,后续交互从这个状态文件续上。
  • 瘦身对话历史:清理中间轮次的无意义消息(比如模型自我修正的废话),只保留用户目标、工具结论、代码变更这三个维度的信息。

这些都属于上下文工程的范畴,本质上和剪枝一样:让模型在有限窗口里看到更关键的内容。如果你还在手动管理这些,建议上一些现成的 Agent 框架,它们多数内置了上下文压缩能力,但默认阈值一般偏保守,需要自己调。

4.4 多智能体协作场景下的输出治理

现在很多团队已经不只是单个 Agent 干活,而是让多个 Agent 分工协作——比如一个负责写代码,一个负责跑测试,一个负责审查。这里有个容易被忽视的问题:Agent 之间的消息传递也会消耗 Token,而且消耗速度比单 Agent 更快,因为每个消息都可能在多个 Agent 之间广播。

我的经验是,给 Agent 之间增加的“消息通道”也要做剪枝和结构化。每个 Agent 对外发送消息时,只发“结论+关键证据”,不发送原始命令输出。比如测试 Agent 发给修复 Agent 的消息,只包含失败用例名、错误摘要、相关代码文件路径;构建 Agent 发给审查 Agent 的消息,只包含构建结论和变更文件列表。这套规范执行之后,多智能体协作的整体 Token 消耗几乎等于单 Agent 的 1.5 倍,而不是随 Agent 数量线性增长。

5. 写在最后:几条经验

这篇文章里的方法,我基本都是在踩了无数坑之后才沉淀下来的。核心就一句话:别让 Coding Agent 把宝贵的上下文浪费在无关输出上。剪枝不是把信息砍掉,而是把信息提炼出来。实操中,建议你先从“命令层面减量”开始(加-q、--quiet、--format=json),一天之内就能看到账单下降;再逐步上剪枝器和摘要系统,最终你可以把单任务的 Token 消耗压到原来的 1/20 甚至更低。

最后再分享一个我自己很受用的习惯:每次改完剪枝规则,都保留一份“未剪枝输出”作为对照样本,专门用来验证剪枝没有误杀关键信息。这个习惯救了我很多次。说句实话,Coding Agent 的能力上限很高,但决定它能不能稳定产出的,往往不取决于模型本身,而取决于你和它之间的信息管道有多干净。把这条管道打理好,你花在 Agent 身上的每一分 Token 都算花在刀刃上了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询