先把结论甩出来:Next.js App Router 里做大模型流式输出,服务端用 Route Handler 直接返回一个 ReadableStream,把模型 SSE 透传给浏览器;前端别急着上 EventSource(它只支持 GET,传不了 body),用 fetch + response.body.getReader() 自己读流就行。整套打通其实就两个文件的事,难点都在那些没人提的细节里。
事情起因是上周我给自己那个内部小工具加了个对话框。之前一次性等模型把几百字吐完再渲染,白屏五六秒,我自己都嫌卡。改成流式,逐字蹦出来,体感立刻不一样了。
服务端:route handler 把流透传出去
app/api/chat/route.ts 长这样:
javascript
export const runtime = 'edge' // node 也行,edge 冷启动快点
export async function POST(req: Request) {
const { messages } = await req.json()
const upstream = await fetch('https://你的模型网关/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.LLM_KEY}`,
},
body: JSON.stringify({ model: 'xxx', messages, stream: true }),
})
// 直接把上游的 SSE 流当成自己的响应体往下游灌
return new Response(upstream.body, {
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
},
})
}
注意 no-transform 那一行,别省。我一开始没写,本地好好的,一上 Nginx 反代就变成等全部生成完才一次性返回------代理把流给缓冲了,排查了快一个钟头才反应过来是这破玩意儿。
如果上游格式跟你前端想要的对不上,就别裸透传,用 TransformStream 中间拆一道 data: 行再重新拼,逻辑也就十几行,这里不展开了。
前端:fetch 读流,逐字渲染
javascript
const res = await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ messages }),
})
const reader = res.body!.getReader()
const decoder = new TextDecoder()
let buf = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
// SSE 一条条 \n\n 切,注意半截 chunk 要留着下一轮拼
const lines = buf.split('\n\n')
buf = lines.pop() ?? ''
for (const line of lines) {
if (!line.startsWith('data:')) continue
const data = line.slice(5).trim()
if (data === '[DONE]') return
const delta = JSON.parse(data).choices[0].delta.content ?? ''
setText((t) => t + delta) // 蹦字
}
}
那个 buf.pop() 留半截的细节是真容易翻车------一个 chunk 不保证正好切在 \n\n 上,UTF-8 中文还可能被切两半,TextDecoder 的 { stream: true } 帮你兜住后半个问题,前半个得自己用 buffer 接。
一点题外的取舍
代码不长,但调通那天我顺手干了件偷懒的事。模型这块我没自己折腾,挂了个零代码就能配智能体的平台------拖一拖把提示词、知识库配好,发布成一个 API,我前端这边只管 fetch 那个地址。说实话第一版我让那小助手当客服答疑,回答干巴巴像背说明书,回去把知识库文档喂细了点、温度调低,第二版才像样。它也就帮我省了搭后端和调模型的活,前端流式这套该写还得写,逐字渲染的边界问题它一个都不替你扛。
学习曲线倒不算陡,但配 RAG 那块文档分块策略我摸了大半天才找到手感,不是点两下就完美。
模型/API 我走的讯飞 MaaS,现成调,没自己部署算力,省了一台 GPU 机器钱。
你们接流式踩过哪些代理缓冲、中文截断的坑?评论区聊聊,我赌一半人栽在 no-transform 上。