聊天记录越来越长怎么办?从消息数量截断到 Token 截断

摘要: 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 的整体结构就开始真正清晰起来了。

相关推荐
冬奇Lab13 分钟前
开源项目第204期:LoopX — 长周期 Agent 控制平面,跑在 Codex/Claude Code 之上的状态管理层
人工智能·开源
ZGIAI40 分钟前
旧模型下线前,客服 Agent 怎么迁移
人工智能·架构
东风破_1 小时前
程序重启后,AI 为什么把你忘了?从 InMemory 到持久化 Memory
人工智能
ZGIAI1 小时前
客服知识库更新后,怎么批量验收
人工智能·架构
Asize2 小时前
AI 协作开发新范式:我用 SDD 做了个排版 npm 包
前端·人工智能
IT_陈寒4 小时前
用了Proxy才发现以前的JavaScript白写了
前端·人工智能·后端
甲维斯4 小时前
Claude Opus5手搓“NewAPI Plus”首轮成果!
人工智能
程序猿DD4 小时前
分享两个我每天都在用的 Skill,拖进豆包就能跑,限时领 30 天会员
人工智能
阿里云大数据AI技术5 小时前
Agentic Search 2.0:从单轮对话迈向企业级 AI 搜索自动驾驶Agent
人工智能·elasticsearch·agent