Appearance
记忆与多轮 · 让 Agent 不做"金鱼"
你有没有遇到过这种对话:
你: 我叫小明
AI: 好的,小明!
…过了一会…
你: 我叫什么?
AI: 很抱歉,我不记得了。这不是模型笨,而是模型本身是无状态的——每一次对话它得到的都只是"你现在发给它的这几条消息",它天然没有任何"之前聊过什么"的存档。让它"记得",靠的是你在每次请求时,把过往对话当上下文一起塞给它。
换句话说:"记忆"不是模型的能力,而是"夹带历史"的工程问题。 这跟基础篇"记忆机制"的概念完全对应。
1. 先看没记忆时长什么样
直接裸调模型,它就是"金鱼"——每条消息都是孤立请求:
js
// src/no-memory.js
import 'dotenv/config'
import { ChatOpenAI } from '@langchain/openai'
const model = new ChatOpenAI({
model: process.env.MODEL_NAME ?? 'gpt-4o-mini',
apiKey: process.env.OPENAI_API_KEY,
configuration: { baseURL: process.env.OPENAI_API_BASE },
})
// 第一轮:告诉它名字
await model.invoke([
{ role: 'system', content: '你是一个友好的助手。' },
{ role: 'user', content: '我叫小明,请记住我。' },
])
// 第二轮:问它"我叫什么"——但它根本不记得上一轮!
const reply = await model.invoke([{ role: 'user', content: '我叫什么名字?' }])
console.log(reply.content) // 大概率是 "我不知道 / 你没告诉我"原因一目了然:第二次调用里,我们没把第一轮的消息带上。
2. 手工维护"历史数组"
最简单的方法,自己维护一个消息数组,每轮都把它整个发给模型:
js
// src/memory-manual.js —— 手工夹带历史
import 'dotenv/config'
import { ChatOpenAI } from '@langchain/openai'
const model = new ChatOpenAI({
model: process.env.MODEL_NAME ?? 'gpt-4o-mini',
apiKey: process.env.OPENAI_API_KEY,
configuration: { baseURL: process.env.OPENAI_API_BASE },
})
// ★ 这整个数组,就是"记忆"的载体
const history = [
{ role: 'system', content: '你是小明的私人助理,会记住他的信息。' },
]
async function chat(userText) {
history.push({ role: 'user', content: userText }) // ① 用户的话进历史
const reply = await model.invoke(history) // ② 整个历史发给模型
history.push(reply) // ③ 模型的回答也进历史
return reply.content
}
await chat('我叫小明,喜欢喝美式咖啡。')
// AI: 好的,小明
console.log(await chat('我喜欢喝什么?'))
// 因为你把"喜欢美式咖啡"夹带了,它能答: 美式咖啡看懂没?所谓的记忆,就是每次请求把 history 数组原封不动带着。 模型不是真的"记得",而是"每一轮都重新读一遍记录"。
3. 历史太长怎么办:截断与摘要
夹带历史有个天花板:模型一次能读的上下文有限,越长越贵、越慢。 所以真实项目要"管理"记忆,常见三种手法:
| 手法 | 做法 | 适用 |
|---|---|---|
| 窗口截断 | 只保留最近 N 条消息,旧的全丢 | 最省事,适合闲聊 |
| 摘要压缩 | 把超长的旧历史用模型"压缩成一段摘要" | 要全局记忆又省 token |
| 外部存储 | 真正持久化到 DB,跨会话恢复 | 产品级,记忆不丢 |
窗口截断最简单——留尾巴:
js
// 只保留最近 20 条(外加 system 提示)
function trimHistory(history, maxTurns = 10) {
const system = history.filter(m => m.role === 'system')
const rest = history.filter(m => m.role !== 'system').slice(-maxTurns * 2)
return [...system, ...rest]
}摘要压缩则要"多一次调用",把旧历史压缩成一句:
js
// 伪代码:当历史太长,让模型把旧的部分总结成摘要,替换掉
async function compress(history) {
const old = history.slice(0, -4)
const summary = await model.invoke([
{ role: 'system', content: '把下面的对话压缩成一段简洁的中文摘要,只保留关键事实(名字、偏好、承诺等)。' },
{ role: 'user', content: old.map(m => `${m.role}: ${m.content}`).join('\n') },
])
return [ // 压缩后的历史:摘要 + 最近4条
{ role: 'system', content: `【对话摘要】${summary.content}` },
...history.slice(-4),
]
}LangChain 有现成的摘要节点(ConversationSummaryBufferMemory 等),真实项目直接调,不用自己写——但原理就是上面那段。
4. 跨会话持久化:让"记忆"真的不丢
窗口/摘要都是"会话内"的。产品级需求是:用户明天再来,你还记得他。 这就要把 history 存到数据库。
LangGraph 的 MemorySaver 我们上节在工具篇见过,它把状态存在内存里,进程重启就没了。生产要持久化,换一个能落盘的 saver 即可:
bash
npm install @langchain/langgraph-checkpoint-sqlite better-sqlite3js
// src/agent-with-persistence.js —— 用 SQLite 做持久记忆
import 'dotenv/config'
import { ChatOpenAI } from '@langchain/openai'
import { createReactAgent } from '@langchain/langgraph/prebuilt'
import { SqliteSaver } from '@langchain/langgraph-checkpoint-sqlite'
import Database from 'better-sqlite3'
import { calculator } from './tools/math.js'
const db = new Database(':memory:') // 实际项目换成 './chat.db' 文件,才是真持久化
// 注意: SqliteSaver 需要等待初始化完成
await SqliteSaver.init(db) // 或用 db path; 各版本 API 略不同, 以文档为准
const agent = createReactAgent({
llm: new ChatOpenAI({
model: process.env.MODEL_NAME ?? 'gpt-4o-mini',
apiKey: process.env.OPENAI_API_KEY,
configuration: { baseURL: process.env.OPENAI_API_BASE },
}),
tools: [calculator],
checkpointSaver: new SqliteSaver(db), // ← 把"记忆"落盘
})
// 给每次对话一个唯一的 thread_id,这就是"用户ID"——同一 thread 共享记忆
await agent.invoke(
{ messages: [{ role: 'user', content: '我叫小明,记住我的名字' }] },
{ configurable: { thread_id: 'user-1001' } }
)
// 换个 thread 再问——因为 thread 不同,它不记得
const other = await agent.invoke(
{ messages: [{ role: 'user', content: '我叫什么?' }] },
{ configurable: { thread_id: 'user-1001' } }
)
console.log(other.messages[other.messages.length - 1].content)
// 用同一个 thread_id: 答"小明"; 用别的 thread_id: 不记得这里 thread_id 就是"记忆的钥匙":它把会话按用户/按房间/按项目分组,每个 thread 独立保存自己的消息历史。这就是产品里"每个用户各有各的记忆"的实现方式。
生产更常见的做法:用 Redis 存消息历史(快、可过期),或用数据库表按
(userId, threadId)存,再配合上面的窗口/摘要策略。LangChain 的RedisChatMessageHistory/SQLiteChatMessageHistory之类直接可用。
5. 组合拳:工具 + 记忆 的完整 Agent
把这几节串起来——一个能持续对话、记得住、还会调工具的小助理:
js
// src/assistant.js —— 记忆 + 工具的完整 Agent(伪代码, 结构示范)
import 'dotenv/config'
import { ChatOpenAI } from '@langchain/openai'
import { MemorySaver } from '@langchain/langgraph'
import { createReactAgent } from '@langchain/langgraph/prebuilt'
import { calculator } from './tools/math.js'
import { getWeather } from './tools/weather.js'
const agent = createReactAgent({
llm: new ChatOpenAI({
model: process.env.MODEL_NAME ?? 'gpt-4o-mini',
apiKey: process.env.OPENAI_API_KEY,
configuration: { baseURL: process.env.OPENAI_API_BASE },
}),
tools: [calculator, getWeather],
checkpointSaver: new MemorySaver(),
})
const thread = { configurable: { thread_id: 'dev-001' } }
const r1 = await agent.invoke(
{ messages: [{ role: 'user', content: '我家在上海,常出差,记住' }] },
thread
)
const r2 = await agent.invoke(
{ messages: [{ role: 'user', content: '我现在在上海,今天要出门,需要带伞吗?' }] },
thread
)
// 它会结合"记住你在上海" + 调 getWeather 查天气 → 给出带伞建议这一行 checkpointSaver + thread_id 就是"记忆"的产品化开关。 有了它,Agent 不需要每次对话都把事情重讲一遍。
🧪 本篇自检
- [ ] 能解释"模型本身无状态",记忆靠夹带历史
- [ ] 会手工维护
history数组做多轮 - [ ] 知道窗口截断与摘要压缩的取舍
- [ ] 用
thread_id+ SQLite/Redis 实现跨会话持久化
这一篇的灵魂,一句话:
记忆 = 你每次请求把
历史数组带上;会话内靠窗口/摘要控制长度,跨会话靠thread_id把历史存在 DB 里。 从今往后,你的 Agent 是一个"记得住你"的 Agent。
到这一步,一个单体的 Agent 已经五脏俱全。但产品级的 Agent 往往不止"一个脑袋"——它可能有多个角色、多级分工、甚至人机协作。下一篇我们把所有零件组装成真正能上线的系统: 组装一个 Agent。