4. 消息模型——Agent 的血流系统
消息不只是"用户说一句、AI 回一句"。在这个 Agent 运行时里,消息是 LLM、工具、记忆之间的通用语言。这篇我们从类型定义一路走到 Provider 适配层,把手里的消息系统做成一个完整的工程组件。
前面三篇文章,我们的 Agent 能跑了。主循环在转,工具在调,测试全绿。
但如果你仔细看代码,会发现一个微妙的问题:整个系统的血液——消息——用的还是最基本的类型定义。Message 就四个字段,没有校验,没有序列化保障,没有 token 预算管理。
这在 Agent 只有两轮对话的时候不是问题。但当对话变长、工具调用变多、需要跨 Session 持久化,消息格式的任何一个漏洞都会酿成灾难。
比如,一个 tool 角色的消息如果没有 toolCallId,LLM 就不知道这个结果对应哪个工具调用。一个 assistant 消息如果错误地挂了 toolCallId,某些 Provider 的 SDK 会直接抛异常。
这不是假设。我踩过这些坑。
所以这一篇不做花哨的功能。我们要把消息系统做一个彻底的工程化——从类型定义、到运行时校验、到 token 预算管理、再到 Provider 格式适配,一步到位。
这是后续所有模块的地基。
消息的 DNA 设计
先从最底层说起。一个 Message 应该长什么样?
export type MessageRole = 'system' | 'user' | 'assistant' | 'tool';
export interface Message {
role: MessageRole;
content: string;
toolCalls?: ToolCall[];
toolCallId?: string;
toolName?: string;
isError?: boolean;
metadata?: Record<string, unknown>;
}
四个角色,七个字段。不多,但每个字段的存在都有它的道理。
为什么 toolCalls 放在 assistant 消息上而不是独立的消息类型?
这是一个有争议的决策。OpenAI 的 API 把 tool_calls 放在 assistant 消息里,Anthropic 则把它作为独立的 tool_use content block。我选择了 OpenAI 的路线。
原因不是我喜欢 OpenAI。而是工具调用决策和决策理由在语义上属于同一条消息。LLM 说"我想调用计算器,因为用户问了一个数学问题"——这个"因为"部分写在 content 里,调用本身写在 toolCalls 里。如果分成两条消息,就丢失了"这个决策来自哪个推理步骤"的关联。
为什么 toolCallId 和 toolName 是 tool 消息的独立字段,而不是嵌套在 ToolCallResult 里?
因为扁平化。嵌套结构在序列化和反序列化时容易出问题——JSON.parse(JSON.stringify(msg)) 会丢失 undefined 字段,嵌套对象的类型守卫也更复杂。扁平字段让每个 tool result 消息自包含:它知道自己是哪个工具调用的结果,不依赖上下文。
export interface ToolCall {
id: string;
name: string;
args: Record<string, unknown>;
}
ToolCall 的 args 是 Record<string, unknown>。不是 any,也不是具体类型。因为工具调用时参数来自 LLM 的 JSON 输出,我们不知道它长什么样——甚至不知道它是不是合法。unknown 强迫你在使用前做类型窄化,这是 TypeScript 能给你的最好保护。
还有一个值得讨论的设计边界:content 为什么是 string 而不是更灵活的内容块结构?
Anthropic 的 ContentBlock[] 支持在一个消息里混排文本、图片、tool_use。如果 content 是 string,图片数据只能通过额外字段或 metadata 传入,多段文本只能拼接。
这个取舍我考虑过。选择 string 的原因很实际:这个系列的定位是纯文本 Agent 运行时核心。图片、音频、多段文本目前不在范围内,引入 ContentBlock 联合类型会让消息结构膨胀,每个操作消息的代码都要处理 string | ContentBlock[] 的分支。用一个 Record<string, unknown> 的 metadata 字段兜住特殊情况,比一开始就上复杂的联合类型更务实。如果后续系列需要多模态支持,可以在 Message 上加一个 contentBlocks 可选字段,不影响现有逻辑。
还有一个细节:metadata 字段。Record<string, unknown> 又是一个逃生舱口。Session 系统可以在上面挂 sessionId,监控系统可以挂 latency,记忆系统可以挂 importance。核心类型不需要知道这些,metadata 给它们留了一个自由空间。
给消息建个质检站
类型定义只能做编译期检查。运行时呢?如果有人构造了一个不合法的消息序列传给 LLM,类型系统救不了你。
举个例子,下面这个消息序列就是非法的:
const invalid = [
userMessage('1+1=?'),
assistantMessage('Let me calculate.'),
toolResultMessage('call_xyz', 'calculator', '2'), // 没有对应的 tool_call!
];
LLM 收到一个 tool result 但看不到对应的 tool call,会困惑这个结果从哪来的。某些 Provider 的 API 甚至会直接 400。
这种错误在开发阶段就要拦住。所以我在消息系统里内置了校验函数:
export function validateMessage(msg: Message): MessageValidation {
const errors: string[] = [];
if (!msg.role) { /* ... */ }
if (msg.role === 'tool') {
if (!msg.toolCallId) {
errors.push('Tool message must have toolCallId');
}
if (!msg.toolName) {
errors.push('Tool message must have toolName');
}
}
if (msg.toolCalls && msg.toolCalls.length > 0) {
if (msg.role !== 'assistant') {
errors.push('Only assistant messages can have toolCalls');
}
for (let i = 0; i < msg.toolCalls.length; i++) {
const tc = msg.toolCalls[i];
if (!tc.id) errors.push(`toolCalls[${i}].id is required`);
// ...
}
}
// ...
}
这不仅仅是"字段存在"的检查。它检查的是角色和字段之间的逻辑一致性:
tool角色必须有toolCallId和toolName,但toolCalls不能出现在 tool 消息上assistant不能有toolCallId和toolName(这些属于 tool 角色)- 只有
assistant可以携带toolCalls
单条校验只是第一步。序列校验才是真正的防线:
export function validateConversation(messages: Message[]): MessageValidation {
const errors: string[] = [];
for (let i = 1; i < messages.length; i++) {
const prev = messages[i - 1];
const curr = messages[i];
if (curr.role === 'tool') {
const hasMatchingToolCall = prev.role === 'assistant' &&
prev.toolCalls?.some((tc) => tc.id === curr.toolCallId);
if (!hasMatchingToolCall) {
errors.push(
`messages[${i}]: tool message has no matching tool_call`
);
}
}
const SAME_ROLE_BLOCKS = new Set(['user', 'assistant']);
if (
SAME_ROLE_BLOCKS.has(prev.role) &&
prev.role === curr.role &&
!prev.toolCalls
) {
errors.push(`consecutive '${prev.role}' messages without tool calls`);
}
}
// ...
}
这里的核心逻辑有两条:
第一,每个 tool 消息的前一条必须是 assistant,且那个 assistant 消息的 toolCalls 里必须有匹配的 id。这保证了你不会丢失 tool call→tool result 的对应关系。
第二,不能连续出现两条 user 或两条 assistant(除非中间的 assistant 带有 toolCalls)。因为如果两个 user 消息连在一起,LLM 会以为第二条是新的对话开始。两个 assistant 消息连在一起更糟糕——大多数 Provider 的 SDK 直接报错。
你可能觉得"谁会写出这种序列"。但我见过 Agent 在错误处理时不小心 push 了重复消息。校验函数就是给这种意外上个保险。
测试里覆盖了这些场景:
it('rejects consecutive user messages without tool', () => {
const msgs = [userMessage('Hi'), userMessage('Hello?')];
const result = validateConversation(msgs);
expect(result.valid).toBe(false);
});
Token 预算管理
对话越长,token 消耗越大。当你的 Agent 跑了十几个工具调用轮次后,消息列表的长度可能会膨胀到三四百条。每次调 LLM 都要把这些消息全部发过去——token 和金钱都在燃烧。
所以消息系统必须有一个 预算管理机制。
但问题来了:token 数只有在真正调 LLM 的时候才能精确知道。不同的 tokenizer(OpenAI 的 cl100k、Anthropic 的、本地模型的)给出的计数都不一样。在调 LLM 之前做精确预算是不可能的。
所以我的策略是:先估算,后精确。在消息管理层用估算值做决策,在真正调 LLM 时用 Provider 返回的精确值校准。
估算器长这样:
export class TokenEstimator {
constructor(options?: {
chineseCharWeight?: number; // 中文字符权重,默认 2.0
messageOverhead?: number; // 每条消息固定开销,默认 4 token
}) {
this.chineseCharWeight = options?.chineseCharWeight ?? 2.0;
this.messageOverhead = options?.messageOverhead ?? 4;
}
estimateText(text: string): number {
if (!text) return 0;
let tokens = 0;
for (const char of text) {
if (/[\u4e00-\u9fff]/.test(char)) {
tokens += 2.0; // 中文字符权重更高
} else if (!/\s/.test(char)) {
tokens += 0.25; // 英文字符每 4 个 ≈ 1 token
}
}
return Math.ceil(tokens);
}
}
这个算法不精确——精确的 tokenizer 需要 BPE 词表。但它有一个关键优势:快。O(1) 级别的计算,遍历一遍字符串就够了。而精确 tokenizer 需要加载几十 MB 的词表文件,对 Node.js 应用来说是不小的开销。
对于预算管理来说,"近似正确"比"精确错误"要好。估算值可能偏差 20%,但它足够做决策——如果估算值已经接近预算上限,你肯定要开始裁剪了。
有了估算器,接下来就是怎么管理消息窗口。消息窗口比 token 估算更有意思,因为涉及到什么是"完整的一轮对话"。
简单方案:超过预算就从最旧的开始删,删到预算以内。
这个方案在大多数场景下都能工作。但它有一个缺陷:删了一半的对话轮次。比如你删了 user 的消息但保留了 assistant 的回答,LLM 就不知道这个回答是针对什么的。
更好的方案是按对话轮次裁剪。
注意 MessageWindow 还接受一个 trimStrategy 参数:'drop_oldest' | 'drop_oldest_pair' | 'summarize'。前两个是具体实现,summarize 是一个预留策略位——未来的实现会让 LLM 或本地算法对中间轮次做摘要压缩,保留语义信息的同时压缩 token。当前它退化为 drop_oldest_pair,保证接口在语义上是完整的。ConversationContext 则是将消息列表与 token 预算封装在一起,方便在 Agent 各模块间传递带预算的消息视图。
按轮次裁剪的实现:
export class MessageWindow {
private dropOldestPair(): void {
// 找到最早的一轮:user → (assistant + tools)*
const userIdx = this.messages.findIndex(
(m, i) => i >= startIdx && m.role === 'user'
);
if (userIdx === -1) {
this.messages.splice(startIdx, 1);
return;
}
// 从 userIdx 开始,找到这一轮的结束
let endIdx = userIdx + 1;
while (endIdx < this.messages.length) {
const role = this.messages[endIdx].role;
if (role === 'user' || role === 'system') break;
endIdx++;
}
this.messages.splice(userIdx, endIdx - userIdx);
}
}
这个函数找到最早的一个 user 消息,然后一直往后扫描直到遇到下一个 user 或 system——这中间的整块内容就是一轮完整的对话。删就整轮删,不把对话拆得七零八落。
另外还有一个更细致的裁剪策略:如果 assistant 消息带有 toolCalls,那么删掉这个 assistant 的同时也要删掉后面对应的 tool result 消息。否则 tool result 就会变成"没有接收方的包裹"。
private dropOldest(): void {
// ...
if (removed.role === 'assistant' && removed.toolCalls) {
const toolCallIds = new Set(removed.toolCalls.map((tc) => tc.id));
this.messages = this.messages.filter(
(m) => !(m.role === 'tool' && m.toolCallId && toolCallIds.has(m.toolCallId))
);
}
}
测试验证了这种完整性:
it('removes tool results when their assistant is dropped', () => {
// 添加 system + 带 tool call 的一轮对话
// 确保裁剪后没有 tool result 丢失对应的 assistant
for (const m of msgs) {
if (m.role === 'tool') {
const hasAssistant = msgs.some(
(am) => am.role === 'assistant' &&
am.toolCalls?.some((tc) => tc.id === m.toolCallId)
);
expect(hasAssistant).toBe(true);
}
}
});
LLM 适配层
到目前为止,我们的 Message 类型是完全自洽的。但它有一个问题:外面的大模型厂商不认识它。
OpenAI Chat Completions API 的消息格式长这样:
{
role: 'assistant',
tool_calls: [{
id: 'call_abc',
type: 'function',
function: { name: 'calculator', arguments: '{"x":1}' }
}]
}
Anthropic Messages API 长这样:
{
role: 'assistant',
content: [
{ type: 'text', text: 'Let me calculate...' },
{ type: 'tool_use', id: 'call_abc', name: 'calculator', input: { x: 1 } }
]
}
两种格式,同一个语义。但字段名、嵌套结构、content 类型全都不一样。
解决方案很直接:写适配器。
适配器是双向的。向外转换是 toOpenAIMessages,向内转换是 fromOpenAIToolCalls——把 OpenAI API 返回的 tool_calls 转回内部的 ToolCall[] 格式:
export function fromOpenAIToolCalls(
toolCalls?: OpenAIMessage['tool_calls']
): ToolCall[] | undefined {
if (!toolCalls || toolCalls.length === 0) return undefined;
return toolCalls.map((tc) => ({
id: tc.id,
name: tc.function.name,
args: safeParseJSON(tc.function.arguments),
}));
}
function safeParseJSON(text: string): Record<string, unknown> {
try {
return JSON.parse(text) as Record<string, unknown>;
} catch {
return {}; // LLM 输出脏 JSON 时静默容错
}
}
safeParseJSON 值得单独讲两句。LLM 输出的 JSON 不总是合法的——多余逗号、省略引号、单引号替代双引号,真实世界中这些情况都会出现。safeParseJSON 选择返回空对象而不是抛异常,是一个有意的设计决策:Agent 在脏数据面前应该继续运行,而不是崩溃。空的 args 可能会导致工具执行出意料的结果,但这个结果会以 tool_error 的形式反馈给 LLM,让它在下一轮修正。Agent 的工作流里有纠错机制,不需要在数据解析层做刚性校验。
适配器的核心原则是纯函数。给一组 Message[],返回 Provider 专有格式。不依赖状态,不保存上下文,便于测试。
export function toOpenAIMessages(messages: Message[]): OpenAIMessage[] {
return messages.map((msg) => {
const base: OpenAIMessage = { role: msg.role, content: msg.content || null };
if (msg.role === 'assistant' && msg.toolCalls) {
base.tool_calls = msg.toolCalls.map((tc) => ({
id: tc.id,
type: 'function',
function: {
name: tc.name,
arguments: JSON.stringify(tc.args),
},
}));
}
if (msg.role === 'tool') {
base.tool_call_id = msg.toolCallId;
base.name = msg.toolName;
}
return base;
});
}
Anthropic 的适配器更有意思,因为它的消息模型和我们的有结构差异:
export function toAnthropicMessages(messages: Message[]): AnthropicMessage[] {
// system prompt 通过独立参数传递,不放在 messages 数组里
// tool_use 放在 assistant 的 content 数组里
// tool_result 放在 user 的 content 数组里(Anthropic 的特殊约定)
// ...
}
注意这里的一个设计决策:token 估算器 + Provider 适配器 + 消息校验器 是独立模块,不耦合。LLM Provider 的实现可以独立使用适配器做格式转换,测试可以分别验证每个模块。
为什么这么拆?因为你的 Agent 可能同时支持多个 Provider。OpenAI 用一个适配器,Anthropic 用另一个,估算器是共享的,校验器也是共享的。如果把它们耦合在 Provider 实现里,每次新增 Provider 你都要复制一份校验逻辑。
测试驱动的设计
写这篇的代码时,我遵循了一个原则:先写测试,后写实现。
不是因为我特别自律,而是因为消息系统太容易出边界情况。不写测试直接写代码,最后一定会有遗漏。
来看看我踩到的几个边界情况:
空 content 的处理。OpenAI 的 API 要求 content 字段存在但可以是 null(当消息只有 tool_calls 时)。我们的 Message.content 是 string 类型,默认是空字符串而非 null。适配器里需要做这个转换。
toolCalls 的 id 必须是唯一且非空的。如果 LLM 生成了一个没有 id 的 tool_call,校验器就应该报错。但 ToolCall.id 的类型是 string,空字符串也能通过编译。运行时校验是唯一的防线。
Anthropic 的 tool_result 应该属于哪个 role。一开始我按照直觉把 tool_result 放在 assistant 消息里,测试告诉我 Anthropic 的 API 要求 tool_result 放在 user 消息里。不写测试直接调 Anthropic,你会花半小时看 400 错误。
为什么消息系统值得认真设计
写到这里你可能觉得:三篇文章之前就已经定义好了 Message 类型,为什么现在又花一整篇来讲它?
因为定义类型和实现系统是两回事。
前一篇的定义是这样的:Message 有四个角色、几个可选字段。完事。
而这一篇你看到的是:
- 消息校验器(运行时保证结构正确)
- Token 估算器(在调 LLM 之前做预算管理)
- 消息窗口(智能裁剪,保持对话完整性)
- Provider 适配器(在统一模型和外部 API 之间翻译)
这些不是"锦上添花"的功能。没有它们,你的 Agent 在跑完第 5 轮对话后可能就会因为消息格式错误而崩溃,或者因为 token 超限而报 400。
消息系统就像水管。你平时感觉不到它的存在,但一旦漏水,整个房子都得遭殃。
下一篇预告
消息系统立好了,Agent 有了"能说会道"的基础。但对话本身不会自动产生价值——Agent 需要记住它说过什么、做过什么、用户喜欢什么。
下一篇,我们实现记忆模块。短期记忆的滑动窗口怎么和长期记忆的知识库配合?什么时候提取、什么时候遗忘?consolidate 策略怎么设计?
这些问题下一篇展开。
评论
发表评论