为什么生产环境很少手写流式响应:AI SDK 三层架构一次讲清
我们在 AI 应用开发 | 手写流式输出:把打字机效果背后的数据流拆开看 里面,已经把流式输出这件事手写跑通了。
但真把这套东西往聊天应用里接的时候,你很快就会感觉到,问题已经不是字怎么出来了,而是后面那一串和业务本身无关、却又必须有人接住的细节:
chunk 边界、半截 JSON、消息历史怎么记、流到一半要不要让用户停下。
今天聊的是 为什么要从手写切到 Vercel AI SDK。
先划重点
手写流式响应跑通之后,往真实聊天应用走,会被两件事情拖住:
一类是看不见的协议翻译,拆 chunk、拼半个字、认结束标记; 另一类是看不见的对话状态,记历史、知道在不在流、能不能重生成。
AI SDK 把这两类工作,分散到三个位置接走:
- 在最上游,把不同模型的 API 翻成一种统一格式
- 在服务端,统一调模型 + 统一回一条结构化事件流
- 在前端,统一维护消息数组和流状态
手写版不是不能跑,是越往前走越像在造基础设施
手写版跑通之后,真接聊天应用你会发现,花时间的地方已经不在打字机效果上了。
一部分时间花在协议边界上。
一个中文字符完全可能被拆到前后两个 chunk 里,一段 JSON 只拿到半截就 JSON.parse 直接炸掉,SSE 事件还得自己按 \n\n 切分、挑出 data 字段、认结束标记。
这些工作原本和业务没一点关系,但不接住一个,整条链就走不通。
chunk 是网络边界,不是业务消息边界。
![]()
另一部分时间花在对话状态上。
手写版的前端 state 一开始通常就长这样:
const [answer, setAnswer] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
answer 这个字符串 state 一开始单轮够用,但一旦需求里出现多轮历史、重生成、从中间分叉,每条消息还得分清是用户还是 AI、是文字还是工具调用、有没有完成,这一串东西塞不进一个字符串里。
一段字符串记得住一句话,记不住一段对话。
![]()
所以手写版的问题不是不能跑,是你一旦从 demo 往真实聊天应用走,就得在协议和状态这两层各造一套自己的基础设施。
花时间的地方不再是业务,而是这些重复又容易出错的细节。
AI SDK 覆盖的是整条链,不是某一个点
聊到 Vercel AI SDK 之前,我先说一下我自己对它的认知是怎么变的。
我一开始也以为它就是个 React hook,useChat 一调就完事了。后来多看几次才发现,它其实是一整套工具。
从模型怎么接进来、服务端怎么调模型、到前端怎么维护对话状态,整条链都覆盖了。
前面那两层工作,协议翻译和状态管理,不是某一个文件的问题,是从模型到前端整条链上散落的细节。
能把这两层一起接住的,也只能是一套覆盖整条链的东西,而不是一个孤立的 hook 或者一个孤立的服务端函数。
我自己是用一家餐厅在脑子里记这条链的
一家餐厅要上菜,它先得有供应商。
不同供应商送来的东西规格千差万别,有的按斤、有的按箱,包装单据也都不一样。
如果后厨每接一家就要重学一遍人家的规矩,这家餐厅根本开不起来。
所以稍微大一点的餐厅都会有一个收货环节,不管哪家供应商送来的,都按统一的规格入库,后厨拿到的永远是同一种格式。
收货之后,后厨才开始干自己的活,按统一的菜单做菜,按统一的餐具盛出来,再交给前台。后厨不关心这块牛肉是哪家送的,它只认入库后的规格。
前台的工作又是另一回事。它不做菜,但它要记住这一桌点了什么、上到第几道、客人有没有催菜、能不能换菜。
AI SDK 这套架构,几乎就是把这家餐厅的分工原封不动搬过来了。
- 不同模型厂商就是不同的供应商
-
provider adapter(
@ai-sdk/xxx) 就是收货环节,把不同厂商 API 的差异在这里一次性抹平 -
streamText+toUIMessageStreamResponse()就是后厨,统一调模型 + 统一往外回一条结构化事件流 -
useChat就是前台,记住这桌的整段对话现在是什么状态
![]()
带着这张图往下读,后面三个节点就是这条链上的三个停靠点。
第一个节点:先把模型接进来
不同模型厂商的 API 本来就未必长一样,路径、鉴权 header、流式事件格式、文本字段路径都可能不一样。
你如果每次都直接对着厂商 API 写代码,很快就会反复写这一家怎么接、那一家怎么接。
这时候 AI SDK 的模型接入层就开始把这事接过去了。
provider 和 adapter 这两个词后面会反复出现,我们先来认识一下。
provider 就是模型的供货方,DeepSeek 是一个 provider,OpenAI 是一个 provider,Anthropic 也是一个 provider。
adapter 就是替你跟这家 provider 对话的那段代码。AI SDK 的做法是,每家 provider 都对应一个 adapter 包。
一个 adapter 收一家 provider
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
export function createDeepSeekAiSdkProvider() {
return createOpenAICompatible({
name: 'deepseek',
apiKey: process.env.DEEPSEEK_API_KEY!,
baseURL: process.env.DEEPSEEK_BASE_URL ?? 'https://api.deepseek.com'
});
}
@ai-sdk/openai-compatible、@ai-sdk/anthropic 这一类包,做的事不是生成文本。
它先替你把不同模型厂商接成 AI SDK 能认的一种统一模型接口。
adapter 的粒度,不是公司品牌,而是协议形状
这里我一开始也有点疑惑,DeepSeek 是一家独立的大模型公司,为什么要用一个叫 openai-compatible 的包来接?
DeepSeek 的 API 形状和 OpenAI 高度兼容,请求路径、请求体字段、流式事件格式基本一致,所有为 OpenAI 写的工具链几乎零代码就能接入。
这不是 DeepSeek 一家的选择,Moonshot、Groq、OpenRouter、Ollama 走的都是同一条路。
所以 @ai-sdk/openai-compatible 根本不在乎对面是 OpenAI 还是 DeepSeek,它只看协议形状对不对,给它配上 baseURL 和 apiKey 就行。
Claude 是走另一条路的典型。
它有自己一套独立的 API 形状,路径、鉴权 header、事件类型、字段结构都和 OpenAI 不一样,所以要走 @ai-sdk/anthropic 这种专门适配。
这一层先替你接住的,不是页面,而是上游模型 API 的差异。
第二个节点:服务端怎么统一调模型和回流
模型接上之后,前端发一条消息过来,服务端总得先有一层把这次请求接住,拿到消息、调模型、再把结果回给前端。
如果你用的是 Next.js,这层通常就在 app/api/.../route.ts 里,最常见就是那个 POST 函数。
后面如果我提到 route handler,你先把它理解成这层服务端入口就行。
原来这层里那一坨读流、缓冲、拼字符串的代码,现在基本就剩调一个函数加返回一个函数。
SDK 版服务端入口还剩什么
import { convertToModelMessages, streamText, type UIMessage } from 'ai';
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: deepseek.chatModel('deepseek-chat'),
messages: await convertToModelMessages(messages)
});
return result.toUIMessageStreamResponse();
}
这几行做了三件事:
convertToModelMessages 把前端消息格式翻成模型能认的格式;
streamText 调模型拿流式结果;
toUIMessageStreamResponse() 再把结果翻回前端能消费的事件流。
原来手写版那一大坨读流、缓冲、拼字符串的代码,基本都被这三个函数接走了。
手写版那层细节,SDK 是怎么处理的
![]()
请求上游模型、读流续字、处理事件边界和结束标记,这些原来散落在服务端入口这层的工作,
全部被 provider adapter 加 streamText 在内部接走。
最后那条「怎么把结果回给前端」的统一响应,则由 toUIMessageStreamResponse() 接走。
服务端轻下来的不是业务逻辑,是那些重复的流式协议处理。
前后端之间,其实还隔着一道翻译
convertToModelMessages 和 toUIMessageStreamResponse() 这两个函数,是一对翻译器。
前端要渲染工具调用卡片、错误块、思考过程,所以 UIMessage 里带了一堆 parts;
但模型只认扁平的 role + content。
一端多一端少,中间就得有人翻。
convertToModelMessages 是把前端消息翻给模型,toUIMessageStreamResponse() 是把模型流再翻回前端消息流。
进去翻一次、出来也翻一次。
为什么回流不能只是纯文本
这里我一开始也没想清楚,手写版那套纯文本流明明能 work,toUIMessageStreamResponse() 为什么要做成一串带 type 字段的 JSON 事件?
因为前端要消费的不止是字。
AI 中途要显示「正在调用 search 工具」和工具结果卡片,要把灰色的「思考过程」和最终回答分开。
中途抛错要让 UI 识别这不是回答内容,结束时还要有一个明确信号解锁输入框。
如果流里只有字,浏览器根本没法区分哪段是思考、哪段是工具调用、哪段是错误、哪段是最终回答。
所以 SDK 把回流做成了结构化事件流,每个事件都带 type: "text-delta" 或 type: "tool-call" 这种标签,前端看到什么 type 就走什么分支。
![]()
打开浏览器 DevTools 看一眼真实返回流会更直观:
{"type":"text-delta","id":"txt-0","delta":"Vercel"}
{"type":"text-delta","id":"txt-0","delta":" AI"}
{"type":"text-end","id":"txt-0"}
{"type":"finish-step"}
{"type":"finish","finishReason":"stop"}
[DONE]
模型 原始 SSE 里,文本增量走的是厂商自己的字段路径,结束信号也走的是厂商自己的结束格式。
AI SDK 回给前端的事件流里,文本增量统一变成 text-delta,结束信号统一变成 text-end、finish-step、finish 这一类标签。
浏览器看到的已经是统一后的结果,不是模型厂商自己的原始协议。
前端处理渲染只要按 type 分发就行,不需要再认任何厂商的方言。
顺带留个钩子:messages 为什么是整段传上来的
你看这层服务端入口里 const { messages } = await req.json(),可能会以为前端传一句话、后端维护历史就行。
但大模型 API 本身是无状态的。
OpenAI、Claude 等大模型的 HTTP 接口其实都没有会话这个概念,每次请求都得把整段对话历史重新喂一次,模型拿到数组那一刻才「恢复」出上下文,生成完立刻丢掉。
所以这条链上的服务端入口每次都是全新的、无记忆的,它只是个转发层,状态落在前端的 messages 数组里。
对话越来越长之后,这个数组也会越来越大,token 吃不消了怎么办、记忆怎么维护,这是下一篇要讲的事。
第三个节点:前端怎么接住消息状态
服务端这层轻下来以后,前端这层也就没那么复杂了。
手写版前端状态,一开始通常就长这样
const [input, setInput] = useState('');
const [answer, setAnswer] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
SDK 版就会变成:
const { messages, sendMessage, status } = useChat({
transport: new DefaultChatTransport({ api: '/api/ai-sdk-chat' })
});
useChat 暴露的不是 answer,是整个 messages 数组
这两段代码的差别,真不是少写几个 state 这么简单。
useChat 接住的是请求发送、流读取、消息追加、状态流转。
你不用再自己读 response.body,也不用自己一边拼字符串,一边维护 isStreaming 什么时候开、什么时候关。
更关键的是它暴露给你的不是一个 answer,是一整个 messages 数组,每条消息长这样:
type UIMessage = {
id: string;
role: 'user' | 'assistant';
parts: Array<{ type: string; text?: string }>;
};
这套结构本来就是给整段对话准备的。
多轮、重生成、从中间分叉这些功能往上一加,全都顺势就成立。
举个例子,regenerate() 就是对 AI 最后一条回答不满意,重新生成一次。
手写版里这件事通常不是再发一次请求这么简单,你得先砍掉最后一条 answer、把字符串状态倒推回消息数组、再补重发逻辑。
而 AI SDK 这边,messages 数组本来就记着每条消息,调一个方法的事。
所以真正少的不是代码行数,是你不用自己从零搭一套消息结构。
但页面最后怎么长,还是业务自己决定
它也没有替你把前端全部做完。
输入框 state、消息怎么渲染、错误 UI 怎么展示、按钮什么时候禁用、滚动什么时候到底,这些还是业务自己决定。
比如输入框的 input / setInput 就是业务自己维护的,useChat 不管:
<textarea value={input} onChange={(event) => setInput(event.target.value)} />
<button type="submit" disabled={status !== 'ready'}>
{status === 'streaming' ? 'AI SDK 流式输出中...' : '调用 AI SDK 流式输出'}
</button>
useChat 接的是聊天状态,不是聊天页面长相。
如果你正从手写版迁过来,先改这三处
前面那条三层链路看懂之后,回到代码里第一刀该下在哪,其实顺序是固定的。
第一刀,先改 provider。
先别动前端,用 @ai-sdk/openai-compatible 或对应的 adapter 包把厂商 API 包一层,让后面 streamText 能直接调,上游差异先在这里隔离掉。
第二刀,再改服务端入口。
把手写读流、缓冲、组装响应那层换成 streamText 加 toUIMessageStreamResponse(),服务端重复的协议细节先拿掉。
第三刀,最后改前端状态。
把 answer 这种单字符串状态换成 useChat 和 messages,后面多轮、重生成、分叉、工具调用才有稳定地基。
从手写版切到 AI SDK,第一刀先别改页面,先改协议层和状态层。
![]()
改完这三刀,你会在两件事上感觉到变化。
一是原来服务端那坨 chunk 拼接、JSON 兜底、字段路径的代码,不用再看第二眼,协议那层不用碰了。
二是后面再冒出「重生成」「从某条消息重开」这类需求的时候,你不用先回头重构 state,useChat 暴露的 messages 数组已经为这些需求打好地基了。
省下来的不只是代码行数,更像是脑子不用再同时装怎么读流和怎么记对话这两件事。
现在来想想以下问题
Q1:服务端返回了一个 type: "tool-call" 的事件,但前端不知道怎么渲染。你觉得问题出在三层里的哪一层?
💡 provider adapter 负责接模型,streamText 负责调模型和回流,useChat 负责前端状态。tool-call 事件已经到了前端,说明前两层没问题。
Q2:如果你要给聊天页面加一个「从这条消息重新开始」的功能,手写版和 SDK 版各需要改什么?
💡 手写版的 answer 是一段字符串,你得先重构出消息数组才能定位到"这条消息"。SDK 版的 messages 数组里每条消息都有 id,天然支持这个操作。
Q3:现在 useChat 每次请求都把整个 messages 数组传给服务端,对话聊了 50 轮之后,你觉得会先撞到什么问题?
💡 这是下一篇的主题:对话越来越长,token 吃不消了,记忆怎么维护。
感谢您的阅读~🌹
我在微信公众号 前端Fusion 中也会持续同步更新关于 AI 与前端开发的相关文章,欢迎大家关注,一起交流学习。
![]()