LLM 长会话的上下文压缩——别让历史把 token 吃光

上一篇说三段式 insight 解决的是”打完一局怎么沉淀经验”。这篇聊一个更要命的事——一局还没打完,历史就已经撑爆了

游戏里一局训练动辄三四十回合,每回合都有局面快照、可选动作、模型决策、最后结果。要是把这一坨全堆进 message 数组里给 LLM 看,到第十回合左右上下文就开始告急;到第二十回合,要么自动截断丢前面(关键设定一起飞了),要么直接报 context length exceeded。

最早的版本就是这么炸的。

朴素方案为什么都不行

先说几个我试过、然后默默删掉的方案。

方案一:滑动窗口,只保留最近 N 轮

简单粗暴。问题也明显——开局阶段的人物设定、卡组构成、战术意图,全是后面决策的根基,丢了之后模型就开始”失忆型抽风”。第 15 回合突然忘了自己是哪个角色,出牌逻辑直接崩。

方案二:直接 summarize 全部历史

每隔 N 回合让 LLM 把前面的对话总结成一段话。听起来很美。

实际跑起来——总结质量飘忽。运气好的时候压缩比 5:1,运气差的时候把关键数值(“体力还剩多少”“场上 buff 是什么”)当背景细节给丢了。下一回合模型一看,“咦我的体力是多少来着”,开始瞎猜。

方案三:让模型自己决定记什么

试过,问题是模型太”贪心”,让它自己删它一条都舍不得删。

最后的做法:分层压缩

后来落地的方案,本质是把会话分层

1
2
3
4
5
6
7
┌─────────────────────────────┐
│ 永久层:人物 + 卡组 + 目标 │ ← 整局不变,开头注入
├─────────────────────────────┤
│ 压缩层:早期回合的结构化摘要 │ ← 由 compactor 定期生成
├─────────────────────────────┤
│ 最近层:最近 K 回合原始对话 │ ← 完整保留,K 大概 3~5
└─────────────────────────────┘

对应代码就两块:

1
2
3
4
gameplay/llm/
├── session_state.py # 维护三层结构
└── prompts/
└── system_session_memory_compactor.j2 # 干压缩活的 prompt

每一层职责清清楚楚。永久层只在开局写一次,整局只读不改;最近层是滑动窗口;中间的压缩层是关键——由 compactor 负责,每隔若干回合把最早的几轮”挤”成结构化摘要。

Compactor 的 prompt 长什么样

这块的 prompt 设计是反复改了七八版才稳的。最早写得很自由:

请总结以下回合的关键信息……

模型回你一段散文。下一次再总结的时候格式又变了,下游解析根本接不住。

最终版本走的是强结构化输出,让模型填表,不让它自由发挥:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
turns: 5-9
state_changes:
- turn: 5
hp: 80 65
buffs_gained: [集中+2]
cards_consumed: [快攻 x1]
- turn: 7
score: 1200 1800
key_decisions:
- turn: 6
chose: 集中堆叠
why: 为第 10 回合留高倍率窗口
notable_events:
- turn: 8: 对手出了 trap,被预判

填表式输出有几个好处:

  1. 数值不会丢——你让它填 hp,它就得给数;总结成”体力下降不少”这种话直接不合格
  2. 可机器读——压缩层本身可以再被读取、再被组合
  3. token 占用可预测——表格结构每条大致占多少 token 是已知的

实测压缩比稳定在 8:1 到 10:1 之间,关键信息保留率(事后人工抽检)大概九成五以上。

触发时机:不能压太勤也不能压太晚

压缩这事儿有调用成本(每次都是一次 LLM 调用),所以不能每回合都压。但拖太晚,原始历史已经长到 compactor 自己都吃不下了。

最后定的策略很土,但有效:

  • 回合数触发:每过 5 个回合压一次
  • token 数兜底:估算当前 message 数组的 token 数,超过阈值的 60% 就强制触发,不管回合数走到没

兜底很重要——有些卡片效果触发会刷一堆系统提示,单回合就能塞进来几千 token,光按回合数计算根本拦不住。

那些没想到的坑

压缩层也会越攒越长

跑长局的时候发现——压缩层本身也在涨。第 30 回合的时候,压缩层里堆了五六段摘要,加起来比原始最近层还长。

后来加了二级压缩:压缩层里的摘要超过 N 段,就触发 compactor 把最早的几段再合并成一段更粗的摘要。粗摘要里只留”宏观走势”——分数曲线、体力曲线、关键转折点,具体数值就不要了。

层级长这样:

1
2
3
4
5
最近层(5 回合原始)

压缩层(每段 = 5 回合摘要,最多 N 段)

归档层(每段 = 多段压缩层的再压缩,只留趋势)

听起来像金字塔,实际就是不停做”更粗的总结”,让总 token 始终可控。

压缩失败要有兜底,不能让整局崩

LLM 调用偶尔会抽风——超时、返回格式不合规、返回内容明显错乱。压缩失败如果直接抛异常,整局训练流程都得停。

兜底很简单:压缩失败就退化成滑动窗口,直接丢掉最早那几个回合。会损失信息,但至少游戏能继续打。日志里把这次失败标红,事后排查 prompt 或者切换模型。

宁可信息有损,也别让流程断。

永久层不是”system prompt 一塞了事”

最早把人物设定、卡组之类的写进 system message,以为这就是”永久”了。结果发现某些 LLM 对 system message 里靠前的内容关注度不均匀,越长越容易”忘”开头。

后来改成每次发请求的时候,把永久层内容显式地、简短地重复一次,附在最近层之前。冗余了一点 token,但确保模型每一轮都”看得见”自己是谁、打的什么卡组。

回过头看

LLM 上下文管理这事,本质是带预算的存档问题——你有固定的 token 预算,要在里面塞下最重要的信息。和数据库的冷热分层、操作系统的缓存淘汰其实是一个套路:高频访问的全量保留,低频但重要的压缩归档,纯历史的可以糊掉甚至丢弃。

工程化的关键不是”用了多牛的 LLM”,而是把这套分层逻辑显式写出来——哪一层放什么、什么时候压缩、压缩失败怎么兜底,全得有明确规则。把这些规则交给模型自己决定,多半翻车。

下一篇说说更隐藏的事——既然 prompt 这么金贵,那一份 system prompt 走天下到底行不行?为什么我们最后拆成了 23 个?

欢迎关注我的其它发布渠道