让用户对着空白页等 5 到 10 秒,等来的只会是”页面是不是卡死了”的投诉。我们决定上流式输出,先试了原生 EventSource,结果发现它根本不支持 POST 请求,而我们的流式接口既要传 Authorization Header 又要传 JSON Body。最后用 fetch + ReadableStream 自研实现,灵活又可控。平台 Prompt Lab 和 SxS 都要”模型一边输出一边显示”的打字机效果。EventSource 是浏览器原生支持的 SSE 客户端,但 POST 请求它用不了。平台用 fetch + ReadableStream 自研实现,灵活又可控。这篇把整个实现链路拆开:协议、渲染、性能、错误处理、取消,以及踩过的坑。
一、为什么需要流式
LLM 输出动辄几百 token,等完整返回再展示用户要等 5-10 秒。流式让”首字延迟”降到 200-500ms,用户体验完全不同。
这个体验差异是决定性的:评测场景里用户要同时对比两三个模型的回答,如果每个都要等 10 秒,整个评测流程根本走不下去。首字 200ms 出字之后,用户至少知道系统在跑,等待焦虑大幅下降。我们上线流式后,评测页面的跳出率降了一截,这直接证明了流式的价值。
二、EventSource 是什么
EventSource 是浏览器 W3C 标准 SSE(Server-Sent Events)客户端,服务端往同一个连接持续推文本:
const es = new EventSource('/api/stream/123')
es.onmessage = e => console.log(e.data)
es.addEventListener('error', e => console.log('error', e))
es.close()
特点:
- 浏览器原生,零依赖;
- 走 HTTP 长连接,兼容性好;
- 只支持 GET(这是大限制);
- 自动重连;
- 服务端格式
data: <text>\n\n。
SSE 协议本身很简单,服务端每段以 data: 开头、空行结尾,EventSource 就会触发 onmessage。因为规范固定,后端实现门槛也低,这也是我们最初选它的原因。
三、为什么平台不用原生 EventSource
平台流式接口需要传 Authorization Header 和 JSON Body,EventSource 不支持:
- 不能设自定义 Header(虽然部分浏览器允许,但 XHR/fetch 更稳);
- 不能 POST Body(只能 GET + URL 参数);
- 不能精细控制重连。
平台改用 fetch + ReadableStream:
async function streamChat(
url: string,
body: any,
onChunk: (text: string) => void
) {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getToken()}`
},
body: JSON.stringify(body)
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
if (!res.body) throw new Error('No body')
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
// SSE 协议按 \n\n 分隔事件
const events = buffer.split('\n\n')
buffer = events.pop() || '' // 最后一段可能不完整
for (const evt of events) {
const line = evt.trim()
if (line.startsWith('data:')) {
const data = line.slice(5).trim()
if (data === '[DONE]') return
try {
const parsed = JSON.parse(data)
onChunk(parsed.content || parsed.text || '')
} catch (e) {
// 非 JSON 直接当文本
onChunk(data)
}
}
}
}
}
这段核心逻辑就是把 fetch 的 ReadableStream 按 SSE 协议解析。有两个细节容易被忽略:一是 decoder.decode(value, { stream: true }),不加这个参数,中文多字节字符会在 chunk 边界被截断,出现乱码;二是 buffer.split('\n\n') 后要把最后一段留回 buffer,因为网络包边界和事件边界往往不一致。
四、打字机效果实现
拿到 chunk 后不能直接 output += chunk 全部渲染,会闪屏。要做”逐字打字”动画:
import { ref } from 'vue'
const output = ref('')
const isStreaming = ref(false)
async function run() {
isStreaming.value = true
output.value = ''
await streamChat('/api/eval/stream',
{ prompt: '...' },
chunk => appendText(chunk)
)
isStreaming.value = false
}
function appendText(text: string) {
// 简单方案:直接累加
// output.value += text
// 平滑方案:把新 chunk 切成字符,requestAnimationFrame 逐字渲染
const chars = [...text] // 正确处理 emoji
let i = 0
function step() {
if (i >= chars.length) return
output.value += chars[i++]
requestAnimationFrame(step)
}
step()
}
这里用了 [...text] 而不是 text.split(''),是因为 emoji 和中文组合字符会被 split 拆成半截,出现”口”字乱码。requestAnimationFrame 逐字渲染的节奏和模型输出速度基本匹配,看起来像真人在打字。但逐字渲染在长文本上有性能问题,下一节说节流方案。
五、性能考量
1. 节流渲染
逐字渲染在长文本上要触发上千次 ref 更新,Vue 响应式会卡。改用 buffer + 50ms 节流:
let pending = ''
let timer: number | null = null
function appendText(text: string) {
pending += text
if (!timer) {
timer = window.setInterval(() => {
if (pending) {
output.value += pending
pending = ''
}
}, 50)
}
}
每 50ms 一次 DOM 更新,体感流畅又省 CPU。
这个改动是压测长回答(2000+ token)时发现卡顿才加的。50ms 的粒度人眼感知不到,但 ref 更新次数从上千次降到几十次,长文本滚动的流畅度提升明显。我们测过 30ms 和 80ms,最终定 50ms,是流畅度和 CPU 占用的平衡点。
2. 自动滚动到底部
const outputRef = ref<HTMLElement>()
watch(output, () => {
nextTick(() => {
if (outputRef.value) {
outputRef.value.scrollTop = outputRef.value.scrollHeight
}
})
})
滚动跟随要在 nextTick 里执行,等 DOM 更新完再滚,否则滚不到最新一行。我们踩过”用户往上翻看历史时被强制滚到底部”的问题,后来加了判断:只有用户滚动位置接近底部时才自动跟随,否则不打扰。
3. 长文本虚拟化
超过 5000 字直接渲染所有 DOM 卡顿。平台用 vue-virtual-scroller:
<RecycleScroller :items="lines" :item-size="20" v-slot="{ item }">
<div>{{ item.text }}</div>
</RecycleScroller>
把流式输出按行拆分,DOM 节点控制在视口内。
虚拟化是最后一个兜底手段,评测报告那种几万字的输出全靠它。按行拆分后 DOM 节点只有视口内的几十个,长文本滚动再也不会卡。注意流式过程中要持续把新行塞进 items 数组,配合节流一起用。
六、错误处理
流式连接中途可能断(网络/服务器重启),要做重连和提示:
async function streamWithRetry(url: string, body: any, onChunk, maxRetry = 3) {
let attempt = 0
while (attempt < maxRetry) {
try {
await streamChat(url, body, onChunk)
return // 成功
} catch (e) {
attempt++
if (attempt >= maxRetry) throw e
await new Promise(r => setTimeout(r, 1000 * attempt))
}
}
}
前端展示”已断开,正在重连(2/3)…”。
重连间隔用指数退避,第一次等 1 秒、第二次等 2 秒,避免断网时疯狂重试打满服务端。这里对比过原生 EventSource 的自动重连,它在断网时会每 3 秒无脑重连,把服务端打挂,自研方案加 backoff 后这个问题彻底消失。
七、取消流式
用户点”停止生成”时,AbortController 立即中断请求:
let controller: AbortController | null = null
function start() {
controller = new AbortController()
fetch(url, {
...body,
signal: controller.signal
})
}
function stop() {
controller?.abort()
isStreaming.value = false
}
后端收到 abort 后,停止 LLM 调用(用 Future.cancel)。
“停止生成”是评测场景的高频操作,用户想换 prompt 重跑,必须立刻中断。abort 之后 fetch 的 reader 会抛 AbortError,记得在错误处理里单独判断,别当成普通异常提示用户。我们还做了个细节:停止时把已生成的部分保留在页面上,用户可以直接复制。
八、后端 SSE 协议格式
后端 Spring Boot 输出标准 SSE:
@GetMapping(value = "/stream/{id}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> stream(@PathVariable long id) {
return chatService.stream(id)
.map(chunk -> "data: " + JsonUtil.toJson(chunk) + "\n\n")
.concatWith(Flux.just("data: [DONE]\n\n"));
}
produces = MediaType.TEXT_EVENT_STREAM_VALUE 告诉 Spring 走 SSE。
前端解析逻辑和后端格式是对死的:每段 data: JSON + 两个换行,结束标记 [DONE]。我们曾经前后端各改各的,格式对不上浪费了半天,后来在接口文档里固定了协议,前端遇到 [DONE] 就结束。这个约定写在文档最前面,双方改代码前都会先看一眼。
九、与 Pinia 状态集成
流式结果存到 Pinia store 多组件共享:
export const useEvalStore = defineStore('eval', () => {
const outputs = ref<Record<number, string>>({})
function append(itemId: number, chunk: string) {
outputs.value[itemId] = (outputs.value[itemId] || '') + chunk
}
return { outputs, append }
})
// 在组件中
const evalStore = useEvalStore()
await streamChat('/api/eval/stream', body,
chunk => evalStore.append(itemId, chunk))
// 另一个组件订阅
const output = computed(() => evalStore.outputs[itemId] || '')
这个设计解决了 SxS 页面的核心痛点:两个模型并行输出,结果分别存到 outputs[itemId],评分面板和输出卡各自订阅,互不干扰。组件卸载再回来,输出还在 store 里,不会丢。这也是把流式状态提升到全局 store 而不是留在组件里的原因。
十、踩过的坑
- 中文/emoji 长度错乱:
text.length把 emoji 算成 2,要用[...text].length。 - 流式乱码:服务端
data:后接中文没指定编码会乱码,统一UTF-8。 - 断网后浏览器疯狂重连:原生 EventSource 会在断网时每 3s 重试,把服务端打挂。自研 fetch + 手动重连加 backoff。
- HTTP/1.1 6 并发限制:浏览器对同一域名最多 6 个并发 HTTP/1.1 连接。批量并发流式调用要切到 HTTP/2 或复用连接。
- 代理缓冲:Nginx 默认
proxy_buffering on会缓冲流式响应导致延迟。加proxy_buffering off。 - HTTPS 大 body 性能:流式响应体超过 1MB 走 HTTP/2 才有性能提升,老 HTTP/1.1 反而比 chunked 慢。
中文 emoji 错乱和流式乱码属于编码细节,中文字符在 chunk 边界被截断的问题要靠 { stream: true } 解决。代理缓冲这条很隐蔽,我们排了两天才定位到 Nginx 在攒缓冲,关掉之后首字延迟立刻降到 300ms 以内。HTTP 并发限制在 SxS 场景最明显——同时流式输出三个模型,连接数就占了一半。
方案选型上,我们对比过三种流式实现,最后选了 fetch + ReadableStream:
| 方案 | 重连控制 | 断线恢复 | 实现复杂度 | 平台选择 |
|---|---|---|---|---|
| 原生 EventSource | 自动但不可控 | 有限 | 低 | ❌ |
| WebSocket | 需自建心跳 | 可恢复 | 高 | ❌ |
| fetch + ReadableStream | 手动 backoff | 完全可控 | 中 | ✅ |
选 fetch 方案的关键理由是重连完全可控:断了之后按指数退避重试,还能带上进度继续渲染,原生 EventSource 做不到这一点。
十一、平台 UI 优化技巧
- 光标闪烁:打字机末尾加
<span class="cursor">|</span>,CSSanimation: blink 1s infinite。 - Markdown 实时渲染:每 200ms 渲染一次 Markdown 预览,不要每 chunk 都渲染。
- 代码高亮:用
shiki或prism,增量高亮新 chunk。 - 复制按钮:用户流式输出进行中就能点复制,不要等结束。
- 暂停/继续:保存已接收内容,下次接着请求时把已生成部分作为前缀发给 LLM。
这五条里,Markdown 节流渲染和代码高亮对体验影响最大。评测输出经常带代码块,如果每 chunk 都全量渲染,打字会明显掉帧;200ms 节流后既流畅又不牺牲实时性。光标闪烁用 CSS 动画实现,零 JS 开销。暂停继续的功能上线后,长回答场景的用户留存提升明显,因为不用再从头重跑。
常见问题(FAQ)
Q1:为什么不用 WebSocket?
LLM 输出是单向流(服务端→客户端),SSE 更轻量、协议更简单、自动重连是开箱即用。WebSocket 适合双向协作。
Q2:EventSource 不能 POST 怎么破?
平台用 fetch + ReadableStream 自研,灵活度更高。EventSource 仅用于简单 GET 场景。
Q3:流式响应体大小有限制吗?
理论上无限制,Nginx 默认 client_max_body_size 1m 是请求体限制,与响应体无关。