流式通信底层:用原生 fetch 与 ReadableStream 解析 SSE 事件流

本节目标

为什么 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'),会同时踩中两个致命坑:

  1. UTF-8 多字节截断:汉字「好」占 3 个字节(E5 A5 BD),若前 2 个字节落在 Chunk 1,第 3 个字节落在 Chunk 2,不带 { stream: true } 的 decode() 会直接输出两个 ``(U+FFFD 替换字符)。
  2. 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 参数。