Blog·Studio
文章系列日历归档关于搜索
Blog·Studio

一个记录思考、笔记与作品的技术博客。

Connect

© 2026 · Blog Studio

鄂ICP备19019526号

crafted with care

stay curious ✦

  1. 文章
  2. ›LLM 推理流式响应的断点续传、取消与请求恢复工程 2026

Index

  • 一、问题的提出:流式响应在生产环境的脆弱性
  • 二、形式化:流式响应的状态机模型
  • 三、服务器端:SSE cancellation propagation
  • 四、客户端:resumption token 与增量校验
  • 五、边缘场景:chunked 重组与丢失恢复
  • 六、网络层:HTTP/2 + HTTP/3 流控与流取消
  • 七、对工程实践的推论(5 条可执行项)
  • 八、讨论:与 WebSocket / gRPC streaming 的对比
  • 九、给 SRE 与推理平台工程师的清单
  • 参考文献

LLM 推理流式响应的断点续传、取消与请求恢复工程 2026

把 LLM 流式响应从脆弱长连接重塑为可恢复可取消的有状态会话:以服务端 cancellation propagation、客户端 resumption token、HTTP/2 RST_STREAM 三件套为主轴,给出从协议层到监控层的完整工程范式。

2026年8月29日·约 36 分钟阅读·10,609 字·8 次阅读·博主
#AI 原生架构
LLM 推理流式响应的断点续传、取消与请求恢复工程 2026

Index

  • 一、问题的提出:流式响应在生产环境的脆弱性
  • 二、形式化:流式响应的状态机模型
  • 三、服务器端:SSE cancellation propagation
  • 四、客户端:resumption token 与增量校验
  • 五、边缘场景:chunked 重组与丢失恢复
  • 六、网络层:HTTP/2 + HTTP/3 流控与流取消
  • 七、对工程实践的推论(5 条可执行项)
  • 八、讨论:与 WebSocket / gRPC streaming 的对比
  • 九、给 SRE 与推理平台工程师的清单
  • 参考文献

LLM 推理流式响应的断点续传、取消与请求恢复工程 2026

一句话摘要:把 LLM 流式响应从"脆弱长连接"重塑为"可恢复可取消的有状态会话"——以服务端 cancellation propagation、客户端 resumption token、HTTP/2 RST_STREAM 三件套为主轴,给出从协议层到监控层的完整工程范式。


一、问题的提出:流式响应在生产环境的脆弱性

LLM 推理的 streaming response(text/event-stream)在 2026 年的生产环境里,仍然是"最不稳定的网络资源"之一。这不是因为协议设计有缺陷——SSE 协议本身在 2014 年就稳定下来——而是因为它的典型生命周期跨越了三层相互不信任的中间件:客户端应用层(App、WebView、IDE 插件)、公网反向代理层(Nginx、Envoy、Cloudflare、ALB)、推理服务层(vLLM、SGLang、TensorRT-LLM、自研网关)。任何一层在 30 秒内主动重置流,用户的 token 已经吐出一半,下游 UI 就会卡在"打字中"状态直到超时——这是 ChatGPT 类产品 2024-2025 年公开事故复盘里最高频的一类投诉。

真实生产场景里至少有三种"流被打断"的事故:

  1. 移动网络切换 —— 用户在 4G/WiFi 间漫游,TCP 连接被运营商侧的 NAT 表静默丢弃,对客户端而言 fetch().body.getReader() 直接返回 done=true,但服务器已经吐出了 200 多个 token,GPU 上的 decode 步骤还在跑。
  2. 客户端进程崩溃 —— 用户按 Home 键、浏览器进入后台、IDE 插件热重启,浏览器立即关闭 SSE 连接,但服务器还在继续执行 generation loop,浪费算力 + 用户重新打开看到空白。
  3. 网关滚动重启 —— 推理网关做版本发布、配置热加载、P99 延迟恶化触发自动扩缩,整个网关进程重启会丢失所有进行中的长连接——比单连接断开更严重,因为是"雪崩式重连",新网关在前 60 秒会被重连风暴打挂。

传统 web API(如 REST GET)是"无状态请求-响应"模型,断了重试没有副作用。但 LLM 流式响应是有状态会话:服务器已经消耗了 prefill 算力、分配了 KV cache 块、生成了 200 个 token,这些成本如果不显式管理就白白浪费。所以流式响应的工程问题不是"网络协议优化",而是一个有状态计算资源在不可靠网络上的可恢复性问题。本篇就围绕这个核心展开:从形式化模型、服务端取消传播、客户端续传令牌、网络层流控、SRE 监控清单五个层次给出 2026 年生产环境的工程范式。


二、形式化:流式响应的状态机模型

把一次 LLM 流式响应建模为有限状态机,是工程化"断点续传 + 取消 + 重试"的基础。状态集至少要覆盖六个:

CONNECTING  → STREAMING  → COMPLETED
              ↓  ↑
            PAUSED → RESUMING
              ↓  ↑
          CANCELLED

CONNECTING 表示 TCP 握手 + TLS 协商 + SSE event-stream MIME 协商阶段。STREAMING 是稳定流式阶段,每 chunk 携带 data: {...}\n\n SSE 帧。COMPLETED 是 data: [DONE] 终止帧到达 + 客户端收到 TCP FIN。PAUSED 是网络层丢包但未收到 RST 的中间态,由应用层心跳超时触发。RESUMING 是客户端基于 Last-Event-ID 重连并请求续传的过渡态。CANCELLED 是显式取消(用户点停、客户端 abort、服务器主动拒绝),是终止态不可逆。

两个关键不变量必须满足:

不变量 1:resumption token 的不可伪造性。服务器端为每个 session 生成 HMAC 签名的 resumption token(形如 resumption_id=<session_uuid>&seq=<n>&sig=<HMAC-SHA256(secret, session_uuid||n)>),客户端持有这个 token 才能续传。签名必须绑定到 (session_uuid, 当前最大 seq, 用户 API key hash)——如果只绑定 session_uuid,攻击者截获一个 token 就可以无限续传;如果不绑定 seq,攻击者可以跳到任意历史位置导致重复 token 输出。生产环境常见错误是只用 JWT 签 session_uuid 不签 seq,导致 replay 攻击。

不变量 2:chunked transfer encoding 的字节级可恢复性。HTTP/1.1 的 chunked transfer 是把流切成 (length_hex)\r\n(content)\r\n 的字节帧,TCP 层只保证字节流有序,不保证 SSE 帧边界对齐。所以 SSE 客户端 reader 必须先做"字节级 buffer 累积 → \n\n 分隔符定位 → 完整 frame 切分"三步解析。这意味着续传不能从任意字节位置恢复——必须从某个完整 SSE frame 之后恢复,这就是为什么 Last-Event-ID 协议字段必须是 server-side 已经完整发送的 event id,而不是字节偏移量。

正式的不变量陈述如下:设服务器已发送的 SSE event 集合为 E={(idi,datai,typei):i∈N}E = \{(id_i, data_i, type_i) : i \in \mathbb{N}\}E={(idi​,datai​,typei​):i∈N},每个 idiid_iidi​ 单调递增,dataidata_idatai​ 是增量 token 或终止标记。客户端在收到 E1:kE_{1:k}E1:k​ 后断开,持有 idkid_kidk​ 和 HMAC 签名的 resumption token。续传时客户端发送 Last-Event-ID: id_k + resumption token,服务器从 idk+1id_{k+1}idk+1​ 开始重发(不重发 idkid_kidk​,避免重复)。此协议满足 (a) 不重不漏:客户端收到的并集 =E1:n= E_{1:n}=E1:n​,其中 nnn 是最终 seq;(b) 顺序保持:客户端按 id 升序拼接;(c) 取消可观测:服务器收到 abort 信号时,已发出但未被客户端确认的 chunks 会被服务器侧的引用计数清理,避免 KV cache 泄漏。

这三条不变量是后续工程方案的形式化基础。下面把它们落地到具体协议和实现层。


三、服务器端:SSE cancellation propagation

服务器侧的"取消传播"是工程化的最难点,因为 LLM 推理生成循环是CPU/GPU 密集的同步操作,不像 Node.js 那种事件循环可以靠 abort signal 中断。一个典型的 vLLM decode loop 长这样:

async def generate_stream(prompt, params):
    seq_id = kv_cache.allocate(prompt)
    ctx = ContextVar("cancel_signal", default=None)
    try:
        async for token in scheduler.generate(seq_id, params):
            if ctx.get() is not None:
                break
            yield sse_format(data=token)
    finally:
        kv_cache.release(seq_id)

关键点:(a) kv_cache.release(seq_id) 必须在 finally 里调用——否则客户端断开但服务器没检测到取消,KV block 会泄漏最终 OOM。(b) 取消检查必须在每个 token yield 之前,不能等整个 batch 完成。(c) 取消信号必须通过 ContextVar 传递,而不是共享变量,因为 Python 的 asyncio task 默认不共享上下文。

prefill 阶段的取消延迟和decode 阶段的取消延迟有数量级差异,这一差异主要来自 GPU kernel 调度的不可抢占性。vLLM 内部把 decode 步切成"调度批次(scheduling batch)→ 准备 kernel launch 参数 → 启动 GEMM kernel → 同步等待 → yield token"五步,其中第 3 步一旦启动就不能被 Python 层的 cancel signal 中断——必须等 CUDA stream 上的 inflight kernels 全部 sync 完成后才能让 Python 控制流回到 await token,这之间的延迟就叫做 kernel tail latency。在 H100 + FlashAttention-3 + 7B 模型的实测里,kernel tail latency 通常在 30-80 ms 之间;如果 batch size 比较大(> 32 sequences)且 sequence length 不均匀(短序列拖累长序列),可能恶化到 150-300 ms。这就是为什么"软取消"是默认模式——硬取消无法在 kernel 启动后生效,只能让当前 batch 自然结束。

更进一步的工程优化是kernel-level preemption——H100 的 MIG(Multi-Instance GPU)可以把一张卡切成多个独立 instance,每个 instance 有自己的 stream 和 priority,cancel 时让 scheduler 把低优先级 instance 的 kernel 从 queue 里剔除。但这需要应用层感知 MIG 拓扑且牺牲一定吞吐,2026 年的主流方案仍是应用层软取消 + kernel 自然结束。

预取消信号的传递路径通常是这样的:HTTP/2 RST_STREAM → Nginx → upstream HTTP/2 stream cancel → vLLM asyncio.Task.cancel() → ContextVar 标记 → 下一个 yield 点检查。这条链路上每跳都有 5-30 ms 的延迟,Nginx 默认会延迟 1 秒(等 RST 重传确认)才真正关 upstream,所以客户端 abort 后 1-2 秒内 GPU 才真正停止计算,这个延迟对 P99 推理延迟是显著的污染源。生产环境的对策是在 Nginx 配置 proxy_ignore_client_abort on;——告诉 Nginx 客户端断开立即关 upstream,不用等 1 秒重传确认。这个开关在普通 HTTP 短连接上是危险的(可能丢失客户端 abort 但服务端已完成的部分响应),但在 SSE 长流式上几乎无副作用且收益巨大,是必开的工程项。

prefill 阶段的取消延迟和decode 阶段的取消延迟有数量级差异:

阶段取消响应延迟资源浪费程度
prefill(首 token 之前)< 100 ms低(KV block 是新建未使用)
decode 早期(前 10 tokens)50-200 ms中(少量 KV block 已分配)
decode 中期(10-200 tokens)100-500 ms高(KV block 已用一半)
decode 后期(> 200 tokens)200 ms - 2 s极高(KV block 已满)

实战数据来自 vLLM 0.6.x + 7B 模型 + A100 上的实测。最危险的是 decode 中后期取消——此时 KV block 已占满,取消信号要等当前 yield step 完成才能生效,而一个 yield step 通常调度 50-100 ms,加上 GPU stream 上的 inflight kernels,可能拖到 1-2 秒。这 1-2 秒里新请求的 KV block 分配会排队,造成"雪崩式 P99 恶化"。

工程对策:服务器侧必须分阶段暴露 cancellation hook,让客户端可以表达"软取消(允许跑完当前 yield step)"和"硬取消(立即 abort GPU kernel)"。硬取消要靠 CUDA stream 的 cudaEventSynchronize + cudaStreamDestroy,但代价是损坏 KV block 的引用计数一致性,需要后续 GC 扫描修复——所以默认应只暴露软取消,硬取消作为高级 API 给运维故障演练使用。

参考 Anthropic 的 streaming cancellation 设计(2025 年公开博客)和 vLLM 的 Scheduler.cancel_request(seq_id, mode="soft"|"hard") API,可知主流方案都是这个分层模式。


四、客户端:resumption token 与增量校验

客户端侧的 resumption 实现远比服务器侧复杂,因为它涉及三个独立组件:持久化层(IndexedDB / SQLite / Redis)、网络层(fetch + AbortController)、解析层(IncrementalSSEParser)。

resumption token 持久化的核心设计原则:

  1. 每次收到一个完整 SSE frame 立即写盘(同步 fsync 或 IndexedDB commit),不要批量 flush——批量 flush 会在断电时丢失最后一批 token。
  2. token 格式必须是 HMAC 签名的不可伪造结构——客户端无法伪造 token 跳到任意历史位置。
  3. 持久化层必须支持过期清理——30 天未活跃的 session 应被 GC,避免存储膨胀。

参考 OpenAI 的 streaming response 设计(带 created 字段的每个 chunk)和 Anthropic 的 message_delta 事件流,客户端应该实现这样一个最小循环:

const reader = response.body.getReader();
let buffer = "";
let seq = 0;
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  buffer += new TextDecoder().decode(value);
  let frameEnd;
  while ((frameEnd = buffer.indexOf("\n\n")) !== -1) {
    const frame = buffer.slice(0, frameEnd);
    buffer = buffer.slice(frameEnd + 2);
    const event = parseSSEFrame(frame);
    if (event.id) {
      await resumptionStore.save(sessionId, {
        seq: ++seq,
        eventId: event.id,
        data: event.data,
        checksum: crc32(frame),
        timestamp: Date.now(),
      });
    }
  }
}

增量校验和通常用 CRC32 或 xxHash64——前者浏览器内置(crc32() via Web Crypto + table 化实现),后者性能更好但需要 polyfill。每 frame 一个 checksum 而不是每 token 一个,因为 SSE frame 边界对齐到 \n\n,比 token 边界更稳定。客户端重连时携带 Last-Event-ID: <last_seen_id> + checksum 列表,服务器对比已确认 checksum 与待发送 checksum,决定是否需要重新对齐——这一步是"字节级可恢复性"的真正落地。

断网后重连的认证与续传握手有五个关键 header(多了一个 X-Resume-Mode 用于语义区分):

  • Last-Event-ID: <id>:客户端最后成功接收的事件 ID。
  • X-Resumption-Token: <HMAC token>:不可伪造的会话凭证。
  • X-Resumption-Checksum: <crc32_list>:可选的完整性校验序列,服务器可借此跳过已确认 chunks。
  • Authorization: Bearer <api_key>:仍需要,因为 resumption token 不等同于 API key,只绑定到 (session, seq, api_key_hash) 三元组。

服务器收到续传请求时,按以下伪代码处理:

def handle_resume(req):
    last_id = req.headers["Last-Event-ID"]
    token = parse_hmac(req.headers["X-Resumption-Token"])
    if not verify_hmac(token, secret=(session_uuid, last_id, api_key_hash)):
        return 401
    seq = token.seq
    while True:
        chunk = cache.get(seq)
        if chunk is None:
            yield sse_format(id=seq, data="[RESUMED]")
            break
        if chunk.id <= last_id:
            seq += 1
            continue
        yield sse_format(id=chunk.id, data=chunk.data)
        seq += 1

注意 if chunk.id <= last_id: seq += 1 这一步——它让服务器跳过客户端已确认的事件,从下一个开始重发,避免重复。这就是不变量 1 的工程落地。

续传的实际工程难点是"客户端不知道自己已经收到哪些 frame"——这听起来荒谬但非常常见。比如客户端写入 IndexedDB 的 callback 是异步的,正在写第 150 个 frame 时用户切到后台 tab,IndexedDB 的 commit 被推迟,第 151-160 个 frame 已经到了但还没落盘,等用户切回时第 170 个 frame 已经流过,IndexedDB 里只有 1-150 + 170,而客户端以为收到了 1-170。重连时如果客户端报 Last-Event-ID: 170,服务器从 171 开始重发,但 151-170 就永久丢失了。

对策是双重确认机制:每个 SSE frame 携带 id 和 ack_id 两个字段,ack_id 表示"服务器收到客户端 ack 的最大 id"。客户端每收 N 个 frame 发一次 ack(含已确认的 checksum 列表),服务器根据 ack_id 判断客户端真正收到了哪些。丢失的 frame 区间会在下一个 ack 时被服务器发现并补发。这种"双向 ack + 校验和对比"模式参考 TCP 的 selective repeat 思路,是 LLM 流式响应在不可靠网络上的标准答案。

生产实践里更激进的方案是双向幂等 hash——服务器对每个 SSE frame 算 SHA-256 hash 写入 chunk header,客户端收到后立即算 hash 对比,不一致就立刻 NACK(发 X-Resume-Nack: <id> header)。这种方案在金融场景的高精度需求里有意义,但对 LLM 推理的容错性(错一两个 token 用户感知不到)来说 overkill。默认采用 ack_id + checksum 双确认就足够。


五、边缘场景:chunked 重组与丢失恢复

即使有 resumption token,实际生产里仍会遇到"部分 chunk 丢失 + 部分 chunk 乱序 + 部分 chunk 重复"三种边缘情况。这三种情况的根因都是 TCP 层字节流不可分割性,所以必须在 SSE 应用层做 chunk 重组。

SSE event id 字段 + Last-Event-ID 协议是 SSE 1.1 草案里的标准字段,但很多 LLM 服务商(包括早期 OpenAI)不发送 id: 字段——这是反模式。生产级实现必须每 frame 都带 id: <monotonic_int>,否则客户端无法精确告诉服务器"我收到了哪里"。Last-Event-ID 是 HTML5 EventSource 的内置 header,浏览器会自动在重连时附带,但 fetch().body.getReader() 模式下需要客户端手动管理。

自适应 buffer 大小与回压机制:

客户端 reader 不能假设"一次 read() 返回一个完整 frame"——通常一次 read 返回多个 frame 或半个 frame。Buffer 必须动态增长:起始 1 KB,每次溢出翻倍直到 64 KB 上限。Buffer 满时应触发"应用层 backpressure"——暂停调用 reader.read() 直到消费完积压。这是 vLLM 客户端和 OpenAI Node SDK 都在用的标准模式。

长连接 keepalive 与心跳检测:

SSE 协议允许服务器发 :\n\n(注释行)作为心跳,每 15-30 秒一次。心跳的作用是:(a) 防止中间 NAT / 代理的连接超时(Nginx 默认 60 秒、Cloudflare 默认 100 秒)。(b) 让客户端区分"服务器在思考"vs"服务器挂掉"——前者心跳照常,后者心跳超时。vLLM 默认 15 秒一次心跳,Anthropic 是 8 秒一次,频率越高误判率越低但 overhead 越大。

心跳超时阈值应配置为心跳间隔的 3-5 倍——15 秒间隔配 60 秒超时,30 秒间隔配 120 秒超时。客户端 timeout 必须分级:read timeout(单次 read 等待数据)、idle timeout(无数据总时长)、session timeout(从建立到现在的总时长),三者独立配置。OpenAI 的官方 SDK 默认 read timeout=600s, idle timeout=60s,可作为基线。


六、网络层:HTTP/2 + HTTP/3 流控与流取消

SSE 在 HTTP/1.1 上的传输效率其实不高,因为 HTTP/1.1 的 head-of-line blocking 让 SSE 流和同一连接上的其他请求互相阻塞。生产环境应该强制 HTTP/2 + SSE——HTTP/2 的多路复用让 SSE 流独占一个 stream ID,与其他请求完全隔离。

HTTP/2 RST_STREAM 帧的语义映射:

当客户端 abort SSE 连接时,浏览器底层会发一个 HTTP/2 RST_STREAM 帧(错误码 CANCEL = 0x8)。服务器(Nginx / Envoy / 自研网关)收到 RST_STREAM 后必须立即关闭对应的 upstream stream,而不是走正常的 FIN 握手——否则上游 vLLM 的 generate 循环要等 TCP keepalive 超时才知道连接已断。Nginx 1.25+ 已经支持 proxy_request_buffering off; proxy_read_timeout 1h; + RST_STREAM 透传,Envoy 的 stream_idle_timeout 配置也是为此设计。

HTTP/3 (QUIC) 的 0-RTT 重连与 cancellation:

QUIC 在 2026 年的生产部署率已超 60%(Cloudflare / Fastly 主干网默认开启)。QUIC 给 LLM 流式响应带来三个核心收益:

  1. 0-RTT 重连 —— 客户端断网后重连不需要重新 TLS 握手,省 100-300 ms。配合 resumption token 实现"无感续传"。
  2. stream-level cancellation 不影响 connection —— QUIC 的 stream 是独立的,取消一个 stream 不需要关闭整个 connection,连接池可复用。
  3. 连接迁移 —— 移动网络切换(4G → WiFi)时 QUIC connection ID 不变,不需要重新建连,这是 SSE 在移动端最大的痛点之一。

Cloudflare 的实测数据显示,QUIC + SSE 的移动端 P99 断流率从 HTTP/1.1 的 8.2% 降到 2.1%,是数量级的提升。生产部署建议:所有 LLM 流式 API 强制 HTTP/2 或 HTTP/3,禁用 HTTP/1.1 长连接。

反向代理的流式改造:

Nginx 默认配置对 SSE 不友好——proxy_buffering on 会把上游响应缓冲到磁盘再发给客户端,把 SSE 变成 "batch SSE"(每 30 秒一次大块响应),首字节延迟恶化 100x。必关:proxy_buffering off; proxy_cache off; gzip off;(gzip 在 chunked 上有 bug)。Envoy 类似:buffer_limit_soft: 0; buffer_limit_hard: 0;。自研网关必须实现"逐 chunk 透传"——收到上游 chunk 立即 forward 给下游,不聚合不延迟。


七、对工程实践的推论(5 条可执行项)

把上面 6 节的形式化模型 + 协议层 + 网络层收敛为 5 条可立刻落地的工程实践:

推论 1:客户端必带 resumption token 持久化层。

每个接入 LLM 流式 API 的客户端 SDK 必须实现三层:(a) IndexedDB / SQLite 持久化最近 N 次会话的 (session_id, last_event_id, HMAC token, checksum_list) 四元组。(b) 网络层断线自动重试 + Last-Event-ID 头注入。(c) 应用层"用户感知重连"——重连时给 UI 一个 "正在恢复..." 提示,避免空白闪烁。这是 Anthropic 客户端 SDK 在 2025 年改版后的标准设计。

推论 2:服务器端必暴露 cancellation hook。

推理服务必须支持以下 cancellation 协议:

POST /v1/cancel/{session_id}
Authorization: Bearer <api_key>
Content-Type: application/json
{ "mode": "soft" | "hard", "reason": "user_abort" | "client_disconnect" | "policy_violation" }

soft 模式允许当前 yield step 完成(典型 50-200 ms 内响应),hard 模式立即 abort GPU kernel(典型 < 10 ms 响应但有 KV cache 引用计数风险)。默认 soft,hard 仅给运维。

推论 3:监控必采集"流式首字节延迟 / 末字节延迟 / 中断率"三件套。

传统 HTTP API 监控的 TTFB(Time To First Byte)和 Total Time 不够用,必须新增三个 LLM 流式专属 metric:

  • sse_first_token_latency_ms:从请求发起到收到第一个 token 的延迟(≈ prefill 时间 + 网络延迟)。
  • sse_last_token_latency_ms:从收到第一个 token 到收到 [DONE] 的延迟(≈ decode 时间 + 网络延迟)。
  • sse_abort_rate_permille:每千次请求的中断次数,按中断原因(network_reset / client_abort / server_cancel / timeout)分维度。

这三个 metric 的 Prometheus / OTLP 表达:

# HELP sse_first_token_latency_ms Time from request to first SSE event
# TYPE sse_first_token_latency_ms histogram
sse_first_token_latency_ms_bucket{model="claude-opus-4",le="100"} 12
sse_first_token_latency_ms_bucket{model="claude-opus-4",le="500"} 487
sse_first_token_latency_ms_bucket{model="claude-opus-4",le="2000"} 998
sse_first_token_latency_ms_bucket{model="claude-opus-4",le="+Inf"} 1000

# HELP sse_abort_rate_permille SSE abort rate per 1000 requests
# TYPE sse_abort_rate_permille gauge
sse_abort_rate_permille{reason="client_abort",model="claude-opus-4"} 23
sse_abort_rate_permille{reason="network_reset",model="claude-opus-4"} 47
sse_abort_rate_permille{reason="timeout",model="claude-opus-4"} 8

推论 4:错误恢复必区分"可重试 / 不可重试"。

不是所有 SSE 中断都该重试:

错误类型是否可重试重试策略
网络层 RST_STREAM是用 Last-Event-ID + resumption token 续传
客户端主动 abort否不重试,释放服务器资源
服务器 5xx部分仅当 error code 是 upstream_timeout / kv_oom 才重试,其他直接失败
服务器 429 rate limit是退避重试,遵守 Retry-After 头
服务器 400 invalid request否不重试,提示用户修正 prompt
[DONE] 之后断开否视为正常完成

推论 5:网关层必开启 HTTP/2 流式透传 + 主动健康检查。

所有反向代理必须 (a) 启用 HTTP/2,(b) 关闭 buffering / caching / gzip,(c) 配置 proxy_read_timeout ≥ 1 小时(长于 SSE 最长生命周期),(d) 开启主动健康检查(每隔 5 秒向上游发 : 注释心跳验证连接活性)。Envoy 的 health_check 配置 + stream_idle_timeout: 3600s 是基线。


八、讨论:与 WebSocket / gRPC streaming 的对比

SSE 是 LLM 推理的最佳协议吗?不一定。

WebSocket 的全双工优势:WebSocket 支持客户端 ↔ 服务器双向流,理论上可以让客户端中途发送"停止生成""重新生成""追加指令"等控制信号,而 SSE 只支持服务器 → 客户端单向。但 LLM 推理的实际场景里,这种中途控制的频率极低——用户要么 cancel(一次性事件)要么追加(另起新请求),用 WebSocket 把控制信号也走流式通道是过度设计。WebSocket 的真正痛点是 HTTP/2 多路复用下 WebSocket 必须独占一个 stream,与同连接的 REST 请求互相阻塞,反而比 SSE 更差。

gRPC streaming 在多租户场景的适用性:gRPC 的 server-streaming 协议(rpc Generate(...) returns (stream Token))在多租户隔离、schema 强类型、内置 deadline 传播上有结构化优势。但 gRPC 的浏览器支持需要 grpc-web proxy,多一层跳数;proto 定义对前端工程师不友好;HTTP/3 支持 2026 年才稳定。对于公开 LLM API 仍然是 SSE 占优,但企业内部多租户推理平台用 gRPC 是合理选择(如 Cloudflare Workers AI 内部用的就是 gRPC)。

混合方案:Anthropic 在 2025 年公开过"sse + 后期切换 ws"的实验——前 N 个 token 走 SSE 让首字节延迟最低,decode 进入稳态后切到 WebSocket 拿双向能力。但实测收益 < 5% 的延迟改善,远不及 HTTP/3 升级的收益(~40% 移动端 P99 改善)。结论:2026 年的工程基线仍是 SSE + HTTP/2/3 + resumption token,WebSocket/gRPC 留给特殊场景。


九、给 SRE 与推理平台工程师的清单

把上面九节内容压缩成 SRE 可以直接拿来打分的 checklist:

上线前 11 条自检:

  1. 客户端 SDK 是否实现了 resumption token 持久化(IndexedDB / SQLite)?
  2. 客户端 SDK 是否在网络断开后自动重试 + 注入 Last-Event-ID?
  3. 服务器端是否暴露 POST /v1/cancel/{session_id} API,支持 soft / hard 模式?
  4. 服务器端 cancellation hook 是否走 ContextVar 传播,而不是共享变量?
  5. 反向代理是否关闭 buffering / caching / gzip?
  6. 反向代理是否启用 HTTP/2 或 HTTP/3?
  7. 反向代理是否配置 proxy_read_timeout ≥ 3600s?
  8. KV cache 是否在 generate 循环 finally 里显式 release?
  9. 监控是否采集 sse_first_token_latency_ms / sse_last_token_latency_ms / sse_abort_rate_permille 三件套?
  10. 错误恢复是否按"可重试 / 不可重试"分类(参考推论 4 的表格)?
  11. SSE frame 是否每帧携带 id: 字段?

4 条故障演练:

  • 演练 A(客户端断网):在客户端发起请求后立即 disable network 5 秒再恢复,验证 UI 是否显示"正在恢复..."而非空白,是否能续传而不丢 token。
  • 演练 B(网关重启):在客户端流式响应进行到第 100 个 token 时滚动重启网关,验证重连后是否从第 101 个 token 续传(而非重头再来)。
  • 演练 C(服务器 OOM):通过压测工具触发 KV cache 满载,验证服务器是否主动取消低优先级请求并返回 error.code = "kv_oom",客户端是否正确识别为不可重试。
  • 演练 D(移动网络切换):在 4G / WiFi 间快速漫游 10 次,验证 HTTP/3 连接迁移是否生效(连接 ID 不变,token 无丢失)。

把这 11 条 + 4 条演练做成 CI / pre-prod smoke test,能覆盖 90% 的流式响应生产事故。


参考文献

  1. WHATWG, HTML5 Server-Sent Events specification, 2014 (revised 2024). https://html.spec.whatwg.org/multipage/server-sent-events.html
  2. Fielding, R. T., Hypertext Transfer Protocol (HTTP/1.1): Message Syntax and Routing, RFC 7230, IETF, 2014.
  3. Belshe, M., Peon, R., Hypertext Transfer Protocol Version 2 (HTTP/2), RFC 7540, IETF, 2015.
  4. Iyengar, J., Thomson, M., QUIC: A UDP-Based Multiplexed and Secure Transport, RFC 9000, IETF, 2021.
  5. Kwon, Y., PagedAttention: Virtual Memory Management for LLM Inference, SOSP 2023.
  6. vLLM Project, vLLM Scheduler and Async Output API documentation, 2025.
  7. Anthropic, Streaming messages and cancellation, Anthropic Engineering Blog, 2025.
  8. OpenAI, API reference: Streaming, OpenAI Platform Documentation, 2025.
  9. Cloudflare, HTTP/3: the past, present, and future, Cloudflare Blog, 2024.
  10. Envoy Project, Envoy proxy: streaming and long-lived requests, Envoy Documentation, 2024.
  11. Nginx Project, Nginx as a reverse proxy for streaming endpoints, Nginx Blog, 2025.
  12. Sreeramaneni, S., Building resilient streaming APIs, USENIX SREcon 2024 talk slides.
←返回文章列表

Related

可能也会喜欢

  • LLM 网关多模型路由与负载均衡工程 20269月11日
  • LLM 多 LoRA 推理服务工程 2026:从热插拔到租户编排9月10日
  • LLM 投机解码工程 2026:从草稿模型到树注意力的统一架构9月4日

Conversation

0 条

留下你的想法

加载评论中…

New comment