摘要: Memory 能保存多轮聊天,但历史消息不可能无限塞进模型上下文。本文从最简单的
slice(-N)开始,逐步升级到 Token 截断,并从二分查找的角度理解:如何在有限 Token 预算下,找到应该保留的最近上下文。
上一篇我们解决了一个问题:
聊天历史不能只放在内存里
↓
否则程序一重启
↓
Memory 就没了
把聊天记录持久化以后,即使程序重新启动,我们依然可以恢复之前的对话。
但问题也随之出现。
假设一个用户一直和 AI 聊:
erlang
message 1
message 2
message 3
...
message 100
...
message 1000
如果每次调用模型,都直接:
ini
const messages = await history.getMessages();
await model.invoke(messages);
就意味着随着聊天越来越久,我们每次都要把越来越多的历史重新发送给模型。
显然不能一直这样下去。
所以 Memory 不只是解决:
历史消息怎么保存?
还必须继续解决:
历史消息太多以后,这一轮到底应该把哪些消息发送给模型?
这就进入上下文管理的第一个手段:
截断。
一、最简单的截断:只保留最近几条消息
先来看一种最直接的方案。
假设聊天记录一共有 8 条:
go
const messages = [
{ type: 'human', content: '我叫李四' },
{
type: 'ai',
content: '你好李四,很高兴认识你!'
},
{
type: 'human',
content: '我是一名设计师'
},
{
type: 'ai',
content: '设计师是个很有创造力的职业!你主要做什么类型的设计?'
},
{
type: 'human',
content: '我喜欢艺术和音乐'
},
{
type: 'ai',
content: '艺术和音乐都是很好的爱好,它们能激发创作灵感。'
},
{
type: 'human',
content: '我擅长 UI/UX 设计'
},
{
type: 'ai',
content: 'UI/UX 设计非常重要,好的用户体验能让产品更成功!'
},
];
我们规定:
ini
const maxMessages = 4;
调用模型之前,先获取所有历史:
ini
const allMessages =
await history.getMessages();
然后:
ini
const trimmedMessages =
allMessages.slice(-maxMessages);
也就是:
ini
allMessages.slice(-4);
最终只留下最近四条消息。
二、slice(-4) 到底做了什么?
先看一个简单数组:
ini
const arr = [1, 2, 3, 4, 5];
console.log(arr.slice(-2));
结果:
csharp
[4, 5]
所以:
scss
slice(-N)
可以理解成:
从数组末尾开始,取最近 N 个元素。
放到聊天记录里:
1 Human:我叫李四
2 AI:你好李四
3 Human:我是一名设计师
4 AI:设计师是个很有创造力的职业......
5 Human:我喜欢艺术和音乐
6 AI:艺术和音乐都是很好的爱好......
7 Human:我擅长 UI/UX 设计
8 AI:UI/UX 设计非常重要......
执行:
ini
allMessages.slice(-4);
得到:
5 Human:我喜欢艺术和音乐
6 AI:艺术和音乐都是很好的爱好......
7 Human:我擅长 UI/UX 设计
8 AI:UI/UX 设计非常重要......
最早的四条被丢掉。
最新的四条被保留。
三、为什么通常优先保留最近的消息?
因为聊天天然具有很强的上下文连续性。
例如:
用户:
useEffect 是干什么的?
AI:
useEffect 用来处理组件中的副作用......
用户:
那第二个参数呢?
这里:
那第二个参数呢?
如果脱离上一轮,很难知道这个"第二个参数"指什么。
再比如:
csharp
用户:
帮我看看这段代码。
AI:
第三行这里有一个问题......
用户:
为什么这里不能直接写 await?
后一轮通常依赖前几轮。
所以当上下文空间有限时,一个很自然的策略就是:
优先保留最近的消息
↓
优先删除最老的消息
这其实就是一个不断向前移动的窗口:
csharp
[1 2 3 4]
[2 3 4 5]
[3 4 5 6]
[4 5 6 7]
也就是常说的:
sql
Sliding Window
滑动窗口。
四、但"最多保留 4 条"真的合理吗?
现在问题来了。
我们规定:
ini
const maxMessages = 4;
看起来控制住了上下文长度。
但:
4 条消息到底有多长?
比如下面也是四条:
用户:你好
AI:你好,有什么可以帮你的?
用户:React 是什么?
AI:React 是一个用于构建用户界面的 JavaScript 库。
它们非常短。
但下面同样也是四条:
yaml
用户:
这里有一份 500 行的项目代码,请帮我分析......
AI:
下面是完整代码分析......
用户:
这是项目运行时输出的 3000 字错误日志......
AI:
我逐段分析一下......
还是:
4 条消息
但两组消息占用的上下文空间完全不是一个量级。
所以:
消息数量
实际上只是一个非常粗略的指标。
真正限制大模型上下文的,并不是:
你发了多少条消息
而是:
这些内容最终占了多少 Token
于是截断策略就需要继续升级。
五、从"限制消息数量"升级成"限制 Token 数量"
之前的思路是:
ini
const maxMessages = 4;
目标:
最多保留 4 条
现在我们改成:
ini
const maxTokens = 100;
目标变成:
不管最终留下多少条消息,只要这些消息总 Token 数不超过 100。
这两种限制方式有本质区别。
比如消息都很短:
erlang
message1 → 5 tokens
message2 → 6 tokens
message3 → 7 tokens
message4 → 8 tokens
message5 → 6 tokens
message6 → 9 tokens
...
那么 100 Token 可能可以留下十几条消息。
但如果每条消息都很长:
message1 → 40 tokens
message2 → 35 tokens
message3 → 50 tokens
可能只能留下两条。
所以 Token 截断真正关注的是:
上下文大小
而不是:
消息条数
六、第一步:先计算消息到底占多少 Token
为了根据 Token 截断,首先必须解决一个问题:
这些消息到底有多少 Token?
这里使用:
javascript
import { getEncoding }
from 'js-tiktoken';
先拿到一个编码器:
ini
const enc =
getEncoding("cl100k_base");
然后自己写一个 Token 统计函数:
ini
function countTokens(messages, encoder) {
let total = 0;
for (const msg of messages) {
const content =
typeof msg.content === 'string'
? msg.content
: JSON.stringify(msg.content);
total +=
encoder.encode(content).length;
}
return total;
}
这段代码最重要的是:
css
encoder.encode(content).length
七、encoder.encode() 在做什么?
大模型处理文本时,并不是简单按照:
一个汉字 = 一个单位
一个英文单词 = 一个单位
这样的方式处理。
文本首先需要经过 Tokenizer。
比如:
arduino
encoder.encode("我是一名设计师");
会得到一组 Token 对应的编号:
css
[ ...tokenIds]
具体编号是什么并不重要。
我们现在真正关心的是:
matlab
.length
因为:
css
encoder.encode(content).length
就可以得到:
当前这段内容被编码成了多少个 Token。
然后不断:
makefile
total += ...
把所有消息的 Token 加起来:
diff
message1
+
message2
+
message3
+
...
最终:
scss
countTokens(messages, enc)
得到整个消息数组大概占多少 Token。
八、开始使用 trimMessages
有了 Token 计算方式以后,就可以开始真正按照 Token 截断。
LangChain 提供了:
trimMessages
我们的代码是:
php
const trimmedMessages =
await trimMessages(allMessages, {
maxTokens: maxTokens,
tokenCounter: async (msgs) =>
countTokens(msgs, enc),
strategy: "latest"
});
先不要被配置项吓到。
其实核心就三个:
maxTokens
tokenCounter
strategy
分别解决三个问题。
九、maxTokens:最多允许使用多少 Token
首先:
makefile
maxTokens: 100
它规定了:
上下文 Token 预算
=
100
假设当前历史总共:
240 tokens
当然不能全部留下。
所以必须:
删除一部分历史
↓
重新计算
↓
直到 <= 100
最终:
trimmedMessages
必须控制在这个 Token 预算内。
十、tokenCounter:怎么知道有没有超过?
但是:
trimMessages
怎么知道:
这批 messages
到底是 80 tokens
还是 180 tokens?
所以我们需要告诉它:
tokenCounter
也就是:
Token 应该怎么计算。
这里传进去:
vbnet
tokenCounter: async (msgs) =>
countTokens(msgs, enc)
整个调用关系可以理解成:
markdown
trimMessages
↓
"这批消息多大?"
↓
tokenCounter(msgs)
↓
countTokens(msgs, enc)
↓
encoder.encode()
↓
得到 Token 数
所以:
maxTokens
负责规定上限。
而:
tokenCounter
负责计算当前值。
十一、strategy: "latest":优先保留最近的消息
现在还剩最后一个问题:
超过 Token 上限以后,应该删除哪部分消息?
我们想保留的是:
最近上下文
所以:
vbnet
strategy: "latest"
表达的就是当前 Demo 的这个意图:
优先保留最近消息
↓
最老的消息优先被截断
例如:
message1
message2
message3
message4
message5
message6
如果空间不够:
message1 先被舍弃
然后 message2
然后......
尽量留下:
message4
message5
message6
所以三个配置放在一起:
maxTokens
→ 最多允许多大
tokenCounter
→ 怎么计算消息大小
strategy
→ 超出以后优先保留哪一侧
整个 Token 截断就清楚了。
十二、Token 截断真正要解决的是什么问题?
假设现在有 6 条历史消息。
从最近开始保留:
最近 1 条
→ 18 tokens
最近 2 条
→ 37 tokens
最近 3 条
→ 58 tokens
最近 4 条
→ 82 tokens
最近 5 条
→ 108 tokens
最近 6 条
→ 130 tokens
规定:
ini
const maxTokens = 100;
我们的目标是什么?
不是:
找到一组随便不超过 100 的消息
而是:
在不超过 100 Token 的前提下,尽可能多保留最近的历史消息。
所以真正要找的是:
最大的 k
满足:
最近 k 条消息的 Token 总数
<=
maxTokens
套进刚才的数据:
最近 1 条 → 18 ✅
最近 2 条 → 37 ✅
最近 3 条 → 58 ✅
最近 4 条 → 82 ✅
最近 5 条 → 108 ❌
最近 6 条 → 130 ❌
所以:
ini
k = 4
最终保留最近四条。
到这里,这个问题已经非常像一道算法题了。
十三、为什么这里可以想到二分查找?
看刚才的数据:
18
37
58
82
108
130
随着:
保留的消息越来越多
Token 总数也会越来越大。
于是判断结果形成:
✅ ✅ ✅ ✅ ❌ ❌
也就是说存在一个非常明显的:
边界
边界左侧:
token <= maxTokens
边界右侧:
token > maxTokens
而这就是二分查找非常典型的应用场景:
在具有单调性的范围中寻找边界。
所以 Token 截断这个问题,可以抽象成:
markdown
0 条消息
1 条消息
2 条消息
3 条消息
4 条消息
5 条消息
6 条消息
↓
判断最近 k 条消息
是否超过 maxTokens
↓
✅ ✅ ✅ ✅ ✅ ❌ ❌
↑
找这个边界
我们最终要找的是:
最后一个合法位置。
十四、二分查找的不是 Token,而是"保留多少条"
这里容易理解错。
二分并不是去 Token 数组里面:
查找某个 Token
它真正二分的是:
保留消息的数量
假设一共有:
100 条消息
那么答案一定在:
0 ~ 100
之间。
我们可以先尝试:
最近 50 条
计算 Token。
如果:
ini
最近 50 条
=
3000 tokens
maxTokens
=
4000
说明:
50 条可以放下
但是我们还想尽量多保留一些。
所以应该:
往更大的范围继续找
比如:
75 条
如果:
yaml
75 条
=
4600 tokens
超过:
yaml
4000
说明:
75 太多
那么答案就缩小到了:
50 ~ 75
继续二分:
62
↓
56
↓
59
......
最终找到:
最大的合法 k
这就是典型的:
二分答案。
十五、为什么不直接一条一条试?
当然也可以。
例如:
最近 1 条
↓
最近 2 条
↓
最近 3 条
↓
最近 4 条
↓
......
每增加一条,就重新判断:
有没有超过 maxTokens
直到:
第一次超过
然后返回前一个结果。
这种方式很好理解。
但如果历史消息很多:
yaml
1000 条
10000 条
就可能需要尝试很多次。
尤其这里每一次判断都不是简单的:
matlab
arr.length
而需要执行:
scss
tokenCounter(messages)
进一步进行:
css
encoder.encode(content)
也就是说:
判断一次"能不能放下"本身就存在 Token 计算成本。
所以如果已经知道:
保留消息越多
↓
Token 越多
具有单调性,那么自然可以想到:
二分查找
不断把范围砍掉一半:
yaml
1000
↓
500
↓
250
↓
125
↓
62
↓
......
而不是:
1
2
3
4
5
6
......
一个一个试。
十六、但不要简单记成"Token 截断 = O(log n)"
看到二分以后,很容易直接写:
scss
时间复杂度 O(log n)
但这并不严谨。
因为:
scss
O(log n)
描述的是:
寻找截断边界需要尝试多少次。
但每次尝试过程中,我们还要执行:
scss
countTokens(messages, enc)
而这个函数本身需要:
arduino
for (const msg of messages)
遍历消息,并执行:
css
encoder.encode(content)
所以整个 Token 截断的实际成本,还与:
消息数量
消息长度
Tokenizer 编码成本
Token Counter 的实现
是否存在缓存
有关。
因此更准确地说:
"寻找最大合法截断位置"这个子问题具有单调性,因此可以使用二分查找降低边界尝试次数。
这比简单说:
scss
Token 截断复杂度就是 O(log n)
要准确得多。
十七、回到 trimMessages
理解这些以后,再来看:
php
const trimmedMessages =
await trimMessages(allMessages, {
maxTokens: maxTokens,
tokenCounter: async (msgs) =>
countTokens(msgs, enc),
strategy: "latest"
});
就不应该只把它当成:
LangChain 的一个 API
而是应该看到背后的问题:
markdown
allMessages
↓
优先保留最近的历史
↓
计算当前 Token
↓
和 maxTokens 比较
↓
确定截断边界
↓
得到 trimmedMessages
所以这几个参数其实非常有逻辑:
maxTokens
↓
告诉程序:
"最多给你这么大的空间"
tokenCounter
↓
告诉程序:
"你应该这样测量消息大小"
strategy
↓
告诉程序:
"空间不足时优先保留哪部分消息"
trimMessages 只是把这些工作封装起来,我们不用每次自己重新实现整个截断过程。
至于 LangChain 不同版本内部具体使用什么方式寻找这个截断位置,属于库自身的实现细节。
这里更值得掌握的是:
Token 截断天然是一个带容量限制、具有单调边界的问题,因此可以用二分查找的思想理解截断位置的寻找过程。
十八、为什么 Token 截断比 slice(-4) 更有开销?
现在再回头看两种方案。
第一种:
ini
allMessages.slice(-4);
它只需要知道:
数组有多长
然后直接截取最后几个元素。
根本不关心:
"我叫李四"
到底是多少 Token。
但是 Token 截断需要:
css
读取消息
↓
获取 content
↓
Tokenizer 编码
↓
计算 Token
↓
和 maxTokens 比较
↓
寻找截断位置
↓
返回最终消息
所以显然:
消息数量截断
更简单。
而:
Token 截断
计算成本更高。
但 Token 截断换来的好处也很明显:
对上下文大小的控制更加准确。
十九、消息数量截断和 Token 截断怎么选?
现在把两种方案放在一起看。
按消息数量
代码:
ini
const trimmedMessages =
allMessages.slice(-4);
特点:
简单
快速
容易实现
但问题是:
4 条消息可能很短
也可能非常长
所以只能粗略控制上下文。
按 Token 数量
代码:
php
const trimmedMessages =
await trimMessages(allMessages, {
maxTokens: 100,
tokenCounter: async (msgs) =>
countTokens(msgs, enc),
strategy: "latest"
});
特点:
真正按 Token Budget 控制
所以:
消息比较短
→ 可以留下更多
消息比较长
→ 自动留下更少
最终目标始终是:
totalTokens <= maxTokens
因此更接近真实的大模型上下文管理。
二十、还有一个很重要的问题:截断并没有删除 Memory
这里特别容易误会。
执行:
ini
const allMessages =
await history.getMessages();
const trimmedMessages =
await trimMessages(allMessages, ...);
得到:
trimmedMessages
并不意味着:
History 里的旧聊天记录
真的被删除了
我们只是:
在调用模型之前,从完整历史中选出一部分。
整个关系其实是:
scss
History
│
│ 保存完整聊天历史
│
↓
getMessages()
│
↓
allMessages
│
│ 上下文管理
↓
trimMessages()
│
↓
trimmedMessages
│
↓
model.invoke()
所以:
保存多少
和:
这次发送多少
是两个完全不同的问题。
二十一、Memory 和 Context 不是一回事
这个区别非常重要。
假设数据库里已经存了:
yaml
1000 条聊天记录
我们完全可以继续保存。
但当前调用模型时,只发送:
yaml
最近 3000 Tokens
也就是说:
Memory
可以很大。
但:
Context
必须受到控制。
所以:
Memory Storage
≠
Model Context
一个负责:
历史不要丢
另一个负责:
这一轮模型到底需要看什么
这也是为什么真实的 Memory 系统一定需要:
Memory Management
而不能简单地:
把数据库里所有历史
全部发送给 LLM
二十二、但截断会带来新的问题
现在看起来问题解决了:
完整聊天历史
↓
一直保存
调用模型
↓
只保留最近 N Tokens
模型上下文不会无限增长。
但是想一个场景。
用户在很早以前说过:
以后给我的代码示例都使用 JavaScript。
然后双方已经聊了几十轮。
这条消息已经被:
Token 截断
移出了当前上下文。
现在用户问:
给我写一下快速排序。
模型还能知道:
应该使用 JavaScript
吗?
不知道。
因为:
这条旧消息虽然还存在 History 中
但:
这一次没有被发送给模型
于是:
截断
虽然解决了:
上下文越来越大
却带来了另一个问题:
重要的旧信息可能被一起截掉
所以,单靠截断还不够。
二十三、Memory Management 不只有截断
这时候我们再回头看整个 Memory 管理问题,就会发现至少有三种非常典型的思路:
markdown
Memory Management
┌────────────┼────────────┐
↓ ↓ ↓
截断 总结 检索
保留最近内容 压缩历史信息 需要时再找回来
截断
历史太长
↓
直接舍弃一部分旧消息
优点:
简单
问题:
旧信息直接消失在当前上下文
总结
把很早以前的:
几十条消息
压缩成:
一段摘要
例如:
用户是一名设计师,擅长 UI/UX,
喜欢艺术和音乐,
代码示例偏好 JavaScript。
这样历史信息仍然存在,但占用的 Token 更少。
检索
甚至可以不把所有长期 Memory 都放在 Context 里。
而是:
当前用户提问
↓
去长期 Memory 中搜索
↓
找出和当前问题相关的旧信息
↓
只把相关内容加入 Context
例如用户问:
继续帮我完善之前的 React 项目
系统才去找:
之前关于 React 项目的聊天记忆
而不是把过去几个月的所有聊天记录全部塞进去。
二十四、最后重新看整个截断过程
从最开始:
ini
allMessages.slice(-4);
到后面的:
css
trimMessages(allMessages, {
maxTokens,
tokenCounter,
strategy: "latest"
});
其实完成的是一次非常自然的升级:
scss
聊天历史越来越长
↓
不能全部进入 Context
↓
最简单:
限制消息数量
↓
slice(-N)
↓
发现消息长度不同
↓
消息数量不能准确表示上下文大小
↓
改成限制 Token
↓
计算 Token
↓
设置 maxTokens
↓
优先保留最近消息
↓
寻找最大合法截断边界
↓
得到 trimmedMessages
而这里值得记住的算法思想就是:
当"保留消息数量"与"Token 总量"之间具有单调关系时,可以把截断问题转化为一个二分边界问题:寻找最大的
k,使最近k条消息的 Token 总数不超过maxTokens。
最终:
History
负责保存完整历史。
而:
trimMessages
这一类上下文管理手段负责决定:
这一轮到底让模型看到多少历史。
这两个问题分开以后,Memory 的整体结构就开始真正清晰起来了。