
最近帮朋友(深圳某电商创业公司的 CTO)做上线前的压测,他们的 AI 搜索摘要功能跑了三个月 demo 效果很好,单机响应 1 秒左右。结果 40 个并发用户一上,响应时间直接飙到 47 秒,GPU 服务器内存占用 30GB+,API 开始疯狂返回 504。最要命的是,一个 worker 挂了直接带走整个服务。
"加点服务器不就行了?"他问。
这问题两年内我听过不下二十遍了。答案是:不行。对一个压根没按生产标准设计的原型堆硬件,只会让你烧掉六位数的云账单,然后在最不该挂的时候挂掉。"模型能跑"到"服务不崩"之间,隔着一门专门的工程学科。这篇文章讲的就是这门学问——架构、代码、数字,以及我花真金白银踩出来的坑。
判断差距最简单的方法就一个问题:40 个人同时用会怎样?原型会崩。生产级应用会给出一个设计过的响应——要么排队、要么限流、要么降级、要么扩容。
四件事区分这两者,所有内容都围绕它们展开:
深圳那个团队四个都没有。模型、FastAPI 应用、会话缓存全塞进一个进程。推理一慢,所有请求堵在同一条代码路径上,内存随每个排队的会话不断增长。问题不在硬件,在于架构。
首先决定模型跑在哪里。诚实地说就三种选择,你的决定决定了下游几乎所有事情。
| 方案 | 成本(每百万 Token) | 延迟 | 运维工作量 |
|---|---|---|---|
| 托管 API(OpenAI 兼容网关) | ¥15–¥110 | 300–900ms | 几乎为零 |
| 自托管 GPU(vLLM / TGI / TensorRT-LLM) | ¥3.5–¥30(8卡 H100 机器) | 40–150ms | 高:驱动、显存、自动扩缩容 |
| 量化 CPU(llama.cpp / Ollama) | ¥0.3–¥2 | 2–8 秒 | 中等,出乎意料地稳 |
2026 年我的默认选择是:核心模型用托管端点,自托管只用于大流量或数据敏感场景。深圳那家因为涉及库存和交易数据不能出 VPC,走的是 vLLM 自托管。如果没这个约束,先用托管端点,有用户了再考虑基础设施。
无论选哪个,我坚持一点:用统一接口把模型封装起来,改个环境变量就能在托管和自托管之间切换。第一周就做这个抽象,因为你迟早要切换,不想在故障时重构调用点。
我的交付架构是这样的,故意做得无聊:
Client ─▶ Nginx (TLS, 限流) ─▶ API 副本 (FastAPI, 无状态) │ ├─▶ Redis (会话, 缓存, 队列) ├─▶ Postgres (事实数据, 审计日志) └─▶ vLLM (GPU) / 托管 LLM API
三条规则撑起整个架构。第一,API 层无状态——每个会话 ID 映射到一个 Redis key,绝不依赖进程内存,所以可以跑 20 个副本并随时杀掉任何一个。第二,模型无论本地 vLLM 还是托管端点都走同一个契约。第三,模型需要的东西在请求时组装:上下文窗口从 Redis 和 Postgres 拼出来,用完即弃。
你笔记本上的代码撑不住生产。这里是服务层的形态,两个细节大多数人跳过:明确的输入预算和模型调用的硬超时。
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field app = FastAPI() class ChatRequest(BaseModel): conversation_id: str message: str = Field(..., max_length=4_000) @app.post("/v1/chat")
async def chat(req: ChatRequest, user=Depends(require_auth)): # 1. Auth: validate the caller (API key / JWT). Never skip this.
# 2. Load the last 10 turns from Redis (bounded window).
turns = await redis.lrange(f"conv:{req.conversation_id}", 0, 9) # 3. Call the model with a hard timeout.
try: response = await asyncio.wait_for( model.complete(turns + [{"role": "user", "content": req.message}]), timeout=10.0, ) except asyncio.TimeoutError: raise HTTPException(503, "model busy — retry shortly") # 4. Persist the turns and return.
await redis.rpush(f"conv:{req.conversation_id}", req.message, response) return {"reply": response}
asyncio.wait_for 是这个文件里最重要的一行。没有它,模型一慢就堵死所有 worker,队列无限增长,504 是必然。有了它,失败被隔离:一个请求报错返回清晰信息,系统其他部分继续运行。"并发一上就崩"的故事,十有八九就是缺了这个超时。
模型抽象层:
class LLMBackend(Protocol): async def complete(self, messages: list[dict], stream: bool = False) -> str: ... class HostedBackend: # OpenAI-compatible endpoint
async def complete(self, messages, stream=False): return await client.chat.completions.create( model="your-model", messages=messages, stream=stream, timeout=15.0) class LocalBackend: # vLLM / TGI on your GPU box
async def complete(self, messages, stream=False): return await httpx.AsyncClient(timeout=30.0).post( "http://llm-server:8000/v1/chat/completions", json={"model": "self-hosted", "messages": messages})
改一行配置就能切换两者。接口是核心价值所在——它让你在不停服的情况下迁移。
API 要跑在真正的 ASGI 服务器上——千万别在用户面前开 uvicorn 开发模式。FastAPI 意味着用进程管理器跑多个 worker:
gunicorn app.main:app \ --worker-class uvicorn.workers.UvicornWorker \ --workers 4 --threads 8 \ --timeout 60 --graceful-timeout 30
我的经验公式:workers = 2 × vCPU 核数,超时设成你能接受的最差延迟,前端用 Nginx 或 Traefik 处理 TLS 和连接限制。然后在正确的层面自动扩缩容:模型服务器按 GPU 利用率和队列深度扩缩,不要按 CPU——GPU 跑满时 CPU 指标看起来很闲,在错误的指标上自动扩缩是凌晨三点被叫醒的捷径。
如果自托管模型,不要自己写推理循环。vLLM、TGI、TensorRT-LLM 提供连续批处理(Continuous Batching),这是 GPU 利用率从 15% 到 85% 的区别。连续批处理意味着服务器不等整批完成——序列一完成就腾出位置,立刻塞入新的。在 8 卡 H100 机器上,把一个摘要任务从朴素循环迁移到 vLLM,吞吐量从 180 请求/分钟提升到 780 请求/分钟,硬件完全一样。这不是调优,是完全不同的产品。
每个端点都需要限流,LLM 端点最需要——流量一突发直接变成模型账单。我用两层。第一层,边缘:
limit_req_zone $binary_remote_addr zone=llm:10m rate=20r/m;
location /v1/chat { limit_req zone=llm burst=5 nodelay; proxy_pass http://api;
}
第二层,应用级 Token 预算,按会话维度控制——一个失控的 Agent 循环一小时内烧掉的 Token 比一万个真实用户还多。有个客户的客服 Agent 碰到重试 bug,一晚上在自托管机器加托管备援上烧了大概 ¥1300。限流器保护了服务器,Token 预算保护了账户。两个都要加:
spent = await redis.get(f"budget:{req.conversation_id}") or 0
if int(spent) > 50_000: # ~50k tokens per conversation
raise HTTPException(429, "conversation budget exhausted")
# ... after the model call ...
await redis.incrby(f"budget:{req.conversation_id}", tokens_used)
对于长回答,流式输出 Token。流式把 8 秒的完整答案变成首 Token 延迟不到一秒,用户感知上快得多。用 Server-Sent Events,模型客户端传 stream=True,每个 Token chunk 到达时立即推给客户端。API 形状几乎不变,感知延迟大幅下降。
最快的推理是不跑的那次。缓存精确匹配的 Prompt,更重要的是缓存检索结果和工具输出——同样的文档片段会被反复向量化。Redis 缓存检索片段,一小时 TTL,把那家电商应用的上游模型调用减少了大概 60%。还要缓存大块静态内容:系统 Prompt、Schema 定义、不随用户变化的前置上下文。每个缓存的 Token 都是没花出去的钱。
第一周就加上,别等出问题再补。用 OpenTelemetry + Prometheus,至少追踪这些:
一个同时展示这五个指标的 Dashboard 救过我好几次。一个朋友的推理服务,p99 悄悄涨了三倍,十天后才发现——队列深度每天涨一点,但没人盯着看。
这是 demo 里永远不会展示的部分:
自托管是最