前端转 AI 100 天 Day 32:LLM 客户端工程化——重试、超时、降级、限流

前端转 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 ↔ Python asyncio.sleep
  • 前端 Promise ↔ Python asyncio
  • 前端 axios 拦截器 ↔ Python 客户端中间层

你的前端经验直接迁移。

唯一的新东西是"多模型降级"------前端降级一般是"切 CDN"或"用缓存",后端要"切模型"。

思路一样:主路不通走辅路。


八、今日踩坑

  1. RateLimitError 不读 Retry-After → 服务端说等 30 秒,你 1 秒就重试,更严重
  2. 指数退避没加抖动 → 所有请求同时重试,把服务商打挂
  3. 4xx 也重试 → 浪费时间,比如 401 重试 100 次也没用
  4. 重试次数太多 → 用户等 5 分钟看到失败,体验极差
  5. 超时统一 60 秒 → 简单问答也等 60 秒,用户早跑了
  6. asyncio.Semaphore 在错误的循环里创建 → 在模块级建 Semaphore,跨事件循环会报错
  7. 降级不区分错误 → 主模型 400 参数错也切换,浪费时间
  8. 降级后没通知用户 → 用户不知道现在用的是备用模型
  9. 令牌桶实现有 bug → 用了 time.time() 而不是 time.monotonic(),时间跳变会乱
  10. 限流太严 → 用户体验差,太松没效果,需要根据实际调
  11. Semaphore 忘记释放 → 用 async with 保证
  12. 重试时重复扣费 → LLM 调了两次,钱花两次(无法避免,业务层做幂等)
  13. 日志里打印完整 prompt → 泄露用户隐私 + 日志巨大
  14. 降级客户端创建多个 → 每个请求都建,浪费资源,要用单例
  15. @lru_cache 缓存了客户端 → 好,但如果配置变了要手动清缓存

九、今日总结

今天核心收获三件事:

  1. LLM 调用有 5 种失败模式 :网络 / 超时 / 429 / 5xx / 4xx,不同模式不同策略
  2. 重试要"聪明" :区分错误类型、指数退避、读 Retry-After、加抖动
  3. 生产级客户端要"多层防护":并发限流 + 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 开发者。

相关推荐
曲径海盗1 小时前
【Python 学习第 39 天】for 循环
python·学习笔记·编程入门
万联WANFLOW2 小时前
Kimi K3.1 Pro 接口接入指南:模型选型、费用计算与零数据保留
java·spring boot·python·安全·spring·大模型
问心无愧05132 小时前
[护网杯 2018]easy_tornado
python·tornado
全栈弄潮儿2 小时前
Python实战第13期:模块与包
后端·python·编程语言
2401_885885043 小时前
芬兰语音通知接口怎样对接?语音提醒接入流程
android·前端·python·php·语音识别
Dev _3 小时前
Python解释器安装在系统级目录导致命令行用户‌没有权限‌向该目录写入文件
开发语言·python
Dev _4 小时前
PyCharm旧进程仍在后台运行,导致新实例无法启动或连接
ide·python·pycharm
quantdash_cc5 小时前
如何检查滚动收益率计算中的窗口长度不足?——从数学定义到 Pandas 实现
开发语言·python·数据分析·pandas·量化交易·股票数据·quantdash
砚底藏山河5 小时前
python量化:实时行情WebSocket断线重连状态机
java·python·websocket·金融