← 返回资讯

流式 UI 实现:让大模型输出"打字机效果"不卡顿

2026-07-03

流式输出(Streaming)让用户在模型生成第一个 token 时就开始看到内容,而非等待整段回复完成。对于长文生成场景,首字延迟(TTFT)从 5~30 秒降到 300ms 以内,是体验差距最大的单一优化。

你大概率是这么踩进这个坑的:本地开发时用 fetch 直接拿完整 JSON 渲染,一切正常,字数少、模型响应快,感觉不出差别。上线之后用户反馈”点了发送卡半天没反应”,你打开日志一看,模型确实在正常吐字,只是被你的接口攒成一个大包,等生成完毕才一次性返回给前端。用户盯着一个转圈的 loading,等了 8 秒才看到结果——这 8 秒不是模型慢,是你的架构在”假装”没有流式。这篇讲的就是怎么把这条链路上每一层的”攒批”行为都拆掉,让 token 从模型吐出来到用户眼睛看到之间,尽量不经过任何一次完整缓冲。

先说清楚一个容易混淆的概念:TTFT(Time To First Token,首字延迟)和 TPS(Tokens Per Second,吐字速率)是两个独立指标,优化方式完全不同。TTFT 主要取决于你的网络链路有没有做无谓缓冲(这篇文章解决的问题),TPS 则取决于模型本身的推理速度和你选的模型档位(这个后端说了不算,前端更说了不算)。如果你的产品里长文生成体验差,先用 performance.now() 在前端埋点分别测这两个值,再决定往哪个方向优化——很多团队把两者混为一谈,砸钱升级模型档位结果 TTFT 纹丝不动,因为瓶颈根本不在推理速度上,而在 Nginx 反向代理的默认缓冲策略上。

全链路架构

LLM API(SSE 流)
  │ chunk by chunk

后端(Node.js / Python)
  │ 透传或聚合后转发

前端(SSE / WebSocket / ReadableStream)


React 组件(逐字追加 state)

关键原则:中间层不要缓冲再转发,直接透传 chunk,否则消除了流式的意义。

这条链路上每一环都有机会”偷偷”把流式变成非流式,而且大多数情况下你不会收到任何报错,只会收到”变慢了”这种模糊反馈,排查起来特别费劲。按链路顺序列一遍常见嫌疑对象:

  1. 模型供应商侧:确认你传的请求体里 stream: true 确实生效了。有些供应商即使传了这个参数,返回的还是完整 JSON(尤其是走了某些第三方代理转发的情况),你可以用 curl -N 直接测底层接口,看响应是不是分块到达的,-N 参数会关闭 curl 自身的缓冲,能看到真实的到达节奏。
  2. 反向代理层(Nginx/Caddy):Nginx 默认会把上游响应先攒够一定大小(proxy_buffering 默认开启)再转发给客户端,SSE 这种小包高频的场景正好踩中这个默认行为的反面。除了代码里加的 X-Accel-Buffering: no 响应头,Nginx 配置里也建议直接加 proxy_buffering off;,两边双保险,因为响应头有时会被中间的 CDN 剥离掉。
  3. CDN / Edge 网关:如果你的接口经过 Cloudflare、阿里云 CDN 之类的边缘节点,很多 CDN 对非静态资源默认也会做缓冲甚至压缩(gzip 会等攒够一个 block 才输出),需要单独给这条路径关闭缓存和压缩,或者绕过 CDN 直连源站。
  4. Node.js 运行时:如果你在 Node.js 里用的是 Express 之类的传统框架,注意别在中间件里对 res 做了 res.end() 之前的额外 buffer 操作(比如日志中间件把 response body 完整读一遍再转发),这类”顺手”的中间件逻辑经常是隐藏的流式杀手。

后端透传:Node.js + Hono 示例

// Hono(Edge-friendly)流式透传
app.post("/api/chat", async (c) => {
  const { messages } = await c.req.json();

  const upstream = await fetch("https://api.openai.com/v1/chat/completions", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${process.env.OPENAI_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ model: "gpt-4o", messages, stream: true }),
  });

  // 直接把 upstream body 作为响应体返回,零缓冲
  return new Response(upstream.body, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      "X-Accel-Buffering": "no",  // 关闭 Nginx 缓冲,关键!
    },
  });
});

X-Accel-Buffering: no 是生产环境最常遗漏的配置:Nginx 默认会缓冲响应,加上这个头部才能让 SSE 实时到达浏览器。

这里还有个容易被忽略的坑:proxy_read_timeout。Nginx 默认这个值是 60 秒,意思是上游超过 60 秒不发数据就会被判定超时,连接直接被切断,前端表现为流式内容读到一半突然中断,浏览器控制台看到的是 net::ERR_INCOMPLETE_CHUNKED_ENCODING,后端日志里则是一个平平无奇的 502。如果你的场景涉及长文生成、复杂推理链(比如带工具调用的多轮 Agent 输出),单次生成超过一分钟很常见,这个超时值必须手动调高,建议设到 300 秒起步,具体以你实测的最长生成耗时为准,不要拍脑袋填一个数字了事。同理,如果你用的是云厂商的负载均衡(阿里云 SLB、AWS ALB 之类),它们自己也有独立的空闲超时配置,往往在负载均衡层就把连接掐了,Nginx 那边配置改了也没用,这种情况要去云控制台把 LB 层的超时也一并调整。

另外提一句流式协议的选型。SSE(Server-Sent Events)、WebSocket、原始 HTTP chunked transfer 这三种都能做到”边生成边发”,但适用场景不同,选错了会带来不必要的复杂度:

方案适用场景优点缺点
SSE(text/event-stream单向流式输出(模型→前端),绝大多数聊天场景浏览器原生 EventSource 支持自动重连;协议简单,基于普通 HTTP只能服务器推客户端,不能反向发消息;部分企业代理对长连接有限制
WebSocket需要双向实时通信,比如带打断/中途插话的语音对话全双工,交互延迟最低需要单独处理心跳、重连、状态管理,服务端资源占用更高
原始 chunked 流(本文示例用的方式)你想完全自定义协议格式,不想受 SSE 的 data: 前缀约束灵活,可以直接透传上游 JSON chunk没有内置重连机制,断线需要自己实现续传逻辑

大部分聊天类产品用 SSE 或者本文示例里这种基于 ReadableStream 的原始 chunked 透传就够了,没必要一上来就上 WebSocket——双向能力用不上的话,只是白白多背了一套连接管理的维护成本。

前端消费:React + ReadableStream

async function streamChat(messages: Message[], onChunk: (text: string) => void) {
  const res = await fetch("/api/chat", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ messages }),
  });

  if (!res.body) throw new Error("No response body");

  const reader = res.body.getReader();
  const decoder = new TextDecoder();

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    // 解析 SSE data 行
    const text = decoder.decode(value, { stream: true });
    for (const line of text.split("\n")) {
      if (!line.startsWith("data: ") || line === "data: [DONE]") continue;
      const json = JSON.parse(line.slice(6));
      const delta = json.choices?.[0]?.delta?.content ?? "";
      if (delta) onChunk(delta);
    }
  }
}

// React 组件使用
function ChatMessage() {
  const [content, setContent] = useState("");

  const handleSend = async () => {
    setContent("");
    await streamChat(messages, (chunk) => {
      setContent((prev) => prev + chunk);
    });
  };

  return <div className="whitespace-pre-wrap">{content}</div>;
}

这段代码里最容易被忽略、但线上一定会暴雷的一行是 decoder.decode(value, { stream: true }) 里的 { stream: true } 参数。中文、日文这类多字节字符在 UTF-8 编码下经常跨 chunk 边界被切断——比如一个汉字的字节流恰好被切成两半分到两个 chunk 里。如果不传 { stream: true }TextDecoder 会把每次调用都当成独立、完整的输入来解析,遇到被截断的字节序列就会输出乱码替换符 。传了 { stream: true } 之后,解码器会把不完整的尾部字节暂存起来,等下一个 chunk 到达后拼上再解码,这是专门为流式场景设计的参数,中文场景下必须加,英文场景即使不加也很少暴露问题(因为英文字符都是单字节),这也是为什么很多国外教程的示例代码里没有这行、抄过来测中文输出就乱码的原因。

再说说 SSE 数据帧的解析细节。标准 SSE 协议里,一条消息可以由多行组成,比如 event: message\ndata: 第一行\ndata: 第二行\n\n,多个 data: 行要用换行符拼接成一条完整消息,消息之间用空行分隔。上面示例代码里用 text.split("\n") 逐行处理、只认 data: 前缀,是一种简化写法,能覆盖大多数 OpenAI 兼容接口的输出格式,但如果你对接的供应商返回了多行 data: 或者用了 id:/retry: 字段做断线续传,就得用更完整的 SSE 解析器(比如 eventsource-parser 这个库),不要自己手写正则去啃,边界情况比想象中多,一行 split("\n") 处理不了 chunk 恰好在一条 SSE 消息中间被切断的情况——这也是原始 fetch 流式方案和浏览器原生 EventSource API 最大的差异:EventSource 内置了完整的帧解析和自动重连,自己用 fetch + ReadableStream 则要么自己补全这套逻辑,要么接受”极端情况下偶尔丢一帧”的风险。

Vercel AI SDK 方案(推荐快速上手)

// 使用 Vercel AI SDK,屏蔽底层流式细节
import { useChat } from "ai/react";

function ChatUI() {
  const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat({
    api: "/api/chat",
  });

  return (
    <div>
      {messages.map((m) => (
        <div key={m.id}>
          <strong>{m.role}:</strong> {m.content}
        </div>
      ))}
      <form onSubmit={handleSubmit}>
        <input value={input} onChange={handleInputChange} />
        <button disabled={isLoading}>发送</button>
      </form>
    </div>
  );
}

Vercel AI SDK 自动处理 SSE 解析、错误重连、loading 状态,是 Next.js 项目的最快路径。不过它对后端接口的返回格式有约定(默认走它自己定义的 data stream protocol,不是裸的 OpenAI SSE 格式),如果你要对接非 OpenAI 兼容的自建后端,需要用它提供的 LangChainAdapter 或手动实现 toDataStreamResponse,直接把裸 chunk 塞给 useChat 大概率解析不出来,这是很多人上手时第一个卡壳的地方,遇到”消息一直不显示”先检查这层协议是否对上。

React 渲染这块还想再展开说说,因为它是流式体验里最容易”看起来对了但其实很卡”的一环。上面代码里每来一个 chunk 就 setContent((prev) => prev + chunk),如果模型吐字速度很快(比如每秒 3050 个 token),意味着每秒触发 3050 次 React 状态更新和重渲染。单次渲染的组件不复杂时问题不大,但只要这个消息组件里嵌了 Markdown 解析、代码高亮之类稍重的逻辑,每次 keystroke 级别的重渲染都会让主线程忙不过来,表现就是文字一卡一卡地往外蹦,而不是顺滑的打字机效果。

解法是用 requestAnimationFrame 做节流合批:chunk 到达时只写入一个 useRef 缓冲区,不直接触发 state 更新;用一个 requestAnimationFrame 循环按浏览器刷新率(通常 60fps,约每 16ms 一次)把 ref 里攒的内容一次性 flush 到 state。这样无论模型吐字多快,React 的渲染频率都被锁定在浏览器能顺畅处理的节奏上,用户观感是均匀的逐字滚动,而不是忽快忽慢的卡顿感。这个技巧不只对聊天场景有用,任何”高频小更新、只关心视觉结果”的场景(进度条、实时日志滚动)都适用同一个思路。

最后提一下成本和吞吐的估算,这是产品决策时经常被忽略、但上线后必然要面对的问题。流式只改变了”用户什么时候看到内容”,并不改变”模型生成消耗了多少 token、花了多少钱”——这一点很多刚接触的同学会搞混,以为流式输出比一次性输出更省钱或者更快出结果,其实底层推理耗时和 token 消耗量完全一样,流式只是把同样的结果拆成小块尽早展示出来。如果你在做多供应商接入、需要预估不同模型的响应速度和成本,这部分数据变动频繁,具体以官方定价页为准,别把某个时间点测到的数字直接硬编码进产品文案里。

常见坑与解法

问题原因解法
流式内容不实时,批量到达Nginx/代理缓冲X-Accel-Buffering: noCache-Control: no-cache
中文字符乱码TextDecoder 未开启流模式new TextDecoder() 调用时传 { stream: true }
连接中断后内容丢失无重连机制使用 EventSource API(自动重连)或记录已收到的 token offset
React 渲染抖动每个 chunk 都触发 re-renderuseRef 缓冲,requestAnimationFrame 批量更新

常见问题

Python 后端怎么做流式透传? FastAPI 用 StreamingResponse + 生成器函数:yield f"data: {chunk}\n\n"。关键是不要在生成器外面加 await,保持惰性求值。

流式输出支持函数调用(Tool Calling)吗? 支持,但函数调用的 arguments 是分多个 chunk 拼出来的 JSON 字符串,需要先缓冲拼完整再 JSON.parse,不能逐 chunk 处理。

如何在流式渲染中做 Markdown 解析? 使用支持增量解析的库(如 marked + streaming 模式,或 react-markdown 配合状态更新),避免每个 chunk 都重新解析整个字符串造成闪烁。


← 返回 应用模式总览:从 Prompt 到 Agent | 应用模式专题

相关阅读:AI Agent 开发实战:从 ReAct 到工具调用 · 应用可观测与日志

流式 API 需要同时对接多个供应商?力达云聚合 API 统一封装各家流式协议差异,前端代码无需适配每家的 SSE 格式。