我们给大模型扩展了 Tool,让它可以做一些事情而不只是回答问题;给大模型扩展了 RAG,基于 query 获取向量数据库里相关的知识放入 prompt。但这些其实都依赖一个东西:Memory。
大模型是无状态的,你这次调用和下次调用没区别,它并不知道之前你问了什么、回答了什么。有同学说,不对啊,我明明可以基于上次的回答继续问——这是因为你已经做了 Memory 管理。
记得我们之前写的这个循环么:在 messages 数组放入了 SystemMessage,告诉大模型它的角色、功能,然后放入了 HumanMessage(用户的问题),然后 invoke 大模型,这是第一次调用。大模型返回了 AIMessage 和 tool_calls 信息,我们基于 tool_calls 去调用工具,然后把结果封装成 ToolMessage 也放入 messages 数组。循环继续调用大模型,直到不再有 tool_calls,就把那个 AIMessage 返回,这就是最终回复。
这个过程我们循环调用了多次大模型。你觉得大模型是怎么知道之前问过什么、回答过什么的?就是基于 messages 数组,也就是 Memory。如果不做 Memory 管理,大模型根本不知道之前回答过什么,所以说它是无状态的。
但这种 messages 数组不断 push 的 Memory 管理机制显然不靠谱:大模型的上下文大小是有限的(比如 GPT-4o 大概是 200k token),不管这个限制多大,当你无限往 memory 里增加 message 的时候,总是会超的。所以我们要学一些 Memory 的管理策略。
先不看有哪些方案,你自己考虑下应该怎么做:
- 可以只保留最近的几条 message,之前的舍弃掉
- 直接舍弃之前的也不好,可以对之前的做一些总结,保留这个总结和最近的几条 message
- 可以用我们刚学的向量数据库,根据语义检索之前的 message
没错,主流的也就是这三种思路:截断、总结、检索。
其实你每天都在用 memory 的这些策略:你用 Cursor 或者 Claude Code 的时候,会有一个 token 的计数,当达到的时候,会触发总结,然后开始新的一轮计数。Cursor 达到上下文限制会自动触发总结;Claude Code 达到限制自动触发总结,或者也可以 /compact 手动总结(compact 是压实压紧的意思)。
Memory 的 API 演进
还有一个问题,就是 messages 存在哪。现在都是存在内存中的,而实际上可以做持久化,存在文件、Redis、数据库等。
所以之前 Memory 一共有两个维度的 API:
- ChatMessageHistory 相关:它是存储层,也就是 messages 存在哪,可以是内存、文件、数据库等
- 逻辑层:也就是截断、总结、向量数据库这些,每个 xxMemory 类都有一个
chatHistory属性,关联着存储层
但是,这些 memory 的 API,已经全部被废弃了(移到了 @langchain/classic 这个包)。因为它们不够灵活——像之前提到的截断、总结、检索(向量数据库)完全可以自己实现:截断就是根据总 token 数量来保留最近的 message;总结就是调用大模型对之前的 message 生成一个摘要;检索向量数据库就是之前的 RAG 流程,只不过用来对 message 做语义检索。用 memory 这些 API 反而更黑盒而且不灵活,所以新版干脆都去掉了。
但是加了一个 trimMessages 的 API,可以根据 token 来截断消息。所以现在 Memory 相关就剩下了 history + trimMessages 的 API。
我们来写代码试一遍:
mkdir memory-test
cd memory-test
npm init -y
pnpm install dotenv @langchain/core @langchain/openai @langchain/community langchain存储层:内存和文件
内存存储
创建 src/history-test.mjs:
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(`用户: ${userMessage1.content}`);
console.log(`助手: ${response1.content}\n`);
// 第二轮对话(基于历史记录)
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(`用户: ${userMessage2.content}`);
console.log(`助手: ${response2.content}\n`);
// 展示所有历史消息
console.log("[历史消息记录]");
const allMessages = await history.getMessages();
console.log(`共保存了 ${allMessages.length} 条消息:`);
allMessages.forEach((msg, index) => {
const type = msg.type;
const prefix = type === 'human' ? '用户' : '助手';
console.log(` ${index + 1}. [${prefix}]: ${msg.content.substring(0, 50)}...`);
});
}
inMemoryDemo().catch(console.error);用 InMemoryChatMessageHistory 来管理 message,放到内存里,用 addMessage 添加 HumanMessage 和 AIMessage,分别调用了两次大模型。跑一下可以看到:第一次调用大模型,它回答了红烧肉、冬阴功汤;第二次再和它对话,它能知道之前聊过的内容,接着聊。
我们之前是用 messages 数组实现的,现在换成了 InMemoryChatMessageHistory 的 API。
文件存储:长时记忆
保存在文件里也是可以的。你看 Cursor 就是把对话过程持久化了,随时可以找到一个之前的聊天继续聊——这就是 message 持久化的好处,也叫做长时记忆(LTM,long-term memory)。相应的,内存中那种叫短时记忆(short-term memory)。
试一下存到文件的长时记忆,创建 src/history-test2.mjs:
import 'dotenv/config';
import { ChatOpenAI } from '@langchain/openai';
import { FileSystemChatMessageHistory } from "@langchain/community/stores/message/file_system";
import { HumanMessage, SystemMessage } from "@langchain/core/messages";
import path from "node:path";
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 fileHistoryDemo() {
// 指定存储文件的路径
const filePath = path.join(process.cwd(), "chat_history.json");
const sessionId = "user_session_001";
const systemMessage = new SystemMessage(
"你是一个友好的做菜助手,喜欢分享美食和烹饪技巧。"
);
console.log("[第一轮对话]");
const history = new FileSystemChatMessageHistory({
filePath: filePath,
sessionId: sessionId,
});
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(`用户: ${userMessage1.content}`);
console.log(`助手: ${response1.content}`);
console.log(`✓ 对话已保存到文件: ${filePath}\n`);
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(`用户: ${userMessage2.content}`);
console.log(`助手: ${response2.content}`);
console.log(`✓ 对话已更新到文件\n`);
}
fileHistoryDemo().catch(console.error);第一轮对话问红烧肉怎么做,第二轮接着问好吃吗,对话内容会保存到 chat_history.json 文件里。
然后我们再创建第三个文件,加载那个文件里的历史 message,继续问需要的食材(第三轮对话):
const restoredHistory = new FileSystemChatMessageHistory({
filePath: filePath,
sessionId: sessionId,
});
const restoredMessages = await restoredHistory.getMessages();
console.log(`从文件恢复了 ${restoredMessages.length} 条历史消息:`);
const userMessage3 = new HumanMessage("需要哪些食材?");
await restoredHistory.addMessage(userMessage3);
const messages3 = [systemMessage, ...(await restoredHistory.getMessages())];
const response3 = await model.invoke(messages3);
await restoredHistory.addMessage(response3);可以看到,第一轮、第二轮对话都被持久化保存到了文件里,后面可以从文件里恢复前两轮对话,继续第三轮。基于这个完全可以实现 Cursor 这种功能。
ChatMessageHistory 的 API 只是用来存储 message,接下来实现那三种策略。
策略一:截断
创建 src/memory/truncation-memory.mjs:
import { InMemoryChatMessageHistory } from "@langchain/core/chat_history";
import { HumanMessage, AIMessage, trimMessages } from "@langchain/core/messages";
import { getEncoding } from "js-tiktoken";
// ========== 1. 按消息数量截断 ==========
async function messageCountTruncation() {
const history = new InMemoryChatMessageHistory();
const maxMessages = 4;
const messages = [
{ type: 'human', content: '我叫张三' },
{ type: 'ai', content: '你好张三,很高兴认识你!' },
{ type: 'human', content: '我今年 25 岁' },
{ type: 'ai', content: '25 岁正是青春年华,有什么我可以帮助你的吗?' },
{ type: 'human', content: '我喜欢编程' },
{ type: 'ai', content: '编程很有趣!你主要用什么语言?' },
{ type: 'human', content: '我住在北京' },
{ type: 'ai', content: '北京是个很棒的城市!' },
{ type: 'human', content: '我的职业是软件工程师' },
{ type: 'ai', content: '软件工程师是个很有前景的职业!' },
];
// 添加所有消息
for (const msg of messages) {
if (msg.type === 'human') {
await history.addMessage(new HumanMessage(msg.content));
} else {
await history.addMessage(new AIMessage(msg.content));
}
}
const allMessages = await history.getMessages();
// 按消息数量截断:保留最近 maxMessages 条消息
const trimmedMessages = allMessages.slice(-maxMessages);
console.log(`保留消息数量: ${trimmedMessages.length}`);
console.log("保留的消息:", trimmedMessages.map(m => `${m.constructor.name}: ${m.content}`));
}
// 计算消息数组的总 token 数量
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;
}
// ========== 2. 按 token 数量截断(使用 js-tiktoken 计数) ==========
async function tokenCountTruncation() {
const history = new InMemoryChatMessageHistory();
const maxTokens = 100; // 限制最多 100 个 token
const enc = getEncoding("cl100k_base");
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));
}
}
const allMessages = await history.getMessages();
// 使用 trimMessages API:使用 js-tiktoken 计算 token 数量
const trimmedMessages = await trimMessages(allMessages, {
maxTokens: maxTokens,
tokenCounter: async (msgs) => countTokens(msgs, enc),
strategy: "last", // 保留最近的消息
});
// 计算实际 token 数用于显示
const totalTokens = countTokens(trimmedMessages, enc);
console.log(`总 token 数: ${totalTokens}/${maxTokens}`);
console.log(`保留消息数量: ${trimmedMessages.length}`);
console.log("保留的消息:", trimmedMessages.map(m => {
const content = typeof m.content === 'string' ? m.content : JSON.stringify(m.content);
const tokens = enc.encode(content).length;
return `${m.constructor.name} (${tokens} tokens): ${content}`;
}).join('\n '));
}
async function runAll() {
await messageCountTruncation();
await tokenCountTruncation();
}
runAll().catch(console.error);这里有两种计数逻辑:第一种是消息条数,直接 slice 就行;第二种是 token 数量,用 trimMessages 的 API,用 js-tiktoken 这个包来计数。
pnpm install js-tiktoken跑一下可以看到:第一次根据数量截取了 4 条最近的 message,第二次根据 token 来截取。之后把截取的 messages 传给大模型调用就行了。这就是第一种策略,截断。
策略二:总结
创建 src/memory/summarization-memory.mjs:
import 'dotenv/config';
import { ChatOpenAI } from "@langchain/openai";
import { InMemoryChatMessageHistory } from "@langchain/core/chat_history";
import { HumanMessage, SystemMessage, AIMessage, getBufferString } 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 summarizationMemoryDemo() {
const history = new InMemoryChatMessageHistory();
const maxMessages = 6; // 超过 6 条消息时触发总结
const messages = [
{ type: 'human', content: '我想学做红烧肉,你能教我吗?' },
{ type: 'ai', content: '当然可以!红烧肉是一道经典的中式菜肴。首先需要准备五花肉、冰糖、生抽、老抽、料酒等材料。' },
{ type: 'human', content: '五花肉需要切多大块?' },
{ type: 'ai', content: '建议切成 3-4 厘米见方的块,这样既容易入味,口感也更好。切好后可以用开水焯一下。' },
{ type: 'human', content: '炒糖色的时候有什么技巧吗?' },
{ type: 'ai', content: '炒糖色是关键步骤。用小火烧,等冰糖完全融化变成焦糖色,冒小泡时就可以下肉了。' },
{ type: 'human', content: '需要炖多长时间?' },
{ type: 'ai', content: '一般需要炖 40-60 分钟,用小火慢炖,直到肉变得软糯入味。' },
{ type: 'human', content: '最后收汁的时候要注意什么?' },
{ type: 'ai', content: '收汁时要用大火,不断翻动,让汤汁均匀包裹在肉块上。看到汤汁变得浓稠就可以了。' },
];
for (const msg of messages) {
if (msg.type === 'human') {
await history.addMessage(new HumanMessage(msg.content));
} else {
await history.addMessage(new AIMessage(msg.content));
}
}
const allMessages = await history.getMessages();
console.log(`原始消息数量: ${allMessages.length}`);
// 如果消息过多,触发总结
if (allMessages.length >= maxMessages) {
const keepRecent = 2; // 保留最近 2 条消息
// 分离要保留的消息和要总结的消息
const recentMessages = allMessages.slice(-keepRecent);
const messagesToSummarize = allMessages.slice(0, -keepRecent);
console.log("\n💡 历史消息过多,开始总结...");
console.log(`📝 将被总结的消息数量: ${messagesToSummarize.length}`);
console.log(`📝 将被保留的消息数量: ${recentMessages.length}`);
// 总结将被丢弃的旧消息
const summary = await summarizeHistory(messagesToSummarize);
// 清空历史消息,只保留最近的消息
await history.clear();
for (const msg of recentMessages) {
await history.addMessage(msg);
}
console.log(`\n保留消息数量: ${recentMessages.length}`);
console.log(`\n总结内容(不包含保留的消息): ${summary}`);
} else {
console.log("\n消息数量未超过阈值,无需总结");
}
}
summarizationMemoryDemo().catch(console.error);
// 总结历史对话的函数
async function summarizeHistory(messages) {
if (messages.length === 0) return "";
const conversationText = getBufferString(messages, {
humanPrefix: "用户",
aiPrefix: "助手",
});
const summaryPrompt = `请总结以下对话的核心内容,保留重要信息:
${conversationText}
总结:`;
const summaryResponse = await model.invoke([new SystemMessage(summaryPrompt)]);
return summaryResponse.content;
}有 10 条消息,我们只保留最近的 2 条,之前的用 LLM 做总结。这里用到了 getBufferString 的 API,它可以给 HumanMessage、AIMessage 等加上不同的前缀来格式化。
跑一下可以看到:之前的 8 条内容做了总结,然后最近的 2 条保留。这样总结后再继续聊,token 消耗就少了。
当然,更常用的是根据 token 来触发总结,而不是消息条数(src/memory/summarization-memory2.mjs):超过 maxTokens(200)触发总结,从后往前累加消息,保留最近的消息直到达到 keepRecentTokens(80),其余做总结。逻辑和按条数一样,只是触发条件换成了 token 计数——我们每天用的 Cursor、Claude Code 就是这种策略。
策略三:检索
先把 Milvus 跑起来,用代码创建集合、插入数据(src/memory/insert-conversations.mjs):
import "dotenv/config";
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
},
dimensions: VECTOR_DIM
});
const client = new MilvusClient({
address: 'localhost:19530'
});
async function getEmbedding(text) {
const result = await embeddings.embedQuery(text);
return result;
}
async function main() {
try {
console.log('连接到 Milvus...');
await client.connectPromise;
console.log('✓ 已连接\n');
// 创建集合
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 }
]
});
// 创建索引
await client.createIndex({
collection_name: COLLECTION_NAME,
field_name: 'vector',
index_type: IndexType.IVF_FLAT,
metric_type: MetricType.COSINE
});
// 加载集合
await client.loadCollection({ collection_name: COLLECTION_NAME });
// 插入对话数据
const conversations = [
{
id: 'conv_001',
content: '用户: 我叫赵六,是一名数据科学家\n助手: 很高兴认识你,赵六!数据科学是一个很有趣的领域。',
round: 1,
timestamp: new Date().toISOString()
},
{
id: 'conv_002',
content: '用户: 我最近在研究机器学习算法\n助手: 机器学习确实很有意思,你在研究哪些算法呢?',
round: 2,
timestamp: new Date().toISOString()
},
{
id: 'conv_003',
content: '用户: 我喜欢打篮球和看电影\n助手: 运动和文化娱乐都是很好的爱好!',
round: 3,
timestamp: new Date().toISOString()
},
{
id: 'conv_004',
content: '用户: 我周末经常去电影院\n助手: 看电影是很好的放松方式。',
round: 4,
timestamp: new Date().toISOString()
},
{
id: 'conv_005',
content: '用户: 我的职业是软件工程师\n助手: 软件工程师是个很有前景的职业!',
round: 5,
timestamp: new Date().toISOString()
}
];
console.log('生成向量嵌入...');
const conversationData = await Promise.all(
conversations.map(async (conv) => ({
...conv,
vector: await getEmbedding(conv.content)
}))
);
const insertResult = await client.insert({
collection_name: COLLECTION_NAME,
data: conversationData
});
console.log(`✓ 已插入 ${insertResult.insert_cnt} 条记录\n`);
} catch (error) {
console.error('错误:', error.message);
}
}
main();创建集合 conversations 用来保存对话记录(id/vector/content/round/timestamp),然后插入一些数据。这样,我们就把每轮对话格式化后存到了向量数据库里,记录了对话的时间、轮次。
接下来对话的时候,就可以用 RAG 来检索之前的对话内容了(src/memory/retrieval-memory.mjs):
import 'dotenv/config';
import { ChatOpenAI, OpenAIEmbeddings } from "@langchain/openai";
import { InMemoryChatMessageHistory } from "@langchain/core/chat_history";
import { MilvusClient, MetricType } from '@zilliz/milvus2-sdk-node';
import { HumanMessage } from "@langchain/core/messages";
const COLLECTION_NAME = 'conversations';
const VECTOR_DIM = 1024;
const model = new ChatOpenAI({
modelName: process.env.MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
temperature: 0,
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
},
});
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
model: 'text-embedding-v3',
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
},
dimensions: VECTOR_DIM
});
const client = new MilvusClient({
address: 'localhost:19530'
});
async function getEmbedding(text) {
const result = await embeddings.embedQuery(text);
return result;
}
/**
* 从 Milvus 中检索相关的历史对话
*/
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,
metric_type: MetricType.COSINE,
output_fields: ['id', 'content', 'round', 'timestamp']
});
return searchResult.results;
} catch (error) {
console.error('检索对话时出错:', error.message);
return [];
}
}
/**
* 策略 3: 检索(Retrieval)
* 使用 Milvus 向量数据库存储历史对话,根据当前输入检索语义相关的历史
*/
async function retrievalMemoryDemo() {
try {
console.log('连接到 Milvus...');
await client.connectPromise;
console.log('✓ 已连接\n');
} catch (error) {
console.error('❌ 无法连接到 Milvus:', error.message);
console.log('请确保 Milvus 服务正在运行(localhost:19530)');
return;
}
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);
console.log(`\n[第 ${i + 1} 轮对话]`);
console.log(`用户: ${input}`);
// 1. 检索相关的历史对话
console.log('\n【检索相关历史对话】');
const retrievedConversations = await retrieveRelevantConversations(input, 2);
let relevantHistory = "";
if (retrievedConversations.length > 0) {
retrievedConversations.forEach((conv, idx) => {
console.log(`\n[历史对话 ${idx + 1}] 相似度: ${conv.score.toFixed(4)}`);
console.log(`轮次: ${conv.round}`);
console.log(`内容: ${conv.content}`);
});
// 构建上下文
relevantHistory = retrievedConversations
.map((conv, idx) => {
return `[历史对话 ${idx + 1}]
轮次: ${conv.round}
${conv.content}`;
})
.join('\n\n━━━━━\n\n');
} else {
console.log('未找到相关历史对话');
}
// 2. 构建 prompt(使用检索到的历史作为上下文)
const contextMessages = relevantHistory
? [
new HumanMessage(`相关历史对话:\n${relevantHistory}\n\n用户问题: ${input}`)
]
: [userMessage];
// 3. 调用模型生成回答
console.log('\n【AI 回答】');
const response = await model.invoke(contextMessages);
// 保存当前对话到历史消息
await history.addMessage(userMessage);
await history.addMessage(response);
// 4. 将对话保存到 Milvus 向量数据库
const conversationText = `用户: ${input}\n助手: ${response.content}`;
const convId = `conv_${Date.now()}_${i + 1}`;
const convVector = await getEmbedding(conversationText);
try {
await client.insert({
collection_name: COLLECTION_NAME,
data: [{
id: convId,
vector: convVector,
content: conversationText,
round: i + 1,
timestamp: new Date().toISOString()
}]
});
console.log(`💾 已保存到 Milvus 向量数据库`);
} catch (error) {
console.warn('保存到向量数据库时出错:', error.message);
}
console.log(`助手: ${response.content}`);
}
}
retrievalMemoryDemo().catch(console.error);我们调用大模型,问了一些问题("我之前提到的机器学习项目进展如何?"、"我周末经常做什么?"、"我的职业是什么?"),通过 RAG 流程来检索之前的对话,来生成回答。这样,不管之前多久聊过什么内容,都能够继续聊。
聊完之后我们把最新的聊天也存入了向量数据库,这样之后检索就知道我们这次聊了什么。这就是实现 Memory 的第三种策略,检索。
总结 + 检索:组合使用
总结、检索,这俩策略经常同时用。比如你开发一个聊天应用:每聊 20 条就触发一次总结,生成摘要存入 Milvus 向量数据库。你问 AI "我们聊过什么内容",这时候 RAG 就是从 Milvus 的摘要集合里查找,生成回答。
常见问题
总结后的 message 要加入 history 吗? 肯定要——就是用总结替换之前的 message,而不是删掉就完了。
summarize 后 token 还是超出上限怎么办? 一般不会,Claude Code 和 Cursor 都有 token 限制,达到一定 token 就触发总结然后清空之前的 message,不会同时留着很多 memory。
每次请求都把全量内容给模型,很费 token? 你平时用的 Cursor 不也是这样么,不然它怎么知道之前聊过什么,只不过到了一定 token 会总结。另外第二轮确实会重复消耗第一轮的 token,但可以做缓存(cache)优化。
不同会话检索的内容会串掉吗? 用其他字段(比如 xx_id)先过滤,过滤完之后再做向量相似度匹配来检索——和用 MySQL 查询的思路一样,那边一个表怎么区分多个用户的数据,这里也一样,只不过匹配的时候是向量相似度匹配。
每次聊天记录都存向量库,下次获取时要先总结吗? 一般是的:总结后再存入向量数据库,检索出来的是总结,而不是原始的大段聊天记录。
getBufferString 的写法? Node 版是 getBufferString(messages, { humanPrefix: "用户", aiPrefix: "助手" }),不要用 Python 版的字符串参数写法,输出会不对。
对话中涉及 tool_call 行为怎么管理? 比如文件读写可以保留文件路径在对话里,用到的时候再让大模型去读。
text-embedding-v3 没免费额度了? 可以用 text-embedding-v4,或者付费(10 块钱能用很久)。
总结
大模型是无状态的,需要我们管理 Memory——也就是管理给它的 messages,它才能继续之前的话题聊。
- 存储:LangChain 封装了
ChatMessageHistory的 API 用来存储 messages,可以存在内存(InMemoryChatMessageHistory)、文件(FileSystemChatMessageHistory)、Redis、数据库等 - API 演进:之前有各种 memory 的 API,现在都废弃了(不够灵活,完全可自己实现),现在只剩
history + trimMessages - 三种管理策略:
- 截断:超出一定条数、一定 token 数量就去掉之前的 message(
trimMessages+ js-tiktoken 计数) - 总结:调用大模型生成对话摘要,这样就可以删掉原始 message 了
- 检索:结合向量数据库做语义检索,通过 RAG 来检索之前聊的内容
- 截断:超出一定条数、一定 token 数量就去掉之前的 message(
- 我们常用的 Cursor 就是超出一定 token 会触发总结;也可以总结 + 检索组合:在 Milvus 中存储对话总结,然后结合检索来查找
管理好了 memory,就无论什么时候都可以基于之前的话题继续聊了,这是做 AI Agent 开发必须要做的。
