1. runAgentWithFallback 这类函数,到底在解决什么问题
1.1 从“一个回合”到“带兜底的一回合”意味着什么
接触过 Agent 系统的人,对“Turn”这个概念应该不陌生。一次 Turn 简单说就是 Agent 从接收输入开始,到完成输出或动作的完整闭环。但真实生产环境里,一个回合从来不是“输入→输出”这么简单。以我参与过的某客服场景项目为例,Agent 在一个回合里可能要做意图识别、工具调用、上下文汇总、生成话术,中间任何一环失败,整个回合就必须重来,用户看到的就是“机器人卡住了”或者“请稍后再试”。
runAgentTurnWithFallback 这种函数,就是把“执行一个回合”和“失败怎么办”这件事打包在一起。它给你的不是一个理想化的接口,而是一个默认处理了异常、超时、限流、降级路径的执行单元。换句话说,它先假设所有环节都会出问题,然后再去设计怎么让回合不可见地失败。这个思路,和我最早写单次工具调用函数的感受完全不同。早期我习惯先写正常流程,再补异常处理;现在反过来,设计阶段先列失败场景,再写主流程,函数一旦交付,线上出问题反而更少。
很多人会问,为什么不直接在业务代码里写 try-catch 呢?我个人的体验是,try-catch 能处理异常,却无法系统性处理“策略”问题。比如某工具超时了,是重试还是换一个等价工具?模型返回结果格式不对,是原地 re-prompt 还是清空上下文重来?这类决策如果散落在各个业务代码里,很快就失控;而把整段流程收敛进 runAgentTurnWithFallback,策略就变成可配置、可观察、可测试的东西了。
1.2 为什么需要 Fallback 单独成块,而不是外部重试
试验过几轮后你会发现,外部重试是最容易写、但效果最差的做法。外部重试的逻辑是“这个回合失败了,我重新调用一次”,它的问题在于:上一次调用产生的副作用怎么办?如果已经调用了外部工具、写入了日志、改变了状态,重试很可能造成重复执行。而 runAgentTurnWithFallback 做的事是把重试或降级放在“回合内部”,在回合状态还能被感知和控制的时候介入。
具体来说,一次带 Fallback 的回合,关心的不是“这个函数成功了没”,而是“在何种约束条件下,最终给用户交付了什么”。约束条件可能包括最大耗时、最大重试次数、可用工具集合、允许消费的 Token 预算。也就是说,Fallback 不是为了掩盖失败,而是为了在失败时仍然输出一个可用的、符合预期的结果。拿我刚才说的客服场景举例:用户问“我的订单什么时候到”,如果物流查询工具超时了,Fallback 会让 Agent 转而回答“订单查询系统暂时繁忙,稍后为你重试”,而不是直接抛异常。
2. 核心设计拆解:状态机、输入输出与降级策略
2.1 用状态机来理解一回合的完整生命周期
我在实际编码前习惯先画状态图,runAgentTurnWithFallback 的核心状态其实很清晰:初始态(待执行)、运行态(处理中)、等待子任务态(工具调用或模型生成挂起)、失败态(某一步异常)、降级态(启用备用方案)、终态(成功或失败)。这里重点说两处容易忽略的设计。
第一,等待子任务态与失败态的边界。工具调用发起网络请求后,Agent 进入等待;这个请求超时或返回异常时,不能直接跳到失败态,而要进入降级态做策略判定。判定结果可能是“继续等待”(重试)、“换一个工具”(替代路径)或者“标记失败”。把这两步分开,策略逻辑才有地方放。
第二,终态里要补充“部分成功”的概念。什么叫部分成功?比如一次回合要调用两个工具,第一个成功、第二个失败,但整体输出仍然可交付,只是少了一些信息。我设计的 runAgentTurnWithFallback 不会把这种情况简单归为失败,而是返回一个带 reported_failures 列表的结果对象。调用方可以根据这个列表决定下一步动作,比如“告诉用户部分信息已经查到,其余稍后再试”。这样做的好处非常明显:很多场景根本不需要严格意义上的重试,只需要把结果降级到可接受程度就够了。
2.2 函数签名与参数设计:配置是重点,不只是入参
先给一个经过多次迭代后的函数签名示例。这里用 Python 写,和具体语言无关,但思路通用。
def run_agent_turn_with_fallback( *, turn_input: TurnInput, executor: TurnExecutor, fallback_policy: FallbackPolicy, state_manager: StateManager, observer: TurnObserver | None = None, ) -> TurnResult: ...几个关键点解释一下:
turn_input是要处理的用户请求和当前上下文,不是裸字符串,而是一个结构化对象,里面要带上会话 ID、用户 ID、时间戳、辅助标记。这个习惯我强烈建议保留,因为 Fallback 逻辑通常要根据用户身份或会话状态做白名单或限流判断,裸字符串根本没有这些信息。executor是真正“干活”的组件,它封装了模型调用、工具调用、上下文整理。把它作为参数注入而不是在函数内部初始化,是为了测试时能够替换成 Mock。没有这一步,你很难在测试环境模拟超时和异常。fallback_policy把策略参数化,这是整个函数设计里的灵魂。我用一个 dataclass 来承载所有可配置项,看着更明确。
@dataclass class FallbackPolicy: max_attempts: int = 2 timeout_per_attempt: float = 15.0 enable_graceful_degradation: bool = True retryable_errors: tuple[type[Exception], ...] = ( TimeoutError, ConnectionError, RateLimitError, ) degrade_to_template: bool = True max_retry_wait: float = 5.0 use_exponential_backoff: bool = True这个 dataclass 的作用是把“什么情况可以重试”和“重试怎么等待”固化下来。实际项目中,我根据不同业务场景会传入不同的 policy。例如对延迟敏感的简单查询,timeout_per_attempt=5.0,max_attempts=1,直接走降级;对能容忍等待的复杂任务,max_attempts=3,指数退避上限到 10 秒。
state_manager负责维护会话级状态。为什么要单独拎出来?因为 Fallback 机制可能需要修改上下文,比如把失败的工具调用从上下文里移除,或者插入一条“刚刚查询失败”的系统提示。如果状态分散在 executor 内部,这个函数就没法干净地控制回合进度了。observer是可选参数,用来做可观测性上报。它会收到“开始某一步”“某一步失败”“进入降级分支”“回合结束”等事件。生产环境的排障,几乎全靠它输出的 trace。
2.3 降级策略的三层设计:重试、替代路径、最小可用输出
Fallback 不是一个单一动作,而是一个分层结构。我把它拆成三档,按顺序触发。第一档是重试,用于暂时性错误,比如瞬时网络抖动、限流。第二档是替代路径,用于某个组件能力不达标的场景,比如主工具不可用时,换成备选工具或换一个模型。第三档是最小可用输出,用于所有尝试都失败时,给用户一个体面的兜底响应。
必须强调的是:不要一上来就想“降级到模板”。你优先要做的仍然是把原路径救回来。我见过太多项目把 fallback 简写成“失败直接返回话术模板”,这其实丢掉了 agent 系统最值钱的部分——尽力完成真实任务的能力。所以策略里我设了一个degrade_to_template开关,只有当它开启时才允许最终输出模板,如果一个回合连模板都没有,那就必须返回失败对象,让上层决定是否给用户报错提示。
这样的分层逻辑如果都写在一段一级 try-except 里会非常乱,我会把它拆成几层嵌套的处理结构,这一点在后面代码部分会详细展示。
3. 核心实现与关键逻辑:一份可直接参考的代码骨架
3.1 主流程解析:每一一步都要能被观测
下面是我实际用过的简化版本,去掉业务细节之后,主干结构很稳定。
def run_agent_turn_with_fallback( *, turn_input: TurnInput, executor: TurnExecutor, fallback_policy: FallbackPolicy, state_manager: StateManager, observer: TurnObserver | None = None, ) -> TurnResult: attempt = 0 current_input = turn_input last_error: Exception | None = None while attempt < fallback_policy.max_attempts: attempt += 1 observer.on_turn_start(attempt=attempt, turn_id=turn_input.turn_id) try: result = executor.execute_turn( turn_input=current_input, state=state_manager.get_state(turn_input.session_id), ) observer.on_turn_success(attempt=attempt, result=result) return result except fallback_policy.retryable_errors as exc: last_error = exc observer.on_turn_failure(attempt=attempt, error=str(exc)) wait_time = _compute_wait(attempt, fallback_policy) state_manager.append_system_message( session_id=turn_input.session_id, content=f"上一步执行失败: {exc!s},尝试重试中。", ) time.sleep(wait_time) except Exception as exc: last_error = exc observer.on_turn_failure(attempt=attempt, error=str(exc), fatal=True) break if not fallback_policy.enable_graceful_degradation: raise RuntimeError(f"turn failed after {attempt} attempts") from last_error fallback_result = _run_fallback_path( turn_input=turn_input, executor=executor, policy=fallback_policy, last_error=last_error, observer=observer, ) return fallback_result如果你把它读了一遍,会发现两个很关键的设计:一是在一次回合里,重试不是简单重复完整流程,而是会把失败信息注入到上下文中,让模型知道刚才发生了什么。这个操作对 LLM 多轮对话尤其重要,因为它能引导模型不再重复上次的错误动作。第二个设计是所有分支都上报 observer,任何一步失败都能被追踪到,这种做法在线上排查时省了大量时间。
_compute_wait是等待时间计算函数,也是很多项目不重视但很重要的函数。建议按指数退避加随机扰动来实现:
import random def _compute_wait(attempt: int, policy: FallbackPolicy) -> float: if not policy.use_exponential_backoff: return policy.max_retry_wait / 2 base = 0.5 * (2 ** (attempt - 1)) jitter = random.uniform(0, 0.3) return min(base + jitter, policy.max_retry_wait)设定上限很重要,否则第三次重试可能等 2 秒、4 秒、8 秒,用户早就流失了。实测下来,对大多数真实 Agent 场景,重试等待在 0.3 到 3 秒之间最合适。
3.2 降级路径的实战写法:不简单返回模板,而是给模型一个备用机会
前面说了三层降级,那么_run_fallback_path内部就至少要有两套逻辑。第一套是给模型一次不看失败工具、只依赖已有上下文作答的机会,我称呼它为“上下文重建降级”;第二套才是直接输出固定模板。
def _run_fallback_path( *, turn_input: TurnInput, executor: TurnExecutor, policy: FallbackPolicy, last_error: Exception | None, observer: TurnObserver | None, ) -> TurnResult: rebuilt_input = TurnInput( turn_id=turn_input.turn_id, session_id=turn_input.session_id, user_message=turn_input.user_message, metadata=turn_input.metadata, skip_tool_calls=True, ) try: result = executor.execute_turn( turn_input=rebuilt_input, state=state_manager.get_state(turn_input.session_id), ) result.attempt_count = attempt result.used_fallback_path = True return result except Exception as exc: observer.on_graceful_degradation(error=str(exc)) if policy.degrade_to_template: return TurnResult( is_success=True, text=_default_fallback_template(), used_fallback_path=True, reported_failures=[str(exc)], ) raise这里最关键的是skip_tool_calls=True。它告诉执行器:不要再尝试调用任何工具了,直接让模型基于现有上下文回答。对很多场景来说,这是最优的降级方式——用户看到的不再是机械报错,而是 Agent 尝试解释“我现在查不到,但你之前的问题我可以基于已知信息回应一部分”。
我在线上见过的最成功的降级,是用户问附件上传失败的原因,Agent 在工具链路全挂的情况下,直接根据本地日志和上下文分析出“可能是网络策略限制”,然后给出操作建议。这个结果虽然不是通过实时工具获取的,但对用户却非常有用。它的前提,就是降级路径里给了模型一个不再强行调用工具的出口。
3.3 状态回滚:Fallback 最容易忽略的副作用问题
如果你只是把上面代码跑通,放到简单场景没问题;但放到真实生产里,很容易踩另一个坑——状态污染。举例来说,某个回合先调用了“创建工单”工具,之后模型生成话术超时了。重试时你会发现,工单已经被创建过一次,重试如果再调创建工具,就会产生重复工单。
解决这个问题,我认为必须在 executor 层设计幂等和回滚能力。至少要支持两类操作:一类是带幂等键的操作,每次副作用调用携带 request_id,外部系统去重;另一类是补偿动作,比如创建了什么、加错了什么,要有一个rollback(operation_id)的接口。在 runAgentTurnWithFallback 里,重试前会调用state_manager.begin_attempt()和executor.rollback_pending_ops(),保证重试不是“推倒重来”而是“干净重来”。
注意,我不是说所有副作用都需要回滚,有些动作是不可逆的(比如发短信、转人工)。这类动作应该在策略层做标记,比如policy.irreversible_prefix_actions,一旦执行过就把它们排除出可重试的范围,并在后续的 turn 中告诉模型“这个动作已经完成,不要再执行”。这些细节不处理好,Fallback 不但不能兜底,反而会火上浇油。
4. 典型落地场景与参数调优:拿真实场景说话
4.1 场景一:第三方大模型接口超时
这是最常见的场景。模型服务负载高、网络跨区域,响应时间从 2 秒直接涨到 30 秒。如果超时设得太短,正常任务也总是失败;设得太长,用户等待太久。
基于项目实测,我会把timeout_per_attempt设置在模型平均响应时间的 3 倍左右。假设平时 P95 是 6 秒,那么超时设 18 秒比较合理。重试次数不要贪多,两次到三次足够;三次都超时,说明服务端大概率有问题,继续重试只会恶化。
fallback_policy = FallbackPolicy( max_attempts=3, timeout_per_attempt=18.0, retryable_errors=(TimeoutError, ConnectionError), enable_graceful_degradation=True, max_retry_wait=4.0, )有一个心得:指数退避的起始间隔不要设太小,否则三次重试全部挤在最初几分钟内,服务端压力依然很大。第一个间隔建议 1 秒以上,第二次间隔 2-4 秒,第三次间隔不超过 8 秒。这个节奏对限流场景也适用,因为很多限流窗口本身就是秒级的。
4.2 场景二:Agent 内部工具调用失败
Agent 调工具是比模型超时更复杂的问题。工具失败有不同的失败原因,比如逻辑错误、权限不足、依赖服务异常。跑过一段时间后,我发现简单把所有工具失败都重试,是非常昂贵的决策。比如某个系统返回“权限不足”,重试一百次结果都一样;但返回“依赖服务暂不可用”,重试就有意义。
所以实践中我维护了一张工具失败码表,把错误类型映射到“可重试”或“不可重试”。这部分和 runAgentTurnWithFallback 配合的方式是:executor 内部抛出的异常里要带should_retry标记,然后这个函数在捕获异常后判断它是否属于retryable_errors。
class ToolExecutionError(RuntimeError): def __init__(self, message: str, tool_name: str, should_retry: bool): super().__init__(message) self.tool_name = tool_name self.should_retry = should_retry对不可重试的错误直接跳出循环,进入降级路径,尝试用其他已有信息作答。对可重试的错误才走重试。这个细分能让 fallback 的命中率显著上升,因为把大量“无效重试”过滤掉了。
4.3 场景三:请求速率限制(限流)
限流在重试策略里是个特殊存在。限流响应通常会告诉你 Retry-After 的秒数,如果忽略了它,按自己的指数退避节奏重试,很可能一直在撞限流窗口。我在接收高并发场景时,遇到过系统明明好了,但客户端还在按固定节奏疯狂重试的情况。
处理方式很简单:在 executor 的异常对象里带上retry_after字段,自适应覆盖 fallback_policy 的等待时间。能做到这一点,前提还是那个观点——异常要结构化,不要只传字符串。结构化异常让策略层有足够材料做决策。
class RateLimitError(Exception): def __init__(self, retry_after: float): super().__init__(f"rate limited, retry after {retry_after}s") self.retry_after = retry_after在_compute_wait里判断一下,如果异常带 retry_after,就优先使用它。
4.4 关键参数速查表
| 参数 | 推荐初始值 | 调参依据 | 注意事项 |
|---|---|---|---|
max_attempts | 2-3 | 任务重要性、服务稳定性 | 超过 3 次用户体验急剧变差 |
timeout_per_attempt | P95 时延 x3 | 耗时监控数据 | 不要低于 P50,否则频繁误判 |
use_exponential_backoff | True | 服务端承受能力 | 配合 jitter 使用 |
max_retry_wait | 4-8s | 限流窗口时长 | 小于 Retry-After 时会被持续限流 |
enable_graceful_degradation | True | 业务是否允许降低精度 | 金融计费等场景要谨慎 |
degrade_to_template | True | 是否有统一客服话术 | 有固定话术才开启 |
这些不是一次性定死的。我的习惯是每两周根据线上 trace 数据重新过一遍参数,看看超时率和降级率的变化,再针对性调整。参数调整最怕的是拍脑袋,合理做法是根据 observer 上报的分位数延迟和失败类型分布来做决策。
5. 常见问题与排查技巧:踩过的坑和对应的解法
5.1 问题一:重试竟然放大了故障
有段时间我在处理某个系统故障时,意外发现服务端已经被打得快不行了,可客户端还在不断重试,直接把恢复的机会给堵死了。后来统计到大概 40% 的失败请求是重复请求,才意识到问题出在策略过激。
解决方法是:把全局开关放进策略里,或者让策略支持“断路器模式”。具体来说,在调用_compute_wait和判断重试次数前,先检查一个全局计数器,如果最近 1 分钟失败率已经超过 60%,就不再发起重试,直接走降级。
def _circuit_breaker_is_open(policy: FallbackPolicy) -> bool: if not hasattr(policy, "failure_rate_threshold"): return False recent_attempts = observer.recent_failure_rate() return recent_attempts >= policy.failure_rate_threshold断路器不能永远打开,要有半开状态。比如 30 秒后允许 1% 的流量继续尝试,成功就恢复。这个机制能极大提升系统在极端情况下的自愈能力。我自己线上遇到过降温后留了 3 个可用实例,全靠这种熔断后才活下来。
5.2 问题二:降级结果反而更差,用户满意度下降
有的项目把降级路径设计成“不回工具,只回话术”,一降级用户就收到“系统繁忙”。这类设计最容易被业务方吐槽。原因在于直接切到模板,丢失了所有上下文和可用信息。
我的改进思路是,降级路径里要按顺序做三件事:先用上下文中已有的信息回答,再提供部分结果,最后才是纯模板。如果 Agent 在之前的对话里已经获取过用户订单信息,降级时完全可以回答“根据之前的订单记录,你的包裹状态是发货中,但物流轨迹暂时无法刷新。”
这个逻辑在代码里就是在执行降级任务时传入prefer_cache_info=True的标记,相当于引导模型把本地缓存/之前记录当作信息源。实际体验下来,降级后的用户满意率能提升很多。
5.3 问题三:边界条件与少量无法收敛的回合
不论重试策略多好,总会有少数回合就是无法成功。我的经验是,一定要给这个函数增加“最终明确失败”的出口,而不是无限循环。上面的代码里 max_attempts 和 graceful_degradation 已经覆盖了这种情况,但还有一类回合要特别小心:幂等性冲突导致的失败。比如“创建订单”这类操作,重试有可能造成重复下单,即使工具调用的请求已经成功,但响应超时导致客户端误判。
处理这种不透明失败的最佳方式是:在 executor 层对高危写操作增加查询确认步骤。重试前先检查上次写操作的状态,确认是否真的失败。这里同样依赖 request_id,它在第一阶段就已经发给下游了。如果下游说“已经收到”,重试就可以安全跳过写操作,直接继续后续生成环节。这个检查逻辑建议也放进state_manager的preflight_confirmation()方法里,统一管理,别散在业务代码各处。
5.4 排查技巧分享:如何快速定位失败环节
说了这么多坑,给一个排查流程:
- 优先看 observer 上报的 turn_lifecycle 事件,区分是“没有进入重试直接失败”还是“重试耗尽后失败”。
- 如果是重试耗尽,看每次尝试失败的错误类型分布,大概率是外部工具权限或下游服务故障。
- 如果是降级路径失败,重点检查模板和模型上下文大小,很多降级失败是因为上下文太长。
- 如果失败出现在某次重试之后,画像大概率是状态污染或幂等冲突,看 state_manager 的变更记录就能定位。
线上机器人出问题时,这个排查思路能帮你节省一半以上的时间。靠肉眼打日志的效率太低了。
6. 关于这个设计,我个人的一些经验式心得
写 Agent 系统这么久,我逐渐体会到 runAgentTurnWithFallback 这类函数远远不止是一个工程单元,它其实是系统稳定性的缩影。你设计的策略决定了系统在异常下的行为边界,而行为边界直接决定了用户最终的体感。把 Fallback 想得越复杂,系统就越脆弱;把 Fallback 想得太简单,系统的可用性就完全靠运气。这里的平衡点,就是你的策略是否足够结构化、是否具备可观测性。
最后分享一个小技巧:如果你现在维护的 Agent 系统里还没有这种统一回合函数,不要急着一步到位,先挑一个最简单的场景开始。比如第一版只做超时重试,不引入降级路径。跑两周,看失败样本,再逐步把降级策略加进去。一口吃成胖子通常会在线上翻车,而渐进式设计反而能稳定改进。