练习 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]

答对了——而且答案不可能来自原始对话,因为原始的那两轮已经不在磁盘上了。 phoenixGo 1.26 这些事实,活在那条摘要消息里,模型是从摘要里 读出来的。

发生了什么

压缩不是删除,是换一种更省空间的形式保留。 被折叠的那几条原始消息 真的从 History 里消失了,但它们说过的事实——项目名、语言版本、 已有的工具——被浓缩进了一条摘要消息,跟着会话继续往下走。练习 10 的备份是"原文整份留一份、放在旁边";这一章的压缩是"原文不留, 但把它的意思留下来"——两种应对"这些信息以后可能还要用"的策略, 一种保真但占地方,一种省地方但有损。选哪种,取决于你能不能接受 "细节可能丢,但要点不会丢"。

分割点为什么必须落在真正的 user 消息前面。 一次工具调用往返, 是 assistant 说"我要调用工具" + 一条或几条 tool 消息回答它—— 这两头必须完整地待在一起,从中间切开,模型看到的会是"上文说要调用 工具,然后呢?",协议本身就不完整,很多 API 会直接拒绝这样的请求。 唯一安全的切割点,是"新的一轮 user 发言"之前——那意味着上一轮的 你来我往已经彻底闭合。

这道判断在这套协议里几乎不用动脑子,这也是选它当主线协议的理由 之一(练习 4 埋过这句话)。 工具的回执走独立的 tool role, 永远不会伪装成 user 消息——只要看见 Role == "user",就一定是 真人说的话,不用再去甄别"这是不是工具结果套壳"。octo 实现的 Anthropic 消息协议里,tool_result 是搭在 user 角色的消息上发的, 所以那边多写了一个 IsPlainUserMessage 专门排除"看起来是 user、 其实是工具回执"的情况。协议在设计时把角色分得干净,后面这类判断 就少一层心智负担。

"不给工具"是比"告诉它别调用工具"更硬的保证。 compressionPrompt 里写了"不要调用任何工具",但真正让这句话作数的,是 summarizesend 时把 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 消息,说明这本来就是刚开始没多久的 会话,没什么可折叠的,硬压缩反而会把仅有的上下文也搭进去。

加分练习

  1. summarize 换一个更小、更便宜的模型,同时保留正式回复用主模型 ——对照 octo 的 LiteSender/LiteModel 设计,感受"总结这件事本身 值不值得用贵模型做"。
  2. 实现 archiveChunk 的极简版:压缩时把被折叠的原始消息写到 .chunks/<session-id>-N.md,并在摘要消息里附一句"完整原文在 xxx,需要时可以用 read_file 查看",让模型自己决定要不要去读。
  3. 故意去掉 compressionPrompt 里"不要调用任何工具"那几句话,同时把 summarize 里的 toolsnil 换成真正的工具列表,看总结请求里 模型会不会真的尝试调用工具——这是"双保险"里去掉其中一层会发生 什么的真机实验。
  4. 试着连续触发两次压缩(多轮对话,反复让预算告急),观察第二次压缩 时,"摘要消息"本身会不会被当成旧对话的一部分,折叠进更新的摘要 里——"摘要的摘要"会不会失真,动手看一眼再回答。