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 Streaming | SSE(Server-Sent Events) | WebSocket |
|---|---|---|---|
| 方向 | 单向(服务端 → 客户端) | 单向(服务端 → 客户端) | 双向 |
| 协议 | HTTP/1.1 chunked | HTTP/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-stream、Cache-Control: no-cache、Connection: 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: {"choices": [{"delta": {"con 就是一个被截断的半条数据。所以我们按 \n 切分后,最后一段(可能不完整)要留到下一个 chunk 拼接后再处理。
decoder.decode(value, { stream: true })。中文是多字节编码(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_start、on_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 循环结束后需要检查:流是正常结束(收到了 done 或 error 事件)还是异常中断?这里不要直接读取 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 伴侣的流式响应架构。
关键要点:
- 流式输出将用户感知延迟从 1500-3000ms 压缩到 200-500ms,对实时对话产品是必选项而非优化项
- SSE 协议格式极简(
event+data+ 空行分隔),天然支持事件分类,Workers 原生兼容,复杂度远低于 WebSocket - Hono 的
streamSSE函数将"创建 Response"和"写入数据"解耦——先返回响应头,再持续往 body 写入 SSE 事件 - 服务端用 AsyncGenerator 封装 LLM 流式调用,上层代码用
for await...of消费,每个 token 立即通过writeSSE转发给前端 waitUntil是 Workers 环境下异步后处理的关键——保证情绪更新、记忆写入等任务不会因响应结束而被中断- 断流容错需要三层配合:服务端滑动超时、前端部分内容保留、异步后处理幂等去重