「从 0 搭一套 Agent 记忆系统」系列第 2 篇。上一篇我们达成了一个共识:所谓"多轮记忆",本质是把历史消息重新塞回模型的输入。那紧接着的问题就是——这段历史,代码里到底怎么组织?
上一篇的结论在这里变成现实:既然记忆 = "把历史塞回输入",那"历史"在代码里必须是某种能被规整地追加、读取、再塞回的东西。很多人的第一版(包括我自己)都是最朴素的:
// 第一版:一个普通数组,自己约定格式
const history = []; // 只有"人"和"机"两种,靠字符串区分
history.push({ role: "human", content: "我叫李四,是一名设计师" });
history.push({ role: "ai", content: "你好李四,很高兴认识你!" });
// 每次调用,把数组原样交给模型
const resp = await model.invoke(history);
history.push({ role: "ai", content: resp.content }); // 手动转回你的格式
能用,而且确实能让模型"看起来记得"。但聊着聊着,你会撞上四堵墙。
裸数组的四堵墙
① 消息角色是你自己发明的,模型不认识。
真实对话里不止有"人"和"机"。前面还要跟一句系统指令("你是…"),中间可能穿插工具调用、工具返回的结果。你手动发明 {role: "human"|"ai"} 这套格式,模型端却各自为政——哪天要传工具结果,你又要发明 {role: "tool"}。格式是你写的,规范也只能靠你记住。
② 模型输出的消息,跟你存的消息不是同一套东西。
上面代码里 resp.content 是你从模型响应里掏字符串再拼回去。可模型返回的对象除了正文,还带着 response_metadata(用量、耗时)、可能的 tool_calls 等元数据。你只存正文,等于每转一手就丢一层信息。
③ 追加、读取、清空,全是散落的 push。
哪条是最新消息?怎么只取最近 N 条?怎么整段清空重来?array.push 本身答不了这些问题,你得在每次调用点自己写一遍循环或切片——同一段逻辑在每个 demo 里复制三份。
④ 没有任何"接口"概念,换存储等于重写。
今天历史放内存数组,明天想让服务重启后还记得,改成从 JSON 文件读——因为代码处处直接操作 history 这个数组,换存储就得把整个流程推倒重来。
第一层抽象:把"消息"变成有结构的对象
解药第一步:不要让消息裸奔,给它一个统一的类型体系。
LangChain(以及绝大多数 Agent 框架)会把消息建模成一组 BaseMessage 子类:
BaseMessage
├── HumanMessage 用户说的话
├── AIMessage 助手/模型的回复
├── SystemMessage 系统指令(角色设定、规则)
└── ToolMessage 工具执行后返回的结果
每个消息对象都带统一的属性,type 标识角色、content 装正文,还能挂元数据:
const userMsg = new HumanMessage("红烧肉怎么做?");
const sysMsg = new SystemMessage("你是一个友好的做菜助手");
userMsg.type; // 'human'
userMsg.content; // '红烧肉怎么做?'
为什么要折腾成对象而不是字符串/裸对象?三个现实收益:
- 角色体系统一:人、机、系统、工具——由类型天然区分,不再靠注释和约定;
- 模型输出即历史:
model.invoke()返回的本来就是AIMessage对象,可以直接原样塞回历史,不用掏字符串再拼,一分信息都不丢; - 消息能自描述:打印一个
HumanMessage,你能看到它的type、content、元数据,序列化、调试都方便。
第二层抽象:把"一段历史"变成可管理的对象
消息有了类型,但"一串消息"还得有统一的管理动作——追加、读取、清空。于是有了 ChatMessageHistory 这一类内存历史:
import { InMemoryChatMessageHistory } from "@langchain/core/chat_history";
import { HumanMessage, AIMessage } from "@langchain/core/messages";
const history = new InMemoryChatMessageHistory();
await history.addMessage(new HumanMessage("我叫李四,是一名设计师"));
await history.addMessage(new AIMessage("你好李四,很高兴认识你!"));
const all = await history.getMessages(); // 拿到全部消息(对象数组)
await history.clear(); // 清空
对比裸数组,变化很实在:
| 裸数组 | ChatMessageHistory |
|---|---|
靠 push 手动加,格式自拟 | addMessage(消息对象),格式由类型保证 |
| 取全部/取最近/清空都得自己写 | getMessages() / clear(),语义清晰 |
| 角色靠字符串约定 | Human/AI/System/Tool 类型天然区分 |
| 换存储 = 重写流程 | 背后只是接口,实现可换 |
一段能跑的完整演示
下面是我项目里的一个真实 demo(history-test1):一个"做菜助手",连续两轮对话,且第二轮把历史带上,让模型记得第一轮聊过什么。
const history = new InMemoryChatMessageHistory();
// 固定一句系统指令
const system = new SystemMessage("你是一个友好、幽默的做菜助手,喜欢分享美食和烹饪技巧");
// —— 第一轮 ——
const user1 = new HumanMessage("你今天吃的什么?");
await history.addMessage(user1);
const round1 = [system, ...(await history.getMessages())]; // 系统指令 + 全部历史
const resp1 = await model.invoke(round1);
await history.addMessage(resp1); // ★ 模型返回的 AIMessage 直接塞回历史
// —— 第二轮:只问"好吃吗?",不带任何背景 ——
const user2 = new HumanMessage("好吃吗?");
await history.addMessage(user2);
const round2 = [system, ...(await history.getMessages())]; // 这次历史里有上一轮问答
const resp2 = await model.invoke(round2);
await history.addMessage(resp2);
// 看看到底存了什么
const all = await history.getMessages();
all.forEach((m, i) =>
console.log(`${i + 1}. [${m.constructor.name}] ${m.content.substring(0, 30)}`)
);
// 输出大致是:
// 1. [HumanMessage] 你今天吃的什么?
// 2. [AIMessage] 哈哈,作为AI,我其实没有味蕾……
// 3. [HumanMessage] 好吃吗?
// 4. [AIMessage] 哎呀~你这一问,我虚拟的味蕾差点"过载重启"!
注意两个容易忽略、但很关键的点:
- 第二轮
"好吃吗?"本身没有上下文,是[system, ...history]里的历史让它知道你上一句聊的是"吃了什么"。记忆 = 结构化的历史 + 每次拼回去的动作。 resp1是模型返回的AIMessage,addMessage(resp1)时没做任何"转回你的格式"的操作——模型吐出来的对象,就是历史要存的对象。这就是消息抽象最大的红利:输入、历史、输出,三者共用同一种对象语言。
这一步到底"值钱"在哪
如果你只是想跑通一个聊天 demo,裸数组完全够用。这个抽象的意义,是给后面的每一层铺路:
-
为"管理"铺路:有了
getMessages(),后面做截断(只取最近 N 条 / N token)、做总结(把被挤掉的老消息送去压缩),都是在"读出一段历史"之后的事。窗口管理真正操作的是这一段结构化历史,而不是散落的数组。 -
为"存储"铺路:
ChatMessageHistory是一层接口,InMemory只是它的一种实现。LangChain 还提供了文件版——把构造那行换一下,同样代码就能把历史落进磁盘:// 换一行:内存 → 文件(长期记忆的雏形) import { FileSystemChatMessageHistory } from "@langchain/community/stores/message/file_system"; const history = new FileSystemChatMessageHistory({ filePath, sessionId }); // 之后 addMessage / getMessages 用法完全一样服务重启、进程退出,历史还在文件里;下次启动
getMessages()就"恢复记忆"了。只换一行就能换存储,这正是接口抽象的价值。 这一块后面"存储篇"会专门展开。 -
为"工程化"铺路:像"给一串普通对象批量装进历史"这种高频动作,一旦历史变成结构对象,就能抽成公共函数。我们项目里把它抽成了
util/history.mjs:// util/history.mjs —— 传入 [{type:'human'|'ai', content}],返回装好的历史 export async function createHistory(messages) { const history = new InMemoryChatMessageHistory(); for (const msg of messages) { await history.addMessage( msg.type === "human" ? new HumanMessage(msg.content) : new AIMessage(msg.content) ); } return history; }之后任何演示要初始化一段历史,一行
createHistory(messages)搞定,再也不用来回复制那个 for 循环。
小结
这一篇只做了一件事:把"历史"从裸数据,升级成"有类型的消息对象 + 可管理的历史容器"。
- 消息类型体系:
Human / AI / System / Tool,统一属性,模型输出即历史; - 内存历史:
addMessage / getMessages / clear,统一管理动作; - 接口抽象:内存版只是实现之一,换文件版只改一行——这是后续一切长期记忆的地基。
下一篇进入真正烧脑的部分:历史会无限增长,而上下文窗口是有限的。当装不下时,该丢谁、该留谁、要不要先总结一下再丢?——三种窗口管理策略(截断、总结、检索)的取舍。