你说你折这玩意干什么-agent工作的核心,还真得好好考虑怎么折叠上下文

上下文工程:Agent 能持续工作的核心

终于进入大折叠时代,连iphone都开始捣鼓折叠屏了。这波热度不是巧了吗,agent中恰好有个概念,上下文工程,就是讲怎么折叠、编排上下文的。今天笔者基于claude架构,给大家讲讲agent的大脑供氧-上下文工程,以及claude的五层压缩手段。

前言

我的 Agent 刚搭好,迫不及待指使它干干活。它头十轮表现很好:读文件、找 bug、改代码,每一步都对。

跑到第三十轮,它开始不对劲了。它把一个十分钟前读过的文件又读了一遍,然后凭记忆改代码;它忘了我开头交代过"别动配置文件";它每一步看着都合理,连起来却不像在干同一件事。

工具没变,模型没变,循环也没变。变的是上下文------从最开始的两千个 token,经过几轮的agent loop,涨到了几万甚至几十万token。

我的第一反应是"模型不够强。诚然,随着模型能力的增强,大可以把上下文空间开放的很大。可是,多大才是个头啊?而且看市面上的LLM,几乎清一色都是 1M 的上下文,但是人家的agent就能充分利用这点上下文,我的agent怎么没跑多久就成了呆瓜呢?

事实证明,一个agent能否持续工作,不仅仅是看它的大脑LLM有多么强大,有多么大的上下文空间,而是看它在agent中怎样编排、怎样最大程度的在有限的空间,发挥最大的能力。

这件事有个名字,叫上下文工程(Context Engineering)。如果说,工具系统是 Agent 的手脚,而上下文工程这一层是它的大脑供氧------供不上,再好的手脚也动不了。

上下文里到底装了些什么

在谈怎么管之前,先得看清楚账。

在agent中,一次请求发出去,模型看到的基本是这么几样东西:

装了什么 能不能压 为什么
系统提示词 不能压 那是行为准则,压掉就等于换了个人
用户输入 不能压 用户的原始话术,改一个字都算篡改
工具定义 尽量少放 可以延迟加载,但不能凭空消失
工具调用结果 能压 大部分是一次性的,查完就没用了
历史对话 能压 几十轮之后,前面那些来回已经不重要了

结论很直接:真正有压缩空间的只有后两行。所以整套上下文管理,说到底是围绕"工具结果"和"历史对话"做文章。

Claude Code 为这件事做了个命令,叫 /context:把窗口按类别切成格子画出来,谁在吃上下文一眼看清楚。我在自己的 Agent 里照这个思路实现了一份------16×16 一共 256 个格子,每格代表窗口的 1/256,按系统提示、工具、记忆、技能、消息分别上色,空格子是还没用到的,深色格子是留给压缩的余量。

这件事值得做的理由很实在:不先量一下,你根本不知道是谁把窗口吃掉的。 很多人第一反应是去压历史对话,结果一量才发现,光工具定义就占了三成。

不等它爆:三层防线

讲压缩之前,先把一个思路摆正。

大多数人的做法是"等上下文快满了,再想办法压"。这个顺序是反的。真正省事的做法是一开始就少放点东西进去------摘要压缩是没办法的办法,不是常规手段。

Claude Code 里的摘要压缩要等窗口快满了才触发------公开分析给出的比例在 87% 到 95% 之间浮动,随版本变化,总之是"最后一步"。那前面那么长的路靠什么撑着?靠三道防线。

第一道:把用量盯住

要干预,先得知道现在用了多少。

精确的数字其实拿得到:每次 API 调用返回的 usage 里就带着这轮的实际输入 token 数,AI SDK 把它规范成了 usage.inputTokens

问题在于它是事后才有的。你得先发出去、等它返回,才知道这一轮用了多少------而"要不要干预"这个判断,恰恰得在发出去之前做。

所以用量跟踪得是两截拼起来的:上一次 API 返回的精确值打底,再加上之后新增的那些消息的估算值。估算按字符数折算 token,中文再乘一个安全系数------中文一个字的信息量比英文一个字符大得多,按 4 字符 1 token 算会低估不少。

有了这个数,才有后面两步的判断依据。

第二道:超大结果当场截断

工具返回的东西大小是完全不可控的。一个 cat 大文件、一次 pnpm install 的完整日志、一次全仓库 grep,随便一个就能把窗口撑掉一半。

这里我用的是双重约束,阈值都是动态的------按窗口比例算,而不是写死一个字符数:

  • 单条工具结果不超过窗口的 50%
  • 整个上下文不超过窗口的 75%

超过第一条,就把这条结果从中间切开:保留头部 60%、尾部 40%,中间替换成 [truncated: 128400 → 50000 chars]头尾都要留是有道理的:头部通常是结论和标题,尾部通常是报错和收尾,中间那些重复的正文才是真正可以丢的。

超过第二条,就从最老的 工具结果开始,把整条替换成 [compacted: read_file output removed to free context],直到降回预算以内。

第二个约束之所以必须存在,是因为只卡单条会漏。十条都不超标的结果加起来,照样能把窗口塞满。

第三道:按时间修剪

这一道最轻,也最容易被整个跳过。

老的工具结果几乎不会再用到了。第三轮读的那个文件内容,到第三十轮的时候,模型早就把它消化成了自己的结论,原文留着只是占地方。

所以按时间分两档修剪:

档位 触发条件 怎么处理
软修剪 超过 5 分钟 保留头尾各 1500 字符,中间换成 [soft pruned: ...]
硬清除 超过 10 分钟 整条替换成 [tool result expired: read_file]

这里有个例外:出错的工具结果不修剪。 假如模型得到了错误的工具结果,那么它可能会采用换个工具、调整参数等等策略,以后它也会记得这个工具有什么问题。如果把出错的工具结果砍掉,那么它就会重蹈覆辙,这显然不是我们希望的。

还有一条更硬的规矩:用户消息和助手回复永远不修剪。 可以清工具结果,可以砍历史记录,但你和模型之间说过的话必须完整------那是对话结构本身,砍了它,LLM 完全不明觉厉,渐渐的开始挂羊头卖狗肉了。

三道防线合起来的目标写出来就一句话:把触发 LLM 摘要压缩的时间尽量往后推。 摘要要花一次模型调用、会丢信息、还会打断对话结构,只能作为agent上下文界的潜龙,能不用就不用。

还压不下去,才轮到 LLM 摘要

三道防线都过完了还是超,终于到了山穷水尽之地。这时只好呼唤出最狠的、最不顾一切的: LLM 摘要

总体来看,压缩有两条路,代价完全不同:

紧凑化 Compaction 摘要化 Summarization
做法 把老的工具结果换成占位符 用一段文字替换整段对话
对话结构 保留 被打断
信息损失 小,只丢工具结果 大,全靠摘要写得好不好
能回收多少 中等
要不要调模型 不用

紧凑化:不花钱的那一档

紧凑化的实现短得可以一次说完:挑出所有工具结果,保留最近 3 条 ,其余全部换成 [tool result cleared]

保留最近 3 条,是因为模型手头正在处理的东西,几乎一定在最近几步里。把刚刚读到的文件立刻清掉,它会一脸茫然地再读一遍------省下的 token 又花回去了。

也不是所有工具结果都能清。这里我用了一份白名单,只清 read_filebashgrepgloblist_directory 这些"查了还能再查"的。web_search 这类搜一次要花钱的、write_file 这类结果是"我确实写成功了"这个事实的,清掉之后再确认的成本比留着高。

摘要化:Claude 的 auto-compact 是怎么做的

Claude Code 这一档的做法拆开看有五步,每一步都在解决一个具体问题:

  1. 剥离图片 ,原地只留一个 [image] 标记------图片的 token 开销极大,而摘要里它本来也表达不出来,只留下一个痕迹,证明它来过。
  2. fork 一个子 Agent 去生成摘要,不是在主循环里顺手做掉------压缩本身也要占上下文,塞在主循环里会和被压缩的内容抢地方,于是分割出上下文,派遣一个小弟去干。至于怎么fork子agent,且听下回分解。
  3. 按九段结构生成摘要:用户意图、技术概念、文件改动、错误修复、问题解决、用户消息、待办任务、当前工作、下一步,这些本质是给做摘要的LLM看的,按这些结构生成。
  4. 恢复关键文件:把最近读过的文件恢复 5 个、并按修改时间优先;单个文件超过 5000 token 的只放一个路径引用,不带上正文
  5. 替换消息,新的上下文由四块拼成:压缩边界标记、摘要、恢复的关键文件、最近的对话

第三步的九段结构是整件事的关键。它逼着模型分类------只说"总结一下",这就是质量太低的prompt。它会写一段"用户希望我优化代码,我已经做了一些工作"这样的废话,这种摘要比没有更糟,因为它占着位置还传递不了信息。按字段填,每一栏都得落到具体的东西上。

第四步容易被忽略,但它决定压缩之后 Agent 还能不能接着干活。刚"读"过的文件全被折进摘要了,模型接着要改的时候只能再读一遍------那这次压缩就白压了。把最近的文件原文留回来,是在保现场。

我这边是怎么实现的

我的实现把这两档分开成两个函数。

紧凑化那个叫 microcompact,就是上面说的白名单加保留最近 3 条。摘要那个用一份五段结构的提示词:用户意图、已完成的操作、关键发现、当前状态、需要保留的细节,总长度压到 800 字以内,并在提示词里明确要求"文件路径、UUID、版本号必须原样保留,不要翻译或改写"。

有几个地方是刻意设计的:

摘要也要对齐轮次边界。 切分点会往前退到最近的用户消息,不能把一轮对话从中间切开------切一半的对话,比多留一条消息更糟。

摘要可以叠。 第二次压缩的时候,不是把上一次的摘要丢掉重新来,而是把"已有摘要 + 新对话"一起喂进去,生成一份新的。这样跑一百轮,摘要也不会退化成"用户想让 Agent 干点活"。

摘要失败要能退回去。 调模型这一步包在 try 里,失败就原样返回,不做任何替换。宁可这次不压,也不能因为一次超时把上下文搞坏。

有些东西一开始就不该放进去

压缩和卸载,是同一件事的两面。压缩是把已经进去的东西弄短,卸载是根本不让它进去

这条原则有一句很直白的表述:上下文窗口是昂贵的稀缺资源,而磁盘是廉价的。 一个 5000 行的日志塞进上下文就是几万个 token,写进磁盘只占几 KB。

虽然内存在价格层面也不便宜。。

所以当某个工具快要返回一大坨结果时,正确做法不是"想办法塞进去",而是落盘,原地只留一句索引

bash 复制代码
[已卸载到 .cache/search-20260915.log 的 1284 行中,前 20 行匹配 "timeout"]

模型看到这行字,知道有这么个东西、在哪、大概是什么。真要看细节,它自己调 read_file 把文件取回来。

卸载的关键在于留一个能找回来的索引。只写"结果太大已省略",模型就彻底失去了这条线索;写上路径和摘要,它就多了一个"我可以去看"的选项。

这套思路在我这边有两处落地。一处是记忆系统:.memory/ 目录下是一堆 markdown 文件,每个文件一条事实,外加一个 MEMORY.md 做索引------常驻上下文的只有那份索引,正文按需读。另一处是会话存档,用 JSONL 逐行追加,跟 Claude Code 用的是同一套格式。

检索:按成本递增排工具

卸载之外的另一半,是"需要的时候再去取",也就是 JIT(Just-in-time,按需加载)------不提前塞,等真正需要了再取。

反面是全量预填充:开工前把可能用到的资料一股脑塞进去。问题是"可能用到"和"用得到"之间的距离非常大,你塞进去的十份资料里可能只有一份被看过,另外九份每一轮都在花钱、都在稀释注意力。

JIT 有一个很实用的落地技巧:按成本递增来排列工具调用。同样是找一段代码,有三条路:

工具 做什么 成本
Glob 按文件名搜,只返回路径 最便宜
Grep 按内容搜,返回命中的行 中等
Read 把整个文件读进来 最贵

先用 Glob 把范围缩到几个文件,再用 Grep 定位到那几行,最后才 Read 那一个文件。反过来做------上来就读十个文件------上下文立刻就被撑满了。

需要的外部知识就交给 RAG:分块、向量化、混合检索、把命中的片段注入上下文。检索用的是向量加关键词两路并跑,按七三开加权------纯向量检索对专有名词和错误码很不敏感,纯关键词又抓不住语义相近的表述,两路合起来才稳。

装不下,就换一个窗口

有些任务就是塞不进一个窗口:翻遍整个仓库找一处引用,或者跑二十次实验对比结果。硬塞进去就要不停压缩,而压缩会破坏对话结构。

解法是开第二个窗口。子 Agent 有自己独立的 messages 数组,从零开始,父 Agent 的上下文完全不受污染------它探索了三十轮、读坏了十个文件,父 Agent 那边只知道一句话。

两种沟通方式,代价完全不同:

消息传递 共享上下文
子 Agent 拿到什么 一条任务描述 父 Agent 当前全部上下文
父 Agent 多出什么 任务 + 结果 只有结果
子 Agent 的 token 开销 大,要复制父上下文
适用场景 独立的探索任务 需要前情提要的续作

我用的是消息传递那一套。子 Agent 拿到的只有一句任务描述,跑完之后,只把它最后一条 assistant 消息带回父 Agent。

只取最后一条是刻意的。子 Agent 中间那三十步的探索过程------读了哪些文件、试了哪些错路------全部留在它自己的窗口里,随它一起消失。父 Agent 要的是结论,不是过程。

配套的是四道闸门,缺一个都可能出事:

  • 嵌套深度默认 1,子 Agent 不能再派子 Agent,防递归爆炸
  • 并发上限默认 3,防一次派出十个把 token 烧穿
  • 单步上限 30 步,防止子 Agent 在里面绕不出来
  • 超时 60 秒,超了就 abort;但 abort 之前会把已有的最后一条 assistant 消息捞出来返回,标成 [部分结果]------跑了一半的结论,也比一句"失败"有用

隔离的代价也很直接:子 Agent 不知道父 Agent 知道的事。任务描述必须写清楚,写不清楚,它就得把同样的路重走一遍。

缓存:唯一一个每一轮都在省钱的

前面所有动作,本质上都在减少"每一轮要发多少"。缓存不一样,它省的是每一轮都要重复发的那部分

这两件事的收益量级完全不同。假设你把上下文从 100k 压到了 20k,这个收益是每一步都有的;但如果你在压完之后,还把不变的前缀缓存住了,那是在已经压过的 20k 上,再按十分之一的价格付一次。乘起来才有量级差别。

先分清三个容易混的概念:

名字 在哪一层 谁控制
KV Cache 模型推理引擎内部 谁都控制不了
Prompt Cache API 层,按前缀命中 开发者
Context Collapse 上下文里,把老消息折叠成标记 开发者

Prompt Cache 的原理一句话:你发过去的请求有一个前缀,只要前缀和上次一模一样,服务端就不用重新算,直接复用,命中的部分按大概十分之一的价格计费。

各家的实现分三种模式:隐式缓存 是服务端自动做的,你只能从 usage 里看到返回的 cached_tokens显式标记 是你在请求里插一个 cache_control: { type: "ephemeral" } 把断点标出来;显式缓存创建是先调一次 API 拿到缓存对象的 ID,之后带着这个 ID 请求。

于是就有了最常见的一个坑:把每轮都在变的东西放在了最前面。

系统提示里带一个当前时间戳、工具列表每次按不同顺序拼、会话 ID 放在开头------这些看起来无害的小事,会让整个前缀每次都不同,缓存一次都命中不了。

我把系统提示词拆成了一条管道(Prompt Pipe),每个管道函数负责一段,顺序是刻意排的:

less 复制代码
const builder = new PromptBuilder()
  .pipe('coreRules', coreRules())          // 行为准则,几乎不变
  .pipe('toolGuide', toolGuide())          // 工具数量,很少变
  .pipe('deferredTools', deferredTools())  // 延迟工具摘要,搜到工具才变
  .pipe('memoryContext', memoryContext())  // 记忆索引,写入记忆才变
  .pipe('ragContext', ragContext())        // 知识库概况,导入文档才变
  .pipe('sessionContext', sessionContext())// 会话信息,这个一定会变 ------ 放最后

原则只有一条:越稳定的越靠前,越易变的越靠后。因为前缀缓存是从头开始匹配的,第一个字节变了,后面全都白搭。

省了多少钱得看得见才算数。所以每轮的 usage 会被拆成四类分别记账------普通输入、缓存写入、缓存读取、输出------再按各自的单价算成本,同时算出缓存命中率和"如果不走缓存要花多少"。终端里敲一下 /usage,命中率、实际花费、省下的钱一次看全。

这五个动作有先后,顺序不能换

卸载、压缩、检索、隔离、缓存,这五个动作不是一张并列的清单,它们之间有明确的先后:

flowchart LR A[Offload 卸载] --> B[Cache 缓存] --> C[Reduce 压缩] --> D[Isolate 隔离] --> E[Retrieve 检索]

排序的依据是"省多少"和"损多少"。

卸载排在最前面,因为它几乎不损信息,只是把东西挪个位置,收益还很大。

紧接着是缓存。它比卸载还干净------丝毫不损信息,而且收益跟着轮数走,跑五十轮就省五十次。

到压缩这里开始有损耗了,所以被排在前两个之后。压缩内部也是同一个顺序:先紧凑化,再摘要化。

隔离能解决根本装不下的问题,代价是要重建一份上下文,还多一层协调。

检索放最后,因为它每一次都要花一轮工具调用去换,只在前面几步都做完了还不够的时候才动它。

完整代码

下面这个 demo 把一段 145 轮的对话依次过三道防线,每一步记一次账。它只做一件事:让你看见每一道防线各自能捞回多少。

javascript 复制代码
// 上下文的三层防线:一段 145 轮的对话是怎么被压回窗口里的
// 运行:node context-defense.js
//
// 三层的名字和触发思路来自 Claude Code 的 microcompact / snip / auto-compact。
// 这里的"摘要"是写死的假摘要,真实实现要拿这些消息去调一次 LLM。

const WINDOW = 100_000
const TRIGGER = WINDOW - 13_000   // 触发阈值 = 窗口 - 13k,留出的余量给"压缩本身"
const SNIP_TARGET = 20_000        // 清完老工具结果还超这个数,才轮到砍老消息
const COMPACT_TARGET = 8_000      // 砍完还超这个数,才轮到上摘要

const ROUNDS = 145

// 中英混排的粗略估算:中文 1 字 1 token,英文 4 字符 1 token
const tok = s => {
  const cjk = (s.match(/[一-龥]/g) || []).length
  return cjk + Math.ceil((s.length - cjk) / 4)
}
const size = msgs => msgs.reduce((a, m) => a + tok(m.text), 0)

// 中文一个字顶两个英文字符宽,表格对齐得自己算
const width = s => [...s].reduce((a, c) => a + (c.charCodeAt(0) > 255 ? 2 : 1), 0)
const pad = (s, n) => s + ' '.repeat(Math.max(0, n - width(s)))

// ── 造一段长对话:每轮 用户提问 → 工具调用 → 工具结果 → 助手分析 ──
const FILE_LINE = 'export function handler() { return 1 }\n'
const ANALYSIS = '这个模块的结构和前一个基本一致:导出一个 handler,没有参数校验,也没有错误处理。'
  + '参数直接从调用方传进来,中间没有任何形状检查,上游一旦改了签名,这里不会报错,只会在运行时炸。'
  + '如果要在这里加约束,得先确认调用方传进来的参数形状,否则改动会波及到上游。'
  + '先记一笔,等全部读完再统一处理,免得改到一半停下来。'
  + '这类模块一共有几十个,逐个改工作量不小,但改动本身是机械的,风险主要在漏改。'
  + '更稳妥的做法是先写个脚本把符合条件的模块列出来,再决定动哪几个。'

function makeHistory(rounds) {
  const msgs = []
  for (let i = 0; i < rounds; i++) {
    msgs.push({ role: 'user', text: `第 ${i + 1} 轮:看看 module-${i}.ts` })
    msgs.push({ role: 'assistant', text: `[tool_call] read_file({ path: "module-${i}.ts" })` })
    msgs.push({ role: 'tool', text: `// module-${i}.ts\n` + FILE_LINE.repeat(43) })
    msgs.push({ role: 'assistant', text: ANALYSIS })
  }
  return msgs
}

// ── 第 1 层 microcompact:老工具结果换成占位符,保留最近 3 条 ──
const CLEARABLE = new Set(['read_file', 'bash', 'grep', 'glob', 'list_directory'])
const KEEP_RECENT_TOOL_RESULT = 3

function microcompact(msgs) {
  const toolIdx = msgs.map((m, i) => (m.role === 'tool' ? i : -1)).filter(i => i >= 0)
  const clear = new Set(toolIdx.slice(0, Math.max(0, toolIdx.length - KEEP_RECENT_TOOL_RESULT)))

  let cleared = 0
  const messages = msgs.map((m, i) => {
    if (!clear.has(i)) return m
    cleared++
    return { ...m, text: '[tool result cleared]' }
  })
  return { messages, cleared }
}

// ── 第 2 层 snip:从头部整轮砍掉最老的消息,砍掉就是没了 ──────
function snip(msgs, target) {
  let i = 0
  while (i < msgs.length && size(msgs.slice(i)) > target) i++
  while (i < msgs.length && msgs[i].role !== 'user') i++   // 对齐到一轮的开头,别切一半
  return { messages: msgs.slice(i), dropped: i }
}

// ── 第 3 层 auto-compact:折成一段九段结构的摘要 ─────────────
const SUMMARY = `## 用户意图
通读 src/ 下的 module-*.ts,确认结构是否一致
## 技术概念
ESM 模块、handler 导出约定
## 文件改动
暂无
## 错误修复
暂无
## 问题解决
已确认一百四十余个模块结构一致,都缺参数校验
## 用户消息
"看看 module-N.ts",重复若干轮
## 待办任务
统一补参数校验
## 当前工作
刚读完 module-144.ts
## 下一步
给出统一改造方案`
const KEEP_RECENT_MESSAGES = 6
const RESTORE_FILES = 3   // 压缩后回贴最近读过的几个文件

function autoCompact(msgs) {
  const kept = msgs.slice(-KEEP_RECENT_MESSAGES)
  // 第 1 层留着的那几条工具结果,这时候派上用场:把文件内容原样贴回来,
  // 不然模型刚"读完"的文件全没了,还得再读一遍。
  const restored = msgs.filter(m => m.role === 'tool').slice(-RESTORE_FILES)
    .map(m => ({ role: 'user', text: `[压缩后恢复的文件]\n${m.text.slice(0, 600)}` }))

  return {
    messages: [
      { role: 'user', text: `[以下是之前对话的压缩摘要]\n\n${SUMMARY}\n\n[摘要结束,以下是最近的对话]` },
      ...restored,
      ...kept,
    ],
    folded: msgs.length - kept.length,
    restored: restored.length,
  }
}

// ── 依次过三道防线,每过一道记一次账 ─────────────────────
function main() {
  console.log(`\n=== ${ROUNDS} 轮对话,窗口 ${WINDOW / 1000}k,触发阈值 ${TRIGGER / 1000}k ===\n`)
  console.log(pad('阶段', 32) + pad('上下文', 12) + pad('消息数', 10) + pad('节省', 8) + '做了什么')

  let msgs = makeHistory(ROUNDS)
  const base = size(msgs)

  const row = (name, cur, note) => {
    const t = size(cur)
    const save = base === t ? '-' : `${Math.round((1 - t / base) * 100)}%`
    console.log(pad(name, 32) + pad(`${(t / 1000).toFixed(1)}k`, 12) +
      pad(String(cur.length), 10) + pad(save, 8) + note)
  }

  row('原始', msgs, `超过阈值 ${((base - TRIGGER) / 1000).toFixed(1)}k`)

  // 第 1 层:先试最轻的,不删消息、不动结构
  const m = microcompact(msgs)
  msgs = m.messages
  row('1. microcompact 清老工具结果', msgs, `清了 ${m.cleared} 条工具结果`)

  // 第 2 层:还超才砍老消息,砍掉的永久丢失
  if (size(msgs) > SNIP_TARGET) {
    const s = snip(msgs, SNIP_TARGET)
    msgs = s.messages
    row('2. snip 砍掉最老的消息', msgs, `砍了 ${s.dropped} 条`)
  } else {
    row('2. snip 砍掉最老的消息', msgs, '没触发,第一层就够了')
  }

  // 第 3 层:最后一招,花一次 LLM 调用换最大回收
  if (size(msgs) > COMPACT_TARGET) {
    const c = autoCompact(msgs)
    msgs = c.messages
    row('3. auto-compact 折成摘要', msgs, `折了 ${c.folded} 条,回贴 ${c.restored} 个文件`)
  } else {
    row('3. auto-compact 折成摘要', msgs, '没触发')
  }

  console.log('\n(摘要那一段是写死的。真实实现要把被折掉的消息拼起来,调一次 LLM 生成)')
}

main()

跑出来是这样:

markdown 复制代码
=== 145 轮对话,窗口 100k,触发阈值 87k ===

阶段                            上下文      消息数    节省    做了什么
原始                            93.9k       580       -       超过阈值 6.9k
1. microcompact 清老工具结果    34.6k       580       63%     清了 142 条工具结果
2. snip 砍掉最老的消息          19.9k       324       79%     砍了 256 条
3. auto-compact 折成摘要        1.9k        10        98%     折了 318 条,回贴 3 个文件

(摘要那一段是写死的。真实实现要把被折掉的消息拼起来,调一次 LLM 生成)

说一句来源。microcompact、snip、auto-compact 这三个名字和它们的分层思路,来自对 Claude Code 打包产物的逆向分析,官方文档里没有正式记载;自动压缩的触发比例同样如此,公开分析给出的数在 87% 到 95% 之间。机制是清楚的、也是跑得通的,但具体阈值别当常数背下来。

几行数字值得停下来看。

第一层就能砍掉六成,而且一条消息都没删。 它动的是内容不是结构------对话的骨架原封不动,模型看到的还是完整的你来我往,只是那些已经用完的工具结果变成了占位符。这就是把它排在第一位的理由:最轻、不丢结构、收益还最大。

第二层砍掉的是消息本身,砍了就真没了。 所以它的阈值放得比第一层宽------只有第一层做完还超标,才轮到它动手。这也是为什么它叫 snip 而不是 clear:一个是在内容上做减法,一个是在历史长度上做减法。

第三层回收得最狠,代价也最大。 98% 的节省背后是 318 条消息被折进了一段摘要,对话结构在那一刻断掉了。它前面还有一步容易被忽略的动作:回贴 3 个文件。不做这一步,模型刚读完的文件全没了,下一轮得重读一遍,省下的 token 又还回去了。

总结

做了什么

  1. 把上下文拆开清点了一遍:系统提示和用户输入不能压,真正有压缩空间的只有工具调用结果和历史对话。
  2. 搭了三层防线:先把用量盯住,再有超大结果当场截断(单条 50%、总量 75% 双重约束),最后按时间分软硬两档修剪。三层合起来的目标是把 LLM 摘要压缩尽量往后推。
  3. 分清了两条压缩路线:紧凑化换占位符、不破坏结构、不花钱;摘要化花一次模型调用换最大回收,代价是对话结构被打断。顺序是先轻后重。
  4. 拆开了 Claude 的 auto-compact:剥图片、fork 子 Agent 生成九段结构摘要、回贴最近文件、重组上下文------其中"回贴关键文件"决定了压缩之后还能不能接着干活。
  5. 写了一个能跑的 demo,把 145 轮对话过三道防线的过程逐步打出来,每一层各自捞回多少一目了然。

核心价值

  • 上下文工程是 Agent 开发里唯一能靠自己拉开差距的地方。 模型大概率是同一个,工具系统也大同小异,最后落在"往模型嘴里喂什么"上。Agent 能不能撑到第 50 轮,取决于这一层。
  • 能不压就不压,能卸载就别让它进来。 摘要压缩是最后手段不是常规手段------它要花钱、会丢信息、还会打断对话结构。卸载和缓存一分钱信息都不损,所以排在压缩前面。
  • 压缩之后必须留下能继续干活的现场。 回贴最近读过的文件、对齐轮次边界、保留最近几条工具结果------这些"少压一点"的动作看着像浪费,实际上是在防止模型把刚做过的事重做一遍。省下的 token 又花回去,等于没压。

上下文工程不是往里塞更多,而是决定什么该留在外面、什么该折叠、什么该换个窗口去处理------当然,还有怎么节省流水一般流走的token。

相关推荐
ynchyong1 小时前
VUE 中 不能将类型“R[]”分配给类型“UnwrapRefSimple<R>[]”
typescript·vue·ts·unwraprefsimple
千桐科技2 小时前
qKnow 开源版 v2.4.3 更新解析:从知识数据接入到自定义解析与结果复用
开源·llm·agent·ai智能体·qknow·智能体构建
Ticnix2 小时前
MCP 工具拿不到 user_id?用 contextvars 做请求级用户隔离
python·agent·mcp
流浪9253 小时前
从 Vibe Coding 到 LangGraph:AI 时代编程范式的演进与重构
llm
掰头战士3 小时前
让散乱的工具调用成为正规军,今天咱们聊聊 Tool System管线的读写锁
typescript·llm·agent
zLLM_Lab3 小时前
DeepSeek V4.1 Flash 工程实践(二):从权重装配到八卡文本链路
llm·deepseek
小坏讲微服务4 小时前
Spring AI 高频面试题20道
java·spring·ai·agent·springai
一直在努力的小宁4 小时前
[特殊字符] 具身智能Agent开发调研|零基础超详细笔记(万字长文)
agent·vla·vlm·vln·harness
桃西西呀5 小时前
提前一天预报雾霾:手搓神经网络,讲清学习率、初始化、正则化
人工智能·深度学习·llm