AI 问答的流式响应是怎么实现的?从 fetch 到 SSE 逐字解析

摘要:接到一个「循证问答」需求,要让大模型的回答像 ChatGPT 一样一个字一个字蹦出来,还要能随时点停止、能把回答里引用的 [1] 角标点开看证据。这篇文章记录我从零手写 SSE 流式响应消费端的完整过程,包含协议格式、buffer 切块、中文乱码、代理缓冲这些踩坑点,最后给一份可直接复用的通用代码。

一、为什么要自己手写

最直接的问题是:为什么不用浏览器原生的 EventSource

EventSource 确实天生就是为 SSE 准备的,但它有四个致命限制,而我们的场景全踩了:

需求 EventSource fetch + ReadableStream
POST + JSON body ❌ 只能 GET
自定义请求头
真正中断网络请求 ❌ 只能 close() AbortController
读取原始字节流 ❌ 拿不到 body.getReader()

我们的问答接口是个 POST /api/chat/stream,要带 session_idmessage 这些参数,还要支持用户点「停止」真正掐断后端生成。所以只能上 fetch 手写。

二、先搞清楚协议:SSE 报文长什么样

服务端返回 Content-Type: text/event-stream,本质是一串用空行分隔的文本块:

vbnet 复制代码
event: stage
data: {"stage":"检索证据库","status":"start"}

event: citations
data: {"documents":[{"number":1,"title":"..."}]}

event: token
data: {"content":"新生"}

event: token
data: {"content":"儿脓毒症..."}

event: done
data: {}

SSE 的几条核心规则:

  • 每行都是 field: value,最常见的是 event:(事件名)和 data:(数据)
  • data: 允许多行 ,最终用 \n 拼接
  • 事件之间必须有且仅有一个空行\n\n
  • 不写 event: 的行默认是 message 事件,常被用来做心跳/注释,客户端一般直接丢弃

三、整体时序:一次回答的完整数据流

在逐行看代码之前,先对端到端流程有个全局认识:

sequenceDiagram participant C as 前端<br/>(fetch + ReadableStream) participant B as 后端<br/>(FastAPI) participant M as 大模型 C->>B: POST /api/chat/stream<br/>(session_id, message, 带 AbortSignal) activate B B->>B: RAG 检索证据库 B-->>C: event: stage(检索证据库) B-->>C: event: citations(召回证据 documents) B->>M: 发起流式生成 activate M loop 逐 token 生成 M-->>B: token 增量 B-->>C: event: token(content 追加) end M-->>B: 生成完成 deactivate M B-->>C: event: answer(完整答案覆盖) B-->>C: event: done deactivate B opt 用户点击「停止」 C->>B: abort() 中断连接 B-->>C: reader.read() 抛 AbortError end

这张图里有两个关键设计值得先记住:

  1. token 是增量、answer 是覆盖 ------token 做逐字动画,answer 做最终定稿,两者配合既流畅又准确;
  2. done / error 是显式终止信号 ------不靠 reader.read() 返回 done 来判断(网络异常也会触发它,语义含糊)。

四、分五步手写

Step 1:发起请求,拿到流

ts 复制代码
const response = await fetch('/api/chat/stream', {
  method: 'POST',
  headers: {
    Accept: 'text/event-stream',      // 声明要 SSE
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ session_id, message }),
  signal: abortSignal,                // ← 中断的通道
});

if (!response.ok) throw new Error(await getErrorMessage(response));
if (!response.body) throw new Error('当前浏览器不支持流式响应');

const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';

signal 是整个链路唯一的「刹车」:外部随时调用 abortController.abort(),后面的 reader.read() 就会抛 AbortError

Step 2:读循环 + 累积 buffer

ts 复制代码
const consumeBuffer = (flush = false) => {
  // 归一化换行,再按空行切块
  const blocks = buffer.replace(/\r\n/g, '\n').split('\n\n');
  // 关键:最后一段可能是不完整的半块,弹回去等下次再拼
  buffer = flush ? '' : blocks.pop() || '';
  blocks.forEach((block) => {
    if (!block.trim()) return;
    const event = parseEventBlock(block);
    if (event) onEvent(event);
  });
};

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  buffer += decoder.decode(value, { stream: true });  // ★ stream: true
  consumeBuffer();
}
buffer += decoder.decode();   // ★ 最后 flush 一次
consumeBuffer(true);

这十几行里有三个知识点,每一个都对应一个真实踩过的坑:

blocks.pop() 留半块。 网络是分块到达的,一个事件块可能被 TCP 拆成两半:前半段是 ...data: {"content":"新生,后半段是 儿"}\n\nsplit('\n\n') 后,最后一段大概率还没等到空行,把它弹回 buffer,等下一波字节到了拼上再切。这个过程可以画成下面这个小流程图:

flowchart TD A[chunk 到达] --> B[decoder.decode<br/>stream:true 解码] B --> C[buffer += 文本] C --> D[按空行 split] D --> E{最后一段<br/>是完整块吗?} E -->|否, 是半块| F[pop 弹回 buffer<br/>等下一波拼上] E -->|是| G[逐块 parseEventBlock] F --> H[等待下一次 read] H --> A G --> I[onEvent 驱动 UI]

decode(value, { stream: true }) 防中文乱码。 UTF-8 里一个中文字是 3 字节,网络 chunk 可能恰好把某个字切成一字节 + 两字节。stream: true 会让 decoder 把不完整的多字节序列暂存起来 ,凑齐再输出,而不是直接狂暴乱码;循环结束后再用不带 streamdecode() 把内部残留强制 flush 出来。

③ 结尾要 flush = true 再切一次。 最后一个事件块后面可能没有结尾空行,得靠这次兜底。

Step 3:解析单个事件块

ts 复制代码
const parseEventBlock = (block: string): StreamEvent | undefined => {
  let event = 'message';
  const dataLines: string[] = [];

  block.split('\n').forEach((line) => {
    if (line.startsWith('event:')) event = line.slice(6).trim();
    else if (line.startsWith('data:')) dataLines.push(line.slice(5).trimStart());
  });

  if (!dataLines.length || event === 'message') return undefined; // 丢弃心跳
  const data = JSON.parse(dataLines.join('\n'));                  // 多行 data 合并
  return { event, data };
};

这里 dataLines.join('\n') 对应「多行 data: 合并」,.trimStart() 去掉 data: 后面的一个可选空格。

Step 4:驱动 UI 状态机

解析出事件后,真正有意思的是怎么把不同类型的事件映射到 UI。我们定义了一个事件协议:

ts 复制代码
type StreamEvent =
  | { event: 'stage'; data: { stage: string } }      // 当前阶段,如「检索证据」
  | { event: 'citations'; data: { documents: Citation[] } } // 召回的证据
  | { event: 'token'; data: { content: string } }    // 逐字增量
  | { event: 'answer'; data: { content: string } }   // 完整答案(覆盖)
  | { event: 'done'; data: {} }                       // 结束
  | { event: 'error'; data: { message?: string } };   // 出错

token 增量拼到内容尾部做打字机,answer 整段覆盖做定稿。先打字、后校正,兼顾动画流畅和内容准确。

Step 5:中止与错误区分

ts 复制代码
try {
  await streamChat(message, { signal, onEvent });
} catch (error) {
  const aborted = error instanceof DOMException && error.name === 'AbortError';
  // aborted ? 显示「已停止」 : 显示真实错误
}

用户点「停止」和网络报错,都会走到 catch,唯一可靠的分辨方式是 error.name === 'AbortError'。千万别用 error.message 去匹配字符串。

五、最容易忽略的一层:代理与压缩

前端代码写对了,上生产一测发现「首字延迟 3 秒」「整段答完才一次性吐出来」------十有八九是代理层缓存/压缩在作祟,跟你的 JS 一毛钱关系没有。

三个必须处理的地方:

配置 作用 没有它的后果
accept-encoding: identity 请求不接受 gzip 浏览器要等压缩块攒够才解压,首 token 巨慢
x-accel-buffering: no 关掉 nginx 对 upstream 的缓冲 nginx 把整段响应攒完才吐给前端
cache-control: no-cache, no-transform 禁中间代理缓存/转码 运营商代理可能缓存或改写响应导致截断

对应到 dev/生产代理配置:

ts 复制代码
'/api/chat/': {
  target: 'http://your-backend',
  changeOrigin: true,
  headers: { 'accept-encoding': 'identity' },
  onProxyRes(res) {
    res.headers['cache-control'] = 'no-cache, no-transform';
    res.headers['x-accel-buffering'] = 'no';
  },
}

六、后端怎么配合(FastAPI 示例)

前端只消费协议,后端负责产出。配一个 FastAPI 最小实现,长这样:

python 复制代码
import json
from fastapi.responses import StreamingResponse

def sse(event: str, data: dict) -> str:
    return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"

@app.post("/api/chat/stream")
async def chat_stream(body: ChatRequest):
    async def gen():
        yield sse("stage", {"stage": "检索证据库"})
        docs = retrieve(body.message)
        yield sse("citations", {"documents": docs})
        for token in llm.stream(body.message, docs):
            yield sse("token", {"content": token})
        yield sse("answer", {"content": full_answer})
        yield sse("done", {})

    return StreamingResponse(
        gen(),
        media_type="text/event-stream",
        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
    )

两个注意点:asyncio 的生成器里每个 await(比如真正的 token 生成)都会让 yield 刷到网络;响应头里同样要带 X-Accel-Buffering: no,否则服务端前面的 nginx 还是会把你的流缓冲掉。

七、一份可直接复用的通用代码

把上面的东西封装成一个无渲染依赖的 client:

ts 复制代码
export type StreamEvent<E extends string, D> = { event: E; data: D };

export async function ssePost<E extends string, D>(
  url: string,
  body: unknown,
  options: {
    signal?: AbortSignal;
    onEvent: (event: StreamEvent<E, D>) => void;
    parseData?: (event: string, raw: unknown) => unknown;
  },
) {
  const response = await fetch(url, {
    method: 'POST',
    headers: {
      Accept: 'text/event-stream',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(body),
    signal: options.signal,
  });

  if (!response.ok) throw new Error(`请求失败(${response.status})`);
  if (!response.body) throw new Error('当前浏览器不支持流式响应');

  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  const consume = (flush = false) => {
    const blocks = buffer.replace(/\r\n/g, '\n').split('\n\n');
    buffer = flush ? '' : blocks.pop() || '';
    for (const block of blocks) {
      if (!block.trim()) continue;
      let event = 'message';
      const dataLines: string[] = [];
      for (const line of block.split('\n')) {
        if (line.startsWith('event:')) event = line.slice(6).trim();
        else if (line.startsWith('data:')) dataLines.push(line.slice(5).trimStart());
      }
      if (!dataLines.length || event === 'message') continue;
      const raw = JSON.parse(dataLines.join('\n'));
      options.onEvent({
        event: event as E,
        data: (options.parseData?.(event, raw) ?? raw) as D,
      });
    }
  };

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });
    consume();
  }
  buffer += decoder.decode();
  consume(true);
}

八、总结

回头看,手写 SSE 的难点从来不在「读流」这个动作本身,而在这些细节:

  1. 半块数据 ------用 split + pop 保留不完整尾段;
  2. 中文乱码 ------decode(v, { stream: true }) + 结尾 flush;
  3. 真正的中止 ------AbortSignal 一路透传 + 用 error.name 判断;
  4. 假流式------代理层禁压缩、禁缓冲,否则前端写得再好也不逐字。

把这四点记牢,剩下的就是套协议的事了。如果你的流式响应还带有「工具调用」这类更复杂的事件,也可以沿着同一套状态机继续扩展------本质都一样:把一个持续到达的字节流,切块、解码、解析成一个个可响应的事件

相关推荐
用户521133498121 小时前
拍照识别试卷:我在一台 2016 年的 NAS 上做「错题本」,踩了 8 个坑
人工智能
ikoala1 小时前
DeepSeek 官方仓库惊现 DeepSeek Harness 桌面端!
前端·javascript·后端
Zzj_tju1 小时前
VLM 读图评测:先审计评分器,再判断读错还是算错
人工智能·深度学习·机器学习·语言模型
嘟嘟嘟95271 小时前
AI Agent 的边缘困境
人工智能·架构·agent
deepseek231 小时前
Claude Mythos 5越界投毒拆解:穿过CAPTCHA向PyPI投毒,82%重跑有害率背后的偏见推理与监控失效
人工智能·claude·ai agent
杨航 AI1 小时前
本地双4060ti16g加macmini不断优化私有化大模型
人工智能
hey you~2 小时前
语音机器人如何配合人工完成复杂进线服务?三种协同模式与落地要点
人工智能·机器人·语音识别·智能客服·呼叫中心·转人工策略
www.022 小时前
Codex额度用完怎么办?Windows定时自动继续VS Code Codex任务实战
人工智能·windows·vscode·自动化·openai·codex·autohotkey
星禾元亨2 小时前
生成式 AI 时代的架构演进与 GEO 优化:实体企业 AI 落地避坑指南
大数据·人工智能·架构·自动化·创业创新