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 的核心结构:
1interface UIMessage<METADATA = unknown, DATA_PARTS extends UIDataTypes = UIDataTypes> {2id: string3role: 'system' | 'user' | 'assistant'4metadata?: METADATA5parts: UIMessagePart<DATA_PARTS>[]6}
几个关键字段:
id:消息唯一标识。useChat内部会自动生成,你可以直接用它当 React 的keyrole:system/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-{name} | { 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-{name} | { type: 'data-custom', data: ... } | 自定义数据 part |
step-start | 标记一次 step 的开始 | 用于多步 Agent 的 UI 分隔 |
看一个典型的 UIMessage 实例(来自 AI 伴侣的一次对话):
01const message: UIMessage = {02id: 'msg_1a2b',03role: 'assistant',04parts: [05{06type: 'reasoning',07text: '用户提到了上次周五说的难过的事,我应该先检索记忆。',08state: 'done',09},10{11type: 'tool-searchMemory',12toolCallId: 'call_1',13state: 'output-available',14input: { query: '周五 难过' },15output: [16{ content: '上周五用户说 ...', intimacy: 0.7 },17],18},19{20type: 'text',21text: '我记得你上周五说到工作的压力很大,今天感觉好一些了吗?',22state: 'done',23},24],25}
前端组件按 part 类型分别渲染:
01function AssistantMessage({ message }: { message: UIMessage }) {02return (03<div>04{message.parts.map((part, i) => {05if (part.type === 'reasoning')06return <ThinkingBubble key={i} text={part.text} />0708if (part.type === 'text')09return <TextSegment key={i} text={part.text} streaming={part.state === 'streaming'} />1011if (part.type.startsWith('tool-'))12return <ToolCard key={i} part={part} />1314if (part.type === 'source-url')15return <SourceLink key={i} url={part.url} title={part.title} />1617return null18})}19</div>20)21}
这种「按 part 分片渲染」的模式,UI Message Parts 会完整展开。
3. ModelMessage:后端的消息
ModelMessage 的结构更接近 OpenAI 原生 Chat API:
1type ModelMessage =2| SystemModelMessage3| UserModelMessage4| AssistantModelMessage5| ToolModelMessage
各自的形态:
01interface SystemModelMessage {02role: 'system'03content: string04}0506interface UserModelMessage {07role: 'user'08content: string | Array<TextPart | ImagePart | FilePart>09}1011interface AssistantModelMessage {12role: 'assistant'13content: string | Array<TextPart | ReasoningPart | FilePart | ToolCallPart>14}1516interface ToolModelMessage {17role: 'tool'18content: Array<ToolResultPart>19}
和 UIMessage 的主要差异整理成表:
| 维度 | UIMessage | ModelMessage |
|---|---|---|
| 消息数组? | parts 数组 | content 是字符串或数组 |
| 角色数量 | 3 种(system/user/assistant) | 4 种(多了 tool) |
| 工具调用的表达 | 嵌在 assistant.parts 里的 tool-xxx | assistant 发 tool-call,后续独立一条 tool 消息带结果 |
| state / streaming 字段 | 有 | 无 |
| 元数据 | 有 metadata | 无 |
| 用于 | UI 渲染、useChat state | LLM 调用、streamText({ messages }) |
一句话总结:UIMessage 关心怎么展示,ModelMessage 关心怎么准确表达对话语义。
工具调用最能体现这个差异。在 UIMessage 里,一条 assistant 消息就能包含完整的工具调用链(思考 → 调用 → 结果 → 文本);但在 ModelMessage 里,这会被拆成两条独立的消息:
01const modelMessages: ModelMessage[] = [02{03role: 'user',04content: '查一下上周五我们聊了什么。',05},06{07role: 'assistant',08content: [09{ type: 'text', text: '好的,让我回忆一下。' },10{11type: 'tool-call',12toolCallId: 'call_1',13toolName: 'searchMemory',14input: { query: '周五' },15},16],17},18{19role: 'tool',20content: [21{22type: 'tool-result',23toolCallId: 'call_1',24toolName: 'searchMemory',25output: { type: 'json', value: [{ content: '...' }] },26},27],28},29{30role: 'assistant',31content: '我记得你上周五说到...',32},33]
这四条 ModelMessage,对应前端看到的 1 条 assistant UIMessage(里面装着 reasoning + tool part + text)加上一条 user UIMessage。
4. convertToModelMessages:两者之间的桥
前端 useChat 给后端发请求时,默认会把当前 messages: UIMessage[] 直接作为请求体发过去。后端 streamText 不能直接吃 UIMessage,得先转换——这个转换函数就是 convertToModelMessages:
01import { streamText, convertToModelMessages, UIMessage } from 'ai'02import { models } from '@/shared/models'0304export async function POST(req: Request) {05const { messages }: { messages: UIMessage[] } = await req.json()0607const result = streamText({08model: models.chat,09messages: convertToModelMessages(messages),10tools: { /* ... */ },11})1213return result.toUIMessageStreamResponse()14}
convertToModelMessages 做三件事:
- 展平 parts:把一条
UIMessage里的 text / tool-xxx part 拆成多条 ModelMessage - 丢弃 UI-only 字段:
id/state/metadata对 LLM 没意义,一律移除 - 合并相邻段:连续的 text part 合并成单个 text content,少浪费点 token
反过来,后端生成流式响应后,AI SDK 的 toUIMessageStreamResponse 会把 ModelMessage 风格的事件流翻译成 UIMessageStream 协议,前端 useChat 再把流重新组装成 UIMessage[]。这个往返闭环是 AI SDK 最核心的价值之一。
什么时候可能要手写转换
大部分场景下默认 convertToModelMessages 够用。偶尔会遇到下面这些情况。
只想保留最近 N 条消息
1const recentUI = messages.slice(-20)2const modelMsgs = convertToModelMessages(recentUI)
注入系统消息
1const modelMsgs: ModelMessage[] = [2{ role: 'system', content: buildSystemPrompt(userContext) },3...convertToModelMessages(messages),4]
压缩历史对话
超长历史要摘要化。先自己把旧消息摘要成一条 system 消息,再拼上最近 K 条 UIMessage 转换后的结果。
前端 part 不想喂给模型
比如一个 data-emotion part 是业务数据,LLM 不需要看。默认 convertToModelMessages 会忽略 data-* part,所以一般不用管。但如果你想把它注入到 system prompt 里,就得自己写一层转换。
5. Transport:前端发给后端的是什么
useChat 默认通过 DefaultChatTransport 把 UIMessage 数组 POST 给后端。HTTP 请求体长这样:
1{2"id": "chat_abc",3"messages": [4{ "id": "msg_1", "role": "user", "parts": [{ "type": "text", "text": "hi" }] },5{ "id": "msg_2", "role": "assistant", "parts": [{ "type": "text", "text": "hello" }] },6{ "id": "msg_3", "role": "user", "parts": [{ "type": "text", "text": "今天过得怎么样?" }] }7]8}
每次请求,前端都把整个会话历史发过去。短会话没问题,长会话会越来越重。常见两种解法。
解法 A:后端持久化,前端只发最新一条
Transport 可以自定义请求转换:
01import { DefaultChatTransport } from 'ai'0203const transport = new DefaultChatTransport({04api: '/api/chat',05prepareSendMessagesRequest: ({ id, messages }) => {06return {07body: {08id,09// 只发最新一条10message: messages[messages.length - 1],11},12}13},14})
后端根据 id 从 D1 / KV 加载历史,拼上新消息再调模型。适合长期会话、多用户、需要持久化的产品——比如 AI 伴侣。
解法 B:前端压缩上下文
前端维护一个「最近 N 条 + 摘要」的滑动窗口,只发窗口内的消息。实现更简单,但会话连续性会弱一些。
7. 给 AI 伴侣项目定一个消息约定
把前面讲的内容落地到 AI 伴侣项目里。
前端消息结构(UIMessage 的 parts 组合):
1type CompanionAssistantParts = [2// 可选:情绪判定(data part,UI 展示为小标签)3{ type: 'data-emotion', data: { primary: string; intensity: number } }?,4// 可选:检索记忆(tool part,UI 展示为卡片)5{ type: 'tool-searchMemory', ... }?,6// 主体:文字回复7{ type: 'text', text: string },8]
元数据(UIMessage.metadata)用来放业务追踪数据:
1interface CompanionMetadata {2sessionId: string3intimacy: number // 亲密度当前分值4emotionTransition?: { // 情绪是否发生变化5from: string6to: string7}8}
后端入口统一结构:
01export async function POST(req: Request) {02const { id, messages } = await req.json()0304// 注入 system prompt(从用户画像动态生成)05const userProfile = await loadUserProfile(id)06const modelMsgs: ModelMessage[] = [07{ role: 'system', content: buildSystemPrompt(userProfile) },08...convertToModelMessages(messages),09]1011const result = streamText({12model: models.chat,13messages: modelMsgs,14tools: { searchMemory, updateEmotion },15})1617return result.toUIMessageStreamResponse({18messageMetadata: ({ part }) => {19if (part.type === 'finish') {20return {21sessionId: id,22intimacy: userProfile.intimacy,23}24}25},26})27}
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 SDK:prompt vs messages 两个参数的区别、system prompt 的位置、多模态 prompt、prompt 管理策略。