AI Gateway 上线之后:推理流量为什么需要自己的网关
古董级程序员,大厂出来后一直在创业公司,现在仍在一线做 AI 相关的工程。更完整的技术记录写在微信公众号「字与码」:工作经历、对新工具的看法,以及这些年踩过的坑,会不定期发在那里。若这篇对你有用,欢迎顺手关注。
一套客服摘要平台刚上线时,最容易让人误判的指标是 QPS。它看上去像一个普通接口:客服系统把对话记录发过来,模型返回摘要、情绪、待办和质检标签。入口是 /v1/chat/completions,后面是几组推理 Pod,Kubernetes 上挂一个 Gateway,按连接数转发。
问题出在第二周。周一上午,在线客服开始抱怨摘要按钮要等很久,后台看 GPU 利用率却不算满,错误率也没有明显升高。更麻烦的是,慢只发生在一部分请求上。短会话有时 2 秒返回,有时卡 20 秒。长上下文批处理本来放在夜里跑,周末积压以后延续到了白天,把在线摘要拖进了同一条队列。
这篇文章不想再抽象地讲“AI Gateway 很重要”。我按这个可复制的案例往下拆:一个客服摘要平台如何在 Kubernetes 上把长上下文批处理和在线摘要拆成不同推理池,如何让网关按 token、任务类型和队列状态路由,如何记录可排障的指标,如何灰度、回滚和验收。

事故现场:不是 QPS 高,是长请求把路堵住了
平台里有两类任务。在线任务来自客服工作台,客服点一下“生成摘要”,希望几秒内看到结果。离线任务来自质检系统,按工单批量重算过去 7 天的对话摘要,有些会话很长,输入能到 60K token。最初为了省事,两类请求都打到同一个模型服务:
client -> public gateway -> inference-gateway -> llm-summarizer-service -> vllm pods
服务端一共有 8 张 GPU,4 个 Pod,每个 Pod 2 张卡。模型是同一个长上下文模型,在线和离线共用。网关使用普通的 least request 策略,谁当前连接少就转给谁。这个策略对传统 HTTP 服务能工作,但对推理服务很容易失真。
下面是慢请求里截出来的两条样例,字段做了脱敏,结构保留了真实工程里需要关注的部分。
{
"request_id": "req_live_8c13",
"tenant": "support-basic",
"task": "live_ticket_summary",
"model": "summary-default",
"messages": [
{
"role": "system",
"content": "把客服对话整理为三段:用户问题、处理进展、下一步待办。"
},
{
"role": "user",
"content": "<一段约 2800 token 的客服对话>"
}
],
"max_tokens": 420,
"stream": false
}
{
"request_id": "req_batch_91f4",
"tenant": "quality-job",
"task": "batch_recompute_summary",
"model": "summary-default",
"messages": [
{
"role": "system",
"content": "对历史工单做完整摘要、风险标签、客服违规点和证据引用。"
},
{
"role": "user",
"content": "<一段约 52000 token 的历史对话和知识库片段>"
}
],
"max_tokens": 1800,
"stream": false
}
从网关角度看,它们都是一个 POST 请求。在线请求输入 2800 token,输出 420 token;批处理请求输入 52000 token,输出 1800 token。两者对 GPU 的占用、KV cache 压力、prefill 时间完全不是一个量级。普通网关看不见这些差异,排队就从这里开始。
那天的监控也很有迷惑性。全局 P95 还在 18 秒以内,错误率不到 0.3%,GPU 利用率在 65% 到 82% 之间抖动。真正坏掉的是在线摘要的 TTFT 和排队时间,用户感知是“点了没有反应”。如果只看平均延迟,会误以为扩容一两个 Pod 就能解决。
把流量拆开以前,先把请求成本算出来
我们没有一上来换网关,也没有先调 Kubernetes 调度器。第一步是让入口层能估算请求成本。对摘要平台来说,粗略 token 估算已经够用,不需要追求和模型 tokenizer 完全一致。关键是路由前要知道请求大概属于哪一类。
入口服务给每个请求补了一个内部头,业务方也可以显式传入任务标签:
POST /v1/chat/completions HTTP/1.1
Content-Type: application/json
X-AI-Tenant: support-basic
X-AI-Task: live_ticket_summary
X-AI-Request-Class: interactive
X-AI-Trace-Id: req_live_8c13
网关收到请求后记录这些派生字段:
{
"trace_id": "req_live_8c13",
"tenant": "support-basic",
"task": "live_ticket_summary",
"model_alias": "summary-default",
"input_tokens_estimated": 2864,
"output_tokens_requested": 420,
"request_class": "interactive",
"stream": false,
"route_pool": "summary-online",
"route_reason": "class=interactive,input<=8192",
"gateway_queue_ms": 12
}
这里有两个细节很重要。
一是模型名不再直接等于模型版本。业务仍然请求 summary-default,网关把它映射到具体模型池。这样灰度和回滚发生在平台层,不需要业务系统改代码。
二是 route_reason 必须落日志。推理路由不是黑箱调度。出问题时,团队要能回答:为什么这个请求去了这个池,为什么没有走 fallback,为什么被限流,为什么被降级。
Kubernetes 里的新形状:两个池,不是两套系统
最后落地的第一版并不复杂。我们把推理池拆成三组,但只有两类硬隔离:
| 推理池 | 任务 | 上下文 | 策略 |
|---|---|---|---|
summary-online | 客服工作台实时摘要 | 输入小于 8K token | 保 TTFT,少排队,限制输出 |
summary-long | 长会话人工触发摘要 | 输入 8K 到 64K token | 限并发,允许慢一点 |
summary-batch | 离线质检重算 | 输入 8K 到 64K token | 低优先级队列,可暂停 |
Kubernetes 资源上,在线池和长上下文池分开部署。批处理池可以在夜间借用长上下文池,但白天遇到在线压力时会暂停取任务。这不是为了追求完美利用率,而是为了把用户等待时间从批处理吞吐里解耦出来。
一个简化后的 Deployment 如下:
apiVersion: apps/v1
kind: Deployment
metadata:
name: summarizer-online
labels:
app: summarizer
inference.pool: summary-online
spec:
replicas: 3
selector:
matchLabels:
app: summarizer
inference.pool: summary-online
template:
metadata:
labels:
app: summarizer
inference.pool: summary-online
spec:
terminationGracePeriodSeconds: 90
containers:
- name: runtime
image: example.local/llm-summarizer:2026-06-15
args:
- "--model=/models/summary-7b"
- "--max-model-len=8192"
- "--served-model-name=summary-default"
ports:
- containerPort: 8000
readinessProbe:
httpGet:
path: /health/ready
port: 8000
periodSeconds: 5
resources:
limits:
nvidia.com/gpu: "1"
长上下文池的差异在参数和副本数:
apiVersion: apps/v1
kind: Deployment
metadata:
name: summarizer-long
labels:
app: summarizer
inference.pool: summary-long
spec:
replicas: 2
template:
metadata:
labels:
app: summarizer
inference.pool: summary-long
spec:
terminationGracePeriodSeconds: 240
containers:
- name: runtime
image: example.local/llm-summarizer:2026-06-15
args:
- "--model=/models/summary-14b-long"
- "--max-model-len=65536"
- "--served-model-name=summary-default"
- "--max-num-seqs=8"
resources:
limits:
nvidia.com/gpu: "2"
同一个模型别名背后可以对应不同运行时。在线池用较小模型和短上下文,长上下文池用更大的窗口。业务方不需要知道这些细节,只要声明任务和输入。
网关规则:能解释,比聪明更重要
第一版 AI Gateway 规则写得很克制。它没有试图预测每张 GPU 未来几十秒的状态,只做了几件能解释的事:按任务分类、按输入长度分池、按队列时间保护在线请求、按租户做预算。
配置片段像这样:
apiVersion: ai.example.com/v1alpha1
kind: InferenceRoute
metadata:
name: support-summary-route
spec:
modelAliases:
summary-default:
defaultPool: summary-online
pools:
- name: summary-online
service: summarizer-online.default.svc.cluster.local
maxInputTokens: 8192
maxOutputTokens: 600
maxQueueMs: 800
priority: 100
- name: summary-long
service: summarizer-long.default.svc.cluster.local
minInputTokens: 8193
maxInputTokens: 65536
maxOutputTokens: 2200
maxQueueMs: 12000
priority: 50
- name: summary-batch
service: summarizer-batch.default.svc.cluster.local
maxInputTokens: 65536
maxOutputTokens: 2200
maxQueueMs: 300000
priority: 10
rules:
- name: live-summary-short
when:
taskIn: ["live_ticket_summary", "agent_assist_summary"]
inputTokensLte: 8192
routeTo: summary-online
- name: live-summary-long
when:
taskIn: ["live_ticket_summary"]
inputTokensGt: 8192
routeTo: summary-long
- name: quality-batch
when:
requestClass: batch
routeTo: summary-batch
queuePolicy:
pauseWhen:
pool: summary-online
ttftP95MsGt: 2000
这段配置有一个隐藏的取舍:长会话的在线请求不会挤进短池。用户如果打开了一个很长的历史工单,可以慢一点,但不能让普通短工单一起变慢。批处理更明确,它被允许排队,甚至被暂停。
租户预算也放在网关层,而不是散在业务代码里:
apiVersion: ai.example.com/v1alpha1
kind: InferenceBudget
metadata:
name: support-summary-budget
spec:
tenants:
- name: support-basic
dailyInputTokens: 120000000
dailyOutputTokens: 18000000
concurrentRequests: 80
maxSingleInputTokens: 12000
- name: quality-job
dailyInputTokens: 400000000
dailyOutputTokens: 50000000
concurrentRequests: 12
maxSingleInputTokens: 65536
allowedWindows:
- "20:00-08:30"
预算不是只为了省钱。它也是容量保护。离线租户不能在白天无限吃并发,在线租户不能把超长会话误发到短池。真正的降级不是一个冷冰冰的 429,而是能告诉调用方为什么失败以及可选路径是什么。
{
"error": {
"code": "inference_queue_paused",
"message": "批处理队列已暂停,在线摘要池 TTFT P95 超过阈值。",
"retry_after_seconds": 900,
"route": "summary-batch",
"fallback_options": ["retry_later", "short_summary_only"]
}
}
指标字段:不要再只问“模型慢不慢”
这次排障能推进,是因为我们把指标从总耗时拆开了。推理请求至少要看入口、排队、prefill、decode、传输和客户端取消。下面这些字段是我认为第一版必须有的。
| 字段 | 含义 |
|---|---|
trace_id | 贯穿业务、网关、模型运行时的请求 ID |
tenant | 租户或调用方,脱敏后可用于预算和拆账 |
task | 业务任务类型,比如实时摘要、批处理重算 |
model_alias | 业务请求的模型别名 |
model_version | 实际命中的模型版本 |
route_pool | 命中的推理池 |
route_reason | 路由规则命中的解释 |
input_tokens_estimated | 入口估算 token |
input_tokens_actual | 运行时实际 token |
output_tokens | 实际生成 token |
gateway_queue_ms | 网关排队时间 |
runtime_queue_ms | 模型运行时内部排队 |
prefill_ms | 输入处理耗时 |
decode_ms | 生成耗时 |
ttft_ms | 第一个 token 或首字节等待 |
tokens_per_second | decode 吞吐 |
kv_cache_used_ratio | KV cache 使用比例 |
gpu_memory_used_ratio | GPU 显存使用比例 |
fallback_reason | 降级或切池原因 |
client_cancelled | 客户端是否取消 |
Prometheus 指标可以长这样,标签别放太多高基数字段,trace_id 放日志和 trace,不放 metrics:
ai_gateway_requests_total{pool="summary-online",task="live_ticket_summary",status="ok"} 18422
ai_gateway_route_decisions_total{pool="summary-long",reason="input_tokens_gt_8192"} 912
ai_gateway_queue_seconds_bucket{pool="summary-online",le="0.5"} 16520
ai_gateway_ttft_seconds_bucket{pool="summary-online",le="2"} 18011
ai_gateway_rejections_total{code="budget_exceeded",tenant="quality-job"} 37
ai_runtime_kv_cache_used_ratio{pool="summary-long",pod="summarizer-long-0"} 0.81
ai_runtime_prefill_seconds_bucket{pool="summary-long",le="15"} 682
日志里则保留足够的单请求信息:
{
"ts": "2026-06-15T10:16:23.481+08:00",
"trace_id": "req_live_8c13",
"event": "inference.completed",
"tenant": "support-basic",
"task": "live_ticket_summary",
"model_alias": "summary-default",
"model_version": "summary-7b-2026-06-15",
"route_pool": "summary-online",
"route_reason": "rule=live-summary-short",
"input_tokens_estimated": 2864,
"input_tokens_actual": 2911,
"output_tokens": 318,
"gateway_queue_ms": 14,
"runtime_queue_ms": 81,
"prefill_ms": 436,
"decode_ms": 1120,
"ttft_ms": 612,
"tokens_per_second": 283.9,
"status": "ok"
}
没有这些字段,团队只能反复问“是不是模型慢”。有了这些字段,问题会具体很多:在线池排队是否变长,长上下文池 prefill 是否变慢,批处理是否在白天抢并发,某个模型版本是否 decode 掉速。
排障过程:从一条慢请求追到队列
事故当天我们按四步查。
第一步,拿客服反馈的时间段切在线任务的 TTFT。总延迟 P95 是 18 秒,但在线摘要的 TTFT P95 已经到 6.4 秒,P99 接近 21 秒。用户说“没反应”,对应的不是生成总耗时,而是 TTFT。
第二步,按 route_pool 分组。那时还没有拆池,所有请求都在 summary-default,但我们临时按 input_tokens_estimated 分桶。输入小于 8K 的请求,在批处理高峰时 runtime queue 明显变长。小请求本身 prefill 不慢,慢在进入模型前排队。
第三步,看运行时内部队列和 KV cache。几台 Pod 的连接数差不多,但 KV cache 使用差异很大。有的 Pod 正在处理两个 50K token 请求,新的短请求被排在后面。least request 看起来公平,实际把轻重请求混在一起了。
第四步,检查批处理调度。周末积压的质量重算任务没有截止时间控制,只要队列里有任务就持续发。它没有错,错的是平台没有给它低优先级通道。
这四步排完,结论就很清楚了:扩容可以缓解,但不会修复策略问题。只要长短请求继续混池,短请求仍然可能排在长请求后面。真正要改的是入口路由和队列优先级。
发布步骤:把网关变成一个可回滚的开关
推理网关改造最怕一次性切大流量。我们把发布拆成五个阶段,每个阶段都有明确退出条件。
第一个阶段只打标不分流。入口服务补 X-AI-Task、X-AI-Request-Class,网关估算 token,但仍然转发到旧服务。这样可以验证分类准确率和 token 估算误差。
stage: shadow-label
traffic: 100%
route_effect: disabled
write_headers:
- X-AI-Task
- X-AI-Request-Class
- X-AI-Input-Tokens-Estimated
验收条件是:95% 以上请求有任务标签;估算 token 和运行时实际 token 的 P90 误差小于 18%;未知任务比例低于 1%。达不到就不能分流。
第二个阶段部署新池但不接生产流量。用回放流量和合成请求压测两个池。短池关注 TTFT,长池关注完成率和显存水位。
kubectl apply -f k8s/inference/summarizer-online.yaml
kubectl apply -f k8s/inference/summarizer-long.yaml
kubectl apply -f k8s/inference/inference-route-shadow.yaml
第三个阶段只让 5% 在线短请求进入 summary-online。长上下文和批处理仍走旧路径。这个阶段不是为了省资源,而是验证新路由链路的监控、日志、错误语义和超时。
canary:
match:
requestClass: interactive
inputTokensLte: 8192
percent: 5
routeTo: summary-online
stickyBy: tenant
第四个阶段扩大在线短请求到 50%,同时让长请求进入 summary-long。批处理仍然不动。原因很简单:在线链路先稳定,再处理离线吞吐。
第五个阶段才接入 summary-batch,并给批处理加暂停规则。这个阶段要盯着白天在线 TTFT,只要超过阈值,批处理暂停,不争辩。
每个阶段都用配置版本号标记:
metadata:
annotations:
ai.example.com/route-config-version: "support-summary-2026-06-15-r3"
版本号看起来琐碎,但回滚时救命。出了问题,大家讨论的是从 r3 回到 r2,不是在一堆手工改动里猜当前状态。
回滚方式:不要把回滚设计成删 Deployment
这类系统回滚有三层,必须分开。
第一层是路由回滚。把流量切回旧服务或旧池,正在运行的请求继续完成。这个回滚最快,通常几十秒内生效。
kubectl patch inferenceroute support-summary-route \
--type merge \
-p '{"spec":{"globalMode":"legacy_passthrough"}}'
第二层是模型别名回滚。如果新模型版本输出质量有问题,把 summary-default 从新版本切回旧版本,但保持网关和池不变。
modelAliases:
summary-default:
defaultVersion: summary-7b-2026-06-10
rollbackFrom: summary-7b-2026-06-15
第三层是运行时回滚。只有在 Pod 崩溃、显存泄漏、运行时队列异常时才回滚 Deployment 镜像。
kubectl rollout undo deployment/summarizer-online
kubectl rollout undo deployment/summarizer-long
不要把这三件事混在一起。很多推理平台出事故时,团队一着急就删新服务、重启 Pod、改模型名,最后连到底哪一步修好了都说不清。网关层的价值之一,就是把流量策略、模型版本和运行时发布拆开。
流式请求还要额外注意连接耗尽。即使摘要接口多数是非流式,平台里通常还有问答或生成类任务。Pod 下线前要停止接新请求,保留足够 terminationGracePeriodSeconds,网关要识别 draining 状态。否则发布时会制造半截回答。
验收标准:别用“感觉快了”
上线后我们没有用“用户反馈少了”作为唯一判断,而是提前写了验收标准。下面这组数值是示例,真实系统要按自己的设备、模型和业务调。
| 指标 | 改造前 | 验收线 |
|---|---|---|
| 在线摘要 TTFT P95 | 6.4s | 小于 1.8s |
| 在线摘要总耗时 P95 | 18s | 小于 7s |
| 在线摘要 runtime queue P95 | 4.9s | 小于 600ms |
| 长上下文摘要完成率 | 94.1% | 大于 98% |
| 批处理白天暂停生效率 | 无 | 5 分钟内生效 |
| 路由日志完整率 | 61% | 大于 99% |
| 未知任务比例 | 7.8% | 小于 1% |
| 回滚演练耗时 | 未演练 | 小于 3 分钟 |
还有几条质量验收不能省。
在线摘要不能因为换短池而明显缩水。我们抽样 300 条工单,让人工标注“问题、处理进展、待办、风险标签”是否完整,要求新旧差异在可接受范围内。长上下文请求不能静默截断,超过限制必须返回明确错误或进入长池。批处理暂停后不能丢任务,只能延迟执行。
验收时也要看成本。短池用小模型以后,单次在线摘要成本下降,但长池更贵。最终判断不是只看 GPU 利用率,而是看单位有效摘要成本、在线体验和批处理完成时限三者是否同时达标。
后来真正有用的,不是“网关”这个名字
这次改造之后,系统形状变了。业务方还是调同一个摘要接口,但平台内部已经不再把所有请求当成一样的 HTTP 流量。在线摘要、长上下文摘要、离线批处理有了不同队列、不同模型池、不同预算和不同 SLO。
最有用的几个变化很朴素。
请求进入系统时就知道自己大概有多重。日志能说明它为什么被路由到某个池。批处理不再和在线请求争抢同一条队列。模型升级可以通过别名灰度,不需要业务代码跟着发版。回滚先回滚路由,再看是否需要回滚模型或运行时。
这也是我现在判断 AI Gateway 是否值得做的标准。不是看它支持多少花哨 CRD,也不是看控制面多复杂,而是看它能不能让推理系统回答几个生产问题:这条请求为什么慢,为什么到了这个模型,谁在排队,谁在消耗预算,出问题时能不能在几分钟内退回去。
普通网关没有错。只是 LLM 推理的负载单位不是请求数,而是 token、队列、上下文、缓存和模型版本。等这些东西开始影响用户体验时,推理流量就该有自己的入口层了。