流式输出卡在半截不动了?先把流式渲染的坑趟平再谈体验
三个最常见的翻车现场
独立开发者做 AI 应用,十个里有九个在“流式输出”这一步摔过跤。而且摔得很冤——因为问题往往不在模型侧,而在前端处理 SSE(Server-Sent Events)的方式上。
翻车现场一:等半天,答案“啪”地一下全出来。 你明明在请求里写了 stream: true,接口也确实返回了流,但页面上用户盯着空白看了八秒,然后整段文字一次性砸出来。体验直接从“高级 AI 产品”退化成“卡死的表单”。
原因几乎总是同一个:前端用 await response.json() 或者一次性 response.text() 去接流式响应。流还没传完,这些方法不会 resolve,于是所有 chunk 攒到最后一起渲染。这种写法对普通 JSON 接口没问题,对流式接口是致命的。
翻车现场二:文字出来了,但每秒疯狂闪烁重排。 有人意识到要边收边渲染,于是每收到一个 chunk 就 innerHTML 重写整个对话气泡。段落一长,浏览器不停重排重绘,页面肉眼可见地抖动,读到一半的内容跳来跳去。
翻车现场三:调试时完全不知道流到底有没有在动。 请求卡住、代理缓冲、网关超时,任何一环都可能让流“半截不动”。但你打开 DevTools 只看到一条 pending 的请求,不知道是模型慢、网关堵,还是自己的解析代码写挂了。没有日志,只能瞎猜。
这三个坑的共同点是:你没把“流”当成流来对待。下面我们把正确路径一步步走通。
正确路径的第一步:选个干净的流式入口调试
调试流式渲染,最怕的是环境本身不可控。如果你直连各家模型,每个网关的超时策略、缓冲行为、SSE 格式细节都不一样,排查问题时你分不清是代码的锅还是链路的锅。用一个统一的 OpenAI 兼容入口做基线,是省事的做法。
这篇文章用 ThisToken.AI 的网关来演示。注册流程很简单:打开官网,邮箱注册账号,在控制台的 API Keys 页面创建一个密钥并妥善保存。是否收费、怎么计费,以官网价格页为准,本文不引用具体数字。
跑通这段代码后,你就有了一个确定在正常工作的流式数据源——之后前端再出问题,就知道该往自己代码里找。
第二步:先在服务端确认流真的在动
用下面这段 Python 脚本做“基线验证”。它直接连网关,逐 token 打印,如果终端里文字一段段往外蹦,说明网关侧的流式完全正常。
# pip install openai
from openai import OpenAI
client = OpenAI(
api_key="你的_API_KEY", # 替换为 ThisToken.AI 控制台中的密钥
base_url="https://api.thistoken.ai/v1",
)
stream = client.chat.completions.create(
model="gpt-4o-mini", # 按网关支持的模型名填写
messages=[
{"role": "system", "content": "你是一个简洁的中文助手。"},
{"role": "user", "content": "用三句话解释什么是 SSE 流式输出。"},
],
stream=True,
)
full = ""
for chunk in stream:
delta = chunk.choices[0].delta.content
if delta:
full += delta
print(delta, end="", flush=True) # flush 很关键,别攒缓冲
print("\n--- 完成,共收到", len(full), "个字符 ---")两个细节值得注意:flush=True 保证你看到的是真实的到达节奏,而不是 Python 自己的缓冲;打印 len(full) 让你在调试时有据可查——如果最终字符数和完整回答对不上,说明中途丢块了。
第三步:前端用 ReadableStream 增量渲染
基线确认后,回到浏览器。正确姿势是用 response.body.getReader() 手动读流,并且只追加增量、不重写全量:
async function streamChat(prompt) {
const res = await fetch("/api/chat", { // 走自己的后端转发,密钥不进浏览器
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt }),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
const bubble = document.querySelector("#answer"); // 对话气泡节点
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true });
// 关键:append 而不是 innerHTML 整体重写
bubble.append(text);
bubble.scrollTop = bubble.scrollHeight;
}
}注意代码里请求的是 /api/chat——你自己的后端路由,由它持有密钥并转发到 https://api.thistoken.ai/v1。密钥永远不暴露在浏览器里,这是底线,哪怕只是个调试 Demo。
“只追加”这一点解决了闪烁问题:DOM 节点只新增文本,不销毁重建,浏览器不需要整段重排。如果之后要做 Markdown 渲染,可以在流结束后对完整文本做一次格式化,流式过程中保持纯文本追加。
第四步:给调试装上“仪表盘”
最后补上现场三的解法:让流的状态可见。三个低成本手段:
- 每个 chunk 打时间戳:
console.log(Date.now(), delta),一眼看出流是匀速到达还是中途断流。 - 前端计数器:在页面上显示“已收到 N 块 / M 字符”,卡住时你立刻知道是第几块之后停的。
- 检查中间层缓冲:如果你用了 Nginx 或 Serverless 函数转发,确认关闭了响应缓冲(Nginx 加
X-Accel-Buffering: no),否则网关在流式输出,中间层却在攒包,前端照样一坨砸出来。
小结
流式输出的体验问题,九成出在“把流当整体处理”这个习惯上。路径其实很清晰:先用统一网关做基线验证(服务端逐 token 打印),再用 ReadableStream 做增量渲染(只追加不重写),最后加上时间戳和计数器让链路透明。这套流程搭好之后,无论是换模型、换前端框架,还是排查线上卡顿,你都有了一套可靠的调试底座。
还没有可以跑基线的网关账号?花两分钟注册一个就能把上面那段 Python 跑起来:https://api.thistoken.ai/register
---
不想折腾多家供应商的接入差异?在 https://api.thistoken.ai/register 注册,用一个 base_url 调用所有模型。