site logo

Marico's space

从原型到生产:部署稳定运行的 LLM 应用

Others 2026-09-07 17:37:54 5

最近帮朋友(深圳某电商创业公司的 CTO)做上线前的压测,他们的 AI 搜索摘要功能跑了三个月 demo 效果很好,单机响应 1 秒左右。结果 40 个并发用户一上,响应时间直接飙到 47 秒,GPU 服务器内存占用 30GB+,API 开始疯狂返回 504。最要命的是,一个 worker 挂了直接带走整个服务。

"加点服务器不就行了?"他问。

这问题两年内我听过不下二十遍了。答案是:不行。对一个压根没按生产标准设计的原型堆硬件,只会让你烧掉六位数的云账单,然后在最不该挂的时候挂掉。"模型能跑"到"服务不崩"之间,隔着一门专门的工程学科。这篇文章讲的就是这门学问——架构、代码、数字,以及我花真金白银踩出来的坑。

核心认知:原型不是应用

判断差距最简单的方法就一个问题:40 个人同时用会怎样?原型会崩。生产级应用会给出一个设计过的响应——要么排队、要么限流、要么降级、要么扩容。

四件事区分这两者,所有内容都围绕它们展开:

  1. 无状态。生产服务必须随时可以重启。如果你的会话状态、用户 session、模型句柄都存在进程内存里,既无法水平扩展,也无法扛住崩溃。
  2. 层级边界。模型服务、API 逻辑、状态存储必须是独立进程,通过明确契约通信。如果三个东西塞进一个进程,一个推理慢了就全部阻塞。
  3. 背压。当需求超过容量时,系统必须优雅降级——排队、礼貌拒绝、或者丢弃负载。绝对不能悄悄丢弃请求。
  4. 可观测性。无法度量就无法修复。延迟分位数、队列深度、GPU 利用率、Token 消耗,这些要从第一天就可见,而不是出了问题再补。

深圳那个团队四个都没有。模型、FastAPI 应用、会话缓存全塞进一个进程。推理一慢,所有请求堵在同一条代码路径上,内存随每个排队的会话不断增长。问题不在硬件,在于架构。

第一步:选模型Serving方案

首先决定模型跑在哪里。诚实地说就三种选择,你的决定决定了下游几乎所有事情。

方案 成本(每百万 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
Enter fullscreen mode Exit fullscreen mode

三条规则撑起整个架构。第一,API 层无状态——每个会话 ID 映射到一个 Redis key,绝不依赖进程内存,所以可以跑 20 个副本并随时杀掉任何一个。第二,模型无论本地 vLLM 还是托管端点都走同一个契约。第三,模型需要的东西在请求时组装:上下文窗口从 Redis 和 Postgres 拼出来,用完即弃。

第三步:生产级 API 形状(FastAPI)

你笔记本上的代码撑不住生产。这里是服务层的形态,两个细节大多数人跳过:明确的输入预算和模型调用的硬超时。

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}
Enter fullscreen mode Exit fullscreen mode

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})
Enter fullscreen mode Exit fullscreen mode

改一行配置就能切换两者。接口是核心价值所在——它让你在不停服的情况下迁移。

第四步:正确地Serving和扩缩容

API 要跑在真正的 ASGI 服务器上——千万别在用户面前开 uvicorn 开发模式。FastAPI 意味着用进程管理器跑多个 worker:

gunicorn app.main:app \ --worker-class uvicorn.workers.UvicornWorker \ --workers 4 --threads 8 \ --timeout 60 --graceful-timeout 30
Enter fullscreen mode Exit fullscreen mode

我的经验公式: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;
}
Enter fullscreen mode Exit fullscreen mode

第二层,应用级 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)
Enter fullscreen mode Exit fullscreen mode

对于长回答,流式输出 Token。流式把 8 秒的完整答案变成首 Token 延迟不到一秒,用户感知上快得多。用 Server-Sent Events,模型客户端传 stream=True,每个 Token chunk 到达时立即推给客户端。API 形状几乎不变,感知延迟大幅下降。

第六步:激进缓存

最快的推理是不跑的那次。缓存精确匹配的 Prompt,更重要的是缓存检索结果和工具输出——同样的文档片段会被反复向量化。Redis 缓存检索片段,一小时 TTL,把那家电商应用的上游模型调用减少了大概 60%。还要缓存大块静态内容:系统 Prompt、Schema 定义、不随用户变化的前置上下文。每个缓存的 Token 都是没花出去的钱。

可观测性:真正重要的指标

第一周就加上,别等出问题再补。用 OpenTelemetry + Prometheus,至少追踪这些:

  • 延迟分位数——API 和模型调用分别看 p50、p95、p99。
  • 队列深度和 Worker 饱和度——这是你要崩还没崩的信号,不是已经崩了的信号。
  • GPU 利用率和显存余量——自托管的话,自动扩缩容必须看这个。
  • 每个端点、每个会话的 Token 消耗——财务指标,和限流器联动。
  • 按错误类型分的错误率——区分 429(配额)、503(模型忙)、5xx(真故障),修复方式完全不同。

一个同时展示这五个指标的 Dashboard 救过我好几次。一个朋友的推理服务,p99 悄悄涨了三倍,十天后才发现——队列深度每天涨一点,但没人盯着看。

生产现实:我踩过的坑

这是 demo 里永远不会展示的部分:

  1. 模型服务器显存悄悄耗尽。一个长上下文请求可以把 24GB 显存的卡推到 OOM。vLLM 会抢占重调度,但你的代码必须把模型返回的 5xx 当作可重试的,加上退避重试。
  2. Prompt 注入在生产环境依然有效。未清洗的内容——检索来的文档、工具输出、用户消息——可以覆盖你的系统 Prompt。把所有模型输入当敌对数据处理;隔离检索文本,绝不让它重写指令。
  3. Token 成本是扩缩容特性,不是缺陷。日活 5 万用户,每请求 1500 Token 左右,按现在的价格每天成本 ¥430–¥1100。上线前就要算清楚,别等账单来了再反应。
  4. 延迟是双峰的。一旦 GPU 共享或缓存未命中,p95 会比中位数高 3–5 倍。按 p95 设计,别按 demo 设计。
  5. 冷启动是真的。缩容到零后第一个用户要等 40 秒等模型加载进显存。如果你的产品受不了,保持一个热备副本。
  6. 环境间依赖漂移。同一个模型版本,2% 流量时好好的,20% 流量时行为可能变化——权重一样,但批处理动态不同。锁定版本,扩容前重新跑评估。
  7. 队列会掩盖延迟。有界队列让应用看起来很响应,直到队列满了。用队列就要配队列深度报警,宁可快速返回 503,也不要让用户等到天荒地老。

什么时候不要自托管

自托管是最