🥰个人主页:会编程的土豆(欢迎来访)
💎作者简介:后端学习者
❄️个人专栏:数据结构与算法,数据库,leetcode
✨那些你一个人走过的夜路,终将化作照亮未来的光

上下文压缩是 Agent 工程里最容易被"讲得比做得好"的一块。

本文先给出完整的四层方法论,再逐层对照这个项目的真实实现——哪层做了、哪层没做、没做的代价是什么。


0. 一句话结论

业界标准的上下文压缩是四层:

① 代码去重与截断   (零成本,砍量)
② 滑动窗口         (按时间,砍旧)
③ 预算强制截断     (按优先级,保命)
④ LLM 语义摘要     (按语义,保质量)

这个项目实现了第 ① 层的 Top-K、第 ② 层、第 ③ 层,没有第 ④ 层,也没有第 ① 层的哈希去重和结构化抽取。

也就是说:做了 1.5 层。

这不是缺陷通报,而是一个明确的架构取舍,后面会讲清楚取舍的理由和代价。


1. 上下文到底是什么

用外卖骑手的比喻最直观。

你让骑手送一单,不能只说"送外卖",得把整张单子给他:

槽位

骑手场景

作用

Constraints

"绝对不准闯红灯"

保命规矩,不能违反

Profile

"顾客是 VIP,吃辣,不要香菜"

个性化

TaskMem

"顾客刚打电话改了地址,放前台"

最近变化

Recall

"商家资料:这是麻辣香锅,含花椒"

外部知识

ToolState

"导航显示还有 2 公里"

实时状态

Current Question

"我的外卖还有多久到?"

当前诉求

骑手的电动车只能挂一个箱子。 箱子就这么大——这就是上下文窗口。

压缩的本质,就是在箱子容量固定的前提下,决定带什么、扔什么。

而大模型是无状态的,这张单子每一单都要重新写一遍。所以压缩不是"优化",是每轮都必须执行的固定成本。


2. 第一层:代码去重与截断(零成本,砍量)

这一层的原则是:能用 if 判断解决的,绝不调模型。

2.1 哈希去重 —— 本项目没做

通用做法:检索出 5 条资料,其中 3 条是同一段落,算 MD5 扔掉重复的。

本项目的实际情况:

RAG 侧只有一个去重,去的是改写后的 query,不是检索结果:

queries = dedupKeepOrder(append(queries, query))

检索结果侧没有 content hash 去重。

为什么没做?

因为多路召回(Milvus / ES / Neo4j)的结果本来就靠 RRF 融合处理:

score = Σ weight / (k + rank + 1)     // k = 60

RRF 融合时会以 content 作为聚合键,同一段内容在不同路命中会累加分数,而不是产生两条重复结果。

换句话说:去重被"融合"顺带解决了,不需要额外做哈希去重。

这是一个值得注意的设计——很多"必须做的优化",在换一种架构之后会自然消失。

但要注意边界:RRF 只能处理"完全相同的内容"。如果两条资料是同一段话的不同排版(多一个空格、少一个换行),content 不同就会被当成两条。真正的哈希去重需要先做归一化(这正是文档解析阶段 normalizeText 做的事)。

2.2 Top-K 硬截断 —— 本项目做了,而且更细

通用做法:召回 10 条,砍掉后 7 条,只留前 3 条。

本项目:TopK 之外还加了一道 MinScore:

Schema

TopK

MinScore

chat

3

0.4

tool

2

0.5

react

2

0.5

rag

3

0.4

为什么 MinScore 比 TopK 更重要?

只有 TopK 的话,召回的 3 条可能全是 0.3 分的垃圾——凑数式召回。加了 MinScore 之后,宁可只返回 1 条,也不返回 3 条低质量的。

TopK 控制上限,MinScore 控制下限。 两个都要有。

2.3 结构化抽取 —— 本项目没做,只有长度截断

通用做法:工具返回 2000 字 JSON,只抽 {"temp": 18} 出来,剩下 1990 字丢掉。

本项目:只做长度截断。

// source_tools.go
if len(trace.Summary) > 120 {
    trace.Summary = trace.Summary[:120] + "…"
}

这里有个真实风险:

如果工具返回的是 JSON,截到 120 字节很可能得到:

{"temperature": 18, "humidity": 6

半截 JSON 比没有更糟——模型可能把残缺结构当成有效数据,产生错误的参数理解。

正确的做法应该是:

  1. 先判断 content-type,JSON 就走结构化抽取路径

  2. 只保留白名单字段(温度、湿度、风力)

  3. 非结构化文本才退回长度截断

这个改进的成本很低(几十行代码),收益是明显的——它是四层里性价比最高、但最容易被漏掉的一层。


3. 第二层:滑动窗口(按时间,砍旧)

3.1 本项目的真实窗口

func (m *ShortTerm) Add(role, content string) {
    max := m.MaxTurns * 2          // 每轮 user + assistant
    if len(m.Messages) > max {
        m.Messages = m.Messages[len(m.Messages)-max:]
    }
}

配置 short_term_max_turns: 5 → 窗口 10 条消息(5 轮)。

注意:这里没有"保留最近 3 轮 + 前面生成摘要"的分层。

它是纯粹的一刀切:第 11 条进来,第 1 条直接消失,没有任何过渡。

3.2 硬切掉的到底是什么

不是"冗余的旧消息",而是:

  • 用户在第 1 轮说的名字、身份、约束

  • 第 2 轮确认过的方案细节

  • 前面建立的所有指代关系

结果是一种很典型的失败模式:失忆式追问。

用户:就按刚才那个方案改。 模型:请问您指的是哪个方案?

不是模型笨,是"刚才那个方案"的指代对象已经被窗口切走了。

3.3 如果要做摘要,三种粒度

粒度

做法

成本

效果

整段摘要

把被切掉的 N 轮压成一句话

1 次 LLM 调用

保留大意,丢细节

关键信息抽取

只抽"实体 / 决策 / 未决事项"

1 次 LLM 调用

结构化,可长期保存

指代消解

写入历史时就把"刚才那个方案"替换成具体名称

0 额外调用

最省,但需要在写入侧改造

第三种是最划算的——它不压缩,而是让内容变得"可截断"。指代一旦被消解,即使这条消息被切走,剩下的消息也能自解释。

这也是一个通用原则:最好的压缩,是让不需要压缩。


4. 第三层:预算强制截断(按优先级,保命)

4.1 真实算法:整条丢弃,不是截半条

这是你稿子里最容易讲错的地方。

你写的是:

"第 3 条资料超长了,写代码直接把第 3 条资料的最后 200 个字用 ... 截断"

代码里不是这样。 真实实现是整条丢弃:

func trimByBudget(items []ContextItem, budget int) []ContextItem {
    total := 0
    for i, item := range items {
        total += len(item.Text)
        if total > budget {
            return items[:i]     // 丢弃剩余整条,不截断单条文本
        }
    }
    return items
}

全局裁剪同样如此:

for _, idx := range order {          // 按 slotPriority 降序
    if total <= a.globalLimit { break }
    fs := &rc.Filled[idx]
    for len(fs.Items) > 0 && total > a.globalLimit {
        last := fs.Items[len(fs.Items)-1]
        total -= len(last.Text)
        fs.Items = fs.Items[:len(fs.Items)-1]
    }
}

4.2 为什么整条丢,而不是截半条

三个理由:

① 半条记忆比没有更危险

一条记忆被截成半句:"用户对花生……",模型可能补全成"用户对花生无过敏"。

截断产生的是"看似完整的错误信息",而丢弃产生的是"明确的缺失"。 后者模型知道没有,前者模型会自己编。

② 可预测

整条丢弃是确定性的:同样的输入,永远丢同样的条目。截半条还要处理"从哪里截"、"句号在哪"、"截断标记怎么加"。

③ 语义完整性

一条记忆是一个完整的语义单元。切开之后它就不是一个事实了。

4.3 一个必须知道的单位坑

total += len(item.Text)              // len() 是字节数

Go 的 len(string) 统计的是字节。中文一个字 3 字节,所以:

  • 全局 2400 预算 ≈ 800 个汉字

  • 单个槽位 350 预算 ≈ 116 个汉字

而同一项目里 truncateStr 用的是 rune(字符),两套单位并存。

这是真实存在的偏差,面试时主动说,是"我读过实现"的最强证据。


5. 第四层:LLM 语义摘要(按语义,保质量)

5.1 本项目没有这一层

全库搜索 Summarize / Compress / 滚动摘要,没有任何上下文压缩实现。命中的 Summary 全是业务字段(文档摘要、工具结果摘要)。

5.2 为什么没做

三个理由,和"记忆合并不用 LLM"是同一套逻辑:

① 热路径成本

每轮对话都要拼上下文。摘要等于把一次请求变成两次,首 token 延迟翻倍。

② 篡改不可逆

把"用户对花生过敏,严重时需注射肾上腺素"压成"用户有过敏史",剂量信息永久丢失。摘要是有损且不可逆的。

③ 不可测试

同样输入,摘要结果每次都不同。没法写回归断言。

5.3 什么时候才真的值得做

不是"所有长内容都摘要",而是满足下面全部条件时:

条件

说明

内容确实超预算

不超就别摘要,白花钱

内容必须保留

不能像 Recall 那样整条丢掉

内容是可叙述的

状态/参数类不能摘要,会失真

对延迟不敏感

比如离线任务、文档预处理

5.4 提取式 vs 生成式

这是第四层最关键的技术选择:

方式

做法

优点

缺点

生成式

让模型"重新写一段 200 字的摘要"

流畅、可读

会引入幻觉、改写事实

提取式

让模型"从原文里挑出最相关的句子"

不改写、可溯源

可能不够连贯

对 Agent 上下文,我建议提取式。

理由:上下文的作用是给模型提供事实依据,不是给人阅读。宁可保留原始措辞(哪怕句子略有跳跃),也不要让中间环节改写事实。

如果一定要生成式,必须做两件事:

  1. 摘要里保留原始出处(哪条记忆、哪个 chunk)

  2. 摘要和原文一起存,出问题能回查


6. 一个被忽略的真相:压缩发生在写入阶段

这是这个项目最有价值、也最容易被讲漏的一点。

它没有在读取时做语义压缩,而是把压缩前移到了写入时。

对比两种架构:

架构

压缩时机

压缩对象

代价

运行时摘要

每次请求

对话原文

每轮一次 LLM 调用,延迟叠加

预压缩(本项目)

写入时一次

蒸馏成事实短句

丢失原文,不可还原

用户说的一段 50 字的话,在写入阶段就被抽成:

用户公司: 字节跳动
用户岗位: 后端工程师

之后每次召回,拿到的都是这 10 个字左右的事实,而不是 50 字原文。

这就是为什么它不需要第四层——原文档已经在写入时"脱水"过一次了。

但代价也很明确:

  • 丢失语气和语境:"我其实不太想去字节,但没办法" → 只抽出"用户在字节工作"

  • 丢失未完成意图:"我还在考虑要不要换工作" → 可能抽不出来,因为不是确定事实

  • 不可还原:原文进了 chat_history,但召回不走那条路

所以这套架构的压缩比很高,但"事实准确性"和"上下文连续性"是两个不同的东西——它保证了前者,牺牲了后者。

面试时如果能把这一点讲出来,说明你不是在背方案,而是真的理解了架构。


7. 四层怎么选:一张决策表

场景

该用哪层

检索结果大量重复

① 哈希去重(先归一化)

检索结果条数过多

① Top-K + MinScore

工具返回长 JSON

① 结构化抽取(本项目缺)

对话轮数多

② 滑动窗口(+ 指代消解)

仍超预算

③ 按优先级整条丢弃

关键长文档不能丢

④ 提取式摘要(加出处)

有离线预算

④ 在写入/入库阶段预压缩

顺序原则:

能用规则不用模型,能用丢弃不用改写,能用预压缩不用运行时压缩。


8. 面试口径

"上下文压缩我分四层看:去重截断、滑动窗口、预算强制截断、LLM 摘要。我们实现了前三层的一部分,第四层没做。

第一层,检索结果侧没有做哈希去重,因为 RRF 融合本身会以 content 为键聚合,重复内容会累加分数而不是产生两条。Top-K 有,而且加了 MinScore,避免凑数式召回。工具结果只有 120 字节截断,没有结构化抽取——这是个缺口,半截 JSON 比没有更危险。

第二层滑动窗口是 10 条消息硬切,没有摘要。代价是指代失效,用户说'按刚才那个方案'模型就不知道指什么。要改的话,最划算的不是摘要,是写入时做指代消解。

第三层是全局 2400 预算,按槽位优先级从低到高整条丢弃,不截半条——因为半条记忆会让模型脑补成错误事实。

第四层 LLM 摘要我们没有,理由是热路径成本、改写不可逆、不可回归测试。但更重要的原因是我们的压缩前移到了写入阶段:对话在入库时就被蒸馏成事实短句,召回拿到的本来就是压缩后的结果。

代价是丢了语气、语境和未完成的意图。所以这套架构保证了事实准确,牺牲了对话连续性,这是明确的取舍。"


9. 结语

上下文压缩有四层,但真正拉开工程水平差距的不是层数,而是三个判断:

  1. 知道哪些压缩该在写入时做(预压缩),哪些必须在读取时做

  2. 知道"截断"和"丢弃"不是一回事 —— 半条信息比没有更危险

  3. 知道自己的系统停在了第几层,以及为什么停在那里

能把"我没做第四层"和"为什么不做第四层"讲得比"我做了第四层"更清楚,才是真的懂了。

Logo

更多推荐