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 层的 useDeferredValue、useThrottle、memo 都是作用在步骤 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,插入一个队列作为缓冲。具体有三个关键设计:
requestIdleCallback驱动输出:只在浏览器主线程空闲时推送,天然形成自适应背压- 每次只推一条 :确保
useChat每次处理的增量最小,避免数据过载导致线程阻塞 - 队列上限背压(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
修复:保存 idleHandle,cancel() 中调用 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+ 次,线程阻塞 ──────────────┘
useDeferredValue、useThrottle、memo 作用在链路后半段,只能减少渲染开销,无法降低推送频率。只有从 Transport 层拦截 stream,才能在数据到达 useChat 之前控制频率,从根源解决问题。
方案适用场景
- 使用 Vercel AI SDK(
useChat+DefaultChatTransport)的流式聊天应用 - 模型短时间内推送大量消息片段,导致高频 chunk 到达
- 界面在流式渲染期间卡死、CPU 持续高位
可调参数
ThrottledChatTransport 构造函数接受 idleTimeoutMs 参数(默认 100ms):
- 增大(如 1000):推送频率更低,CPU 更省,但文本延迟更明显
- 减小(如 80):更流畅,但 CPU 占用更高
- 建议从 100 开始,根据实际体验调整
如果这篇文章对你有帮助,欢迎点赞收藏。有问题欢迎评论区交流 👋