摘要:接到一个「循证问答」需求,要让大模型的回答像 ChatGPT 一样一个字一个字蹦出来,还要能随时点停止、能把回答里引用的
[1]角标点开看证据。这篇文章记录我从零手写 SSE 流式响应消费端的完整过程,包含协议格式、buffer 切块、中文乱码、代理缓冲这些踩坑点,最后给一份可直接复用的通用代码。
一、为什么要自己手写
最直接的问题是:为什么不用浏览器原生的 EventSource?
EventSource 确实天生就是为 SSE 准备的,但它有四个致命限制,而我们的场景全踩了:
| 需求 | EventSource |
fetch + ReadableStream |
|---|---|---|
| POST + JSON body | ❌ 只能 GET | ✅ |
| 自定义请求头 | ❌ | ✅ |
| 真正中断网络请求 | ❌ 只能 close() |
✅ AbortController |
| 读取原始字节流 | ❌ 拿不到 | ✅ body.getReader() |
我们的问答接口是个 POST /api/chat/stream,要带 session_id、message 这些参数,还要支持用户点「停止」真正掐断后端生成。所以只能上 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事件,常被用来做心跳/注释,客户端一般直接丢弃
三、整体时序:一次回答的完整数据流
在逐行看代码之前,先对端到端流程有个全局认识:
这张图里有两个关键设计值得先记住:
token是增量、answer是覆盖 ------token做逐字动画,answer做最终定稿,两者配合既流畅又准确;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\n。split('\n\n') 后,最后一段大概率还没等到空行,把它弹回 buffer,等下一波字节到了拼上再切。这个过程可以画成下面这个小流程图:
② decode(value, { stream: true }) 防中文乱码。 UTF-8 里一个中文字是 3 字节,网络 chunk 可能恰好把某个字切成一字节 + 两字节。stream: true 会让 decoder 把不完整的多字节序列暂存起来 ,凑齐再输出,而不是直接狂暴乱码;循环结束后再用不带 stream 的 decode() 把内部残留强制 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 的难点从来不在「读流」这个动作本身,而在这些细节:
- 半块数据 ------用
split + pop保留不完整尾段; - 中文乱码 ------
decode(v, { stream: true })+ 结尾 flush; - 真正的中止 ------
AbortSignal一路透传 + 用error.name判断; - 假流式------代理层禁压缩、禁缓冲,否则前端写得再好也不逐字。
把这四点记牢,剩下的就是套协议的事了。如果你的流式响应还带有「工具调用」这类更复杂的事件,也可以沿着同一套状态机继续扩展------本质都一样:把一个持续到达的字节流,切块、解码、解析成一个个可响应的事件。