跳到主要内容

LLM 请求生命周期是一个状态机 —— 像对待状态机一样对待它

· 阅读需 11 分钟
Tian Pan
Software Engineer

大多数团队将 LLM 请求处理视为一个线性函数:调用 API,检查异常,可能重试一次,然后返回结果。但在实践中,情况完全不是这样。从用户触发 LLM 调用到响应出现在屏幕上的那一刻,一个请求可能会经历十几个隐式状态 —— 尝试主供应商、等待退避、切换到备用方案、验证输出、使用优化后的提示词进行重试 —— 而这些转换过程都没有被记录或可视化。

其结果是,调试变成了在分散于各个服务的日志中进行事后追溯,对于 “这个请求实际上做了什么?” 这一问题,没有一个权威的答案。将 LLM 请求生命周期视为一个显式的有限状态机,是一种架构上的演进,它让你无需进行考古式的工作就能回答这个问题。

为什么隐式状态是生产环境中的隐患

考虑一个典型的生产环境中的 LLM 调用封装。它可能包含:

  • 一个带有指数退避(exponential backoff)的重试循环,用于处理瞬时错误
  • 当主模型返回 503 或达到速率限制时,回退到备用模型
  • 一个验证步骤,检查输出是否符合预期的模式(schema)
  • 当验证失败时,重写请求并重新提示的路径

其中每一个都是一种状态。它们之间的每一次转换都是一个事件。但大多数实现将所有这些逻辑编码为单个函数内部嵌套的 if-else 逻辑,并使用捕捉到部分转换却遗漏其他的临时日志记录。

实际的后果体现在三种反复出现的失败模式中:

静默回退(Silent fallbacks)。 主模型失败,系统路由到备用模型,用户得到了响应 —— 但响应的质量略微下降,或者延迟、成本比平时更高。由于没有人显式记录回退转换,这种模式在仪表盘中是不可见的,直到下游有人发现异常。

伪装的重试风暴(Retry storms in disguise)。 当供应商经历性能下降而非硬性错误时,重试可能在第三或第四次尝试时成功,表现为 “慢请求” 而非 “失败”。从外部看系统运行良好。但累积的重试成本在增加,而 P90 延迟在数周内默默爬升。

被误归类为成功的验证失败(Validation failures misclassified as successes)。 API 返回 HTTP 200,但响应未能通过模式验证。封装器重新提示一次,获得了有效的响应并将其返回。调用方看到的是成功。而该请求需要两次 LLM 调用而非一次、耗时两倍、成本也是两倍的事实,却未被记录。

这三种失败的根源相同:中间状态是不可见的。

映射实际状态

一个实用的模型为任何 LLM 请求定义了八种状态:

  1. PENDING —— 请求已排队但尚未发出
  2. DISPATCHED —— 请求已发送至特定的供应商/模型
  3. AWAITING_RESPONSE —— 等待流式输出开始或响应到达
  4. VALIDATING —— 响应已到达;正在检查输出
  5. RETRYING —— 上一次尝试失败;退避定时器正在运行
  6. FALLING_BACK —— 主路径被视为不可用;正在路由到备用方案
  7. CIRCUIT_OPEN —— 熔断器已触发;请求直接失败而不尝试联系供应商
  8. TERMINAL —— 请求达到最终结果(SUCCESS、VALIDATION_FAILURE、EXHAUSTED 或 DEGRADED)

这些状态之间的转换是事件,而不是日志消息。每次转换都有原因(触发因素)、目标状态(去向)和成本(增加的延迟、消耗的 Token、供应商的变更)。

使这个状态机显式而非隐式的关键在于,将每一次进入和退出状态记录为结构化事件,而不是散落在重试循环中的 log.info 副作用。

区分三类不同的故障

隐式实现变得复杂的另一个原因是,工程师将实际上需要不同处理方式的三种故障类型混为一谈:

瞬时基础设施故障(Transient infrastructure failures) 是短暂且可自我修复的:429 速率限制、短暂的 503 错误、网络超时、负载下的 TLS 握手超时。正确的应对措施是等待并重试同一个供应商。等待时间应从 1 到 2 秒开始,每次尝试翻倍,并包含随机抖动以避免惊群效应。在升级处理之前,合理的上限是 3 到 5 次重试。

供应商性能降级(Provider degradation) 是重试无法解决的失败模式,也是熔断器存在的意义。它表现为在错误率上升之前超时率攀升 —— P99 延迟激增而 P50 保持正常,或者在 30 秒硬限制下超时的请求逐渐增多。继续向降级的供应商发送请求会浪费预算并恶化延迟,而不会提高成功率。熔断器在滚动窗口内跟踪失败率(常见的阈值是 60 秒内 50% 的失败),当超过阈值时,将供应商移至 CIRCUIT_OPEN 状态。在探测请求确认恢复之前,新的请求将完全绕过该供应商。

质量故障(Quality failures) 是最常被忽视的。它们发生在供应商返回 HTTP 200 但输出未能通过验证时 —— 错误的模式、截断的内容、受限字段中的幻觉值、空的必需数组。针对同一模型重试相同的提示词在这里很少奏效;对于该提示词,失败是确定性的。正确的做法是转换到重新提示变体(语义等价但结构不同的请求),或升级到能力更强的模型。这两条路径都需要表示为显式的转换,而不是隐藏在验证函数内部且未记录的第二次调用。

让每一次状态转换都变得可观测

一个显式的状态机为每个请求生成追踪记录,无需进行日志考古即可回答以下问题:

  • 首先尝试了哪个供应商?后续尝试的顺序是怎样的?
  • 在成功或升级处理之前进行了多少次重试?
  • 每次尝试的超时或完成延迟是多少?
  • 请求是否因验证失败而需要重新提示(re-prompt)?进行了多少次?
  • 在路由时,该供应商的熔断器(circuit breaker)是否处于开启状态?
  • 最终结果是什么?是由什么原因导致的?

这种追踪是一个结构化对象,而不是一系列日志行。它包含请求 ID、一系列带有时间戳和原因代码的状态转换,以及一个带有结果元数据(消耗的 token 数、总延迟、尝试过的供应商、降级深度)的最终状态。

可观测性的价值是累积的。单条追踪告诉你一个请求发生了什么。聚合追踪则告诉你系统正在发生什么。降级率(fallback rates)上升表明在仪表盘出现错误之前,主要供应商已经开始退化。平均降级深度(请求在成功之前在降级链中走了多远)的增加则预示着系统性压力。重新提示次数的分布情况可以展示哪些提示词模板(prompt templates)存在结构性的输出质量问题。

在重试和降级是隐式的系统中,这些都无法被察觉。

实现原则:将执行与观测分离

构建此类系统时的一个现实陷阱是将状态记录逻辑与执行逻辑耦合在一起。如果你在重试循环内部添加 trace.add_event("retrying"),就会导致观测代码难以测试、难以替换,并与它原本要观测的业务逻辑纠缠不清。

更整洁的方法是将状态机实现为一个独立的层,由它驱动执行但不直接执行。状态机知道请求处于哪个状态,以及哪些转换是合法的。执行层负责调用供应商。状态机记录转换,并根据结果决定下一个状态。

这种分离还使得重试策略可以独立于观测策略进行配置。当你希望为不同类型的请求(如面向用户的交互式查询与后台批处理作业)设置不同的重试预算,而又不希望重复编写插桩(instrumentation)代码时,这一点至关重要。

根据生产经验,有一个值得注意的影响:在请求路径中进行同步日志记录是危险的。在请求过程中同步地将每个状态转换记录到数据库,会在团队察觉问题之前就让 p99 延迟翻倍。观测事件应该异步发送到队列或流中,在关键路径之外进行处理。

你真正能获得什么

采用这种模式的团队通常会按顺序报告以下益处:

首先,平均调试时间显著下降。当请求表现异常时,追踪记录会清晰地显示它经历了哪些状态以及原因,无需猜测。“为什么这个响应延迟高且质量低?”这类问题可以在几秒钟内得到解答,而不需要进行跨服务的日志联接(log join)。

其次,成本归因变得可能。由于每一次 LLM 调用都标记了其原因(首次尝试、第 N 次重试、深度为 M 的降级、因验证失败而重新提示),token 的支出可以被分解。团队通常会发现,总 token 成本的 5% 到 15% 是重试和重新提示的开销——这些开销在以前是不可见的,因此也无法优化。

第三,供应商的可靠性变得针对请求类型可衡量。一个在短文本创意生成上错误率很高、但在结构化提取任务上可靠性极佳的模型,会在聚合追踪中表现出这种模式。这使得路由决策可以基于经验数据,而非供应商给出的 SLA 数字。

状态机框架强制执行了一个具有巨大价值的设计决策:每个请求必须有一个终态,每个终态必须有一个原因。在每个请求必须通过定义的终态退出的模型中,静默失败或无限循环的系统是不可能存在的。仅这一项约束就能消除整类生产漏洞——即那种请求从未真正失败但也从未真正成功,在产生虚无结果的同时持续消耗资源的情况。

韧性的无聊现实

实际的实现——状态枚举(state enum)、转换表、结构化事件发射器和追踪聚合器——并不复杂。这些模式早已成熟。带抖动的指数退避(exponential backoff with jitter)、带有半开探测状态的熔断器、硬超时、降级链、验证失败后的重新提示:这些都不是什么新奇的想法。

一直以来缺失的不是机制,而是模型。工程师实现了重试逻辑就认为大功告成了。他们不会问“这个请求现在处于什么状态?”,因为他们没有定义状态。其结果是,韧性(resilience)在理想路径下有效,但在最关键的时刻——当生产环境出现问题且你需要理解原因时——却变得无法调试。

将请求生命周期视为一个状态机。每个状态都有名称。每次转换都有原因。每个请求都有最终结果。这种纪律性是“可推导的韧性”与“只有在不需要解释时才起作用的韧性”之间的区别。

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