最近给业务接了一版大模型「打字机」输出。页面要边收边渲染 Markdown,请求得带 Token,body 还是 POST。
项目里平时接口都走 axios。第一反应是:SSE 不就是 EventSource 吗?搜了一圈 demo,再对一下我们的接口约束,发现这俩基本对不上。最后还是用 fetch + ReadableStream 自己啃了一层。
把选型过程记一下,免得下次又纠结。
先看接口长什么样
我们这边对话接口大致是:
- 方法:
POST - Header:
Authorization: Bearer xxx - Body:业务参数(prompt、上下文之类)
- 响应:多数时候
text/event-stream,偶发 NDJSON,极端情况还有直接吐文本块的 - 帧内容偏 OpenAI 风格:
choices[0].delta.content
UI 侧只要两件事:来一段就刷一段;用户点停止立刻断掉。
为什么没用 EventSource
EventSource 写法很干净:
js
const es = new EventSource('/api/stream')
es.onmessage = (e) => {
console.log(e.data)
}
但它有几个硬限制:
- 基本只支持 GET
- 自定义 Header 基本没戏(Bearer 挂不上)
- 没有 request body
- 断线会自动重连------对话场景里,一次请求结束就结束了,自动重连反而容易把状态搞乱
如果后端肯改成「先 POST 拿个 ticket,再 GET 拉流」,EventSource 也能用。我们不想为了前端 API 再加一轮协议,就没走这条路。
所以:不是 EventSource 差,是它适合「公开的、GET 的推送」,不适合「鉴权 POST 的对话流」。
为什么没继续死磕 axios
axios 在项目里已经很成熟了:拦截器、错误码、刷新 Token 都齐。
但浏览器端做流式时,axios 的心智还是「等响应差不多齐了再处理」。你当然可以想办法拿底层能力,但:
- 按行拆 SSE、处理半包、解析
data:,这些 axios 都不会帮你做 - 和现有拦截器(默认当 JSON、统一 toast)很容易打架
我们最后的分工很简单:
- 普通接口继续 axios
- 对话流单独走
fetch
没必要为了「全项目只有一种请求库」硬拧在一起。
最终方案:fetch + ReadableStream
fetch 能覆盖我们卡住的点:POST、Header、AbortSignal、response.body.getReader()。
剩下的麻烦集中在三块:
- TCP 一次给你的不一定是完整一行(半包)
- 要识别
data:、跳过:注释行、碰到[DONE]收手 - 帧结构不统一,得抽一层 parser
核心逻辑大概是这样(路径和字段做过简化):
ts
type FrameParser = (value: unknown) => string | null
function defaultFrameParser(value: unknown): string | null {
if (value == null) return null
if (typeof value === 'string') {
const t = value.trim()
return t === '' || t === '[DONE]' ? null : value
}
if (typeof value !== 'object') return null
const o = value as Record<string, unknown>
// 有的网关 HTTP 200,业务错误塞在 body.code 里
if (typeof o.code === 'number' && o.code !== 0 && o.code !== 200) {
throw new Error(typeof o.message === 'string' ? o.message : '请求失败')
}
const choices = o.choices
if (Array.isArray(choices) && choices[0] && typeof choices[0] === 'object') {
const delta = (choices[0] as any).delta
if (delta && typeof delta.content === 'string' && delta.content) {
return delta.content
}
}
for (const k of ['content', 'text', 'delta', 'result', 'output'] as const) {
const v = o[k]
if (typeof v === 'string' && v) return v
}
if (o.data && typeof o.data === 'object') {
return defaultFrameParser(o.data)
}
return null
}
function processDataLine(
payload: string,
parseFrame: FrameParser,
append: (s: string) => void
) {
const data = payload.trim()
if (!data || data === '[DONE]') return
try {
const chunk = parseFrame(JSON.parse(data))
if (chunk) append(chunk)
} catch {
// 不是 JSON 就当纯文本增量
append(data)
}
}
export async function streamTextChatRequest(options: {
url: string
body?: unknown
token?: string
signal?: AbortSignal
timeoutMs?: number
onDelta: (delta: string, full: string) => void
}): Promise<string> {
const {
url,
body,
token,
signal: outerSignal,
timeoutMs = 300_000,
onDelta
} = options
const controller = new AbortController()
const onOuterAbort = () => controller.abort()
if (outerSignal) {
if (outerSignal.aborted) controller.abort()
else outerSignal.addEventListener('abort', onOuterAbort)
}
const timer = window.setTimeout(() => controller.abort(), timeoutMs)
const headers: Record<string, string> = {
Accept: 'text/event-stream, application/json, text/plain, */*',
'Content-Type': 'application/json'
}
if (token) headers.Authorization = `Bearer ${token}`
let full = ''
const append = (s: string) => {
if (!s) return
full += s
onDelta(s, full)
}
try {
const response = await fetch(url, {
method: 'POST',
headers,
body: body == null ? undefined : JSON.stringify(body),
signal: controller.signal
})
if (!response.ok) {
throw new Error((await response.text()) || `HTTP ${response.status}`)
}
const ct = response.headers.get('content-type') ?? ''
const reader = response.body?.getReader()
if (!reader) {
const t = await response.text()
if (t.trim()) append(t)
return full
}
const decoder = new TextDecoder()
let lineCarry = ''
const useSse =
ct.includes('text/event-stream') || ct.includes('application/x-ndjson')
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
if (!useSse) {
if (chunk) append(chunk)
continue
}
// 半包:上一截尾巴和下一段拼起来再按行切
lineCarry += chunk
const lines = lineCarry.split('\n')
lineCarry = lines.pop() ?? ''
for (const line of lines) {
const trimmed = line.replace(/\r$/, '').trim()
if (!trimmed || trimmed.startsWith(':')) continue
if (trimmed.startsWith('data:')) {
processDataLine(trimmed.slice(5).trimStart(), defaultFrameParser, append)
} else if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
// 兼容一行一个 JSON 的 NDJSON
processDataLine(trimmed, defaultFrameParser, append)
}
}
}
if (lineCarry && useSse) {
const trimmed = lineCarry.replace(/\r$/, '').trim()
if (trimmed.startsWith('data:')) {
processDataLine(trimmed.slice(5).trimStart(), defaultFrameParser, append)
} else if (trimmed) {
append(trimmed)
}
}
return full
} finally {
window.clearTimeout(timer)
outerSignal?.removeEventListener('abort', onOuterAbort)
}
}
页面里用法也很直白:
tsx
const ac = new AbortController()
await streamTextChatRequest({
url: '/api/ai/chat',
token: getToken(),
body: { prompt },
signal: ac.signal,
onDelta: (_delta, full) => {
setMarkdown(full)
}
})
// 停止生成
ac.abort()
流式过程中用 Markdown 组件渲染;结束后再切成可编辑文本。传输层只负责吐字,展示层自己决定怎么画。
线上踩过的几个坑
1. 半包
一行 JSON 经常被拆成两个 chunk。一开始直接 chunk.split('\n'),偶发解析失败、字丢了。加了 lineCarry 之后稳定很多。
2. 心跳行
SSE 里会有以 : 开头的注释/心跳,当正文拼进去会出奇怪字符,记得跳过。
3. HTTP 200 但业务失败
有的网关不报 4xx,错误写在 JSON 的 code 里。parser 里不处理的话,UI 会一直空转,最后才发现其实早就失败了。
4. Content-Type 不老实
同一条业务链路,有时是 text/event-stream,有时是 NDJSON,还有直接吐文本。所以用 Content-Type 分流:能按行解析就按行,否则当普通文本追加。
5. 要不要每帧都 setState
我们目前是每来一段就 setMarkdown(full)。对话体量下没感觉到卡;如果后面接推理模型狂喷 token,再考虑 rAF 合并也行,没必要一上来就优化。
有没有想过用 fetch-event-source
想过。@microsoft/fetch-event-source 把 SSE 协议层包得比较完整,POST、Header、重连策略都省事。
我们没引,主要是:
- 对话流要的是「一次请求读完」,不需要它那套重连
- 网关格式不止标准 SSE,自己控 parser 更直接
- 项目里这类能力希望和现有鉴权、错误处理、超时取消放在同一层
如果你们后端 SSE 很标准、又希望少写解析代码,用现成库完全合理。我们是被兼容性推着走到手写这条路的。
现在怎么选
结合这次接入,我自己的判断是:
- GET、无自定义鉴权、要自动重连 → EventSource 够用
- 普通 JSON 业务接口 → 继续 axios
- POST + Header + 边收边解析 + 可取消 →
fetch+ReadableStream(或 fetch-event-source)
LLM 对话流,绝大多数时候落在第三种。
具体用原生 fetch 还是再包一层库,看团队对依赖和可控性的取舍就行;选型本身不复杂,别被「SSE 就等于 EventSource」带偏就够了。