Files
PolyGateway/research-wiki/findings/2026-09-11-streaming-stall-hedging-research.md
T
iomgaa 03cbf4c8db docs: add approved streaming stall measurement and mid-stream hedging design
Findings: industry paradigm research (watchdog-retry is the norm,
mid-stream hedging has no public precedent; pi-tps threshold-based
stall deduction; Gregg off-CPU paired reporting; vLLM exclusion
clauses). Design: generation_ms redefined as net generation time
(wall minus all inter-chunk stalls over threshold), CallStats gains
stall_ms/stall_count, HEDGE__STALL_AFTER_S mid-stream hedge
independent of HEDGE__AFTER_S. Issues #27 #28.
2026-09-13 22:53:00 -04:00

28 KiB
Raw Permalink Blame History

流式中段卡顿对冲与"净生成时间"口径调研

调研日期: 2026-09-11。对应缺口: ①对冲触发判据只盯"首 token 未到",真实故障是"首 token 快(中位 1.6s)但流中段长时间沉默(中位 55s)";②generation_ms 把中段沉默包进墙钟,下游需要"净生成时间"。

〇、总览: 结论先行

主题 A: 流式中段卡顿(stream started but stalls mid-way)的对冲/恢复范式

范式名称 出处 与我们问题的对应关系 可直接借鉴的做法
Hedged/Tied requests(原始定义) Dean & Barroso, CACM 2013 论文语境是一元(unary)完整响应的 leaf 扇出,"first result"指整个响应,未讨论增量/流式响应 延迟派发(等 p95 再发第二路)+ 首个结果到达即取消其余 + 额外负载可低至 ~2%;tied request 的跨副本取消协议
自适应对冲(TTFT 竞速) llms.blog 生产架构文;bhope/hedge; InfoQ LLM 网关语境把赢家裁定从"响应头"前移到"首 token";但该文断言"卡顿几乎只发生在排队/prefill"——与我们实测(中段沉默中位 55s)相矛盾,SGLang decode 饿死 issue 证明中段卡顿真实存在 按模型×渠道维护滚动 P90/P95 TTFT(DDSketch)作动态对冲阈值;token bucket 限制对冲放大率
Envoy hedge policy Envoy 官方文档 对冲赢家裁定在响应头("first response with acceptable headers"),一旦裁定即 commit;响应体中段停顿不会重新对冲,只能靠 stream idle timeout 兜底 "判赢即 commit、不回头"的语义参考;hedge_on_per_try_timeout 命名;per_try_timeout 只在响应开始前有效(PR #10082)
gRPC hedging gRPC A6 提案 + 官方指南 hedgingDelay 以"收到非错误响应(头)"为节拍;收到 nonFatal 状态码立即补发下一路;server pushback(grpc-retry-pushback-ms)可推迟/禁止对冲;retryThrottling 用 token bucket 压对冲率 maxAttempts/hedgingDelay/nonFatalStatusCodes 配置三元组;上限钳到 5;deadline 覆盖整个对冲链
流内不活跃看门狗(inactivity watchdog) LiteLLM PR #29767、LangChain PR #36949(已合并) 业界对中段沉默的主流应对是"看门狗→超时→抛错/重试",不是并发副本;关键细节: 以解析后的内容 chunk 间隔计时,SSE keepalive 注释不重置计时器 stream_chunk_timeout(LangChain, 默认 120s) / LITELLM_STREAM_INACTIVITY_TIMEOUT_SECONDS(LiteLLM 提案,默认关);首 chunk 不受看门狗约束(TTFT 由请求超时管)
沉默检测→整路重试 OpenAI Codex CLI issue #33051 Responses 流静默 300s 后判定 stream disconnected - retrying sampling request (1/5),是"中段沉默触发重试"的真实生产先例 沉默超阈值 → 归为 Transient → 走既有重试/换源链,而非保活等待
serving 层 ITL 劣化成因 vLLM disagg prefill 文档、vLLM PR #33743、SGLang #32549/#29879 中段卡顿的服务端根因: prefill 与 decode 同卡互踩、chunked prefill 调度饿死 decode(实测 decode 被压到 ~1 batch/24s) 中段沉默是结构性现象而非异常个例,网关层必须有对策;观测指标参考 sglang:decode_step_latency_seconds

主题 B: "净生成时间"(扣除沉默段)观测口径范式

范式名称 出处 与我们问题的对应关系 可直接借鉴的做法
TTFT / ITL(=TPOT) / E2EL 标准定义 NVIDIA NIM/AIPerf 指标文档、NVIDIA 技术博客 E2EL = TTFT + Generation_time(首 token→末 token,剔除 [DONE]);ITL = (E2EL−TTFT)/(输出 token 数−1);排队、prefill、网络全部计入,无任何沉默扣除 口径分层: 排队/prefill 归 TTFT,纯解码段单独计量;工具间定义不一致(TTFT 是否计入 ITL 均值)必须显式声明
TPOT vs ITL 聚合权重 Anyscale 指标文档 单请求 TPOT = ITL 均值;多请求聚合时 avg TPOT 按请求等权、avg ITL 按 token 加权 库内聚合统计要声明权重口径,否则跨实现不可比
vLLM 阶段化计时 vLLM metrics 设计文档 + per-request metrics 特性 request_queue_time / prefill_time / decode_time 三段直方图;响应体 metrics 对象给 generation_time_ms(= decode time,排除排队与 prefill/TTFT) generation_time_ms 命名先例(注意: vLLM 的 decode time 仍含引擎侧中段停顿,沉默扣除在服务端的口径也没人做)
off-CPU / wall-time 分解 Brendan Gregg off-CPU analysis 墙钟 = on-CPU + off-CPU(阻塞);方法论要求给阻塞段归因(stack/原因),而非只减总量 沉默段扣除必须同时记录沉默总量与归属(哪段、多长、几次),否则下游无法审计
沉默扣除实战先例 pi-tps(pi 编辑器扩展) 唯一直接对应"从生成速率里扣除沉默段"的实现: gap ≥500ms 判为 stall,累计 stallMs/stallCount,从 streaming window 中扣除后算 TPS;连续停顿算一次事件;burst 交付/样本不足时输出 — 不硬算 阈值式扣除(减所有超阈值的段,而非只减最大一段);三分支防御(主口径/保守兜底/不可辨识时返回 null)
OpenRouter 供应商口径 OpenRouter 路由/延迟文档 供应商对比口径: latency = TTFT(含网络+排队+prefill),throughput = 解码 TPS;Total = TTFT + tokens/TPS "延迟=TTFT、速率=解码段"的二元口径是用户侧最广泛认知的形态

一、主题 A 详细发现

A1. Tail at Scale 原始定义与流式后续工作

  • 原始定义: "客户端先向其认为最合适的副本发送请求,短暂延迟后再发第二路;首个结果返回后取消其余在途请求"。额外负载可控: 等 95 分位预期延迟再发对冲,只惠及少量请求;更激进的对冲需要更快的取消(跨服务器取消的 tied requests)。(CACM 原文、作者 PDF)
  • 论文场景是搜索 leaf 扇出的一元响应(各 leaf 返回 top-k 部分结果给聚合器),"partial"在文中指分片结果,不是增量 token 流;全文无 streaming/incremental 响应的对冲讨论。(semicolony 批注版)
  • 后续工作的共识补充: 高负载下对冲会级联放大(每个慢请求都对冲→负载↑→更慢),生产系统需要熔断/自适应阈值等反馈回路。(semicolony 批注版·批评节)
  • LLM 语境的适配文(llms.blog): 把对冲裁定前移到 TTFT 竞速,主张"首 token 一旦产出,prefill 已完成、decode 高度确定,卡顿几乎只发生在排队/prefill"。此断言与我们实测不符(首 token 中位 1.6s、中段沉默中位 55s),且被 serving 层证据推翻(见 A4),引用时须警惕。(llms.blog)
  • bhope/hedge(Go 库): 按 host 用 DDSketch 学延迟分布,主路超 p90 估计即发备路,token bucket 压对冲率(防故障期放大),p99 从 64ms→17ms(~74%),开销 ~9%;明确支持流式负载(按 TTFT 而非响应头计量)。(GitHub、InfoQ 解读)

A2. 基础设施层: Envoy / gRPC / NGINX

  • Envoy: 启用 hedge policy 后"竞速多个并发上游请求,把首个带可接受响应头的响应返回下游;由 retry policy 判定响应是否可接受"。当前对冲只能由 per-try timeout 触发(hedge_on_per_try_timeout: per-try 超时不取消原请求,直接补发)。(HTTP routing 文档、HedgePolicy API、PR #6228)
  • Envoy 对"响应已开始但中途停顿": per_try_timeout "只在响应开始前有效"(PR #10082 明确允许全局 route timeout 禁用时配 per_try_timeout 给流式端点用)——即判赢发生在响应头,中段停顿不会触发新的对冲;流式响应的停顿只能靠 stream idle timeout 兜底(route timeout 默认 15s 与流式不兼容,须禁用改用 idle_timeout)。(PR #10082、超时 FAQ)
  • gRPC(A6): hedgingPolicy = {maxAttempts, hedgingDelay, nonFatalStatusCodes};原请求立即发,每过 hedgingDelay 未收到非错误响应就补发一路;收到 nonFatal 状态码立即补发下一路;任一请求收到非错误响应→取消全部其余并返回该响应;fatal 状态码→取消全部并报错;server pushback(grpc-retry-pushback-ms,负值=禁止再发)可推迟/终止对冲;retryThrottling{maxTokens, tokenRatio} 按失败/成功比用 token bucket 压制对冲与重试;maxAttempts 客户端钳到 5;deadline 覆盖整条对冲链。(A6 提案、官方指南)
  • gRPC 对冲对 server-streaming: listener 把 onNext/onClose 转发给所有在途对冲 RPC——流式场景赢家仍按"首个非错误响应(头)"裁定,没有按消息流中段进度重新裁定的语义。(A6 提案)
  • NGINX: 无对冲原语;ngx_http_mirror_module 只做多副本镜像且忽略镜像响应(fire-and-forget),不构成 FIRST_COMPLETED 竞速。(NGINX mirror 模块文档)

A3. LLM 网关/库层: 中段 stall 的真实实现与讨论

项目 机制 关键语义 来源
LiteLLM(提案, PR 已 stale 关闭) _StreamInactivityGuard + LITELLM_STREAM_INACTIVITY_TIMEOUT_SECONDS(默认关) 以 asyncio.wait_for 约束 chunk 间隔;首 chunk 不受看门狗约束(TTFT 归请求 timeout, reasoning 模型首 token 慢);触发抛 litellm.Timeout(408) PR #29767
LiteLLM(已发) ttft_timeout 配置;stream_timeout 修为"整流墙钟上限" ttft_timeout: 无首 token 超时即 litellm.Timeout(非流式内部提升为流式实现);httpx read timeout 被 keepalive 字节重置,对"有字节无内容"的卡顿完全失效 config 文档、PR #31238
LiteLLM(bug 证据) 中段超时在 200 已发后抛在生成器内 被误记为成功(遥测语义教训);另有反向机制: 上游沉默时主动注入 SSE ping 保活(PR #36024, Anthropic 每 ~30s ping, Claude Code 20s 看门狗) Issue #29602、PR #36024
LangChain(已合并, 1.2.0) stream_chunk_timeout(默认 120s) + LANGCHAIN_OPENAI_STREAM_CHUNK_TIMEOUT_S 计解析后内容 chunk 的间隔, SSE keepalive 不重置;触发抛 StreamChunkTimeoutError(TimeoutError 子类,携带 timeout_s/model/chunks_received 结构化字段);配套 TCP keepalive/TCP_USER_TIMEOUT socket 选项 PR #36949、API 参考
LangChain JS(诉求) 用户要"不活跃超时"而非"总时长超时" 总时长超时误杀长流;间隔计时才是正确语义 langchainjs #9088
OpenAI Codex CLI 流静默检测 + 整路重试 静默 300s 判 stream disconnected,重试 retrying sampling request (1/5);用户报告 5–10 分钟级卡顿 issue #33051
Portkey request_timeout 曾用 AbortSignal.timeout() bug: 流已开始仍被总超时杀掉;修复为收到首个响应即清定时器——"总时长 vs 不活跃"同一教训 Issue #471、streaming 文档
Bifrost / Helicone 重试+fallback+自适应 LB 未见公开的中段沉默触发重试/对冲实现;Bifrost 生态文章把 "parallel hedged requests" 列为路由模式之一但无流式细节 Bifrost retries、Helicone retries

结论: 业界对"流中段沉默"的落地形态收敛为 间隔式不活跃看门狗 → 抛错(Timeout 子类)→ 交给既有重试/换源链;"中段沉默触发并发副本(hedging)"在任何主流网关/库中均未找到公开实现——我们的 FIRST_COMPLETED 异源对冲若覆盖中段沉默,属于超业界现状的能力,但语义上可组合"看门狗 + 已取消输家不可复活"来实现。

A4. serving 层: inter-token stall 的观测与成因

  • 成因: 连续批处理引擎把新请求的 prefill(算力密集)与在途请求的 decode(带宽密集)交错;vLLM 官方文档承认未分离时"decode 中插入 prefill 会导致 tail ITL 尖刺",故推 P/D 分离分别调 TTFT 与 ITL。(vLLM disagg prefill)
  • 实测极端案例: SGLang 严格 prefill-first 调度下,持续 prompt 负载使 decode 饿死到 ~1 batch/24s——即中段沉默数十秒是调度器结构性产物,非异常。(SGLang #32549)
  • 观测指标先例: vLLM vllm:inter_token_latency_seconds 直方图(llm-d 运维文档建议用 histogram_quantile 盯分位,"ITL 高→输出断断续续");SGLang 新增 forward_pass_duration_seconds/decode_step_latency_seconds(按 prefill 桶×decode 请求数打标,专测 decode 被干扰)。(vLLM metrics、llm-d 观测文档、SGLang PR #29879)
  • 缓解手段: chunked prefill(vLLM 默认预算 2048 tokens/迭代,防长 prompt 冻结在途会话)、inter-prefill-budget(中位 TTFT 降 37%,防 HoL 阻塞)。(LeCompute 调度调优、vLLM PR #33743)

二、主题 B 详细发现

B1. LLM 推理基准的标准时间口径

  • NVIDIA NIM/AIPerf 官方定义: TTFT = 提交查询→收到首个 token(忽略空内容的首个响应;含排队+prefill+网络);E2E 请求延迟 = TTFT + Generation_time(首 token→末 token,剔除末尾 [DONE]/空响应);ITL =(E2E−TTFT)/(输出 token 数−1),显式排除 TTFT,分母减 1 使其只刻画 decode 段。(NVIDIA NIM metrics)
  • NVIDIA 技术博客同口径: "TTFT 含请求排队、prefill 与网络延迟";"E2E = TTFT + 生成时间;ITL 为相邻输出 token 间隔均值(不含首 token)"。(NVIDIA 博客)
  • 排队与网络如何处理: 基准口径不扣除——排队计入 TTFT/E2EL,网络计入 TTFT;跨工具可比的前提是定义对齐("Tool implementations vary, so compare results only when definitions align")。(NVIDIA NIM metrics)
  • Anyscale/vLLM 口径: 单请求 TPOT = ITL 均值 =(E2E−TTFT)/(token 数−1);聚合时 avg TPOT 按请求等权、avg ITL 按 token 加权——两者回答不同问题(单请求体感 vs 系统稳态流率)。(Anyscale metrics)
  • MLPerf Inference: LLM server 场景收 TTFT + TPOT 两指标,TPOT 定义为"所有生成 token 间的平均间隔";延迟自 LoadGen 调度时刻起算(含排队)。(inference_rules.adoc、MLPerf 5.1 说明)
  • 陷阱先例: vLLM bench serve 的 ITL 在 chunk 与 token 非 1:1 时(reasoning/tool-call parser 缓冲)算错——chunk 边界 ≠ token 边界,任何按 chunk 计时的口径都要声明这一点。(vLLM #27485);另有"ITL 低于 TPOT"的历史 bug(均值口径混淆)。(vLLM #6531)

B2. wall-time vs on-CPU: 扣除外部阻塞时间的标准做法与陷阱

  • off-CPU analysis(Brendan Gregg): CPU profiling 只看在 CPU 上执行的线程;off-CPU 分析测量线程被阻塞(off-CPU)的时间并带上下文(stack trace),两者互补以解释 100% 线程时间。off-CPU 时间 = 调度器 off-cpu→on-cpu 探针的时间戳差。(Off-CPU Analysis、offcputime 工具)
  • 方法论要点: ①墙钟分解必须成对报告(on-CPU + off-CPU flame graph 同看),只报"净时间"会丢失归因;②阻塞时间若发生在请求的同步路径上,则直接等比影响延迟——即"扣除"是分析手段,不改事实;③归因需要上下文(stack/wakeup stack),单给一个总数无法行动。(Off-CPU Flame Graphs、Wakeup/Off-Wake)
  • 映射到我们的场景: generation_ms(墙钟)= 活跃生成(on-CPU 类比)+ 中段沉默(off-CPU 类比);Gregg 范式要求的"带归因的分解"对应"沉默总量 + 沉默次数 + 各段位置",而非只给一个净数。

B3. 从 token 到达序列扣沉默段的计量学先例与陷阱

  • 唯一找到的成体系先例 — pi-tps: 每个流式更新测量与上一更新的间隔;gap ≥ 500ms 判为 stall,整段累计为 stallMs、连续停顿算一次 stallCount;算 TPS 时把 stall 从 streaming window 中扣除;每个 message 边界重置 stall 时钟(工具调用间隙不误计);三分支防御: 主口径(≥5 次更新、平均间隔 ≥1ms、stall < 活跃时间)、保守兜底(用含 TTFT 的全窗口,宁可低估)、结构性不可辨识时返回 null 显示 —(burst 交付/样本不足)。(pi-tps 包页)

  • vLLM 服务端口径: generation_time_ms = 首 token→末 token 的 decode 时间,已排除排队与 prefill/TTFT,但仍包含引擎侧中段停顿(preempt/调度等待);mean_itl_ms 单 token 响应为 null;n>1 时整个 metrics 抑制(无法归因)——口径排除条款的范本。(vLLM per-request metrics)

  • OpenRouter 用户侧口径: latency = TTFT(网络+排队+prefill),throughput = 解码 TPS;Total = TTFT + tokens/TPS——二元口径,不做沉默扣除,但把"等"与"生成"分开呈现是行业用户心智。(OpenRouter 延迟指南、供应商评估)

  • 三种扣除形态的判定(综合以上先例):

    形态 评价
    只减最大一段 无任何先例;多段沉默场景系统性高估活跃时间,不推荐
    减所有超阈值段(pi-tps 式) 有唯一直接先例;阈值外小停顿计入活跃时间,偏差有界且方向保守,推荐
    减所有段(纯 token 间隔求和) 等价于重定义 ITL;chunk≠token 时不可算(vLLM #27485),放弃
  • 尾部归属: AIPerf 显式剔除末尾 [DONE]/空响应(E2E 止于末 token);pi-tps 的 stream span 止于最后一次 message_update;最后一个 token 的间隔属于生成段内,流终止信号之后的时间一律不计。vLLM 单 token 响应 mean_itl_ms = null 提示: token 数 <2 时一切间隔类口径应输出 null 而非 0。

三、对库设计的具体建议

3.1 中段对冲的赢家裁定语义

  1. 裁定事件分两级: 现行"首 token 竞速"保留(命名对齐业界: TTFT race);新增中段沉默竞速——主路沉默超过 stall_after_s 触发异源副路,副路同样按"首个内容 chunk"裁定。
  2. commit 点: 借鉴 Envoy/gRPC"首个可接受响应即 commit":一旦某一路的首个内容 chunk 已向下游交付,该路成为 committed winner,其余路取消、不得复活——chat completion 的增量内容不可拼接(两路文本分叉,缝合即结果污染),这是 Env名"判赢不回头"语义在 LLM 场景的必然推论。
  3. commit 前摇摆: 若副路先出 chunk 而主路恰好恢复,以先交付者为准(FIRST_DELIVERED),不做内容比车。
  4. committed winner 再沉默: 不再对冲(无副本可换语义),走看门狗 → stall_timeout 抛错 → 错误分类 Transient → 交既有重试/换源链(Codex CLI 先例);已在下游的增量内容如实保留并标记截断。
  5. 成本与放大控制: 借鉴 gRPC retryThrottling(token bucket 压对冲率)与 Tail at Scale 级联警告: 对冲预算独立计量,故障期自动收敛;输家 est 不喂熔断的现行设计保持。

3.2 沉默扣除口径的推荐形态

  1. 记录原语: 传输层记录每个内容 chunk 的单调时间戳序列(空 chunk/SSE keepalive 不计,LangChain 语义),遥测侧由时间戳派生,不在热路径做判定。

  2. 派生字段(CallStats 只增不删):

    字段 口径
    generation_ms(已有) 首内容 chunk→末内容 chunk 墙钟(vLLM generation_time_ms 同口径)
    stall_ms Σ 所有 > stall_threshold_ms 的相邻 chunk 间隔(pi-tps 式,非只减最大段)
    stall_count 超阈值间隔个数(连续停顿合并为一次)
    active_generation_ms generation_ms − stall_ms,派生值、永不覆盖墙钟原值(Gregg 分解原则)
  3. 阈值形态: 固定默认值(pi-tps 用 500ms,Anthropic ping 间隔 ~30s 提示阈值需 ≫ keepalive 周期,建议默认 2–5s 量级、可配),不做自适应分位阈值(避免与对冲阈值耦合出反馈环)。

  4. 排除条款照抄 vLLM: chunk 数 <2 时间隔类字段为 null;非流式调用三个新字段全 null;对冲输家的沉默数据保留在输路记录但不进赢家 CallStats。

  5. 对冲与沉默的关系: stall_after_s(对冲触发)与 stall_threshold_ms(计量口径)是两个独立参数——前者触发动作、后者只做观测,命名与语义都必须分开。

3.3 配置命名先例对照

语义 业界先例 建议沿用风格
首 token 超时 LiteLLM ttft_timeout、stream_timeout HEDGE__AFTER_S 已符合;看门狗类用 STREAM__TTFT_TIMEOUT_S
中段不活跃看门狗 LangChain stream_chunk_timeout(默认 120s)、LiteLLM 提案 ...STREAM_INACTIVITY_TIMEOUT_SECONDS STREAM__STALL_TIMEOUT_S
中段对冲触发阈值 无直接先例(gRPC hedgingDelay 是按响应头的节拍) HEDGE__STALL_AFTER_S
沉默计量阈值 pi-tps 固定 500ms(代码常量) STREAM__STALL_THRESHOLD_MS
对冲并发上限 gRPC maxAttempts(钳 5) 现有单副路设计已等价 maxAttempts=2

四、来源清单

保留(关键证据)

弃用(及原因)

  • learnaivisually、hivenet、clickhouse、themainthread 等科普稿 — 定义与 NVIDIA/Anyscale 一手文档重复且更浅
  • macnica(代理商博客)、AWS Neuron/营销页、acecloud、saraswatmks 博客 — 营销/转载性质
  • Portkey/Helicone/Bifrost 营销文档 — 只证实"无中段沉默对冲实现",不承载技术语义
  • Solana/Rust hedged RPC crates — 与 LLM 流式无关的一元 RPC 竞速
  • eunomia eBPF 教程 — Gregg 一手文已覆盖

五、未能确认的事项

  1. "中段沉默触发并发副本"是否有任何已投产实现——未找到;不排除闭源网关(如各云厂 AI 网关)内部存在。建议以"看门狗→重试"为基线交付,把中段对冲作为可选增强。
  2. pi-tps 的 500ms 阈值是否经生产校准(单个开源扩展,样本未知);我们的阈值应基于自有遥测分布定,不照抄数值。
  3. Tail at Scale 之后是否有学术流式对冲后续工作(本次检索未见;如需可再查 IEEE/ACM 数据库)。