一个用户提交了 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. 自测
- 五处共享资源里,哪一处最致命?为什么 KV cache 会随序列长度线性增长?
- 为什么"按 token 数分批"比"按请求数分批"更合理?
- 三处限制(输入/批/输出)为什么都要设?只设输入限制会漏掉什么?
- 双队列的设计代价是什么?它解决了哪种具体现象?
- 为什么"拒绝原因分类"很重要?三类拒绝该怎么给码?
- 判断"是否需要复杂隔离"的那个问题是什么?