做聊天应用最怕什么?
用户跟 AI 聊了半小时,说了自己叫李四、是设计师、喜欢 UI/UX,结果下一句问"我适合学什么?"——AI 回你一句:"请问您是谁?"
没有 Memory 的 Agent,就像一个每次见面都要重新自我介绍的社恐。
今天这篇文章,我们就以开发 codex 这个聊天应用为背景,把 Agent Memory 的三大核心手段——截断(Truncation)、总结(Summarization)、检索(Retrieval) ——从思路到代码,一次性讲透。最后你会发现,Harness 的 Memory 模块本质上就是这三件事的组合拳。
一、Memory 到底在解决什么问题?
LLM 的上下文窗口是有限的(哪怕是 128K 也不是无限的),而一次会话的消息会越来越多。摆在面前的只有三条路:
- 截断:扔旧的,留新的 —— 简单粗暴,但会丢信息
- 总结:把旧的压成摘要 —— 有损压缩,但保留主线
- 检索:把全量历史存起来,需要时再捞 —— 无损,但要额外基础设施
一句话总结:截断是"断舍离",总结是"做笔记",检索是"翻档案"。
生产环境的 Memory 模块,一定是三者的组合。下面逐个拆。
二、截断:最简单也最容易翻车的方案
2.1 按消息条数截断(入门版)
最朴素的思路:保留最近 N 条消息。
import { InMemoryChatMessageHistory } from '@langchain/core/chat_history';
import { HumanMessage, AIMessage } from '@langchain/core/messages';
async function messageCountTruncation() {
const history = new InMemoryChatMessageHistory();
const maxMessages = 4;
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 设计非常重要!' },
];
for (const msg of messages) {
if (msg.type === 'human') {
await history.addMessage(new HumanMessage(msg.content));
} else {
await history.addMessage(new AIMessage(msg.content));
}
}
// invoke 之前截断
const allMessages = await history.getMessages();
const trimmedMessages = allMessages.slice(-maxMessages);
console.log(`保留消息数量:${trimmedMessages.length}`);
}
踩坑点:如果你 slice(-4),很可能把"用户说'我叫李四'"这条扔掉了,但留下了"AI 说'你好李四'"——上下文就串了。所以按条数截断时,一定要按"轮次"(human + ai 一对)截断,而不是单条。
顺带说一句:为什么每次 addMessage 都要 new 一个 Message?
刚接触 LangChain 的人经常会疑惑:{ type: 'human', content: '我叫李四' } 明明是个普通对象,为什么不能直接塞给 history,非要 new HumanMessage(...)?
因为 HumanMessage / AIMessage / SystemMessage 是类,不是普通对象。它们内部携带了 role、_getType()、getType()、_combineTokens() 等方法,模型调用链和 history 都靠这些方法识别消息角色。
- 必须 new 的场景:从外部数据(JSON、DB 字段、用户输入)构造消息时,一定要包一层类,否则 LLM 拿到的消息角色是"未定义"。
- 不用 new 的场景:从 history 里
getMessages()拿出来、或者slice()截出来的消息,本身就是对应类的实例,可以直接addMessage。
所以你会看到总结代码里这么写:
const recentMessages = allMessages.slice(-keepRecent);
// 下面这行不用 new,因为它已经是 message 实例了
for (const msg of recentMessages) await history.addMessage(msg);
如果你手贱写 history.addMessage(new HumanMessage(msg.content)),反而会把 AI 消息的 role 冲掉变成 human——这类 bug 排查起来最烦,因为它不报错,只是回答越来越离谱。
2.2 按 Token 截断(生产版)
真实场景下,消息长短差别巨大。一条 2000 字的 prompt 和一条 "嗯",token 开销天差地别。按条数截断根本不靠谱。
LangChain 提供了 trimMessages,配合 js-tiktoken 做精确 token 计算:
import { trimMessages } from '@langchain/core/messages';
import { getEncoding } from 'js-tiktoken';
function countTokens(messages, encoding) {
let total = 0;
for (const msg of messages) {
const content = typeof msg.content === 'string'
? msg.content
: JSON.stringify(msg.content);
total += encoding.encode(content).length;
}
return total;
}
async function tokenCountTruncation() {
const history = new InMemoryChatMessageHistory();
// ... 填充消息同上 ...
const allMessages = await history.getMessages();
const enc = getEncoding('cl100k_base'); // 编码
console.log(`所有消息的总token数:${countTokens(allMessages, enc)}`);
const trimmedMessages = await trimMessages(allMessages, {
maxTokens: 150,
// 不同 LLM token 计算方式不一样,交给你自己决定
tokenCounter: async (msgs) => countTokens(msgs, enc),
strategy: 'last', // 从最后一条开始,往前留
});
console.log(`截断后 token:${countTokens(trimmedMessages, enc)}`);
}
那个 typeof msg.content === 'string' 三元是干嘛的?
msg.content 在 LangChain 里的类型是 string | MessageContentComplex[]:
- 纯文本消息:
content就是字符串。 - 多模态消息:
content是数组,形如[{ type: 'text', text: '...' }, { type: 'image_url', image_url: {...} }]。
js-tiktoken 的 encoding.encode() 只接受字符串,你直接扔数组进去它会抛类型错误。所以这里做个判断:
- 是字符串 → 直接用,最准。
- 是数组 →
JSON.stringify拍成字符串,算个近似值。
严格来说,多模态消息里图片的 token 跟文本根本不是一个算法(图有自己一套 patch 计算方式),但作为上下文预算的估算,JSON.stringify 已经够用了。记住一句话:token 计算不是会计对账,而是做预算,精度到 5% 就够了。
trimMessages 内部到底怎么截的?
trimMessages 的核心不是"从头或从尾简单切",而是围绕"保留哪些消息"做二分查找。
为什么能二分?因为后 k 条消息的 token 总量,关于 k 是单调递增的——k 越大,累积 token 越多,不存在"多加一条反而变少"的情况。单调 → 可以二分。
举个例子。假设从旧到新有 5 条消息,每条 token 如下:
A(30) B(50) C(40) D(60) E(20)
maxTokens = 100,strategy = 'last'
我们要找的是最大的 k,使得"最后 k 条"的总 token ≤ 100。
- 试
k = 2→ 后 2 条 = D + E = 80 ≤ 100 ✓(还能更大,往右搜) - 试
k = 3→ 后 3 条 = C + D + E = 120 > 100 ✗(超了,往左搜) - 试
k = 2→ 确定
最终保留 [D, E] ,共 80 tokens。
整个过程只需要 log₂(5) ≈ 3 次 token 计算,而不是"一条一条往前加,直到爆掉"的 O(n) 次。当你 history 有几百条消息、且每条 token 计算都要跑 tiktoken 时,这个差距就是几十毫秒 vs 几百毫秒。
几个关键点:
cl100k_base是 OpenAI GPT-3.5/4 系列用的编码。用国产模型(Qwen、GLM)时,编码器要换成对应的(或者用tiktoken的近似版)。strategy: 'last'表示从最新往旧数,凑够 maxTokens 就停。也有'first'。- 一定要在
invoke之前截断,不能等模型报 context length 错误才处理。
截断只能救急,不能救命。真正丢失的"我是李四"这类关键信息,是截断永远补不回来的。
三、总结:让 AI 自己做笔记
截断的痛点很明确:丢信息。那能不能让 AI 把旧消息"压一压"再留着?
能。这就是 Summarization Memory。
3.1 基础版:超过 N 条就总结
思路很直接:
- 检查消息条数
- 超过阈值 → 把老消息交给 LLM 总结成一段话
- clear history,塞回"最近消息 + 摘要"
import { ChatOpenAI } from '@langchain/openai';
import { getBufferString, SystemMessage, AIMessage } from '@langchain/core/messages';
const model = new ChatOpenAI({
modelName: process.env.MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
temperature: 0,
configuration: { baseURL: process.env.OPENAI_BASE_URL },
});
async function summarizeHistory(messages) {
if (messages.length === 0) return '';
// 消息数组 -> 带角色前缀的字符串
const conversationText = getBufferString(messages, '用户', '助手');
const summaryPrompt = `请总结以下对话的核心内容,保留重要信息:
${conversationText}
总结:`;
const summaryResponse = await model.invoke([new SystemMessage(summaryPrompt)]);
return summaryResponse.content;
}
async function summarizationMemoryDemo() {
const history = new InMemoryChatMessageHistory();
const maxMessages = 6;
const keepRecent = 2;
// ... 填充消息 ...
const allMessages = await history.getMessages();
if (allMessages.length > maxMessages) {
const recentMessages = allMessages.slice(-keepRecent);
const messagesToSummarize = allMessages.slice(0, -keepRecent);
const summary = await summarizeHistory(messagesToSummarize);
await history.clear();
for (const msg of recentMessages) await history.addMessage(msg);
await history.addMessage(new AIMessage(summary)); // 把摘要塞回去
}
}
注意 getBufferString(messages, '用户', '助手') 这个工具——它把消息数组拼成带角色标签的字符串,省得你手动 map + join。
这里 history.addMessage(msg) 没 new,因为 recentMessages 已经是消息实例;而 new AIMessage(summary) 必须 new,因为 summary 是 model.invoke 返回的纯字符串,得手动包成 AI 消息塞回 history。
判断标准就一条:你手上拿到的是"字符串 / 普通对象"还是"消息实例"。前者必须 new,后者别多此一举。
3.2 进阶版:按 Token 触发总结
按条数触发的问题是:可能这 6 条都很短,没必要总结;也可能 3 条就把 4K 窗口挤爆了。
改成按 token 触发:
const encoder = getEncoding('cl100k_base');
const maxTokens = 200; // 超过就触发总结
const keepTokens = 80; // 最近消息至少保留多少 token
const totalTokens = countTokens(allMessages, encoder);
if (totalTokens >= maxTokens) {
const recentMessages = [];
let recentTokens = 0;
// 从最新一条往前倒推,凑够 keepTokens
for (let i = allMessages.length - 1; i >= 0; i--) {
const msg = allMessages[i];
const msgTokens = countTokens([msg], encoder);
if (recentTokens + msgTokens <= keepTokens) {
recentMessages.unshift(msg);
recentTokens += msgTokens;
} else {
break;
}
}
const messagesToSummarize = allMessages.slice(
0, allMessages.length - recentMessages.length
);
const summary = await summarizeHistory(messagesToSummarize);
await history.clear();
for (const msg of recentMessages) await history.addMessage(msg);
await history.addMessage(new AIMessage(summary));
}
这套逻辑就是 Claude Code 里 /compact 命令的雏形——把老的上下文压缩成摘要,留最近几轮保证连贯性。
总结的本质是"有损压缩":你丢掉了细节,但保住了主线。李四还是李四,只是"喜欢艺术和音乐"这种边角料可能被压掉。
四、检索:Milvus 让 Memory 永远"在线"
总结也有极限。如果用户跟 AI 聊了 500 轮,摘要越滚越大,最后摘要本身也塞不进 context。
终极方案:向量数据库。
把所有对话历史 embedding 后存进 Milvus,需要时再按语义检索。这样:
- 上下文窗口只放"当前问题 + 检索到的相关历史"
- 历史永不丢失,可以检索过去任何一轮
- 跨会话也能用(比如用户下次开新会话,还能找到上次提到的项目)
4.1 建集合 & 插入对话
import { MilvusClient, DataType, MetricType, IndexType } from '@zilliz/milvus2-sdk-node';
import { OpenAIEmbeddings } from '@langchain/openai';
const COLLECTION_NAME = 'conversations';
const VECTOR_DIM = 1024;
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
model: 'text-embedding-v3',
configuration: { baseURL: process.env.OPENAI_BASE_URL },
dimension: VECTOR_DIM,
});
const client = new MilvusClient({ address: 'localhost:19530' });
async function main() {
await client.connectPromise;
// 1. 建集合
await client.createCollection({
collection_name: COLLECTION_NAME,
fields: [
{ name: 'id', data_type: DataType.VarChar, max_length: 50, is_primary_key: true },
{ name: 'vector', data_type: DataType.FloatVector, dim: VECTOR_DIM },
{ name: 'content', data_type: DataType.VarChar, max_length: 5000 },
{ name: 'round', data_type: DataType.Int64 },
{ name: 'timestamp', data_type: DataType.VarChar, max_length: 100 },
],
});
// 2. 建索引(向量字段一定要建)
await client.createIndex({
collection_name: COLLECTION_NAME,
field_name: 'vector',
index_type: IndexType.IVF_FLAT,
metric_type: MetricType.COSINE,
});
// 3. 加载到内存才能查
await client.loadCollection({ collection_name: COLLECTION_NAME });
// 4. 插入数据(先 embedding 再 insert)
const conversations = [
{ id: 'conv_001', content: '用户:我叫赵六,是一名数据科学家\n助手:很高兴认识你!', round: 1, timestamp: new Date().toISOString() },
{ id: 'conv_002', content: '用户:我最近在研究机器学习算法\n助手:机器学习确实很有意思。', round: 2, timestamp: new Date().toISOString() },
// ...
];
const data = await Promise.all(
conversations.map(async (conv) => ({
...conv,
vector: await embeddings.embedQuery(conv.content),
}))
);
await client.insert({ collection_name: COLLECTION_NAME, data });
}
几个实践提醒:
- Milvus 没有
DateTime类型,时间戳用VarChar存 ISO 字符串,省事。 - 主键用 UUID /
时间戳_序号,别用自增 int(并发场景容易撞)。 content的max_length要留足,一次对话两三轮拼接很容易破千。- 索引类型:小数据量用
IVF_FLAT够;数据量上百万后再考虑HNSW。
new Date().toISOString() 到底返回什么?
一行代码,但值得展开讲两句。
toISOString() 返回的是 ISO 8601 标准格式,形如:
2025-01-15T08:30:00.000Z
拆开看:
2025-01-15→ 日期T→ 日期和时间之间的分隔符08:30:00.000→ 时间(精确到毫秒)Z→ Zulu time,代表 UTC 时区。也就是说不管你在北京、纽约还是伦敦,这行代码跑出来都是同一个 UTC 时间点。
为什么大家喜欢用它存时间?
- 字符串字典序 = 时间顺序。
'2025-01-15T08:30:00.000Z' < '2025-01-15T09:00:00.000Z'直接成立,排序、范围查询都能当字符串比,Milvus 里用VarChar存起来毫无压力。 - 跨时区无歧义。前端展示时自己
new Date(str).toLocaleString()转本地时间即可,不会像'2025-01-15 08:30:00'这种本地时间字符串一样出现"到底是哪个时区"的扯皮。 - 前后端通吃。JS、Python、Go 都能原生解析 ISO 8601,序列化传输零成本。
反例:别用 new Date().toString() 存 Milvus,它返回的是 Wed Jan 15 2025 08:30:00 GMT+0800 (中国标准时间) 这种人类可读但机器难处理的格式,排序会失效,且带时区后缀解析麻烦。
4.2 检索 + 拼上下文
每次用户提问时,先用问题去 Milvus 里捞 top-k 相关历史:
async function retrieveRelevantConversations(query, k = 2) {
const queryVector = await embeddings.embedQuery(query);
const searchResult = await client.search({
collection_name: COLLECTION_NAME,
vector: queryVector,
limit: k,
metric_type: MetricType.COSINE,
output_fields: ['id', 'content', 'round', 'timestamp'],
});
return searchResult.results;
}
async function retrievalMemoryDemo() {
await client.connectPromise;
const history = new InMemoryChatMessageHistory();
const conversations = [
{ input: '我之前提到的机器学习项目进展如何?' },
{ input: '我周末经常做什么?' },
{ input: '我的职业是什么?' },
];
for (let i = 0; i < conversations.length; i++) {
const { input } = conversations[i];
const userMessage = new HumanMessage(input);
// 1. 检索相关历史
const retrieved = await retrieveRelevantConversations(input, 2);
const relevantHistory = retrieved.length
? retrieved.map((conv, idx) =>
`[历史对话 ${idx + 1}]\n轮次:${conv.round}\n${conv.content}`
).join('\n\n------\n\n')
: '';
// 2. 拼上下文
const contextMessages = relevantHistory
? [new HumanMessage(`相关历史对话:\n${relevantHistory}\n\n用户问题: ${input}`)]
: [userMessage];
contextMessages.push(userMessage);
// 3. 调用 LLM
const response = await model.invoke(contextMessages);
// 4. 新对话也写回 Milvus,形成闭环
const conversationText = `用户: ${input}\n助手: ${response.content}`;
const convId = `conv_${Date.now()}_${i + 1}`;
const vector = await embeddings.embedQuery(conversationText);
await client.insert({
collection_name: COLLECTION_NAME,
data: [{
id: convId,
content: conversationText,
vector,
round: i + 1,
timestamp: new Date().toISOString(),
}],
});
await history.addMessage(response);
}
}
这套流程就是 RAG 的经典范式:检索 → 拼上下文 → 生成 → 落库。区别只在于检索源是"对话历史"而不是"知识文档"。
检索 Memory 的核心价值:让 AI 有能力"想起"具体某一轮对话,而不是只记得一段模糊的摘要。
五、把三招合起来:codex 的 Memory 模块设计
回到文章开头那个"聊了半小时就失忆"的场景。真实的 codex 会这么做:
┌─────────────────────────────────────────────┐
│ 用户新消息进来 │
└──────────────┬──────────────────────────────┘
▼
┌─────────────────────────────────────────────┐
│ 1. 计算当前 history 的 token 总量 │
└──────────────┬──────────────────────────────┘
▼
┌─────────┴─────────┐
│ 超阈值? │
▼ ▼
是 否
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ 触发 Summary │ │ 直接拼上下文 │
│ (老消息→摘要) │ │ │
└────────┬─────────┘ └────────┬─────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────┐
│ 2. 用当前问题检索 Milvus(top-k 相关历史) │
└──────────────┬──────────────────────────────┘
▼
┌─────────────────────────────────────────────┐
│ 3. 拼装最终 context: │
│ SystemPrompt │
│ + 历史摘要 │
│ + Milvus 检索到的相关历史 │
│ + 最近 k 轮原文 │
│ + 当前用户消息 │
└──────────────┬──────────────────────────────┘
▼
调用 LLM 生成回答
│
▼
┌─────────────────────────────────────────────┐
│ 4. 新对话写回 Milvus,形成闭环 │
└─────────────────────────────────────────────┘
每 20 条触发一次总结就是你笔记里的策略——这很合理:
- 太频繁(比如每 5 条)→ summary 调用成本高,且每次压缩都损失一点信息
- 太稀疏(比如每 100 条)→ context 早就爆了
- 20 条大约是 10 轮对话,差不多对应"话题要切换"的粒度
六、几个血泪踩坑点
最后,把笔记里那些容易翻车的点集中讲一下:
1. slice(-N) 按条数截断,永远不靠谱。
长短消息混在一起,token 分布完全非线性。生产一定用 trimMessages + js-tiktoken。
2. getBufferString 的角色标签要显式传。
默认是 "Human" / "Assistant",中文场景传 ('用户', '助手') 更自然,LLM 理解也更稳。
3. 别乱 new message。
从 history 里拿出来的已经是实例,直接 addMessage 就好;只有从字符串/JSON/DB 字段构造时才需要 new。乱 new 会把角色搞乱,且不报错,非常隐蔽。
4. msg.content 未必是字符串。
多模态场景下它是数组,tiktoken.encode 会直接抛错,所以要用 typeof ... === 'string' ? ... : JSON.stringify(...) 兜一下。
5. Milvus 的 loadCollection 千万别漏。
建了集合、建了索引、插了数据,但没 loadCollection,search 会告诉你"collection not loaded"。这个坑我踩过两次。
6. embedding 的 dimension 必须和集合的 dim 一致。
text-embedding-v3 支持多种维度,OpenAIEmbeddings 初始化时的 dimension 和 createCollection 里的 dim 不一致,插数据会直接报错。
7. 写回 Milvus 的时候,一定要写"用户 + 助手"的完整对话。
只写用户问题,后面检索语义会偏——因为用户的短问题通常缺少"上一轮 AI 回复"里的关键实体信息。
8. 摘要不是越详细越好。
temperature: 0 + "保留重要信息"这种模糊 prompt,会让 LLM 倾向于复述。更稳的模板是给几条硬规则,比如:"保留姓名、职业、偏好、未完成的任务,其余压缩"。
9. 时间戳用 new Date().toISOString(),别用 toString()。
ISO 8601 带 Z 后缀,跨时区无歧义,字典序即时间序;toString() 的本地化格式在 Milvus VarChar 里排序会乱套。
七、总结
一句话回顾三种 Memory 手段:
| 手段 | 本质 | 优势 | 代价 |
|---|---|---|---|
| Truncation | 丢旧留新 | 零成本、零延迟 | 丢信息 |
| Summarization | 有损压缩 | 保留主线、成本可控 | 丢细节、需 LLM 调用 |
| Retrieval | 按需召回 | 无损、可跨会话 | 需向量库、检索质量依赖 embedding |
Agent 不是靠"记住所有"变强的,而是靠"在对的时候想起对的事"变强的。
Codex、Claude Code、Cursor 这些工具的 Memory 模块,说穿了就是这三招 + 一次精心的上下文编排。你不用一次全上,可以从"按 token 截断 + 定时总结"起步,等量级上来了再补 Milvus。
下一次你的 Agent 答非所问的时候,先别骂模型——大概率是它真的忘了。