Files

110 lines
4.7 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
id: devops.nginx.scenario.ai-gateway-sse-canary
title: AI 应用网关:流式输出不卡顿、按用户灰度新版本
type: scenario
domain: devops
stack: [nginx, fastapi]
tags: [SSE, 流式输出, 灰度发布, 限流, 网关]
level: intermediate
prerequisites: [devops.nginx.https-stream-limit]
est_hours: 2
related: [devops.nginx.interview, devops.docker.scenario.compose-dev-env]
week: [7, 13, 14]
---
# AI 应用网关:流式输出不卡顿、按用户灰度新版本
## 业务背景与问题
企业内部的 AI 问答助手:前端单页应用,后端 Python FastAPI 调用大模型并以 SSE 流式返回。上线后遇到三个问题:
1. 经过 Nginx 后,回答不再逐字出现,而是等十几秒后整段出现;长回答偶尔 504。
2. 新版本的检索策略想先给内部 20 个同事试用,没有灰度手段,只能全量发布。
3. 有人写脚本刷接口,模型调用费用突增。
## 约束
- 只有一台入口服务器上的 Nginx,不引入新的网关产品。
- 灰度要能按用户指定,也要能随时关闭。
- 正常用户的体验不能受限流影响。
## 方案设计
```mermaid
flowchart LR
B[浏览器] --> N[Nginx]
N -->|/ 静态资源| S[dist/]
N -->|/api/ 限流 + 按 Cookie 选择| ST[AI 服务 稳定版]
N -->|canary=1| CA[AI 服务 灰度版]
N -->|/ai/stream 关闭缓冲| ST
```
1. **流式**:`/ai/stream` 单独一个 location,`proxy_buffering off`、`proxy_cache off`、`proxy_read_timeout 300s`;后端每 15 秒发一条 `: ping` 心跳,防止中间设备断开空闲连接。
2. **灰度**:`map $cookie_canary $api_backend`,内部同事访问一个“加入体验”页面,由后端为其设置 `canary=1` 的 Cookie;关闭灰度只需把 map 默认值都指向稳定版并 reload。也可以用 `split_clients` 按比例放量。
3. **限流**:Nginx 按 IP 做第一道粗粒度限流(`limit_req`,返回 429);后端再按用户 / 租户做精细的配额(Redis,见 [Redis 限流](../../../backend/redis/03-lock-ratelimit.md)),并记录每个用户的模型调用量。
## 关键代码
完整配置模板和自动化测试见 [examples/gateway](../examples/gateway/):
```nginx
map $cookie_canary $api_backend {
default api_stable;
"1" api_canary;
}
location /api/ {
limit_req zone=api_limit burst=5 nodelay;
proxy_pass http://$api_backend;
}
location /ai/stream {
proxy_pass http://api_stable;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 300s;
}
```
FastAPI 端的 SSE 响应头和心跳(示意):
```python
from fastapi.responses import StreamingResponse
async def event_stream(chunks):
async for text in chunks: # chunks:大模型返回的增量文本
yield f"data: {text}\n\n"
yield "data: [DONE]\n\n"
def sse(chunks) -> StreamingResponse:
return StreamingResponse(event_stream(chunks), media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
```
测试用真实 Nginx 验证了四点:SPA 回退、反向代理并传递 `X-Forwarded-For`、带 `canary=1` Cookie 的请求进入灰度后端、SSE 第一条事件不被缓冲、超过限流返回 429。
## 取舍
| 方案 | 优点 | 缺点 |
| --- | --- | --- |
| **Nginx map / split_clients(本方案)** | 无新组件,配置简单 | 规则复杂时难维护;改规则要 reload |
| API 网关产品(APISIX、Kong 等) | 动态配置、插件丰富 | 多一套系统要运维 |
| 应用内特性开关 | 粒度最细(按功能) | 需要改代码,新旧逻辑并存 |
| Kubernetes + 服务网格 | 按权重、按头部灰度都很方便 | 需要 K8s 和网格基础设施 |
## 易错点
- 只在 `/api/` 关了缓冲,SSE 走的其实是另一个 location。
- 开了 gzip 且把 `text/event-stream` 加进 `gzip_types`,流式又被攒起来。
- `proxy_read_timeout` 理解成总时长:它是两次读之间的最大间隔,心跳能避免超时。
- 灰度 Cookie 由前端随意设置:任何人都能进灰度。应由后端校验身份后设置。
- 限流只在 Nginx 按 IP:公司出口是同一个 IP,所有同事一起被限。内部系统应按用户维度在后端限流。
## 面试怎么讲
> “AI 助手经过 Nginx 后流式输出变成整段,原因是 Nginx 默认缓冲代理响应。我给流式接口单独配了 location,关闭缓冲、放宽读超时,后端加心跳;用 map 按 Cookie 把内部同事路由到灰度版本;在 Nginx 做按 IP 的粗限流、后端按用户做配额。我还写了自动化测试,用真实 Nginx 验证第一条事件不被缓冲、灰度和限流生效。”