AI Gateway 上线之后:推理流量为什么需要自己的网关
原创 · 约 38 分钟阅读 · 阅读 --
Last updated on

AI Gateway 上线之后:推理流量为什么需要自己的网关

作者: Alex Xiang


古董级程序员,大厂出来后一直在创业公司,现在仍在一线做 AI 相关的工程。更完整的技术记录写在微信公众号「字与码」:工作经历、对新工具的看法,以及这些年踩过的坑,会不定期发在那里。若这篇对你有用,欢迎顺手关注。

一套客服摘要平台刚上线时,最容易让人误判的指标是 QPS。它看上去像一个普通接口:客服系统把对话记录发过来,模型返回摘要、情绪、待办和质检标签。入口是 /v1/chat/completions,后面是几组推理 Pod,Kubernetes 上挂一个 Gateway,按连接数转发。

问题出在第二周。周一上午,在线客服开始抱怨摘要按钮要等很久,后台看 GPU 利用率却不算满,错误率也没有明显升高。更麻烦的是,慢只发生在一部分请求上。短会话有时 2 秒返回,有时卡 20 秒。长上下文批处理本来放在夜里跑,周末积压以后延续到了白天,把在线摘要拖进了同一条队列。

这篇文章不想再抽象地讲“AI Gateway 很重要”。我按这个可复制的案例往下拆:一个客服摘要平台如何在 Kubernetes 上把长上下文批处理和在线摘要拆成不同推理池,如何让网关按 token、任务类型和队列状态路由,如何记录可排障的指标,如何灰度、回滚和验收。

Kubernetes AI Gateway 推理路由示意图

事故现场:不是 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_seconddecode 吞吐
kv_cache_used_ratioKV cache 使用比例
gpu_memory_used_ratioGPU 显存使用比例
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-TaskX-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 P956.4s小于 1.8s
在线摘要总耗时 P9518s小于 7s
在线摘要 runtime queue P954.9s小于 600ms
长上下文摘要完成率94.1%大于 98%
批处理白天暂停生效率5 分钟内生效
路由日志完整率61%大于 99%
未知任务比例7.8%小于 1%
回滚演练耗时未演练小于 3 分钟

还有几条质量验收不能省。

在线摘要不能因为换短池而明显缩水。我们抽样 300 条工单,让人工标注“问题、处理进展、待办、风险标签”是否完整,要求新旧差异在可接受范围内。长上下文请求不能静默截断,超过限制必须返回明确错误或进入长池。批处理暂停后不能丢任务,只能延迟执行。

验收时也要看成本。短池用小模型以后,单次在线摘要成本下降,但长池更贵。最终判断不是只看 GPU 利用率,而是看单位有效摘要成本、在线体验和批处理完成时限三者是否同时达标。

后来真正有用的,不是“网关”这个名字

这次改造之后,系统形状变了。业务方还是调同一个摘要接口,但平台内部已经不再把所有请求当成一样的 HTTP 流量。在线摘要、长上下文摘要、离线批处理有了不同队列、不同模型池、不同预算和不同 SLO。

最有用的几个变化很朴素。

请求进入系统时就知道自己大概有多重。日志能说明它为什么被路由到某个池。批处理不再和在线请求争抢同一条队列。模型升级可以通过别名灰度,不需要业务代码跟着发版。回滚先回滚路由,再看是否需要回滚模型或运行时。

这也是我现在判断 AI Gateway 是否值得做的标准。不是看它支持多少花哨 CRD,也不是看控制面多复杂,而是看它能不能让推理系统回答几个生产问题:这条请求为什么慢,为什么到了这个模型,谁在排队,谁在消耗预算,出问题时能不能在几分钟内退回去。

普通网关没有错。只是 LLM 推理的负载单位不是请求数,而是 token、队列、上下文、缓存和模型版本。等这些东西开始影响用户体验时,推理流量就该有自己的入口层了。