新手做 AI 应用最常见的困惑:大模型不是能聊天吗,为什么聊几句就把我忘了?关掉程序再开,之前的对话全没了?对话越来越长,上下文塞爆了怎么办?
这背后其实是同一个问题------大模型天生没有记忆 。本文用 LangChain.js 从零手写一个「会记忆的 Agent」,层层递进:内存记忆 → 文件记忆 → 向量数据库检索记忆,技术栈 LangChain.js + OpenAI 兼容接口 + js-tiktoken + Milvus。
一、先想清楚:为什么 Agent 必须有 Memory
一张图看透 Agent 的本质:
ini
Agent = LLM + Harness(工具 + RAG + Memory + ...)
LLM 本身只是个「会说话的大脑」,它有个致命缺陷:无状态(stateless)。
- 你问「我叫李四」,它回了;
- 你再问「我叫什么?」,它傻了------上一句话它根本没记住;
- 你能续着聊,是因为前端把历史消息数组又发了一遍给它,而不是它自己记得。
1.1 最朴素的记忆:messages 数组
很多入门教程会教:把对话都存进一个数组,每次请求把整个数组丢给模型:
js
const messages = [
{ role: "system", content: "你是助手" },
{ role: "user", content: "我叫李四" },
{ role: "assistant", content: "你好李四!" },
];
// 下次提问时,把整个 messages 数组带上
这确实算「记忆」,但马上会碰到三座大山:
| 问题 | 大白话 |
|---|---|
| 持久化 | 数组在内存里,服务一重启就全没了 |
| 上下文窗口 | 聊几百轮后数组巨长,模型窗口(比如 200k token)装不下 |
| 开销 | token 越多 = 越贵 + 响应越慢,把没用的旧话反复喂给模型太浪费 |
所以真正工程化的 Memory 模块,核心就干两件事:把记忆存下来(持久化) + 对塞不下的记忆做淘汰管理。
1.2 一个贯穿全文的目标
做个做菜助手 ,让它学会「基于上一次对话接着答」。我们会一层层给它加记忆,最终做成一个像 codex 那样每次聊完自动沉淀记忆、下次能想起来的应用。
二、扫盲:先把这 4 个概念盘明白
后面代码会反复出现这些词,先建立直觉,别被吓到。
2.1 async / await 与 Promise
读写文件、调用大模型都要花时间,JS 不能「干等」,所以给出一张「取餐单号」------Promise:
async函数 = 告诉 JS「我这里要等外部慢事」,它不直接返回结果,而是返回一张单号;await= 「站在门口等,单送到(拿到真结果)才走下一行」;.catch()= 售后:哪一步失败了,把失败原因接住打印出来,程序不崩。
await 只能写在 async 函数里,所以两个文件开头一定长这样:
js
fileHistoryDemo()
.catch(console.error); // 失败兜底,打印红字错误
💡 小坑:我最早把结尾写成
.then(console.error),.then是成功 才触发,失败根本不会打印。要用.catch才对。
2.2 LangChain 的 Message 对象
LangChain 不让你手搓 { role, content },而是给你几类「带类型的消息对象」:
| 类 | 对应 role | 谁发的 |
|---|---|---|
SystemMessage |
system | 系统设定,比如「你是做菜助手」 |
HumanMessage |
user | 用户 |
AIMessage |
assistant | 模型 |
一个典型的 SystemMessage:
js
const systemMessage = new SystemMessage(
"你是一个友好,幽默的做菜助手,喜欢分享美食和烹饪技巧"
);
2.3 ChatMessageHistory:管理消息的容器
LangChain 把「存消息的容器」抽象成了 ChatMessageHistory,只有两个核心方法,读源码发现就这么简单:
| 方法 | 作用 |
|---|---|
addMessage(msg) |
往历史里加一条消息(用户/助手都加) |
getMessages() |
把历史里所有消息取回来 |
clear() |
清空历史 |
配合「两段式」用法:先把历史取出来拼上 system,一次性发给模型:
js
const messages1 = [systemMessage, ...(await history.getMessages())];
const response1 = await model.invoke(messages1);
await history.addMessage(response1); // 把回答也记回去,完成闭环
2.4 Model:OpenAI 兼容的模型封装
js
const model = new ChatOpenAI({
modelName: process.env.MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
temperature: 0,
configuration: { baseURL: process.env.OPENAI_BASE_URL },
});
模型名、key、baseURL 全走 .env------这样既能用官方,也能接国内各种 OpenAI 兼容中转,不用改代码。
三、Memory 的两层设计:存哪 + 怎么淘汰
把 Memory 拆成两层独立的思考,代码才写得清楚:
python
┌─────────────────────────────────────────────┐
│ 管理逻辑(怎么淘汰) │
│ ① 截断 slice(-n) 保留最近的,丢掉最旧的 │
│ ② 总结 AI 把旧对话压缩成一句话摘要 │
│ ③ 检索 向量库按相似度找出"最相关"的旧记忆 │
├─────────────────────────────────────────────┤
│ 存储逻辑(存哪) │
│ ① 内存 InMemoryChatMessageHistory(临时) │
│ ② 文件 FileSystemChatMessageHistory(长期) │
│ ③ 向量库 Milvus(长期 + 可搜索) │
└─────────────────────────────────────────────┘
存储逻辑 回答「记忆放哪、死了还在不在」;管理逻辑回答「上下文快爆了,怎么把重要的留下」。二者可以任意组合------下面我们就按 存储 内存→文件→向量库 的升级路线走,顺路把三种管理策略都实现一遍。
四、第一步:内存记忆 InMemoryChatMessageHistory
先让 Agent 在同一进程内「记住」你。对应文件 history-test.mjs。
4.1 完整的做菜助手多轮对话
js
import 'dotenv/config';
import { ChatOpenAI } from '@langchain/openai';
import {
InMemoryChatMessageHistory // 短期内存记忆
} from '@langchain/core/chat_history';
import {
HumanMessage, SystemMessage
} 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 inMemoryDemo() {
// 一个内存里的"对话账本"
const history = new InMemoryChatMessageHistory();
const systemMessage = new SystemMessage(
"你是一个友好,幽默的做菜助手,喜欢分享美食和烹饪技巧"
);
// ---- 第一轮 ----
console.log("[第一轮对话]");
const userMessage1 = new HumanMessage("你今天吃的什么?");
await history.addMessage(userMessage1); // 记下:用户问了什么
const messages1 = [systemMessage, ...(await history.getMessages())];
const response1 = await model.invoke(messages1); // 发给模型
await history.addMessage(response1); // 记下:助手回了什么
console.log(`助手:${response1.content}`);
// ---- 第二轮(基于历史)----
console.log("[第二轮对话 基于历史记录]");
const userMessage2 = new HumanMessage("好吃吗?");
await history.addMessage(userMessage2);
const messages2 = [systemMessage, ...(await history.getMessages())];
const response2 = await model.invoke(messages2);
await history.addMessage(response2);
console.log(`助手:${response2.content}`);
// ---- 看账本里存了几条 ----
const allMessages = await history.getMessages();
console.log(`共保存了 ${allMessages.length} 条对话`);
}
inMemoryDemo()
.catch(console.error)
.finally(() => console.log("done"));
4.2 这条流程长什么样
关键就一行精髓:每次提问都把整本账本带上 。模型看到 messages2 里既有第一轮的「你今天吃的什么」,也有你的「好吃吗?」,自然知道你在问什么。
内存记忆的局限肉眼可见:InMemory 字面意思------账本只活在进程内存里,程序一重启,记忆清空。这就引出第二步。
五、第二步:文件记忆 FileSystemChatMessageHistory(重启不丢)
把账本从内存搬进硬盘文件。LangChain 社区包自带 FileSystemChatMessageHistory,自动把历史持久化成 JSON 文件。对应文件 history-test3.mjs。
5.1 只改了哪几行?
对比第四步,核心变化只有一处 :把 InMemoryChatMessageHistory 换成 FileSystemChatMessageHistory,并指定文件路径和会话 ID。
js
import {
FileSystemChatMessageHistory // 文件记忆
} from '@langchain/community/stores/message/file_system';
const filePath = path.join(process.cwd(), "chat_history.json");
const sessionId = "user_session_001"; // 多用户:不同 session 各记各的账
const history = new FileSystemChatMessageHistory({ filePath, sessionId });
剩下的增删查代码一行都不用改 ------因为 addMessage / getMessages 接口完全一致。这就是 LangChain 抽象得好的地方:存储实现随便换,业务代码不动。
5.2 从文件里恢复记忆,接着聊第三轮
js
async function fileHistoryDemo() {
const restoredHistory = new FileSystemChatMessageHistory({ filePath, sessionId });
// 从文件中恢复 history(进程重启后依然在)
const restoredMessages = await restoredHistory.getMessages();
console.log(`从文件中恢复了 ${restoredMessages.length} 条历史信息:`);
restoredMessages.forEach((msg, index) => {
const type = msg.type;
const prefix = type === 'human' ? '用户' : '助手';
console.log(`${index + 1}. [${prefix}]: ${msg.content.substring(0, 50)}...`);
});
// 接着聊第三轮
console.log("[第三轮对话]");
const userMessage3 = new HumanMessage("需要哪些食材?");
await restoredHistory.addMessage(userMessage3);
const messages3 = [systemMessage, ...(await restoredHistory.getMessages())];
const response3 = await model.invoke(messages3);
await restoredHistory.addMessage(response3);
console.log(response3.content);
console.log("对话已保存到文件");
}
跑完后你会发现当前目录多了个 chat_history.json:
json
[
{ "type": "human", "content": "需要哪些食材?" },
{ "type": "ai", "content": "做红烧肉需要五花肉、冰糖、生抽、老抽......" }
]
重启程序再跑,它能准确报出上一轮聊到哪了------记忆终于「活到下次开机」。
5.3 小结:内存 vs 文件
| 维度 | InMemory | FileSystem |
|---|---|---|
| 存放位置 | 进程内存 | 磁盘 JSON 文件 |
| 重启后 | ❌ 清空 | ✅ 还在 |
| 多用户 | 靠对象区分 | 靠 sessionId 区分 |
| 适用 | 单次会话内 | 轻量持久化 |
| 检索能力 | 无 | 只能整段读出,不能「按相似度找」 |
文件记忆解决了「重启不丢」,但暴露了新问题:它只能整本账本从头读到尾。对话一旦几百轮,光靠文件你会遇到两个坎------上下文塞爆、以及「想不起来一个多月前聊的具体事」。这就轮到管理策略和向量库登场。
六、三种淘汰策略:截断 / 总结 / 检索
无论账本存哪,塞不下时都要淘汰。LangChain 社区流传最广的三板斧,对应文件都在 demo/src/memory/ 下。
6.1 截断(Truncation):最简单粗暴
思路:只保留最近的 N 条,最旧的直接丢。
按消息条数截断 ------一个 slice 就够:
js
const maxMessages = 4;
const trimmedMessages = allMessages.slice(-maxMessages); // 留最后 4 条
console.log(`保留消息数量:${trimmedMessages.length}`);
按 token 数截断 ------生产环境更该用 token 而不是「条数」,因为每条消息长短不一。LangChain 提供 trimMessages,配合 js-tiktoken 精确算 token:
js
import { getEncoding } from 'js-tiktoken'; // OpenAI 官方分词器
import { trimMessages } from '@langchain/core/messages';
const enc = getEncoding("cl100k_base"); // 编码方案要和模型一致
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;
}
const trimmedMessages = await trimMessages(allMessages, {
maxTokens: 100, // token 上限
tokenCounter: async (msgs) => countTokens(msgs, enc),
strategy: "last", // 从最后往前留,保住最新上下文
});
白话理解 :strategy: "last" = 从队尾往前数,数到 100 token 就停,前面的全砍。好处是省 token、便宜、不爆窗口 ;代价是砍掉的部分可能还有价值------万一旧话里藏着关键设定呢?
6.2 总结(Summarization):让 AI 帮忙「压缩」
思路 :被裁掉的旧对话别扔,让模型把它总结成一句话塞回去。对应 summarization-memory.mjs。
三步走:
js
async function summarizeHistory(messages) {
if (messages.length === 0) return "";
// ① 把消息对象数组拼成一段可读文本(getBufferString:HumanMessage -> "用户: xxx")
const conversationText = getBufferString(messages, "用户", "助手");
const summaryPrompt = `请总结以下对话的核心内容,保留重要信息:
${conversationText}
总结:`;
// ② 让大模型做压缩
const summaryResponse = await model.invoke([new SystemMessage(summaryPrompt)]);
return summaryResponse.content;
}
// ③ 数量超限时:老消息去总结,最近的保留原样
if (allMessages.length > maxMessages) {
const keepRecent = 2;
const recentMessages = allMessages.slice(-keepRecent); // 最近的 2 条原样留
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 本质就是把对象转成字符串,好喂给模型;进阶版 summarization-memory2.mjs 则是用 token 数(maxTokens: 200 / keepRecentTokens: 80)而不是条数来控制何时触发、保留多少------逻辑一模一样,只是单位更精确。
白话总结 :账本要爆了 → 把前面的旧对话压缩成「你们之前聊了 XX,他介绍了自己叫李四、是设计师」这句摘要,加上最近几条原文 → 模型下次既看到摘要又不丢最新细节。代价是多花一次模型调用做总结,且长对话层层摘要会失真。
6.3 检索(Retrieval):只找「最相关」的旧记忆
思路 :前两种都是「倒掉 / 压缩」,而检索式记忆是保留全部历史,每次只取最相关的一段。这要靠 embedding 向量 + 向量数据库------也是下一章 Milvus 的主角。这里先记下结论:
截断保新鲜、总结保梗概、检索保细节且能"跨话题想起旧事",越往后越接近人脑,也越复杂。
三者的取舍:
| 策略 | 保什么 | 代价 | 典型场景 |
|---|---|---|---|
| 截断 | 最新上下文 | 丢旧信息 | 对话很短的助手 |
| 总结 | 全局梗概 | 丢细节 + 花一次 AI 调用 | 长会话压缩 |
| 检索 | 相关细节 | 要向量库,工程重 | 懂你的长期记忆 Agent |
七、第三步:Milvus 向量库------让记忆「可被搜索」
前面存文本只能「整段读」,我们要的是:用户随口问一句,系统能从一大堆历史里捞出"最像"的那几段。做法是把每段记忆转成向量,再算向量余弦相似度。
7.1 用 Docker 一键起 Milvus
Milvus 官方提供 standalone 编排文件(milvus-standalone-docker-compose.yml),在仓库里新建 milvus 目录放进去:
bash
docker compose -f ./milvus-standalone-docker-compose.yml up -d
compose= 用配置文件一键编排多个容器;-f指定文件,up -d后台启动;- Milvus 默认监听 19530 端口。
启动后可以装个 Attu(Milvus 生态最好的 GUI)可视化看数据,就像看 MySQL 的 Navicat。
7.2 装依赖 & 连上
bash
pnpm i @zilliz/milvus2-sdk-node # Milvus 官方 Node SDK
pnpm i @langchain/openai dotenv
js
import { MilvusClient, DataType, MetricType, IndexType }
from '@zilliz/milvus2-sdk-node';
import { OpenAIEmbeddings } from '@langchain/openai';
const COLLECTION_NAME = 'conversations'; // 集合(类比 MySQL 的表)
const VECTOR_DIM = 1024; // 向量维度(要和 embedding 模型一致)
// embedding:把文字变成 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' }); // 连 19530
7.3 建集合:给每轮对话建一张「档案卡」
对应 insert-conversations.mjs。字段设计很直观:
js
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 }, // 时间(存字符串)
]
});
// 给 vector 字段建索引 + 指定相似度算法,不然搜不了
await client.createIndex({
collection_name: COLLECTION_NAME,
field_name: 'vector',
index_type: IndexType.IVF_FLAT,
metric_type: MetricType.COSINE, // 余弦相似度:越接近1越相关
});
await client.loadCollection({ collection_name: COLLECTION_NAME }); // 载入内存才能搜
💡 注意:Milvus 没有专门的 Date/DateTime 类型,时间戳这里存成字符串即可。
写数据时,把「一整轮对话」当一条记忆,用 embedding 模型转成向量再插入:
js
const conversations = [
{ id: 'conv_001', content: '用户:我叫赵六,是一名数据科学家\n助手:很高兴认识你!',
round: 1, timestamp: new Date().toISOString() },
// ... 多轮历史
];
const conversationData = await Promise.all(
conversations.map(async (conv) => ({
...conv,
vector: await embeddings.embedQuery(conv.content), // 文字 -> 1024维向量
}))
);
await client.insert({ collection_name: COLLECTION_NAME, data: conversationData });
embedding 的灵魂是:语义相近的句子,向量方向也相近。所以「我叫赵六」和「你叫什么名字」能算出很高的相似度------这就是机器能「想起你」的原理。
八、完整闭环:仿 codex 的记忆型聊天应用
现在把前面所有零件拼起来,对应 retrieval-memory.mjs。流程就三步:
用户提问
→ ① 把问题转向量,去 Milvus 搜最像的 2 段历史(检索记忆)
→ ② 把搜到的历史 + 当前问题拼给大模型
→ ③ 模型回答后,把这一轮也写回 Milvus(自动沉淀新记忆)
8.1 先写一个「查记忆」的函数
js
async function retrieveRelevantConversations(query, k = 2) {
try {
const queryVector = await getEmbedding(query); // 问题 -> 向量
const searchResult = await client.search({
collection_name: COLLECTION_NAME,
vector: queryVector,
limit: k, // 只要最像的 k 段
metric_type: MetricType.COSINE,
output_fields: ['id', 'content', 'round', 'timestamp'],
});
return searchResult.results;
} catch (err) {
console.error('检索对话时出错', err.message);
return []; // 检索失败也别中断对话
}
}
8.2 主流程:检索 → 增强 → 回答 → 回写
js
async function retrievalMemoryDemo() {
await client.connectPromise;
const questions = [
"我之前提到的机器学习项目进展如何?",
"我的职业是什么?",
];
for (let i = 0; i < questions.length; i++) {
const input = questions[i];
const userMessage = new HumanMessage(input);
// ① 检索相关历史
const retrieved = await retrieveRelevantConversations(input, 2);
let relevantHistory = '';
if (retrieved.length > 0) {
relevantHistory = retrieved.map((conv, idx) =>
`[历史对话 ${idx + 1}] 轮次:${conv.round}\n${conv.content}`
).join('\n');
} else {
console.log('未找到相关历史对话');
}
// ② 有相关历史就带上去,没有就只发当前问题
const contextMessages = relevantHistory
? [new HumanMessage(`相关历史对话:\n${relevantHistory}\n\n用户问题:${input}`)]
: [userMessage];
// ③ 调用模型
const response = await model.invoke(contextMessages);
console.log(response.content);
// ④ 把这一轮也存回 Milvus,越聊越懂你
const conversationText = `用户:${input}\n助手: ${response.content}`;
const convId = `conv_${Date.now()}_${i + 1}`;
const convVector = await getEmbedding(conversationText);
await client.insert({
collection_name: COLLECTION_NAME,
data: [{
id: convId, content: conversationText, vector: convVector,
round: i + 1, timestamp: new Date().toISOString(),
}],
});
}
}
8.3 为什么这套能「更懂你」
对比一下:
- 用户问「我的职业是什么?」------这句话单独发给一个没有记忆的模型,它只能瞎猜或让你说明;
- 但在上面流程里,Milvus 检索会命中历史里那句「我的职业是软件工程师」,连同上下文一起喂给模型 → 它真的记得你。
这和 codex / 记忆型聊天应用的思路完全一致:每聊 20 条就触发一次总结,把摘要和精华存进 Milvus;下次开聊先检索历史,再接着回答。记忆是「越用越厚」的资产。
完整依赖清单:
json
{
"@langchain/community": "^1.1.29",
"@langchain/core": "^1.2.9",
"@langchain/openai": "^1.5.11",
"@zilliz/milvus2-sdk-node": "^3.0.5",
"dotenv": "^17.4.2",
"js-tiktoken": "^1.0.21",
"langchain": "^1.5.10"
}
九、踩坑与经验
.then()不是失败兜底 。async 函数结尾要么.catch(),要么函数内try/catch。.then(console.error)是成功才执行,错了它根本不管。- 切换存储后端不用改业务代码 。
InMemoryChatMessageHistory和FileSystemChatMessageHistory接口一致,换实现只动一行------用接口抽象去设计,别写死具体类。 - tiktoken 编码要跟模型对上。不同模型分词器不同,token 算不准会过早/过晚触发截断。
- token 比「条数」更值得作为淘汰单位。一条超长消息能顶 20 条短的,用条数截断误差很大。
- Milvus 的时间戳没有专门类型,用 VarChar 字符串存 ISO 时间即可;embedding 维度(1024)必须和模型输出一致,否则写入报错。
- embedding 服务国内网络 :用
OPENAI_BASE_URL指到兼容网关,模型名、维度都对得上就行。 - 本地起 Milvus 别硬装 ,官方给了 standalone 的 docker-compose,一条
docker compose up -d最省事(前提是 Docker 配好了国内镜像加速,否则 pull 会超时)。 - 检索失败要降级 :
retrieveRelevantConversations里 catch 后返回[],宁可当普通对话也别让整个流程崩掉。
十、总结
回到开头那三个问题,现在的答案很清晰:
| 问题 | 解法 |
|---|---|
| 模型记不住我 | 每轮对话的 user/assistant 消息都 addMessage 进 history |
| 重启就忘 | 把 history 落盘:FileSystemChatMessageHistory 写 JSON |
| 上下文塞爆 / 太贵 | 截断保新鲜、总结保梗概 |
| 想不起很久以前的细节 | embedding + Milvus 按相似度检索,只带最相关记忆 |
Agent = LLM + Harness 。给模型配了工具它能「干活」,配了 RAG 它能「懂知识」,而 Memory 是把两者粘起来的那根线 ------让 Agent 记得你是谁、聊到哪、在乎什么。从内存到文件再到向量库,本质上是在回答同一个问题:怎么让一个没有记忆的模型,表现得像真的记得你。