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

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

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

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

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

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

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


目录

  1. [一个请求的生死:nginx 在做什么](#一个请求的生死:nginx 在做什么 "#1-%E4%B8%80%E4%B8%AA%E8%AF%B7%E6%B1%82%E7%9A%84%E7%94%9F%E6%AD%BBnginx-%E5%9C%A8%E5%81%9A%E4%BB%80%E4%B9%88")
  2. 第一个断层:响应不是一个包,是一条河流
  3. 第二个断层:后端可能还不存在
  4. 第三个断层:你永远不知道新模型有没有变傻
  5. 第四个断层:连接不是用完就扔的
  6. [第五个断层:推理失败的方式和 HTTP 500 不同](#第五个断层:推理失败的方式和 HTTP 500 不同 "#6-%E7%AC%AC%E4%BA%94%E4%B8%AA%E6%96%AD%E5%B1%82%E6%8E%A8%E7%90%86%E5%A4%B1%E8%B4%A5%E7%9A%84%E6%96%B9%E5%BC%8F%E5%92%8C-http-500-%E4%B8%8D%E5%90%8C")
  7. [重新设计:AI 流量层需要什么](#重新设计:AI 流量层需要什么 "#7-%E9%87%8D%E6%96%B0%E8%AE%BE%E8%AE%A1ai-%E6%B5%81%E9%87%8F%E5%B1%82%E9%9C%80%E8%A6%81%E4%BB%80%E4%B9%88")
  8. [A3S Gateway 怎么应对这五个断层](#A3S Gateway 怎么应对这五个断层 "#8-a3s-gateway-%E6%80%8E%E4%B9%88%E5%BA%94%E5%AF%B9%E8%BF%99%E4%BA%94%E4%B8%AA%E6%96%AD%E5%B1%82")
  9. 和现有方案的真实对比

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

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

markdown 复制代码
客户端  ──→  nginx  ──→  后端  ──→  nginx  ──→  客户端
               ↑                       ↑
          收到完整响应             转发给客户端

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

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

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

但 LLM 的响应不是这样的。

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

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

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

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

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

用户已经关掉标签页了。

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


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

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

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

css 复制代码
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 代理的基础上打补丁。

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

rust 复制代码
// 零缓冲的 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();
        }
    }
}

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

rust 复制代码
// 缓冲式代理:等全部到齐再发
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)

scss 复制代码
用户请求 (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 开发里那种"新代码和旧代码跑同样的逻辑"的蓝绿部署,而是真实地把一部分用户的请求路由到新模型,观察它在野外的表现。

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

erlang 复制代码
发布 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 秒的连接池保活:

rust 复制代码
// 检测到 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),触发扩容,等待副本通过健康检查,然后重放请求:

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

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

ini 复制代码
desired_replicas = ⌈ (in_flight + queue_depth) / (container_concurrency × target_utilization) ⌉

版本流量分割与自动回滚

hcl 复制代码
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 触发回滚
  }
}

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

流量镜像

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

被动健康管理

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

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

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

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

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

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


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

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

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

相关推荐
yunhuibin2 小时前
NIN网络学习
人工智能·python·深度学习·神经网络·学习
王解2 小时前
第八篇:内外兼修 —— 配置系统与日志监控
人工智能·ai agent·nanobot
zhangshuang-peta2 小时前
人工智能代理的上下文管理突破与长期任务执行
人工智能·ai agent·mcp·peta
隔壁大炮2 小时前
03.深度学习——特点
人工智能·深度学习
两万五千个小时3 小时前
构建mini Claude Code:02 - 把 Bash 拆成专用工具(read_file, write_file 等)
人工智能·python
~央千澈~3 小时前
06实战处理AI音乐技术详解第一阶段:频谱破坏·卓伊凡
人工智能
Hcoco_me3 小时前
车载摄像头核心知识点结构化总结
人工智能·深度学习·数码相机·算法·机器学习·自动驾驶
henry1010103 小时前
Ansible自动化运维全攻略(AI生成)
linux·运维·python·ansible·devops
逻辑君3 小时前
根与星辰【第2章】
人工智能·程序人生