前言
在构建 AI Agent 时,很多开发者会发现:大模型本身是无状态的。你上一句告诉它“我叫李四”,下一句问“我叫什么”,它大概率会一脸茫然。
要让 Agent 拥有“记忆”,我们需要一套完善的 Memory 机制。正如架构图所示:Agent = LLM + Harness (Tool + RAG + Memory...)。Memory 是 Agent 理解上下文、实现长期陪伴的核心枢纽。
本文是系列的上篇,我们将从最基础的记忆存储出发,探讨上下文窗口限制带来的开销问题,并手把手实现基于消息截断与消息总结的记忆管理策略。
一、 破局无状态:基础记忆的存储与管理
大模型是无状态的。之前我们通过简单地维护一个 chatMessages 数组来做会话管理。在 LangChain 中,这套机制被抽象成了 InMemoryChatMessageHistory。
1.1 内存短期记忆(InMemory)
我们通过 addMessage 添加 HumanMessage、AIMessage、ToolMessage,调用模型后直接将结果追加到 history 中。每次请求时,通过 getMessages() 获取所有历史消息。
javascript
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_API_BASE_URL }
})
async function inMemoryDemo() {
// 数组升华为内存记忆的实例
const history = new InMemoryChatMessageHistory()
// 独立的系统提示词
const systemMessage = new SystemMessage('你是一个友好、幽默的做菜助手,喜欢分享美食和烹饪技巧')
console.log("[第一轮对话]")
const userMessage1 = new HumanMessage("你今天吃的什么?")
await history.addMessage(userMessage1) // messages 数组添加了对话
const messages1 = [systemMessage, ...(await history.getMessages())]
const response1 = await model.invoke(messages1)
console.log(`助手: ${response1.content} \n`)
await history.addMessage(response1) // 维护 memory
console.log('[第二轮对话: 基于历史记录]')
const userMessage2 = new HumanMessage("好吃吗")
await history.addMessage(userMessage2)
// ... 组装 messages 并调用大模型
}
注释:InMemoryChatMessageHistory 适合短期会话,但程序重启后数据就会丢失。
1.2 文件持久化记忆(FileSystem)
为了实现持久化,我们可以借助 FileSystemChatMessageHistory,将聊天记录存入本地 JSON 文件。
javascript
import path from 'node:path'
import { FileSystemChatMessageHistory } from '@langchain/community/stores/message/file_system'
async function fileHistoryDemo() {
const filePath = path.join(process.cwd(), 'chat_history.json')
const sessionId = "user_session_001" // 多用户隔离
const history = new FileSystemChatMessageHistory({
filePath,
sessionId,
})
// 从文件中恢复 history
const restoredHistory = new FileSystemChatMessageHistory({
filePath,
sessionId,
})
const restoredMessages = await restoredHistory.getMessages()
console.log(`从文件中恢复了${restoredMessages.length}条历史对话`)
// ...继续对话并落盘
}
二、 上下文限制的挑战:截断与总结
随着对话轮次增加,messages 会无限膨胀,面临三大痛点:
- 上下文窗口大小限制(如 200k tokens)。
- 开销巨大(Token 数越多,费用越高)。
- 模型注意力分散(无用信息干扰)。
因此,Memory 的管理逻辑应运而生:截断、总结、检索。
2.1 简单粗暴的截断(Truncation)
按消息数量截断是最简单的做法:
javascript
let allMessages = await history.getMessages();
const maxMessages = 4;
const trimmedMessages = allMessages.slice(-maxMessages); // 只保留最近4条
但消息长度不一,按条数截断容易爆 Token。因此我们需要引入 基于 Token 的精确截断。
借助 js-tiktoken 和大模型自带的 trimMessages 工具:
javascript
import { trimMessages } from '@langchain/core/messages'
import { getEncoding } from 'js-tiktoken'
// 自定义 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;
}
async function tokenCountTruncation() {
const history = new InMemoryChatMessageHistory();
const maxTokens = 100; // token 上限
// ... 添加 messages 的逻辑省略
let allMessages = await history.getMessages();
const enc = getEncoding('cl100k_base'); // 编码器
// 最近的,内容定制的 token 长度计算,截取
const trimmedMessages = await trimMessages(allMessages, {
maxTokens: maxTokens,
tokenCounter: async (msgs) => countTokens(msgs, enc),
strategy: 'last', // 保留最后的
});
}
注释:不同 LLM 的 Token 计算方式不一样,需使用对应编码器。
2.2 更有温度的总结(Summarization)
截断会直接丢失早期记忆。更优雅的做法是:当历史消息过长时,让大模型自己把老消息总结成一段摘要,再清空历史并放入摘要和最近的消息。
javascript
import { getBufferString } from '@langchain/core/messages'
// 总结历史消息
async function summarizeHistory(messages) {
if (messages.length === 0) return '';
// 对象数组 -> 拼接的消息字符串
const conversationText = getBufferString(messages, "用户: ", "助手: ");
const summaryPrompt = `请总结以下对话的核心内容,保留重要信息:\n${conversationText}\n总结:`;
const summaryResponse = await model.invoke([new HumanMessage(summaryPrompt)]);
return summaryResponse.content;
}
async function summarizationDemo() {
// ... 添加 messages
let allMessages = await history.getMessages();
const maxMessages = 6;
const keepRecent = 2; // 保留最近2条
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));
}
}
进阶版还可以基于 Token 数量触发总结(例如 maxTokens = 200,keepRecentTokens = 80),逻辑大同小异,核心在于按 Token 从后往前倒推保留最近对话,其余全部交给 LLM 进行摘要压缩。
小结:
通过 InMemory 和文件存储,我们实现了记忆的基础留存;通过截断和总结,我们解决了上下文窗口限制和 Token 开销问题。
但问题来了:如果用户三周前提到过“我对海鲜过敏”,后来聊了上千条做菜记录,总结机制早就把这件事冲刷掉了怎么办?这就需要引入长期记忆——向量数据库(Vector DB)。