用 AI 写代码,聊得久了很容易碰到这种事:开头明明交代过「不要修改 config.yaml」,几十轮以后,它还是把这个文件改了。
多数时候不是模型故意不听,而是这句话已经不在它眼前了。每次请求,工具都要把对话重新发给模型,而测试日志、文件内容这类工具输出越攒越多,迟早装不下。装不下就得腾地方,腾的时候丢了什么、留了什么,决定了 AI 还记不记得你说过的话。
各家的做法大体分两类:一类是让模型把前面的对话写成一段总结,Claude Code、Codex、Aider 都是这个思路;另一类是先把占地方的工具输出压缩掉,重要的话钉住不动,我日常用的 WES Code 走的是这条路。这篇对照官方文档讲清楚各家怎么做,用一个能直接运行的 Go 小程序演示最简单的删法为什么会丢掉约束,再看 WES Code 的分层压缩在引擎里跑出来是什么样。
一、为什么要压缩:上下文装满了就得腾地方
一次对话里,真正占地方的往往不是你和 AI 说的话,而是工具输出:跑一次测试几十行,读一个文件几百行,几轮下来就是几万个 token。腾地方的常见做法有三种:
| 做法 | 好处 | 代价 |
|---|---|---|
| 从最早的消息开始删 | 简单、不用调用模型 | 最早的往往是任务和约束,最先被删 |
| 让模型写一段总结 | 能保留前因后果 | 总结本身要调用一次模型,细节可能被概括掉 |
| 先压缩工具输出,重要的话钉住 | 不调用模型,约束原样保留 | 压缩掉的输出细节,需要时得重新读 |
二、让模型写总结:Claude Code、Codex、Aider
Claude Code 的文档写道,对话接近上下文窗口时会自动压缩,把历史对话写成总结。也可以手动输入 /compact,并在后面加一句话告诉它总结时保留什么,文档里的例子是 /compact Focus on code samples and API usage。还可以在项目根目录的 CLAUDE.md 里写一段压缩说明:
markdown
# Compact instructions
When you are using compact, please focus on test output and code changes
同一页文档还给了几条控制上下文的办法:用 /context 看是什么占了地方;换到不相关的任务时用 /clear 重新开始;跑测试、处理日志这类输出很长的活交给子代理,长输出留在子代理那边,只把总结带回主对话。文档也提醒,/compact 要把整段对话读一遍才能总结,压缩一个很大的上下文,本身就是一次很大的请求。
Codex 的 /compact 也是这个思路:文档说它会把可见的对话写成总结来腾出 token,确认以后,用一段简洁的总结替换前面的轮次,同时保留关键细节。
Aider 把这件事做成了一个阈值:--max-chat-history-tokens 是聊天历史的软阈值,超过以后开始总结;做总结用的是 --weak-model 指定的那个模型,也就是同时用来写提交信息的那个辅助模型。
这类做法的好处是能保留前因后果,代价也很明确:总结的质量取决于模型,你那句「不要修改 config.yaml」会不会被写进总结,要看模型当时怎么概括。Claude Code 允许你写压缩说明,就是为了让你能左右它留什么。
三、最简单的删法为什么会丢掉约束
先看最省事的做法:从最早的消息开始删,删到装得下为止。我写了一个小程序,模拟一段四轮的对话:第一句交代任务和约束,后面每轮都跑一次测试、拿回四十多行失败日志。然后假设只装得下一半,对比两种删法:
go
// 做法一:从最早的消息开始删,直到装得下
func dropOldest(ms []msg, budget int) []msg {
for len(ms) > 1 && size(ms) > budget {
ms = ms[1:]
}
return ms
}
// 做法二:带「不要」「必须」的消息钉住;旧的命令输出换成一行;还装不下,再删最早的、没钉住的消息
func layered(ms []msg, budget, keepRecent int) []msg {
out := append([]msg(nil), ms...)
seen := 0
for i := len(out) - 1; i >= 0; i-- {
if out[i].role != "tool" {
continue
}
if seen++; seen <= keepRecent {
continue
}
first := strings.SplitN(out[i].text, "\n", 2)[0]
out[i].text = fmt.Sprintf("[已压缩] 共 %d 行,第一行:%s", strings.Count(out[i].text, "\n")+1, first)
}
// ...... 还装不下,就从前往后删没钉住的消息
return out
}
运行结果:
text
原始对话: 12 条, 8233 字符;只装得下 4116 字符
== 从最早的消息开始删:剩 6 条,4092 字符
第一条: 再跑一次全部测试
「不要修改 config.yaml」还在吗: false
== 钉住约束 + 压缩旧输出:剩 5 条,4101 字符
第一条: 帮我把订单模块的测试跑通。注意:不要修改 config.yaml。
「不要修改 config.yaml」还在吗: true
同样只装得下一半,第一种删法把开头的任务和约束删掉了,AI 接下来看到的第一句话是「再跑一次全部测试」;第二种删法先把旧的测试日志压成一行,再删没钉住的消息,那句约束一直留在最前面。
道理很简单:最占地方的是工具输出,最不能丢的是你交代的话,这两样不该按同一个规则处理。
四、WES Code:把「记住」和「腾地方」分开
WES Code 用的引擎把这件事拆成了两半。
记住交给会话状态:每轮对话结束后,引擎会在后台用同一个模型调用一次,把这一轮整理成一份结构化的状态,下一轮从这份状态接着做。这次调用不在界面上显示,失败了就退回一份不调用模型、直接生成的快照。状态里记的东西是固定的几类:
go
type SettlementSnapshot struct {
PendingTask string `json:"pending_task"`
Goal string `json:"goal"`
// ......
CurrentPhase string `json:"current_phase"`
// ......
CompletedActions []CompletedAction `json:"completed_actions"`
KeyDecisions []string `json:"key_decisions"`
RejectedPaths []string `json:"rejected_paths,omitempty"`
FileStates []FileState `json:"file_states,omitempty"`
OpenQuestions []string `json:"open_questions,omitempty"`
Constraints []string `json:"constraints,omitempty"`
和「写一段总结」相比,这些字段把容易丢的东西单独列了出来:目标、做完了哪些操作、做过哪些关键决定、试过但放弃了哪些方案、有哪些约束。
腾地方交给一条不调用模型的压缩流水线,按代价从小到大排了五层:
go
pipeline := wcontext.NewCompressPipeline(0, budgetCfg, base.Logger)
if budgetCfg.DeduplicatorEnabled {
pipeline = pipeline.AddLayer(&compression.ToolResultDeduplicator{
KeepRecent: budgetCfg.MicroCompactKeepRecent,
})
}
pipeline = pipeline.
AddLayer(&compression.FileCardStrategy{
KeepRecent: budgetCfg.MicroCompactKeepRecent,
SymbolExtractor: s.fileCardSymbolExtractor,
}).
AddLayer(&compression.AgeBasedCompactStrategy{
MaxAge: budgetCfg.MicroCompactMaxAge,
KeepRecent: budgetCfg.MicroCompactKeepRecent,
}).
AddLayer(&compression.DropOldestStrategy{}).
AddLayer(&compression.InlineTruncateStrategy{
MaxContentChars: 100,
KeepRecent: budgetCfg.MicroCompactKeepRecent,
}).
WithQualityGuard(wcontext.NewQualityGuard(0))
- 去重:和后面某次结果一模一样的旧工具结果,换成一句「和后面那次内容相同」
- 文件卡片:读过的旧文件换成一张卡片,记着文件路径、行数和顶层的函数、类型名,需要细节时再读一次
- 按年龄压缩:模型还没看到的结果不碰,看过的结果里最近 3 条原样保留,更早的命令输出压成「跑了什么命令、退出码多少、多少行输出」,失败的会多留结尾 15 行报错;写文件这类操作的结果不压;能转存原文时,压缩后的内容还带着指向原文的指针
- 丢最早的几轮:第一组(你最初交代的任务)和当前这一轮永远不丢,钉住的也不丢;丢之前先把这些轮里做过的操作整理成一条钉住的系统消息
- 紧急截断:前面都不够时,才把旧内容截短
哪些话算「钉住」,有一份内置的关键词表,另外还会让模型判断哪些消息是目标、约束或偏好:
go
var defaultSafetyKeywords = []string{
"禁止", "不允许", "不要", "危险", "严禁", "必须", "绝不",
"do not", "never", "must not", "forbidden", "prohibited",
"dangerous", "critical constraint", "safety",
}
var defaultControlPatterns = []string{
"在我说可以之前", "等我确认", "不要自动", "需要我的同意",
"before i confirm", "wait for my approval", "do not proceed",
"until i say", "don't do anything until",
}
我在 WES Code 用的引擎里写了个测试,同样用 -overlay 临时放进去跑、不往仓库写文件:构造一段四轮的对话,第一轮交代「不要修改 config.yaml」,后面六次跑测试,其中第一次和最后一次的输出一模一样,然后依次过钉住、去重、按年龄压缩、丢最早几轮:
text
原始对话: 16 条消息,估算 3927 token
== 钉住:扫描关键词
第 0 条被钉住,原因 safety_constraint:user(钉住):帮我把订单模块的测试跑通。注意:不要修改 config.yaml。
== 第一层:去掉重复的工具结果
user:结果 t1,共 1 行,第一行 [Duplicate: same content as later call]
估算 3345 token
== 第三层:压缩旧的工具结果(模型还没看的不动,看过的最近 3 条原样保留)
user:结果 t2,共 17 行,第一行 [exec] ran `go test ./order/... -run TestTotal` → exit 1, 33 lines output
user:结果 t3,共 43 行,第一行 order_test.go:11: TestDiscount: got 90, want 100
......(t4、t5、t6 同样原样保留)
估算 3116 token
== 第四层:还不够,丢掉最早的几轮
system(钉住):[已完成操作摘要 --- 以下操作已执行,不要重复]
user(钉住):帮我把订单模块的测试跑通。注意:不要修改 config.yaml。
......(第一轮的两次调用和结果)
user:再跑一次全部测试
......
user:现在怎么样了?
......
估算 1860 token
几层下来,估算的 token 从 3927 降到 1860,第一条消息因为带着「不要」被钉住,始终在最前面;中间那一轮(看折扣的两次测试)被丢掉了,换来最前面一条钉住的「已完成操作摘要」,提醒模型这些操作已经做过、不要重复。
流水线还有一道熔断:同一个会话里连续 3 次压缩都省不下一成,就判定再压也没用,先停下来,照当前的上下文继续,免得每一轮都白白压一遍。
五、各自的边界
- 让模型写总结(Claude Code、Codex、Aider):能保留前因后果,但总结本身要调用模型,压缩很大的上下文就是一次很大的请求;哪些细节进总结,要看模型怎么概括
- WES Code 有三点要心里有数:
- 关键词只认得明确的说法。「config.yaml 是线上配置,别碰」里有「别」没有「不要」,关键词表就匹配不到,只能靠模型判断和会话状态里的约束字段兜住
- 压缩掉的旧输出只剩一行概要,模型需要细节时,得重新读文件或再跑一次命令
- 每轮结束整理会话状态要在后台多调用一次模型,界面上看不到,但会多用一些 token
六、几条实用建议
- 约束写得明确些:用「不要」「必须」「禁止」这类词,比委婉的说法更不容易在压缩里丢掉;长期有效的规矩,放进项目的规则文件,比如 Claude Code 的 CLAUDE.md
- 换任务就开新对话:Claude Code 文档也建议换到不相关的任务时用 /clear,旧上下文只会占地方
- 别让大段输出进主对话:跑全量测试、看长日志时,只让它看失败的部分或者结尾,或者交给子代理去处理
- 手动压缩时告诉它留什么:用 Claude Code 的 /compact 时,在后面加一句要保留的内容
写在最后
上下文装不下是必然的,区别只在腾地方的时候怎么取舍。让模型写总结,好处是能留住前因后果,但留什么要看模型;最简单的从头删,最先丢的恰恰是你最早交代的话;WES Code 把「记住」交给结构化的会话状态,把「腾地方」交给不调用模型的分层压缩,你说过的约束被钉住,不参与压缩。了解这些规则之后,就知道哪些话该说得更明确、哪些输出不该让它进对话。
文中 WES Code 的部分来自我日常使用和它引擎里的代码,其余工具的说法来自各自的官方文档。你遇到过 AI 聊着聊着就忘了约定的情况吗?最后是怎么解决的?欢迎在评论区聊聊。
觉得有用的话,欢迎点赞、收藏。