2636 字
约 8 分钟
14
S23-A15 · 推理的故障隔离:一个坏请求会不会拖垮全服务

一个用户提交了 12 万 token 的长文档,服务在生成过程中显存耗尽,进程 OOM 重启。重启期间所有请求失败——一个坏请求换来了全服务的三分钟不可用。而它不是攻击,只是有人把一份用户手册整本贴了进去。

推理服务的故障隔离,核心是回答一个问题:哪些东西是"全服务共享"的。这个问题的特殊性在于输入不可信:请求来自用户,长度与内容都不由你决定。而传统服务里,请求的大小通常由接口契约约束——推理服务没有这条保护。
显存、队列、连接、线程池共享,于是任何一个请求的异常都能波及全体。


1. 五处共享资源

资源 被一个请求霸占的后果
显存(KV cache) OOM,进程重启
排队槽位 队列满,新请求被拒
长连接/worker 其它请求排队
CPU(预处理) 延迟整体上升
下游配额 触发限流

第一项最致命:KV cache 随序列长度线性增长第三项(worker)的传导很直接:一个请求占住 worker 30 秒,等于这段时间少一个 worker。当 worker 数与并发数相当时,一个坏请求就是 10% 的容量消失。
,而它通常预分配在显存里。一个超长请求就能吃掉所有余量。

第二项的传导更隐蔽:一个慢请求占住队列槽位十几秒,队列短时被填满,新请求开始被拒——而监控只显示"拒绝率上升",看不出原因。

2. 四道隔离闸门

闸门 作用
入口长度限制 拒绝超长输入
按 token 分批 控制每批显存
单请求超时 释放槽位
请求级熔断 连续失败即隔离

第一项最便宜:在入口按 token 数拒绝,返回明确错误而不是让它跑一半挂掉。

第一项(入口限额)要做好"提前判断":能在解析前就算出 token 数(用分词器的 tokenizer 估算),比"生成到一半才发现超限"省一次完整失败。
第二项的要点是"按 token 数分批而不是按请求数":10 个短请求与 1 个长请求在显存上的占用可能相同,所以按请求数分批会让显存使用剧烈波动。

// 三道限制同时生效
serving:
  max_input_tokens: 8192          # 入口即拒,避免半途 OOM
  max_batch_tokens: 16384         # 按 token 分批,显存可控
  max_output_tokens: 1024         # 输出也能吃显存(KV 增长)
  request_timeout_ms: 30000       # 释放槽位
// 关键:三者都要设,只设其中一个是常见的不完整防护

3. 队列设计:长短分离

做法 效果
单队列 FIFO 长请求堵住短请求
双队列(长/短) 长请求不阻塞短请求
按优先级 重要请求优先
拒绝而非排队 超载时快速失败

第一项的问题就是"队头阻塞":一个 12 万 token 的请求排在前面,后面 20 个短请求全部等它。第三项(按优先级)要小心"饥饿":高优先级请求持续到来时,低优先级可能永远排不上。所以优先级通常配"老化"(等待越久优先级越高)。
表现为"平均延迟不高但 P99 极差"。

第二项是最常见的解法:按预估 token 数分流到两个队列,各自独立并发。代价是利用率下降(两个池不能互相借用)。

// 双队列的实际参数(示例)
short 队列:max_tokens ≤ 2048,并发 16,超时 8s
long  队列:max_tokens ≤ 8192,并发 4, 超时 30s
// 效果:短请求 P99 从 12s 降到 1.2s,代价是长请求排队更久

4. 单请求隔离的三种手段

手段 代价
独立进程/容器 资源开销大,最彻底
独立 worker 线程池 中等
仅超时与限额 最轻,但挡不住 OOM

第三项挡不住"显存被吃光"这类问题:超时是事后的,而 OOM 是事中的。所以对"输入长度不可控"的场景,必须有前置的长度限制。

第二项(独立 worker 池)的关键是"池的上限独立":短请求池可以被长请求占满的显存影响不到,前提是它们不共用同一块卡。共用卡时,隔离只在调度层面,不在资源层面。
第一项在极端场景值得:把"长文档"类请求路由到单独的部署,即使它挂了也不影响短请求。

除了"资源隔离",还有一层"请求级"的隔离:同一用户或同一会话的并发要限制。否则一个用户开 20 个并发会占掉大部分槽位,而他的每个请求都不算超长——单请求限额完全拦不住这种"多而小"的占满。

// 请求级限制(与资源限制互补)
per_user_concurrency: 4         # 单用户并发上限
per_session_turns: 50           # 单会话轮次上限(防无限循环)
global_concurrency: 32          # 全局并发(正常量)
// 效果:一个用户刷不上,也拖不垮别人

5. 观测:三处必须能看出来

观测 用途
输入长度分布(P99/最大值) 判断限额是否合理
拒绝原因分类 区分"超长"与"过载"
单请求的显存峰值 定位坏请求

第二项是最容易被忽略的:所有拒绝都返回 429 时,无法区分"你太长"与"系统太忙"。第三项(单请求显存峰值)需要逐请求埋点,成本较高;一个便宜的替代是记录"输入 token 数 + 输出 token 数 + 耗时",三者异常的组合基本能定位坏请求。
前者用户可改,后者用户只能等。

// 三类拒绝要给不同的码与不同的话
INPUT_TOO_LONG   413  输入 %d token 超过上限 %d,请拆分
QUEUE_FULL       429  当前排队 %d,建议重试(Retry-After: 5)
TIMEOUT          504  生成超时,可缩短 max_tokens 后重试
// 三者混成一个 429 → 用户无法自助,客服也无法分流

隔离方案还要能被"演练"验证:主动注入一次超长请求,看它是否被入口拦住、是否影响其它请求的 P99。没有演练过的隔离,等于写在文档里的假设。

// 隔离演练的三个断言(在预发环境跑)
1. 提交 12 万 token → 立即返回 413,不进入队列
2. 提交 8000 token × 5 并发 → 短请求 P99 不受影响
3. 单用户开 20 并发 → 被限制为 4,其余返回 429
// 任一不满足 → 隔离在某处失效,定位到对应闸门

6. 排错:五种"一个请求拖垮全服务"

现象 成因 处理方向
偶发 OOM 后整体重启 无长度限制 入口限额
P99 极差但均值正常 队头阻塞 长短队列分离
拒绝率突然上升 槽位被慢请求占满 单请求超时
重启后短时全失败 无就绪探针/无多副本 多实例 + 就绪探针
某些用户总失败 其请求固定超长 前端预校验

第四项(无就绪探针)会让"重启"变成"三分钟不可用":多副本 + 就绪探针能让容量在重启期间降级而不是归零。这条是隔离之外的兜底,但常被一起省略。
第五项要往前推:让前端在提交前估算 token 数并提示,比服务端拒绝体验好得多,也少一次往返。

还有一个"部署形态"的取舍值得写清:单实例多副本 + 入口限额,通常比"单实例 + 精细隔离"更划算。因为多副本让故障范围变成 1/N,而精细隔离需要更多的代码与更多的不确定性。

// 两种路线的对比
路线 A:单实例 + 双队列 + 逐请求熔断 → 利用率高,但一个 OOM 全挂
路线 B:3 副本 + 入口限额 + 超时 → 单点故障只损失 1/3 容量
// 常见结论:B 更简单可靠;A 在 GPU 极贵的场景才值得

7. 边界:什么情况不必做复杂隔离

场景 为什么 做法
内部工具、用户可信 无恶意输入 简单限额
输入长度天然固定 无超长风险 不做
批量离线推理 无实时要求 单队列即可
已有网关统一限制 平台负责 跟随平台

判断口径一句话:输入长度是否由不可信的一方决定。最后一条与"成本"有关的边界:隔离是有代价的——双队列降低利用率,独立部署增加资源占用。所以隔离的粒度应当与"损失的量级"匹配:一次全服务重启三分钟,值得付出 20% 的利用率。
是,就必须有入口限额与队列隔离;否(内部调用、固定模板),超时与限额足够。


8. 自测

  1. 五处共享资源里,哪一处最致命?为什么 KV cache 会随序列长度线性增长?
  2. 为什么"按 token 数分批"比"按请求数分批"更合理?
  3. 三处限制(输入/批/输出)为什么都要设?只设输入限制会漏掉什么?
  4. 双队列的设计代价是什么?它解决了哪种具体现象?
  5. 为什么"拒绝原因分类"很重要?三类拒绝该怎么给码?
  6. 判断"是否需要复杂隔离"的那个问题是什么?
S23-A15 · 推理的故障隔离:一个坏请求会不会拖垮全服务
https://www.clxhxhhr.top/posts/6498/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。