前端转 AI 第 32 天:LLM 客户端工程化 - 重试、超时、降级、限流
昨日回顾
Day 31 跑通了第一次真实 LLM 调用,助手会"思考"了。
但能调用 ≠ 能上线。真实场景下的 LLM API:
- 会超时:网络抖动,模型排队
- 会限流:高峰期返回 429
- 会挂掉:某家服务商临时故障
- 会抽风:返回空内容、格式错误
Day 32 目标:给 LLM 客户端加一层生产级封装------重试、超时、降级、限流,一个都不能少。
一、LLM 调用的 5 种失败模式
先搞清楚"LLM 调用可能怎么失败",才能对症下药。
1. 网络层失败
典型场景:
- DNS 解析失败
- 连接超时
- TLS 握手失败
- 中途断连
异常类型: APIConnectionError / ConnectTimeout
特点: 一般重试就能成功,属于"临时故障"。
2. 超时
典型场景:
- 模型排队(高峰期)
- 响应过长(模型生成几千字)
- 网络慢
异常类型: APITimeoutError / ReadTimeout
特点: 重试可缓解,但要注意"重复消费"问题(重试可能导致模型被调两次,钱花了两次)。
3. 限流(429)
典型场景:
- 超出 QPS 限制
- 超出日配额
- 超出并发限制
异常类型: RateLimitError(状态码 429)
特点:
- 必须重试 ,但要等待一段时间再试(退避)
- 通常响应头里有
Retry-After告诉你等多久 - 不能死循环重试,会越限越严重
4. 服务端错误(5xx)
典型场景:
- 服务商故障
- 网关错误
- 服务重启
异常类型: APIError(状态码 500-599)
特点: 一般是临时故障 ,重试可缓解。持续失败就要切换模型。
5. 业务错误(4xx 非 429)
典型场景:
- 401 认证失败:API Key 错了
- 403 无权限:没开通这个模型
- 400 参数错误:比如 messages 为空
- 404 模型不存在
异常类型: APIStatusError(其他 4xx)
特点:
- 不能重试!重试 100 次还是失败
- 必须修复代码或配置
核心认知:
不同错误,不同策略。
- 网络 / 超时 / 429 / 5xx → 重试
- 4xx(非 429) → 立即失败,通知开发者
二、重试:最基础的容错
1. 天真重试
Day 10 写过一个通用重试装饰器:
python
@retry(max_attempts=3, delay=1, backoff=2)
async def call_llm():
return await client.chat(...)
能解决简单问题,但 LLM 场景有几个不足:
- 不区分错误类型:4xx 也重试,浪费时间
- 没有指数退避 + 抖动:所有请求同时重试会"惊群"
- 不读 Retry-After 头:明明服务器说"等 30 秒",你还 1 秒就重试
2. 完整重试逻辑
创建 src/ai_assistant/llm_retry.py:
python
"""LLM 调用重试逻辑"""
import asyncio
import random
from typing import TypeVar, Callable, Awaitable
from openai import (
APIConnectionError,
APITimeoutError,
RateLimitError,
APIStatusError,
APIError,
)
from .observability.logging import get_struct_logger
logger = get_struct_logger(__name__)
T = TypeVar("T")
# 可重试的异常类型
RETRYABLE_EXCEPTIONS = (
APIConnectionError, # 网络错误
APITimeoutError, # 超时
RateLimitError, # 429
)
def is_retryable(exc: Exception) -> bool:
"""判断异常是否可重试"""
# 网络 / 超时 / 限流 → 重试
if isinstance(exc, RETRYABLE_EXCEPTIONS):
return True
# 5xx 服务端错误 → 重试
if isinstance(exc, APIStatusError):
return 500 <= exc.status_code < 600
# 4xx → 不重试
return False
def get_retry_delay(
attempt: int,
base_delay: float = 1.0,
max_delay: float = 60.0,
exc: Exception | None = None,
) -> float:
"""
计算重试等待时间
策略:
- 指数退避:1s, 2s, 4s, 8s...
- 加抖动:避免惊群
- 上限:不超过 max_delay
- 优先用 Retry-After 头
"""
# 优先读 Retry-After(服务器说等多久就等多久)
if isinstance(exc, RateLimitError):
retry_after = _get_retry_after(exc)
if retry_after is not None:
return retry_after
# 指数退避
delay = min(base_delay * (2 ** (attempt - 1)), max_delay)
# 加抖动:±20%
jitter = delay * 0.2 * (random.random() * 2 - 1)
return max(0.1, delay + jitter)
def _get_retry_after(exc: Exception) -> float | None:
"""从异常响应头里读 Retry-After"""
if not hasattr(exc, "response"):
return None
response = exc.response
if response is None:
return None
retry_after = response.headers.get("Retry-After")
if retry_after is None:
return None
try:
return float(retry_after)
except ValueError:
return None
async def retry_llm_call(
func: Callable[[], Awaitable[T]],
max_attempts: int = 3,
base_delay: float = 1.0,
max_delay: float = 60.0,
operation: str = "llm_call",
) -> T:
"""
带智能重试的 LLM 调用
Args:
func: 无参异步函数,内部执行 LLM 调用
max_attempts: 最大尝试次数
base_delay: 首次重试延迟
max_delay: 最大延迟上限
operation: 操作名(用于日志)
Returns:
func 的返回值
Raises:
最后一次的异常
"""
last_exc: Exception | None = None
for attempt in range(1, max_attempts + 1):
try:
return await func()
except Exception as e:
last_exc = e
if not is_retryable(e):
logger.warning(
"不可重试的错误,立即失败",
operation=operation,
attempt=attempt,
error_type=type(e).__name__,
error=str(e),
)
raise
if attempt == max_attempts:
logger.error(
"达到最大重试次数",
operation=operation,
attempts=attempt,
error_type=type(e).__name__,
)
raise
delay = get_retry_delay(attempt, base_delay, max_delay, e)
logger.warning(
"调用失败,准备重试",
operation=operation,
attempt=attempt,
max_attempts=max_attempts,
delay_s=round(delay, 2),
error_type=type(e).__name__,
error=str(e)[:100],
)
await asyncio.sleep(delay)
# 理论上走不到这
raise last_exc # type: ignore
3. 关键设计点
1. 区分可重试 / 不可重试
python
def is_retryable(exc):
if isinstance(exc, (APIConnectionError, APITimeoutError, RateLimitError)):
return True
if isinstance(exc, APIStatusError):
return 500 <= exc.status_code < 600
return False
4xx 立即失败,不浪费时间和钱。
2. 优先读 Retry-After
服务端最清楚该等多久。 读它的响应头比瞎猜准。
3. 指数退避 + 抖动
python
delay = base_delay * (2 ** (attempt - 1)) # 1, 2, 4, 8...
jitter = delay * 0.2 * random... # ±20%
抖动至关重要 ------避免大量请求在同一时刻"齐刷刷"重试,把服务器再打挂一次(惊群)。
4. 日志详细
每次重试都记录: 第几次、为什么、等多久。排查问题时一目了然。
4. 使用
修改 RealLLMClient.chat:
python
from .llm_retry import retry_llm_call
class RealLLMClient:
async def chat(self, messages: list[dict], **kwargs) -> str:
async def _call():
return await self.client.chat.completions.create(
model=self.model,
messages=messages,
**kwargs,
)
response = await retry_llm_call(
_call,
max_attempts=3,
base_delay=1.0,
operation=f"llm_chat:{self.model}",
)
return response.choices[0].message.content or ""
重试逻辑从业务代码里抽离出来了。
三、超时:不同场景不同策略
1. 为什么需要超时
没有超时的请求 = 定时炸弹。
Uvicorn 默认无限等待------一个 LLM 请求卡住,整个 worker 都卡住。
必须显式设超时。
2. 分层超时
LLM 调用的超时应该分层:
| 层级 | 超时 | 说明 |
|---|---|---|
| 连接超时 | 5s | 建 TCP 连接 |
| 读超时(首字节) | 30s | 等模型开始返回 |
| 总超时 | 90s | 整个请求 |
| HTTP 客户端超时 | 60s | OpenAI SDK 层 |
为什么分层?
- 连接超时短:DNS / TCP 建连很快,超过 5s 说明网络有问题
- 读超时长:模型可能"思考"一会儿,尤其复杂推理
- 总超时:兜底,防止无限等待
3. 配置超时
OpenAI SDK 支持 timeout 参数:
python
from openai import AsyncOpenAI
# 方式一:数字(总超时)
client = AsyncOpenAI(
api_key="...",
timeout=60.0,
)
# 方式二:httpx.Timeout(细粒度)
import httpx
client = AsyncOpenAI(
api_key="...",
timeout=httpx.Timeout(
connect=5.0, # 连接
read=30.0, # 读
write=10.0, # 写
pool=5.0, # 从连接池获取
),
)
推荐细粒度配置------每个阶段有不同预期。
4. 不同场景不同超时
不是所有调用都该用同一个超时。
| 场景 | 推荐超时 | 理由 |
|---|---|---|
| 简单问答 | 15s | 快速响应 |
| 日常对话 | 30s | 稍长 |
| 长文生成 | 90s | 输出可能几千字 |
| 复杂推理 | 120s | o1 / R1 类模型思考久 |
| 工具调用 | 30s | 要等模型决策 |
按场景选择超时,而不是一刀切。
5. 实现
在客户端里支持传超时:
python
class RealLLMClient:
async def chat(
self,
messages: list[dict],
timeout: float | None = None,
**kwargs,
) -> str:
"""调用 LLM,支持 per-request 超时"""
# 用请求级超时覆盖默认
client = self.client.with_options(
timeout=timeout or self.timeout
)
async def _call():
return await client.chat.completions.create(
model=self.model,
messages=messages,
**kwargs,
)
return await retry_llm_call(_call, ...)
client.with_options(timeout=...) 返回一个带新超时的客户端。
业务代码里:
python
# 简单问答
reply = await llm.chat(messages, timeout=15)
# 长文生成
reply = await llm.chat(messages, timeout=90, max_tokens=4000)
四、降级:主模型挂了切备用
1. 为什么需要降级
真实场景里,任何一个 API 都可能暂时不可用。
- DeepSeek 限流严重 → 切通义
- OpenAI 网络不通 → 切国内模型
- 主模型 500 → 切备用
降级 = "主路不通走辅路"。
2. 降级策略
按场景选择:
| 策略 | 说明 | 适用 |
|---|---|---|
| 同模型重试 | 短时间抖动 | 网络抖动 |
| 切换模型 | 主模型挂了换备用 | 服务商故障 |
| 降级到小模型 | 大模型挂了用小模型 | 成本 / 稳定性 |
| 返回缓存 | 有缓存就给缓存 | 非实时场景 |
| 降级到兜底回复 | 直接返回预设话术 | 用户不敏感 |
今天实现:模型降级。
3. 实现多模型降级
创建 src/ai_assistant/llm_fallback.py:
python
"""带降级的 LLM 客户端"""
from dataclasses import dataclass, field
from typing import Any
from .llm_real import RealLLMClient, RealLLMError
from .llm_retry import retry_llm_call, is_retryable
from .observability.logging import get_struct_logger
from .observability.metrics import LLM_CALLS
logger = get_struct_logger(__name__)
@dataclass
class ModelEndpoint:
"""一个模型端点配置"""
name: str # 显示名,如 "deepseek-primary"
api_key: str
base_url: str
model: str
timeout: float = 60.0
priority: int = 100 # 数字越小优先级越高
enabled: bool = True
class FallbackLLMClient:
"""
多模型降级客户端
按 priority 依次尝试,直到某个成功
"""
def __init__(self, endpoints: list[ModelEndpoint]):
# 过滤可用 + 按优先级排序
self.endpoints = sorted(
[e for e in endpoints if e.enabled],
key=lambda e: e.priority,
)
if not self.endpoints:
raise ValueError("没有可用的模型端点")
# 为每个端点创建客户端
self.clients: dict[str, RealLLMClient] = {
e.name: RealLLMClient(
api_key=e.api_key,
base_url=e.base_url,
model=e.model,
timeout=e.timeout,
)
for e in self.endpoints
}
logger.info(
"LLM 降级客户端初始化",
endpoints=[e.name for e in self.endpoints],
)
async def chat(self, messages: list[dict], **kwargs) -> str:
"""按优先级依次尝试"""
last_error: Exception | None = None
for endpoint in self.endpoints:
client = self.clients[endpoint.name]
try:
logger.info(
"尝试调用模型",
endpoint=endpoint.name,
model=endpoint.model,
)
async def _call(client=client):
return await client.chat(messages, **kwargs)
result = await retry_llm_call(
_call,
max_attempts=2, # 每个端点少重试几次,快速切换
base_delay=0.5,
operation=f"fallback:{endpoint.name}",
)
# 成功
logger.info(
"模型调用成功",
endpoint=endpoint.name,
)
return result
except RealLLMError as e:
last_error = e
# 4xx 类错误可能是配置问题,其他端点也会失败
# 但为了稳健,还是继续尝试
LLM_CALLS.labels(
model=endpoint.model,
status="endpoint_failed",
).inc()
logger.warning(
"模型端点失败,切换下一个",
endpoint=endpoint.name,
error=str(e)[:100],
)
continue
# 全部失败
logger.error("所有模型端点都失败")
raise RealLLMError(f"所有模型都失败,最后错误:{last_error}")
# ========== 工厂函数 ==========
def create_fallback_client() -> FallbackLLMClient:
"""从环境变量创建降级客户端"""
import os
endpoints = []
# 主模型(DeepSeek)
if os.getenv("DEEPSEEK_API_KEY"):
endpoints.append(ModelEndpoint(
name="deepseek-primary",
api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url="https://api.deepseek.com/v1",
model=os.getenv("LLM_MODEL", "deepseek-chat"),
priority=10,
))
# 备用 1(通义)
if os.getenv("DASHSCOPE_API_KEY"):
endpoints.append(ModelEndpoint(
name="qwen-fallback",
api_key=os.getenv("DASHSCOPE_API_KEY"),
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
model="qwen-plus",
priority=20,
))
# 备用 2(智谱)
if os.getenv("ZHIPU_API_KEY"):
endpoints.append(ModelEndpoint(
name="zhipu-fallback",
api_key=os.getenv("ZHIPU_API_KEY"),
base_url="https://open.bigmodel.cn/api/paas/v4",
model="glm-4-flash",
priority=30,
))
# 兜底(如果都没有,用 OpenAI)
if os.getenv("OPENAI_API_KEY"):
endpoints.append(ModelEndpoint(
name="openai-last",
api_key=os.getenv("OPENAI_API_KEY"),
base_url="https://api.openai.com/v1",
model="gpt-4o-mini",
priority=100,
))
if not endpoints:
raise ValueError("至少需要配置一个模型的 API Key")
return FallbackLLMClient(endpoints)
4. 关键设计
1. 按优先级排序
python
self.endpoints = sorted(..., key=lambda e: e.priority)
priority=10 的主模型先试,失败切 priority=20 的备用。
2. 每个端点少重试
python
max_attempts=2 # 而不是 3
主模型重试 3 次 + 备用重试 3 次 = 6 次,太慢。
改成每个 2 次,快速失败,快速切换。
3. 用环境变量灵活配置
设置了哪个 Key,就自动启用哪个模型。 不设置的不占位置。
4. 完整日志
每次尝试、每次失败、每次切换都有记录------生产排查必备。
5. 使用
deps.py 里改用降级客户端:
python
from .llm_fallback import FallbackLLMClient, create_fallback_client
@lru_cache
def get_llm() -> FallbackLLMClient:
return create_fallback_client()
业务代码完全不用改 ------因为它同样有 chat(messages) 方法。
"接口一致"再次发挥作用。
6. 测试降级
模拟主模型挂了:
设置一个错误的 DEEPSEEK_API_KEY:
bash
export DEEPSEEK_API_KEY="sk-invalid"
export DASHSCOPE_API_KEY="sk-real-key"
调用时:
ini
INFO 尝试调用模型 endpoint=deepseek-primary
WARN 模型端点失败,切换下一个 endpoint=deepseek-primary
INFO 尝试调用模型 endpoint=qwen-fallback
INFO 模型调用成功 endpoint=qwen-fallback
用户完全无感知,服务照常工作。
五、限流:别把 API 打爆
1. 为什么需要限流
场景: 10 个用户同时问问题,你的服务并发发起 10 个 LLM 调用。
后果:
- 触发服务商 QPS 限制:429
- 触发服务商并发限制:排队
- 本地资源被打满:内存、连接数
解决:信号量(Semaphore)控制并发。
2. 用 asyncio.Semaphore 限流
Day 8 学过 Semaphore,直接拿来用:
python
import asyncio
class ConcurrencyLimitedLLMClient:
"""带并发限制的 LLM 客户端"""
def __init__(self, client, max_concurrency: int = 10):
self.client = client
self.semaphore = asyncio.Semaphore(max_concurrency)
async def chat(self, messages: list[dict], **kwargs) -> str:
async with self.semaphore:
return await self.client.chat(messages, **kwargs)
效果:
- 同时最多 10 个 LLM 调用
- 第 11 个请求会等待,直到有位置
3. 分优先级限流
不同请求用不同优先级:
python
from enum import IntEnum
class Priority(IntEnum):
HIGH = 0
NORMAL = 1
LOW = 2
class PriorityLimiter:
"""优先级限流器"""
def __init__(
self,
high_concurrency: int = 5,
normal_concurrency: int = 10,
low_concurrency: int = 20,
):
self.semaphores = {
Priority.HIGH: asyncio.Semaphore(high_concurrency),
Priority.NORMAL: asyncio.Semaphore(normal_concurrency),
Priority.LOW: asyncio.Semaphore(low_concurrency),
}
async def acquire(self, priority: Priority = Priority.NORMAL):
return self.semaphores[priority]
async def __aenter__(self):
return self
async def __aexit__(self, *args):
pass
class LimitedLLMClient:
def __init__(self, client):
self.client = client
self.limiter = PriorityLimiter()
async def chat(
self,
messages: list[dict],
priority: Priority = Priority.NORMAL,
**kwargs,
) -> str:
sem = await self.limiter.acquire(priority)
async with sem:
return await self.client.chat(messages, **kwargs)
用法:
python
# 高优先级(付费用户)
await llm.chat(messages, priority=Priority.HIGH)
# 普通(普通用户)
await llm.chat(messages, priority=Priority.NORMAL)
# 低优先级(后台任务)
await llm.chat(messages, priority=Priority.LOW)
高优先级有独立配额,不会被普通请求挤掉。
4. 令牌桶限流(进阶)
QPS 限制: "每秒最多 N 个请求"。
令牌桶算法:
python
import time
import asyncio
class TokenBucket:
"""
令牌桶限流
- capacity: 桶容量
- refill_rate: 每秒补充多少个令牌
"""
def __init__(self, capacity: int, refill_rate: float):
self.capacity = capacity
self.refill_rate = refill_rate
self.tokens = capacity
self.last_refill = time.monotonic()
self.lock = asyncio.Lock()
async def acquire(self, tokens: int = 1):
async with self.lock:
while True:
now = time.monotonic()
elapsed = now - self.last_refill
# 补充令牌
self.tokens = min(
self.capacity,
self.tokens + elapsed * self.refill_rate,
)
self.last_refill = now
if self.tokens >= tokens:
self.tokens -= tokens
return
# 不够,等一下再试
needed = tokens - self.tokens
wait_time = needed / self.refill_rate
await asyncio.sleep(wait_time)
用法:
python
bucket = TokenBucket(capacity=10, refill_rate=5) # 每秒 5 个
async def call_llm(...):
await bucket.acquire()
return await client.chat(...)
效果:
- 平滑限流:不会瞬间爆发
- 允许突发:桶里有 10 个令牌时能一次用掉
- 持续限速:稳定在 5 QPS
这是工业级的限流方案。
5. 组合使用
最终客户端:并发限制 + 令牌桶 + 降级。
python
class ProductionLLMClient:
"""生产级 LLM 客户端"""
def __init__(self, fallback_client, max_concurrency=10, qps=10):
self.fallback = fallback_client
self.concurrency_sem = asyncio.Semaphore(max_concurrency)
self.rate_limiter = TokenBucket(capacity=qps, refill_rate=qps)
async def chat(self, messages, priority=Priority.NORMAL, **kwargs):
# 1. 并发限制
async with self.concurrency_sem:
# 2. QPS 限制
await self.rate_limiter.acquire()
# 3. 重试 + 降级
return await self.fallback.chat(messages, **kwargs)
三层防护:
css
请求 → [并发信号量] → [令牌桶] → [重试+降级] → LLM
同时最多 N 个 QPS 限制 失败切换
完整覆盖了 LLM 客户端的各种边界。
六、把一切整合起来
1. 分层架构
markdown
┌──────────────────────────────────────────┐
│ ProductionLLMClient(生产客户端) │
├──────────────────────────────────────────┤
│ - 并发限流(Semaphore) │
│ - QPS 限流(TokenBucket) │
└──────────────┬───────────────────────────┘
▼
┌──────────────────────────────────────────┐
│ FallbackLLMClient(降级客户端) │
├──────────────────────────────────────────┤
│ - 多模型优先级切换 │
│ - 每端点独立重试 │
└──────────────┬───────────────────────────┘
▼
┌──────────────────────────────────────────┐
│ RealLLMClient(真实客户端) │
├──────────────────────────────────────────┤
│ - OpenAI SDK 封装 │
│ - 超时控制 │
│ - 指标埋点 │
│ - 结构化日志 │
└──────────────────────────────────────────┘
每一层职责单一,可以独立测试。
2. 完整实现
src/ai_assistant/llm_production.py:
python
"""生产级 LLM 客户端"""
import asyncio
import time
from dataclasses import dataclass
from typing import AsyncIterator
from .llm_fallback import FallbackLLMClient, create_fallback_client, ModelEndpoint
from .observability.logging import get_struct_logger
logger = get_struct_logger(__name__)
class TokenBucket:
"""令牌桶限流器(上面实现的)"""
def __init__(self, capacity: int, refill_rate: float):
self.capacity = capacity
self.refill_rate = refill_rate
self.tokens = float(capacity)
self.last_refill = time.monotonic()
self.lock = asyncio.Lock()
async def acquire(self, tokens: int = 1):
async with self.lock:
while True:
now = time.monotonic()
elapsed = now - self.last_refill
self.tokens = min(
self.capacity,
self.tokens + elapsed * self.refill_rate,
)
self.last_refill = now
if self.tokens >= tokens:
self.tokens -= tokens
return
needed = tokens - self.tokens
await asyncio.sleep(needed / self.refill_rate)
@dataclass
class ClientConfig:
"""客户端配置"""
max_concurrency: int = 10
qps: int = 10
burst: int = 20
class ProductionLLMClient:
"""
生产级 LLM 客户端
组合:
- 并发限流(Semaphore)
- QPS 限流(TokenBucket)
- 重试(retry_llm_call)
- 降级(FallbackLLMClient)
"""
def __init__(
self,
fallback_client: FallbackLLMClient | None = None,
config: ClientConfig | None = None,
):
self.fallback = fallback_client or create_fallback_client()
self.config = config or ClientConfig()
self.concurrency_sem = asyncio.Semaphore(self.config.max_concurrency)
self.rate_limiter = TokenBucket(
capacity=self.config.burst,
refill_rate=self.config.qps,
)
logger.info(
"生产 LLM 客户端已初始化",
max_concurrency=self.config.max_concurrency,
qps=self.config.qps,
burst=self.config.burst,
)
async def chat(self, messages: list[dict], **kwargs) -> str:
"""调用 LLM"""
# 1. 并发限流
async with self.concurrency_sem:
# 2. QPS 限流
await self.rate_limiter.acquire()
# 3. 走降级客户端(内含重试)
return await self.fallback.chat(messages, **kwargs)
async def stream_chat(self, messages: list[dict], **kwargs) -> AsyncIterator[str]:
"""流式调用(暂不支持降级,流式较难重试)"""
# 简化版:只用主模型
primary = self.fallback.endpoints[0]
client = self.fallback.clients[primary.name]
async with self.concurrency_sem:
await self.rate_limiter.acquire()
async for chunk in client.stream_chat(messages, **kwargs):
yield chunk
3. 更新 deps.py
python
from .llm_production import ProductionLLMClient, ClientConfig
@lru_cache
def get_llm() -> ProductionLLMClient:
return ProductionLLMClient(config=ClientConfig(
max_concurrency=20, # 同时最多 20 个
qps=10, # 每秒最多 10 个
burst=20, # 突发最多 20 个
))
业务代码完全不改。
4. 配置环境变量
.env:
env
# 主模型
DEEPSEEK_API_KEY=sk-xxx
LLM_MODEL=deepseek-chat
# 备用模型(可选,但有更好)
DASHSCOPE_API_KEY=sk-yyy
# 应用认证
API_KEY=your-app-key
5. 压测验证
用 hey 压测:
bash
hey -n 100 -c 30 -m POST \
-H "Content-Type: application/json" \
-H "X-API-Key: your-app-key" \
-d '{"message": "你好"}' \
http://localhost:8000/api/chat
30 个并发,100 个请求。
观察日志:
- 前 20 个请求立刻处理(信号量内)
- 后 10 个排队等待
- QPS 稳定在 10 左右
- 没有任何 429 错误(因为限制了 QPS)
这才是生产级的表现。
6. 高并发下"不崩"的秘密
没有这层防护时:
arduino
用户并发 50 个请求
→ 50 个 LLM 调用同时发出
→ 触发服务商 QPS 限制
→ 全部 429
→ 用户看到"服务不可用"
有防护后:
用户并发 50 个请求
→ 信号量放行 20 个
→ 令牌桶限速 10 QPS
→ 其余 30 个排队
→ 用户等待时间略长,但都能成功
→ 没有 429
核心思想:
用"排队"代替"拒绝"。
用户愿意等 3 秒,但不愿看到"错误"。
七、前端视角:和前端"并发控制"完全一致
做过前端的你,对这套东西应该很熟悉:
| 前端场景 | 对应方案 |
|---|---|
| 图片懒加载限并发 | Semaphore |
| 接口请求防抖/节流 | TokenBucket |
| 重试失败的请求 | axios-retry |
| 多 CDN 降级 | Fallback |
| 超时中断请求 | AbortController |
axios 的完整配置:
js
const client = axios.create({
baseURL: "https://api.example.com",
timeout: 30000,
});
// 重试拦截器
client.interceptors.response.use(null, async (error) => {
const { config } = error;
if (!config || !config.retry) return Promise.reject(error);
config.__retryCount = config.__retryCount || 0;
if (config.__retryCount >= 3) return Promise.reject(error);
config.__retryCount++;
const delay = Math.pow(2, config.__retryCount) * 1000;
await new Promise((r) => setTimeout(r, delay));
return client(config);
});
对比 Python 版:
python
async def retry_llm_call(func, max_attempts=3, base_delay=1.0):
for attempt in range(1, max_attempts + 1):
try:
return await func()
except Exception as e:
if not is_retryable(e):
raise
if attempt == max_attempts:
raise
delay = get_retry_delay(attempt, base_delay, exc=e)
await asyncio.sleep(delay)
一一对应,思路完全一样:
- 前端
setTimeout↔ Pythonasyncio.sleep - 前端 Promise ↔ Python
asyncio - 前端 axios 拦截器 ↔ Python 客户端中间层
你的前端经验直接迁移。
唯一的新东西是"多模型降级"------前端降级一般是"切 CDN"或"用缓存",后端要"切模型"。
思路一样:主路不通走辅路。
八、今日踩坑
RateLimitError不读Retry-After→ 服务端说等 30 秒,你 1 秒就重试,更严重- 指数退避没加抖动 → 所有请求同时重试,把服务商打挂
- 4xx 也重试 → 浪费时间,比如 401 重试 100 次也没用
- 重试次数太多 → 用户等 5 分钟看到失败,体验极差
- 超时统一 60 秒 → 简单问答也等 60 秒,用户早跑了
asyncio.Semaphore在错误的循环里创建 → 在模块级建 Semaphore,跨事件循环会报错- 降级不区分错误 → 主模型 400 参数错也切换,浪费时间
- 降级后没通知用户 → 用户不知道现在用的是备用模型
- 令牌桶实现有 bug → 用了
time.time()而不是time.monotonic(),时间跳变会乱 - 限流太严 → 用户体验差,太松没效果,需要根据实际调
Semaphore忘记释放 → 用async with保证- 重试时重复扣费 → LLM 调了两次,钱花两次(无法避免,业务层做幂等)
- 日志里打印完整 prompt → 泄露用户隐私 + 日志巨大
- 降级客户端创建多个 → 每个请求都建,浪费资源,要用单例
@lru_cache缓存了客户端 → 好,但如果配置变了要手动清缓存
九、今日总结
今天核心收获三件事:
- LLM 调用有 5 种失败模式 :网络 / 超时 / 429 / 5xx / 4xx,不同模式不同策略
- 重试要"聪明" :区分错误类型、指数退避、读
Retry-After、加抖动 - 生产级客户端要"多层防护":并发限流 + QPS 限流 + 重试 + 降级
一个感受:
LLM 客户端开发和"传统 HTTP 客户端"开发在本质上是同一件事**。**
- 传统 API:超时 3s,重试 3 次,挂了切备用
- LLM API:超时 30s,重试 3 次,挂了切模型
唯一区别:LLM 更慢、更贵、更不稳定。 所以超时更长、重试更谨慎、成本要监控。
这些工程问题,前端做过后端的你,早就懂了。
你的 LLM 客户端从"能调"升级到"能上线"。
这是"能跑 Demo"和"能扛生产流量"的分水岭。
Day 32 完成度:✅
明日预告(Day 33)
- 流式输出(Streaming) :
- SSE 协议详解
- 流式输出的原理
- OpenAI SDK 的流式 API
- FastAPI 的 SSE 实现
- 前端接收流式响应
- 让助手从"等 3 秒一次性返回"变成"打字机效果"
最后
如果这篇对你有用,点个赞让我知道有人在看。
评论区互动: 你在生产环境怎么处理"第三方 API 不稳定"的?重试 / 降级 / 熔断?欢迎分享你的方案。
有问题评论区见,我们第 33 天继续。
100 天,第 33 天见。
附录:系列目录
第一阶段(已完成 ✅)
| 阶段 | 天数 | 内容 |
|---|---|---|
| Python 基础 | 01-15 | 语法 + 命令行助手 |
| 后端工程化 | 16-30 | 数据库/测试/FastAPI/Docker/上线 |
第二阶段:大模型应用(进行中 🎯)
| 天数 | 内容 |
|---|---|
| 31 | 大模型入门 + 第一次真实调用 ✅ |
| 32 | LLM 客户端工程化(重试/超时/降级/限流) ✅ |
| 33-35 | 流式输出 + Function Calling + 结构化输出 |
| 36-40 | Prompt 工程 + 多模型适配 + 成本优化 |
| 41-45 | 本地模型 + 阶段复盘 |
第三阶段:RAG(Day 46-60)
第四阶段:Agent(Day 61-80)
第五阶段:完整项目(Day 81-100)
30 天已过,70 天继续。
一起从切图仔变成 Agent 开发者。