LLM 流式响应的工程化:从代理到协议

流式(Streaming)是 LLM 应用的体验标配——但"把 LLM 的流转发给前端"只是入门。心跳、断线、取消、背压、事件协议——每个都是线上事故的高发区。这篇文章是 LLM 流式的完整工程实践(基于自研 SSE 代理引擎)。

一、为什么流式难

L 问 L 题 M : 1 2 3 4 → → . . . . 生 成 经 每 中 长 客 错 的 过 一 间 连 户 误 流 : 层 层 接 端 无 ( L 都 缓 断 断 处 t L 可 冲 线 开 放 o M 能 : : : : k : N 3 服 流 e 网 缓 g 0 务 中 n 关 冲 i s 端 间 、 n 还 出 序 → 超 x 无 在 错 列 时 数 调 , ) 业 、 攒 据 怎 务 断 够 被 L 么 服 开 中 L 告 务 、 4 间 M 诉 乱 K 层 ( 客 → 序 B 掐 浪 户 断 费 端 反 才 向 发 t 代 o 理 → k e → 流 n 式 ) C 变 D " N 等 全 → 部 " 浏 览 器

二、SSE 协议基础

H 格 关 T 式 键 T : d d e d : ✅ ✅ ✅ P a a v a t t e t 每 可 连 响 a a n a 行 以 接 应 : : t : 以 自 保 : : 定 持 C { { { \ 义 ( o " " d } n 事 直 n c c o \ \ 件 到 t o o n n n 类 服 e n n e \ 型 务 n t t \ n 结 ( 器 t e e n 束 d 结 - n n ( o 束 T t t 事 n 或 y " " 件 e 断 p : : 分 / 开 e 隔 e ) : " " ) r 你 好 r t " " o e } } r x \ \ / t n n r / \ \ e e n n t v r e y n ) t - s t r e a m

三、服务端工程要点

1. 穿透中间层(不缓冲)

1
2
3
4
5
6
7
8
9
func streamHandler(w http.ResponseWriter, r *http.Request) {
    // 关键头:让中间层不缓冲
    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    w.Header().Set("Connection", "keep-alive")
    w.Header().Set("X-Accel-Buffering", "no")   // Nginx 关缓冲
    flusher, _ := w.(http.Flusher)
    ...
}

2. 心跳(防中间层超时)

1
2
3
4
5
6
7
8
// 每 15s 发一条注释(SSE 注释不渲染,但连接活跃)
ticker := time.NewTicker(15 * time.Second)
go func() {
    for range ticker.C {
        fmt.Fprintf(w, ": ping\n\n")     // 注释行 = 心跳
        flusher.Flush()
    }
}()

注意:心跳和实际数据要协调(有数据就不发心跳,避免数据+心跳都发导致混乱)。

3. 取消传播(客户端断开 → 停 LLM)

1
2
3
4
// 客户端断开 → r.Context() 取消 → 传给 LLM 调用
ctx := r.Context()
stream, err := llm.Stream(ctx, prompt)   // 带 ctx
// 客户端断开 → ctx 取消 → LLM 停止 → 不再计费

这是最容易被忽略的省钱点:客户端关了页面,LLM 还在生成 = 白烧 token。

4. 背压控制

1
2
3
4
5
// LLM 生成快(如 100 token/s),客户端/网络慢
// → 要缓冲 + 限速,否则内存/带宽爆
chunk := <-stream
// 用带缓冲的 channel + 客户端消费速度控制
// 或直接依赖 TCP 背压(HTTP/1.1 下 Flush 会阻塞)

实际:HTTP/1.1 下 Flush() 天然有背压(TCP 缓冲区满会阻塞)。HTTP/2 要额外处理(流独立,不受单连接背压)。

5. 事件协议(错误/重试/元数据)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 数据事件
event: delta
data: {"content": "你", "index": 42}

// 元数据(思考过程/用量)
event: meta
data: {"tokens": 123, "model": "gpt-4o"}

// 错误(可恢复/不可恢复)
event: error
data: {"code": "UPSTREAM_TIMEOUT", "retryable": true}

// 结束
event: done
data: {"usage": {"in": 1200, "out": 350}}

事件协议的价值:客户端能区分"流结束"和"流中断"、能处理错误重连、能展示思考过程。

四、客户端工程要点

1. 解析与渲染

1
2
3
4
5
6
7
8
9
const es = new EventSource("/api/chat");   // GET 场景
// 或 fetch + ReadableStream(POST 场景,EventSource 不支持 POST)
const reader = resp.body.getReader();
const decoder = new TextDecoder();
while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    renderDelta(decoder.decode(value));   // 逐块渲染
}

注意:

✅ ✅ ✅ E 中 渲 v 文 染 e 按 用 n " t U 追 S T 加 o F " u - 不 r 8 用 c " e 解 重 码 渲 只 可 染 支 能 " 持 跨 ( 性 G c 能 E h ) T u n → k 带 截 断 b ( o 用 d y T e 的 x 请 t 求 D 用 e c f o e d t e c r h 处 流 理 式 )

2. 断线重连

客 户 端 收 收 连 策 到 到 接 略 断 : e e 开 r r ( r r 无 o o 事 r r 件 ( ( ) 可 不 → 重 可 试 重 心 ) 试 跳 ) 超 → 时 → 检 显 测 示 显 " 示 → 重 错 连 误 重 中 连 " + → 已 生 3 成 s 内 容 后 保 重 留 试 ( 最 多 3 次 )

关键:重连时携带已收到的 index(服务端从断点续发,避免重复/缺失)。

3. 取消(用户停止生成)

用 → 户 点 服 " 务 停 端 止 收 " 到 → c t a x b o 取 r 消 t ( → ) 停 / L c L o M n t → r o 停 l 计 l 费 e r . a b o r t ( )

五、压测与监控

监 压 控 测 指 连 心 T t 断 注 长 内 标 接 跳 T o 线 意 连 存 : 时 超 F k 重 : 接 ( 长 时 T e 连 占 大 分 率 n 率 用 响 布 ( P / 连 应 ( 中 9 秒 接 缓 长 间 5 ( 数 冲 连 层 ( 生 ( ) 接 断 首 成 单 占 开 字 速 机 比 比 延 率 ) 例 迟 ) 6 ) ) 5 5 3 5 限 制 → 服 务 端 连 接 管 理 )

六、踩坑记录

  1. Nginx 缓冲:X-Accel-Buffering: no 漏配 → 流式变"等全部完成"(用户以为卡死)
  2. 心跳和 done 冲突:心跳把 done 顶掉 → 事件序列错乱 → 心跳在独立 goroutine + done 前停止心跳
  3. 客户端断开没停 LLM:token 白烧 → ctx 传播 + 服务端侧超时兜底
  4. 中文乱码:chunk 中间断开 UTF-8 字符 → TextDecoder(stream: true) 处理

总结

LLM 流式工程化的核心认知:

  1. 穿透中间层:X-Accel-Buffering、SSE 头、心跳——一个不能少
  2. 取消传播:客户端断开 = 停 LLM = 省钱
  3. 事件协议:delta/meta/error/done,让客户端"懂"流
  4. 断线重连:带断点续传,体验无缝

流式不是"把流转发",是"把生成过程变成可靠的实时协议"。 每层都稳了,用户才觉得"真快、真稳"。