1. 首页
  2. AI 应用
  3. 把 Agent 的上下文窗口从垃圾场修成工作台:一次上下文爆炸治理的完整复盘

把 Agent 的上下文窗口从垃圾场修成工作台:一次上下文爆炸治理的完整复盘

metal 44分钟前 ⋅ 1 阅读
ad

写在前面:这是 webfunny_ai_server / webfunny_ai_fe 两个项目在 2026 年 10 月做上下文治理的实战记录。目标是把自动埋点 Agent 的单任务 token 消耗压到原来的三分之一以下,中间翻过一次车——摘要机制上线当天就制造了一起"第三点位幻觉"事故。这篇文章按时间线复盘整个过程,包括那些做错了的地方。

一、问题不是"窗口不够大",是"窗口里全是垃圾"

2026 年 10 月初,代码修复(codefix)工具链上线联调。功能能跑通,但数据很难看:自动埋点一个任务跑 442 秒,其中"读取仓库代码"阶段就烧掉 40 多次工具调用、170 秒,最后还没产出。

我们把单次任务的上下文拆开看,token 花在了四个地方:

读路径——改 5 行代码要读 100KB 文件。 模型的惯用动作是先用 git_search_code grep 定位,再用 git_read_file 读文件。问题在于 git_read_file 没有"按需读取"的概念,一读就是整个文件(带行号,上限 100KB)。而且 grep 返回的命中行不带函数归属信息,模型不知道这行代码属于哪个函数,只能把整个文件读回来自己判断。

写路径——改 5 行代码要输出 20KB 整文件。 git_write_file 是覆盖式写入,模型必须把文件完整重写一遍再交回来。大文件直接撞上 max_tokens 截断,我们此前已经吃过一次"Unexpected end of JSON input"的报错——流式响应被截断,JSON 解析失败,整轮白跑。

重复输出——diff 每轮都从头输出。 逐点位循环里,git_get_diff 每轮返回累计的全量 diff。第 3 个点位出 diff 时,前 2 个点位的 diff 原封不动再来一遍,纯属重复占用。

历史膨胀——裁剪是"截断式"的。 长对话超限后,直接丢弃最早的 N 条消息。脉络断了不说,早期花的 token 等于白烧。

结论很简单:token 消耗与文件大小成正比,而不是与改动大小成正比。这个模式在埋点这种"窄任务"上尤其致命——改两行埋点代码,付的是整个文件的成本。

二、先看看别人怎么做的

动手前做了一圈调研:

产品

思路

Cursor

本地 tree-sitter 索引 + 嵌入检索 + 行级 diff 补丁

GitHub Copilot

远程索引 + 代码片段检索

Claude Code

工具化按需读取 + SEARCH/REPLACE 编辑块 + 上下文压缩

Aider

repo map(仓库符号地图)常驻上下文 + unified diff 编辑

Sourcegraph Cody

SCIP 代码图 + token 预算管理

 

共同点很明确:不让模型读全量、不让模型写全量、上下文里只放"够用的信息"。另外有一家同行产品(ThinkingAI)刻意避开了代码修改——只做官方 SDK 集成和事件模型模板,多端代码生成一直没发布。而我们做的自动埋点要直接改用户的业务代码,比 SDK 集成激进得多,没有现成方案可抄,token 优化体系只能自己建。

据此定了分层改造方案:

  •  P0 补丁编辑协议:用 SEARCH/REPLACE 补丁替代整文件写回
  •  P1 符号级读取:先出符号表,再按行号区间精读函数体
  •  P2 检索升级:repo map 常驻 + 混合检索 + diff 增量展示
  •  P3 历史压缩:上下文裁剪从"截断丢弃"改成"摘要保留脉络"

三、P0:补丁编辑协议

新增 git_patch_file 工具,参数是一组 {search, replace} 补丁。模型不再输出整个文件,只输出它改动的片段。

工具侧要做的事比想象中多:

唯一性校验。 search 段必须能唯一定位,否则替换会出乱子。先做精确匹配,不唯一或不匹配时降级为归一化匹配——每行 trimEnd、去掉首尾空行再比。依然失败就报错,绝不瞎替换。

原子性。 所有补丁先整体校验,全部通过后一次性 fs.writeFileSync 落盘。任何一个补丁失败,整体回滚,不留半成品。

行号偏移。 多个补丁顺序应用时,前面的补丁会改变后续行号。所以每应用一个补丁就重建一次行偏移表,保证第 2 个补丁报的行号不是基于旧文件的。

括号平衡检查。 字符串/注释感知的括号配对检查,防模型手滑删掉半个大括号。异常只 WARN 不阻断——毕竟这是提示,不是裁判。

参数上限卡死:单次 1~8 个补丁、search 段不超过 4096 字符、replace 段不超过 8192 字符。防止模型借补丁之名行整文件重写之实。

四、P1:符号级读取

git_list_symbols 解决"读前不知道文件里有什么"的问题。支持 .js/.jsx/.ts/.tsx/.mjs/.cjs/.vue(vue 只提取 <script> 块内符号),输出紧凑格式:

  1. L12-L20  handleSearch (arrow)
  2. L42-L60  CardDetail (component)

 

配合 SKILL 里的引导语"用 git_read_file 按行号区间精读函数体",模型从"读整个文件"变成"读 L12-L20 这 9 行"。

两个容易翻车的细节:关键字过滤(if/for/new/import 这些不能误报成函数),嵌套函数的归属判定(箭头函数套箭头函数时,内层函数要记到外层名下)。

五、P2:repo map、混合检索、增量 diff

`git_get_repo_map` 输出仓库的目录树 + 每个文件的关键符号(单文件最多 8 个,组件/类/钩子带类型前缀),上限 30KB。结果按子路径缓存,带 mtime 失效。首次扫描时顺带预热符号缓存,后面搜索标注零额外 IO。

`git_search_code` 双模式。mode=grep(默认)在命中行上标注所属函数:

[handleSearch L42-L60] 命中行内容

 

模型看到行号区间就知道该读哪,省掉"读文件确认上下文"的二次调用。mode=symbols 按符号名(支持正则)检索函数/方法/组件定义。

`git_get_diff` 增量。逐文件对比上次 diff 的缓存,没变化的文件只出一行摘要"不重复展示";full=true 强制全量。SKILL 约定逐点位出增量 diff,全部完成后最终确认时用 full=true。

这一层做完,读/写/diff 三条路径的 token 结构就变了。剩最后一块:历史消息。

六、P3:历史消息摘要——第一次做错了

6.1 第一版:纯规则式摘要

最初的设计很朴素:trim(messages, 15) 保留 system + 最近 15 条,被裁掉的旧消息压缩成一条"历史摘要"——工具调用只记工具名、文本截 120 字符、总量上限 1500 字符。不调用 LLM,零延迟零成本。

设计文档里其实留了一个 generateSummaryPrompt(LLM 摘要提示词模板),但一直没接线。当时觉得规则式够用,LLM 摘要属于锦上添花。

这个判断第二天就被打脸。

6.2 上线当天的事故:第三点位幻觉

自动埋点单点位模式,点位 1、2 成功,点位 3 报错:文件不存在:components/buyServiceDialog/index.js。然后模型开始反复搜索、反复失败,日志里出现"不知道要干啥了"——典型幻觉。

翻日志找到完整因果链:

17:00:25  Context exceeds threshold, summarizing...   (密集刷屏)

17:01:53  LLM 摘要失败,降级规则式摘要: stream has been aborted

17:08:45  上下文已裁剪: dropped=47, kept=17, summary=3257 chars

17:09:39  文件不存在:components/buyServiceDialog/index.js

 

三件事叠加:

直接原因——摘要把点位定位信息压丢了。 我们的架构里,流程状态机(点位清单、定位结论、工作区信息)靠"内联卡片"在消息里透传。规则式摘要对卡片消息只截 120 字符,buryReadCode 卡片的 filePath/trigger/readRange 全丢。模型失去定位结论后凭语义猜路径,猜出个不存在的 buyServiceDialog,读文件报错,错误信息又进上下文,越搜越乱。

放大器——dropped=0 空转。 裁剪后 kept=17(非 system 消息仅 15 条),已经不可再裁,但 token 估算仍超阈值。于是每轮循环都触发一次 summarizing,日志刷屏,实际什么都没裁。

慢性病——降级摘要无上限膨胀。 LLM 摘要失败时走规则式降级,把新片段直接 append 到旧摘要后面。从 1755 字符涨到 3257 字符还在涨,冗余信息反过来污染上下文。

至于 LLM 摘要为什么失败——stream has been aborted,是用户连续操作导致同会话上一轮请求还没结束就被新一轮 abort 了。降级机制兜住了底,但兜底质量太差。

6.3 复盘:第一版的五个设计缺陷

事故之后把整套机制翻出来重新审了一遍,整理出五个缺陷:

缺陷 1:规则式摘要对工具密集型 Agent 是结构性缺陷。 工具结果才是代码场景最有价值的信息,而规则式摘要对工具结果只记 120 字符首行。git_read_file 的文件内容摘要后只剩"文件 xxx 共 N 行:1→ import...",模型被迫重读。摘要触发越频繁,模型越像失忆,陷入"读 → 丢 → 重读"的死循环——这就是 442 秒耗时的第三个放大器。规则式摘要对闲聊场景勉强可用,对工具密集场景不成立。

缺陷 2:触发时机单一,没有循环内预算。 needsSummary 只在 runStream 入口检查一次。但对话循环每轮都会把工具结果 append 进 messages,maxToolCalls=40 × 平均 10KB 工具结果 = 400KB+,循环内早就撞上 API 上下文上限了。正确做法是每轮循环开始前都做预算检查。

缺陷 3:摘要产物不持久化,跨请求失效。 这是架构性缺陷。每个 HTTP 请求都新建 AgentEngine 实例,loadHistory 恢复的只有前端传来的文本消息。上一轮的工具调用过程、定位结论、摘要,全部随请求结束消失。摘要做得再好,下一轮请求开始就归零。

缺陷 4:没有增量摘要。 每次 trim 都对新丢弃的消息从头构建摘要。连续多轮裁剪时,上一轮已摘要的内容被二次压缩(120 字符再截断一次),信息层层损失。业界标准做法(LangChain、Aider 都是)是增量摘要:只摘要新丢弃的部分,与旧摘要合并,摘要只增不减。

缺陷 5:token 估算粗糙,阈值语义混乱。 中文 1.5 字符/token、英文 4 字符/token 的粗估,对代码这种符号密集文本偏差可达 2 倍以上。而且 summaryThreshold 的取值与模型真实上下文上限毫无关系,定多少全凭感觉。

6.4 第二版:LLM 增量摘要 + 循环内预算 + 持久化

重做分四步:

P0:LLM 增量摘要接线。 util/llm.js 里新增独立的 summarize 轻量调用——独立模型配置(summaryModel)、max_tokens=2048、temperature=0.3、30 秒超时、不重试。设计原则是"快速失败比等待重试划算":摘要失败就降级规则式,代价极低,不值得耗在重试上。每次只对"上次摘要之后新丢弃的消息"调用 LLM 压缩,输出与旧摘要合并。摘要注入为 system 消息,带 [历史摘要] 前缀,与用户消息隔离,避免模型把摘要当成新指令。

P1:循环内动态预算 + 配对保护。 阈值改成 min(summaryThreshold, contextLimitTokens × 60%)——留 40% 给本轮工具结果与回复。检查移到 while 循环每轮开始(含第一轮)。裁剪加了配对保护:保留区的起点如果落在 tool 调用组内部(tool 结果、或发起 tool_calls 的 assistant 消息),向前回溯到完整组边界,绝不从中间切断——切断的组会被后续的 sanitize 整组丢弃,那是丢数据而不是压缩。

P2:摘要持久化。 conversation 表加 summary 列(ALTER 幂等补列),每轮对话结束把 summaryText fire-and-forget 存库(Web 会话才存,im_/temp- 前缀的跳过)。新请求进来时先查库,恢复为 system 消息。这一步同时修复了"三阶段拆分缓存复用失效"的问题——上一轮的定位结论终于有载体传到下一轮了。

P3:规则式摘要分级保底。 规则式摘要是 LLM 摘要失败时的兜底,但兜底也要讲质量。按工具类型定制提取逻辑:git_read_file 保留文件路径 + 行数(让模型知道重读哪个区间,而不是盲目重读)、git_prepare_workspace 保留分支/基线/工作区路径等键值行、diff 卡片保留改动文件清单、读取卡片保留点位定位结论。

6.5 事故专项修复

针对 6.2 的事故,又补了三个专项:

卡片关键字段提取。 _extractCardSummary 对 ```json 围栏的卡片透传消息做解析,提取关键字段——buryReadCode 卡片的"点位名 → 文件路径 → 行号区间"、codeFixDiff 卡片的改动文件清单。有围栏就结构化提取,没有就回退原来的 120 字符截断。卡片承载的是流程状态机,丢字段等于断流。

空转抑制。 _summarySkipRound 标记:裁剪前后消息数不变(已无可裁剪消息)时,本轮不再重复触发摘要,靠单条分级截断兜底。

降级摘要膨胀上限。 规则式降级把新片段 append 到旧摘要时,总长超过 3000 字符就丢弃旧摘要只留最新片段。旧片段信息价值递减,冗余会污染上下文。

七、配置层的三个教训

改代码的过程中顺带挖出三个配置问题,单独拎出来说:

隐性覆盖陷阱。 我们改了 config/index.js 的默认 summaryThreshold 从 6000 到 20000,测试环境却一直没生效。查了半天发现 config.local.json 里写死的 summaryThreshold: 6000 一直在覆盖默认值——实际压缩阈值一直跑 6000(当时窗口的 0.6%),过早裁剪失忆的锅有它一份。教训:改默认值之后要确认有没有 local 覆盖。

阈值定多少合适。 调研结论是压缩阈值普遍设在上下文窗口的 50%~70%——Claude Code 在 200K 窗口约 60~70% 触发压缩,SWE-bench 各框架 128K 窗口多在 64~96K 触发。6000 太小(一轮 repo map 上限 30KB ≈ 8K token,加几个搜索结果就触发压缩,直接导致 SKILL 正文被裁掉)。最终定为 20000,并且把阈值语义改成与真实上下文上限挂钩的 min() 结构,而不是孤立数字。

maxTokens 不拉满。 模型上下文窗口升到 1M 后,我们没把 maxTokens 拉到官方允许的最大值,而是取 16K(官方典型推荐值)。单轮回复预算失控会白烧额度、拖慢流式——窗口变大不等于每轮就该多输出。

八、工具链侧的其他小优化

几条投入很小、收益直接的:

搜索去重缓存。 模型有个坏习惯:搜不到就重复搜同一个关键词。在 git_search_code 入口按 模式|关键词|范围|上限 生成 cacheKey,命中直接返回缓存结果加一句"此前已搜索过相同模式与范围,结果一致"。会话级 Map 上限 30 条。40+ 次工具调用里,相当一部分是这类无效调用。

文件枚举换 `git ls-files`。 原实现是 fs 递归 walk + 手动忽略表。换成 git ls-files --cached + --others --exclude-standard,天然遵守 .gitignore,大仓库下不再遍历 node_modules/dist 的巨型目录树。实测 397 文件仓库全库枚举 181ms(含两次 git 进程启动)。git 失败降级回 fs walk。注意点:worktree 新增的未跟踪文件不在索引里,所以要补 --others --exclude-standard。

符号提取换 @babel/parser。 自研正则在箭头函数嵌套、class 方法、TS 泛型上漏判(实测漏过 fetchList = async <T>(...): Promise<T[]> 这种写法)。换成 babel AST 遍历,纯 JS 零原生依赖,TS/JSX 插件内置。两个坑:@babel/parser 必须 7.x(8.x 是 ESM-only,与项目 CommonJS 不兼容);首版还漏了 .ts 文件按扩展名选插件,导致 TS 文件全部走正则降级,测试时才发现。babel 解析失败或文件超 200KB 时降级回正则,保证不抛错。

缓存失效联动。 写文件(git_write_file / git_patch_file)落盘后,主动清空搜索缓存和 repo map 缓存——文件内容变了,旧的搜索结果就是错的。符号缓存按 mtime 自动失效,不用管。

SKILL 正文裁剪豁免。 裁剪时保留最近 2 个已加载的 SKILL 正文(单条截 8KB),作为 system 消息永久保留。工作流程约定(步骤、卡片格式)被裁掉的话,模型会失忆并输出不完整的结果。

loadHistory 防 400。 恢复历史时 system 消息全保留,其余只取最近 20 条,防止恢复阶段就把 API 上下文上限顶爆。

九、前端配合:让历史消息"瘦身"

这套机制的运行离不开前端配合。webfunny_ai_fe 侧的关键逻辑在 useAiChat.js 的 toHistory:

const toHistory = function (list) {

    return list.filter(function (m) {

        return !m.virtual && !m.type && !m.error;

    }).map(function (m) {

        return { role: m.role, content: m.content };

    });

};

 

两层含义:其一,虚拟消息(开场气泡)、实时步骤伪消息、错误提示不参与模型上下文,直接过滤掉;其二,历史只传 {role, content} 两个字段。结构化卡片(```json 围栏)随文本存库,刷新后由前端的 MessageBubble 从文本解析还原渲染——卡片数据只有一份载体,不额外占上下文。

服务端恢复历史时把摘要(system 消息)放在最前面,前端继续对话时带最近 20 条历史。双方各留一层裁剪,防的是不同环节的爆窗。

十、效果与遗留

改完之后的数据:

路径

改造前

改造后

输入(读代码)

整文件 100KB 级

符号表 3KB + 函数体 1KB 级(约降 90%)

输出(写代码)

整文件覆盖 20KB 级

补丁 0.5KB 级(约降 85%)

diff 展示

每轮累计全量

增量摘要(逐点位循环省 90% 重复)

历史消息

逐条截断丢弃

1.5KB 摘要(脉络不丢)

 

以上数字出自改造时的节省预估(按改造前后的典型消息体估算),整体单任务 token 消耗的预期是降到改造前的三分之一以下。

十一、几点经验

  1. 上下文治理的主战场不在摘要,在协议。读整文件、写整文件才是大头。摘要做得再好,也只是收拾协议层漏出来的残局。我们改造后最大的收益来自补丁编辑和符号读取,摘要属于第二层防线。
  2. 摘要机制要用事故来检验。第一版规则式摘要的缺陷,在闲聊场景完全暴露不出来,直到埋点流程的卡片字段被压丢、模型幻觉,才知道"截 120 字符"这个决策错在哪。测试用例里没有的失败模式,生产环境会替你补上。
  3. 兜底路径要有质量。LLM 摘要失败降级规则式,方向没错,但降级产物必须按工具类型分级提取,而不是一刀切截首行。事故证明,降级路径恰恰是最常被走到的路径。
  4. 阈值和护栏要区分语义。maxToolCalls 是防死循环的护栏,不是预算——正常流程应该在 10~15 次内收敛,靠调大上限解决不了模型探索策略失控。同理,压缩阈值定多少不重要,压缩策略才重要。
  5. 摘要产物要有生命周期。只在单次请求内有效的摘要是没有意义的。持久化(落库、跨请求恢复)才是摘要体系成立的前提,这也顺带解决了多阶段任务"跨轮缓存失效"的老问题。

 

关于Webfunny

Webfunny专注于前端监控系统,前端埋点系统的研发。 致力于帮助开发者快速定位问题,帮助企业用数据驱动业务,实现业务数据的快速增长。支持H5/Web/PC前端、微信小程序、支付宝小程序、UniApp和Taro等跨平台框架。实时监控前端网页、前端数据分析、错误统计分析监控和BUG预警,第一时间报警,快速修复BUG!支持私有化部署,Docker容器化部署,可支持千万级PV的日活量!

  点赞 1   收藏 1
  • metal
    共发布11篇文章 获得1个收藏
全部评论: 0 条