把模型调用当成普通 HTTP 接口来写,通常能跑通,但线上会以一种特殊方式坏掉:不是抛异常,而是慢、贵、且静默。客户端重试三次、网关重试两次、SDK 内部再重试一次——同一次请求被放大成六次计费,而前面的流式内容早已推给了用户。
这篇按"差异 → 超时 → 重试 → 限流 → 分类 → 降级 → 流式中断 → 可观测"的顺序,把失败处理拆成可配置的策略。
1. 与普通 HTTP 调用的四个差异
| 差异 | 普通接口 | 模型调用 | 工程含义 |
|---|---|---|---|
| 延迟分布 | 集中在均值附近 | 长尾显著,p99 常是 p50 的数倍 | 均值没用,必须看分位数 |
| 失败相关性 | 与负载弱相关 | 失败率随并发与输出长度上升 | 重试在高负载时最容易失败 |
| 计费方式 | 按请求数 | 按输入+输出 token | 重试与降级都直接改变成本 |
| 完成语义 | 成功/失败二元 | 存在"半成功":流已输出后中断 | 无法用单一状态码表达 |
最后一条最关键:模型调用有三种终态——完整成功、完全失败、部分成功。只处理前两种的代码,在第三种情况下既收不到错误、也拿不到完整内容。
典型量级:首 token 延迟与总延迟是两个指标,长输出的总延迟取决于输出长度,按所选模型版本与服务方配额核对,不要用常数写死。
2. 超时怎么设
llm:
client:
connect-timeout: 3s # 建连,包含 DNS 与 TLS
read-timeout: 30s # 非流式:整段响应
stream-idle-timeout: 12s # 流式:两个 chunk 之间的最大间隔
total-deadline: 60s # 含重试的总上限
| 超时项 | 语义 | 设错的后果 |
|---|---|---|
| connect-timeout | 建连握手 | 太长会在网络故障时占满连接池 |
| read-timeout(非流式) | 等完整响应 | 太短会杀掉长输出请求 |
| stream-idle-timeout | 相邻 chunk 间隔 | 设成总时长会在长回复中途断连 |
| total-deadline | 含所有重试 | 不设则重试叠加出超长等待 |
两个原则:连接超时与读超时分开(网络问题与生成慢是两件事);应用侧超时必须短于上游网关,否则网关先返回 504,重试又会触发一次真实计费,而客户端看到的仍是一次失败。
流式场景下 read-timeout 的含义要改:不是"等整段响应",而是"等下一个 chunk 的间隔";用非流式阈值卡长流会在中途砍断。
3. 重试的边界
| 条件 | 是否可重试 | 说明 |
|---|---|---|
| 幂等的读式调用(无副作用) | 是 | 绝大多数生成调用属于此类 |
| 已产生副作用的调用(写库、下单) | 否 | 重试会重复执行,需业务幂等键 |
| 4xx 参数错误 | 否 | 重试结果一样 |
| 429 限流 | 是,需退避 | 不读 Retry-After 会持续被限 |
| 5xx / 连接重置 | 是,需抖动 | 无抖动会造成同步重试风暴 |
| 超时 | 谨慎 | 原请求可能仍在计费 |
def retry_delay(attempt, base=0.5, cap=8.0):
# 指数退避 + 全抖动:避免同批客户端同时重试
return random.uniform(0, min(cap, base * 2 ** attempt))
def call_with_retry(fn, max_attempts=3, deadline=None):
for i in range(max_attempts):
try:
return fn()
except Retryable as e:
if deadline and now() > deadline:
raise Budget("deadline exceeded")
sleep(e.retry_after or retry_delay(i))
raise Exhausted(max_attempts)
三件事必须同时有:退避(别立刻重打)、抖动(别让所有客户端同一时刻重试)、总时长上限(否则重试把一次等待叠加成数倍)。反过来,重试会放大限流:上游已因过载返回 429,重试等于继续加压。限流场景的重试次数应少于故障场景,且优先遵循服务端给出的等待时间。
4. 限流:分清 429 与别的错误
def handle_429(resp, limiter):
ra = resp.headers.get("Retry-After") # 秒数或 HTTP 日期
wait = parse_retry_after(ra) if ra else limiter.next_slot()
limiter.penalize(wait) # 反馈到本地令牌桶
return wait
| 信号 | 含义 | 动作 |
|---|---|---|
429 + Retry-After |
明确限流,给了等待时间 | 按值等待,不抖动 |
| 429 无头 | 限流但未给时间 | 用本地桶估计,退避加倍 |
| 403 / 401 | 鉴权或配额问题 | 不重试,告警 |
| 400 参数类 | 请求本身不合法 | 不重试,检查构造逻辑 |
| 503 | 上游过载或不可用 | 短退避重试,随后降级 |
客户端自我限速用令牌桶最实用:按观测到的配额设速率,收到 429 时降速并逐步恢复。
class TokenBucket:
def __init__(self, rate, burst):
self.rate, self.burst, self.tokens, self.t = rate, burst, burst, now()
def take(self):
self.tokens = min(self.burst, self.tokens + (now() - self.t) * self.rate)
self.t = now()
need = (1 - self.tokens) / self.rate
sleep(max(0, need)); self.tokens = max(0, self.tokens - 1)
配额通常按每分钟请求数与每分钟 token 数双维度限制,只按 QPS 限速遇到长输出仍会被打到。
5. 失败分类与对应动作
| 类型 | 典型信号 | 动作 | 不可做的事 |
|---|---|---|---|
| 瞬时故障 | 连接重置、5xx | 退避重试(限次) | 无限重试 |
| 过载限流 | 429 | 等待 + 降速 | 立刻重打 |
| 上下文超限 | 400 含 length/token 关键词 | 缩短上下文后重发 | 原样重试 |
| 内容拦截 | 策略或过滤拒绝 | 按产品策略处理 | 反复改写绕过 |
| 超时 | 读超时、空闲超时 | 有限重试 → 降级 | 直接重试超大请求 |
| 服务不可用 | 持续 5xx、DNS 失败 | 切备用模型 → 降级 | 死等 |
// 分类后再决策,别把所有异常塞进同一个 catch
Action decide(LlmError e) {
if (e instanceof RateLimited) return Action.WAIT_THEN_RETRY;
if (e instanceof ContextTooLong) return Action.SHRINK_AND_RETRY;
if (e instanceof Timeout) return Action.DEGRADE_OR_RETRY_ONCE;
if (e instanceof AuthError) return Action.FAIL_FAST_ALERT;
return Action.DEGRADE;
}
分类的价值在于:只有"可重试"这类才消耗重试预算。把超时和 400 混着手动重试,既烧钱又延迟用户。
6. 降级的层次
| 层级 | 做法 | 质量影响 | 何时用 |
|---|---|---|---|
| L1 换小模型 | 同任务交给更快/更便宜的模型 | 推理深度下降 | 上游主模型持续不可用 |
| L2 减检索片段 | 从 top-k 降到更少条 | 答案可能不全 | 上下文超限或延迟敏感 |
| L3 模板化回答 | 用规则拼装的兜底答案 | 无生成能力 | 需要确定性回复的场景 |
| L4 明确告知 | 返回"暂时不可用,稍后重试" | 用户体验下降 | 全部手段失效 |
def answer(q, ctx):
try:
return llm.complete(prompt(q, ctx), tier="primary")
except (Timeout, Unavailable):
try:
return llm.complete(prompt(q, ctx[:2]), tier="small") # L1 + L2
except Exception:
log.degraded(q, reason="all_tiers_failed")
return TEMPLATE_FALLBACK.format(q=q) # L3
降级必须记录事件,否则线上看起来"一切正常",实际大量请求走了兜底。降级率比成功率更能反映真实体验。
7. 流式中断:留还是丢
| 场景 | 建议 | 理由 |
|---|---|---|
| 已输出大半,尾部断开 | 保留 + 标注不完整 | 用户已读到主要内容,丢弃等于白花成本 |
| 刚输出开头就断 | 丢弃并重试 | 残留半句比空白更糟 |
| 重连重发整段 | 会重复计费 | 输入 token 再次全额计费 |
def stream_with_resume(prompt, session, max_resume=1):
emitted = session.emitted_text # 已推送给用户的内容
try:
for chunk in client.stream(prompt):
session.push(chunk)
except StreamInterrupted:
if len(emitted) < MIN_KEEP: # 开头就断:丢弃重来
session.reset()
# 续写:把已输出内容作为前缀告知模型,避免重复生成
stream_with_resume(continuation_prompt(prompt, emitted), session, max_resume - 1)
要点:"半成功"要落在数据模型里,例如给消息加 complete: false 标记。前端据此提示不完整,后端据此决定是否计入评测——把半截回答当成功样本会持续污染指标。
8. 可观测:五个必须有的指标
| 指标 | 口径 | 用途 |
|---|---|---|
| 成功率 | 完整成功 / 总请求 | 基础健康度 |
| p95 / p99 延迟 | 分模型、分流式与非流式 | 长尾才是用户感知 |
| 重试率 | 发生重试的请求占比 | 偏高说明上游或配额有问题 |
| 降级率 | 走了 L1–L3 的请求占比 | 最容易被忽略的真实体验指标 |
| token 消耗 | 输入/输出分开统计 | 成本归因,识别重试浪费 |
metrics.observe("llm_call", {
"model": model_id, "stream": True, "outcome": outcome, # ok|partial|error
"attempts": attempts, "degraded": tier != "primary",
"in_tokens": usage.prompt_tokens, "out_tokens": usage.completion_tokens,
"latency_ms": elapsed,
})
标签里必须带 outcome(三分态),否则"部分成功"会被算成成功,成功率长期虚高。
9. 排错
| 现象 | 成因 | 处理 |
|---|---|---|
| 成本突然翻倍 | 多层重试叠加 | 统计 attempts 分布,收敛到单层重试 |
| 429 越重试越多 | 未读 Retry-After |
按服务端时间等待,降低令牌桶速率 |
| 网关报 504 但模型侧有计费 | 应用超时长于网关 | 应用超时收紧到网关以内 |
| 线上看着健康但用户抱怨 | 降级率高未监控 | 增加降级率指标与告警 |
| 流式断开后内容重复 | 重连未做前缀续写 | 传已输出内容作为续写前缀 |
10. 边界
| 问题 | 本层能解决吗 | 应去哪一层 |
|---|---|---|
| 输出质量差但不报错 | 否 | 提示词与评测 |
| 单次请求必然超上下文 | 部分 | 切分与预算 |
| 上游长期不可用 | 否 | 多供应商与容量规划 |
11. 自测
- 模型调用的三种终态分别是什么,缺了哪一种会让指标虚高?
- 流式场景下读超时的语义与非流式有何不同?
- 为什么应用侧超时要短于上游网关?
- 重试的三个必备要素是什么,缺了抖动会怎样?
- 降级四个层次里,哪一层的质量损失最不可控,为什么?
- 流式半成功后重连,怎样避免重复计费与重复内容?