大模型 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 错误 | 解析失败 | 通常否 | 可能是接口不兼容或服务异常 |
| 流中断 | 已输出部分文本后断开 | 不应自动续接 | 重试可能造成重复文本和重复调用 |
"有条件重试"必须同时满足:
- 重试次数有限;
- 每次重试之间有等待;
- 只重试可能恢复的错误;
- 日志能够记录重试原因;
- 业务能够接受潜在的重复请求和额外费用。
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 与指数退避的优先级
本文的等待策略是:
- 如果响应包含合法的
Retry-After,优先使用; - 否则使用指数退避;
- 指数退避加入随机抖动;
- 所有等待时间都有上限;
- 达到最大请求次数后立即失败。
示例:
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. 练习题
- 把
max_attempts设置为 1,观察客户端如何关闭重试; - 使用错误的 API Key,验证 401 是否会立即失败;
- 为
_request_with_retry()增加脱敏日志,记录请求次数和状态码; - 模拟 429 并设置
Retry-After: 2,检查等待逻辑; - 思考为什么 400 不应该自动重试;
- 思考流式输出中断后,前端应该如何提示用户"答案可能不完整"。