一个基于 FastMCP + FastAPI + SSE 的旅游 AI Agent,在生产环境压测中遭遇 30–40 并发的容量悬崖。本文记录完整的诊断思路、五个关键改动、踩过的坑,以及最重要的——每个决策背后的原则。

一、背景:系统长什么样

这是一个多工具调用的出行智能体(订酒店/机票/火车票、订单管理、行程规划),技术栈:

  • MCP Client:FastAPI + SSE 流式输出,负责意图分类、LLM 编排(DeepSeek/Qwen)、工具调度、ES 会话日志
  • MCP Server:FastMCP(streamable-http),封装酒店/机票/火车/天气等十余个查询工具,本质都是无状态 HTTP 包装 + 进程内缓存
  • 部署:两台服务器(A 机弱、B 机强),nginx 在 A 机做反向代理分流到两台 client;MCP Server 只在 A 机部署一份

初始 nginx 配置:

upstream yunzhi_backend {
    server A机:7301 weight=3 max_conns=15 max_fails=2 fail_timeout=10s;
    server B机:7300 weight=7 max_conns=85 max_fails=2 fail_timeout=10s;
    keepalive 32;
}

压测结果:并发爬到 30–40 时延迟陡增、吞吐不再上升——典型的容量悬崖。

二、诊断:顺着请求链路找"单点漏斗"

一条请求的完整路径是:

前端 → nginx → client(uvicorn) → LLM API
                    ↓
              MCP Server(跨机) → 业务 HTTP 接口
                    ↓
              ES(写日志,等refresh) → 返回 end 帧

逐段排查,找到了四个叠加的瓶颈:

瓶颈 1:client 单 worker(且 workers 参数静默失效)

代码里其实写过 workers=4,但被注释掉了——因为"加了没效果"。真相是:

uvicorn.run(app, workers=4)   # ❌ 传 app 对象时 workers 被静默忽略
uvicorn.run("module:app", workers=4)   # ✅ 必须传导入字符串

uvicorn 多进程模式需要在每个子进程里重新 import 模块,所以只接受字符串入口。传对象时它不报错、不警告,直接单进程跑——这是个非常容易踩的坑。单事件循环里每个 SSE chunk 还要过两次正则过滤 + JSON 序列化,30–40 路并发流把一个 CPU 核打满,悬崖就是这么来的。

瓶颈 2:MCP Server 是全局唯一漏斗

B 机扛 85% 流量,但它的 client 每一次工具调用都要跨机打到 A 机那个单进程、单事件循环的 MCP Server。client 扩到多少个 worker 都没用——所有机器、所有 worker 的工具调用最终汇入同一个 Python 进程。横向扩容扩错了层,瓶颈只是换了个地方排队。

瓶颈 3:refresh="wait_for" 偷走了容量

为了保证多轮对话跨实例一致(nginx 轮询会把下一轮打到另一台机器),每轮对话结束前同步写 ES 并等待 refresh:

await es.index(index=index_name, document=log, refresh="wait_for")

ES 默认 refresh_interval 是 1s,意味着每个请求尾部白白挂 0.5–1s。按 Little’s Law(并发容量 = 吞吐 × 单请求驻留时间),驻留时间凭空多 1s,等于全集群容量被砍掉一大截。并发越高 wait_for 堆积越多,还会反过来迫使 ES 频繁 refresh,恶性循环。

瓶颈 4:nginx weight 与 max_conns 比例打架

weight=3:7(按 30% 分发)但 max_conns=15:85(按 15% 封顶)——弱机会先被打满 max_conns 开始拒绝,而强机还有余量。分发比例和容量上限表达了两套不一致的世界观。

三、五个改动,按杠杆排序

改动 ①:client 多 worker(修正启动方式)

弃用代码内 uvicorn.run(),改为 Docker + CLI 启动:

docker run -d --name yunzhi-client -p 7300:7300 \
  -v /mnt/sdc1/tripv2:/data \
  --restart unless-stopped --ulimit nofile=65536:65536 \
  yunzhi:v1 \
  uvicorn api_fastmcp_client_yunzhi_v2:app \
    --host 0.0.0.0 --port 7300 --workers 4 \
    --log-level info --timeout-keep-alive 120

验证 worker 真的起来了:docker exec yunzhi-client ps aux | grep uvicorn 应看到 1 master + 4 worker;启动日志里 MCP 连接成功的日志应有 4 条(每 worker 各持一条 session——顺带把单 session 队头阻塞摊薄成 1/4)。

注意:走 CLI 后,代码 __main__ 里的 limit_concurrency 等配置全部失效,限流交给 nginx 的 max_conns 前置兜底,这反而是更合理的分层。

改动 ②:MCP Server 双机本地化部署(最高杠杆)

一个改动同时拿到三个收益:B 机 85% 流量的工具调用从跨机 RTT 变成本机回环;A 机 Server 负载直接减掉 85%;消除单点——原来 A 机一挂 B 机也废。

迁移步骤(无 registry 场景):

# A 机:镜像管道直传
docker save mcpapi:v5 | gzip | ssh root@B机 'gunzip | docker load'

# 代码/字典/配置同步(挂载式部署)
rsync -avz /mnt/sdc1/tripv2/ root@B机:/mnt/sdc1/tripv2/

# B 机启动(踩坑:7020 被既有容器占用,宿主机端口换 7021)
docker rm mcp-server   # 先清掉端口冲突时创建失败的残留容器
docker run -d --name mcp-server -p 7021:7020 \
  -v /mnt/sdc1/tripv2:/data \
  --restart unless-stopped --ulimit nofile=65536:65536 \
  mcpapi:v5 \
  uvicorn api_fastmcp_server_v2:app --host 0.0.0.0 --port 7020 --workers 2

容器网络的坑:client 容器是桥接模式(-p 映射),容器内的 127.0.0.1 是容器自己。client 配置里的 Server 地址要写宿主机网卡 IP(流量仍走本机回环),或者 --add-host=host.docker.internal:host-gateway,或者干脆双容器 --network host

回滚预案:A 机 Server 全程不动。出问题把 client 配置改回 A 机地址、restart,一分钟恢复原状。改完观察半天到一天再做下一步。

改动 ③:Server 升级 stateless + 多 worker

FastMCP 的 streamable-http 默认是有状态会话(靠 mcp-session-id 路由),直接开多 worker 会导致同一会话的后续请求落到别的 worker 上报错。工具本身全是无状态的,所以先开无状态模式:

# 模块顶层(所有 @mcp.tool() 注册完之后、if __name__ 之外)
app = mcp.http_app(stateless_http=True)

这里又踩了一个经典坑:第一次把这行写进了 if __name__ == "__main__": 块里,uvicorn 启动后两个 worker 反复 Child process died,报 Attribute "app" not found。原因:uvicorn CLI 是 import 模块来找 app 的,import 时 __name__ 是模块名而不是 "__main__",整个块根本不执行。

原则记一条:CLI 启动需要的东西(app 对象)必须在模块顶层;只属于脚本模式的东西(signal handler、uvicorn.run、预热)留在 if __name__ 块里。 这样 python xxx.py 老启动方式依然可用,天然是回滚备份。

升级后必须验证无状态模式行为:用 fastmcp Client 实测 list_tools + call_tool 各一次,全通才切流量。

改动 ④:nginx 权重对齐 + 容量保守原则

比例对齐很简单,但数字怎么定才是关键。升级后曾想把弱机配额从 15 提到 30——被一个朴素的事实否决:这轮升级的全部受益方是 B 机,A 机什么都没变。给一台没动过的机器配额 ×2,结局大概率是:A 机先到悬崖 → max_fails 触发 → nginx 把它踢出 → 流量瞬间全砸 B 机 → 来回震荡。

最终配置:

server A机:7301 weight=15 max_conns=15 max_fails=2 fail_timeout=10s;
server B机:7300 weight=85 max_conns=85 max_fails=2 fail_timeout=10s;

两个参数的分工:weight 管分发比例(只看比例不看绝对值,15:85 ≡ 3:17),max_conns 管并发封顶(绝对值;SSE 长连接场景下它限的实际是"单机同时进行中的对话数")。两者比例对齐后,两台机会同步逼近各自上限,弱机打满时 nginx 自动把新请求让给强机——期望中的过载保护。

原则再记一条:配额跟着压测数据走,不跟着感觉走。 A 机完成同款升级、重新压测拿到新数字之前,维持已验证的 15。

改动 ⑤:ES 写入止血 + 会话历史的正确归宿

一个曾经的候选方案被主动放弃了:nginx 按 conversation_id 一致性 hash 粘会话 + 进程内缓存历史。多 worker 杀死了这个方案——nginx 只能粘到"机器",粘不到"进程",同一会话的两轮请求即使落在同一台机,也可能进不同 worker,进程内缓存照样穿透。架构决策是联动的,上游一变下游的前提就可能失效。

替代路线分两步:

止血(零代码,一条命令)

curl -X PUT "http://ES:9200/llm-agent-logs/_settings" \
  -H 'Content-Type: application/json' \
  -d '{"index": {"refresh_interval": "200ms"}}'

wait_for 的等待上限从 ~1s 缩到 ~200ms,日志型索引写入量不大,refresh 变频的开销可忽略,但全链路每请求省下 ~0.8s 驻留时间——按 Little’s Law 直接折算成容量。

正式方案(下个迭代):会话历史上 Redis(conversation_id → 最近 N 轮 messages,TTL 30 分钟),读写亚毫秒、天然跨机器跨 worker 全局一致;ES 改 refresh=False + asyncio.create_task() 异步落库,退回纯审计角色。改动范围集中在两个函数:历史读取加 Redis 优先、ES 写入前加 Redis 写。

四、目标拓扑

              nginx (A机:7300, weight 15:85)
               /                      \
    A机 client ×4 workers      B机 client ×4 workers
          ↓ localhost                ↓ localhost
    A机 MCP Server             B机 MCP Server
    (stateless ×2)             (stateless ×2)
          ↓                          ↓
      业务HTTP接口 / LLM API / ES(异步) / Redis(会话)

没有跨机工具调用,没有单事件循环漏斗,没有 refresh 排队,任何一台机器宕掉另一台可独立服务。

五、沉淀下来的原则

  1. 顺着请求链路找"单点漏斗"——横向扩容前先确认扩的是不是瓶颈层,否则只是把队伍挪到下一个窗口排。
  2. Little’s Law 是容量诊断的第一性原理:并发容量 = 吞吐 × 驻留时间。砍掉请求尾部任何无谓的等待(如 refresh wait),等价于免费扩容。
  3. 静默失效比报错更危险uvicorn.run(app, workers=N) 不报错地忽略参数;if __name__ 块在 CLI import 模式下不执行。对"配置加了但没效果"保持怀疑,去验证进程数、验证日志条数。
  4. 配额跟着压测走:没有新数据,不给没变化的机器加码。
  5. 每一步可回滚:旧入口保留、旧服务不停、配置切换一分钟内可逆,观察期足够长再推进下一步。
  6. 架构决策是联动的:开了多 worker,"粘性+进程内缓存"方案即作废;状态的归宿要么彻底无状态,要么放进真正的共享存储(Redis),不要停在中间态。
  7. 心里要有完整的端口地图:迁移时撞上端口冲突,花两分钟搞清占用者是谁、谁在调它,比绕过去更重要。

环境:Ubuntu / Docker / uvicorn / FastAPI / FastMCP / nginx / Elasticsearch。文中机器名与端口已做泛化处理。

Logo

小龙虾开发者社区是 CSDN 旗下专注 OpenClaw 生态的官方阵地,聚焦技能开发、插件实践与部署教程,为开发者提供可直接落地的方案、工具与交流平台,助力高效构建与落地 AI 应用

更多推荐