为什么生产环境很少手写流式响应:AI SDK 三层架构一次讲清

0 阅读14分钟

我们在 AI 应用开发 | 手写流式输出:把打字机效果背后的数据流拆开看 里面,已经把流式输出这件事手写跑通了。

但真把这套东西往聊天应用里接的时候,你很快就会感觉到,问题已经不是字怎么出来了,而是后面那一串和业务本身无关、却又必须有人接住的细节:

chunk 边界、半截 JSON、消息历史怎么记、流到一半要不要让用户停下。

今天聊的是 为什么要从手写切到 Vercel AI SDK

先划重点

手写流式响应跑通之后,往真实聊天应用走,会被两件事情拖住:

一类是看不见的协议翻译,拆 chunk、拼半个字、认结束标记; 另一类是看不见的对话状态,记历史、知道在不在流、能不能重生成。

AI SDK 把这两类工作,分散到三个位置接走:

  • 在最上游,把不同模型的 API 翻成一种统一格式
  • 在服务端,统一调模型 + 统一回一条结构化事件流
  • 在前端,统一维护消息数组和流状态

手写版不是不能跑,是越往前走越像在造基础设施

手写版跑通之后,真接聊天应用你会发现,花时间的地方已经不在打字机效果上了。

一部分时间花在协议边界上。

一个中文字符完全可能被拆到前后两个 chunk 里,一段 JSON 只拿到半截就 JSON.parse 直接炸掉,SSE 事件还得自己按 \n\n 切分、挑出 data 字段、认结束标记。

这些工作原本和业务没一点关系,但不接住一个,整条链就走不通。

chunk 是网络边界,不是业务消息边界。

chunk-vs-message-boundary.png

另一部分时间花在对话状态上。

手写版的前端 state 一开始通常就长这样:

const [answer, setAnswer] = useState('');
const [isStreaming, setIsStreaming] = useState(false);

answer 这个字符串 state 一开始单轮够用,但一旦需求里出现多轮历史、重生成、从中间分叉,每条消息还得分清是用户还是 AI、是文字还是工具调用、有没有完成,这一串东西塞不进一个字符串里。

一段字符串记得住一句话,记不住一段对话。

answer-vs-messages.png

所以手写版的问题不是不能跑,是你一旦从 demo 往真实聊天应用走,就得在协议和状态这两层各造一套自己的基础设施

花时间的地方不再是业务,而是这些重复又容易出错的细节。


AI SDK 覆盖的是整条链,不是某一个点

聊到 Vercel AI SDK 之前,我先说一下我自己对它的认知是怎么变的。

我一开始也以为它就是个 React hook,useChat 一调就完事了。后来多看几次才发现,它其实是一整套工具

从模型怎么接进来、服务端怎么调模型、到前端怎么维护对话状态,整条链都覆盖了。

前面那两层工作,协议翻译状态管理不是某一个文件的问题,是从模型到前端整条链上散落的细节

能把这两层一起接住的,也只能是一套覆盖整条链的东西,而不是一个孤立的 hook 或者一个孤立的服务端函数。

我自己是用一家餐厅在脑子里记这条链的

一家餐厅要上菜,它先得有供应商。

不同供应商送来的东西规格千差万别,有的按斤、有的按箱,包装单据也都不一样。

如果后厨每接一家就要重学一遍人家的规矩,这家餐厅根本开不起来。

所以稍微大一点的餐厅都会有一个收货环节,不管哪家供应商送来的,都按统一的规格入库,后厨拿到的永远是同一种格式。

收货之后,后厨才开始干自己的活,按统一的菜单做菜,按统一的餐具盛出来,再交给前台。后厨不关心这块牛肉是哪家送的,它只认入库后的规格。

前台的工作又是另一回事。它不做菜,但它要记住这一桌点了什么、上到第几道、客人有没有催菜、能不能换菜。

AI SDK 这套架构,几乎就是把这家餐厅的分工原封不动搬过来了。

  • 不同模型厂商就是不同的供应商
  • provider adapter(@ai-sdk/xxx 就是收货环节,把不同厂商 API 的差异在这里一次性抹平
  • streamText + toUIMessageStreamResponse() 就是后厨,统一调模型 + 统一往外回一条结构化事件流
  • useChat 就是前台,记住这桌的整段对话现在是什么状态

ai-sdk-three-layer-chain.png

带着这张图往下读,后面三个节点就是这条链上的三个停靠点。


第一个节点:先把模型接进来

不同模型厂商的 API 本来就未必长一样,路径、鉴权 header、流式事件格式、文本字段路径都可能不一样。

你如果每次都直接对着厂商 API 写代码,很快就会反复写这一家怎么接、那一家怎么接。

这时候 AI SDK 的模型接入层就开始把这事接过去了。

provideradapter 这两个词后面会反复出现,我们先来认识一下。

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,它只看协议形状对不对,给它配上 baseURLapiKey 就行。

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 是怎么处理的

mermaid-sdk.png

请求上游模型读流续字处理事件边界结束标记,这些原来散落在服务端入口这层的工作,

全部被 provider adapter 加 streamText 在内部接走。

最后那条「怎么把结果回给前端」的统一响应,则由 toUIMessageStreamResponse() 接走。

服务端轻下来的不是业务逻辑,是那些重复的流式协议处理。

前后端之间,其实还隔着一道翻译

convertToModelMessagestoUIMessageStreamResponse() 这两个函数,是一对翻译器

前端要渲染工具调用卡片、错误块、思考过程,所以 UIMessage 里带了一堆 parts

但模型只认扁平的 role + content

一端多一端少,中间就得有人翻。

convertToModelMessages把前端消息翻给模型toUIMessageStreamResponse()把模型流再翻回前端消息流

进去翻一次、出来也翻一次。

为什么回流不能只是纯文本

这里我一开始也没想清楚,手写版那套纯文本流明明能 work,toUIMessageStreamResponse() 为什么要做成一串带 type 字段的 JSON 事件?

因为前端要消费的不止是字。

AI 中途要显示「正在调用 search 工具」和工具结果卡片,要把灰色的「思考过程」和最终回答分开。

中途抛错要让 UI 识别这不是回答内容,结束时还要有一个明确信号解锁输入框。

如果流里只有字,浏览器根本没法区分哪段是思考、哪段是工具调用、哪段是错误、哪段是最终回答。

所以 SDK 把回流做成了结构化事件流,每个事件都带 type: "text-delta"type: "tool-call" 这种标签,前端看到什么 type 就走什么分支。

structured-event-stream.png

打开浏览器 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-endfinish-stepfinish 这一类标签。

浏览器看到的已经是统一后的结果,不是模型厂商自己的原始协议。

前端处理渲染只要按 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 能直接调,上游差异先在这里隔离掉。

第二刀,再改服务端入口。

把手写读流、缓冲、组装响应那层换成 streamTexttoUIMessageStreamResponse()服务端重复的协议细节先拿掉

第三刀,最后改前端状态。

answer 这种单字符串状态换成 useChatmessages后面多轮、重生成、分叉、工具调用才有稳定地基

从手写版切到 AI SDK,第一刀先别改页面,先改协议层和状态层。

migration-three-cuts.png

改完这三刀,你会在两件事上感觉到变化。

一是原来服务端那坨 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 与前端开发的相关文章,欢迎大家关注,一起交流学习。

分享底图_压缩.png