最近把一个 AI 对话页迁到 Next.js App Router。流式输出在 Pages Router 时代我闭着眼都能写,到了 App Router + RSC + server action 这套新范式,卡了我大半天。把过关的路记下来。
第一个纠结:用 Route Handler 还是 Server Action
App Router 下做流式有两条路。
Route Handler( app/api/chat/route.ts ) 是最稳的,本质还是返回一个标准的 Response,body 塞一个 ReadableStream:
javascript
export async function POST(req: Request) {
const stream = new ReadableStream({
async start(controller) {
const res = await callModel(/* ... */)
for await (const chunk of res) {
controller.enqueue(
new TextEncoder().encode(chunk.delta)
)
}
controller.close()
},
})
return new Response(stream, {
headers: { 'Content-Type': 'text/event-stream' },
})
}
客户端老老实实 fetch + getReader() 读,跟以前没两样。
Server Action 看起来更"新潮",能直接在组件里 await 调用。但纯 server action 做逐字流式其实很别扭------它是为表单提交和数据变更设计的,不是天生为长连接流式准备的。要硬做得配合 useActionState 或返回一个可迭代的东西,绕。我试了一版,体感不如 Route Handler 直接。
我的选择
流式走 Route Handler,server action 只用来做非流式的副作用,比如把这轮对话存库、更新会话标题。各干各的,别让 server action 硬扛流式。
踩到的坑
坑一:动态渲染。 默认有缓存,对话接口必须显式 export const dynamic = 'force-dynamic',否则你会发现流式接口被缓存住,第二次问返回上次的内容,人都傻了。
坑二:客户端组件边界。 渲染对话气泡、维护输入状态的那部分必须 'use client'。我一开始忘加,useState 直接报错,App Router 默认是 server component 这点得时刻记着。
坑三:Edge runtime 的流式更顺,但部分 Node API 用不了,按需取舍。
没做完美的地方
server action 存库失败时的回滚我做得很糙,目前就打个日志,没做真正的补偿。先用着。
模型那一端我接的讯飞 MaaS,现成的推理服务直接调,不折腾自建。你们 App Router 下流式是走 Handler 还是 Action?评论区交个底。