流式通信底层:用原生 fetch 与 ReadableStream 解析 SSE 事件流
本节目标
- 掌握大模型 API 流式响应(Server-Sent Events, SSE)的底层报文格式与分块传输机制
- 解决流式字节切分中的两大经典工程 Bug:UTF-8 多字节中文字符截断乱码与 TCP 跨块半包/粘包行拆分
- 用原生
fetch、ReadableStream、TextDecoder与AbortController编写零依赖的流式增量解析器与tool_calls碎片拼接器
为什么 Agent 必须构建在流式(Streaming)底座上?
一个复杂的 Agent 任务往往包含 3~8 轮推理,每轮生成数百个 Token。如果采用阻塞式 HTTP 等待完整响应,首字节时间(TTFB)动辄长达 10~30 秒,用户面对白屏极易误以为页面卡死;更关键的是,在流式传输下,我们可以边接收模型的思考过程(Thought)边实时渲染 UI,甚至在检测到模型输出违规内容或死循环征兆的瞬间,通过 AbortController 立即切断连接,节省 80% 的无效 Token 成本。
剖析 SSE 原始字节流的跨块粘包陷阱
网络层的 ReadableStream 每次 reader.read() 吐出的 Uint8Array 分块(Chunk)是由操作系统套接字缓冲区决定的,绝不会 neatly 按行对齐:
Chunk 1 字节流末尾: data: {"choices":[{"delta":{"content":"你好
Chunk 2 字节流开头: 世界"}}]}
data: [DONE]
如果你直接对每个 Chunk 调用 new TextDecoder().decode(chunk).split('\n'),会同时踩中两个致命坑:
- UTF-8 多字节截断:汉字「好」占 3 个字节(
E5 A5 BD),若前 2 个字节落在 Chunk 1,第 3 个字节落在 Chunk 2,不带{ stream: true }的decode()会直接输出两个 ``(U+FFFD 替换字符)。 - JSON 半包解析崩溃:Chunk 1 末尾的
data: {"choices":...还没收完就被送去JSON.parse(),当场抛出SyntaxError: Unexpected end of JSON input。
手写工业级 SSE 流式解析器与 Tool Call 增量聚合器
在流式模式下,模型发起工具调用时,tool_calls 的 arguments 也是按几十个碎片(Delta)一点点流式吐出的(例如先吐 {"path":,再吐 "/src/,最后吐 index.js"})。下面的代码完整实现了跨块缓冲、UTF-8 流式解码、增量参数拼接与超时熔断:
export async function streamChatCompletion({
url,
apiKey,
model,
messages,
tools,
onContentDelta,
onToolCallDelta,
timeoutMs = 30000
}) {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(new Error('LLM 流式请求超时')), timeoutMs)
try {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`
},
body: JSON.stringify({ model, messages, tools, stream: true }),
signal: controller.signal
})
if (!res.ok || !res.body) {
const errText = await res.text().catch(() => '')
throw new Error(`LLM HTTP ${res.status}: ${errText.slice(0, 200)}`)
}
const reader = res.body.getReader()
const decoder = new TextDecoder('utf-8')
let lineBuffer = ''
let fullContent = ''
// 按 index 聚合流式分片的 tool_calls
const toolCallsMap = new Map()
while (true) {
const { value, done } = await reader.read()
if (done) break
// 关键点 1:必须开启 { stream: true } 保留跨块未完成的 UTF-8 字节序列
lineBuffer += decoder.decode(value, { stream: true })
// 关键点 2:只消费已遇到换行符的完整行,将尾部残余半行留给下一轮 Chunk
const lines = lineBuffer.split('\n')
lineBuffer = lines.pop() || ''
for (const rawLine of lines) {
const line = rawLine.trim()
if (!line || line.startsWith(':')) continue // 忽略空行与 SSE 心跳注释
if (!line.startsWith('data:')) continue
const payload = line.slice(5).trim()
if (payload === '[DONE]') continue
const packet = JSON.parse(payload)
const delta = packet.choices?.[0]?.delta
if (!delta) continue
// 拼接普通文本增量
if (delta.content) {
fullContent += delta.content
onContentDelta?.(delta.content, fullContent)
}
// 拼接流式 tool_calls 增量碎片
if (Array.isArray(delta.tool_calls)) {
for (const tc of delta.tool_calls) {
const idx = tc.index ?? 0
if (!toolCallsMap.has(idx)) {
toolCallsMap.set(idx, {
id: tc.id || '',
type: 'function',
function: { name: tc.function?.name || '', arguments: '' }
})
}
const entry = toolCallsMap.get(idx)
if (tc.id) entry.id = tc.id
if (tc.function?.name) entry.function.name += tc.function.name
if (tc.function?.arguments) {
entry.function.arguments += tc.function.arguments
onToolCallDelta?.(entry.function.name, tc.function.arguments)
}
}
}
}
}
// 冲刷 TextDecoder 内部残余字节
lineBuffer += decoder.decode()
const toolCalls = [...toolCallsMap.entries()]
.sort((a, b) => a[0] - b[0])
.map(([, v]) => v)
return {
role: 'assistant',
content: fullContent || null,
...(toolCalls.length ? { tool_calls: toolCalls } : {})
}
} finally {
clearTimeout(timer)
}
}课后练习
练习 1:在上面的代码中,为什么调用 decoder.decode(value, { stream: true }) 时必须传入 { stream: true },且在 lineBuffer.split('\n') 后要用 lineBuffer = lines.pop() || ''?
答案:{ stream: true } 告诉 TextDecoder 当块尾出现不完整的多字节 UTF-8 字符(如中文字符被切在两个网络包之间)时暂存残余字节等待下一块拼接,避免产生 `` 乱码;而 lines.pop() 将最后一个尚未遇到 \n 换行符的半截字符串留在 lineBuffer 中,防止把半截 JSON 送入 JSON.parse 导致崩溃。
练习 2:流式返回 tool_calls 时,为什么不能在收到第一个包含 tc.function.name 的 SSE 帧时就立即调用工具函数?
答案:在流式协议中,第一个 delta.tool_calls 帧通常只包含工具的 id 和 function.name,其 function.arguments 字符串会在随后几十个 SSE 帧中逐段拼接,必须等流结束或该工具调用块完整闭合后才能解析出合法的 JSON 参数。