前端开发··1 阅读·预计 11 分钟

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 字段:它有 idlestreamingdoneerror 多个阶段。散落多个 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 把「流式会话」变成一个可穷举的状态机:STARTTOKENDONEERROR 四种迁移,杜绝了「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 流与渲染流解耦。当每一层都守住自己的那条契约——服务端透传、中间抗背压、前端批渲染——流式体验才从「能跑」变成「稳定地快」。

0 评论

评论区

登录 后参与评论