为 Agent 管好一张"上下文预算":从数条数到数 token
写 Agent 的人迟早会遇到这么一件事:
模型开始"忘事"了------明明第一轮告诉过它的事,第五轮它就当没发生。于是你往 messages 数组里拼命塞历史,模型又突然报"上下文超长"。
我先给结论:大模型是无状态的,它"记得"的所有事情,都只是你每次脉塞进 messages 数组里的那些文字。 而这张 messages 有大小上限,还按 token 收费。所以每一轮对话,我们手里就一张"上下文预算",怎么花,决定了 Agent 会不会失忆、会不会爆窗。
这篇不扯理论,我用一个真实的 LangChain + js-tiktoken 小项目,讲清楚一个核心概念------截断(truncation),本质上是在 token 预算内做取舍。
一个概念:你要截断的不是"消息",是"预算"
新手最容易的直觉是:上下文太长 → 删掉最早的几条 → 完事。
js
// ❌ 拍脑袋型:按条数删
const trimmed = allMessages.slice(-4);
这看着很美,但它有一个致命盲区:一条消息和一条消息,token 的量差得远。 一句"好的"是 2 个 token,一篇"红烧肉做法"是 900 个 token。你敢保证删除"最早的 4 条"之后,剩下的就一定不超窗口?
所以正确的问题不是"还剩几条",而是:还剩多少 token,这些 token 是不是最新、最该被模型看到的内容。
"上下文预算"拆开就两半:
text
预算 = 上下文窗口上限 − 模型输出要预留的空间
└──────── 能用来塞历史的部分 ────────┘
我们管理的,是后面那一格:花得越聪明,Agent 记性越好、越不会超窗。
起点:消息数量截断(先能跑,再谈优化)
最容易上手的一版,就是简单 slice,只保最靠后的 n 条:
js
import { InMemoryChatMessageHistory } from '@langchain/core/chat_history';
import { HumanMessage, AIMessage } from '@langchain/core/messages';
const history = new InMemoryChatMessageHistory();
const maxMessage = 4;
// 把 8 条消息塞进 history
for (const msg of messages) {
msg.type === 'human'
? await history.addMessage(new HumanMessage(msg.content))
: await history.addMessage(new AIMessage(msg.content));
}
const allMessages = await history.getMessages();
const trimmed = allMessages.slice(-maxMessage);
console.log(`保留 ${trimmed.length} 条`);
跑出来保留 4 条,思路清晰,也够演示。但它默认"每条消息一个 token 权重",这恰恰是最危险的地方------消息长短不一,按条数截断和按预算截断是两回事。
转折:token 数量截断(上精度)
要真正不超预算,必须先知道"这条消息到底占多少 token"。
用 js-tiktoken 拿到 OpenAI 的 cl100k_base 编码器,就能把任何一段文字换算成真实的 token 数:
js
import { getEncoding } from 'js-tiktoken';
const enc = getEncoding('cl100k_base'); // 不同模型编码器不同
function countTokens(messages, encoding) {
return messages.reduce((total, msg) => {
const content = typeof msg === 'string' ? msg.content : JSON.stringify(msg.content);
return total + encoding.encode(content).length;
}, 0);
}
有了这把"秤",再交给 LangChain 的 trimMessages 去自动找那个"刚好不超过 maxTokens 的最大消息集合":
js
import { trimMessages } from '@langchain/core/messages';
const trimmed = await trimMessages(allMessages, {
maxTokens: 100, // 预算上限
tokenCounter: async (msgs) => countTokens(msgs, enc), // 我用 tiktoken 精确计
strategy: 'last', // 保留最新,裁掉最旧
});
体会一下这两版的心智差异:
| 消息数量截断 | token 数量截断 | |
|---|---|---|
| 判断依据 | 条数 | token 预算 |
| 是否知道超不超窗 | 不一定 | 精确 |
| 保留的 | 最近 n 条 | 不超过预算的最近一段 |
| 成本 | 几乎为零(slice) | 需要编码器,略贵 |
麻烦一点,但这是一次从"感觉够"到"算得准"的升级。
一个反直觉的点:中文字符 ≠ token
这一节我想单独拎出来,因为真做中文 Agent 的迟早会踩。
很多人会拿"字符数"当 token 数来估。但 token 不是按字符切的:
text
"hello" ≈ 1 个 token
"我就是李四" ≈ 6~7 个 token(中文往往一个字≈1~2 token)
同一句话,用字符数估和用 token 数估,结果完全不同。所以我上面坚决引入 tiktoken 而不是 content.length------估算的误差,会在长对话里累积成爆窗或白掏的钱。
总结下来,Agent 的 Memory 无外乎三种手段:截断、总结、检索。这一篇只挑了截断------它是零依赖者的第一课:在有限的上下文预算里,先学会正确地"放掉"。