Node.js 到 React 的 ChatGPT 流式全链路:SSE 代理、背压与 hook 状态的一致性治理
引言
ChatGPT 的流式响应不是一个端点,而是一条跨了 Node 服务端、代理层、React 客户端的长链路。任何一层「按块当事件」「逐字写状态」「忽略背压」,都会把流式折腾成卡顿甚至乱序。本文不重复 SSE 协议本身,只拆这条链路三处最容易出错的接缝,并给出可复用的治理范式。
一、Node 端:把上游流正确交给下游
服务端通常是「转发上游 OpenAI 流」的角色。反例是用 await 把上游一次性读全再回传,流式在服务端就断了。
// 反例:服务端先把上游读完再返回,流式变阻塞
app.post('/api/chat', async (req, res) => {
const upstream = await fetch('https://api.openai.com/v1/chat/completions', { ... });
const full = await upstream.text(); // 等全部 token 到齐
res.send(full); // 才一次性发回,客户端无流式体验
});
// 正例:pipe 上游的 ReadableStream,流完整透传
app.post('/api/chat', async (req, res) => {
const upstream = await fetch('https://api.openai.com/v1/chat/completions', { ... });
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
// 直接把上游 body 的字节流 pipe 到响应,不落地缓冲
const reader = upstream.body.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
res.write(value); // 边读边写,逐块透传
}
res.end();
});
服务端的关键职责是「透传」而非「聚合」。res.write 落盘到网络缓冲即可,不必关心 SSE 事件边界——那是客户端的事。
二、背压:上游快、下游慢时的丢帧与爆内存
当客户端消费慢于上游产出,中间缓冲会无限增长。反例是完全忽略 res.write 的返回值。
// 反例:忽略 backpressure,res.write 连写不等待 drain
while (true) {
const { value, done } = await reader.read();
if (done) break;
res.write(value); // 不检查返回值,慢客户端下缓冲区飙升
}
// 正例:监听 drain 事件,尊重背压信号
const reader = upstream.body.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
// res.write 返回 false 表示内核缓冲已满,等待 drain
if (!res.write(value)) {
await new Promise((resolve) => res.once('drain', resolve));
}
}
res.end();
res.write 返回 false 是 Node 的背压信号:下游还没消费完,先别继续写。等待 drain 再继续,避免内存缓冲失控——这是同步读取链路上的背压闭环。
三、React 端:增量状态要「批」不要「逐字」
流到了客户端,最考验的是如何进 React 状态。反例是每来一个 token 就 setState 一次,触发一次完整的渲染。
// 反例:每个 token 都 setState,瞬间触发几十次渲染
const [text, setText] = useState('');
function onToken(token) {
setText((prev) => prev + token); // 每个词一次 setState,渲染风暴
}
// 正例:用缓冲累加 + 请求动画帧批处理,合并到一帧
const [text, setText] = useState('');
const bufferRef = useRef('');
const frameRef = useRef<number | null>(null);
function onToken(token) {
bufferRef.current += token; // 只写 ref,不触发渲染
if (frameRef.current === null) {
frameRef.current = requestAnimationFrame(() => {
setText(bufferRef.current); // 一帧只 setState 一次
frameRef.current = null;
});
}
}
token 先写进 ref,用 requestAnimationFrame 把同一帧内的多次 token 合并成一次 setState。流式照流,渲染却只随帧刷新。这是「数据流」与「渲染流」解耦的关键。
四、用 useReducer 收敛「流式状态机」
流式会话不止一个 text 字段:它有 idle、streaming、done、error 多个阶段。散落多个 useState 会制造状态不一致。
// 反例:多个独立 useState,阶段与内容容易失同步
const [status, setStatus] = useState('idle');
const [text, setText] = useState('');
const [error, setError] = useState(null);
// 三个状态各自更新,'done' 时 text 可能还没写完
// 正例:用 useReducer 把流式生命周期建模成状态机
const initialState = { status: 'idle', text: '', error: null };
function reducer(state, action) {
switch (action.type) {
case 'START': return { ...state, status: 'streaming', text: '' };
case 'TOKEN': return { ...state, text: state.text + action.token };
case 'DONE': return { ...state, status: 'done' };
case 'ERROR': return { ...state, status: 'error', error: action.error };
default: return state;
}
}
const [state, dispatch] = useReducer(reducer, initialState);
useReducer 把「流式会话」变成一个可穷举的状态机:START、TOKEN、DONE、ERROR 四种迁移,杜绝了「status 已 done 但 text 没写完」这类中间态。
五、跨端一致的三条契约
整条链路收敛成三条跨端一致的规则,避免每层各搞一套:
// 正例:跨端契约
// 1. 服务端只透传,不聚合;字节流 pipe 到下游
// 2. 转发层尊重背压:res.write 返回 false 就等 drain
// 3. 客户端增量走 ref + RAF 批处理,生命周期走 useReducer 状态机
// 反模式速查
// await upstream.text():服务端把流读死
// 忽略 res.write 返回值:慢客户端下内存失控
// 每个 token setState:渲染风暴
// 多个 useState 拼状态:生命周期失同步
这三条分别落在「Node 端正确性」「传输稳定性」「React 渲染效率」上,缺了任何一层,另两层的努力都会被抵消。
结语
ChatGPT 流式响应真正的复杂度不在协议,而在「流要跨越三个异构边界还保持一致性」:服务端要把上游流透传给下游,转发层要用背压信号守住缓冲,客户端要把 token 流与渲染流解耦。当每一层都守住自己的那条契约——服务端透传、中间抗背压、前端批渲染——流式体验才从「能跑」变成「稳定地快」。
评论区
登录 后参与评论