上下文压缩:token 怎么数、压什么留什么、压完怎么验证没丢
上下文总会满:先用真实用量校准一个本地 token 估算器,再实现分段摘要式压缩,明确哪几类消息必须原样保留,最后用一组回答早期事实的探针验证压缩没有丢掉关键信息。
今日目标
- 能实现一个可校准的 token 估算器,并说清估算与精确计数各自的适用场景
- 能设计压缩策略并说明哪几类消息必须原样保留、为什么
- 能用可验证的方式检查压缩是否丢失关键信息
前四天一直在往上下文里加东西:引用、指令、清单、记忆。今天是第一次往外拿。读完回到页面顶部把三条目标勾掉。
小白版讲解
桌面只放今天要用的那几份文件
那位新人的工位现在很热闹:你贴给他的资料、团队规章、待办清单、交接笔记,还有这两小时里翻过的十几个文件,全摊在桌上。桌子是有限的,再来一份就得先挪走一份。
问题在于挪走哪一份。最省事的做法是抽走最下面那几张——越旧越靠下,听起来很合理。但最旧的那张可能正是你早上交代他的那句话:这个仓库的构建命令不是 pnpm build。抽走它,他下午会用错的命令跑一遍,再回来问你为什么跑不通。
正确的做法是归档:那一叠翻过的文件收起来,但先在便签上记一行「这些文件里确认过的事实是……」。桌子空了,结论还在。
这就是上下文压缩(context compaction):把一段变长的历史换成一份短的摘要,而不是扔掉它。 第六天那条用量上限一超就停下来提示你「压缩一次」——今天做的是它的另一半:不只是停,还能腾。
边界先划清楚:这一天只讲实现。 什么该进上下文、什么不该是策略问题,本平台的《5 天上下文工程》整门课都在讲它。今天只回答三个工程问题:怎么数、压什么留什么、压完怎么证明没丢。
第三个最容易被跳过,而它是唯一能让你放心把压缩开在生产环境里的那一步:前两个做错了,你看到的是「答得不太对」;第三个不做,你连「答得不对是因为压缩」都不知道。
先会数再会省:字符估算、分词器、真实用量
要省,先得会数。「数 token」有三种做法,各有各的用处,混着用就会出错。
一、精确计数:跑模型自己的分词器。 唯一真的准。但这门课连的是任意一个 OpenAI 兼容网关,后面可能是任何一家的任何一个模型——分词表根本不唯一。装一个分词器包只给你一种精确的错觉:算得很准,算的是别家的分词。
二、真实用量:读网关回传的 usage。 末尾那条 usage 里的 prompt_tokens 就是这次真实花掉的量。它准,但它是事后的:判断「这次会不会超」必须在发出去之前就有个数。
三、本地估算。 形状很简单,两项相加:
tokens ≈ a × 中日韩字符数 + b × 其余字符数第八天那行报账用的就是它,a 取 1、b 取四分之一——一个汉字大致一个 token,四个英文字符大致一个。够打一行「约 86 token」,但也就到这儿了。
三者的分工一句话:估算做决策(请求之前,要不要先压一压),真实用量对账(请求之后,估得准不准),精确计数结算(你真按 token 出账单的时候,那是第二十天)。
用回传的用量校准估算器
既然每次请求都回传真实用量,系数就不该是拍出来的,而该是回归出来的。
做法是顺路收样本:发请求之前把消息数组拼成一段文本记下来,usage 回来时把「中日韩字符数、其余字符数」和「真实 token 数」凑成一条样本。攒够三条就解一次最小二乘——两个未知数、两条正规方程。
顺路收,不额外发请求去测。 专门发几段去测既花钱,测的还是另一种文本;你要估准的是自己这个程序发出去的那种。
fit(minSamples = 3): FitResult {
const before = meanAbsPercentError(this.samples, active)
if (this.samples.length < minSamples) return notEnough(before, this.samples.length)
// 两条正规方程的六个和:Σcjk²、Σcjk·rest、Σrest²、Σcjk·t、Σrest·t
let sxx = 0, sxy = 0, syy = 0, sxt = 0, syt = 0
for (const s of this.samples) {
sxx += s.cjk * s.cjk
sxy += s.cjk * s.rest
syy += s.rest * s.rest
sxt += s.cjk * s.tokens
syt += s.rest * s.tokens
}
const det = sxx * syy - sxy * sxy
// 相对判据,不是绝对判据:样本一多,sxx·syy 本身就是个巨大的数
if (Math.abs(det) < 1e-6 * Math.max(1, sxx * syy)) return flatFallback(this.samples, before)
const coeff = {
cjk: clamp((sxt * syy - syt * sxy) / det),
rest: clamp((syt * sxx - sxt * sxy) / det),
}
return { coeff, samples: this.samples.length, errorBefore: before,
errorAfter: meanAbsPercentError(this.samples, coeff), degenerate: false, note: '已校准' }
}def fit(self, min_samples: int = 3) -> FitResult:
before = mean_abs_percent_error(self.samples, ACTIVE)
if len(self.samples) < min_samples:
return not_enough(before, len(self.samples))
# 正规方程的系数矩阵与右端项,一次遍历攒完
sxx = sum(s.cjk * s.cjk for s in self.samples)
sxy = sum(s.cjk * s.rest for s in self.samples)
syy = sum(s.rest * s.rest for s in self.samples)
sxt = sum(s.cjk * s.tokens for s in self.samples)
syt = sum(s.rest * s.tokens for s in self.samples)
det = sxx * syy - sxy * sxy
if abs(det) < 1e-6 * max(1, sxx * syy): # 中英比例太单一,两个系数分不开
return flat_fallback(self.samples, before)
coeff = Coefficients(
cjk=clamp((sxt * syy - syt * sxy) / det),
rest=clamp((syt * sxx - sxt * sxy) / det),
)
return FitResult(coeff, len(self.samples), before,
mean_abs_percent_error(self.samples, coeff), False, "已校准")唯一的技术含量是那个 det 判断:行列式接近 0 意味着样本里中英比例几乎不变(最常见的是全为英文),这时两个系数在数学上分不开。硬解会得到一对荒唐的系数(常见的是一正一负),比不校准更糟,因为它看起来像是校准过的。所以这一支必须承认解不出来,退回一个统一的每字符系数。
自检拿五段不同中英比例的文本校准,打出这两行:
✔ 校准 5 条样本:系数 中日韩 0.50 / 其余 0.51,误差 55.6% → 2.6%
✔ 全是纯 ASCII 的样本被判为解不开:样本的中英比例太单一,两个系数分不开,退回一个统一的每字符系数两行都离线可复现,但离线的「真实用量」来自剧本(按字符数折算),所以误差掉得很好看;接真网关时代码一行不用改,回归出来的是另一对系数,误差也不会这么低。
最后一条纪律:校准结果是进程级的一份表,全部报账共用,否则同一段文本会在两处报出两个数。
什么时候触发:按比例,不按轮数
最容易想到的判据是轮数:聊满二十轮压一次。它是错的——轮数和占用量之间没有稳定关系。一次 grep 命中两千行,一轮就能吃掉半个窗口;十轮「改一行、跑一次测试」可能还不到一成。按轮数触发只有两种表现:短对话压得太早,长结果压得太晚(已经超限才想起来压)。
正确的判据是比例:估算占用超过总预算的七成就压。
这就得先有「总预算」这张表。前几天每一路各自报了账,今天是汇总的地方,也是全课唯一讨论总预算怎么分的地方。思路一句话:常驻的那几路给死额度,剩下的留给会长大的那几路。
| 这一路 | 性质 | 额度 | 谁负责 |
|---|---|---|---|
| 系统指令 | 常驻,长度不变 | 3% | 第一天 |
| 项目指令文件 | 常驻,长度不变 | 10% | 第九天 |
| 记忆 | 常驻,缓慢变长 | 5% | 第十一天 |
| 引用注入 | 一次性,可控 | 20% | 第八天 |
| 压缩摘要 | 越滚越多,要限量 | 7% | 今天 |
| 对话 | 会长大 | 40% | 每一轮 |
| 工具结果 | 长得最快 | 15% | 每次工具调用 |
给死额度的用处不是省钱,是让越界的时候能指出谁越界了:只有一个总数时你只知道「满了」,有了各路额度才知道该压哪一路。本实验的 /budget 就打这张表,空的那两路也照样列着占额度。
触发线留在七成而不是九成,理由很实际:压缩自己也要占地方——摘要请求要把那段要压掉的对话原样再发一遍,摘要写出来也占位置。九成才压,很可能压缩这一次请求自己就超了。
自检把这条钉成一项断言:同样十四条消息、同样的轮数,只有工具结果长短不同——
✔ 同样 14 条消息、同样的轮数:长的 1368 token 触发,短的 176 token 不触发压什么:连续的工具往返
可压的东西不是均质的:同一段历史里,一千个 token 的工具结果和一千个 token 的用户需求,压缩价值差一个量级。
最值得压的是连续的工具往返,三条理由一条比一条实在:
一、它占得最多。 带行号读回几百行、一次测试输出一千多字符——工具结果几乎总是最大的那一块。
二、它的信息已经被消化过了。 模型读完文件说了句「divide 没有除零保护」,结论就在紧随其后的助手消息里;留着原文只是为同一个结论重复付钱。
三、它可以重新取。 文件还在磁盘上,命令还能再跑一次。压掉它最坏是需要时再读一遍;而压掉一段用户需求,那句话再也回不来——他不会重说一遍,他以为你记着。
所以优先级是按「能不能重新取」排的,不是按新旧排的。 这一条能直接迁走:能重新取的(文件、检索结果、网页正文)先压,取不回来的(用户的话、一次性响应、随机 id)最后压。
留什么:一张明确的保留清单
留什么必须写成白名单:先说清哪几类原样留下,剩下的才是可压区。反过来列举「哪些可以压」,迟早会漏掉一类新出现的消息,而后果是静默丢信息——比第八天那条红线更难发现:注入丢了有一行报账,压缩丢了什么都没有。
保留清单四条:
- 系统指令。 它是这个程序的人设与规则,压掉等于换了个程序。
- 待办清单。 那是「我做到哪一步了」的唯一记录,压成摘要之后模型会重做已经做完的事。清单是第十天的机制,但保留清单必须先给它留好位置:本实验的判据写成前缀匹配,那份清单只要以那几个字开头就自动受保护。
- 未完成的工具调用。 工具结果条数少于调用条数,说明上一轮被打断了。摘要里写一句「调用了 run_command」替代不了那条缺失的结果——模型需要的是补上它。
- 最近几组。 模型正在做的那件事全在这几组里。摘要是结论,它此刻需要的是原文。压掉它们最典型的现象是它开始重复刚做过的事。
还有一条比清单更硬:带工具调用的助手消息与它的全部工具结果不可分割——和第七天的分叉规则同源,拆开会得到「请求了工具却没有结果」的消息数组,下一轮请求直接不合法。所以第一步不是挑消息,是分组:
export function groupMessages(messages: Message[], options = DEFAULT_COMPACT_OPTIONS): Group[] {
const groups: Group[] = []
for (let i = 0; i < messages.length; i += 1) {
const message = messages[i] as Message
if (message.role === 'assistant' && message.toolCalls?.length) {
// 把后面连续的 tool 消息一起吞进这一组
let end = i + 1
while (end < messages.length && messages[end]?.role === 'tool') end += 1
const results = end - (i + 1)
// 结果比调用少,说明上一轮被打断了:这一组原样保留
groups.push({ start: i, end, keep: results < message.toolCalls.length ? 'unfinished' : 'compressible' })
i = end - 1
continue
}
groups.push({ start: i, end: i + 1,
keep: message.role === 'system' ? 'system' : options.pinned(message) ? 'pinned' : 'compressible' })
}
return groups
}def group_messages(messages: list[Message], options: CompactOptions = DEFAULT) -> list[Group]:
groups: list[Group] = []
i = 0
while i < len(messages):
message = messages[i]
if message.role == "assistant" and message.tool_calls:
end = i + 1
while end < len(messages) and messages[end].role == "tool":
end += 1
results = end - (i + 1)
keep = "unfinished" if results < len(message.tool_calls) else "compressible"
groups.append(Group(i, end, keep))
i = end
continue
if message.role == "system":
keep = "system"
elif options.pinned(message):
keep = "pinned"
else:
keep = "compressible"
groups.append(Group(i, i + 1, keep))
i += 1
return groups分完组、标出保留项,剩下的连续几组就是可压区;可压区再按用户消息切段——一个用户消息开启一件新的事,按事分段,摘要才不会把两件事揉成一句。
怎么压:让模型写结构化摘要
到这一步才轮到模型上场,而它上场之前要被绑住手脚。
自由发挥的摘要读起来很顺,也很致命:它会把「已经把 divide 改成抛错了」写成「讨论了如何修复除零问题」——动作变成了话题。下一轮模型不知道自己动过手,于是再改一遍;而这次 old_string 已经匹配不上,它开始怀疑文件被别人改了。
所以提示里写死四个小标题,模型只填内容:
已确认的事实:
已做过的改动:
未完成的事情:
关键路径与命令:四行的分工:事实是可以直接引用的结论,改动是不能重做的动作,未完成是下一步的入口,路径与命令是重新取原文的钥匙——有了最后这一行,被压掉的工具结果才算真的可以重新取。提示里还要写「内容必须来自原文,宁可某节写无也不要编」:摘要一旦推测,后面的对话就建立在一句没人说过的话上。
有了固定格式,就有了可校验,而这是今天最重要的一条保底:
摘要写好放回原来的位置,一段换成一条消息。第八天埋的伏笔在这里兑现:注入的资料当初是单独一条消息、没拼进用户那句话,所以压缩时可以只丢注入、保留原话。
最后别忘了在系统提示里加一句「对话历史可能被压缩过,以这句话开头的消息是摘要」。少了它,模型会把摘要当成用户刚说的话,或者反复问「你刚才说的那个文件是哪个」。
怎么验证:拿早期事实当探针
压完了怎么知道没丢东西?「看起来还行」不是答案——压缩的失败是静默的:模型不会说「我丢了一条事实」,它会很自信地拿一条错的、或者凭空补的事实继续干活,而你在几轮之后才从一个奇怪的结果里发现。
可验证的做法是探针:压缩之前记下几条只有早期上下文里才有的事实,压缩之后回头查一遍。
关键是判据。判据是「这条事实还在不在上下文里」,不是「模型答得对不对」。 后者不可靠:答对可能是猜对的,答错也可能是这一轮的运气。而上下文里有没有那句话是确定、可复现的,也是压缩这一步唯一能负责的事。
自检把这一项做成了对照实验,因为对照才说明问题:
✔ 压缩:14 条 → 7 条,估算 1368 → 571 token,2 段摘要都带齐四个小标题
✔ 两条探针在压缩后的上下文里都还找得到:pnpm build:calc、divide by zero
✔ 换成「只留最近三组」的截断式:估算降到 358 token,但「pnpm build:calc」这条事实丢了同一段会话、同一个保留清单,只把「写摘要」换成「直接扔」:省下的 token 甚至更多,但早期那条事实没了。这就是压缩与截断的全部区别,也是那一次额外请求换来的东西。
两句提醒。一、探针最好由用户自己钉——「记住:构建命令是……」这类话天然就是探针。二、真实模式下还该有第二层:把探针问题真的问一遍。两层意思不一样:第一层验压缩,第二层验模型,而只有第一层是你能修的。
源码导读
动手实验
今天挖了五个练习点,四个是「看着更简单、实际更糟」的陷阱:不校准就报系数、一条消息一组(于是压出对不上调用的工具结果)、按轮数触发、摘要拿到就用。起点代码原样跑是十五项里过六项,全部离线。
- 实现两系数估算器与最小二乘校准,看那一行「误差 55.6% 降到 2.6%」,并确认纯 ASCII 样本被判成解不开。
- 把触发判定改成按比例,用同样十四条消息的两段历史确认「长的触发、短的不触发」。
- 实现分组与保留清单:助手消息与它的工具结果绑成一组,系统指令、钉住的清单、未完成的调用、最近三组都不进可压区。
- 实现分段摘要与格式保底:四个小标题缺一个就整次放弃,一个字节都不动。
- 跑
MOCK=1 SELFTEST=1 pnpm start看到15/15 通过,再用/compact在 REPL 里压一次看两条探针;自检的数字都可复现,REPL 里跑真实测试那几轮会有几十 token 的浮动。
验收看五条勾:自检 15/15 通过;校准误差明显下降、样本单一时拒绝硬解;同样条数的两段历史一个触发一个不触发;压完消息数与估算都降、摘要带齐四个小标题、没有孤立的工具结果;两条探针压完还在,而换成截断式压缩时那条事实丢了。
面试题
今天三道题,考的是 token 与压缩的工程判断,不是「什么是上下文窗口」:
- 不引入分词器,你怎么估算 token?误差有多大、什么时候不能用估算?
- 上下文压缩要保留哪几类消息?压掉哪些最安全?
- 怎么证明一次压缩没有丢掉关键信息?
完整题干、分析过程与答题要点见本课面试题库的第十二天。第三题最有区分度——多数人只能答到「让模型自己判断」,能把「判据是上下文里有没有,不是模型答得对不对」说清楚的人很少。
检查清单与明日预告
- 能说清估算、真实用量、精确计数三者的分工,以及为什么本课不引分词器
- 能手写两系数的最小二乘校准,并说清行列式接近 0 时为什么不能硬解
- 能说出按比例触发比按轮数稳的理由,以及触发线为什么不留到九成
- 能画出那张七路预算表,并说清给死额度的真正用处
- 能说出压缩优先级是按「能不能重新取」排的,不是按新旧排的
- 能背出保留清单四条,以及「助手消息与它的工具结果不可分割」这条硬规则
- 能说清摘要为什么要结构化、不合格时为什么整次放弃,以及探针的判据是什么
明天是 D13《先问后做:结构化提问工具、只读探索模式与计划审批》。今天解决的是「桌子放不下了怎么办」,明天解决的是一开始就少往桌上放东西:先问清楚要改什么、先只读地探一遍、先出一份可审批的计划,然后才动手。先做压缩再做计划模式,是因为计划模式会显著拉长上下文——它多出提问、探索、计划三段对话,而这三段又都是最不该被压掉的。今天先把「压什么留什么」定下来,明天那三段进来才有地方待。
面试题库
不引入分词器,你怎么估算一段上下文有多少 token?误差有多大,什么时候不能用估算?Without pulling in a tokenizer, how would you estimate the token count of a context? How large is the error, and when must you not rely on an estimate?
国内高频海外高频基础#token-counting#calibration分析过程 · 先想清楚再作答
- 这题看着像脑筋急转弯,其实在考「你分不分得清估算、真实用量、精确计数」。只答一个「四个字符一个 token」就结束的人,接下来一定接不住追问。
- 怎么拆:先问「这个数拿来干什么」。做决策(这次要不要先压一压)只能用估算,因为决策发生在请求**之前**;对账要用网关回传的 usage,它准但**是事后的**;真要按 token 出账单才需要精确计数。三种数各有各的时机,混用就会出错。
- 估算器的形状要给出来:中日韩字符与其余字符两项系数相加。而关键的一步是**别把系数拍死**——每次请求末尾的用量回传就是「这段文本真实是多少 token」,把若干条(文本, 真实值)当样本解一次最小二乘(两个未知数、两条正规方程)就能回归出这一对系数,样本顺路从每一轮请求收,不额外发请求去测。
- 误差要老实说:估算对自然语言够用,**对代码明显偏乐观**——缩进、括号、下划线命名、JSON 里成串的引号逗号都会被切成更多 token。而代码恰恰是 Coding Agent 上下文里最多的东西。所以估算只能用于有余量的决策,绝不能拿它去逼近上限:触发线留在七成,那三成余量里就包含了估算自己的误差。
- 还有一个能显出你真写过的细节:样本的中英比例太单一时(比如全是英文),两个系数在数学上分不开,行列式接近 0。这时候必须**承认解不出来**,退回一个统一的每字符系数——硬解常常给出一正一负的荒唐系数,比不校准更糟,因为它看起来像是校准过的。
- 可预期的追问:为什么不干脆装一个分词器包?因为精确计数必须绑定具体的编码表,而一个能换网关的程序面对的是任何一家的任何一个模型,分词表根本不唯一——装了包你算得很准,但算的是别家的分词。真要精确就用你所用模型官方的分词器或计数接口。
How to reason about it · think before answering
- This looks like a trick question but it really tests whether you separate three different numbers: the estimate, the reported usage, and an exact count. Answering only four characters per token will not survive the follow-ups.
- How to break it down: ask what the number is for. Decisions — should I compact before this request — can only use an estimate, because the decision happens before the call. Reconciliation uses the usage the gateway returns, which is accurate but only available afterwards. An exact count is needed only when you bill by token.
- Give the shape of the estimator: one coefficient for CJK characters plus one for everything else. The important move is not hardcoding those coefficients — the prompt_tokens in each response tells you what that text really cost, so a handful of (text, real count) samples plus one least-squares fit (two unknowns, two normal equations) recovers the pair. Collect samples along the way; never fire extra requests just to measure.
- Be honest about the error: the estimate is fine for prose and clearly optimistic for code, since indentation, brackets, snake_case names and the quotes and commas in JSON all split into more tokens — and code is most of what a coding agent carries. So use the estimate only for decisions with headroom, never to approach the limit. A trigger at seventy percent leaves the remaining thirty to absorb the estimator's own error.
- One detail that shows you actually built it: when the samples all have the same script mix (all-ASCII, say), the two coefficients are mathematically inseparable and the determinant approaches zero. You must admit it cannot be solved and fall back to a single per-character coefficient. Forcing a solution typically yields one positive and one negative coefficient — worse than no calibration, because it looks calibrated.
- Likely follow-up: why not just install a tokenizer library? Because an exact count is bound to a specific encoding table, and a gateway-agnostic program faces any model from any vendor. You would compute precisely — over someone else's tokenization. When you truly need precision, use the official tokenizer or counting endpoint of the model you are actually calling.
答题要点
- 三个数三种用途:估算做决策(请求之前)、回传用量对账(请求之后)、精确计数才用来结算
- 估算式是两项相加:中日韩字符数与其余字符数各一个系数
- 系数用 usage 回传的真实值做一次最小二乘回归,样本顺路收集,不额外发请求去测
- 误差对代码偏乐观,所以估算只用于有余量的决策,触发线留三成余量吸收误差
- 样本比例单一时行列式接近 0,必须承认解不出来并退回统一系数,不能硬解
Key points
- Three numbers, three jobs: estimate for decisions before the call, reported usage for reconciliation after, exact counts only for billing
- The estimator is two terms: one coefficient for CJK characters, one for everything else
- Fit the coefficients by least squares against the real usage, sampling along the way rather than firing probe requests
- The estimate is optimistic on code, so use it only where there is headroom and leave the trigger a thirty percent margin
- When the samples share one script mix the determinant collapses; admit it and fall back to a single coefficient instead of forcing a solve
做上下文压缩时,哪几类消息必须原样保留?压掉哪些最安全?When compacting context, which kinds of messages must be kept verbatim, and which are the safest to compress away?
国内高频海外高频进阶#context-compaction#keep-list分析过程 · 先想清楚再作答
- 这题的区分度在两处:你的清单是不是**白名单**,以及你压缩的优先级是按什么排的。答「压最旧的」是最常见的错——年龄和价值没有关系。
- 先说保留:必须写成白名单,先说清哪几类原样留下、剩下的才是可压区。反过来列举「哪些可以压」,迟早会漏掉一类新出现的消息,而漏掉的后果是**静默丢信息**——比注入被裁剪更难发现,因为压缩不会给你一行报账。
- 清单四条,每条都有一个具体的失效现象:系统指令(压掉等于换了个程序);待办清单(那是「我做到哪一步」的唯一记录,压掉之后它会重做已经做完的事);未完成的工具调用(工具结果条数少于调用条数,说明上一轮被打断,摘要里写一句「调用了某个工具」替代不了那条缺失的结果);最近几组(模型正在做的那件事全在这里,摘要是结论而它此刻需要原文,压掉的现象是它开始重复刚做过的事)。
- 再说压什么,判据是一句可以迁移的话:**按「能不能重新取」排优先级,不是按新旧排。** 文件还在磁盘上、命令还能再跑一次,所以连续的工具往返最值得压——它占得最多、信息已经被紧随其后的助手消息消化过、而且需要时能重新读一遍。反过来,用户说过的话、外部系统的一次性响应、随机产生的 id 取不回来,最后压。
- 还有一条比清单更硬的结构规则:**一条带工具调用的助手消息与它的全部工具结果是一个不可分割的整体。** 拆开会得到「请求了工具却没有结果」的消息数组,下一轮请求直接不合法。所以压缩的第一步不是挑消息,是分组;分完组再标保留项,剩下的连续几组才是可压区,可压区再按用户消息切段。
- 可预期的追问:怎么保证摘要本身不添乱?提示里写死小标题(已确认的事实 / 已做过的改动 / 未完成的事情 / 关键路径与命令),让模型只填内容。自由发挥的摘要会把「已经改过某个文件」写成「讨论了如何修复」——**动作变成了话题**,下一轮它就不知道自己动过手了。
How to reason about it · think before answering
- Two things separate answers here: whether your keep list is a whitelist, and what your compression priority is ordered by. Drop the oldest is the common mistake — age has nothing to do with value.
- Keeping first: it must be a whitelist. State which classes survive verbatim, and only what is left over is compressible. Enumerating what may be compressed will eventually miss a newly introduced message type, and the consequence is silent information loss — harder to notice than a trimmed injection, because compaction prints no accounting line.
- Four classes, each with a concrete failure mode: the system prompt (drop it and you have a different program); the todo list (the only record of how far the work got, and without it the agent redoes finished steps); unfinished tool calls (fewer tool results than tool calls means the previous turn was interrupted, and a summary line saying it called a tool cannot replace the missing result); and the last few groups (the current task lives there, a summary is a conclusion while the model needs the raw text, and compressing them makes it repeat work it just did).
- Then what to compress, with one transferable criterion: order by whether it can be fetched again, not by age. Files are still on disk and commands can be rerun, so long runs of tool round trips are the best target — they are the largest block, their information was already digested by the assistant message right after them, and they can be re-read on demand. Conversely, what the user said, one-shot responses from external systems, and randomly generated ids cannot be recovered, so compress them last.
- One structural rule outranks the list: an assistant message with tool calls and all of its tool results form an indivisible group. Split them and you get a message array that requested tools without results, which makes the next request invalid outright. So the first step of compaction is grouping, not picking; then mark the keepers, and only contiguous runs of what remains are compressible, split into segments at user messages.
- Likely follow-up: how do you keep the summary itself from making things worse? Fix the headings in the prompt — confirmed facts, changes already made, unfinished work, key paths and commands — and let the model only fill them in. A free-form summary turns already edited that file into discussed how to fix it: the action becomes a topic, and the next turn the model does not know it already acted.
答题要点
- 保留清单必须是白名单:先定原样保留的类别,剩下的才是可压区,否则会静默丢信息
- 四类必留:系统指令、待办清单、未完成的工具调用、最近几组
- 带工具调用的助手消息与它的全部工具结果不可分割,拆开会让下一轮请求不合法
- 压缩优先级按「能不能重新取」排,不按新旧排:工具往返先压,用户的话最后压
- 摘要要用固定小标题约束,防止把「做过的动作」写成「讨论过的话题」
Key points
- The keep list must be a whitelist: define what survives verbatim, and only the remainder is compressible
- Four classes always survive: system prompt, todo list, unfinished tool calls, and the last few groups
- An assistant message with tool calls plus all of its results is indivisible, or the next request becomes invalid
- Prioritize by whether it can be fetched again, not by age: tool round trips first, user statements last
- Constrain the summary with fixed headings so actions taken do not degrade into topics discussed
怎么证明一次上下文压缩没有丢掉关键信息?How do you prove that a context compaction did not lose critical information?
国内高频海外高频深入#compaction-verification#probes分析过程 · 先想清楚再作答
- 这题是今天最有区分度的一道,因为大多数人只能答到「让模型自己判断」或者「人工看一眼摘要」。题眼在「证明」两个字:你要给出一个**可复现的判据**,而不是一种感觉。
- 怎么拆:先说清为什么必须证明。压缩的失败是**静默的**——模型不会说「我丢了一条事实」,它会很自信地拿一条错的、或者凭空补的事实继续干活,你要在几轮之后从一个奇怪的结果里倒推。不可观测的失败必须靠主动检查暴露。
- 做法是探针:压缩之前记下几条只有早期上下文里才有的事实(用户说过的「记住:构建命令是……」这类话天然就是探针),压缩之后回头查一遍。而**判据是「这条事实还在不在上下文里」,不是「模型答得对不对」**——后者是随机变量:答对可能是猜对的,答错也可能是这一轮的运气。用随机变量去验一个确定的机制,验不出任何东西。
- 更进一步:单看一次压缩说明不了什么,要有**对照**。同一段会话、同一份保留清单,把「写摘要」换成「只留最近几组直接扔」,两者省下的 token 差不多,但截断式那一边早期那条事实就没了。这个对照才是「压缩」与「截断」区别的证据,也是你多发一次摘要请求的理由。
- 工程上还要有两条兜底:一是摘要必须**可校验**——固定小标题一个都不许少,缺了就整次放弃、上下文一个字都不动(压坏的上下文比没压的糟得多,而且不可逆,原文已经不在数组里了);二是实现上先把全部段落的摘要都拿到手,再一次性重建数组,这样「放弃」才有干净的退路。
- 可预期的追问:那真实环境里怎么办?两层一起上——第一层是上面这个确定性检查,第二层是把探针问题真的问一遍模型。两层验的不是同一件事:第一层验压缩,第二层验模型,**而只有第一层是你能修的**。再往上还有一层是第二十天的基准集:把「压缩前后同一个任务的通过率」当指标跑一遍。
How to reason about it · think before answering
- This is the question with the most signal, because most candidates stop at let the model judge or eyeball the summary. The word prove is the hinge: you need a reproducible criterion, not a feeling.
- How to break it down: say why proof is required. Compaction fails silently — the model never says it lost a fact, it confidently continues with a wrong or invented one, and you infer the loss several turns later from a strange result. Unobservable failures have to be surfaced by an active check.
- The technique is probes: before compacting, record a few facts that exist only in the early context — a user saying remember: the build command is … is a natural probe — and look for them afterwards. The criterion is whether the fact is still in the context, not whether the model answers correctly. The latter is a random variable: a correct answer may be a lucky guess and a wrong one may be this turn's noise, and testing a deterministic mechanism with a random variable proves nothing.
- Go one step further: a single run proves little, so build a control. Same conversation, same keep list, but replace write a summary with keep only the last few groups and drop the rest. Both save a similar number of tokens, yet the truncating side loses the early fact. That contrast is the actual evidence that compaction differs from truncation, and the justification for the extra summarization request.
- Two engineering backstops: the summary must be checkable — every fixed heading present, and if one is missing the whole compaction is abandoned with the context untouched, since a corrupted context is far worse than an uncompacted one and the loss is irreversible once the originals leave the array. And implement it in two phases, collecting every segment summary before rebuilding the array once, so abandoning has a clean path.
- Likely follow-up: what about production? Run both layers — the deterministic check above, plus actually asking the probe question. They test different things: the first tests compaction, the second tests the model, and only the first is something you can fix. Above both sits an evaluation set: run the same task before and after compaction and compare pass rates.
答题要点
- 压缩的失败是静默的,必须靠主动检查暴露,不能靠「看起来还行」
- 探针:压缩前记下只有早期上下文才有的事实,压缩后回头查一遍
- 判据是「事实还在不在上下文里」,不是「模型答得对不对」——后者是随机变量
- 要有对照:同一段会话换成截断式压缩,省下的 token 差不多但那条事实丢了
- 摘要必须可校验,格式不合格就整次放弃、上下文一个字都不动(先全拿到再重建)
Key points
- Compaction fails silently, so it must be surfaced by an active check rather than by looking fine
- Use probes: record facts that exist only in the early context, then look for them after compacting
- The criterion is whether the fact is still in the context, not whether the model answers it correctly
- Include a control: the same conversation truncated instead of summarized saves similar tokens but loses the fact
- The summary must be checkable; if the format fails, abandon the whole compaction and touch nothing