使用 Transport 节流解决 Vercel AI SDK 流式渲染卡死问题

Vercel AI SDK 高频流式推送导致页面卡死,useDeferredValue、useThrottle 等常规 React 优化全部无效,最终通过 Transport 队列节流解决问题。

一、问题现象

技术栈:React 19 + Vercel AI SDK + Streamdown(Markdown 流式渲染)

当模型侧快速推送流式数据时,页面完全卡死无法操作,通过 Performance Monitor 工具分析结果如下:

  • CPU 占用 90%+,持续数秒甚至更长,界面完全无法操作
  • 频繁 GC,内存曲线锯齿状波动
  • DOM 节点数阶梯式波动,与 GC 频率一致
  • 思考过程(reasoning)和回答正文生成阶段尤为明显

二、失效方案速览

在找到正确解法前,我们尝试了所有常规 React 性能优化手段,全部无效:

方案 结果 失败原因
useDeferredValue(messages) CPU 不降 渲染变为非阻塞,但每秒仍有 100+ 次 setMessages → React 调度 + 组件执行 + DOM diff 照常跑
useThrottle(messages, 10000) CPU 不降 只限制输出值变化频率,100+ 次 setMessages 照常触发
React.memo(Part) 效果有限 减少了历史 Part 重渲染,但当前消息的 reconciliation + Streamdown 解析仍每秒 100+ 次
缓存 source-url filter 无感 相对于主瓶颈占比极小

共同问题:这些方案都作用在渲染层,只能减少单次渲染开销,无法降低 chunk 推送到频率,100+ 次/秒的状态更新持续触发 React 调度和 DOM 操作,线程始终被占满。

三、根因分析:推送频率过高导致 UI 层过载

梳理 useChat 的数据流,发现问题的本质是:模型侧高频推送 chunk(100+ 次/秒),导致 UI 层短时间内需要响应大量状态更新,线程被阻塞。

useChat 的数据流

useChat 返回的 messages 是一个 React state。每当 stream 中有新 chunk 到达,useChat 内部执行以下流程:

markdown 复制代码
chunk 到达 (100+ 次/秒)
    │
    ▼
1 解析 SSE 事件        ← 从 HTTP stream 中解析出 UIMessageChunk
    │
    ▼
2 创建新 messages 对象   ← 不可变更新:新数组 + 新消息对象 + 新 parts 对象
    │
    ▼
3 调用 setMessages      ← 触发 React 状态更新
    │
    ▼
4 Chat 组件重渲染       ← 执行函数体、创建 React 元素、reconciliation
    │
    ▼
5 Streamdown 重新解析    ← 对整个 Markdown 字符串重新解析

每到达一个 chunk,整条链路都要跑一遍:SSE 解析 → 对象创建 → setMessages → 组件重渲染 → DOM reconciliation → Streamdown Markdown 解析。当频率高达 100+ 次/秒时,DOM 频繁创建/销毁、React 树 reconciliation 频繁触发,主线程完全被占满。

关键 :React 层的 useDeferredValueuseThrottlememo 都是作用在步骤 4-5 上的,无法从源头降低推送频率。

为什么直接在组件层节流无效

useThrottle(messages, 10000) 为例:

即使把节流调到 10 秒,useChat 仍然在每秒处理 100+ 个 chunk,每次都触发 setMessages → React 调度更新 → 组件函数执行。这些开销无法通过 React API 消除,因为 chunk 到达的频率无法从组件层控制

结论:必须在 chunk 到达组件之前做拦截。

Vercel AI SDK 的 Transport 架构

翻阅 AI SDK 源码,useChat 的数据流如下:

scss 复制代码
useChat()
  └── transport.sendMessages()        ← 返回 ReadableStream<UIMessageChunk>
        └── HttpChatTransport         ← 发起 HTTP fetch,解析 SSE
              └── processResponseStream()  ← Uint8Array → UIMessageChunk

DefaultChatTransport.sendMessages() 返回一个 ReadableStream<UIMessageChunk>useChat 消费这个 stream,每读到一条 chunk 就执行步骤 1-3。

关键 :如果能在这个 stream 上加一个"节流阀",就能从源头控制 useChat 收到数据的频率。

四、解决方案:Transport 队列节流

解决思路

在 Transport 层拦截 stream,插入一个队列作为缓冲。具体有三个关键设计:

  1. requestIdleCallback 驱动输出:只在浏览器主线程空闲时推送,天然形成自适应背压
  2. 每次只推一条 :确保 useChat 每次处理的增量最小,避免数据过载导致线程阻塞
  3. 队列上限背压(backpressure:数据生产速度超过消费速度时的限流策略):队列满时暂停读取(暂停读取不会丢失数据,恢复后可继续从 ReadableStream 中读取后续数据,不影响数据连续性),防止内存无限增长;取消时主动清理回调和队列,避免内存泄漏

完整实现

新建 ThrottledChatTransport,继承 DefaultChatTransport

typescript 复制代码
import {
  DefaultChatTransport,
  type ChatTransport,
  type HttpChatTransportInitOptions,
  type UIMessage,
  type UIMessageChunk,
} from 'ai'

export class ThrottledChatTransport extends DefaultChatTransport<UIMessage> {
  private readonly idleTimeoutMs: number

  constructor(options?: HttpChatTransportInitOptions<UIMessage> & { idleTimeoutMs?: number }) {
    const { idleTimeoutMs = 100, ...transportOptions } = options ?? {}
    super(transportOptions)
    this.idleTimeoutMs = idleTimeoutMs
  }

  // 重写 sendMessages,拦截返回的 stream
  override async sendMessages(
    options: Parameters<ChatTransport<UIMessage>['sendMessages']>[0],
  ): Promise<ReadableStream<UIMessageChunk>> {
    const stream = await super.sendMessages(options)
    return this.throttleStream(stream)
  }

  override async reconnectToStream(
    options: Parameters<ChatTransport<UIMessage>['reconnectToStream']>[0],
  ): Promise<ReadableStream<UIMessageChunk> | null> {
    const stream = await super.reconnectToStream(options)
    if (stream === null) return null
    return this.throttleStream(stream)
  }

  private throttleStream(stream: ReadableStream<UIMessageChunk>): ReadableStream<UIMessageChunk> {
    const idleTimeoutMs = this.idleTimeoutMs
    const reader = stream.getReader()
    const queue: UIMessageChunk[] = []

    // ⭐ 背压上限:队列满时暂停读取,防止内存无界增长
    const MAX_QUEUE_SIZE = 64

    // 用可变对象避免 TypeScript 类型窄化
    const state = {
      flushScheduled: false,
      sourceDone: false,
      cancelled: false,
      readIndex: 0,  // O(1) 出队指针,避免 shift() 的 O(n)
      idleHandle: null as number | undefined | null,  // 保存回调句柄,取消时清理
      controller: null as ReadableStreamDefaultController<UIMessageChunk> | null,
    }

    // ⭐ 取消已注册的 requestIdleCallback / setTimeout
    const cancelIdle = () => {
      if (state.idleHandle == null) return
      const handle = state.idleHandle
      const ric = (globalThis as { cancelIdleCallback?: (handle: number) => void }).cancelIdleCallback
      if (ric) {
        ric(handle)
      } else {
        clearTimeout(handle)
      }
      state.idleHandle = null
    }

    const flush = () => {
      state.flushScheduled = false
      state.idleHandle = null
      if (state.cancelled) return
      const controller = state.controller
      if (!controller) return

      try {
        // ⭐ 核心:每次只推一条
        if (state.readIndex < queue.length) {
          controller.enqueue(queue[state.readIndex])
          state.readIndex++
        }

        // 消费完后压缩队列
        if (state.readIndex >= queue.length) {
          queue.length = 0
          state.readIndex = 0
        }

        if (state.readIndex < queue.length) {
          // 队列还有数据,排下一次
          scheduleFlush()
        } else if (state.sourceDone) {
          // 队列空了 + 源结束 → 关闭
          controller.close()
        }
      } catch {
        // controller 可能已关闭
      }
    }

    const scheduleFlush = () => {
      if (state.flushScheduled) return
      state.flushScheduled = true

      // ⭐ requestIdleCallback:CPU 空闲时才推送,保存句柄以便取消
      const ric = (
        globalThis as {
          requestIdleCallback?: (cb: () => void, opts?: { timeout: number }) => number | undefined
        }
      ).requestIdleCallback
      if (ric) {
        state.idleHandle = ric(flush, { timeout: idleTimeoutMs })
      } else {
        state.idleHandle = setTimeout(flush, 10) as unknown as number
      }
    }

    // 返回新的 ReadableStream,内部异步消费源 stream
    return new ReadableStream<UIMessageChunk>({
      async start(controller) {
        state.controller = controller
        try {
          for (;;) {
            // ⭐ 背压:队列满时暂停读取,等待消费侧追赶
            while (queue.length - state.readIndex >= MAX_QUEUE_SIZE) {
              if (state.cancelled) return
              if (!state.flushScheduled) scheduleFlush()
              await new Promise<void>((resolve) => setTimeout(resolve, 0))
            }

            const { done, value } = await reader.read()
            if (done) {
              state.sourceDone = true
              if (queue.length > state.readIndex) {
                scheduleFlush()
              } else {
                controller.close()
              }
              break
            }
            // 入队
            queue.push(value)
            scheduleFlush()
          }
        } catch (error) {
          if (!state.cancelled) {
            try { controller.error(error) } catch { /* 已关闭 */ }
          }
        } finally {
          reader.releaseLock()
        }
      },
      cancel() {
        state.cancelled = true
        cancelIdle()       // ⭐ 取消 pending 的回调
        queue.length = 0   // ⭐ 清空队列,释放 chunk 引用
        void reader.cancel()
      },
    })
  }
}

使用方式:

typescript 复制代码
// Chat.tsx
import { ThrottledChatTransport } from '@/lib/throttled-transport'

const [transport] = useState(
  () =>
    new ThrottledChatTransport({
      idleTimeoutMs: 100,  // 浏览器忙碌时最多等 100ms 强制推一条
      body: () => ({ model: modelRef.current, ... }),
    }),
)

const { messages, ... } = useChat({ transport, ... })

优化后的数据流

scss 复制代码
模型推送 chunk (100+ 次/秒)
    │
    ▼
HTTP fetch → ReadableStream              ← 网络层
    │
    ▼
DefaultChatTransport.processResponseStream  ← 解析为 UIMessageChunk
    │
    ▼
queue.push(chunk)                         ← 入队
    │
    ├──────── 队列 ≥ 64?──────────┐
    │     是                       │ 否
    │       │                      │
    │       ▼                      │
    │  暂停 reader.read()           │
    │  等待队列中的消息被消费       │
    │  (背压:暂停读取,等待消费)   │
    │       │                      │
    │  < 64 ◀──────────────────────┘
    │
    ▼
requestIdleCallback(flush, { timeout: 100 })
    │
    │  浏览器空闲时触发
    │  → 出队 1 条 → useChat 处理 → 1 次渲染
    │  → 队列还有 → 排下一次
    │  → 队列空 + 源结束 → close
    │
    ▼
useChat → messages → Chat → Streamdown    ← CPU 空闲时才工作

五、关键设计决策详解

上节列出了三个关键设计,下面逐一解释背后的原因,并补充两个实现细节。

1. 为什么用 requestIdleCallback 而不是 setTimeout

requestIdleCallback 只在浏览器主线程空闲时触发。当 Streamdown 解析耗时长(大文本),浏览器处于忙碌状态,回调自动延后 ------ 形成天然的自适应背压机制:

  • CPU 忙时 :回调延后,不往 useChat 塞数据,避免雪崩
  • CPU 空闲时:回调立即触发,快速排空队列
  • 不需要手动调参,推送频率自适应浏览器的处理能力

setTimeout 不感知 CPU 状态,即使浏览器正忙也会触发,可能造成新的卡顿。

2. 为什么每次只推一条,而不是批量打包?

批量推送的问题:如果积攒了 50 个 chunk 一次刷出,useChat 会在 50 个微任务中处理它们。虽然 React 的 automatic batching 会把同一 macrotask 内的 setMessages 合并为一次渲染,但 50 个 chunk 的 SSE 解析和对象创建开销仍然集中爆发。

单条推送确保每次 useChat 处理的增量最小,Streamdown 解析的文本 delta 也最小,将负载均匀分散到多个空闲周期。

3. 为什么用 readIndex 指针而不是 Array.shift()

shift() 是 O(n) 操作 ------ 需要把数组所有元素前移一位。如果队列积攒了 1000 个 chunk,1000 次 shift() 是 O(n²) = 100 万次操作。用 readIndex 指针直接索引访问,出队 O(1),消费完后使用 queue.length = 0 清空队列。

4. 为什么需要背压机制?暂停读取会不会丢数据?

网络全速入队 + 每 tick 只消费 1 个 → 队列可能无界增长。设置队列上限 MAX_QUEUE_SIZE = 64,队列中消息个数达到上限时暂停 reader.read(),避免内存无限增长。

暂停读取数据是否会丢失reader.read() 是顺序读取,暂停只是延后调用,恢复后继续读取,不影响数据连续性。

5. 为什么 cancel 时要主动取消回调并清空队列?

原始实现 cancel() 只设了 cancelled 标志,存在两个泄漏:

  • 回调句柄未保存requestIdleCallback/setTimeout 无法取消,闭包引用残留,可能导致内存泄漏
  • 队列未清空 → chunk 引用残留到回调触发后才被 GC

修复:保存 idleHandlecancel() 中调用 cancelIdle() 取消回调 + queue.length = 0 清空队列。

六、效果

指标 优化前 优化后
chunk 到达 useChat 频率 100+ 次/秒 自适应(CPU 空闲时才推)
Chat 重渲染频率 100+ 次/秒 大幅降低
Streamdown 解析频率 100+ 次/秒 大幅降低
CPU 占用 90%+(界面卡死) 稳定在 80%左右,不影响页面交互
UI 可交互性 完全卡死 正常响应

七、总结

性能优化要找到真正的性能瓶颈,类似木桶效应------决定容量的是最短的那块木板,找到它,解决它才能真正从源头解决性能问题。

这个案例中,useChat 的处理链路是:

markdown 复制代码
chunk 推送 (100+ 次/秒)
    │
    ▼
SSE 解析 → 对象创建 → setMessages → Chat 重渲染 → DOM reconciliation → Streamdown 解析
└─────────────── 整条链路每秒执行 100+ 次,线程阻塞 ──────────────┘

useDeferredValueuseThrottlememo 作用在链路后半段,只能减少渲染开销,无法降低推送频率。只有从 Transport 层拦截 stream,才能在数据到达 useChat 之前控制频率,从根源解决问题。

方案适用场景

  • 使用 Vercel AI SDK(useChat + DefaultChatTransport)的流式聊天应用
  • 模型短时间内推送大量消息片段,导致高频 chunk 到达
  • 界面在流式渲染期间卡死、CPU 持续高位

可调参数

ThrottledChatTransport 构造函数接受 idleTimeoutMs 参数(默认 100ms):

  • 增大(如 1000):推送频率更低,CPU 更省,但文本延迟更明显
  • 减小(如 80):更流畅,但 CPU 占用更高
  • 建议从 100 开始,根据实际体验调整

如果这篇文章对你有帮助,欢迎点赞收藏。有问题欢迎评论区交流 👋

相关推荐
前端缘梦1 小时前
告别 TS 运行时类型漏洞!Zod 完整入门实战教程(前端 / 全栈必备)
前端·react.js·全栈
the_answer2 小时前
Webpack vs Vite 深度对比分析
前端·webpack
转转技术团队2 小时前
验证码识别实战:前端不写页面,改训模型了?
前端
MomentYY2 小时前
Temperature:AI 的“脑洞旋钮”
前端·llm·ai编程
远航_2 小时前
OpenSpec 完整详细介绍
前端·后端
召钱熏2 小时前
状态枚举正确≠渲染正确:一个语音按钮的状态机边界修复实录
android·前端
SkyWalking中文站2 小时前
认识 Horizon UI · 1/17:SkyWalking 新一代可观测性控制台
运维·前端·监控
cidy_982 小时前
Dify 操作教程:工作流编排 & Chat 对话编排
前端·工作流引擎
tangdou3690986552 小时前
AI真好玩系列-2分钟快速了解DeepAgents | Quick Guide to DeepAgents in 2 Minutes
前端·javascript·后端