开发 LLM app 时,很多人一开始会觉得 message 很简单。大概就是这样:
{
role: "user",
content: "Hello"
}
如果只是调用一个模型,做一个最简单的聊天 demo,这个理解基本够用。但只要应用稍微复杂一点,比如要支持多个 provider、tool calling、图片输入、reasoning 展示、streaming UI、agent 记忆和上下文压缩,message 很快就会变成架构问题。
原因是LLM app 里的 message 不只是聊天记录。它同时承担几种不同职责:
- 它是 provider API 的输入格式。
- 它是模型能理解的上下文。
- 它是应用运行时保存的状态。
- 它也是 UI 或 agent runtime 增量更新的对象。
这几种职责看起来都叫 message,但它们不应该是同一个类型。真实系统里最容易出问题的地方,往往不是某个字段不会转换,而是这些边界没有分清。
这篇文章想讲清楚一个核心问题:
为什么 LLM app 不能直接使用 OpenAI 或 Claude 的 provider message,而要设计自己的 message system?
我们会按下面这张全局图逐层展开:
输入路径是一次模型调用前的静态上下文,可能包含当前用户输入、历史对话、tool result、summary、外部上下文和系统指令,通常会一次性作为 request 发给 provider。输出路径是模型生成时的流式更新,provider stream 会被解析成统一的 message events,再逐步更新 app state。静态 message 的问题是:历史上下文应该如何表达。streaming 的问题是:一条正在生成中的 assistant message 应该如何被逐步构造并同步到应用状态。两者都需要分层。
这里先明确三个 message 的含义:
| 概念 | 是否 provider 相关 | 含义 |
|---|---|---|
| App Message | 否 | 应用内部保存和操作的消息状态,由应用自己定义 |
| Model Message | 否 | 应用在调用模型前整理出的模型输入上下文,是 provider 无关的中间抽象 |
| Provider Message | 是 | 某个具体 provider 的原生 API message 格式,例如 OpenAI message 或 Claude message |
也就是说,Provider Message 不是泛指“给模型看的 message”,而是指某个具体厂商要求的 message 格式。App Message 和 Model Message 则是应用自己抽象出来的通用格式,目的是让业务逻辑不要直接依赖 OpenAI、Claude 或其他 provider 的原生协议。
不同框架会给这两个 provider 无关的抽象起不同名字:
| 全局图里的抽象 | Vercel AI SDK | pi |
|---|---|---|
| App Message | UIMessage | AgentMessage |
| Model Message | ModelMessage | Message |
所以后文讲 Vercel AI SDK 时,UIMessage 对应全局图里的 App Message,ModelMessage 对应 Model Message。讲 pi 时,AgentMessage 对应 App Message,Message 对应 Model Message。
Provider Message 的静态差异
当前我们在 Provider 层。
这一层讨论的是 OpenAI 和 Claude 这类厂商 API 原生接受的 message 格式。它们是发给模型的协议格式,不是应用内部状态。
OpenAI:role、content 和 tool_calls
OpenAI 的 Chat Completions 风格里,一条 message 通常由 role 和 content 组成。简化来看:
type OpenAIMessage = {
role: "system" | "developer" | "user" | "assistant" | "tool";
content?: string | ContentPart[];
tool_calls?: ToolCall[];
};
这里最重要的是 role。OpenAI 的 role 比较多,system 或 developer 用来表达高优先级指令,user 表示用户输入,assistant 表示模型输出,tool 表示工具执行结果。
在最普通的聊天场景里,消息历史可能是这样:
[
{
"role": "system",
"content": "You are a helpful assistant."
},
{
"role": "user",
"content": "What is the weather in Beijing?"
},
{
"role": "assistant",
"content": "Let me check that for you."
}
]
真正让 message 变复杂的是 tool calling。在 OpenAI 里,工具调用不是 content 里的一个 block,而是 assistant message 上的一个独立字段:tool_calls。
一个典型流程是:
user message
↓
assistant message with tool_calls
↓
tool message
↓
assistant message
简化后长这样:
[
{
"role": "user",
"content": "What's the weather in Beijing?"
},
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\":\"Beijing\"}"
}
}
]
},
{
"role": "tool",
"tool_call_id": "call_1",
"content": "20°C, sunny"
},
{
"role": "assistant",
"content": "The weather in Beijing is 20°C and sunny."
}
]
这个设计的特点是:文本、工具调用和工具结果不是同一种东西。文本主要放在 content,工具调用放在 assistant message 的 tool_calls,工具结果则用一条独立的 role: "tool" message 表达。
所以 OpenAI 的 message 模型可以理解成偏 message-level fields 的设计。很多语义是通过 message 顶层字段表达的。
Claude:role 和 content blocks
Claude 的 Messages API 看起来也有 role 和 content,但它对 content 的理解更统一。简化来看:
type AnthropicMessage = {
role: "user" | "assistant";
content: ContentBlock[];
};
Claude 的 role 更少,主要就是 user 和 assistant。它没有 OpenAI 那种独立的 tool role。工具调用和工具结果都通过 content block 表达。
例如,一条 assistant message 里可以同时包含文本和工具调用:
{
"role": "assistant",
"content": [
{
"type": "text",
"text": "Let me check that."
},
{
"type": "tool_use",
"id": "toolu_1",
"name": "get_weather",
"input": {
"city": "Beijing"
}
}
]
}
工具执行结果也不是 role: "tool",而是放在 user message 的 content block 里:
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_1",
"content": "20°C, sunny"
}
]
}
所以 Claude 的 tool calling 流程是:
user message
↓
assistant message with tool_use block
↓
user message with tool_result block
↓
assistant message
Claude 的核心差异不是字段名字不同,而是建模方式不同。它更像是把文本、图片、工具调用、工具结果、thinking 都看作 content 里的不同 block。
所以 Claude 的 message 模型可以理解成偏 content block 的设计。message role 比较简单,复杂语义主要由 content blocks 表达。
差异不只是字段名不同
OpenAI 和 Claude 在普通聊天里都像是:
{
role: "user",
content: "..."
}
但 tool calling 会把两者的结构差异暴露得很明显:
这就是为什么真实 LLM app 不能简单地说“message 就是 role 加 content”。OpenAI 有 tool role,Claude 没有。OpenAI 的 tool call 在 assistant message 的 tool_calls 字段里,Claude 的 tool call 在 assistant content 的 tool_use block 里。OpenAI 的 tool result 是一条独立 tool message,Claude 的 tool result 是 user message 里的 tool_result block。
如果你的应用内部直接存 OpenAI 格式,那么接 Claude 时就要处理一个问题:Claude 没有 tool role,tool result 应该怎么转过去?如果你的应用内部直接存 Claude 格式,那么接 OpenAI 时又要处理另一个问题:OpenAI 不把 tool call 当作 content block,而是放在 tool_calls 字段里。
如果还要支持 Gemini、Bedrock、Groq、OpenRouter 或其他 provider,差异会继续增加。
App Message 和 Model Message
当前我们从 Provider 层 上移到 模型层 和 应用层。
这一层讨论的是应用内部应该如何保存 message,以及在调用模型前如何把应用状态转换成模型可理解的上下文。
Provider message 是给模型看的,但 app message 是给应用看的。模型只关心下一次请求里要传什么:用户说了什么,助手之前说了什么,工具调用和工具结果是什么。但应用关心的东西更多。
比如一个 LLM app 可能需要保存:
- 用户上传了哪些文件。
- 某条消息是否正在 streaming。
- 某个工具调用是否正在执行。
- 某个工具执行失败时的错误是什么。
- UI notification、artifact、引用来源、模型名称和 token usage。
- 压缩后的 summary。
- 调用模型前动态注入的外部上下文。
这些都不是 provider 原生 message 能自然表达的。把它们硬塞进 OpenAI message 或 Claude message,会让应用状态和 provider 协议绑死。
更合理的设计通常是分层:
这三层的职责不同:
| 层级 | 职责 |
|---|---|
| App Message | 表达应用真正关心的状态 |
| Model Message | 表达模型应该看到的干净上下文 |
| Provider Message | 表达具体厂商 API 要求的格式 |
这个边界非常重要。App Message 可以很丰富,可以包含 UI-only 状态、agent runtime 事件、session metadata、artifact、summary 和自定义结构。Model Message 应该更干净,只保留模型能理解、也应该理解的内容。Provider Message 则是最后一公里,由 provider adapter 转换出来。
这一层的结论是:
App Message 是应用状态,Model Message 是模型上下文,Provider Message 是厂商协议。三者应该分开。
Provider Stream 的差异
当前我们回到 Provider 层,但从静态 message 进入 streaming。
静态 message 解决的是“历史上下文如何表达”。streaming 解决的是“一个正在生成中的 assistant message 如何被更新”。
在真实 LLM app 里,assistant message 通常不是一次性出现的,而是通过 stream 一点点构造出来的。文本会逐段到达,reasoning 可能会单独 streaming,tool call 的 arguments 也可能是一段一段拼出来的。
也就是说,message system 不只要回答“历史上下文如何表达”,还要回答“正在生成中的 message 如何被构造”。
OpenAI Stream:chunk delta
OpenAI 的流式接口更像是 chunk delta。每次事件里通常会有一个 delta,里面可能包含文本增量,也可能包含 tool call 增量。
对于普通文本,应用可以把 delta.content 逐段拼起来。对于工具调用,应用还要把 delta.tool_calls 里的 function arguments 逐段拼成完整 JSON。最后再根据 finish_reason 判断这次 assistant 输出是正常结束,还是因为需要调用工具而结束。
例如,模型要生成一句文本时,最终完整的 assistant message 是:
{
"role": "assistant",
"content": "The weather in Beijing is sunny."
}
但在 streaming 过程中,OpenAI 不会一次性返回这条完整 message,而是可能长这样:
[
{
"choices": [
{
"delta": {
"role": "assistant"
},
"finish_reason": null
}
]
},
{
"choices": [
{
"delta": {
"content": "The weather"
},
"finish_reason": null
}
]
},
{
"choices": [
{
"delta": {
"content": " in Beijing is sunny."
},
"finish_reason": null
}
]
},
{
"choices": [
{
"delta": {},
"finish_reason": "stop"
}
]
}
]
应用消费这个 stream 时,不是每次都拿到一条完整 assistant message,而是不断拿到 message 的增量补丁。最终 assistant content 需要由消费方拼出来:
"The weather" + " in Beijing is sunny."
工具调用时更明显。最终完整的 assistant message 可能是:
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\":\"Beijing\"}"
}
}
]
}
但在 streaming 过程中,OpenAI 可能把 function arguments 分成多个 chunk 返回:
[
{
"choices": [
{
"delta": {
"tool_calls": [
{
"index": 0,
"id": "call_1",
"type": "function",
"function": {
"name": "get_weather",
"arguments": ""
}
}
]
},
"finish_reason": null
}
]
},
{
"choices": [
{
"delta": {
"tool_calls": [
{
"index": 0,
"function": {
"arguments": "{\"city\""
}
}
]
},
"finish_reason": null
}
]
},
{
"choices": [
{
"delta": {
"tool_calls": [
{
"index": 0,
"function": {
"arguments": ":\"Beijing\"}"
}
}
]
},
"finish_reason": null
}
]
},
{
"choices": [
{
"delta": {},
"finish_reason": "tool_calls"
}
]
}
]
消费方要根据 index 找到同一个 tool call,把 function.arguments 拼成完整 JSON:
{
"city": "Beijing"
}
简化来看,OpenAI stream 的消费模型接近这样:
chunk.delta.content
chunk.delta.tool_calls
chunk.finish_reason
核心字段可以这样理解:
| 事件/字段 | 含义 |
|---|---|
delta.role | assistant 角色信息,通常只在开头出现 |
delta.content | 文本增量,需要消费方逐段拼接 |
delta.tool_calls | 工具调用增量,包含 tool call id、function name、arguments 片段 |
finish_reason: "stop" | assistant 正常完成 |
finish_reason: "tool_calls" | assistant 当前轮结束,等待应用执行工具 |
finish_reason: "length" | 因长度限制停止 |
usage | token usage,通常在流结束附近出现,取决于接口和配置 |
所以 OpenAI 的原始 stream 更像“不断给你 message 顶层字段的增量补丁”。
Claude Stream:message/block lifecycle
Claude 的流式接口更接近 message/block lifecycle。
它会显式告诉你 message 什么时候开始,content block 什么时候开始,block delta 是文本、thinking 还是 tool input,block 什么时候结束,最后 message 什么时候结束。
例如,模型生成一段文本时,最终完整的 assistant message 是:
{
"role": "assistant",
"content": [
{
"type": "text",
"text": "The weather in Beijing is sunny."
}
]
}
但在 streaming 过程中,Claude 会按 message 和 content block 的生命周期返回事件:
[
{
"type": "message_start",
"message": {
"role": "assistant",
"content": []
}
},
{
"type": "content_block_start",
"index": 0,
"content_block": {
"type": "text",
"text": ""
}
},
{
"type": "content_block_delta",
"index": 0,
"delta": {
"type": "text_delta",
"text": "The weather"
}
},
{
"type": "content_block_delta",
"index": 0,
"delta": {
"type": "text_delta",
"text": " in Beijing is sunny."
}
},
{
"type": "content_block_stop",
"index": 0
},
{
"type": "message_stop"
}
]
这里消费方不是只看一个全局 delta.content,而是先看到一个 text block 开始,然后把后续 content_block_delta 归到 index: 0 这个 block 上。
工具调用时,最终完整的 assistant message 可能是:
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "toolu_1",
"name": "get_weather",
"input": {
"city": "Beijing"
}
}
]
}
但在 streaming 过程中,Claude 也是按 block 生命周期表达:
[
{
"type": "content_block_start",
"index": 1,
"content_block": {
"type": "tool_use",
"id": "toolu_1",
"name": "get_weather",
"input": {}
}
},
{
"type": "content_block_delta",
"index": 1,
"delta": {
"type": "input_json_delta",
"partial_json": "{\"city\""
}
},
{
"type": "content_block_delta",
"index": 1,
"delta": {
"type": "input_json_delta",
"partial_json": ":\"Beijing\"}"
}
},
{
"type": "content_block_stop",
"index": 1
}
]
消费方要根据 index: 1 知道这些 delta 属于同一个 tool_use block,并把 partial_json 拼成完整 input:
{
"city": "Beijing"
}
简化来看,Claude stream 的消费模型接近这样:
message_start
content_block_start
content_block_delta
content_block_stop
message_delta
message_stop
核心事件可以这样理解:
| 事件 | 含义 |
|---|---|
message_start | assistant message 开始 |
content_block_start | 一个新的 content block 开始,例如 text、thinking、tool_use |
content_block_delta | 当前 content block 的增量,可能是 text delta、thinking delta、input_json delta |
content_block_stop | 当前 content block 结束 |
message_delta | message 级别的增量信息,例如 stop reason、usage |
message_stop | assistant message 完成 |
ping | keep-alive 事件 |
error | provider 返回错误 |
因此 Claude 的原始 stream 更像“message 里每个 block 的生命周期事件”。消费方需要根据 content block index 把 delta 归到对应 block 上,而不是只拼一个全局字符串。
Streaming 协议也需要抽象
OpenAI 和 Claude 的差异不只存在于静态 message 格式里,也存在于 streaming 协议里:
两者都能表达文本、reasoning 和工具调用,但消费方需要处理的事件形态不同。如果 UI 或 agent runtime 直接依赖 provider stream,那么 provider 一多,上层代码就会被各种底层协议污染。
这一层的结论是:
Provider Stream 也只是厂商协议,不应该直接成为 UI 或 agent runtime 的事件接口。
Message Events
当前我们从 Provider Stream 上移到 模型层事件抽象 和 应用层事件抽象。
这一层讨论的是:应用真正需要的不是 provider chunk,而是 message events。
如果应用只是把 token 打印到终端,直接消费 provider stream 也许够用。但 LLM app 通常需要维护一个正在变化的应用状态。UI 需要知道当前 assistant message 是否已经开始,当前 text part 是否还在 streaming,reasoning 是否应该单独渲染,tool input 是否完整,tool result 是否返回,当前 step 是否结束,以及 error 或 abort 时应该如何更新界面。
agent runtime 也有类似问题。它不只关心“模型吐了哪些 chunk”,还关心:
- 当前 assistant message 的 partial state 是什么。
- 是否已经产生 tool call。
- tool call 执行期间如何通知外部。
- 工具结果是否应该进入下一轮模型调用。
- 当前 turn、step、agent run 是否已经结束。
这些状态很难直接用 OpenAI chunk 或 Claude SSE event 作为应用层 API。
因此,成熟框架通常会在 provider stream 之上再做一层 event abstraction:
Provider Stream
↓
Message Events
↓
App State
这层抽象负责把原始 provider 事件转成应用能理解的 message events,例如:
- text start / delta / end
- reasoning start / delta / end
- tool input start / delta / available
- tool output available / error
- message done
- abort
- error
静态 message abstraction 解决的是“历史上下文如何表达”的问题。stream abstraction 解决的是“一个 assistant message 如何被逐步生成,并同步到 UI 或 agent runtime”的问题。
这一层的结论是:
稳定的 LLM app 不只需要自己的 message 类型,也需要自己的 message event 类型。
Vercel AI SDK 的 Message System
当前我们进入 框架案例。先看 Vercel AI SDK。
Vercel AI SDK 的核心场景是全栈 AI app,尤其是带前端 streaming UI 的应用。它的 message system 可以理解成围绕 UI 状态设计。
放回全局图里看,Vercel AI SDK 的 UIMessage 对应 App Message,ModelMessage 对应 Model Message。
它最重要的区分是:
UIMessage: 应用和 UI 状态
ModelMessage: 模型输入上下文
静态分层:UIMessage 到 ModelMessage
Vercel AI SDK 的静态 message 流程可以这样理解:
UIMessage[]
↓ convertToModelMessages()
ModelMessage[]
↓ streamText()
Provider
ModelMessage 是模型层消息。它代表传给 AI SDK Core 函数的消息结构,也就是说,它更接近“模型调用需要什么”。
UIMessage 是 UI 层消息。它不是单纯为了调用模型设计的,而是为了前端渲染、streaming 状态、工具调用状态、持久化和客户端交互设计的。
一个 UIMessage 大概包含:
type UIMessage<Metadata, DataParts, Tools> = {
id: string;
role: "system" | "user" | "assistant";
metadata?: Metadata;
parts: UIMessagePart<DataParts, Tools>[];
};
这里的重点是 parts。
UIMessage.parts 可以包含很多类型的 part:
text
reasoning
tool-*
source-url
source-document
file
data-*
step-start
这和 provider message 的思路不一样。在 provider message 里,tool call 主要是为了让模型知道要调用什么工具。在 UIMessage 里,tool part 是为了让前端知道这个工具调用现在处于什么状态。
例如,一个工具调用在 UI 里可能经历这些状态:
input-streaming
input-available
output-available
output-error
当模型正在生成工具参数时,UI 可以显示工具输入还在 streaming。当工具参数完整了,UI 可以显示工具准备执行。当工具结果返回了,UI 可以在同一个 tool part 上显示 output。如果工具失败了,UI 可以在同一个 tool part 上显示 error。
也就是说,Vercel AI SDK 把工具调用从“模型协议”提升成了“UI 状态”。
UIMessage 还可以通过泛型扩展三类东西:
| 扩展点 | 用途 |
|---|---|
metadata | 消息级别的业务信息,例如创建时间、模型名称、token usage、trace id |
data-* parts | 应用自定义结构化内容,例如 data-weather、data-chart、data-search-status |
tools | 类型安全的工具定义,让 UI 知道 input、output、error 的类型 |
所以 UIMessage 不只是聊天记录,它更像是一个 AI app 的前端消息状态容器。
流式分层:Provider Stream 到 UIMessage state
Vercel AI SDK 的流式抽象可以理解成四层:
Provider Stream
↓
fullStream / TextStreamPart
↓
UI Message Stream Protocol
↓
UIMessage state
第一层是 provider 原始流,例如 OpenAI chunks、Claude SSE events,或者其他 provider 的 streaming response。Vercel AI SDK 默认会通过 provider adapter 消化这些差异。如果确实需要接触 provider 原始事件,也可以通过 includeRawChunks 让 fullStream 暴露 type: "raw" 的 chunk。
第二层是 AI SDK Core 的统一流。streamText() 返回的结果里有 textStream 和 fullStream。textStream 只包含文本 delta,适合最简单的纯文本场景;fullStream 则是完整事件流,包含 text delta、reasoning、tool call、tool result、source、metadata、finish 等事件。
fullStream 可以理解成“模型输出事件”的统一抽象:
fullStream 事件 | 含义 |
|---|---|
text-delta | 文本增量 |
reasoning / reasoning-delta | reasoning 增量,具体命名取决于版本和模型能力 |
source | RAG、搜索或引用来源 |
file | 文件类输出 |
tool-call-streaming-start | 工具输入开始 streaming |
tool-call-delta | 工具输入参数增量 |
tool-call | 工具调用输入已经完整 |
tool-result | 工具执行结果 |
start-step / finish-step | 多 step 生成中某一步开始或结束 |
finish | 整个生成结束,包含 finish reason 等信息 |
error | 流式过程中出现错误 |
raw | provider 原始 chunk,仅在开启 includeRawChunks 时出现 |
第三层是 UI Message Stream Protocol。服务端可以通过 toUIMessageStreamResponse() 把 streamText() 的结果转成客户端可消费的 HTTP stream。
这个协议里会出现更接近 UI message 生命周期的事件:
| UI Message Stream 事件 | 含义 |
|---|---|
start | 一条新 UI message 开始,通常带 message id |
text-start | 一个 text part 开始 |
text-delta | text part 的文本增量 |
text-end | text part 完成 |
reasoning-start | 一个 reasoning part 开始 |
reasoning-delta | reasoning part 的文本增量 |
reasoning-end | reasoning part 完成 |
tool-input-start | tool part 的 input 开始 streaming |
tool-input-delta | tool input 的 JSON/text 增量 |
tool-input-available | tool input 已经完整,可以执行工具 |
tool-output-available | tool output 已经返回,可以更新同一个 tool part |
tool-output-error | tool 执行失败,可以把错误挂到 tool part 上 |
source-url / source-document | source part |
file | file part |
data-* | 应用自定义 data part |
finish | message stream 完成 |
abort | stream 被中止 |
[DONE] | SSE 流结束标记 |
第四层是最终的 UIMessage state。前端的 useChat() 消费 UI Message Stream Protocol,把事件累积成 UIMessage[],并更新每条 message 的 parts。
所以前端组件通常不需要理解 OpenAI chunk 或 Claude SSE event,而是围绕 UIMessage.parts 和 part state 渲染。
Vercel AI SDK 的设计中心
Vercel AI SDK 的 message system 以 UI state 为中心。它要解决的核心问题是:
服务端生成的内容如何稳定地变成客户端可渲染状态?
所以它把应用层消息设计成 UIMessage,把 text、reasoning、tool、file、source、data 都放进 parts。对于有前端交互的 AI 应用来说,这非常自然。
pi 的 Message System
当前我们继续看 框架案例。这次是 pi。
pi 的核心场景更偏 agent runtime、coding agent 和长上下文工具系统。它的 message system 可以理解成围绕运行时历史和上下文转换设计。
放回全局图里看,pi 的 AgentMessage 对应 App Message,Message 对应 Model Message。
它最重要的区分是:
AgentMessage: agent/app runtime 状态
Message: LLM-compatible 模型上下文
静态分层:AgentMessage 到 Message
pi 的静态 message 流程可以这样理解:
AgentMessage[]
↓ transformContext()
AgentMessage[]
↓ convertToLlm()
Message[]
↓ Provider Adapter
Provider Message
AgentMessage 是 agent/app 层消息。它不仅可以包含标准 LLM message,比如 user、assistant、toolResult,也可以包含应用自定义消息。
pi 通过 TypeScript declaration merging 允许应用扩展 CustomAgentMessages。也就是说,应用可以定义自己的 message 类型:
type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessages];
这对 agent 很重要,但不只对 agent 有用。任何需要在应用消息里保存额外结构化状态的系统,都可以从这种设计里受益。
agent 的运行历史里经常不只有用户和助手对话。它可能还有:
- 命令执行记录。
- 文件操作记录。
- artifact。
- 通知。
- 分支 summary。
- 压缩 summary。
- 外部系统事件。
这些东西有些应该展示给用户,有些应该用于 session 存储,有些应该进入模型上下文,有些则只是 UI 状态,不能直接传给模型。
如果所有东西都硬塞进 Model Message,message 类型会变得很混乱。pi 的做法是让 AgentMessage 承载更丰富的 runtime state,然后在调用模型前再转换成模型能理解的 Message。
Message 是 LLM-compatible message。它通常只包含模型可理解的角色,例如:
user
assistant
toolResult
然后 provider 层再把这套 Message 转成 OpenAI、Claude 或其他 provider 的原生格式。
所以 pi 里真正关键的边界是 convertToLlm()。它负责把 AgentMessage[] 转成 Message[]。
这个过程中可以做几类事情:
| 转换动作 | 例子 |
|---|---|
| 保留标准 LLM message | user、assistant、toolResult 直接保留 |
| 过滤 UI-only message | notification 只给界面显示,不进入模型上下文 |
| 转换 custom message | bash execution message 转成描述命令、输出和退出码的 user message |
| 转换 summary message | compaction summary 或 branch summary 转成模型可读上下文 |
除了 convertToLlm(),pi 还有一个重要边界:transformContext()。
transformContext() 发生在 convertToLlm() 之前,输入的是 AgentMessage[],输出的仍然是 AgentMessage[]。这意味着它处理的是 agent/app 层上下文,而不是 provider 层消息。
它适合做:
- 上下文裁剪。
- 上下文压缩。
- 注入外部上下文。
- 根据 token 预算调整历史。
- 整理 agent message 顺序。
- 保留 UI 或 session 层需要的信息。
然后,处理后的 AgentMessage[] 再交给 convertToLlm(),转换成真正给模型看的 Message[]。
流式分层:Provider Stream 到 AgentMessage state
pi 的流式抽象也可以理解成四层:
Provider Stream
↓
AssistantMessageEvent
↓
AgentEvent
↓
AgentMessage state
第一层同样是 provider 原始流,例如 OpenAI chunks、Claude SSE events 或其他 provider 的 streaming response。provider 层负责解析这些差异。
第二层是 LLM 层的统一事件:AssistantMessageEvent。它把不同厂商的 stream 统一成 assistant message 的构造过程:
start
text_start / text_delta / text_end
thinking_start / thinking_delta / thinking_end
toolcall_start / toolcall_delta / toolcall_end
done
error
这些事件可以这样理解:
AssistantMessageEvent | 含义 |
|---|---|
start | assistant message 开始,携带初始 partial message |
text_start | 一个 text block 开始 |
text_delta | text block 的文本增量 |
text_end | text block 完成 |
thinking_start | 一个 thinking block 开始 |
thinking_delta | thinking block 的增量 |
thinking_end | thinking block 完成 |
toolcall_start | 一个 toolCall block 开始 |
toolcall_delta | tool call arguments 的增量 |
toolcall_end | toolCall block 完成,得到完整 ToolCall |
done | assistant message 成功完成,reason 可能是 stop、length、toolUse |
error | assistant message 失败或被中止,reason 可能是 error、aborted |
这些事件都带有当前累积出来的 partial assistant message。这样消费方不需要自己从 provider chunk 里恢复完整消息,也不需要知道某个 provider 是用 chunk delta 还是 block event。
第三层是 agent 层的统一事件:AgentEvent。
agent loop 会把 AssistantMessageEvent 包进更高层的 agent runtime 事件,例如:
AgentEvent | 含义 |
|---|---|
agent_start | 一次 agent run 开始 |
turn_start | 一个 turn 开始 |
message_start | 一条 user、assistant 或 toolResult message 开始 |
message_update | streaming 中的 assistant message 更新,内部带 AssistantMessageEvent |
message_end | 一条 message 完成 |
tool_execution_start | 工具开始执行 |
tool_execution_update | 工具执行中产生部分结果 |
tool_execution_end | 工具执行结束,带 result 和 error 状态 |
turn_end | 一个 turn 完成,带 assistant message 和 tool results |
agent_end | agent run 完成,带最终 messages |
AssistantMessageEvent 和 AgentEvent 不是两套互相替代的事件。AssistantMessageEvent 是模型层的事件,描述 assistant message 正在如何被构造;AgentEvent 是 agent runtime 层的事件,描述一次 agent run 里发生了什么。agent loop 会把模型层事件放进 message_update,同时再发出 turn、message、tool execution 和 agent lifecycle 事件。
关系可以画成这样:
这里最容易混淆的是 toolcall_* 和 tool_execution_*。它们都和工具有关,但不是同一个阶段:
| 事件 | 所在层 | 表示什么 | 是否说明工具已经执行 |
|---|---|---|---|
toolcall_start | AssistantMessageEvent | 模型开始生成一个 tool call block | 否 |
toolcall_delta | AssistantMessageEvent | 模型正在流式生成 tool call arguments | 否 |
toolcall_end | AssistantMessageEvent | 模型已经生成完整 ToolCall | 否 |
tool_execution_start | AgentEvent | agent runtime 决定开始执行这个工具 | 是,开始执行 |
tool_execution_update | AgentEvent | 工具执行中产生了 partial result | 是,执行中 |
tool_execution_end | AgentEvent | 工具执行完成,得到 result 或 error | 是,已结束 |
换句话说,toolcall_* 描述的是“模型写出了一个工具调用请求”。这个请求只是 assistant message 内容的一部分。tool_execution_* 描述的是“agent runtime 真的执行了这个请求”。执行前,runtime 还可能做参数校验、权限检查、beforeToolCall hook、顺序或并发调度;执行后,还可能做 afterToolCall hook、错误包装和 tool result message 写入。
一个包含工具调用的 turn,事件顺序通常接近这样:
agent_start
turn_start
message_start assistant message 开始
message_update
assistantMessageEvent: start
message_update
assistantMessageEvent: text_delta
message_update
assistantMessageEvent: toolcall_start
message_update
assistantMessageEvent: toolcall_delta
message_update
assistantMessageEvent: toolcall_end
message_end assistant message 完成,里面已经有完整 toolCall
tool_execution_start runtime 开始执行 toolCall
tool_execution_update 工具可选地返回 partial result
tool_execution_end runtime 得到工具结果或错误
message_start toolResult message 写入 runtime history
message_end
turn_end
agent_end
如果只保留 message_start/update/end,上层最多知道“assistant message 里出现了一个 tool call”。它不知道这个工具是否被允许执行、什么时候开始执行、是否并发执行、执行过程中有没有 partial result、最后是成功还是失败。反过来,如果只保留 tool_execution_*,上层又看不到模型生成工具参数的流式过程,也无法在工具调用还没完整时展示正在 streaming 的 arguments。
所以这两组事件的关系是:
message_update + toolcall_*:
模型输出阶段,构造 assistant message 中的 toolCall 内容
tool_execution_*:
agent 执行阶段,运行 toolCall 并产生 toolResult
AgentEvent 的重点不是描述模型 token,而是描述 agent runtime 里发生了什么。模型输出只是 runtime 的一部分;工具执行本身也是 runtime 的一部分,所以它有单独的生命周期事件。
第四层是最终的 AgentMessage state。agent runtime 通过这些事件维护当前消息、历史消息和 tool execution 状态。对于 UI、扩展和 session runtime 来说,重要的不是 provider 原始 stream,而是 agent 层可理解的 message/event 状态。
可以把 pi 的 streaming 分层画成这样:
pi 的设计中心
pi 的 message system 以 runtime history 为中心。它要解决的核心问题是:
运行时历史里有很多非 LLM 原生消息,调用模型前要如何整理、过滤、压缩和转换?
所以它把应用层消息设计成 AgentMessage,允许应用扩展整个 message union,并通过 transformContext() 和 convertToLlm() 建立明确的上下文边界。
Vercel AI SDK 和 pi 的设计侧重点
当前我们仍在 框架案例层,但这部分把两个系统放在一起比较。
Vercel AI SDK 和 pi 都在做 message abstraction。它们都承认“应用里的 message”和“模型看到的 message”不是一回事,也都不希望上层直接依赖 OpenAI 或 Claude 的 provider message。
区别在于,它们选择了不同的设计中心:
Vercel AI SDK: UI state 为中心
pi: runtime history 为中心
Vercel AI SDK 的 UIMessage 把扩展点放在 parts、metadata、typed data parts 和 typed tool parts 上。它的应用层消息天然接近“前端如何渲染这一条消息”的问题。
pi 的 AgentMessage 把扩展点放在整个 message union 上。应用可以增加新的 message role 或 message shape,然后通过 convertToLlm() 决定它要不要进入模型上下文,以及如何进入模型上下文。它的应用层消息天然接近“运行时历史里发生过什么”的问题。
可以这样对比:
| 维度 | Vercel AI SDK | pi |
|---|---|---|
| 应用层消息 | UIMessage | AgentMessage |
| 模型层消息 | ModelMessage | Message |
| 常见使用入口 | AI app、chat UI、streaming UI、agent UI | agent runtime、coding agent、长上下文工具系统 |
| 应用层重点 | 可渲染 parts、streaming 状态、tool part、metadata、data parts | runtime history、自定义 message、上下文转换 |
| 转换边界 | UIMessage → ModelMessage | AgentMessage → transformContext() → convertToLlm() → Message |
| 工具调用抽象 | UI part 带状态 | AgentMessage / Message / provider adapter 分层 |
| 自定义数据 | data parts、metadata、typed UIMessage | CustomAgentMessages declaration merging |
| 上下文处理 | 通常由应用或 SDK 流程组织 | transformContext 是核心流程的一部分 |
它们不是谁更好,也不是能力上互斥,而是在 message 抽象上选择了不同的中心。
如果你的核心问题是“服务端生成的内容如何稳定地变成客户端可渲染状态”,Vercel AI SDK 的 UIMessage 很有价值。它把前端需要的状态建模得很清楚。
如果你的核心问题是“运行时历史里有很多非 LLM 原生消息,调用模型前要如何整理、过滤、压缩和转换”,pi 的 AgentMessage 很有价值。它把模型输入边界建模得很清楚。
开发者应该如何设计自己的 Message 层
当前我们回到 应用设计层。
理解 OpenAI、Claude、Vercel AI SDK 和 pi 的设计之后,可以得到一些通用经验。
1. 不要把 provider message 当内部状态
Provider 格式是模型接口,不是应用状态模型。OpenAI message 和 Claude message 都是为了各自 API 设计的。它们会变化,也会因为 provider 不同而不同。
应用内部如果直接存 provider message,短期看实现简单,长期会让多 provider、tool calling、streaming、持久化和 UI 状态都变难。
2. 区分 App Message、Model Message 和 Provider Message
这三个类型应该有不同职责:
App Message: 应用真正保存和操作的状态
Model Message: 模型应该看到的上下文
Provider Message: 具体厂商 API 要求的格式
App Message 可以更丰富。它可以包含 UI 状态、工具执行状态、附件、artifact、summary、外部上下文和业务 metadata。
Model Message 应该更干净。它只保留模型能理解、也应该理解的内容。
Provider Message 应该只存在于 provider adapter 边界。
3. 明确转换边界
不管你叫它 convertToModelMessages(),还是 convertToLlm(),都应该有一个明确的位置负责把应用消息转成模型消息。
这个边界可以做几件重要的事:
- 过滤不该给模型看的 UI-only message。
- 把 custom message 转成模型可理解的文本或结构。
- 注入当前任务需要的外部上下文。
- 根据 token 预算压缩或裁剪历史。
- 保证 tool call 和 tool result 的顺序、关联关系正确。
没有这个边界,应用状态会不断泄漏到模型上下文里。
4. tool call 和 tool result 必须用稳定 ID 关联
OpenAI 有 tool_call_id,Claude 有 tool_use_id,应用内部也应该保留类似的关联关系。
复杂 agent 流程里,可能同时存在多个 tool call、并发执行、失败重试、partial result 和跨 turn continuation。如果 tool call 和 tool result 没有稳定 ID,后续转换和渲染都会很脆弱。
5. streaming UI 不只是字符串拼接
真实 streaming 里可能有:
- 文本增量。
- reasoning 增量。
- 工具输入增量。
- 工具输出。
- 工具错误。
- source 或 file。
- step boundary。
- abort 和 error。
所以 UI 层最好有自己的状态模型。直接把 stream 理解成不断 append string,会很快不够用。
6. agent 场景要重视 context transform
agent 的历史记录通常比普通 chat 更复杂。直接把所有历史塞给模型,很容易导致上下文污染、token 浪费,甚至行为异常。
像 transformContext() 这样的边界非常重要。它让应用可以在调用模型前整理历史,而不是把 runtime state 原样暴露给模型。
7. UI 场景要重视可渲染 message state
UI 不只需要知道模型说了什么,还需要知道这条消息现在处于什么状态。
例如工具调用是否还在生成 input,工具是否正在执行,结果是否已经返回,reasoning 是否应该折叠,source 是否可点击,文件是否可下载。
这就是为什么 UIMessage.parts 这样的设计很有价值。它让 UI 围绕稳定的 message parts 渲染,而不是围绕 provider chunk 渲染。
总结
OpenAI 和 Claude 的 message 格式代表了两种底层协议风格。OpenAI 更偏 message-level fields:role 比较丰富,tool call 放在 assistant message 的 tool_calls 字段里,tool result 是独立的 tool role message。Claude 更偏 content block:role 更少,tool call 和 tool result 都是 content block,只是分别出现在 assistant 和 user message 里。
这些差异本身并不难理解。真正重要的是,它们提醒我们:provider message 不应该直接成为 LLM app 的核心状态,provider stream 也不应该直接成为 UI 或 agent runtime 的核心事件接口。
Vercel AI SDK 和 pi 都在这个问题上做了分层。Vercel AI SDK 用 UIMessage 表达应用和前端渲染状态,用 ModelMessage 表达模型输入。pi 用 AgentMessage 表达应用或 agent runtime 状态,用 Message 表达 LLM-compatible 上下文,并通过 transformContext() 和 convertToLlm() 建立清晰边界。
对 LLM app 开发者来说,最重要的不是记住每个 provider 的所有字段,而是建立清楚的分层意识:
Provider Message 是厂商 API 格式
Model Message 是模型输入格式
App Message 是应用状态格式
Provider Stream 是厂商流式响应
Message Events 是模型输出事件格式
App State 是最终被 UI 或 agent runtime 使用的状态
message system 的核心不是字段兼容,而是边界设计。当这个分层清楚之后,多 provider、tool calling、streaming UI、agent context 和 session persistence 都会更容易设计。
参考资料
- Vercel AI SDK 源代码:重点参考
packages/ai、packages/ui-utils和相关 message 类型定义。 - pi 源代码:重点参考
packages/agent中的AgentMessage、convertToLlm()和transformContext()。