用Next.js流式输出缩短聊天等待感
教程说明如何让 Next.js 服务端转发模型生成片段,并由浏览器逐步读取和显示。示例强调在流开始前完成鉴权、输入校验和限额检查;流式输出改善等待体验,不会直接加快模型生成。
AI 深度解读
AI 回答要生成 8 秒,页面就必须让用户盯着转圈等 8 秒吗?原文给出的办法是流式响应:模型每生成一段,服务端就往浏览器转发一段,让用户先读起来,而不是等整篇答案完成。
这没有让模型算得更快,却能缩短用户看不到任何结果的时间。对 Next.js 开发者,更值得读的是后半部分:中文乱码、回答中断、取消后继续计费,以及上线后流式输出突然失效,都藏在这条看似简单的数据通道里。
流式响应改变的是等待方式
语言模型逐段生成文本,许多 AI API 可以在生成过程中持续返回内容。接入流式响应,需要打通两端:服务端持续发送,客户端持续读取。
链路很直观:模型产生片段,Route Handler 转发片段,浏览器收到后更新界面。任何一环攒够全文再交给下一环,用户看到的仍然是长时间等待。
原文用「完整答案需要 8 秒,首批文字在 1 秒或更短时间出现」说明体验差异,但没有提供模型、网络环境或测试数据。这是示意场景,不能当成接入后的性能承诺。
流式输出优先改善首次看到内容的时间,不保证缩短完整答案的生成时间。
服务端的关键,是把模型输出接到 ReadableStream 上:
// app/api/chat/route.ts
export async function POST(req: Request) {
const { prompt } = await req.json();
// 1. 在开始流式输出前完成认证、校验和额度限制。
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
try {
for await (const chunk of model.stream({ prompt, signal: req.signal })) {
controller.enqueue(encoder.encode(chunk.text));
}
controller.close();
} catch (err) {
controller.error(err);
}
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
},
});
}
注意 controller.enqueue(encoder.encode(chunk.text)):流传输的是字节,所以要先用 TextEncoder 编码,再推入响应。new Response(stream) 允许响应在流关闭前发送内容,但实际何时到达浏览器,还受部署链路影响。
这里的 model.stream 是占位接口,需要替换成供应商 SDK。原文假设它返回可异步迭代的片段,每段文本位于 chunk.text;具体 SDK 的字段和调用方式仍需适配。
浏览器收到一段字节,不代表收到完整字符
客户端不能继续等完整响应,而要通过 res.body.getReader() 循环读取。下面这段代码同时展示了增量读取和字符解码:
async function ask(
prompt: string,
onText: (text: string) => void,
signal?: AbortSignal
) {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
signal,
});
if (!res.ok || !res.body) throw new Error('Request failed');
const reader = res.body.getReader();
const decoder = new TextDecoder();
let text = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
text += decoder.decode(value, { stream: true });
onText(text);
}
text += decoder.decode(); // 刷出解码器中剩余的内容
onText(text);
}
最容易漏掉的是 { stream: true } 和最后一次无参数的 decoder.decode()。前者让解码器暂存尚未收全的字符字节,后者完成收尾。
网络分块不按文字边界切。一个中文字符或 Emoji 的字节,可能分散在两个块里。如果每块各自解码,不完整的字节就可能变成 �。只测英文,很容易错过这个问题。
收到文本以后,也不必每个小块都立即触发 React 更新。原文建议最多每个动画帧更新一次状态;对于 Markdown,可以生成中先显示纯文本,结束后再格式化。这样能减少频繁解析,也能避免未闭合的 ** 或代码块让已有内容反复变样。
代价是用户要等生成结束才能看到格式。短回答直接逐块更新通常足够,长回答再按实际卡顿情况优化。
上线前要查的是整条链路
流式响应把一次请求拆成了两个错误处理阶段。
开始发送之前,输入错误、未登录、触发限流等情况,应返回正常的错误响应,例如 Response.json({ error }, { status: 4xx }),客户端通过 res.ok 判断。认证和成本限制也要放在这里,避免先调用模型,再发现请求不该被允许。
开始发送之后,示例响应的 HTTP 状态已经是 200,无法在模型中途失败时改成 500。此时流会报错,客户端的 reader.read() 可能拒绝执行,需要捕获异常、保留已有回答,并提示「回答已中断,请重试」。
这里原文有个实现缺口:配套 React 示例的异常分支调用 setAnswer('Something went wrong. Try again.'),会覆盖已有内容,与后文「保留已收到的回答」不一致。落地时应把回答文本和错误提示分开存储。
取消同样需要贯穿两端。浏览器用 AbortController 把信号传给 fetch;服务端把 req.signal 继续传给模型 SDK。只停止界面更新,不能证明上游生成已经结束。
作者也承认,连接断开后的行为可能因托管平台而异。因此需要发起一次长回答,点击 Stop,再检查供应商用量,确认取消是否真正传递上去了。
另外两项问题往往只在部署后出现:
- 反向代理、压缩层或 CDN 可能缓冲响应,导致本地逐字显示,上线后整篇一起到达。
Cache-Control: no-cache, no-transform是检查项之一,仍需核对路由上的缓冲、压缩和平台流式支持配置。 - 长回答可能超过 Serverless 函数时限。原文给出的配置是
export const maxDuration = 60;,单位为秒,实际允许值取决于托管套餐。同时还要限制模型输出长度,但原文没有给出对应 SDK 参数。
我的判断
这篇最有用的地方,是把「能显示流式文字」和「能可靠交付回答」之间的距离讲清楚了。解码、错误、取消、缓冲、超时,直接对应用户会遇到的故障,适合作为代码评审时的检查依据。
不过,「约 30 行就能完成」只适合描述最小通路。代码里的认证、校验和限流仍是注释,模型接口也是占位写法。它展示了机制,没有交付完整的生产方案。
作者自述用 Next.js 做 SaaS,但没有展示线上测量结果、取消后的账单对比或渲染性能数据。因此,「流式输出是成本最低的提速方式」更接近经验判断。
和等待完整 JSON 响应相比,纯文本流能更早展示内容,却需要承担中途失败和取消的处理成本。对于逐步阅读的聊天回答,这个交换通常合理;如果业务必须拿到完整结果才能继续操作,原文也没有证明逐段展示一定更合适。
能拿来做什么
- 给现有 Next.js 聊天页接流式输出时,先打通模型、Route Handler 和浏览器,再用中文、Emoji 及长回答检查解码和读取逻辑。
- 把界面状态拆成回答文本、生成状态和错误提示,让主动停止、中途失败、正常结束各有明确表现,避免异常覆盖已经生成的内容。
- 在真实部署环境验证两件事:首段是否持续到达,以及点击 Stop 后上游是否停止生成。前者查代理链路,后者查取消传播和供应商用量。
- 如果长回答或 Markdown 渲染出现卡顿,再采用每帧合并更新、结束后格式化,并结合托管时限设置
maxDuration和模型输出上限。