30秒速览
- Vercel AI SDK 3.0的streamUI能让React组件像聊天消息一样逐段“长”出来,比打字机效果更进一步,直接流式传输可渲染的JSX。
- 我用在文章卡片、动态表单和仪表板问答三个场景,代码量砍半,但类型安全需要自己用Zod建护栏,不能全交给AI。
- 注意SEO和网络中断问题,流式UI适合做交互触发的增量模块,核心内容还是走SSR/ISR稳如老狗。
打字机效果根本就是“假流式”,我花了两年才从这种幻觉里走出来
“AI 聊天界面不就是打字机效果吗?”——这种想法在我脑子里盘踞了差不多两年。我第一次做流式输出时,以为把模型吐出来的 token 一个接一个塞进 setText(prev => prev + chunk) 就足够现代化了。毕竟 2022 年几乎所有 AI 产品都这么干,用户看到文字一行行往外蹦,好像也买账。
但很快我就撞了墙。去年我们团队做一套 AI 驱动的数据分析后台,典型需求是:“帮我展示最近 7 天各产品线的销量趋势,并用柱状图和表格对比。”后端拿到这句话,调用模型生成一份带数据摘要、图表配置和 Markdown 表格的 JSON,前端再解析渲染。整个过程从用户点击按钮到页面出现内容,快则 4 秒,慢的时候 13 秒——那次 13 秒的等待让我记忆犹新,产品经理走过来说:“这体验太像 2005 年的网页了。”我连反驳的力气都没有,因为所有能用的优化手段我都用过了:流式传输、分块解析、乐观占位……本质上还是在等模型把整段话“写完”再一次性吐出来。流式文字只是让用户觉得“系统在动”,但对真正的交互来说,那些逐字出现的文字根本没法承载按钮、卡片、图表这些带交互的 UI 组件。打字机效果只是把卡顿包装得稍微好看了一点,骨架还是那个骨架。
今年初,我把 Next.js 项目升级到 App Router,顺带把 AI 层的依赖切到了 Vercel AI SDK 3.0。本来是想用它的 streamText 简化一下后端逻辑,没想到翻文档时看到 createStreamableUI 这个 API,旁边附了一个 Demo:一个代办清单工具,调用一次 AI,页面上先是出现标题栏,然后列表项一条一条插进来,每条还带着勾选框和删除按钮——那感觉就像 React 组件自己在时间轴上“长”了出来,而不是从前端拼装好再一起渲染。我坐在显示器前愣了好一会儿,那种震撼和 2013 年第一次看见 React 虚拟 DOM 刷新页面时的感觉一模一样:你以为自己已经理解了流式,其实过去两年做的只是让文字跑了个动画。
下面我会把这次改造的完整过程拆开讲,包括原理、代码、项目中踩过的坑,以及我下一步打算怎么继续折腾这种“组件级流式”。
让 React 组件“生长”而不是“加载”——SDK 背后究竟做了什么
要理解这种魔法,得先把传统流式和组件流式的区别掰开揉碎。过去我们用 fetch 接一个 ReadableStream,服务端每生成一段文本就推给浏览器,前端拿 response.body.getReader() 循环拼接,再用 dangerouslySetInnerHTML 或类似手段塞进 DOM。这种方式能输出的只有纯文本或一段 HTML 字符串,即便想渲染一个带样式的卡片,也得等全部 HTML 拼接完毕,再把整块内容塞给 react-markdown 之类的东西一次性解析。这个过程中用户看到的是文字从无到有,但交互元素(比如卡片上的“下载 CSV”按钮)始终不存在,直到最后一批数据到达。
Vercel AI SDK 3.0 的做法完全不同。它把工具调用和流式 UI 绑在了一起,核心思路是:AI 在流式生成过程中,可以随时“抛出”一个工具调用请求,服务端立即返回一个 React 组件的 placeholder,然后随着工具调用的参数逐步完整,组件对应的 props 也被逐段填入,最终变成一个完整的、可交互的 React 元素。这一切都借助 createStreamableUI 和 服务端操作(Server Actions)实现,不需要前端轮询,也不需要你手写 WebSocket。
最让我开窍的一段代码长这个样子:
// app/actions.tsx —— 服务端核心逻辑
import { createStreamableUI, createStreamableValue } from 'ai/rsc';
import { openai } from '@ai-sdk/openai';
import { streamText } from 'ai';
import { z } from 'zod';
export async function submitMessage(formData: FormData) {
'use server';
const ui = createStreamableUI();
const message = formData.get('message') as string;
(async () => {
const { textStream } = await streamText({
model: openai('gpt-4-turbo'),
system: `你是数据分析助手。当用户要求展示图表或表格时,你会调用相应的工具。`,
messages: [{ role: 'user', content: message }],
tools: {
showChart: {
description: '展示一个图表组件',
parameters: z.object({
title: z.string(),
type: z.enum(['bar', 'line']),
data: z.array(z.object({ label: z.string(), value: z.number() })),
}),
},
showTable: {
description: '展示一个表格组件',
parameters: z.object({
headers: z.array(z.string()),
rows: z.array(z.array(z.string())),
}),
},
},
});
// 逐步处理工具调用
for await (const chunk of textStream) {
// 如果模型决定调用工具,chunk 可能会包含 toolCalls
// 这里简化处理:实际项目中需要解析 tool_call delta
ui.update(思考中...);
}
// 模拟流式填充组件:实际开发中,可以根据完整工具调用结果渲染最终组件
ui.done(
{/* 这里会用之前流的工具调用结果来渲染真正的图表/表格组件 */}
<React.Suspense fallback={加载图表...}>
{/* 客户端组件 */}
);
})();
return ui.value;
}
你可能注意到我特意用了 gpt-4-turbo,它在函数调用和结构化输出上的表现比早期模型稳定得多,而且响应速度足够支撑这种“边想边画”的交互。实际落地时,我不会直接让 AI 流式吐完整的 JSON 数据,而是利用函数调用参数流(tool call argument stream),让前端在 tool_call 的 arguments 字段还只有部分时就先渲染一个骨架。例如当 {"title": "一周销售趋势" 这个片段到达时,我的前端就能立刻挂载一个卡片,标题栏填上那一部分文字,其余区域显示脉动动画;等 "type": "bar" 传过来,图表区域马上替换成一个空白的柱状图容器,继续等待 data 数组填充。这就是那篇 Demo 里代办清单一条条“长”出来的真正原理。整个过程不再依赖轮询,流中断、重连也有 SDK 的 experimental_onToolCall 等钩子兜底。
我把这个思路抽象成了一套“组件流式工厂”模式:每个工具对应一个高阶函数,接收一个 stream 对象,返回一个带有 useStreamableState 的 React 组件,负责把逐渐完整的 props 映射到组件不同区域的渲染状态。这样一来,任何设计系统里的组件——图表、表格、表单、卡片——都可以零改造接入流式生成流程。
改造那个“2005 年的后台”:从 13 秒死等到 3 秒出骨架
有了前面的技术打底,我决定把上面那个数据分析后台整个翻新。旧流程是:用户输入一句自然语言,前端 POST 到 /api/generate-report,后端等 AI 返回完整的报告 JSON(包含文本摘要、图表数据、表格数据),然后一次性返回,前端再渲染。新流程变成:前端调用 Server Action,AI 用 streamText 边生成边通过工具调用来“吐出”组件。我将原来的单一 JSON 拆成了三个独立的工具:showSummaryCard、showChart、showTable,这样 AI 能按任意顺序逐步调用,前端也可以根据调用顺序动态挂载组件。
真正跑起来的时候,效果比我想象的还要夸张。用户输入“帮我展示最近 7 天各产品线的销量趋势”之后,大约 0.6 秒,页面上先弹出一个半透明卡片,标题栏写着“七月份各产品线销量趋势”(AI 在第一个工具调用参数中给出了标题的部分文本)。接着不到 1 秒,卡片里多了一个空白的图表容器,y 轴刻度已经画好,等待数据点。再过 1 秒左右,柱状图的数据从第一个柱子到第七个柱子依次“长”出来,同时图表下方开始出现表格的标题行。整个过程用户能清晰地看到系统正在“做”什么,而不是干等一个最终结果。从前那个需要 13 秒的请求,因为组件骨架在 3 秒内就出现了,用户感知到的等待时间直接砍掉了一大半。产品经理再次走过来的时候,只说了四个字:“早点弄啊。”
这里给出简化后的 Server Action 里处理流式工具调用的核心片段,这是让组件“生长”的关键:
// 处理流式工具调用,逐步更新 UI
let currentUI = createStreamableUI();
const uiUpdates: Record = {};
for await (const part of result.fullStream) {
if (part.type === 'tool-call') {
const { toolCallId, toolName, args } = part;
// 将工具调用的参数拼接为字符串,便于流式传递
const argsText = JSON.stringify(args);
if (toolName === 'showChart') {
const chartProps = JSON.parse(argsText);
uiUpdates[toolCallId] = (
);
currentUI.update({Object.values(uiUpdates)});
} else if (toolName === 'showTable') {
// 同样处理表格
const tableProps = JSON.parse(argsText);
uiUpdates[toolCallId] = (
);
currentUI.update({Object.values(uiUpdates)});
}
}
}
// 当 fullStream 结束后,将所有骨架替换为真实组件
currentUI.done(
{Object.entries(uiUpdates).map(([id, ]) => {
// 根据 id 从完整结果中获取最终数据,渲染真实组件
return ;
})}
);
实际生产中的代码会更复杂,因为工具调用的参数是流式到达的,不能简单地用 JSON.parse 一次解析。我引入了 partial-json 这类库,可以容忍不完整的 JSON 字符串,从而在每一个 delta 到来时更新对应组件的 props。例如 {"title": "销量趋势", "data":[ 就能让图表容器先显示标题和轴,数据数组每新增一个元素,图表就动态推一根柱子。这种粒度下,用户甚至能观察到柱状图从左到右依次出现,体感非常接近“魔法”。
我还做了一些边界情况处理:如果 AI 半途抛出工具调用格式错误,我会降级为展示普通文本回复;如果图表数据量太大导致渲染卡顿,我会在客户端侧用 requestIdleCallback 分批提交柱子。整个改造花费了两周左右,但换来的交互流畅度直接让这个后台的内测 NPS 从 32 涨到了 67。
这些坑可能让你想把“流式组件”这个词从字典里删掉
做完改造之后我一度觉得找到了终极方案,但很快就撞上了现实的墙。首先是组件间的依赖问题:比如图表需要和摘要卡片里的数据范围保持一致,但 AI 可能先返回图表,再返回摘要,导致摘要生成时图表已经渲染完毕,改起来很麻烦。我的解法是给每个工具调用挂一个 contextId,前端根据 id 建立依赖图谱,只有前置组件完成后再渲染后续组件。这又引入了新的问题——如果前置组件迟迟没生成,后续组件就会一直卡在骨架状态,用户会以为挂了。我不得不在每个骨架上加一个超时机制:超过 8 秒没更新,就显示“生成超时,点击重试”。
第二个大坑是滚动位置的剧烈跳动。组件一个个“长”出来的时候,页面高度持续变化,如果用户正在浏览已经生成的内容,新组件插入会导致内容被推走。我的对策是用 ResizeObserver 监视容器高度,在插入新组件前计算偏移量,并用 scrollBy 补偿,但偶尔还是会有瞬间跳动。Firefox 下尤其明显,最终我只能让新增组件先以 0 高度渲染,获取真实高度后再动画展开,这才基本稳住。
另外,AI 生成的内容不够稳定也是个老问题。虽然 gpt-4-turbo 的函数调用准确率已经很高,但偶尔还是会给出类型不匹配的参数,比如把柱状图的 type 写成 'column' 而不是 'bar'。我的防御措施是在 zod schema 之外再加一层运行时校验,并在组件中提供 fallback 渲染:如果类型不合法,就降级为纯文本表格。你还要处理好并发工具调用:有时 AI 会一口气连续调用三四个工具,如果后端没有正确处理 Promise.all 和 UI 更新的顺序,就会出现组件乱序挂载。我在 Server Action 里用了一个 AsyncLocalStorage 队列来保证 UI 更新顺序与工具调用顺序一致,才彻底解决。
我觉得下一步值得关注的是,把这种“生长感”交给产品经理自己编排
改造完成后,我花了几天时间复盘整个流程,发现虽然底层流式已经相当可靠,但业务方要想快速搭建一个带“组件生长”的 AI 功能,门槛还是太高。你需要理解 Server Actions、流式状态管理、zod schema、工具调用参数解析…… 这对前端工程师都有点吃力,更别说产品或者运营了。
这让我开始考虑把流式组件的编排抽象成一种“剧本”。就像现在用 JSON 配置表单、用 DSL 描述页面一样,未来是不是能让产品经理在一张画布上拖拽出组件流式出现的步骤:第一步展示标题卡片,第二步在卡片内展开摘要文本,第三步替换成交互式图表,第四步弹出操作按钮——然后这份“剧本”直接作为 AI 的系统指令,模型在生成回复时自动按步骤调用对应的工具。我最近用 generateText 配合 stepCount 参数做了个原型,已经能让模型在生成答案前先规划要用的组件序列,再按顺序输出工具调用。虽然目前只支持线性流程,但分支、循环这些逻辑我相信很快可以接入。
另一个让我兴奋的方向是“局部再生”。现在如果用户对某个图表不满意,说“把柱状图换成折线图”,整个流式组件序列需要重新跑一遍,其实太浪费。如果我能只把那一小块 UI 标记为可重新流式生成的独立节点,让 AI 只针对这一部分重新调用工具,前端做一个平滑的过渡动画,体验会再上一个台阶。React 的 useOptimistic 和 Server Actions 的 revalidate 机制已经有了这样的雏形,只是需要把流式工具调用的部分拆得更细。
说到底,让 React 组件“长”出来这件事,最让我着迷的不是技术本身,而是它终于把“AI 在帮你做事”这个事实可视化了。用户不用再面对一个冰冷的加载条,而是亲眼看着界面像有生命一样一点一点拼凑出他们想要的结果。这种体验,比任何 NPS 分数都更能说服我继续往深里挖。