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 里。如果分成两条消息,就丢失了"这个决策来自哪个推理步骤"的关联。

为什么 toolCallIdtoolName 是 tool 消息的独立字段,而不是嵌套在 ToolCallResult 里?

因为扁平化。嵌套结构在序列化和反序列化时容易出问题——JSON.parse(JSON.stringify(msg)) 会丢失 undefined 字段,嵌套对象的类型守卫也更复杂。扁平字段让每个 tool result 消息自包含:它知道自己是哪个工具调用的结果,不依赖上下文。

export interface ToolCall {
  id: string;
  name: string;
  args: Record<string, unknown>;
}

ToolCallargsRecord<string, unknown>。不是 any,也不是具体类型。因为工具调用时参数来自 LLM 的 JSON 输出,我们不知道它长什么样——甚至不知道它是不是合法。unknown 强迫你在使用前做类型窄化,这是 TypeScript 能给你的最好保护。

还有一个值得讨论的设计边界:content 为什么是 string 而不是更灵活的内容块结构?

Anthropic 的 ContentBlock[] 支持在一个消息里混排文本、图片、tool_use。如果 contentstring,图片数据只能通过额外字段或 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 角色必须有 toolCallIdtoolName,但 toolCalls 不能出现在 tool 消息上
  • assistant 不能有 toolCallIdtoolName(这些属于 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.contentstring 类型,默认是空字符串而非 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 策略怎么设计?

这些问题下一篇展开。


评论

此博客中的热门博文

我写了半年 prompt,最后发现最好的技巧就三个