我做多轮对话时最早的"记忆"实现,就是把所有消息放进数组,每次请求都完整传给模型。对话少时没有问题;轮次一多,token 开销持续上涨,旧内容开始挤占上下文窗口,真正相关的信息反而被淹没。
大模型本身是无状态的。所谓 Memory(记忆),本质上是应用替模型完成两件事:保存历史,以及决定本轮把哪些历史重新放进上下文。
我依次实现了三种策略:
- 截断:只保留最近消息;
- 总结:把较老对话压缩成摘要;
- 检索:把历史写入向量数据库,需要时按语义找回。
这三种方式不是互相替代,而是成本与能力逐级增加。
1. 消息条数截断:最便宜,也最粗糙
最直接的做法是保留最近 N 条消息:
js
import { InMemoryChatMessageHistory } from "@langchain/core/chat_history";
import { HumanMessage, AIMessage } from "@langchain/core/messages";
const history = new InMemoryChatMessageHistory();
await history.addMessage(new HumanMessage("我叫李四"));
await history.addMessage(new AIMessage("你好李四"));
// ...继续加入对话
const allMessages = await history.getMessages();
const recentMessages = allMessages.slice(-4);
实际运行时,8 条消息最后保留了 4 条。
它的优点很明显:不调用额外模型、不需要数据库、行为容易预测。问题也同样明显:消息长度差异很大。"保留 4 条"可能只有几十个 token,也可能包含一段很长的工具输出,仍然把窗口撑满。
因此,消息条数适合原型或消息长度比较稳定的场景,不适合作为严格的上下文预算。
2. 按 token 截断:从条数升级为预算
模型限制和计费都围绕 token,而不是消息条数。这里使用 js-tiktoken 计算内容长度,再交给 LangChain 的 trimMessages:
js
import { trimMessages } from "@langchain/core/messages";
import { getEncoding } from "js-tiktoken";
const encoder = getEncoding("cl100k_base");
function countTokens(messages) {
return messages.reduce((total, message) => {
const content =
typeof message.content === "string"
? message.content
: JSON.stringify(message.content);
return total + encoder.encode(content).length;
}, 0);
}
const trimmed = await trimMessages(allMessages, {
maxTokens: 100,
tokenCounter: async (messages) => countTokens(messages),
strategy: "last",
});
这个示例本地保留了 4 条消息,共计算出 78 token。数字只代表这组固定示例和所选编码器,我不会把它外推成任何性能结论。
按 token 截断比按条数准确,但仍有两个坑:
- 编码器必须与实际模型尽量匹配,否则预算只是近似;
- 直接从中间切断,可能丢掉 system message、tool call 与 tool result 的配对关系。
生产实现需要明确哪些消息必须保留,并保证工具消息结构合法。
3. 总结式记忆:让旧消息留下"压缩版本"
只截断会永久丢失旧信息。总结式记忆(Summarization Memory)会保留最近对话,把更老的部分交给模型压缩成摘要:
js
import {
HumanMessage,
SystemMessage,
getBufferString,
} from "@langchain/core/messages";
async function summarizeHistory(messages, model) {
if (messages.length === 0) return "";
const conversationText = getBufferString(messages, "用户", "助手");
const response = await model.invoke([
new HumanMessage(`
请总结以下对话,保留用户身份、偏好、承诺和未完成事项:
${conversationText}
`),
]);
return response.content;
}
const keepRecent = 2;
const recent = allMessages.slice(-keepRecent);
const oldMessages = allMessages.slice(0, -keepRecent);
const summary = await summarizeHistory(oldMessages, model);
await history.clear();
await history.addMessage(new SystemMessage(`历史摘要:${summary}`));
for (const message of recent) {
await history.addMessage(message);
}
方案 A 是简单截断,成本为零但信息直接消失;方案 B 是总结,增加一次模型调用,换来更长时间跨度的连续性。
总结也不是无损压缩。模型可能遗漏数字、姓名或约束,多次"摘要的摘要"还会不断累积偏差。因此重要业务事实不应该只存在自然语言摘要里,应该提取为明确字段,例如:
js
{
userProfile: {
name: "李四",
occupation: "设计师",
interests: ["艺术", "音乐"]
},
pendingTasks: ["补充作品集链接"]
}
我还尝试过按 token 进一步划分"保留区"和"总结区",但初版出现了未定义变量和作用域问题。这里采用更完整、边界更清楚的第一版总结流程。
4. 检索式记忆:不是每轮都带上全部历史
长时记忆的另一个思路,是把每轮对话向量化后存入向量数据库。新问题到来时,只检索语义相关的几轮,而不是把所有历史都发给模型。
检索式记忆使用 Milvus,集合同时保存向量和对话元数据:
js
await client.createCollection({
collection_name: "conversations",
fields: [
{
name: "id",
data_type: DataType.VarChar,
max_length: 50,
is_primary_key: true,
},
{
name: "vector",
data_type: DataType.FloatVector,
dim: 1024,
},
{
name: "content",
data_type: DataType.VarChar,
max_length: 5000,
},
{ name: "round", data_type: DataType.Int64 },
{
name: "timestamp",
data_type: DataType.VarChar,
max_length: 100,
},
],
});
await client.createIndex({
collection_name: "conversations",
field_name: "vector",
index_type: IndexType.IVF_FLAT,
metric_type: MetricType.COSINE,
});
插入时,先把一轮问答拼成文本,再生成 Embedding(嵌入向量):
js
const conversationText =
`用户:${input}\n助手:${response.content}`;
const vector = await embeddings.embedQuery(conversationText);
await client.insert({
collection_name: "conversations",
data: [{
id: crypto.randomUUID(),
content: conversationText,
vector,
round,
timestamp: new Date().toISOString(),
}],
});
下一次提问时,用同一个 Embedding 模型生成查询向量:
js
async function retrieveRelevantConversations(query, k = 2) {
const queryVector = await embeddings.embedQuery(query);
const result = await client.search({
collection_name: "conversations",
vector: queryVector,
limit: k,
metric_type: MetricType.COSINE,
output_fields: ["id", "content", "round", "timestamp"],
});
return result.results;
}
这里有一个必须补上的业务边界:原始集合没有 user_id 或租户字段。真实系统必须先按用户、会话或组织过滤,再做向量检索,否则不同用户的记忆可能互相泄漏。
5. 三种方案怎么组合
三种记忆策略解决的时间尺度不同:
| 策略 | 适合保存 | 主要成本 | 主要风险 |
|---|---|---|---|
| 最近消息截断 | 当前几轮的精确上下文 | token | 老信息被直接丢弃 |
| 历史总结 | 中期对话脉络 | 额外模型调用 | 摘要遗漏、误差累积 |
| 向量检索 | 跨会话长期记忆 | Embedding、数据库、检索 | 召回错误、权限隔离 |
我更愿意把它们组合成三层:
- 最近消息原样保留;
- 超出窗口的旧消息形成滚动摘要;
- 值得长期保存的事实和对话写入数据库,按需检索。
这比"所有历史都向量化"更稳。最近两轮对话如果还要先检索才能找到,既增加延迟,也可能因为相似度不高而丢失上下文。
6. Docker Compose 只是基础设施,不是记忆逻辑
本地环境通过 Compose 启动 Milvus、etcd 和 MinIO,凭据全部改为环境变量:
yaml
services:
minio:
image: minio/minio:<VERSION>
environment:
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-<REDACTED>}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-<REDACTED>}
standalone:
image: milvusdb/milvus:<VERSION>
environment:
ETCD_ENDPOINTS: etcd:2379
MINIO_ADDRESS: minio:9000
ports:
- "19530:19530"
这部分依赖 Docker 和外部 Embedding 服务,运行前还需要根据自己的环境补齐配置。示例中的敏感值均已脱敏。
结尾
Agent Memory 不是"把聊天记录存下来"这么简单,关键在于每轮如何选择上下文:
- 先用 token 预算控制最近消息;
- 用摘要延长对话连续性,但不要把关键事实只交给自然语言摘要;
- 用向量检索找回跨会话信息,并把用户隔离放在相似度之前;
- 最近上下文、摘要和长期记忆分层处理,不要只押一种方案;
- 对摘要与检索都保留失败出口,信息不足时让模型明确说不知道。
下一步可以把长期记忆从独立 Milvus 迁到 PostgreSQL + pgvector,让用户、会话、消息和向量处在同一个事务与权限模型里。第七篇会继续这个方向。