05 · LangChain 消息类型
建立前端消息、序列化消息和 LangChain 运行时消息的清晰边界。
课时资源
一、学习目标
本课所在阶段:第二阶段 · 结构理解与代码组织。
学完这一课后,你应该能够:
- 理解为什么前端消息、存储消息、LangChain 运行时消息需要是三种不同的结构
- 看懂
ChatMessage、SerializedMessage、BaseMessage各自的职责边界 - 明白
serializeMessages、deserializeMessages、toLangChainMessages三个转换函数各在什么位置被调用 - 知道这一步做完之后,记忆系统和工具调用为什么不需要重新定义消息格式
二、问题背景
第四课把参数解析和事件组装分到了 service 层,但消息本身还只有一种结构:{ id, role, content }。这个结构从前端页面状态、经过 API、到 Agent 全程都是同一份,暂时够用,但它隐藏了一个潜在问题。
随着功能增加,会有三个场景对消息格式提出不同需求:
- 前端渲染需要
role: 'user' | 'assistant',直接决定消息气泡的样式 - 持久化存储需要一种不依赖前端框架的稳定格式,方便写入数据库和从数据库读出
- LangChain 运行时需要
HumanMessage、AIMessage这样的类实例,因为 LangGraph 的StateGraph只认识这些类型
这三种场景如果共用同一个结构,迟早会出现:把 LangChain 消息实例直接存数据库(序列化失败)、或者 Agent 收到了前端格式的 { role: 'user' } 却不知道怎么变成 HumanMessage。
这一课的任务是:在 service 层里先把三套消息格式的转换路径定清楚,后续课程直接复用。
三、核心概念
这一课最重要的概念是:消息在三个边界上各自有最合适的格式,转换发生在边界处。
| 消息类型 | 类型名 | 谁持有 | 特点 |
|---|---|---|---|
| 前端可渲染消息 | ChatMessage | 页面状态 | `role: 'user' |
| 序列化存储消息 | SerializedMessage | service / database | `type: 'human' |
| LangChain 运行时消息 | BaseMessage | agent | HumanMessage / AIMessage 类实例 |
三个转换函数形成了一条单向链路:
ChatMessage → serializeMessages → SerializedMessage → toLangChainMessages → BaseMessage反向恢复(用于从数据库读取后重建页面状态):
SerializedMessage → deserializeMessages → ChatMessage四、整体流程
反向恢复路径(为后续记忆系统预留):
第二张图对应的是"会话恢复"场景:用户刷新页面后,从数据库取出 SerializedMessage[],再用 deserializeMessages 转成前端可渲染的 ChatMessage[]。这一课还没有实现数据库,但这条路径已经设计好了。
五、运行过程
-
前端发出的仍然是
ChatMessage[]page.tsx的messages状态还是ChatMessage[],发给后端的 payload 格式和之前一样,不需要改动。 -
Service 层在解析时做序列化
parseChatRequest现在在拼出完整messages之后,立即调用serializeMessages转成SerializedMessage[],并把serializedMessages连同原始messages一起返回。 -
Agent 接受的是
SerializedMessage[]streamChatResponse把serializedMessages传给streamChat,Agent 的两个出口函数(runChat、streamChat)的参数类型从ChatMessage[]升级为SerializedMessage[]。 -
Agent 内部用
toLangChainMessages完成最后一次转换workflow.invoke({ messages: toLangChainMessages(messages) })把SerializedMessage[]转成 LangGraph 能识别的BaseMessage[],再进入状态图执行。 -
从这一课起,Agent 只依赖
SerializedMessage,与前端格式完全解耦这意味着:以后
ChatMessage加多模态字段(如attachments),不影响 Agent 输入格式;以后从数据库读出保存的SerializedMessage恢复对话,Agent 可以直接消费,不需要转换器。
六、关键代码解析
app/types/chat.ts - 定义三种消息类型及转换函数
本课最重要的文件。新增了 SerializedMessage 类型,以及三个转换函数:serializeMessages、deserializeMessages、toLangChainMessages。
关键代码:
export interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
}
export interface SerializedMessage {
id: string;
type: 'human' | 'ai';
content: string;
}代码解析:
role和type的命名刻意保持不同:role对前端友好(直接决定气泡样式),type对 LangChain 友好(与HumanMessage、AIMessage的内部_getType()返回值对应)。两个字段含义相近但用途不同,混用容易在后续序列化和反序列化时引入 bug。SerializedMessage没有 React 组件需要的isStreaming、toolCallResults等字段,也没有 LangChain 运行时的additional_kwargs、response_metadata等字段。它只保留了跨层传递所需的最小结构,让每个层次只持有自己关心的信息。- 如果
ChatMessage和SerializedMessage用同一个类型,很容易在 React 状态里混入 LangChain 类实例(HumanMessage/AIMessage),导致JSON.stringify时丢失方法、===比较失效、React 渲染报错。建立清晰的边界是这一课最重要的保护措施。
app/types/chat.ts - 三段转换函数
关键代码:
ChatMessage[] -> SerializedMessage[] -> BaseMessage[]把这条链路拆开后,每一层都只处理自己最擅长的表示方式。数据库不会碰 LangChain 类实例,前端也不用知道 HumanMessage 是什么。
export function serializeMessages(messages: ChatMessage[]): SerializedMessage[] {
return messages.map((m) => ({
id: m.id,
type: m.role === 'user' ? 'human' : 'ai',
content: m.content,
}));
}
export function deserializeMessages(messages: SerializedMessage[]): ChatMessage[] {
return messages.map((m) => ({
id: m.id,
role: m.type === 'human' ? 'user' : 'assistant',
content: m.content,
}));
}
export function toLangChainMessages(messages: SerializedMessage[]): BaseMessage[] {
return messages.map((m) =>
m.type === 'ai' ? new AIMessage(m.content) : new HumanMessage(m.content)
);
}代码解析:
- 三个函数都是纯函数:给定输入,返回新数组,没有副作用。这让它们非常容易单独测试——不需要启动服务器、不需要 mock 任何依赖,直接构造数组输入验证输出即可。
serializeMessages和deserializeMessages互为反函数:deserializeMessages(serializeMessages(msgs))应该等价于原始msgs(内容上等价,不是引用相同)。这个性质很重要,记忆系统下一课会依赖它来"存入数据库、取出后恢复"。toLangChainMessages是最后一步:把SerializedMessage[]转成 LangChain 的类实例。注意它的结果不应该被序列化存储——类实例序列化后无法直接反序列化回来。这就是为什么存储层要用SerializedMessage而不是BaseMessage。- 如果省掉
SerializedMessage这一层,直接从ChatMessage转BaseMessage,那么"从数据库恢复"的路径就无从实现——数据库里存的如果是 LangChain 类实例的 JSON,取出来只能得到一个普通对象,无法直接作为HumanMessage使用。SerializedMessage这一层的存在,正是为了给数据库层提供一个可以安全序列化/反序列化的标准格式。
app/services/chat.service.ts - 在解析时进行序列化
在 parseChatRequest 里新增了序列化这一步,并把 serializedMessages 传给下游 Agent。
app/agent/chatbot.ts - 接收序列化消息并转换为 LangChain 消息
接口从接收 ChatMessage[] 升级成接收 SerializedMessage[],内部通过 toLangChainMessages 转成 LangGraph 可用的 BaseMessage[]。
七、常见问题
为什么不直接把 HumanMessage / AIMessage 存进数据库?
LangChain 的消息类是有方法的类实例,JSON.stringify 会丢失方法(只保留数据字段),也可能包含循环引用(additional_kwargs 里有时有复杂对象)。从数据库读出来的是普通 JSON 对象,无法直接当 HumanMessage 实例使用。SerializedMessage 是一个简单的 POJO,序列化/反序列化完全安全。
deserializeMessages 这一课为什么还没真正用到?
这一课还没有用到——因为还没有数据库。它在这里是为第六课(记忆系统)预留的。记忆系统需要从内存 Map 里取出历史消息再渲染到页面,到时候就会用 deserializeMessages 把 SerializedMessage[] 转回 ChatMessage[]。
为什么 SerializedMessage 的 type 字段用 'human' | 'ai' 而不是继续用 'user' | 'assistant'?
因为 'human' 和 'ai' 是 LangChain 消息系统的约定用词,和 HumanMessage、AIMessage 的类名直接对应。用这个命名让转换函数的逻辑更直观,也方便以后直接对照 LangChain 文档。
这一课 page.tsx 有没有变化?
没有变化。前端看到的仍然是 ChatMessage[],这一课的所有改动都在后端(service 层 + agent 层 + types 层)。这再一次说明了分层的价值:消息表达方式的演进不需要动前端。
八、练习题
- 用表格的方式,写出三种消息类型(
ChatMessage、SerializedMessage、BaseMessage)的字段对比和主要使用场景。 - 解释
serializeMessages和toLangChainMessages的区别:为什么要分成两步,而不是直接从ChatMessage[]转BaseMessage[]? - 在
app/types/chat.ts里加一个测试:对同一组ChatMessage[]先序列化再反序列化,验证内容是否保持一致。 - 如果
ChatMessage要加一个attachments?: string[]字段,SerializedMessage是否需要同步修改?这个选择会影响后面哪些层?说明你的判断依据。
九、总结
这一课真正建立的是:三套消息格式各有各的职责边界,转换在明确的位置发生。
只要你已经能说清楚三种消息类型分别在哪里出现、三个转换函数各自做什么、为什么 SerializedMessage 作为中间层是必要的,这一课就掌握了。
和参考项目相比,这一课只处理了纯文本消息的三层格式。参考项目的完整实现还覆盖了多模态内容、工具调用消息(ToolMessage)和从 Supabase 读出的复杂存储格式,但核心的三层边界设计思路是完全一致的。
登录以继续阅读
解锁完整文档、代码示例及更多高级功能。