LLM 推理流式响应的断点续传、取消与请求恢复工程 2026
把 LLM 流式响应从脆弱长连接重塑为可恢复可取消的有状态会话:以服务端 cancellation propagation、客户端 resumption token、HTTP/2 RST_STREAM 三件套为主轴,给出从协议层到监控层的完整工程范式。
约 36 分钟阅读10,609 字8 次阅读博主

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

一句话摘要:把 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 年公开事故复盘里最高频的一类投诉。
真实生产场景里至少有三种"流被打断"的事故:
fetch().body.getReader() 直接返回 done=true,但服务器已经吐出了 200 多个 token,GPU 上的 decode 步骤还在跑。传统 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 集合为 ,每个 单调递增, 是增量 token 或终止标记。客户端在收到 后断开,持有 和 HMAC 签名的 resumption token。续传时客户端发送 Last-Event-ID: id_k + resumption token,服务器从 开始重发(不重发 ,避免重复)。此协议满足 (a) 不重不漏:客户端收到的并集 ,其中 是最终 seq;(b) 顺序保持:客户端按 id 升序拼接;(c) 取消可观测:服务器收到 abort 信号时,已发出但未被客户端确认的 chunks 会被服务器侧的引用计数清理,避免 KV cache 泄漏。
这三条不变量是后续工程方案的形式化基础。下面把它们落地到具体协议和实现层。
服务器侧的"取消传播"是工程化的最难点,因为 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 实现远比服务器侧复杂,因为它涉及三个独立组件:持久化层(IndexedDB / SQLite / Redis)、网络层(fetch + AbortController)、解析层(IncrementalSSEParser)。
resumption token 持久化的核心设计原则:
参考 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 双确认就足够。
即使有 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,可作为基线。
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 流式响应带来三个核心收益:
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 给下游,不聚合不延迟。
把上面 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 是基线。
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 可以直接拿来打分的 checklist:
上线前 11 条自检:
POST /v1/cancel/{session_id} API,支持 soft / hard 模式?proxy_read_timeout ≥ 3600s?sse_first_token_latency_ms / sse_last_token_latency_ms / sse_abort_rate_permille 三件套?id: 字段?4 条故障演练:
error.code = "kv_oom",客户端是否正确识别为不可重试。把这 11 条 + 4 条演练做成 CI / pre-prod smoke test,能覆盖 90% 的流式响应生产事故。
Conversation
0 条