03-大模型API不只是发送Prompt-流式输出超时重试与异常处理

大模型 API 不只是发送 Prompt:一文搞懂流式输出、超时、重试与异常处理

系列:Python + FastAPI 大模型应用基础(第 3 篇)

目标:理解一次模型请求可能在哪里失败,并用 Python 实现超时控制、有限重试、指数退避、异常分类和基础流式输出。

1. 为什么"接口调用成功"只是起点

在本地测试时,我们可能只发送一次请求:

python 复制代码
response = requests.post(url, json=payload)
print(response.json())

但真实业务系统面对的是不可靠的网络和外部服务:

  • DNS 解析或 TCP 连接失败;
  • 连接成功后,模型长时间没有返回数据;
  • API Key 失效,服务返回 401;
  • 请求频率过高,服务返回 429;
  • 模型服务临时故障,返回 500 或 503;
  • 服务返回 200,但响应并不是合法 JSON;
  • JSON 合法,但缺少程序需要的字段;
  • 流式输出到一半时网络中断。

从第一性原理看,一次模型调用至少包含四层成功:

text 复制代码
网络连接成功
    ↓
HTTP 协议成功
    ↓
响应结构正确
    ↓
模型答案满足业务要求

上层成功依赖下层成功。HTTP 200 只表示服务处理了请求,不代表 JSON 结构一定正确,更不代表模型答案一定符合事实。

2. 先给错误分类,再决定处理方式

错误处理的关键不是写一个巨大的 try/except,而是判断错误是否可能通过重试恢复。

错误类型 常见表现 是否适合自动重试 原因
参数错误 400、422 原请求不修改,重试仍然错误
鉴权失败 401、403 需要修复密钥或权限
地址不存在 404 需要修复 URL 或模型标识
请求超时 Timeout 有条件 可能是暂时网络抖动,也可能已产生费用
请求限流 429 等待后可能恢复,应尊重 Retry-After
服务端故障 500、502、503、504 有限重试 上游服务可能短暂恢复
JSON 错误 解析失败 通常否 可能是接口不兼容或服务异常
流中断 已输出部分文本后断开 不应自动续接 重试可能造成重复文本和重复调用

"有条件重试"必须同时满足:

  1. 重试次数有限;
  2. 每次重试之间有等待;
  3. 只重试可能恢复的错误;
  4. 日志能够记录重试原因;
  5. 业务能够接受潜在的重复请求和额外费用。

3. Timeout(超时)不是一个数字这么简单

常见网络请求至少要关注两类超时。

3.1 连接超时

连接超时表示客户端在规定时间内无法与服务器建立连接,例如:

  • 域名解析异常;
  • 网络无法访问目标服务器;
  • 防火墙拦截;
  • 服务地址或端口填写错误。

3.2 读取超时

读取超时表示连接已经建立,但规定时间内没有读到响应数据。大模型生成本身需要时间,因此读取超时通常需要比普通业务接口更长。

requests 可以分别设置连接和读取超时:

python 复制代码
response = requests.post(
    url,
    json=payload,
    # 连接最多等待 5 秒;读取数据最多等待 90 秒
    timeout=(5, 90),
)

需要注意:这里的读取超时不是整个请求必须在 90 秒内结束,而是底层读取操作等待数据的时间限制。具体行为还会受到客户端库和传输方式影响。

3.3 为什么不能不设置超时

如果不设置超时,某些网络异常可能让工作线程长时间占用。流量增加后,线程、连接池和服务器资源会逐步耗尽,最终影响整个系统。

4. 为什么重试必须加入退避

假设模型服务暂时故障,1000 个客户端立即同时重试,会给故障中的服务增加更大压力,这种现象称为 Thundering Herd(惊群效应)。

常见方案是 Exponential Backoff(指数退避):

text 复制代码
第 1 次失败:等待约 1 秒
第 2 次失败:等待约 2 秒
第 3 次失败:等待约 4 秒
第 4 次失败:等待约 8 秒

还应加入 Jitter(随机抖动),让不同客户端不要在完全相同的时间再次请求:

text 复制代码
实际等待时间 = 指数退避时间 + 小范围随机值

如果服务端返回 Retry-After,客户端应优先参考该值。Retry-After 可能是秒数,也可能是一个 HTTP 日期。

5. 创建项目

项目结构:

text 复制代码
reliable_llm_client/
├── reliable_llm_client.py
├── main.py
└── requirements.txt

本文使用 Python 3.10 及以上版本。

requirements.txt

text 复制代码
requests>=2.31,<3

创建虚拟环境并安装依赖:

powershell 复制代码
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements.txt

设置环境变量:

powershell 复制代码
$env:LLM_API_KEY = "替换为真实密钥"
$env:LLM_BASE_URL = "https://替换为模型服务地址/v1"
$env:LLM_MODEL = "替换为真实模型标识"

不同模型服务商的 URL、模型名称、请求字段和流式响应格式可能不同,必须以实际服务商的官方文档为准。

6. 实现可靠的大模型客户端

新建 reliable_llm_client.py

python 复制代码
import json
import os
import random
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from typing import Any, Iterator

import requests


class LLMClientError(RuntimeError):
    """所有模型客户端业务异常的父类。"""


class LLMConfigError(LLMClientError):
    """环境变量或客户端配置不正确。"""


class LLMHTTPError(LLMClientError):
    """模型服务返回了不能自动恢复的 HTTP 错误。"""

    def __init__(self, message: str, status_code: int) -> None:
        super().__init__(message)
        self.status_code = status_code


class LLMResponseError(LLMClientError):
    """模型响应格式不符合程序预期。"""


class LLMStreamInterrupted(LLMClientError):
    """已经收到部分流式内容后,连接意外中断。"""


@dataclass(frozen=True)
class Settings:
    """模型服务配置。"""

    api_key: str
    base_url: str
    model: str

    @classmethod
    def from_env(cls) -> "Settings":
        """从环境变量创建配置,并统一完成校验。"""

        api_key = os.getenv("LLM_API_KEY", "").strip()
        base_url = os.getenv("LLM_BASE_URL", "").strip().rstrip("/")
        model = os.getenv("LLM_MODEL", "").strip()

        missing = []
        if not api_key:
            missing.append("LLM_API_KEY")
        if not base_url:
            missing.append("LLM_BASE_URL")
        if not model:
            missing.append("LLM_MODEL")

        if missing:
            raise LLMConfigError(
                f"缺少环境变量:{', '.join(missing)}"
            )

        if not base_url.startswith("https://"):
            raise LLMConfigError("远程模型地址必须使用 HTTPS")

        return cls(
            api_key=api_key,
            base_url=base_url,
            model=model,
        )


@dataclass(frozen=True)
class RetryPolicy:
    """有限重试策略。"""

    # max_attempts 包含第一次请求,例如 3 表示最多请求 3 次
    max_attempts: int = 3
    base_delay: float = 1.0
    max_delay: float = 30.0

    # 只有这些状态码允许自动重试
    retryable_status_codes: frozenset[int] = field(
        default_factory=lambda: frozenset(
            {408, 429, 500, 502, 503, 504}
        )
    )

    def __post_init__(self) -> None:
        """防止错误的重试配置进入运行阶段。"""

        if self.max_attempts < 1:
            raise ValueError("max_attempts 不能小于 1")
        if self.base_delay < 0 or self.max_delay < 0:
            raise ValueError("重试等待时间不能为负数")


class ReliableLLMClient:
    """具备超时、有限重试和基础流式解析的模型客户端。"""

    def __init__(
        self,
        settings: Settings,
        retry_policy: RetryPolicy | None = None,
    ) -> None:
        self.settings = settings
        self.retry_policy = retry_policy or RetryPolicy()

        # Session 可以复用底层连接,避免每次都重新建立连接池
        self.session = requests.Session()

    def _parse_retry_after(self, value: str | None) -> float | None:
        """解析 Retry-After,返回需要等待的秒数。"""

        if not value:
            return None

        # 第一种格式:Retry-After: 5
        try:
            seconds = float(value)
            return max(0.0, seconds)
        except ValueError:
            pass

        # 第二种格式:Retry-After: Wed, 21 Oct 2015 07:28:00 GMT
        try:
            retry_time = parsedate_to_datetime(value)
            if retry_time.tzinfo is None:
                retry_time = retry_time.replace(tzinfo=timezone.utc)

            now = datetime.now(timezone.utc)
            return max(0.0, (retry_time - now).total_seconds())
        except (TypeError, ValueError, OverflowError):
            # 无法识别时交给指数退避逻辑处理
            return None

    def _calculate_delay(
        self,
        failed_attempt: int,
        retry_after: str | None = None,
    ) -> float:
        """计算下一次请求前的等待时间。"""

        server_delay = self._parse_retry_after(retry_after)
        if server_delay is not None:
            # 设置上限,避免一个异常响应让当前进程休眠过久
            return min(server_delay, self.retry_policy.max_delay)

        # 第一次失败等待 base_delay,第二次失败等待 2 倍,以此类推
        exponential_delay = (
            self.retry_policy.base_delay
            * (2 ** (failed_attempt - 1))
        )
        capped_delay = min(
            exponential_delay,
            self.retry_policy.max_delay,
        )

        # 随机抖动最多为 0.5 秒,减少大量客户端同时重试
        jitter = random.uniform(0.0, min(0.5, capped_delay * 0.25))
        return capped_delay + jitter

    def _error_message_for_status(self, status_code: int) -> str:
        """将常见状态码转换成不泄露敏感信息的提示。"""

        messages = {
            400: "模型请求参数错误",
            401: "模型服务鉴权失败,请检查 API Key",
            403: "当前密钥没有调用权限",
            404: "模型接口地址或模型标识不存在",
            429: "请求过于频繁或模型额度受限",
        }
        return messages.get(
            status_code,
            f"模型服务返回异常状态码:{status_code}",
        )

    def _request_with_retry(
        self,
        payload: dict[str, Any],
        *,
        stream: bool,
    ) -> requests.Response:
        """发送请求,只对可能恢复的错误进行有限重试。"""

        request_url = f"{self.settings.base_url}/chat/completions"
        request_headers = {
            "Authorization": f"Bearer {self.settings.api_key}",
            "Content-Type": "application/json",
        }

        for attempt in range(1, self.retry_policy.max_attempts + 1):
            try:
                response = self.session.post(
                    request_url,
                    headers=request_headers,
                    json=payload,
                    # 连接超时 5 秒,单次读取等待最多 90 秒
                    timeout=(5, 90),
                    stream=stream,
                )
            except (
                requests.exceptions.Timeout,
                requests.exceptions.ConnectionError,
            ) as exc:
                if attempt >= self.retry_policy.max_attempts:
                    raise LLMClientError(
                        f"网络请求失败,已尝试 {attempt} 次"
                    ) from exc

                delay = self._calculate_delay(attempt)
                time.sleep(delay)
                continue
            except requests.exceptions.RequestException as exc:
                # 其他未知请求异常默认不重试,防止掩盖程序错误
                raise LLMClientError("模型请求发生网络异常") from exc

            # 2xx 表示 HTTP 层成功,把响应交给上层继续解析
            if 200 <= response.status_code < 300:
                return response

            can_retry = (
                response.status_code
                in self.retry_policy.retryable_status_codes
            )

            if can_retry and attempt < self.retry_policy.max_attempts:
                retry_after = response.headers.get("Retry-After")
                delay = self._calculate_delay(attempt, retry_after)

                # 重试前主动关闭失败响应,归还底层连接资源
                response.close()
                time.sleep(delay)
                continue

            status_code = response.status_code
            response.close()
            raise LLMHTTPError(
                self._error_message_for_status(status_code),
                status_code=status_code,
            )

        # 正常情况下循环一定会 return 或 raise,此处只用于类型完整性
        raise LLMClientError("模型请求未获得结果")

    def _build_payload(
        self,
        user_message: str,
        *,
        stream: bool,
    ) -> dict[str, Any]:
        """校验用户输入并构造请求体。"""

        cleaned_message = user_message.strip()
        if not cleaned_message:
            raise ValueError("用户问题不能为空")

        return {
            "model": self.settings.model,
            "messages": [
                {
                    "role": "system",
                    "content": "你是一名严谨的 Python 助手。",
                },
                {
                    "role": "user",
                    "content": cleaned_message,
                },
            ],
            "temperature": 0.2,
            "stream": stream,
        }

    def chat(self, user_message: str) -> str:
        """执行普通非流式对话。"""

        payload = self._build_payload(user_message, stream=False)
        response = self._request_with_retry(payload, stream=False)

        try:
            try:
                data: dict[str, Any] = response.json()
            except requests.exceptions.JSONDecodeError as exc:
                raise LLMResponseError(
                    "模型服务返回的内容不是有效 JSON"
                ) from exc

            try:
                content = data["choices"][0]["message"]["content"]
            except (KeyError, IndexError, TypeError) as exc:
                raise LLMResponseError(
                    "模型响应缺少 choices[0].message.content"
                ) from exc

            if not isinstance(content, str) or not content.strip():
                raise LLMResponseError("模型返回了空内容")

            return content.strip()
        finally:
            # 无论解析成功还是失败,都关闭当前响应
            response.close()

    def stream_chat(self, user_message: str) -> Iterator[str]:
        """以生成器形式逐段返回模型内容。"""

        payload = self._build_payload(user_message, stream=True)

        # 这里只会在收到响应头之前或状态码可重试时重试
        response = self._request_with_retry(payload, stream=True)
        has_received_content = False

        try:
            # SSE 文本按 UTF-8 解码,避免响应头未声明字符集时中文乱码
            response.encoding = "utf-8"

            # iter_lines() 按行读取,不等待整个响应全部生成
            for line in response.iter_lines(decode_unicode=True):
                if not line:
                    continue

                # 常见流式接口使用 SSE 的 data: 前缀
                if not line.startswith("data:"):
                    continue

                raw_data = line.removeprefix("data:").strip()
                if raw_data == "[DONE]":
                    return

                try:
                    event_data: dict[str, Any] = json.loads(raw_data)
                    content = event_data["choices"][0]["delta"].get(
                        "content"
                    )
                    finish_reason = event_data["choices"][0].get(
                        "finish_reason"
                    )
                except (
                    json.JSONDecodeError,
                    KeyError,
                    IndexError,
                    TypeError,
                    AttributeError,
                ) as exc:
                    raise LLMResponseError(
                        "无法解析模型流式事件"
                    ) from exc

                # 某些事件只包含角色或结束原因,不一定包含文本
                if isinstance(content, str) and content:
                    has_received_content = True
                    yield content

                # 部分兼容接口通过 finish_reason 表示正常结束
                if finish_reason is not None:
                    return

            # TCP 正常关闭不一定代表模型回答完整,必须检查协议结束标记
            raise LLMStreamInterrupted(
                "模型流已关闭,但没有收到明确的结束标记"
            )
        except requests.exceptions.RequestException as exc:
            if has_received_content:
                # 已经输出部分内容时不自动重试,避免内容重复
                raise LLMStreamInterrupted(
                    "流式响应中断,已返回的内容可能不完整"
                ) from exc

            raise LLMClientError("读取模型流式响应失败") from exc
        finally:
            response.close()

    def close(self) -> None:
        """释放连接池资源。"""

        self.session.close()

    def __enter__(self) -> "ReliableLLMClient":
        """支持 with ReliableLLMClient(...) as client 写法。"""

        return self

    def __exit__(self, *args: object) -> None:
        """离开 with 代码块时自动关闭客户端。"""

        self.close()

7. 编写运行入口

新建 main.py

python 复制代码
from reliable_llm_client import (
    LLMClientError,
    ReliableLLMClient,
    RetryPolicy,
    Settings,
)


def main() -> None:
    """选择普通或流式模式,调用大模型。"""

    try:
        settings = Settings.from_env()

        # 包含第一次请求在内,最多尝试 3 次
        retry_policy = RetryPolicy(
            max_attempts=3,
            base_delay=1.0,
            max_delay=30.0,
        )

        # with 代码块结束后会自动关闭 Session
        with ReliableLLMClient(settings, retry_policy) as client:
            question = input("请输入问题:").strip()
            mode = input("请选择模式(1=普通,2=流式):").strip()

            if mode == "1":
                answer = client.chat(question)
                print("\n模型回答:")
                print(answer)
            elif mode == "2":
                print("\n模型回答:")

                # end="" 防止 Python 为每个文本片段自动换行
                for text_piece in client.stream_chat(question):
                    print(text_piece, end="", flush=True)

                print()
            else:
                print("模式只能输入 1 或 2")
    except ValueError as exc:
        print(f"输入错误:{exc}")
    except LLMClientError as exc:
        # 业务层只接收统一异常,不需要理解 requests 的所有异常类型
        print(f"调用失败:{exc}")


if __name__ == "__main__":
    main()

8. 代码执行流程

普通请求执行过程:

text 复制代码
校验环境变量
    ↓
校验用户输入
    ↓
发送 HTTP 请求
    ↓
失败是否可以重试?
    ├── 可以:等待后有限重试
    └── 不可以:立即抛出分类异常
    ↓
校验 JSON 和字段结构
    ↓
返回完整回答

流式请求执行过程:

text 复制代码
发送 stream=true 请求
    ↓
收到响应头并检查状态码
    ↓
逐行读取 data: 事件
    ↓
解析 choices[0].delta.content
    ↓
立即把文本片段交给调用者
    ↓
收到 [DONE] 或连接结束

9. 为什么流式输出中断后不能直接重试

假设模型已经返回:

text 复制代码
Python 的装饰器本质上是一个接收函数并返回新函数的......

此时网络中断。如果客户端重新发送完整请求,新请求可能从头生成:

text 复制代码
Python 的装饰器本质上是......

如果程序直接把两次输出拼接,用户会看到重复内容。另一个问题是,第一次请求可能已经产生 Token 费用,重试会再次产生费用。

因此本文采用以下边界:

  • 收到响应前发生可恢复错误:允许有限重试;
  • 收到可重试状态码:允许有限重试;
  • 已经向用户输出部分内容后中断:报告内容不完整,不自动重试。

如果业务必须支持断点续传,需要服务商提供请求幂等、会话恢复或生成任务查询等能力,不能仅依靠客户端猜测。

10. Retry-After 与指数退避的优先级

本文的等待策略是:

  1. 如果响应包含合法的 Retry-After,优先使用;
  2. 否则使用指数退避;
  3. 指数退避加入随机抖动;
  4. 所有等待时间都有上限;
  5. 达到最大请求次数后立即失败。

示例:

text 复制代码
第一次请求 → 429,Retry-After: 3
等待 3 秒
第二次请求 → 503,没有 Retry-After
等待约 2 秒
第三次请求 → 仍然失败
停止重试并报告错误

11. 为什么不把上游完整错误直接返回给用户

上游响应可能包含:

  • 内部请求 ID;
  • 模型服务商的系统信息;
  • 请求参数细节;
  • 调试堆栈;
  • 甚至被错误记录的敏感内容。

面向用户的错误应该简洁、稳定;详细信息应进入经过脱敏的内部日志。API Key、Authorization 请求头和原始用户隐私数据不能进入普通日志。

12. 对抗性审查:当前实现仍有哪些边界

12.1 重试可能造成重复计费

读取超时不代表服务端没有处理请求。模型可能已经生成完成,只是响应没有成功到达客户端。若服务商支持 Idempotency Key(幂等键)或任务查询,应优先使用官方机制。

12.2 Retry-After 上限是业务取舍

本文把最长等待限制为 30 秒,防止命令行程序长时间休眠。但服务端可能明确要求等待更久。生产系统可以把任务转入队列,而不是在请求线程中一直等待。

12.3 不同服务商的流格式不同

本文解析的是常见的:

text 复制代码
data: {"choices":[{"delta":{"content":"文本"}}]}

有些服务商使用不同字段、不同结束标志或完全不同的协议,必须根据官方文档修改解析器。

12.4 重试不是熔断

如果上游持续故障,每个用户请求都重试三次,反而会放大流量。生产系统还需要 Circuit Breaker(熔断器):失败率达到阈值后暂时停止请求,等待服务恢复。

12.5 接口成功不代表答案可靠

超时、重试和异常处理只解决系统稳定性,不能解决模型幻觉。涉及财务、客户信息和业务决策时,还要增加 RAG、规则校验、权限检查和人工审核。

13. 常见错误

错误一:对所有异常无限重试

python 复制代码
# 错误示例:可能形成无限循环,并不断增加费用
while True:
    try:
        call_model()
        break
    except Exception:
        pass

正确思路是限制次数、区分异常、增加等待,并在最终失败后明确退出。

错误二:把读取超时设置得过短

普通数据库接口可能几百毫秒返回,而模型首个 Token 可能需要更长时间。超时值应依据业务的实际延迟分布设定,不能机械照搬其他接口。

错误三:已经输出部分内容后自动从头重试

这会造成重复文本、答案冲突和重复费用。流式中断必须作为单独状态处理。

错误四:将 API Key 写进报错日志

排查问题时只需要记录服务名称、状态码、耗时、请求 ID 和经过脱敏的业务信息,不需要记录完整 Authorization 请求头。

14. 本篇总结

本文建立了一个可靠模型客户端的基础边界:

  • 连接超时和读取超时分别控制;
  • 只对可能恢复的错误进行有限重试;
  • 使用 Retry-After、指数退避和随机抖动;
  • 对配置、网络、HTTP 和响应结构异常进行分类;
  • 普通响应和流式响应采用不同解析方式;
  • 已输出部分内容后发生中断,不盲目自动重试;
  • 不向用户泄露上游响应和敏感配置。

下一篇将进一步拆解 SSE(Server-Sent Events,服务器发送事件)的数据格式,并使用 FastAPI 实现一个完整的流式接口。

15. 练习题

  1. max_attempts 设置为 1,观察客户端如何关闭重试;
  2. 使用错误的 API Key,验证 401 是否会立即失败;
  3. _request_with_retry() 增加脱敏日志,记录请求次数和状态码;
  4. 模拟 429 并设置 Retry-After: 2,检查等待逻辑;
  5. 思考为什么 400 不应该自动重试;
  6. 思考流式输出中断后,前端应该如何提示用户"答案可能不完整"。
相关推荐
To_OC1 小时前
跟 AI 写代码越写越乱?我靠这套「Vibe Coding」思路彻底治好了幻觉屎山
人工智能·agent·vibecoding
深圳慧闻智造技术有限公司2 小时前
机器人减速机壳体加工精度要求的四大核心维度分析
人工智能·机器人·机器人壳体加工
冬奇Lab2 小时前
AI 评测系列(07):自定义 Benchmark——从业务场景到评测集
人工智能
骏晔科技DreamLNK2 小时前
国产蓝牙模块哪个品牌好?骏晔科技 7 款主流型号横向对比
人工智能·科技
Zzz不能停2 小时前
个人博客系统系统---测试报告【笔耕云】
python·功能测试·自动化·压力测试
用户938515635073 小时前
从零搭建 AI 日记助手:用 Milvus 向量数据库 + RAG 让机器读懂你的每一天
javascript·人工智能·全栈
互联网中的一颗神经元3 小时前
小白python入门 - 39. 采集流水线小项目
开发语言·python
阿部多瑞 ABU4 小时前
新帝国殖民主义:文化-情感-金融复合体的当代运作机制
大数据·人工智能·金融