你的 nginx 在扼杀 AI 服务——为什么需要重新设计流量层

59 阅读7分钟

四个数字,定义了这篇文章要讨论的问题:

3 秒:用户能接受的最长等待时间,超过这个阈值流失率急剧上升。

47 秒:一个 70B 模型在 A100 上完成一次完整推理的中位时间。

0.3 秒:同一个模型输出第一个 token 的时间。

$2.48:一块 A100 GPU 每小时的按需定价。如果它在凌晨三点空转,这笔钱就消失了。

这四个数字的张力,就是 AI 基础设施最核心的工程问题:用户要求即时响应,模型需要漫长思考,算力必须精确调度,而传统流量层对这一切一无所知。


目录

  1. 一个请求的生死:nginx 在做什么
  2. 第一个断层:响应不是一个包,是一条河流
  3. 第二个断层:后端可能还不存在
  4. 第三个断层:你永远不知道新模型有没有变傻
  5. 第四个断层:连接不是用完就扔的
  6. 第五个断层:推理失败的方式和 HTTP 500 不同
  7. 重新设计:AI 流量层需要什么
  8. A3S Gateway 怎么应对这五个断层
  9. 和现有方案的真实对比
  10. 实战:为 AI 后端配置完整代理
  11. 弹性扩缩容:数字背后的原理

1. 一个请求的生死:nginx 在做什么

让我们从最基础的问题开始:当一个请求进入 nginx 时,nginx 在做什么?

客户端  ──→  nginx  ──→  后端  ──→  nginx  ──→  客户端
               ↑                       ↑
          收到完整响应             转发给客户端

nginx 的核心模型是代理缓冲(proxy buffering)。它的默认行为是:

  1. 从上游接收完整的响应体
  2. 缓存到本地内存或临时文件
  3. 再把缓存的内容发给客户端

这个设计在 2004 年非常合理。HTTP 响应是静态文件、数据库查询结果、模板渲染输出——它们在生成时就已经完整,只是需要一个缓冲来应对客户端网络抖动。

但 LLM 的响应不是这样的。

一个 LLM 推理服务器的行为更像这样:

后端(vLLM / llama.cpp):
  t=0ms:  收到请求,开始推理
  t=300ms:生成第一个 token:"当"
  t=400ms:生成第二个 token:"然"
  t=500ms:生成第三个 token:","
  ...
  t=47000ms:生成最后一个 token,推理完成

如果 nginx 启用了代理缓冲(默认是启用的),用户看到的是:

用户侧:
  t=0ms:发送请求
  t=47300ms:收到完整的 4096 个 token

47 秒的白屏。然后文字如瀑布倾泻而下。

用户已经关掉标签页了。

实际上 nginx 提供了关闭缓冲的方式:proxy_buffering off。但这只是个开始——当你真正在生产环境运行 AI 服务时,你会发现这是五个断层里最好解决的那一个。


2. 第一个断层:响应不是一个包,是一条河流

关掉 proxy buffering 之后,流式传输看起来解决了。但"流式传输"这个词掩盖了很多细节。

SSE(Server-Sent Events)是 LLM 流式输出的标准协议。一个规范的 SSE 流长这样:

data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"当"},"index":0}]}

data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"然"},"index":0}]}

data: [DONE]

每一行是一个事件,由两个换行符分隔。问题在于:TCP 不保证包边界。在高并发下,网络栈可能把多个 SSE 事件合并成一个 TCP 包,也可能把一个事件拆成多个包。

一个"关闭了代理缓冲"的 nginx 做的事情是:把从 upstream 收到的字节原样转发。这在大多数情况下能工作,但:

  • 连接保活(keepalive):nginx 需要知道什么时候一个响应结束、下一个开始。对于普通 HTTP,这由 Content-LengthTransfer-Encoding: chunked 控制。对于 SSE,连接在整个对话期间保持打开——nginx 的默认超时可能在模型还在思考的时候就切断连接。
  • 内存压力下的降级:当 nginx 的内存池满了(比如同时有 500 个流式请求),它会悄悄地把缓冲打开。你的监控看到的是正常的 200 响应,用户看到的是延迟突然跳升。
  • 响应大小无法预测:nginx 的 proxy_max_temp_file_size 有默认上限。一个长对话的完整 token 流可能超过这个限制。

真正的零缓冲流式传输需要在整个代理层的设计上就把流当作一等公民——而不是在 Web 代理的基础上打补丁。

从实现角度看,区别非常具体:

// 零缓冲的 SSE 转发:收到什么就发什么,不累积
async fn forward_streaming(
    mut upstream: Response<Incoming>,
    sender: &mut ResponseSender,
) {
    while let Some(chunk) = upstream.body_mut().frame().await {
        if let Ok(frame) = chunk {
            // 每一帧立刻发出,不等下一帧
            sender.send_data(frame.into_data().unwrap()).await.ok();
        }
    }
}

与之对比,一个缓冲式代理:

// 缓冲式代理:等全部到齐再发
let body_bytes = hyper::body::to_bytes(upstream.body_mut()).await?;
// 用户在这里等待了整个推理时间
response.body(body_bytes)

这是架构上的选择,不是配置项。


3. 第二个断层:后端可能还不存在

凌晨三点,没有用户访问你的 LLM 服务。Kubernetes 的 HPA 把 GPU 实例缩容到零——因为保留一块 A100 全天待机,每月大约要多花 1800 美元。

早上九点,第一个用户打开对话框,输入一句话,按下发送。

这个请求到达网关时,后端有多少个健康实例?零个。

nginx 会返回什么?502 Bad Gateway

用户会怎么做?刷新,再试,还是 502。如果是企业内部工具,他们会去 Slack 里问"服务挂了吗"。如果是面向消费者的产品,他们大概直接离开了。

这个问题的根源不在于 Kubernetes 的配置,也不在于 HPA 的策略——它在于网关对"后端不存在"这件事的处理方式。

传统网关的心智模型是:后端总是在那里。网关是流量的搬运工,不是调度中心。当后端不在时,唯一的选项是报错。

AI 服务需要不同的心智模型:请求可以等待

不是无限等待——你需要一个合理的超时和队列深度。但在模型启动期间(通常是 30-60 秒),请求应该在内存中排队,而不是被直接丢弃。这个模式叫做冷启动缓冲(cold-start buffering)

用户请求 (09:00:00)
    ↓
网关:发现后端为零 → 触发扩容 → 请求进入内存队列
    ↓
Kubernetes 拉起 GPU 实例 (09:00:45)
    ↓
实例通过健康检查 (09:01:00)
    ↓
网关从队列取出请求 → 发给后端 → 用户在 09:01:03 收到第一个 token

用户感受到的是 63 秒的"思考中",而不是一个 502 错误。这是体验上的天壤之别。

这个能力要求网关具备对扩缩容系统的感知——它必须知道什么时候触发扩容、什么时候后端就绪、队列里的请求如何重放。这些是 nginx 从未被设计来处理的事情。


4. 第三个断层:你永远不知道新模型有没有变傻

软件部署有一个救命稻草:代码是静态的,可以被完整地测试。你在 CI 里跑单元测试、集成测试、端到端测试,如果全部通过,你有理由相信部署是安全的。

模型没有这个救命稻草。

你可以有一个 eval 套件,在 1000 道题上验证准确率从 87% 提升到了 89%。但真实用户的问题是长尾分布的,你的 eval 覆盖了多少长尾?当用户用自己的语言、自己的上下文提问时,新模型的行为是什么?

没有任何静态测试能回答这个问题。 唯一的答案在真实流量里。

这就是为什么 AI 团队需要灰度发布——不是 Web 开发里那种"新代码和旧代码跑同样的逻辑"的蓝绿部署,而是真实地把一部分用户的请求路由到新模型,观察它在野外的表现。

但灰度发布本身是危险的,除非配合自动回滚

发布 v2(新模型):
  第 1 分钟:v1 接收 98% 流量,v2 接收 2%
    → 观察 v2 错误率:0.8%(正常),延迟:1.2s(正常)2 分钟:v1 接收 90%,v2 接收 10%
    → 观察 v2 错误率:1.1%(正常),延迟:1.3s(正常)3 分钟:v1 接收 80%,v2 接收 20%
    → 观察 v2 错误率:8.7% ← 超过阈值 5%
    → 自动回滚:v1 接收 100% 流量,v2 下线
    → 告警发送给 on-call

这个能力需要网关在流量层做版本感知、指标聚合、阈值判断,这是在 nginx 上永远不可能用配置文件实现的。

还有一个更早期的验证手段:流量镜像。在你把任何流量路由给新模型之前,先复制 5% 的真实请求发给它,但只把主模型的回复返回给用户。新模型的回复被丢弃,但你可以记录下来做离线分析——它在真实流量上的表现是什么?和主模型的分歧在哪里?

这是唯一能在"零风险"条件下验证新模型质量的方式。


5. 第四个断层:连接不是用完就扔的

传统 HTTP API 的生命周期:

客户端发送请求 → 服务器处理 → 返回响应 → 连接关闭

每个请求是独立的。连接是短暂的。网关是无状态的路由器。

AI 应用的连接有不同的形态:

对话式 AI:用户和模型之间的对话可能持续几十分钟。如果用 HTTP 实现,每一轮对话都是一个独立请求,这没问题。但如果用 WebSocket——因为你需要双向推送,比如在模型还在生成时,用户可以发送"停止"指令——网关需要维护这个长连接的状态,而不是在连接建立后就把它当成普通 TCP 流。

流式 Agent:一个 AI Agent 可能在执行任务期间持续地向客户端推送进度。这不是请求-响应,这是一个持续数分钟的事件流。

实时语音:语音 AI 需要双向低延迟流——用户说话时上行音频,模型输出时下行音频。这是 WebSocket 或 QUIC,不是 HTTP。

传统网关把 WebSocket 当作需要被"支持"的特殊案例。但在 AI 应用里,持久连接是常态,短暂的请求-响应才是特例。


6. 第五个断层:推理失败的方式和 HTTP 500 不同

一个 Web API 失败,通常是因为:

  • 数据库宕机
  • 代码抛出了异常
  • 依赖服务超时

这些故障是快速的:请求在几百毫秒内失败,网关的超时和重试策略可以处理它们。

AI 推理的故障模式完全不同:

  • 显存溢出(OOM):模型在处理一个特别长的上下文时耗尽显存。请求不会立刻失败——它可能先变慢(GPU 开始 swap),然后在 30 秒后返回一个空响应或 500。
  • 输出退化:模型开始生成乱码或无限重复的内容。从 HTTP 角度看,这是一个成功的 200 响应——但它是有害的。
  • 推理超时:一个复杂推理请求可能正常地需要 2 分钟,但有时会陷入某种循环,永远不结束。网关的超时需要区分"正常慢请求"和"卡死的请求"。

这意味着网关的健康判断不能只依赖 HTTP 状态码。被动健康检查(根据实际请求的成功率来判断后端健康状态)比主动 /health 探针更能反映 AI 后端的真实状态。

当一个后端开始频繁出现 OOM 或超时,网关需要自动减少发往这个实例的流量,甚至暂时将其从负载均衡池中移除——不是等它健康检查失败,而是根据实时的错误率和延迟。


7. 重新设计:AI 流量层需要什么

把上面五个断层放在一起,AI 原生网关需要从设计上解决这五件事:

零缓冲流式传输

不是"支持 SSE",而是在内存模型上把流当作一等公民。每一个字节从上游到达的瞬间就转发出去,不经过任何本地缓冲区。这要求代理层的底层实现使用异步 I/O 和零拷贝转发。

冷启动请求缓冲

网关必须知道后端的当前副本数,并在副本为零时触发扩容、将请求放入内存队列。当副本就绪时,队列中的请求必须以正确的顺序重放,并携带原始的超时 deadline(已经等了 30 秒的请求,不应该再有完整的推理超时)。

版本感知的流量分割与自动回滚

网关需要维护每个后端版本的独立指标(错误率、延迟分位数),并根据配置的阈值决定是继续推进、暂停、还是回滚。这个决策循环必须在网关内部闭合,不能依赖外部系统的协调。

持久连接作为一等公民

WebSocket 握手、协议升级、双向流转发——这些必须和 HTTP 代理使用同样高效的代码路径,而不是挂在 HTTP 代理后面的 hack。

基于实时行为的被动健康管理

主动探针 + 被动错误率追踪,两者都要。当一个实例的错误率在过去 60 秒内超过阈值,它应该从负载均衡池中被暂时移除,直到错误率恢复正常。


8. A3S Gateway 怎么应对这五个断层

零缓冲 SSE 转发

A3S Gateway 对流式请求使用独立的 streaming client,基于 reqwest 的流式响应接口,配合 tcp_nodelay 和 90 秒的连接池保活:

// 检测到 SSE/streaming 请求时,切换到零缓冲路径
let is_sse = is_streaming_request(req.headers());
if is_sse {
    // streaming_client 不累积响应体
    // 每个 chunk 从上游到达即转发
    return stream_response(streaming_client, req, backend).await;
}

每个 token 从模型产出到客户端收到,中间没有任何缓冲层。

冷启动请求缓冲

min_replicas = 0 时,网关在副本为零时将请求放入有界队列(RequestBuffer),触发扩容,等待副本通过健康检查,然后重放请求:

services "llm-backend" {
  scaling {
    min_replicas          = 0      # 允许缩容到零
    max_replicas          = 4
    container_concurrency = 10     # 每副本最多并发 10 个请求
    buffer_enabled        = true   # 启用冷启动缓冲
    executor              = "box"  # 使用 A3S Box 管理副本
  }
}

扩容触发使用 Knative 的计算公式:

desired_replicas = ⌈ (in_flight + queue_depth) / (container_concurrency × target_utilization) ⌉

版本流量分割与自动回滚

services "llm-service" {
  revisions = [
    { name = "v1", traffic_percent = 95, servers = [{ url = "http://v1:8080" }] },
    { name = "v2", traffic_percent = 5,  servers = [{ url = "http://v2:8080" }] },
  ]

  rollout {
    from                 = "v1"
    to                   = "v2"
    step_percent         = 10          # 每步增加 10%
    step_interval_secs   = 60          # 每 60 秒一步
    error_rate_threshold = 0.05        # 错误率超过 5% 触发回滚
    latency_threshold_ms = 5000        # p99 超过 5s 触发回滚
  }
}

流量分割和回滚决策在网关内部闭合,不依赖外部控制平面。

流量镜像

services "llm-service" {
  mirror {
    service    = "llm-v2-shadow"  # 影子后端
    percentage = 10               # 复制 10% 的真实请求
  }
  # 镜像是 fire-and-forget:
  # - 不等待影子后端的响应
  # - 不把影子后端的错误暴露给用户
  # - 镜像请求异步发出,不影响主路径延迟
}

被动健康管理

每个后端实例有一个独立的错误率追踪器。当实例的错误率在滑动窗口内超过阈值,它被标记为不健康,从负载均衡池中移除。当错误率恢复,它重新加入:

services "llm-service" {
  load_balancer {
    strategy = "least-connections"  # 主动选择负载最低的实例
    health_check {
      path     = "/health"
      interval = "10s"
    }
  }
}
# 被动健康检查总是开启的:
# 连续 5 次 5xx 或超时 → 实例暂时移出负载均衡
# 连续 2 次成功 → 实例重新加入

9. 和现有方案的真实对比

nginxTraefikEnvoyA3S Gateway
SSE 零缓冲需手动配置,有隐患支持支持原生,架构级保证
冷启动请求缓冲
版本流量分割需配合 Istio✓(内置)
自动回滚需配合外部系统✓(内置)
流量镜像有限支持有限支持支持
被动健康检查有限有限支持
配置热重载✗(需 reload 进程)✓(零停机)
部署复杂度简单简单需要控制平面简单(单二进制)
运行时依赖OpenSSLGo runtime动态链接无(静态链接 Rust)

Envoy 在技术能力上最接近,但它的使用成本是隐性的高:你需要一个控制平面(Istio、xDS API),需要 Kubernetes,需要一个理解 Envoy 配置模型的工程师。对于一个把 AI 推理作为核心业务的团队来说,维护一套完整的 Service Mesh 是额外的认知负担。

A3S Gateway 的设计取舍是:只做 AI 服务流量层需要的事,用 HCL 配置文件完整描述,单个二进制部署。不需要数据库、不需要控制平面、不需要 Kubernetes(尽管支持)。


10. 实战:为 AI 后端配置完整代理

到这里,我们已经理解了为什么需要 AI 原生网关。下面是一个完整的落地示例:把 A3S Gateway 部署在一个运行 Ollama 的 LLM 服务前面,覆盖认证、限流、熔断、流式传输和弹性扩缩容。

第一步:写 gateway.hcl

这份配置代理一个本地 Ollama 实例,开放给外部调用。它加了 JWT 认证、每分钟 60 次的请求限流、熔断器,以及 TLS 终止:

# gateway.hcl

# ── 入口点 ──────────────────────────────────────────────────────────────
entrypoints "web" {
  address = "0.0.0.0:8080"   # HTTP(开发/内网)
}

entrypoints "websecure" {
  address = "0.0.0.0:443"    # HTTPS(生产)
  tls {
    cert_file = "/etc/certs/fullchain.pem"
    key_file  = "/etc/certs/privkey.pem"
  }
}

# ── 路由 ──────────────────────────────────────────────────────────────────
# /v1/** → Ollama(OpenAI 兼容 API)
routers "llm-api" {
  rule        = "PathPrefix(`/v1`)"
  service     = "ollama"
  entrypoints = ["websecure"]
  middlewares = ["jwt-auth", "rate-limit", "circuit-breaker"]
}

# /ws/** → WebSocket 实时推理(Agent 场景)
routers "llm-ws" {
  rule        = "PathPrefix(`/ws`)"
  service     = "ollama"
  entrypoints = ["websecure"]
  middlewares = ["jwt-auth"]
}

# ── 后端服务 ──────────────────────────────────────────────────────────────
services "ollama" {
  load_balancer {
    strategy = "least-connections"   # 优先把请求送给当前负载最低的实例
    servers = [
      { url = "http://127.0.0.1:11434", weight = 1 },
    ]
    health_check {
      path     = "/api/version"      # Ollama 的健康端点
      interval = "15s"
    }
  }

  # 将 3% 的真实请求镜像到新版本模型,用于离线质量对比
  mirror {
    service    = "ollama-next"
    percentage = 3
  }

  # 弹性扩缩容:空闲时缩容到零,请求到来时自动扩容
  scaling {
    min_replicas          = 0        # 允许缩容到零
    max_replicas          = 4        # 最多 4 个并行推理实例
    container_concurrency = 4        # 每个实例最多同时处理 4 个请求
    target_utilization    = 0.7      # 目标利用率 70%
    buffer_enabled        = true     # 冷启动时缓冲请求,不返回 502
    executor              = "box"    # 由 A3S Box 管理实例生命周期
  }
}

# 影子后端:接收镜像流量,不影响主链路
services "ollama-next" {
  load_balancer {
    strategy = "round-robin"
    servers  = [{ url = "http://127.0.0.1:11435" }]
  }
}

# ── 中间件 ────────────────────────────────────────────────────────────────
middlewares "jwt-auth" {
  type  = "jwt"
  value = "${JWT_SECRET}"            # 从环境变量读取密钥
}

middlewares "rate-limit" {
  type  = "rate-limit"
  rate  = 60                         # 每分钟 60 次(令牌桶)
  burst = 10                         # 突发上限
}

middlewares "circuit-breaker" {
  type              = "circuit-breaker"
  failure_threshold = 3              # 连续 3 次失败 → 开启熔断
  cooldown_secs     = 30             # 30 秒后进入半开状态
  success_threshold = 2              # 2 次成功 → 关闭熔断,恢复正常
}

# ── 配置热重载 ────────────────────────────────────────────────────────────
providers {
  file {
    watch     = true                 # 检测到文件变更自动重载,无需重启
    directory = "/etc/gateway/conf.d/"
  }
}

保存为 gateway.hcl,然后:

a3s-gateway --config gateway.hcl

网关立刻开始监听,配置文件的任何变更会在几毫秒内生效。


第二步:打包成 Docker 镜像

如果你想把网关打包进一个容器(而不是直接用 Homebrew 安装的二进制),可以用下面这个 Dockerfile。注意它是两阶段构建——编译阶段用 Rust 工具链,运行阶段只需要一个 Alpine 底包:

# ── 编译阶段 ──────────────────────────────────────────────────────────────
FROM rust:alpine AS builder

RUN apk add --no-cache musl-dev cmake make perl g++ linux-headers

WORKDIR /build

# 先复制 Cargo 清单预热依赖缓存(层缓存优化)
COPY Cargo.toml Cargo.lock ./
RUN mkdir -p src && echo 'fn main(){}' > src/main.rs && touch src/lib.rs \
    && cargo build --release 2>/dev/null || true \
    && rm -rf src

# 再复制真实源码编译
COPY src/ src/
RUN touch src/main.rs src/lib.rs && cargo build --release

# ── 运行阶段 ──────────────────────────────────────────────────────────────
FROM alpine:3

RUN apk add --no-cache ca-certificates tzdata \
    && addgroup -S gateway && adduser -S gateway -G gateway

COPY --from=builder /build/target/release/a3s-gateway /usr/local/bin/a3s-gateway
COPY gateway.hcl /etc/a3s-gateway/gateway.hcl

USER gateway

EXPOSE 8080 443

ENTRYPOINT ["a3s-gateway", "--config", "/etc/a3s-gateway/gateway.hcl"]

构建并运行:

docker build -t my-llm-gateway:latest .

docker run -d \
  -p 8080:8080 \
  -p 443:443 \
  -v $(pwd)/certs:/etc/certs:ro \
  -e JWT_SECRET=your-secret \
  my-llm-gateway:latest

最终镜像大约 12MB,没有任何运行时依赖。


第三步:用 A3S Box 部署(单机沙箱)

A3S Box 是一个基于 microVM 的沙箱运行时。在不需要完整 Kubernetes 的场景下——比如边缘节点、开发机、或者资源受限的单台服务器——可以用 Box 替代 Docker Compose 来管理网关和 LLM 实例的生命周期。

Box 的配置也是 HCL。创建 box.hcl

# box.hcl — 在 microVM 沙箱中运行网关 + Ollama

workloads "gateway" {
  binary = "/usr/local/bin/a3s-gateway"
  args   = ["--config", "/etc/gateway/gateway.hcl"]

  resources {
    memory_mb = 512
    cpus      = 2
  }

  ports = [8080, 443]

  env = {
    JWT_SECRET = "${JWT_SECRET}"
    RUST_LOG   = "info,a3s_gateway=debug"
  }

  mounts {
    host     = "./gateway.hcl"
    guest    = "/etc/gateway/gateway.hcl"
    readonly = true
  }

  mounts {
    host     = "./certs"
    guest    = "/etc/certs"
    readonly = true
  }

  # 网关进程崩溃时自动重启
  restart = "always"
}

workloads "ollama" {
  binary = "/usr/local/bin/ollama"
  args   = ["serve"]

  resources {
    memory_mb = 8192    # 7B 量化模型约需 6GB
    cpus      = 4
  }

  ports = [11434]

  env = {
    OLLAMA_MODELS = "/models"
  }

  mounts {
    host  = "/data/models"
    guest = "/models"
  }
}

启动:

# 安装 A3S Box
brew install a3s-lab/tap/a3s-box

# 启动所有 workload
a3s-box run --config box.hcl

# 查看状态
a3s-box status

# 查看网关日志
a3s-box logs gateway

Box 的 microVM 隔离意味着:即使 Ollama 因为 OOM 崩溃,网关进程不受影响——它会触发冷启动缓冲,等 Box 把 Ollama 重启起来后,再把队列里的请求重放出去。


第四步:用 Helm 部署到 Kubernetes

对于需要高可用和水平扩展的生产环境,Helm chart 是推荐部署方式。

先准备 values-prod.yaml,把完整的 HCL 配置嵌进去:

# values-prod.yaml
image:
  repository: ghcr.io/a3s-lab/gateway
  tag: "0.2.2"
  pullPolicy: Always

replicaCount: 2          # 网关本身跑 2 个副本做高可用

service:
  type: LoadBalancer     # 云厂商 LB,或者配合 ingress-nginx
  port: 8080

config: |
  entrypoints "web" {
    address = "0.0.0.0:8080"
  }

  routers "llm-api" {
    rule        = "PathPrefix(`/v1`)"
    service     = "ollama"
    middlewares = ["jwt-auth", "rate-limit", "circuit-breaker"]
  }

  services "ollama" {
    load_balancer {
      strategy = "least-connections"
      servers  = [
        { url = "http://ollama-svc.ai.svc.cluster.local:11434" },
      ]
      health_check {
        path     = "/api/version"
        interval = "15s"
      }
    }
    scaling {
      min_replicas          = 0
      max_replicas          = 4
      container_concurrency = 4
      target_utilization    = 0.7
      buffer_enabled        = true
      executor              = "kube"   # 在 K8s 中用 kube executor 管理 Pod 副本
    }
  }

  middlewares "jwt-auth" {
    type  = "jwt"
    value = "${JWT_SECRET}"
  }

  middlewares "rate-limit" {
    type  = "rate-limit"
    rate  = 60
    burst = 10
  }

  middlewares "circuit-breaker" {
    type              = "circuit-breaker"
    failure_threshold = 3
    cooldown_secs     = 30
    success_threshold = 2
  }

部署:

# 克隆仓库(或者 brew install 后找到 chart 路径)
git clone https://github.com/A3S-Lab/Gateway.git
cd Gateway

# 安装
helm install llm-gateway deploy/helm/a3s-gateway \
  -f values-prod.yaml \
  --namespace ai \
  --create-namespace \
  --set-string "extraEnv[0].name=JWT_SECRET" \
  --set-string "extraEnv[0].valueFrom.secretKeyRef.name=llm-secrets" \
  --set-string "extraEnv[0].valueFrom.secretKeyRef.key=jwt-secret"

# 升级配置(热重载,不重启 Pod)
helm upgrade llm-gateway deploy/helm/a3s-gateway \
  -f values-prod.yaml \
  --namespace ai

验证:

# 查看网关 Pod 状态
kubectl get pods -n ai

# 检查仪表盘
kubectl port-forward -n ai svc/llm-gateway 9090:8080
curl http://localhost:9090/api/gateway/health    # 健康状态
curl http://localhost:9090/api/gateway/routes    # 当前路由表
curl http://localhost:9090/api/gateway/metrics   # Prometheus 指标

# 测试流式推理(应该立刻看到 token 逐个返回)
curl -N http://localhost:9090/v1/chat/completions \
  -H "Authorization: Bearer $JWT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"model":"llama3","messages":[{"role":"user","content":"你好"}],"stream":true}'

11. 弹性扩缩容:数字背后的原理

Knative 的扩缩容公式看起来很简单,但它的每一个参数都有具体的物理含义。把这些含义理解清楚,才能在真实场景里设对参数。

公式

desired_replicas = ⌈ (in_flight + queue_depth) / (container_concurrency × target_utilization) ⌉
变量含义
in_flight当前所有实例正在处理的请求总数
queue_depth等待分配给实例的请求数(冷启动缓冲队列)
container_concurrency每个实例允许同时处理的最大请求数
target_utilization目标利用率(0 到 1),预留一定余量应对突发流量

container_concurrency 是最关键的参数,它必须根据你的模型和硬件来设定,而不是拍脑袋决定。一个经验公式:

container_concurrency ≈ GPU显存 / 单请求峰值显存占用

例如:24GB 显存的 GPU,运行 7B Q4 模型(约 5GB),单请求峰值 KV-cache 约 2GB,那么:

container_concurrency ≈ (24 - 5) / 29

把它设为 8 比较保守(留余量给系统开销)。

三个场景走一遍

场景一:空闲 → 首个请求到来(冷启动)

初始状态:replicas = 0,in_flight = 0,queue_depth = 0
          desired = ⌈0 / (8 × 0.7)⌉ = 0t=0s:第 1 个请求到达
      replicas = 0,in_flight = 0,queue_depth = 1
      desired = ⌈1 / 5.6⌉ = ⌈0.18⌉ = 1
      → 触发扩容:启动 1 个实例,请求进入缓冲队列

t=45s:实例通过健康检查,replicas = 1
       queue 里的请求弹出 → 发送给实例
       用户收到第一个 token

用户等了 45 秒,看到的不是错误,而是正常的推理响应。

场景二:负载突增(需要扩容)

当前状态:replicas = 1,container_concurrency = 8,target_utilization = 0.7
          有效容量 = 8 × 0.7 = 5.6(向上取整:实际扩容在超过 6 个请求时触发)

突增到 20 个并发请求:
  in_flight = 8(当前实例已满)
  queue_depth = 12(等待分配)
  desired = ⌈(8 + 12) / 5.6⌉ = ⌈20 / 5.6⌉ = ⌈3.57⌉ = 4

→ 从 1 个实例扩容到 4 个
→ 4 个实例有效容量 = 4 × 5.6 = 22.4,可以容纳 20 个并发请求
→ 等待中的 12 个请求在新实例就绪后(约 45s)依次发送

注意:target_utilization = 0.7 保留了 30% 的余量,这意味着当实例利用率到达 70% 时就开始扩容,而不是等到 100% 再扩。这正是应对 LLM 推理时延高的关键——如果等满了再扩,新实例就绪之前的这段时间,所有请求都在等。

场景三:流量下降(缩容到零)

高峰结束:replicas = 4,in_flight = 2,queue_depth = 0
  desired = ⌈2 / 5.6⌉ = ⌈0.36⌉ = 1
  → 缩容信号:目标 1 个实例

  但缩容有冷却期(scale-down cooldown):
  网关会观察 60 秒(可配置),确认流量确实下降,再执行缩容
  → 避免因为流量的短暂波动导致反复扩缩容(抖动)

再过 5 分钟:in_flight = 0,queue_depth = 0
  desired = 0
  → 缩容到零,GPU 实例关闭
  → 节省费用,直到下一个请求到来

调参建议

参数保守设定激进设定适用场景
container_concurrencyGPU显存的 50% 容量GPU显存的 80% 容量保守:稳定性优先;激进:成本优先
target_utilization0.60.8保守:应对流量尖峰;激进:低延迟要求不高
min_replicas1(保持一个热实例)0(允许冷启动)保守:成本 vs 延迟敏感业务;激进:离线/低频业务
max_replicasGPU 数量GPU 数量 × 2(超卖)取决于预算上限

一个常见的误解是把 target_utilization 设成 1.0——追求把每个实例的显存用满。问题在于,当利用率达到 100% 时,扩容才开始,而 GPU 实例启动需要 30-60 秒,这段时间所有新请求都在等待。0.7 意味着在实例还有 30% 余量时就开始扩容,让新实例在旧实例满负荷之前就绪。


AI 基础设施的核心挑战,不是模型本身——是把模型接入真实世界的那些管道。流量层是其中最底层的管道,也是最容易被忽视的一层。

用为 Web 时代设计的工具来承载 AI 服务,就像用自来水管来输送天然气:它短期内可能跑起来,但每一个假设都在累积风险。

重新设计流量层,从 AI 服务的实际需求出发,是 AI 基础设施现代化不可绕过的一步。