跳到主要内容

你的 try/catch 漏掉的 LLM 请求生命周期

· 阅读需 12 分钟
Tian Pan
Software Engineer

你的 LLM 技术栈可能产生的最危险故障返回的是 HTTP 200。JSON 解析正常。你的 Schema 验证通过。没有抛出异常。而响应结果却是完全错误的 —— 事实错误、结构错误、话说到一半被截断,或者是凭空捏造。

围绕 LLM API 调用编写的一个简单 try/catch 只能处理那些明显的故障:速率限制、服务器错误、网络超时。这些是可见的故障。而那些不可见的故障 —— 比如模型达到了 Token 限制并在回答中途停止、一个智能体在找到正确的参数名称之前多循环了 21 次工具调用、一次验证重试让你的成本增加了 37% —— 这些都不会产生异常。它们会产生结果。

解决方法不是更好的错误处理,而是将 LLM 请求生命周期建模为一个显式的状态机。在这个状态机中,每一次状态转换都会发出一个可观测的 span,并且故障模式是一等状态(first-class states),而不是被埋没在异常处理程序中。

LLM 请求生命周期的真实样貌

大多数生产环境中的 LLM 请求并不是单一的 API 调用。它们是一个包含至少七个不同阶段的流水线,每个阶段都有自己的故障模式:

路由 (Routing) —— 在 Token 到达模型之前,路由层会根据任务复杂度、延迟要求、成本层级和供应商健康状况选择要使用的模型和供应商。路由决策是一个需要追踪的独立状态:触发了哪个规则、选择了哪个模型以及原因。

请求准备 (Request preparation) —— 上下文窗口预算、提示词模板选择、工具 Schema 注入、对话历史裁剪。这里的故障是无声的:超过上下文窗口会产生 400 错误(不可重试),或者更糟的是,如果 Token 计数估算有误,请求会被截断而没有任何提示。

主要生成 (Primary generation) —— 实际的 LLM 调用。这个阶段包含其自身的子状态:连接建立、队列等待时间(决定了首个 Token 时间/TTFT)以及解码阶段(每个输出 Token 的时间)。这些是需要不同优化的不同问题,但如果你只测量总请求时长,它们就是不可见的。

响应解析与 Schema 验证 (Response parsing and schema validation) —— 原始的 LLM 输出并非可信输出。对于结构化流水线,这意味着 JSON 解析、Schema 验证和语义验证。这个阶段本身就是一个微型状态机:已生成 → 已验证 → (有效 | 无效 → 修复提示词 → 已生成)

重试循环 (Retry loop) —— 针对速率限制、服务器错误或验证失败:带有抖动的指数退避、供应商故障转移或提示词变体重试。如果没有显式的状态追踪,每一次重试尝试都是不可见的 —— 你只能看到最终的成功或失败。

降级路由 (Fallback routing) —— 当主要生成失败时:次要供应商 → 更小的模型 → 缓存响应 → 基于规则的降级响应。降级链中的每一跳都是一个独立的状态,具有自己的延迟和成本概况。

升级 (Escalation) —— 在重试和降级耗尽后:人工队列、任务推迟或带有用户通知的安全降级响应。在大多数代码库中,升级只是一个 catch 子句,而不是一个命名的状态。

当你将这个流水线视为一个单一且不透明的函数调用时,你可以测量它的结果(成功或失败),但你无法测量内部发生了什么。

当前错误处理机制遗漏的状态

try/catch 处理的故障是简单的:429(速率限制)、500(服务器错误)、502/503/504(网关错误)、网络超时。这些会抛出异常,出现在错误日志中,并触发报警。

而那些导致实际生产问题的故障不会抛出异常:

finish_reason: "length" —— 模型在回答中途达到了 Token 限制并停止。HTTP 响应是 200。JSON 解析正常。如果不显式检查 finish_reason,你就会将截断的响应作为完整响应提供给用户。这并非理论推测:至少在一个知名的网关库中存在一个已知 Bug,当这种情况发生时,它会静默丢弃工具调用响应。

智能体中无声的重试膨胀 (Silent retry inflation in agents) —— 一个关于 AI 旅游智能体的记录案例研究显示,系统为了完成一个大约需要 28 次调用的任务,进行了 49 次 LLM 调用。每次调用都返回了 200。根本原因是参数名称不匹配 —— 智能体依次尝试了 camelCase、小写,然后是下划线变体,每一次都在语义层失败。智能体最终生成了正确的行程。没有报错。在每天 1,000 次运行的情况下,每个任务额外增加的 21 次调用所产生的复利成本,每年大约会导致 9,271 美元的可避免支出。

验证语义漂移 (Validation semantic drift) —— 响应通过了 JSON Schema 验证,但数值是错误的。一个 confidence_score 字段包含 0.99 —— 这不是计算出来的,而是幻觉产生的。字段存在,类型正确,但数值错误。这需要将语义验证作为一个独立的生命周期状态。JSON Schema 验证是必要的,但并不充分。

幻觉产生的成功 (Hallucinated success) —— 加拿大航空(Air Canada)的聊天机器人发明了一项并不存在的丧亲票价退款政策。API 返回了 200。响应在语法上是正确的,并且结构与真实政策一致。法庭后来判定加拿大航空对其聊天机器人的输出负责。OpenAI 的 Whisper 转录工具在医疗环境中使用时,被发现在约 1% 的样本中捏造短语 —— 其中近 40% 具有临床危害。这些不是基础设施故障,而是披着成功外衣的语义故障。

供应商性能退化但未彻底失效 (Provider degradation without failure) —— Anthropic 的 Claude API 错误率从 2025 年 6 月的 3.2% 攀升至 2025 年 9 月的 11.7%。测量总运行时间的系统将其视为可靠性事件。仅测量实际失败请求的系统看到的则更少。拥有多供应商路由的系统在同一时期的有效可用性为 99.7% —— 因为它们通过熔断器状态知道何时在每个请求失败之前停止向性能退化的端点发送流量。

将生命周期建模为状态机

一个明确的 LLM 请求状态机如下所示:

IDLE → ROUTING → PREPARING → CALLING_PRIMARY
↓ 成功 ↓ 429/5xx
VALIDATING BACKING_OFF → CALLING_PRIMARY (最大 N 次)
↓ 有效 ↓ 无效 ↓ 耗尽
COMPLETE REPAIR_RETRY → CALLING_PRIMARY CALLING_FALLBACK
↓ 成功
VALIDATING → COMPLETE
↓ 耗尽
CALLING_CACHE
↓ 命中 ↓ 未命中
DEGRADED_RESPONSE ESCALATING → HUMAN_QUEUE

这种模型具有几个 try/catch 方法所不具备的重要属性:

防止非法转换。 你无法从 VALIDATING 回到 ROUTING。你无法从 CALLING_CACHE 回到 CALLING_PRIMARY。通过使合法转换显式化,可以使非法转换在无意中变得不可触及。

每个状态都有名称。 ESCALATING 不是一个 catch 块——它是一个一等公民状态,拥有自己的 span、自己的指标和自己的告警阈值。区别在于:在没有埋点的情况下,你无法针对 catch 块的频率设置告警;但在命名状态上,你可以设置 Prometheus 计数器。

状态持久化变得简单直接。 为状态机设置检查点(Checkpointing)意味着记录当前状态加上累积的上下文。如果服务器重启时 Agent 处于 CALLING_FALLBACK 状态,它可以从该状态恢复,而不是重新启动整个请求。像 LangGraph 这样的框架实现了这一点:在每个节点之后将检查点保存到 Redis 或 Postgres,这样 Agent 就能在重启后生存,而无需重复执行已完成的工作。

重试预算是有界且可见的。 状态机为每个状态设置了显式的最大重试次数。重试耗尽是一个名为 CALLING_FALLBACKESCALATING 的显式转换,而不是一个无限循环,直到异常在调用栈中传播得足够远才终止。

在应用于一项研究基准的 SQL 生成任务中,基于 FSM 的编排方法将 GPT-3.5 的任务成功率从 50.7% 提高到了 63.7%,同时将 Token 成本降低了 5 倍——因为显式状态可以在每个失败点启用针对性的恢复策略,而不是对整个任务进行通用的重试。

使用 OpenTelemetry 为每个状态转换进行埋点

OpenTelemetry GenAI 语义约定于 2024 年发布,现在已获得 Datadog、Honeycomb 和 New Relic 的原生支持,它为 LLM 操作定义了四种 span 类型:inferenceembeddingsretrievalexecute_tool。每一个都应该是覆盖整个请求生命周期的根 trace 的子 span。

标准属性包括 gen_ai.usage.input_tokensgen_ai.usage.output_tokensgen_ai.response.finish_reasonsgen_ai.request.modelfinish_reasons 属性尤为重要:将 "length" 作为一个独立的结束原因进行追踪,而不仅仅是 "stop",可以暴露前文提到的截断问题。

标准尚未定义的是重试和备选(fallback)状态的属性。这些需要自定义属性:

  • llm.retry.attempt — 当前重试次数(0 = 首次尝试)
  • llm.retry.reasonrate_limit | server_error | validation_failure | timeout
  • llm.fallback.level — 0(主模型), 1(第一备选), 2(缓存), 3(降级)
  • llm.fallback.trigger — 触发备选转换的原因
  • llm.circuit_breaker.stateCLOSED | OPEN | HALF_OPEN
  • llm.validation.attempt — 验证-修复-重试循环的次数
  • llm.validation.error — 具体的 Schema 验证错误信息

这些属性将你的可观测性从“这个请求成功了吗?”提升到“这个请求经过了哪些状态,在每个状态中停留了多久,以及哪种失败模式触发了每次转换?”

从这些埋点中获得的最有用的派生指标是:重试延迟占总请求延迟的比例。如果你的 p95 延迟中有 40% 是重试等待时间,那么问题在于提供商的可靠性,而不是模型延迟。如果 15% 是验证修复循环,那么问题在于 Prompt 或 Schema 设计。在总耗时直方图中,这些看似相同的问题实际上是完全不同的。

基于状态监控的实用告警阈值

一旦对状态转换进行了埋点,你就可以根据有意义的信号而不是滞后指标进行告警:

按原因分类的重试率 — 当任何请求类别的重试率超过 5% 时发出告警。每个 Prompt 模板持续超过 1% 的 validation_failure 重试率是一个信号,表明 Prompt 或 Schema 需要重新设计,而不是你需要增加更多重试次数。

备选激活率 — 当备选激活率超过 2% 时发出告警。在这个比例下,你主提供商的可靠性已在实质上影响用户,需要审查熔断器配置。

熔断器状态变化 — 当熔断器转换到 OPEN 状态时立即告警。从提供商开始降级到你的熔断器开启之间的延迟,就是用户经历失败的时间窗口。追踪熔断器开启的时间和原因,是缩短该窗口最快的路径。

finish_reason: "length" 频率 — 当该比例超过响应的 0.5% 时发出告警。在这个比例下,你存在系统性的 Token 预算配置错误,导致相当一部分面向用户的响应被静默截断。

升级率 — 当请求达到 ESCALATING 的比例超过总量的 0.1% 时发出告警。升级到人工审核或降级响应是最后的手段;它应该是罕见的。升级率上升是重试和备选配置对于当前提供商可靠性而言过小的最早信号。

状态机追踪带来的可观测性优势不仅限于告警。通过在每个状态转换处设置 span,你可以将重试延迟与生成延迟分开衡量,追踪哪个备选层级对你的月度 Token 账单贡献最大,并识别哪些 Prompt 模板始终需要验证修复循环。对于将 LLM 调用视为不透明函数的监控方式来说,这些信号是不可见的。

弥合差距

在 LLM 应用中,“API 调用成功”与“产品正常运行”之间的差距比大多数软件系统都要大,因为许多故障模式都会产生看起来有效的输出。缩小这一差距需要具备三点:一个命名了每个状态的生命周期模型、在每次状态转换时发出 span 的插桩 (instrumentation),以及在状态级信号聚合成用户可见的故障之前触发的告警。

状态机不需要很复杂。即使是一个极简版本 —— 区分 CALLING_PRIMARYRETRYINGCALLING_FALLBACKDEGRADED_RESPONSE —— 其可观测性也远高于单个 try/catch。增加这些状态的成本是每次转换产生一个 span。而收益在于,对于每一个“成功”的请求,你都能了解它为了达成这一结果实际经历了什么。

References:Let's stay in touch and Follow me for more thoughts and updates