聊聊 Vercel AI SDK 的流式协议:前后端到底是怎么"边想边说"的
做 AI 应用绕不开一个体验问题:模型的回答不能等它全部生成完再"啪"地甩给用户,得像打字机一样一个字一个字往外冒。这个"边想边说"的能力,前端拿到的其实不是一坨最终文本,而是一条结构化的事件流。
Vercel AI SDK 把这条流的格式标准化了下来,官方叫它 Stream Protocol(流式协议) 。这篇文章就带你把它讲清楚:它解决什么问题、有哪几种、每种长什么样,以及------最有意思的------怎么脱离这个 SDK,用你自己的后端(哪怕是 Python)去对接它的前端。
参考自官方文档:ai-sdk.dev/docs/ai-sdk...
一、两种协议:文本流 vs 数据流
1. Text Stream Protocol(文本流)------ 极简版
流里只有纯文本片段,前端拿到一段拼一段,最后凑成完整回答。没有类型、没有元数据,就是最朴素的字符流。
适合:只需要输出一段纯文本、不涉及工具调用/推理过程/引用来源的简单场景。
前端要显式指定用文本协议(通过 TextStreamChatTransport):
typescript
// app/page.tsx
'use client';
import { useChat } from '@ai-sdk/react';
import { TextStreamChatTransport } from 'ai';
const { messages, sendMessage } = useChat({
transport: new TextStreamChatTransport({ api: '/api/chat' }),
});
后端用 toTextStream + createTextStreamResponse:
typescript
// app/api/chat/route.ts
import { convertToModelMessages, createTextStreamResponse, streamText, toTextStream } from 'ai';
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: 'xai/grok-4.5',
messages: await convertToModelMessages(messages),
});
return createTextStreamResponse({
stream: toTextStream({ stream: result.stream }),
});
}
2. UI Message Stream Protocol(数据流)------ 完整版
它基于 SSE(Server-Sent Events),相比纯文本多了几个关键好处:
- 标准化:走 SSE 规范,生态工具通用
- 保活(keep-alive):靠 ping 维持长连接
- 可重连:断线能续
- 缓存友好
最核心的区别是:它传的不是文本,而是一连串带类型、带 ID 的结构化事件(stream part)。文本、推理过程、工具调用、引用来源、错误......每一种都有自己的事件类型。
二、数据流协议的"事件字典"
这是整个协议的精华。后端往外发的每一帧,都是一个带 type 字段的 JSON。按用途分组来看:
消息与步骤生命周期
| 事件类型 | 示例 | 含义 |
|---|---|---|
start |
{"type":"start","messageId":"..."} |
一条新消息开始 |
start-step |
{"type":"start-step"} |
一个步骤(一次 LLM 调用)开始 |
finish-step |
{"type":"finish-step"} |
当前步骤结束(多次调用拼接时用) |
finish |
{"type":"finish"} |
整条消息结束 |
abort |
{"type":"abort","reason":"user cancelled"} |
流被中断 |
文本内容(最常用)
文本不是一次发完,而是 start → delta(多次)→ end 三段式,每个文本块有唯一 id:
| 事件类型 | 示例 | 含义 |
|---|---|---|
text-start |
{"type":"text-start","id":"msg_1"} |
文本块开始 |
text-delta |
{"type":"text-delta","id":"msg_1","delta":"你好"} |
增量文本 |
text-end |
{"type":"text-end","id":"msg_1"} |
文本块结束 |
推理过程(Reasoning)
和文本同构的三段式,专门传模型的思考过程;还能带推理时生成的文件:
| 事件类型 | 示例 | 含义 |
|---|---|---|
reasoning-start |
{"type":"reasoning-start","id":"r_1"} |
推理块开始 |
reasoning-delta |
{"type":"reasoning-delta","id":"r_1","delta":"..."} |
增量推理内容 |
reasoning-end |
{"type":"reasoning-end","id":"r_1"} |
推理块结束 |
reasoning-file |
{"type":"reasoning-file","url":"data:image/png;base64,...","mediaType":"image/png"} |
推理过程中生成的文件 |
工具调用(Tool Calling)
这一组最能体现数据流协议的价值------纯文本流根本表达不了工具调用。它把「输入流式生成 → (可选)审批 → 输出」完整地建了模:
| 事件类型 | 示例 | 含义 |
|---|---|---|
tool-input-start |
{"type":"tool-input-start","toolCallId":"c_1","toolName":"getWeather"} |
开始流式生成工具入参 |
tool-input-delta |
{"type":"tool-input-delta","toolCallId":"c_1","inputTextDelta":"北京"} |
入参增量 |
tool-input-available |
{"type":"tool-input-available","toolCallId":"c_1","toolName":"getWeather","input":{...}} |
入参就绪,可执行 |
tool-approval-request |
{"type":"tool-approval-request","toolCallId":"c_1","approvalId":"a_1","isAutomatic":true} |
该工具调用需要审批 |
tool-approval-response |
{"type":"tool-approval-response","approvalId":"a_1","approved":false,"reason":"..."} |
审批结论 |
tool-output-available |
{"type":"tool-output-available","toolCallId":"c_1","output":{...}} |
工具执行结果 |
tool-output-denied |
{"type":"tool-output-denied","toolCallId":"c_1"} |
审批被拒,未执行 |
引用、文件与自定义数据
| 事件类型 | 示例 | 含义 |
|---|---|---|
source-url |
{"type":"source-url","sourceId":"...","url":"https://..."} |
引用的外部链接 |
source-document |
{"type":"source-document","sourceId":"...","mediaType":"file","title":"标题"} |
引用的文档 |
file |
{"type":"file","url":"https://.../a.png","mediaType":"image/png"} |
文件引用 |
data-* |
{"type":"data-weather","data":{"location":"SF","temperature":100}} |
任意结构化自定义数据 |
custom |
{"type":"custom","kind":"openai.compaction","providerMetadata":{...}} |
provider 专属内容 |
error |
{"type":"error","errorText":"出错了"} |
错误信息 |
data-*是个很实用的口子:data-后面接什么由你定,前端能拿到对应的结构化 payload,适合往消息里塞自定义 UI 组件需要的数据。
流的结束
整条流以一个特殊标记收尾:
ini
data: [DONE]
三、一条真实的 SSE 流长什么样
把上面的事件串起来,一次"你好 → 模型回复"的完整 SSE 响应大致是这样(每行一个 SSE data: 帧):
vbnet
data: {"type":"start","messageId":"msg_abc"}
data: {"type":"start-step"}
data: {"type":"text-start","id":"t_1"}
data: {"type":"text-delta","id":"t_1","delta":"你"}
data: {"type":"text-delta","id":"t_1","delta":"好"}
data: {"type":"text-delta","id":"t_1","delta":",有什么可以帮你?"}
data: {"type":"text-end","id":"t_1"}
data: {"type":"finish-step"}
data: {"type":"finish"}
data: [DONE]
前端 useChat 会自动把这些帧还原成一条 message,并把 text-delta 逐字拼进去,于是你就看到了打字机效果。
四、用 SDK 实现(TypeScript)
需要说明:下面这段是基于 AI SDK 7.0 + Next.js 的写法 ,
toUIMessageStream、createUIMessageStreamResponse这些 API 都是这个 SDK 特有的。协议本身是语言无关的,但"帮你自动处理协议"的这套封装只有官方 TS SDK 提供;换到别的语言/框架,实现方式完全不同(见下一节)。
如果前后端都在 Next.js 里,你几乎不用碰协议细节------SDK 全帮你处理了。
前端就一行:
typescript
// app/page.tsx
const { messages, sendMessage } = useChat(); // 默认就是数据流协议
后端用 toUIMessageStream + createUIMessageStreamResponse:
typescript
// app/api/chat/route.ts
import {
convertToModelMessages,
createUIMessageStreamResponse,
streamText,
toUIMessageStream,
} from 'ai';
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: 'xai/grok-4.5',
messages: await convertToModelMessages(messages),
});
return createUIMessageStreamResponse({
stream: toUIMessageStream({ stream: result.stream }),
});
}
渲染时按 part.type 分支即可:
typescript
{message.parts.map((part, i) => {
switch (part.type) {
case 'text':
return <div key={i}>{part.text}</div>;
// case 'tool-getWeather': ...
// case 'data-weather': ...
}
})}
五、真正的重头戏:用自定义后端对接
这才是协议标准化最大的意义。假设你的模型编排逻辑在一个 Python 后端 (或者任何非 JS 服务)里,前端仍然想用 useChat,你只需要做两件事:
- 响应头带上 :
x-vercel-ai-ui-message-stream: v1 - 按 SSE 格式,把上面那些事件一帧帧写出去 ,最后补一个
data: [DONE]
下面用 Python + FastAPI 举例,但这只是其中一种语言的实现。协议不挑语言,Go、Ruby、Java 都能做------核心就是"按 SSE 格式吐符合上面事件字典的 JSON",各语言的 HTTP 流式响应写法不同而已。
一个最小化的 FastAPI 示例:
python
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import json
app = FastAPI()
def sse(event: dict) -> str:
return f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
@app.post("/api/chat")
async def chat():
async def gen():
yield sse({"type": "start", "messageId": "msg_1"})
yield sse({"type": "start-step"})
yield sse({"type": "text-start", "id": "t_1"})
for token in ["你", "好", ",世界"]:
yield sse({"type": "text-delta", "id": "t_1", "delta": token})
yield sse({"type": "text-end", "id": "t_1"})
yield sse({"type": "finish-step"})
yield sse({"type": "finish"})
yield "data: [DONE]\n\n"
return StreamingResponse(
gen(),
media_type="text/event-stream",
headers={"x-vercel-ai-ui-message-stream": "v1"},
)
前端完全不用改,useChat() 照常工作。
不用自己拼 JSON:现成的适配器
上面手写 SSE 是为了讲清楚原理。真到生产里,更省事的是用生态里已有的适配层------它们把「框架内部的流」直接翻译成这套协议,你连事件字典都不用背。举两个典型:
① Pydantic AI(Python)------ VercelAIAdapter
Pydantic AI 官方内置了对 Vercel AI Data Stream Protocol 的支持,一行 dispatch_request 就把「接收前端输入 → 跑 Agent → 按协议流式吐事件」全包了:
python
from fastapi import FastAPI
from starlette.requests import Request
from starlette.responses import Response
from pydantic_ai import Agent
from pydantic_ai.ui.vercel_ai import VercelAIAdapter
agent = Agent('openai:gpt-5.2')
app = FastAPI()
@app.post('/chat')
async def chat(request: Request) -> Response:
return await VercelAIAdapter.dispatch_request(request, agent=agent)
② LangChain / LangGraph(TS)------ @ai-sdk/langchain
如果后端在 JS 侧但用的是 LangChain,官方 adapter 提供 toBaseMessages()(把 UIMessage 转成 LangChain 消息)和 toUIMessageStream()(把 LangChain/LangGraph 的流转回协议格式),自动识别流类型:
typescript
import { toBaseMessages, toUIMessageStream } from '@ai-sdk/langchain';
import { ChatOpenAI } from '@langchain/openai';
import { createUIMessageStreamResponse, UIMessage } from 'ai';
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const model = new ChatOpenAI({ model: 'gpt-4o-mini' });
const langchainMessages = await toBaseMessages(messages);
const stream = await model.stream(langchainMessages);
return createUIMessageStreamResponse({
stream: toUIMessageStream(stream),
});
}
一句话:能找到现成 adapter 就别手写 SSE;实在没有(比如 Ruby),再照着事件字典自己吐------反正协议是公开且稳定的。
小提示:SDK 版本号和协议 wire 版本号是两回事。响应头目前固定
v1;内部 data-stream 协议里6起支持工具审批流,而7发出的线格式与6一致(v7 == v6 的 wire)。做对接时对齐这几个事件类型即可,不必纠结 SDK 大版本号。
六、小结
- 协议不是 SDK:它是前后端之间传流式数据的格式约定,可脱离 SDK 单独使用。
- 两种协议:纯文本流(简单)与 UI 数据流(默认、功能全,基于 SSE)。
- 数据流的核心是"事件字典" :文本、推理、工具调用、引用、错误各有类型,文本/推理都是
start→delta→end三段式。 - 跨语言对接很简单 :带上
x-vercel-ai-ui-message-stream: v1,按 SSE 吐事件,以data: [DONE]收尾,你的 Python/Go/Ruby 后端就能喂饱前端的useChat。
如果你正在做 AI 应用,又不想把模型编排全塞进 Node,这套协议就是你把「任意后端」和「Vercel 那套顺手的前端 hook」缝合起来的那根线。