Streaming

要点

  • 第八篇文章分析管线延迟时,我们得出一个关键结论:LLM 生成占总延迟的 70-80%,是整条链路的绝对瓶颈
  • 1 为什么选 SSE
  • 流式响应涉及三个层次,每层有不同的职责
  • 1 先理解普通响应 vs 流式响应
  • 1 消费 SSE 流

内容

1. LLM 延迟的本质与流式输出的必然性

第八篇文章分析管线延迟时,我们得出一个关键结论:LLM 生成占总延迟的 70-80%,是整条链路的绝对瓶颈。具体数字是 500-2000ms 才能拿到完整回复,这还只是中等长度的回答——如果 AI 伴侣要输出一段 200 字的安慰话,等待时间可能超过 3 秒。

但这里有一个容易被忽略的事实:LLM 不是「算完了一次性吐出结果」。它的工作方式是逐 token 生成——每隔几十毫秒产出一个 token,直到遇到结束标记。也就是说,第一个 token 可能在 200ms 内就准备好了,但如果你选择等全部 token 生成完再返回,用户就白白多等了 1-2 秒。

这就是流式输出的核心价值:把「等完整回复」变成「看着回复逐字出现」

从用户感知角度看,两种模式的体验差距是断崖式的:

模式首字节时间用户感知
非流式(等完整回复)1500-3000ms"它在想什么?卡了吗?"
流式(逐 token 推送)200-500ms"它在说话了"

人的阅读速度大约是每秒 5-8 个汉字。LLM 的生成速度通常是每秒 30-80 个 token(约 15-40 个汉字)。这意味着LLM 的生成速度远快于人的阅读速度——流式输出时,用户几乎感觉不到等待,文字像「打字」一样自然出现。

对 AI 伴侣产品来说,这种「打字效果」还有额外的情感价值。它模拟了真人聊天时「对方正在输入……」的感觉,增强了对话的沉浸感。相比之下,一次性弹出一大段文字,更像是在看公告栏——高效但冰冷。

2. SSE 协议:从字节到语义

2.1 为什么选 SSE

流式数据从服务端推送到前端,有三种主流方案:

维度HTTP StreamingSSE(Server-Sent Events)WebSocket
方向单向(服务端 → 客户端)单向(服务端 → 客户端)双向
协议HTTP/1.1 chunkedHTTP/1.1,text/event-stream独立协议(ws://)
自动重连浏览器内置需手动实现
事件类型支持 event 字段分类自行约定
边缘环境兼容性可用,但连接协调通常更复杂

WebSocket 在这里通常是过剩的。 AI 伴侣的对话是严格的请求-响应模式:用户发一条,AI 回一条。WebSocket 的双向通道在这里通常没有发挥出优势,反而会引入连接管理、心跳维护、断线重连等额外复杂度。更关键的是,在 Cloudflare Workers 上,一旦你需要做连接协调、房间状态或广播,往往还要额外引入 Durable Objects,架构复杂度和成本都会上升。

SSE 比裸 HTTP Streaming 多了一层结构。 两者底层都是 HTTP chunked transfer,但 SSE 定义了一套轻量协议格式,让数据传输变得有"语义"。

2.2 SSE 协议格式详解

SSE 的协议格式极其简单——简单到你手写都不会出错。看一段真实的 SSE 响应报文:

// response.txt
HTTP/1.1 200 OK
 
Content-Type: text/event-stream
 
Cache-Control: no-cache
 
Connection: keep-alive
 
event: thinking
 
data: {"node": "memory_retrieval"}
 
event: token
 
data: {"content": "我"}
 
event: token
 
data: {"content": "记得"}
 
event: token
 
data: {"content": "你上次"}
 
event: done
 
data: {"emotion": "happy", "memories_used": 3}

整个协议只有四条规则:

规则一:响应头必须是 Content-Type: text/event-stream。 这告诉浏览器"这不是一个普通 JSON 响应,而是一个持续推送的事件流"。浏览器不会等整个响应结束才处理,而是每收到一条完整事件就触发一次回调。

规则二:每条事件由若干 字段: 值 行组成。 常用字段有三个:event(事件类型)、data(数据负载)、id(事件 ID,用于断线重连)。字段名和冒号之间没有空格,冒号和值之间有一个空格。

规则三:事件与事件之间用空行(\n\n)分隔。 这是解析 SSE 的核心标识——遇到双换行就意味着一条完整事件结束了。

规则四:data 字段可以跨多行。 多个 data: 行会被拼接为一个字符串,中间用 \n 连接。但在实际 LLM 流式场景中,我们通常每条事件只有一个 data 行。

为什么这个格式适合 AI 伴侣?因为它天然支持事件分类。我们可以用 event 字段把"思考中"、"正在输出"、"已完成"、"出错了"四种状态区分开。前端根据 event 类型分别处理,不需要自己猜测当前是什么阶段。裸 HTTP Streaming 只有一个连续的字节流,所有分类逻辑都要前端自己写。

3. 三层流式管线总览

流式响应涉及三个层次,每层有不同的职责:

生成层(LangGraph / LLM):负责逐 token 产出内容。这一层的输出是一个异步迭代器(AsyncIterator),每次 yield 一个 token 或一个状态事件。

传输层(Hono / Workers):负责将生成层的输出转换为 SSE 协议格式,通过 HTTP 持续推送给客户端。这一层要处理超时、异常捕获、连接中断检测。

渲染层(React / 前端):负责消费 SSE 流,实时更新 UI。这一层要处理增量文本拼接、Markdown 渲染、状态切换动画。

三层之间的数据流是单向的:

// pipeline.txt
LangGraph 节点执行
 
    ↓ yield { type: 'node_start', node: 'memory_retrieval' }
 
    ↓ yield { type: 'node_end', node: 'memory_retrieval' }
 
    ↓ yield { type: 'node_start', node: 'llm_generate' }
 
    ↓ yield { type: 'token', content: '我' }
 
    ↓ yield { type: 'token', content: '记得' }
 
    ↓ ...
 
    ↓ yield { type: 'done', metadata: { emotion, memories_used } }
 
Hono SSE 中间层
 
    ↓ event: thinking\ndata: {...}
 
    ↓ event: token\ndata: {...}
 
    ↓ event: done\ndata: {...}
 
React 前端
 
    → 显示 "正在思考..."
 
    → 逐字追加文字
 
    → 完成,显示情绪标签

这个设计的关键在于:管线节点的执行状态也被纳入了流式推送。用户不是看到一个静态的 loading 然后突然出现文字,而是能感知到"AI 正在检索记忆……正在组织语言……开始回复了"。

接下来我们从服务端开始,逐步构建整条管线。

4. 服务端实现:从零构建 Hono 流式接口

4.1 先理解普通响应 vs 流式响应

在写流式代码之前,先搞清楚一个基本问题:普通 HTTP 响应和流式响应到底有什么区别?

普通响应的工作方式是:服务端把所有数据准备好,一次性塞进 Response body 返回。浏览器收到完整响应后才开始处理。

// normal.ts
// 普通响应:等 LLM 生成完毕,一次性返回
 
app.post('/chat', async (c) => {
 
  const { message } = await c.req.json()
 
  const reply = await callLLM(message)  // 阻塞 1-3 秒
 
  return c.json({ reply })               // 全部完成后才返回
 
})

用户的体验是:点击发送 → 等 1-3 秒 → 突然出现完整回复。

流式响应的工作方式不同:服务端先返回 Response Header(告诉浏览器"响应开始了"),然后持续地、分块地往 body 里写入数据。浏览器每收到一块数据就可以立即处理,不用等全部写完。

// streaming.ts
// 流式响应:边生成边返回
 
app.post('/chat/stream', async (c) => {
 
  const { message } = await c.req.json()
 
  // 立即返回响应头,body 持续写入
 
  return streamSSE(c, async (stream) => {
 
    const llmStream = await callLLM(message, { stream: true })
 
    for await (const chunk of llmStream) {
 
      await stream.writeSSE({           // 每个 token 立即推送
 
        event: 'token',
 
        data: JSON.stringify({ content: chunk.text })
 
      })
 
    }
 
  })
 
})

用户的体验是:点击发送 → 200ms 后开始逐字出现 → 像打字一样流畅。

关键区别在于 Response 对象的创建时机。 普通响应是"数据准备好了才创建 Response";流式响应是"先创建 Response(一个持续写入的管道),再往里面塞数据"。streamSSE 函数做的就是后者——它创建了一个特殊的 Response 对象,body 是一个可写流(WritableStream),服务端代码可以随时往里面写入新的 SSE 事件。

4.2 Hono 的 streamSSE API 详解

Hono 提供了 streamSSE 函数来创建 SSE 流式响应。先看最小可运行的例子:

// minimal.ts
import { Hono } from 'hono'
 
import { streamSSE } from 'hono/streaming'
 
const app = new Hono()
 
app.get('/hello-stream', async (c) => {
 
  return streamSSE(c, async (stream) => {
 
    // stream 对象是你和前端之间的管道
 
    // 通过 writeSSE 方法往管道里写入 SSE 事件
 
    await stream.writeSSE({
 
      event: 'greeting',
 
      data: '你好'
 
    })
 
    // 模拟延迟
 
    await stream.sleep(1000)
 
    await stream.writeSSE({
 
      event: 'greeting',
 
      data: '世界'
 
    })
 
  })
 
  // 回调函数执行完毕后,流自动关闭
 
})
 
export default app

这段代码做了什么?streamSSE(c, callback) 接收两个参数:

第一个参数 c(Hono Context):Hono 的请求上下文对象。streamSSE 需要它来创建 Response。它会自动设置正确的响应头:Content-Type: text/event-streamCache-Control: no-cacheConnection: keep-alive

第二个参数 callback:一个异步函数,接收 stream 对象。stream 是你和前端之间的通信管道,它提供了三个核心方法:

方法作用
stream.writeSSE({ event, data, id })写入一条 SSE 事件
stream.sleep(ms)暂停指定时间(不阻塞 Workers)
stream.close()手动关闭流

writeSSE 的参数是一个对象:event 是事件类型(字符串),data 是数据负载(字符串),id 是可选的事件 ID。注意 data 必须是字符串——如果你要传 JSON,需要自己 JSON.stringify

当 callback 函数正常执行完毕(return 或跑到末尾),流会自动关闭。前端的 fetch 调用会感知到流结束(reader.read() 返回 { done: true })。如果 callback 抛出未捕获的异常,流也会关闭,但前端无法得知具体错误原因——这就是为什么我们需要在 callback 内部用 try-catch 捕获错误,并通过 error 事件显式通知前端。

4.3 定义事件协议

在编写完整实现之前,先定义前后端之间的事件协议。这是流式架构中最重要的契约——前端根据事件类型决定 UI 行为,服务端根据管线状态发送对应事件。

// events.ts
// 管线阶段事件:告诉前端当前执行到了哪个节点
 
interface ThinkingEvent {
 
  type: 'thinking'
 
  node: string       // 'safety_check' | 'memory_retrieval' | 'llm_generate' | ...
 
  status: 'start' | 'end'
 
}
 
// 文本生成事件:LLM 逐 token 输出
 
interface TokenEvent {
 
  type: 'token'
 
  content: string    // 一个或多个字符
 
}
 
// 完成事件:对话成功结束,携带元信息
 
interface DoneEvent {
 
  type: 'done'
 
  emotion: string
 
  memoriesUsed: number
 
  tokensConsumed: number
 
}
 
// 错误事件:出了问题,附带兜底回复
 
interface ErrorEvent {
 
  type: 'error'
 
  code: string       // 'safety_blocked' | 'llm_timeout' | 'internal_error'
 
  message?: string
 
  fallbackReply?: string  // 兜底话术,前端直接展示即可
 
}
 
type StreamEvent = ThinkingEvent | TokenEvent | DoneEvent | ErrorEvent

设计原则是:前端只做渲染,不做决策。收到 thinking 就显示加载态,收到 token 就追加文字,收到 done 就结束,收到 error 就显示兜底话术。所有的业务判断(安全拦截、情绪路由、降级策略)全部在服务端完成,前端是"哑"的。

为什么要带 fallbackReply?因为不是所有错误都需要用户操作。安全拦截时,前端不需要弹一个"您的消息违规"的红色弹窗——直接显示"这个话题我们换个方向聊聊吧~"更符合 AI 伴侣的产品调性。

4.4 封装统一的 LLM 流式调用

不同的 LLM 提供商(DeepSeek、OpenAI、Claude)都支持流式输出,但返回格式有差异。我们需要封装一个统一接口,让上层管线代码不关心具体用的是哪家模型。

先理解 LLM 的流式 API 是怎么工作的。以 OpenAI 兼容格式(DeepSeek 也用这个格式)为例,当你在请求中设置 stream: true 时,API 不会等生成完毕才返回——它会立即返回一个 SSE 格式的响应流,每生成一个 token 就推送一条事件:

// llm-response.txt
data: {"choices": [{"delta": {"content": "我"}}]}
 
data: {"choices": [{"delta": {"content": "记得"}}]}
 
data: {"choices": [{"delta": {"content": "你"}}]}
 
data: [DONE]

每条 data: 行是一个 JSON,里面的 delta.content 就是新生成的文本片段。最后一条 data: [DONE] 表示生成结束。

我们用 AsyncGenerator 函数来封装这个解析过程。AsyncGenerator 是 JavaScript 中处理"边产出边消费"场景的最佳工具——它让你可以用 for await...of 循环来逐个读取结果,就像遍历数组一样简单:

// llm.ts
// AsyncGenerator 函数:用 async function* 声明,内部用 yield 逐个产出结果
 
// 调用方用 for await...of 消费
 
async function* callLLMStream(
 
  systemPrompt: string,
 
  userMessage: string,
 
  apiKey: string
 
): AsyncGenerator<string> {
 
  // 第一步:发起 HTTP 请求,开启流式模式
 
  const response = await fetch('https://api.deepseek.com/chat/completions', {
 
    method: 'POST',
 
    headers: {
 
      'Content-Type': 'application/json',
 
      'Authorization': `Bearer ${apiKey}`
 
    },
 
    body: JSON.stringify({
 
      model: 'deepseek-chat',
 
      messages: [
 
        { role: 'system', content: systemPrompt },
 
        { role: 'user', content: userMessage }
 
      ],
 
      stream: true  // 关键:开启流式返回
 
    })
 
  })
 
  // 第二步:拿到响应体的 ReadableStream,逐块读取
 
  const reader = response.body!.getReader()
 
  const decoder = new TextDecoder()
 
  let buffer = ''
 
  while (true) {
 
    // reader.read() 每次返回一块数据(Uint8Array)
 
    // done=true 表示流结束
 
    const { done, value } = await reader.read()
 
    if (done) break
 
    // 第三步:将二进制数据解码为文本,拼接到缓冲区
 
    // stream: true 告诉 decoder 后续还有数据,不要丢弃不完整的多字节字符
 
    buffer += decoder.decode(value, { stream: true })
 
    // 第四步:按行切分,解析 SSE 数据
 
    // LLM 返回的也是 SSE 格式,所以我们要在服务端解析它
 
    const lines = buffer.split('\n')
 
    // 最后一行可能不完整,保留到下次处理
 
    buffer = lines.pop() ?? ''
 
    for (const line of lines) {
 
      // 跳过空行和非 data 行
 
      if (!line.startsWith('data: ')) continue
 
      // [DONE] 是流结束标记
 
      if (line === 'data: [DONE]') return
 
      // 第五步:解析 JSON,提取文本内容
 
      const json = JSON.parse(line.slice(6))  // 去掉 'data: ' 前缀
 
      const content = json.choices?.[0]?.delta?.content
 
      if (content) {
 
        yield content  // 产出一个文本片段,调用方立即收到
 
      }
 
    }
 
  }
 
}

代码中有两个容易踩坑的细节:

buffer 机制。网络传输是按块(chunk)到达的,一个 chunk 可能包含多条 SSE 事件,也可能在一条事件的中间被截断。比如 data: &#123;"choices": [&#123;"delta": &#123;"con 就是一个被截断的半条数据。所以我们按 \n 切分后,最后一段(可能不完整)要留到下一个 chunk 拼接后再处理。

decoder.decode(value, &#123; stream: true &#125;)。中文是多字节编码(UTF-8 下一个汉字 3 字节),一个 chunk 可能恰好在一个汉字的字节中间截断。stream: true 告诉 TextDecoder "后面还有数据,如果遇到不完整的字节序列先缓存,不要报错或替换成乱码"。

使用这个封装后,上层代码变得非常简洁:

// usage.ts
// 消费方式:像遍历数组一样简单
 
for await (const text of callLLMStream(systemPrompt, message, apiKey)) {
 
  console.log(text)  // 依次输出:"我"、"记得"、"你"、...
 
}

4.5 完整管线实现

现在把所有零件组装起来。完整的流式接口需要做六件事:安全检查 → 记忆检索 + 情绪读取 → Prompt 组装 → LLM 流式生成 → 发送完成事件 → 异步后处理。

// chat.ts
import { Hono } from 'hono'
 
import { streamSSE } from 'hono/streaming'
 
type Bindings = {
 
  KV: KVNamespace
 
  DB: D1Database
 
  VECTORIZE: VectorizeIndex
 
  AI: Ai
 
  DEEPSEEK_API_KEY: string
 
}
 
const app = new Hono<{ Bindings: Bindings }>()
 
app.post('/chat/stream', async (c) => {
 
  const { message, sessionId } = await c.req.json()
 
  return streamSSE(c, async (stream) => {
 
    try {
 
      // ========== 阶段 1:安全检查 ==========
 
      await stream.writeSSE({
 
        event: 'thinking',
 
        data: JSON.stringify({ node: 'safety_check', status: 'start' })
 
      })
 
      const safetyResult = await runSafetyCheck(message)
 
      if (!safetyResult.safe) {
 
        // 安全拦截:不走后续管线,直接返回兜底话术
 
        await stream.writeSSE({
 
          event: 'error',
 
          data: JSON.stringify({
 
            code: 'safety_blocked',
 
            fallbackReply: '这个话题我们换一个方向聊聊吧~'
 
          })
 
        })
 
        return  // 直接结束,流自动关闭
 
      }
 
      await stream.writeSSE({
 
        event: 'thinking',
 
        data: JSON.stringify({ node: 'safety_check', status: 'end' })
 
      })
 
      // ========== 阶段 2:记忆检索 + 情绪读取(并行) ==========
 
      await stream.writeSSE({
 
        event: 'thinking',
 
        data: JSON.stringify({ node: 'memory_retrieval', status: 'start' })
 
      })
 
      // 这两个操作互不依赖,用 Promise.all 并行执行
 
      // 记忆检索约 50-150ms,情绪读取约 5-15ms
 
      // 并行后总耗时 = max(150, 15) = 150ms,而非 150+15=165ms
 
      const [memories, emotion] = await Promise.all([
 
        retrieveMemories(c.env, message, sessionId),
 
        readEmotion(c.env, sessionId)
 
      ])
 
      await stream.writeSSE({
 
        event: 'thinking',
 
        data: JSON.stringify({ node: 'memory_retrieval', status: 'end' })
 
      })
 
      // ========== 阶段 3:Prompt 组装 ==========
 
      // 把记忆、情绪、人设拼装成完整的 System Prompt
 
      // 这一步是纯 CPU 计算,耗时 < 1ms,不需要 thinking 事件
 
      const systemPrompt = assemblePrompt(memories, emotion)
 
      // ========== 阶段 4:LLM 流式生成 ==========
 
      await stream.writeSSE({
 
        event: 'thinking',
 
        data: JSON.stringify({ node: 'llm_generate', status: 'start' })
 
      })
 
      // callLLMStream 返回 AsyncGenerator,不会阻塞
 
      // 真正的等待发生在 for await 的第一次迭代(等待首个 token)
 
      const llmStream = callLLMStream(
 
        systemPrompt, message, c.env.DEEPSEEK_API_KEY
 
      )
 
      let fullReply = ''
 
      for await (const text of llmStream) {
 
        fullReply += text
 
        // 每个 token 立即推送给前端,不做缓冲
 
        await stream.writeSSE({
 
          event: 'token',
 
          data: JSON.stringify({ content: text })
 
        })
 
      }
 
      // ========== 阶段 5:发送完成事件 ==========
 
      await stream.writeSSE({
 
        event: 'done',
 
        data: JSON.stringify({
 
          emotion: emotion.current,
 
          memoriesUsed: memories.length,
 
          tokensConsumed: fullReply.length
 
        })
 
      })
 
      // ========== 阶段 6:异步后处理 ==========
 
      // waitUntil 告诉 Workers:"HTTP 响应已经发完了,
 
      // 但请保持进程存活,直到这个 Promise 完成"
 
      // 这样情绪更新、记忆写入等操作不会因为流关闭而被中断
 
      c.executionCtx.waitUntil(
 
        postProcess(c.env, sessionId, message, fullReply, emotion)
 
      )
 
    } catch (err) {
 
      // 兜底:任何未预期的异常都通过 error 事件通知前端
 
      // 前端收到后展示 fallbackReply,不会白屏
 
      await stream.writeSSE({
 
        event: 'error',
 
        data: JSON.stringify({
 
          code: 'internal_error',
 
          fallbackReply: '抱歉,我刚才走神了,你再说一次好吗?'
 
        })
 
      })
 
    }
 
  })
 
})

这段代码的执行流程和第八篇文章的管线 DAG 完全对应。区别在于:管线 DAG 描述的是节点之间的依赖关系,而这段代码是这些依赖关系的具体实现。DAG 中"查询理解完成后,记忆检索和情绪读取并行执行"这一拓扑关系,在代码中体现为 Promise.all 的调用位置。

有一个重要的架构细节值得解释。在这段代码中,管线节点的执行是"手动编排"的——我们在代码里显式地控制了执行顺序和 thinking 事件的插入位置。但在实际项目中,更推荐使用 LangGraph 的回调机制(on_chain_starton_chain_end)来自动触发 thinking 事件。这样每次修改管线拓扑时,不需要同步修改事件推送代码。本文采用手动编排是为了让读者看清楚每一步到底发生了什么。

4.6 waitUntil:Workers 环境下的异步后处理

c.executionCtx.waitUntil() 是 Cloudflare Workers 特有的 API,值得单独解释。

在普通的 Node.js 服务器中,HTTP 响应发送后,进程仍然在运行,你可以在后台继续执行任何异步操作。但 Workers 不同——它是 Serverless 的,响应发出后,Workers 运行时会尝试回收执行环境。如果你在 streamSSE 的回调结束后启动了异步任务(比如 setTimeout 或未 await 的 Promise),这些任务很可能被中途杀死。

waitUntil(promise) 就是解决这个问题的。它告诉 Workers 运行时:"我知道响应已经发完了,但我还有后台任务要跑,请等这个 Promise resolve 后再回收我。"

// waituntil.ts
// 异步后处理函数:在响应发出后执行
 
async function postProcess(
 
  env: Bindings,
 
  sessionId: string,
 
  userMessage: string,
 
  aiReply: string,
 
  emotion: EmotionState
 
) {
 
  // 这三个操作互不依赖,并行执行
 
  await Promise.all([
 
    // 根据本轮对话更新情绪状态
 
    updateEmotion(env, sessionId, userMessage, aiReply),
 
    // 从对话中提取记忆片段,写入向量库
 
    extractAndWriteMemories(env, sessionId, userMessage, aiReply),
 
    // 更新亲密度分值
 
    updateIntimacy(env, sessionId, emotion)
 
  ])
 
}

这正是第八篇文章提到的"异步路径"——影响的是下一次回复的质量,不影响当前回复,所以放在响应发出之后执行。

5. 前端实现:流式渲染

5.1 消费 SSE 流

前端使用 fetch API 读取 SSE 流。相比 EventSource API,fetch 支持 POST 请求和自定义 Header,更适合需要发送 JSON body 的场景。

整个消费过程可以拆解为三步:发起请求 → 逐块读取 → 解析事件。

// useChat.ts
function useStreamChat() {
 
  const [status, setStatus] = useState<'idle' | 'thinking' | 'generating' | 'done'>('idle')
 
  const [thinkingNode, setThinkingNode] = useState('')
 
  const [reply, setReply] = useState('')
 
  const [metadata, setMetadata] = useState<{ emotion: string } | null>(null)
 
  const sendMessage = useCallback(async (message: string) => {
 
    setStatus('thinking')
 
    setReply('')
 
    let hasDone = false
 
    let partialReply = ''
 
    // 第一步:发起 POST 请求
 
    const response = await fetch('/api/chat/stream', {
 
      method: 'POST',
 
      headers: { 'Content-Type': 'application/json' },
 
      body: JSON.stringify({ message, sessionId: getSessionId() })
 
    })
 
    // 第二步:获取 ReadableStream 的 reader
 
    // 和服务端的 callLLMStream 一样,用 reader 逐块读取
 
    const reader = response.body!.getReader()
 
    const decoder = new TextDecoder()
 
    let buffer = ''
 
    // 第三步:循环读取,解析 SSE 事件
 
    while (true) {
 
      const { done, value } = await reader.read()
 
      if (done) break
 
      buffer += decoder.decode(value, { stream: true })
 
      // 解析缓冲区中所有完整的 SSE 事件
 
      const { parsed, remaining } = parseSSEEvents(buffer)
 
      buffer = remaining
 
      // 根据事件类型更新 UI 状态
 
      for (const event of parsed) {
 
        switch (event.type) {
 
          case 'thinking':
 
            setThinkingNode(event.data.node)
 
            break
 
          case 'token':
 
            setStatus('generating')
 
            partialReply += event.data.content
 
            setReply(prev => prev + event.data.content)
 
            break
 
          case 'done':
 
            hasDone = true
 
            setStatus('done')
 
            setMetadata(event.data)
 
            break
 
          case 'error':
 
            hasDone = true
 
            setStatus('done')
 
            setReply(event.data.fallbackReply ?? '出错了,请重试')
 
            break
 
        }
 
      }
 
    }
 
    // 流结束后,不要读取闭包里可能过期的 React state
 
    // 用局部变量判断是否正常收到了 done / error 事件
 
    if (!hasDone) {
 
      if (partialReply.length > 0) {
 
        setReply(prev => prev + '\n\n[回复未完成,点击重试]')
 
      } else {
 
        setReply('网络不太稳定,请重新发送消息')
 
      }
 
      setStatus('done')
 
    }
 
  }, [])
 
  return { status, thinkingNode, reply, metadata, sendMessage }
 
}

parseSSEEvents 是 SSE 协议解析器,负责从缓冲区中切分出完整的事件。和服务端解析 LLM 响应的逻辑一样——用双换行 \n\n 分隔事件,最后一段不完整的保留到下次。下面这个示例刻意做了简化:它只处理我们这里使用的"单个 data: 行 + JSON 字符串"场景,没有实现 SSE 规范里多行 data: 的拼接:

// parser.ts
function parseSSEEvents(buffer: string) {
 
  const events: Array<{ type: string; data: any }> = []
 
  const parts = buffer.split('\n\n')
 
  const remaining = parts.pop() ?? ''
 
  for (const part of parts) {
 
    if (!part.trim()) continue
 
    let eventType = 'message'
 
    let data = ''
 
    for (const line of part.split('\n')) {
 
      if (line.startsWith('event: ')) eventType = line.slice(7)
 
      else if (line.startsWith('data: ')) data = line.slice(6)
 
    }
 
    if (data) {
 
      events.push({ type: eventType, data: JSON.parse(data) })
 
    }
 
  }
 
  return { parsed: events, remaining }
 
}

5.2 UI 状态分段展示

基于 status 字段,前端展示三种不同的 UI 形态:

// ChatBubble.tsx
function AIBubble({ status, thinkingNode, reply, metadata }: AIBubbleProps) {
 
  return (
 
    <div className="flex gap-3 items-start">
 
      <Avatar emotion={metadata?.emotion} />
 
      <div className="flex-1 space-y-2">
 
        {/* 思考阶段:显示当前执行的管线节点 */}
 
        {status === 'thinking' && (
 
          <ThinkingIndicator node={thinkingNode} />
 
        )}
 
        {/* 生成阶段:逐字显示回复 + 光标闪烁 */}
 
        {(status === 'generating' || status === 'done') && (
 
          <div className="prose prose-sm">
 
            <MarkdownRenderer content={reply} />
 
            {status === 'generating' && <BlinkingCursor />}
 
          </div>
 
        )}
 
        {/* 完成后:显示情绪标签 */}
 
        {status === 'done' && metadata && (
 
          <EmotionTag emotion={metadata.emotion} />
 
        )}
 
      </div>
 
    </div>
 
  )
 
}

ThinkingIndicator 把管线节点名称翻译为用户友好的提示语:

// ThinkingIndicator.tsx
const nodeLabels: Record<string, string> = {
 
  safety_check: '安全检查中',
 
  memory_retrieval: '回忆与你的过往',
 
  llm_generate: '组织语言中'
 
}
 
function ThinkingIndicator({ node }: { node: string }) {
 
  return (
 
    <div className="flex items-center gap-2 text-sm text-zinc-400">
 
      <LoadingDots />
 
      <span>{nodeLabels[node] ?? '思考中'}</span>
 
    </div>
 
  )
 
}

这种设计让用户在等待的 200-500ms(管线前置节点执行时间)内不是面对空白,而是看到"回忆与你的过往……"这样有情感温度的提示。等 LLM 开始输出 token 后,UI 无缝切换到逐字显示模式。

6. 断流与容错

流式响应比普通请求多了一类故障模式:连接在传输过程中断开。用户可能只看到半句话,AI 的情绪更新也可能执行了一半。

6.1 三类断流场景

网络中断。用户手机信号不好,连接断开。服务端的 stream.writeSSE() 会抛出异常,LLM 可能仍在生成但输出无处可去。

LLM 超时。LLM 提供商响应慢或服务降级,for await...of 长时间没有新 chunk。用户看到文字停在半句话不动了。

Workers 执行受限。Cloudflare Workers 对 CPU 时间、请求生命周期和流式连接时长都有约束;如果你的管线执行时间过长,或者部署套餐/运行时限制被触发,响应可能被中断。这里的具体数值会随套餐和平台策略变化,设计时应以官方文档为准。

6.2 服务端:滑动超时

针对 LLM 超时场景,核心策略是滑动超时——不是限制"总共多久",而是限制"两个 token 之间最多等多久"。这样即使长回复生成了 20 秒,只要 token 持续在流动就不会触发超时。只有 token 流真正中断了才认为超时。

// timeout.ts
async function forwardWithTimeout(
 
  stream: { writeSSE: Function; close: Function },
 
  llmIterator: AsyncGenerator<string>,
 
  gapMs: number = 15000  // 两个 token 之间最多等 15 秒
 
) {
 
  let timer: ReturnType<typeof setTimeout>
 
  const resetTimer = () => {
 
    clearTimeout(timer)
 
    timer = setTimeout(async () => {
 
      await stream.writeSSE({
 
        event: 'error',
 
        data: JSON.stringify({
 
          code: 'llm_timeout',
 
          fallbackReply: '我刚才想说的太多了,脑子有点转不过来...你能再说一次吗?'
 
        })
 
      })
 
      stream.close()
 
    }, gapMs)
 
  }
 
  resetTimer()  // 启动首次计时
 
  try {
 
    for await (const text of llmIterator) {
 
      resetTimer()  // 每收到一个 token,重置计时器
 
      await stream.writeSSE({
 
        event: 'token',
 
        data: JSON.stringify({ content: text })
 
      })
 
    }
 
  } finally {
 
    clearTimeout(timer)
 
  }
 
}

6.3 前端:优雅降级

前端在 while 循环结束后需要检查:流是正常结束(收到了 doneerror 事件)还是异常中断?这里不要直接读取 React state 做判断,因为异步函数内部拿到的 state 可能是旧值,更稳妥的做法是用局部变量记录是否正常结束、以及当前已经收到的部分回复。

// recovery.ts
// 在 while 循环结束后追加
 
if (!hasDone) {
 
  // 流异常终止,没有收到 done / error 事件
 
  if (partialReply.length > 0) {
 
    // 已经有部分内容:保留已显示的文字,提示可重试
 
    setReply(prev => prev + '\n\n[回复未完成,点击重试]')
 
  } else {
 
    // 一个字都没收到:直接提示网络问题
 
    setReply('网络不太稳定,请重新发送消息')
 
  }
 
  setStatus('done')
 
}

已收到部分回复时,绝不能清空。 用户可能已经读了一半,清空会造成困惑。保留已有内容,在末尾追加重试提示。

6.4 幂等性保障

流式场景下有一个隐蔽的问题:用户收到部分回复后重试,第一次请求的 waitUntil 异步任务可能已经在后台跑了,第二次请求又会触发一轮。情绪更新和记忆写入可能被执行两次。

解决方案是请求级幂等 key。每条消息发送时生成唯一 ID,异步后处理以此 ID 做去重:

// idempotent.ts
async function postProcess(env: Bindings, requestId: string, /* ... */) {
 
  // 用 KV 做去重锁,TTL 5 分钟
 
  const lockKey = `post_process_lock:${requestId}`
 
  const existing = await env.KV.get(lockKey)
 
  if (existing) return  // 已经处理过,跳过
 
  await env.KV.put(lockKey, '1', { expirationTtl: 300 })
 
  await Promise.all([
 
    updateEmotion(env, /* ... */),
 
    writeMemories(env, /* ... */),
 
    updateIntimacy(env, /* ... */)
 
  ])
 
}

7. 总结

这篇文章从协议原理到前后端实现,完整拆解了 AI 伴侣的流式响应架构。

关键要点:

  1. 流式输出将用户感知延迟从 1500-3000ms 压缩到 200-500ms,对实时对话产品是必选项而非优化项
  2. SSE 协议格式极简(event + data + 空行分隔),天然支持事件分类,Workers 原生兼容,复杂度远低于 WebSocket
  3. Hono 的 streamSSE 函数将"创建 Response"和"写入数据"解耦——先返回响应头,再持续往 body 写入 SSE 事件
  4. 服务端用 AsyncGenerator 封装 LLM 流式调用,上层代码用 for await...of 消费,每个 token 立即通过 writeSSE 转发给前端
  5. waitUntil 是 Workers 环境下异步后处理的关键——保证情绪更新、记忆写入等任务不会因响应结束而被中断
  6. 断流容错需要三层配合:服务端滑动超时、前端部分内容保留、异步后处理幂等去重