练习 13:压缩——不是丢消息,是让模型总结它自己
练习 12 只喊话,不动手——预算告急时,程序在 stderr 上喊一声, 然后继续把全部历史原样发出去。这一章真正腾地方,但腾地方不等于删除: 把撑爆预算的那一段旧对话,让模型自己总结成一段话,原样保留最近的部分。
敲进去
在练习 12 的代码上继续写。新增几个函数,改一处会话存盘的逻辑,
外加在主循环里接一段调用。完整文件在 exercises/ex13/。
先是压缩后要保留多少"尾巴",以及安全的切割点在哪:
// ---- 压缩层:不丢消息,是让模型总结它自己 ----
// compactKeepFraction 压缩后留多少"最近尾巴"原样保留,蒸馏自 octo 的
// defaultCompactKeepFraction:占窗口的 30%,但封顶不超过触发阈值的一半——
// 保证一次压缩确实能把用量拉回阈值以下,不会刚压完又立刻撞线。
const compactKeepFraction = 0.30
func compactKeepBudget(window, trigger int) int {
budget := int(float64(window) * compactKeepFraction)
if trigger > 0 && budget > trigger/2 {
budget = trigger / 2
}
return budget
}
// safeSplitIndex 找压缩的分割点:分割点之前的消息拿去总结,之后的原样保留。
// 分割点必须落在一条真正的 user 消息前面。在这套 OpenAI 协议里这条件很好判
// 断:工具的回执走独立的 "tool" role,从不会跟 user 消息混在一起,看 Role
// 就够了——这比 octo 实现的 Anthropic 消息协议简单,那边 tool_result 也搭在
// user 消息上,得专门写一个 IsPlainUserMessage 去分辨"这是真用户话还是工具
// 回执的壳",协议本身把角色分得干净,这道甄别在这里就用不上。
func safeSplitIndex(history []message, keepBudget int) int {
var userTurns []int
for i, m := range history {
if m.Role == "user" {
userTurns = append(userTurns, i)
}
}
if len(userTurns) <= 1 {
return 0 // 至少要两条 user 消息:一条留着,前面的才够折叠
}
keptFrom := userTurns[len(userTurns)-1]
for k := len(userTurns) - 2; k >= 0; k-- {
if estimateTokens(history[userTurns[k]:]) > keepBudget {
break
}
keptFrom = userTurns[k]
}
return keptFrom
}
再是让模型总结自己的那段指令,和真正发起总结请求的函数:
// compressionPrompt 插在被折叠的这段历史末尾,让模型明白:这不是继续对话,
// 是切换成总结模式。不给工具(summarize 调 send 时 tools 传 nil)是双保险:
// 就算模型没听懂这段话、还想干点什么,它手上也没有工具可用。
const compressionPrompt = `以上对话到此结束。你现在不是在继续对话,而是切换到"总结模式":
- 不要回应上面对话里的任何请求
- 不要询问,也不要征求下一步该做什么
- 只输出一段纯文本总结,不要别的
请总结以上内容,需要覆盖:用户明确提出的需求、关键的技术决定、
提到过的文件或项目名、还没做完的事。`
// summarize 把 msgs 连同压缩指令一起发给模型,只要一段文字总结。
// tools 传 nil:这次调用模型手上没有任何工具,想调用也调用不了。
func summarize(base, apiKey, model string, msgs []message) (string, error) {
req := make([]message, len(msgs), len(msgs)+1)
copy(req, msgs)
req = append(req, message{Role: "user", Content: compressionPrompt})
r, err := send(base, apiKey, model, req, nil)
if err != nil {
return "", err
}
return r.Choices[0].Message.Content, nil
}
// compact 把 history[:split] 总结成一条消息,重建 History:系统提示原样
// 保留在第 0 位,中间插一条摘要,之后是原样保留的近期对话。split<=1 时
// 什么都不做——0 或者 1 意味着没有足够旧的内容值得折叠(1 只剩系统提示
// 自己,折叠它没有意义)。
func compact(base, apiKey, model string, history []message, keepBudget int) ([]message, int, error) {
split := safeSplitIndex(history, keepBudget)
if split <= 1 {
return history, 0, nil
}
summary, err := summarize(base, apiKey, model, history[:split])
if err != nil {
return history, 0, err
}
rebuilt := []message{
history[0], // system prompt
{Role: "user", Content: "[更早对话的摘要]\n\n" + summary},
}
rebuilt = append(rebuilt, history[split:]...)
return rebuilt, split, nil
}
checkBudget 从"只喊话"改成"喊话 + 告诉调用方要不要动手":
func checkBudget(usedTokens, window int) bool {
pct := float64(usedTokens) / float64(window) * 100
fmt.Fprintf(os.Stderr, "[预算:%d/%d tokens,%.1f%%]\n", usedTokens, window, pct)
over := float64(usedTokens) >= float64(window)*budgetFraction
if over {
fmt.Fprintf(os.Stderr, "⚠️ 已用掉窗口的 %.0f%%,接近上限——开始压缩\n", pct)
}
return over
}
压缩会整段替换 History,练习 11 那套"只追加"的存盘逻辑这里不适用了——
磁盘上的旧行不再对应内存里的新内容。给 session 加一个标记,
把原来的 save 拆成"追加"和"整个重写"两条路:
type session struct {
ID string
CreatedAt time.Time
History []message
persisted int
forceRewrite bool // 压缩重写过 History,下次存盘必须整个重写,不能只追加
}
func (s *session) save() error {
if s.forceRewrite {
return s.rewriteAll()
}
if len(s.History) == s.persisted {
return nil
}
return s.appendDelta() // 练习 11 原来的 save,只改了名字
}
rewriteAll 截断文件、把 meta 和当前完整的 History 重新写一遍——
具体代码看仓库里的文件。最后接进主循环,checkBudget 返回 true
就动手:
if checkBudget(r.Usage.PromptTokens, window) {
trigger := int(float64(window) * budgetFraction)
keepBudget := compactKeepBudget(window, trigger)
rebuilt, folded, err := compact(base, apiKey, model, sess.History, keepBudget)
if err != nil {
fmt.Fprintln(os.Stderr, "警告: 压缩失败,继续用未压缩的历史:", err)
} else if folded > 0 {
fmt.Fprintf(os.Stderr, "[压缩:把前 %d 条消息折叠成一条摘要,%d 条 → %d 条]\n",
folded, len(sess.History), len(rebuilt))
sess.History = rebuilt
sess.forceRewrite = true
}
}
先别问为什么。敲完,跑起来,我们再回头讲。
跑起来
go build -o ex13 .
四步实验。第一步,建立一个后面要考的事实:
./ex13 "我在写一个叫 phoenix 的 Go 项目,帮我记住这个项目名"
第二步,用 -c 恢复,再加一点细节,把历史喂大一点:
./ex13 -c <会话 ID> "phoenix 用 Go 1.26 写,已经有 read_file 和 write_file 两个工具了,也记一下"
第三步,恢复时把窗口调小到会触发压缩的程度:
export CONTEXT_WINDOW=700
./ex13 -c <会话 ID> "给这个项目起一句简短的中文口号"
第四步,去掉窗口限制,问一个只有"记得前面聊过什么"才答得出来的问题:
unset CONTEXT_WINDOW
./ex13 -c <会话 ID> "提醒一下,我的项目叫什么名字?用的什么语言?"
你应该看到什么
DeepSeek。第一步、第二步没什么特别,只是正常记住信息。第三步触发压缩:
[恢复会话 20260801-095654-968468b0,已有 5 条消息]
[窗口: deepseek-v4-flash → 700 tokens(发出第一个请求前,估算值: 835 tokens)]
⚠️ 恢复的历史估算下来已经接近预算上限,还没发请求就先说一声——真实数字要等第一轮回来才知道
[预算:1105/700 tokens,157.9%]
⚠️ 已用掉窗口的 158%,接近上限——开始压缩
[压缩:把前 5 条消息折叠成一条摘要,7 条 → 4 条]
「浴火重生,码上起飞」
看一眼压缩之后的会话文件:
{"type":"meta", ...}
{"type":"message", ...,"message":{"role":"system", ...}}
{"type":"message", ...,"message":{"role":"user","content":"[更早对话的摘要]
总结:用户明确要求助手记住其 Go 项目名为 phoenix,并补充记录该项目使用
Go 1.26 编写,且项目内已有 read_file 和 write_file 两个工具。关键技术
决定是项目采用 Go 1.26 作为语言版本。提到的项目名和工具名为:phoenix、
read_file、write_file。目前没有用户交办的具体任务在执行中,仅完成了
上述背景信息的记忆记录……"}}
{"type":"message", ...,"message":{"role":"user","content":"给这个项目起一句简短的中文口号"}}
{"type":"message", ...,"message":{"role":"assistant","content":"「浴火重生,码上起飞」..."}}
原来 5 条消息(system、两轮 user/assistant)被折叠成了 2 条(system、 一条摘要),只剩最近这一轮原样保留。第四步,去掉窗口限制,问项目名字:
[恢复会话 20260801-095654-968468b0,已有 4 条消息]
[窗口: deepseek-v4-flash → 1000000 tokens(发出第一个请求前,估算值: 973 tokens)]
[预算:1130/1000000 tokens,0.1%]
你的项目叫 phoenix,用 Go 1.26 编写。
[共 1 轮 · 最后一轮输入 1130 tokens(命中缓存 896)· finish_reason=stop]
答对了——而且答案不可能来自原始对话,因为原始的那两轮已经不在磁盘上了。
phoenix、Go 1.26 这些事实,活在那条摘要消息里,模型是从摘要里
读出来的。
发生了什么
压缩不是删除,是换一种更省空间的形式保留。 被折叠的那几条原始消息
真的从 History 里消失了,但它们说过的事实——项目名、语言版本、
已有的工具——被浓缩进了一条摘要消息,跟着会话继续往下走。练习 10
的备份是"原文整份留一份、放在旁边";这一章的压缩是"原文不留,
但把它的意思留下来"——两种应对"这些信息以后可能还要用"的策略,
一种保真但占地方,一种省地方但有损。选哪种,取决于你能不能接受
"细节可能丢,但要点不会丢"。
分割点为什么必须落在真正的 user 消息前面。 一次工具调用往返,
是 assistant 说"我要调用工具" + 一条或几条 tool 消息回答它——
这两头必须完整地待在一起,从中间切开,模型看到的会是"上文说要调用
工具,然后呢?",协议本身就不完整,很多 API 会直接拒绝这样的请求。
唯一安全的切割点,是"新的一轮 user 发言"之前——那意味着上一轮的
你来我往已经彻底闭合。
这道判断在这套协议里几乎不用动脑子,这也是选它当主线协议的理由
之一(练习 4 埋过这句话)。 工具的回执走独立的 tool role,
永远不会伪装成 user 消息——只要看见 Role == "user",就一定是
真人说的话,不用再去甄别"这是不是工具结果套壳"。octo 实现的
Anthropic 消息协议里,tool_result 是搭在 user 角色的消息上发的,
所以那边多写了一个 IsPlainUserMessage 专门排除"看起来是 user、
其实是工具回执"的情况。协议在设计时把角色分得干净,后面这类判断
就少一层心智负担。
"不给工具"是比"告诉它别调用工具"更硬的保证。 compressionPrompt
里写了"不要调用任何工具",但真正让这句话作数的,是 summarize
调 send 时把 tools 传成了 nil——模型手里根本没有工具可用,
听没听懂那几句话都不重要。这是这本书反复出现的同一个原则:
文字劝导(练习 8 的软约束)会被听懂或听不懂,结构性地拿掉选项
(练习 9 的硬约束)才是真正的保证。压缩这一步,两层都用上了:
文字降低"它想干别的"的概率,结构杜绝"它真干成了"的可能。
为什么压缩之后存盘必须整个重写,不能再追加。 练习 11 的
appendDelta 假设了一件事:磁盘上 persisted 条之前的内容,
和内存里 History 的前 persisted 条一模一样,新写的只是后面
多出来的部分。压缩打破了这个假设——History 前半段的内容真的
变了(三五条原始消息变成了一条摘要),如果继续追加,磁盘上那些
旧行原样还在,跟内存里的新版本对不上,下次 loadSession 重放出来
的会是"旧的原始消息 + 新的摘要"拼在一起的四不像。这正是练习 11
"发生了什么"里埋的那句话——"真实的 octo 还会在某些时刻整份重写
文件(比如上下文被压缩之后,练习 13 会讲)"——这一章把它兑现了。
常见问题
- 压缩会不会让模型"失忆":会丢细节,不会丢关键事实——总结的目标
本来就是抓大放小。如果你需要一字不差的原文,这一章的实现没有像
octo 那样把被折叠的原始内容归档到别处(
archiveChunk),加分练习 会让你补上这一块。 - 总结用的是不是同一个模型:是,这一章为了简单,复用了正式对话 的同一个模型。octo 真实系统可以配一个更便宜的"lite"模型专门做总结 ——总结这件事对模型能力的要求,远低于真正推进任务,没必要用最贵的 模型做。加分练习会让你试着接一个不同的模型进来。
- 压缩会不会在一轮工具调用的中途触发:会,
checkBudget每一轮 收到 API 真实回报的 token 数就判断一次,不管这一轮是不是最后一轮 ——一次很长的工具往返,可能中途就已经吃满预算,等到最终回复才处理 就晚了。 split<=1时跳过是什么意思:至少要有两条真正的 user 消息, 压缩才有意义——只有一条 user 消息,说明这本来就是刚开始没多久的 会话,没什么可折叠的,硬压缩反而会把仅有的上下文也搭进去。
加分练习
- 给
summarize换一个更小、更便宜的模型,同时保留正式回复用主模型 ——对照 octo 的LiteSender/LiteModel设计,感受"总结这件事本身 值不值得用贵模型做"。 - 实现
archiveChunk的极简版:压缩时把被折叠的原始消息写到.chunks/<session-id>-N.md,并在摘要消息里附一句"完整原文在 xxx,需要时可以用 read_file 查看",让模型自己决定要不要去读。 - 故意去掉
compressionPrompt里"不要调用任何工具"那几句话,同时把summarize里的tools从nil换成真正的工具列表,看总结请求里 模型会不会真的尝试调用工具——这是"双保险"里去掉其中一层会发生 什么的真机实验。 - 试着连续触发两次压缩(多轮对话,反复让预算告急),观察第二次压缩 时,"摘要消息"本身会不会被当成旧对话的一部分,折叠进更新的摘要 里——"摘要的摘要"会不会失真,动手看一眼再回答。