Server-Sent Events(SSE)是实现 AI 流式输出的最轻量方案。相比 WebSocket,它单向、基于 HTTP、浏览器原生支持,不需要额外库。
本文分享在"领航英语"项目中用 SSE 实现 AI 单词精讲的完整实践,包括前端接收、中断、结构化解析和逐行渲染。
为什么选 SSE
| 方案 | 适用场景 | 复杂度 |
|---|---|---|
| 轮询 | 低频更新 | 低 |
| SSE | 服务端单向推送流式数据 | 低 |
| WebSocket | 双向实时通信 | 高 |
AI 文本生成是典型的单向流式场景:用户发请求,AI 逐 token 返回。SSE 完美匹配。
后端接口(Go + Gin)
Go 侧设置关键响应头:
go
func (h *AIService) ExplainVocabStream(c *gin.Context) {
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no") // 禁用 Nginx 缓冲
c.Stream(func(w io.Writer) bool {
// 调用 LLM streaming API
stream := llmClient.CreateChatCompletionStream(ctx, request)
for chunk := range stream {
fmt.Fprintf(w, "data: %s\n\n", chunk)
c.Writer.Flush()
}
fmt.Fprintf(w, "data: [DONE]\n\n")
return false
})
}
关键细节:X-Accel-Buffering: no 告诉 Nginx 不要缓冲这个响应。没这行的话,Nginx 会把所有 chunk 攒到一起再发给客户端------流式变一次性,效果全没了。
前端 Fetch + ReadableStream
前端不用 EventSource API(因为它不支持 POST 请求和自定义 headers),用 fetch + ReadableStream:
typescript
async function streamAIExplain(word: string, onChunk: (text: string) => void) {
const response = await fetch('/api/ai/explain-vocab/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ word })
})
const reader = response.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 })
const lines = buffer.split('\n')
buffer = lines.pop() || '' // 最后一部分可能不完整,留着下次拼
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6)
if (data === '[DONE]') return
onChunk(data)
}
}
}
}
要点:
TextDecoder的stream: true参数处理多字节字符被截断的情况(UTF-8 中一个中文字 3 字节,流式传输可能从中断开)- buffer 机制保证不完整的行不会丢
[DONE]信号标记流结束
中断请求
用户关掉 AI 精讲面板时,需要立即中断请求,否则浪费 token 和带宽:
typescript
const abortController = ref<AbortController | null>(null)
const startStream = async (word: string) => {
abortController.value = new AbortController()
const response = await fetch('/api/ai/explain-vocab/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ word }),
signal: abortController.value.signal
})
// ...
}
const stopStream = () => {
abortController.value?.abort()
}
AbortController 是浏览器原生 API,fetch 收到 abort 信号会抛出 AbortError,后端也会收到连接断开通知。
结构化 JSON 解析
AI 返回的不是纯文本,而是结构化 JSON:
json
{
"memoryTip": "词根 spect 表示"看",pro- 表示"向前",合起来就是向前看→前景",
"usage": "prospect of doing sth, in prospect",
"example": "The prospect of studying abroad excites her.",
"examNote": "注意与 perspective(视角)区分,考试中常混在一起出题"
}
问题是:SSE 的每个 chunk 可能在任何位置断开,而 JSON 必须完整才能解析。
解法------逐 chunk 累积,尝试解析,失败就继续收:
typescript
let jsonBuffer = ''
onChunk = (chunk: string) => {
jsonBuffer += chunk
let parsed: VocabExplain | null = null
try {
parsed = JSON.parse(jsonBuffer)
} catch {
return // JSON 还不完整,继续等
}
// 解析成功,按字段渲染
renderField('memoryTip', parsed.memoryTip)
renderField('usage', parsed.usage)
renderField('example', parsed.example)
renderField('examNote', parsed.examNote)
}
生产环境建议用更鲁棒的策略:要求 LLM 逐字段输出,每个字段标记分隔符,避免等整个 JSON 收完才开始渲染。
逐行打字效果渲染
收到字段内容后,用 requestAnimationFrame 实现逐字渲染:
typescript
const displayText = ref('')
let displayTimer: number | null = null
function animateText(fullText: string) {
let index = 0
const speed = 30 // ms per char
const tick = () => {
if (index < fullText.length) {
displayText.value = fullText.slice(0, ++index)
displayTimer = window.requestAnimationFrame(
() => setTimeout(tick, speed)
)
}
}
tick()
}
requestAnimationFrame 保证渲染与屏幕刷新同步,不会出现卡顿和闪烁。
完整流程
用户点击 AI 精讲
→ fetch POST /api/ai/explain-vocab/stream
→ Go 服务调用 LLM streaming API
→ SSE chunk 逐条返回
→ 前端累积 JSON buffer
→ 解析成功后逐字渲染
→ 用户关闭面板 → AbortController 中断
这些代码来自领航英语 的实际实现。在线体验:m.dobell.top,点击任意单词卡片即可看到流式 AI 精讲效果。注册送 3 天会员,月卡 29 元。