RSC 流式 UI

要点

  • 前三篇讲的 useChat / useObject 都基于「流数据 → 客户端组件渲染」这套模型
  • 先感受下代码长什么样
  • 最常用的入口是 streamUI,参数和 streamText 很像,但有两个关键区别:text 是一个回调而不是「自动返回字符串」
  • ai/rsc 不是普适工具,但在几类场景里是降维打击
  • AI 伴侣是对话产品,多轮交互、消息历史、持久化是核心

内容

1. 为什么单开一篇讲 ai/rsc

前三篇讲的 useChat / useObject 都基于「流数据 → 客户端组件渲染」这套模型。ai/rsc 提供了第四种玩法:让 LLM 直接 stream 出一个 React 组件树。

这听起来有点反直觉——LLM 怎么可能 stream 组件?它不就是吐字符串吗?

关键在于 RSC(React Server Components)的模型:Server Component 运行在服务端,产物是序列化的 React 元素(不是 HTML,也不是 JSON),传到客户端后直接挂到 React 树里。ai/rsc 做的事情是:根据 LLM 的输出,在服务端动态 yield 出不同的 Server Component 实例,这些实例按流式 chunk 发送到客户端。

用一个例子直观感受下:

// compare.txt
传统 useChat:
 
  后端: streamText → SSE → 客户端解析 UIMessage parts
 
  客户端: <Message parts={...} /> 按 part type 分发渲染
 
ai/rsc:
 
  后端: streamUI → 服务端决定每个 chunk 渲染什么组件
 
  客户端: 直接接收 <Weather city="北京" temp={22} /> 这样的组件

本专栏主线不用 ai/rsc(原因后面讲),但它是一个值得知道的工具,以后遇到特定场景能识别出来。

2. 一个 ai/rsc 最小例子

先感受下代码长什么样。

Next.js Server Action(注意是 .tsx,因为要返回 React 元素):

// actions.tsx
'use server'
 
import { streamUI } from 'ai/rsc'
 
import { z } from 'zod'
 
export async function askCompanion(question: string) {
 
  const result = await streamUI({
 
    model: models.chat,
 
    prompt: question,
 
    // 普通文本,流式渲染一个 <TextBubble>
 
    text: ({ content, done }) => (
 
      <TextBubble text={content} streaming={!done} />
 
    ),
 
    // 工具:返回的是组件,而不是数据
 
    tools: {
 
      getWeather: {
 
        description: '查询城市天气',
 
        inputSchema: z.object({ city: z.string() }),
 
        generate: async function* ({ city }) {
 
          yield <WeatherSkeleton />          // 调用中,骨架屏
 
          const weather = await fetchWeather(city)
 
          return <WeatherCard data={weather} />  // 拿到数据,换完整卡片
 
        },
 
      },
 
    },
 
  })
 
  return result.value  // 这是一个 React element
 
}

客户端组件:

// chat-page.tsx
'use client'
 
import { useState } from 'react'
 
import { askCompanion } from './actions'
 
export default function Chat() {
 
  const [ui, setUi] = useState<React.ReactNode>(null)
 
  const [input, setInput] = useState('')
 
  async function handleSubmit() {
 
    const ui = await askCompanion(input)
 
    setUi(ui)
 
  }
 
  return (
 
    <div>
 
      {ui}
 
      <input value={input} onChange={(e) => setInput(e.target.value)} />
 
      <button onClick={handleSubmit}>发送</button>
 
    </div>
 
  )
 
}

用户问「北京今天天气」,客户端先看到一个 skeleton 天气卡片,后端拿到数据后自动替换成完整卡片——整个过程前端不用写任何 part type 分发逻辑。

3. ai/rsc 的几个核心 API

最常用的入口是 streamUI,参数和 streamText 很像,但有两个关键区别:text 是一个回调而不是「自动返回字符串」,你决定每个 text chunk 渲染成什么组件;tools[xxx].generate 是一个 async generator,可以 yield 中间态、return 最终态。

createStreamableUI

更底层的 API,不和 LLM 绑定。你拿到一个「可变的 React 元素」,通过调用方法流式更新它:

// streamable-ui.tsx
import { createStreamableUI } from 'ai/rsc'
 
async function myAction() {
 
  const ui = createStreamableUI(<Loading />)
 
  // 异步推进 UI
 
  setTimeout(() => ui.update(<Progress percent={30} />), 1000)
 
  setTimeout(() => ui.update(<Progress percent={70} />), 2000)
 
  setTimeout(() => ui.done(<Result data={...} />), 3000)
 
  return ui.value
 
}

客户端 await myAction() 拿到 ui.value,渲染出来会看到三段式的变化。

createStreamableValue

流式值(不是组件)。适合在 RSC 里把一个值流给客户端:

// streamable-value.tsx
'use server'
 
import { createStreamableValue } from 'ai/rsc'
 
export async function countdown() {
 
  const v = createStreamableValue(10)
 
  for (let i = 9; i >= 0; i--) {
 
    await sleep(1000)
 
    v.update(i)
 
  }
 
  v.done()
 
  return v.value
 
}

客户端:

// use-streamable.tsx
'use client'
 
import { readStreamableValue } from 'ai/rsc'
 
import { countdown } from './actions'
 
function Counter() {
 
  const [n, setN] = useState(10)
 
  async function start() {
 
    const stream = await countdown()
 
    for await (const value of readStreamableValue(stream)) {
 
      setN(value)
 
    }
 
  }
 
  return <div onClick={start}>{n}</div>
 
}

本质上是 RSC 版本的「服务端推流」,和 SSE 等价,只是用的是 RSC 协议。

除此之外还有 useUIState / useAIState——当你想做多轮对话的 RSC 版本,需要维护「UI 状态」和「AI 历史状态」两套。useUIState 给前端用、useAIState 给 Server Action 用。这两个 Hook 需要配套一个 AI Provider,使用门槛较高,本篇不展开。官方文档的 RSC Chatbot 例子里有完整代码。

4. 什么情况下 ai/rsc 真的香

ai/rsc 不是普适工具,但在几类场景里是降维打击。

Generative UI,非对话型

Travel Agent(LLM 决定给用户展示一张机票卡片、一张酒店卡片、一个地图标记)、商品推荐(LLM 根据用户提问渲染推荐商品 grid)、Dashboard 问答(用户问「上个月哪个地区销售额最高」,直接生成一个柱状图组件)。这些场景有个共同点:输出是「一组复合组件」,不是「一段文字 + tool call 可视化」。

内部工具、运营后台

用户和 LLM 的交互是「我要一个 X」,LLM 决定怎么组合现有组件给你这个 X。这种 UI 逻辑很重的场景,用 RSC 能让 LLM 当「UI 导演」。

单次查询型产品

不是多轮对话,就是 query → response 一锤子买卖。RSC 比 useChat 轻量。

5. 为什么 AI 伴侣不用它

几个原因。

AI 伴侣是对话产品,多轮交互、消息历史、持久化是核心。useUIState / useAIState 也能做,但复杂度远高于 useChat

AI 伴侣前端要跑在不同平台(Web、iOS、Android)。RSC 只能在 Next.js(或类似 RSC 运行时)里跑,而 useChat 的 UIMessageStream 协议能被任何前端消费。

AI 伴侣后端部署在 Cloudflare Workers。RSC 协议的渲染和序列化对运行时有一定要求,Workers 上目前不是最顺滑的组合。

产品的 UI 由前端工程师决定,我们不希望 LLM 越俎代庖地选组件。

所以本专栏的主线坚持 useChat + UI Parts 方案。RSC 是你以后做 Generative UI 产品时的一把好刀,但不是万能刀。

6. ai/rsc 的已知限制

正式上生产前得知道这些限制。

只能在 RSC 运行时用

Next.js App Router 是唯一主流选择。Remix / SvelteKit / Vue 都不行。

调试和观测比 useChat 难

流里 yield 的是组件实例,不是结构化事件。你看不到清晰的 UIMessageStream 协议事件,埋点和追踪要自己设计。

多轮对话实现复杂

需要配合 AI context provider + useAIState + useUIState,API 远比 useChat 难掌握。产品如果是多轮的,老老实实用 useChat 更划算。

部分高级特性还在实验中

ai/rsc 整体比 Core 和 UI 层成熟度低。过去一年多 API 有过调整,用之前建议看最新官方文档确认稳定度。

客户端依赖体积大

RSC 协议的客户端解析器体积不小。纯 client-side 应用反而用不上它。

7. 小结

  • ai/rsc 允许 LLM stream 出 React Server Components,用于「Generative UI」类产品
  • 核心 API:streamUI(主入口)、createStreamableUI(流组件)、createStreamableValue(流值)
  • 最香的场景:Travel Agent、商品推荐、Dashboard 问答、单次查询型产品
  • 不适合的场景:多轮对话、多端应用、Cloudflare Workers 后端、UI 严格由前端掌控
  • 本专栏主线不用 ai/rsc,坚持 useChat + UIMessageStream 协议
  • 作为「工具箱里的一把刀」记住它,遇到合适场景能识别出来

前端集成四连到此结束。下一篇进入后端集成三连——AI SDK × Hono。把前面学到的 streamText / toUIMessageStreamResponse / onFinish / abortSignal 组合起来,跑在 AI 伴侣项目真实的部署环境上,同时把 Workers 特有的坑趟一遍。