从裸 messages 数组到 ChatMessageHistory:记忆的第一层抽象

2 阅读7分钟

「从 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,你能看到它的 typecontent、元数据,序列化、调试都方便。

第二层抽象:把"一段历史"变成可管理的对象

消息有了类型,但"一串消息"还得有统一的管理动作——追加、读取、清空。于是有了 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 是模型返回的 AIMessageaddMessage(resp1) 时没做任何"转回你的格式"的操作——模型吐出来的对象,就是历史要存的对象。这就是消息抽象最大的红利:输入、历史、输出,三者共用同一种对象语言。

这一步到底"值钱"在哪

如果你只是想跑通一个聊天 demo,裸数组完全够用。这个抽象的意义,是给后面的每一层铺路

  1. 为"管理"铺路:有了 getMessages(),后面做截断(只取最近 N 条 / N token)、做总结(把被挤掉的老消息送去压缩),都是在"读出一段历史"之后的事。窗口管理真正操作的是这一段结构化历史,而不是散落的数组。

  2. 为"存储"铺路ChatMessageHistory 是一层接口,InMemory 只是它的一种实现。LangChain 还提供了文件版——把构造那行换一下,同样代码就能把历史落进磁盘:

    // 换一行:内存 → 文件(长期记忆的雏形)
    import { FileSystemChatMessageHistory } from "@langchain/community/stores/message/file_system";
    const history = new FileSystemChatMessageHistory({ filePath, sessionId });
    // 之后 addMessage / getMessages 用法完全一样
    

    服务重启、进程退出,历史还在文件里;下次启动 getMessages() 就"恢复记忆"了。只换一行就能换存储,这正是接口抽象的价值。 这一块后面"存储篇"会专门展开。

  3. 为"工程化"铺路:像"给一串普通对象批量装进历史"这种高频动作,一旦历史变成结构对象,就能抽成公共函数。我们项目里把它抽成了 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,统一管理动作;
  • 接口抽象:内存版只是实现之一,换文件版只改一行——这是后续一切长期记忆的地基。

下一篇进入真正烧脑的部分:历史会无限增长,而上下文窗口是有限的。当装不下时,该丢谁、该留谁、要不要先总结一下再丢?——三种窗口管理策略(截断、总结、检索)的取舍。