大模型为什么能记住上一轮?从 InMemoryChatMessageHistory 看对话 Memory
很多"多轮对话"看起来像模型记住了上下文,实际却是应用在做一件非常明确的事:保存消息,再把消息放回下一次请求。
本文根据 src/history.mjs 和 readme.md,拆解一个 LangChain 内存 Memory 示例。你会看到 HumanMessage、AIMessage 如何进入历史,第二轮请求如何重新组装上下文,以及为什么 getMessages() 返回数组后不能再访问 .array。代码与模型调用运行未验证。
先建立一个正确的心智模型
把模型看成一个只处理当前输入的函数:
text
本次请求的 messages → 模型 → 本次 AIMessage
如果下一次请求只发送新的问题,上一轮内容就不在输入里。Memory 的作用是把流程改成:
text
HumanMessage ─┐
AIMessage ├→ History → 下一次 messages → 模型
HumanMessage ─┘
这也是为什么 Memory 更准确地说是"上下文管理",而不是模型凭空获得了永久记忆。
这个示例的最小闭环
源码用 InMemoryChatMessageHistory 建立当前进程中的消息容器:
javascript
const history = new InMemoryChatMessageHistory();
它有两个关键动作:
addMessage(message):保存一条消息;getMessages():拿到全部消息组成的数组。
消息带有 type、content 等属性。系统消息负责设定角色,用户消息表达问题,AI 消息保存模型返回内容。工具消息也可以进入同样的消息历史,但本示例没有展开 Tool 调用。
第二轮为什么能够理解"好吃吗"
第一轮不是直接调用模型,而是先保存用户问题:
javascript
await history.addMessage(userMessage1);
const message1 = [
systemMessage,
...(await history.getMessages()),
];
const response1 = await model.invoke(message1);
await history.addMessage(response1);
这里的最后一步决定了下一轮能否看到 AI 的上一条回答。invoke 只负责返回结果,不会替应用自动维护这份 history。
第二轮的逻辑是相同的:
javascript
await history.addMessage(userMessage2);
const message2 = [
systemMessage,
...(await history.getMessages()),
];
const response2 = await model.invoke(message2);
await history.addMessage(response2);
到这里,历史顺序是"用户、AI、用户、AI"。第二轮的"好吃吗"之所以有上下文,是因为第一轮消息被重新放进了 message2。
一个很典型的数组错误
示例最后会读取全部消息并打印:
javascript
const allMessages = await history.getMessages();
allMessages.forEach((element, index) => {
const prefix = element.type === "human" ? "用户:" : "助手:";
console.log(`${index + 1}. ${prefix}${element.content}`);
});
getMessages() 的返回值已经是数组,所以直接调用 forEach。如果写成:
javascript
allMessages.array.forEach(...);
那么 allMessages.array 是 undefined,自然无法读取 forEach。
还有一个小细节:想打印序号,就必须接收 forEach 的第二个参数 index。它不是自动存在的变量。
Promise 在这条链路里做了什么
示例函数声明为 async,因此调用 inMemoryDemo() 得到的是 Promise。函数内部等待三类异步动作:保存消息、读取消息、调用远程模型。
javascript
async function inMemoryDemo() {
await history.addMessage(userMessage1);
const messages = await history.getMessages();
const response = await model.invoke(messages);
}
入口的 .catch() 和 .finally() 分别承担错误处理与收尾:
javascript
inMemoryDemo()
.catch(console.error)
.finally(() => console.log("done"));
所以日志里出现 done,只能证明异步流程走到了 finally;如果前面发生异常,它仍然会打印。
从 Demo 到生产,还缺哪些东西
内存方案的优点是简单,缺点也很明确:数据只存在当前进程。进程重启、实例切换或需要多用户并发时,不能只依赖这一个容器。
根据笔记,继续演进至少要回答这些问题:
| 问题 | 当前示例 | 后续设计方向 |
|---|---|---|
| 历史存在哪里 | Node.js 内存 | 数据库或其他持久化存储 |
| 如何区分用户 | 未涉及 | 会话 ID 与权限边界 |
| 历史太长怎么办 | 全量传入 | 截断、总结或检索 |
| 重启后能恢复吗 | 不能保证 | 持久化与恢复流程 |
| 多实例共享吗 | 未解决 | 统一存储或状态管理 |
材料没有给出生产数据库、持久化组件或上下文压缩方案,因此这里不把它们包装成已实现功能。
一份可复用的排错顺序
- 确认用户消息在
model.invoke前已经addMessage。 - 确认模型响应成功后又执行了
addMessage(response)。 - 打印
await history.getMessages(),检查消息顺序。 - 检查组装请求时是否使用了展开运算符
...。 - 遍历时直接调用
allMessages.forEach,不要添加.array。 - 若重启后数据消失,确认这是否正是 InMemory 的预期行为。
- 若历史变长,重新评估上下文窗口、成本和压缩策略。
结语:Memory 是一条可观察的消息链
理解 LangChain Memory,先不要把它想成神秘的"记忆能力"。在这个示例里,它就是一条可以检查的消息链:消息被添加,历史被读取,请求被组装,AI 回复再被写回。
这条链路稳定后,持久化、总结和检索只是对"保存什么、取出什么、传给模型什么"的进一步工程化。真正需要先掌握的,是上下文从哪里来,以及它有没有在下一轮请求中被带回去。
标签:LangChain, AI Agent, Memory, JavaScript