Skip to content
On this page

记忆与多轮 · 让 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-sqlite3
js
// 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。

要保持清醒 永远不抱有意外的幻想 凭空的期待最要命