Skip to content

消息协议

要点

  • AI SDK 里有两套长得很像、但职责完全不同的消息类型
  • UIMessage 的核心结构
  • ModelMessage 的结构更接近 OpenAI 原生 Chat API
  • 前端 useChat 给后端发请求时,默认会把当前 messages: UIMessage[] 直接作为请求体发过去
  • useChat 默认通过 DefaultChatTransport 把 UIMessage 数组 POST 给后端

内容

1. 为什么有两套消息模型

AI SDK 里有两套长得很像、但职责完全不同的消息类型:

  • UIMessage——前端世界的消息。useChat 拿到的、组件渲染的、要在 React 里 useState 的那种
  • ModelMessage——后端世界的消息。streamText / generateText 接收的、喂给 LLM Provider 的那种

第一次看到这两套模型时,大多数人第一反应都是:为什么不用一套?

因为这两套模型要解决的问题完全不同:

  • UIMessage 解决的是「如何在界面上把一条消息按段渲染出来」——文本段、思考段、工具调用段、引用来源段、文件段,每种都要单独的 UI
  • ModelMessage 解决的是「如何把对话历史精确喂给 LLM」——LLM 不在乎你 UI 里怎么渲染,它只在乎谁说了什么、什么类型、附带什么工具结果

强行合并只会得到两头都别扭的结构。AI SDK 的做法很直接:前端用 UIMessage,后端用 ModelMessage,中间靠 convertToModelMessages 做转换。

这一篇我们把两者的字段、转换关系、以及在 AI 伴侣项目里怎么用,一次讲清楚。

2. UIMessage:前端的消息

UIMessage 的核心结构:

typescript
// ui-message.ts
interface UIMessage<METADATA = unknown, DATA_PARTS extends UIDataTypes = UIDataTypes> {

  id: string

  role: 'system' | 'user' | 'assistant'

  metadata?: METADATA

  parts: UIMessagePart<DATA_PARTS>[]

}

几个关键字段:

  • id:消息唯一标识。useChat 内部会自动生成,你可以直接用它当 React 的 key
  • rolesystem / user / assistant,和 Chat API 的三种角色对应
  • parts:消息主体,是一个数组。这是 UIMessage 和 ModelMessage 最大的结构差异
  • metadata:可选的业务元数据(比如「这条消息的情绪分值」)。后端 streamText 时可以注入,前端渲染时可以读

parts 数组里放的是一个个 UIMessagePart,常见类型如下:

type载荷用途
text`{ type: 'text', text: string, state?: 'streaming''done' }`
reasoning{ type: 'reasoning', text: string, state?: ... }思考过程(Claude thinking / o1 reasoning)
tool-{ type: 'tool-searchMemory', toolCallId, input, output, state }工具调用(类型名是动态的)
source-url{ type: 'source-url', sourceId, url, title? }RAG 引用(URL)
source-document{ type: 'source-document', sourceId, mediaType, title }RAG 引用(文档)
file{ type: 'file', mediaType, url }附件输出
data-{ type: 'data-custom', data: ... }自定义数据 part
step-start标记一次 step 的开始用于多步 Agent 的 UI 分隔

看一个典型的 UIMessage 实例(来自 AI 伴侣的一次对话):

typescript
// ui-message-example.ts
const message: UIMessage = {

  id: 'msg_1a2b',

  role: 'assistant',

  parts: [

    {

      type: 'reasoning',

      text: '用户提到了上次周五说的难过的事,我应该先检索记忆。',

      state: 'done',

    },

    {

      type: 'tool-searchMemory',

      toolCallId: 'call_1',

      state: 'output-available',

      input: { query: '周五 难过' },

      output: [

        { content: '上周五用户说 ...', intimacy: 0.7 },

      ],

    },

    {

      type: 'text',

      text: '我记得你上周五说到工作的压力很大,今天感觉好一些了吗?',

      state: 'done',

    },

  ],

}

前端组件按 part 类型分别渲染:

tsx
// render-parts.tsx
function AssistantMessage({ message }: { message: UIMessage }) {

  return (

    <div>

      {message.parts.map((part, i) => {

        if (part.type === 'reasoning')

          return <ThinkingBubble key={i} text={part.text} />

        if (part.type === 'text')

          return <TextSegment key={i} text={part.text} streaming={part.state === 'streaming'} />

        if (part.type.startsWith('tool-'))

          return <ToolCard key={i} part={part} />

        if (part.type === 'source-url')

          return <SourceLink key={i} url={part.url} title={part.title} />

        return null

      })}

    </div>

  )

}

这种「按 part 分片渲染」的模式,UI Message Parts 会完整展开。

3. ModelMessage:后端的消息

ModelMessage 的结构更接近 OpenAI 原生 Chat API:

typescript
// model-message.ts
type ModelMessage =

  | SystemModelMessage

  | UserModelMessage

  | AssistantModelMessage

  | ToolModelMessage

各自的形态:

typescript
// model-message-shapes.ts
interface SystemModelMessage {

  role: 'system'

  content: string

}

interface UserModelMessage {

  role: 'user'

  content: string | Array<TextPart | ImagePart | FilePart>

}

interface AssistantModelMessage {

  role: 'assistant'

  content: string | Array<TextPart | ReasoningPart | FilePart | ToolCallPart>

}

interface ToolModelMessage {

  role: 'tool'

  content: Array<ToolResultPart>

}

和 UIMessage 的主要差异整理成表:

维度UIMessageModelMessage
消息数组?parts 数组content 是字符串或数组
角色数量3 种(system/user/assistant)4 种(多了 tool)
工具调用的表达嵌在 assistant.parts 里的 tool-xxxassistant 发 tool-call,后续独立一条 tool 消息带结果
state / streaming 字段
元数据有 metadata
用于UI 渲染、useChat stateLLM 调用、streamText({ messages })

一句话总结:UIMessage 关心怎么展示,ModelMessage 关心怎么准确表达对话语义。

工具调用最能体现这个差异。在 UIMessage 里,一条 assistant 消息就能包含完整的工具调用链(思考 → 调用 → 结果 → 文本);但在 ModelMessage 里,这会被拆成两条独立的消息:

typescript
// model-message-tool.ts
const modelMessages: ModelMessage[] = [

  {

    role: 'user',

    content: '查一下上周五我们聊了什么。',

  },

  {

    role: 'assistant',

    content: [

      { type: 'text', text: '好的,让我回忆一下。' },

      {

        type: 'tool-call',

        toolCallId: 'call_1',

        toolName: 'searchMemory',

        input: { query: '周五' },

      },

    ],

  },

  {

    role: 'tool',

    content: [

      {

        type: 'tool-result',

        toolCallId: 'call_1',

        toolName: 'searchMemory',

        output: { type: 'json', value: [{ content: '...' }] },

      },

    ],

  },

  {

    role: 'assistant',

    content: '我记得你上周五说到...',

  },

]

这四条 ModelMessage,对应前端看到的 1 条 assistant UIMessage(里面装着 reasoning + tool part + text)加上一条 user UIMessage。

4. convertToModelMessages:两者之间的桥

前端 useChat 给后端发请求时,默认会把当前 messages: UIMessage[] 直接作为请求体发过去。后端 streamText 不能直接吃 UIMessage,得先转换——这个转换函数就是 convertToModelMessages

typescript
// server-route.ts
import { streamText, convertToModelMessages, UIMessage } from 'ai'

import { models } from '@/shared/models'

export async function POST(req: Request) {

  const { messages }: { messages: UIMessage[] } = await req.json()

  const result = streamText({

    model: models.chat,

    messages: convertToModelMessages(messages),

    tools: { /* ... */ },

  })

  return result.toUIMessageStreamResponse()

}

convertToModelMessages 做三件事:

  1. 展平 parts:把一条 UIMessage 里的 text / tool-xxx part 拆成多条 ModelMessage
  2. 丢弃 UI-only 字段id / state / metadata 对 LLM 没意义,一律移除
  3. 合并相邻段:连续的 text part 合并成单个 text content,少浪费点 token

反过来,后端生成流式响应后,AI SDK 的 toUIMessageStreamResponse 会把 ModelMessage 风格的事件流翻译成 UIMessageStream 协议,前端 useChat 再把流重新组装成 UIMessage[]。这个往返闭环是 AI SDK 最核心的价值之一。

什么时候可能要手写转换

大部分场景下默认 convertToModelMessages 够用。偶尔会遇到下面这些情况。

只想保留最近 N 条消息

typescript
// trim-messages.ts
const recentUI = messages.slice(-20)

const modelMsgs = convertToModelMessages(recentUI)

注入系统消息

typescript
// inject-system.ts
const modelMsgs: ModelMessage[] = [

  { role: 'system', content: buildSystemPrompt(userContext) },

  ...convertToModelMessages(messages),

]

压缩历史对话

超长历史要摘要化。先自己把旧消息摘要成一条 system 消息,再拼上最近 K 条 UIMessage 转换后的结果。

前端 part 不想喂给模型

比如一个 data-emotion part 是业务数据,LLM 不需要看。默认 convertToModelMessages 会忽略 data-* part,所以一般不用管。但如果你想把它注入到 system prompt 里,就得自己写一层转换。

5. Transport:前端发给后端的是什么

useChat 默认通过 DefaultChatTransport 把 UIMessage 数组 POST 给后端。HTTP 请求体长这样:

json
// index.json
{

  "id": "chat_abc",

  "messages": [

    { "id": "msg_1", "role": "user", "parts": [{ "type": "text", "text": "hi" }] },

    { "id": "msg_2", "role": "assistant", "parts": [{ "type": "text", "text": "hello" }] },

    { "id": "msg_3", "role": "user", "parts": [{ "type": "text", "text": "今天过得怎么样?" }] }

  ]

}

每次请求,前端都把整个会话历史发过去。短会话没问题,长会话会越来越重。常见两种解法。

解法 A:后端持久化,前端只发最新一条

Transport 可以自定义请求转换:

typescript
// transport.ts
import { DefaultChatTransport } from 'ai'

const transport = new DefaultChatTransport({

  api: '/api/chat',

  prepareSendMessagesRequest: ({ id, messages }) => {

    return {

      body: {

        id,

        // 只发最新一条

        message: messages[messages.length - 1],

      },

    }

  },

})

后端根据 id 从 D1 / KV 加载历史,拼上新消息再调模型。适合长期会话、多用户、需要持久化的产品——比如 AI 伴侣。

解法 B:前端压缩上下文

前端维护一个「最近 N 条 + 摘要」的滑动窗口,只发窗口内的消息。实现更简单,但会话连续性会弱一些。

7. 给 AI 伴侣项目定一个消息约定

把前面讲的内容落地到 AI 伴侣项目里。

前端消息结构(UIMessage 的 parts 组合):

typescript
// companion-message.ts
type CompanionAssistantParts = [

  // 可选:情绪判定(data part,UI 展示为小标签)

  { type: 'data-emotion', data: { primary: string; intensity: number } }?,

  // 可选:检索记忆(tool part,UI 展示为卡片)

  { type: 'tool-searchMemory', ... }?,

  // 主体:文字回复

  { type: 'text', text: string },

]

元数据(UIMessage.metadata)用来放业务追踪数据:

typescript
// companion-metadata.ts
interface CompanionMetadata {

  sessionId: string

  intimacy: number          // 亲密度当前分值

  emotionTransition?: {     // 情绪是否发生变化

    from: string

    to: string

  }

}

后端入口统一结构:

typescript
// companion-route.ts
export async function POST(req: Request) {

  const { id, messages } = await req.json()

  // 注入 system prompt(从用户画像动态生成)

  const userProfile = await loadUserProfile(id)

  const modelMsgs: ModelMessage[] = [

    { role: 'system', content: buildSystemPrompt(userProfile) },

    ...convertToModelMessages(messages),

  ]

  const result = streamText({

    model: models.chat,

    messages: modelMsgs,

    tools: { searchMemory, updateEmotion },

  })

  return result.toUIMessageStreamResponse({

    messageMetadata: ({ part }) => {

      if (part.type === 'finish') {

        return {

          sessionId: id,

          intimacy: userProfile.intimacy,

        }

      }

    },

  })

}

messageMetadata 这个回调让我们能在流的不同阶段往 UIMessage 的 metadata 里注入数据,后续前端实战里还会复用它。

8. 小结

  • UIMessage 服务前端渲染,parts 数组支持多段分片(text / reasoning / tool / source / file / data)
  • ModelMessage 服务 LLM 调用,结构贴近 OpenAI Chat API,多了一个 tool 角色
  • convertToModelMessages 是两者之间的桥,常规场景够用,特殊场景手写
  • Transport 负责前端怎么把消息发给后端,可以自定义 prepareSendMessagesRequest 实现「只发最新一条」
  • AI 伴侣项目约定:前端用 UIMessage + metadata,后端拼 system prompt + convertToModelMessages,返回时通过 messageMetadata 注入业务数据

下一篇进入核心能力区——Prompt 工程 × AI SDKprompt vs messages 两个参数的区别、system prompt 的位置、多模态 prompt、prompt 管理策略。

基于 MIT 协议开源