☰
深入学Agent Harness工程(11):Error Recovery把错误变成执行路径——TaoToken统一Key下的RecoveryState与指数退避配置骨架
2026/9/27 21:04:25 网站建设 项目流程

1. 一次请求失败,为什么整条 Agent 链就断了

Agent Harness 跑到第 11 篇这个位置,很多人会卡在同一个坎上:System Prompt 已经能随工具、工作区、Memory 动态重组了,工具注册表也接好了,可只要模型调用抛一次异常,整个循环就退出,前面攒的上下文全白费。Error Recovery 要解决的就是这件事——把错误从“终止信号”变成“另一条执行路径”。它适合已经写过基础 Agent Loop、正在被 429、输出截断、上下文超长反复打断的开发者。

我先把问题拆开看。同一条 chat_completion 主链上,至少会撞到四类性质完全不同的故障:模型正常返回但 finish_reason="length",说明输出在当前 token 上限处被截断;请求还没拿到响应就因为输入上下文超长而失败;服务返回 429,短时间速率超限;服务过载或临时不可用,等一等可能恢复,也可能得换模型。

这四类不能共用一句except Exception: retry。截断要改输出预算或建续写消息,输入过长要减历史,限流要等待,而认证失败、余额不足、非法参数这类错误重放多少次都没用,必须改配置。所以 Error Recovery 的本质是一套分类加状态推进机制,try/except 只是它的外壳。RecoveryState 就是承载这套状态机的本地对象,它不进 messages,因为模型不需要“记住”自己刚才被限流过。

2. TaoToken 前置:统一 Key 与 API 通道准备

在写恢复逻辑之前,得先有一个稳定的调用通道,否则你连错误类型都分不清是网络问题还是服务返回。我用 TaoToken 的统一 Key 来跑这一篇的验证,原因是它把模型对话、Coding Plan、API Keys 管理放在同一个控制台里,切换主模型和 fallback 模型时不用改一堆环境变量。

你需要先拿到 Key。打开 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite 创建,然后到 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 核对当前支持的模型名和 OpenAI-compatible 的 base_url 写法。控制台入口在 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,模型对话调试页在 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite ,可以先用它手动发一条请求,确认 Key 和模型名对得上,再进代码。

注意:错误恢复依赖服务返回真实的 status、error code 和 header。先用模型对话页确认通道正常,再去调 RecoveryState,能省掉一半“到底是网络还是代码”的排查时间。

环境变量建议这样放,主模型和 fallback 分开命名,方便后面切换时读取:

export TAOTOKEN_API_KEY="sk-你的key" export TAOTOKEN_BASE_URL="https://taotoken.net/api" export AGENT_PRIMARY_MODEL="你的主模型名" export AGENT_FALLBACK_MODEL="你的备用模型名"

3. 可复制配置:config.toml 与 settings.json 骨架

恢复策略不该硬编码在 Python 里,否则调一次退避参数就得改代码。我把预算、退避、续写上限全部外置成 config.toml,把运行时状态和模型选择放进 settings.json,两者职责分开:config 是静态策略,settings 是可被 RecoveryState 读写的动态值。

先看 config.toml,重点是每个恢复动作都要有独立上限,不能用一个 retry_count 统管所有分支:

# config.toml —— Error Recovery 静态策略 [retry] max_retries = 5 # 单类瞬时错误最大尝试次数 base_delay_ms = 500 # 指数退避基数 max_delay_ms = 32000 # 单次等待上限 jitter_ratio = 0.25 # 抖动比例,避免同时重试 total_wait_budget_s = 120 # 整轮总等待预算 [output] default_max_tokens = 8000 escalated_max_tokens = 64000 max_continuations = 3 # 续写次数上限 [compact] keep_tail_messages = 5 # 应急压缩保留的尾部消息数 max_reactive_compact = 1 # 响应式压缩只执行一次 [model] primary = "你的主模型名" fallback = "你的备用模型名" switch_after_consecutive_529 = 3

再看 settings.json,它保存运行时可变的字段,RecoveryState 初始化时从这里读,恢复过程中回写:

{ "recovery": { "has_escalated": false, "recovery_count": 0, "consecutive_529": 0, "has_attempted_reactive_compact": false, "current_model": "你的主模型名" }, "budget": { "attempts_used": 0, "wait_seconds_used": 0, "tokens_used": 0 } }

五个 recovery 字段各自只约束一种变化:has_escalated 保证输出预算只上调一次,has_attempted_reactive_compact 保证压缩只做一次,recovery_count 单独计续写,consecutive_529 在成功后清零,current_model 决定这次到底调谁。把它们塞进一个模糊的 retry_count,一次 429 就可能吃掉后面的压缩机会,日志里也还原不出到底哪条路径被触发过。

4. 验证请求:指数退避与 finish_reason 分流实测

配置就位后,把恢复逻辑接到模型调用外侧。核心是 with_retry 包住 chat_completion,外层再捕获上下文超长和不可恢复错误。先写退避函数,优先用服务返回的 Retry-After,没有才自己算:

import time, random def retry_delay(attempt, retry_after=None, cfg=None): """优先采用 Retry-After,否则计算带抖动的指数退避。""" if retry_after: return float(retry_after) base = min(cfg["base_delay_ms"] * (2 ** attempt), cfg["max_delay_ms"]) / 1000 jitter = random.uniform(0, base * cfg["jitter_ratio"]) return base + jitter def with_retry(fn, state, cfg): """只对识别出的瞬时错误执行有限重试。""" for attempt in range(cfg["max_retries"]): try: return fn() except Exception as error: name = type(error).__name__.lower() message = str(error).lower() if "ratelimit" in name or "429" in message: delay = retry_delay(attempt, cfg=cfg) state["budget"]["wait_seconds_used"] += delay time.sleep(delay) continue if "overloaded" in name or "529" in message: state["recovery"]["consecutive_529"] += 1 if state["recovery"]["consecutive_529"] >= cfg["switch_after_consecutive_529"]: state["recovery"]["current_model"] = cfg["fallback"] time.sleep(retry_delay(attempt, cfg=cfg)) continue raise raise RuntimeError("Max retries exceeded")

这里有个我踩过的坑:调用 with_retry 时如果用lambda mt=max_tokens, mdl=state["recovery"]["current_model"]: chat_completion(...),默认参数会在创建 lambda 那一刻把模型名冻结下来。即使后面把 current_model 改成 fallback,同一次 with_retry 里后续重试用的还是旧模型,日志显示“switching”,请求根本没换。正确做法是让 lambda 在执行时再读状态:

response = with_retry( lambda: chat_completion( model=state["recovery"]["current_model"], # 执行时读取,不冻结 system=system, messages=messages, tools=openai_tools(TOOLS), max_tokens=max_tokens, ), state, cfg, )

接着处理 finish_reason。length 表示达到 token 上限,不是工具失败。第一次遇到时上调预算、丢弃这次截断文本、用相同 messages 重试;第二次才保存文本并追加续写提示:

if response.finish_reason == "length": if not state["recovery"]["has_escalated"]: max_tokens = cfg["escalated_max_tokens"] state["recovery"]["has_escalated"] = True continue if response.message.content: messages.append({"role": "assistant", "content": response.message.content}) if state["recovery"]["recovery_count"] < cfg["max_continuations"]: messages.append({"role": "user", "content": "继续,从上次中断处接着写。"}) state["recovery"]["recovery_count"] += 1 continue return

输入过长走另一条路,此时没有可用 choice,不能看 finish_reason,只能在外层 except 里做应急压缩。压缩时要注意别从并行工具结果中间切断:

def reactive_compact(messages, keep_tail): tail_start = max(0, len(messages) - keep_tail) while tail_start > 0 and messages[tail_start].get("role") == "tool": tail_start -= 1 tail = messages[tail_start:] return [{"role": "user", "content": "[Reactive compact] 早前对话已裁剪,请从中断处继续。"}, *tail]

那个 while 循环是关键:如果切点正好落在并行工具结果中间,它会向前回退,尽量保留发起这些调用的 assistant 消息,否则新历史会以孤立的 tool 消息开头,tool_call_id 找不到对应调用,消息结构直接损坏。

跑通后,用一张状态推演表验证控制流,不用真调端点也能确认分支对不对:

输入事件初始状态Harness 动作下一次调用变化
finish_reason="length"has_escalated=False上调 token 上限相同消息、更大预算
再次 length已上调、续写 0保存文本并追加续写新增 assistant 与 user
context_length_exceeded未压缩保留安全尾部消息数量减少
暂时 429attempt 0等待后重试请求主体不变
连续过载consecutive_529 增长计划切 fallback模型应变化
非瞬时错误不匹配恢复条件记录并退出本轮不再重放

实测下来,这套分流能把“一次异常终止任务”变成“错误被记录成一条可回放的路径”。但要注意,表格验证的是代码控制流,不是某个兼容服务的真实错误响应。真实接入时还得记录 status、error code、request ID、SDK 重试次数、应用重试次数、实际等待时间和最终模型,才能证明策略按预期跑。

5. 本篇常见错排查

错误一:所有 429 都进同一个重试循环。429 背后可能是请求速率、余额、组织或项目支出限制。只有暂时速率限制适合等待,余额不足重试一百次也没用。分类时要读结构化异常类型和 error code,别只靠字符串匹配 "429"。

错误二:SDK 自动重试和应用层重试叠加。很多 OpenAI-compatible SDK 自己会对合适错误重试。外层再包十次,实际请求次数是两者相乘,费用和时间都会爆。统一算重试预算,把 SDK 内部次数也计进去。

错误三:备用模型没真正切换。就是上面说的 lambda 默认参数冻结问题。修复后要写单元测试:构造“连续三次过载、第四次成功”,断言第四次调用记录里的模型名确实等于 fallback,而不是只断言 state 里的字段变了。

错误四:把有副作用的工具也自动重试。模型调用通常是无副作用的读取式请求,但 Agent Loop 后半段会执行 Bash、写文件、发消息。网络在服务已处理请求后断开时,Harness 无法只靠异常判断操作是否发生,原样重放可能创建两份资源。恢复层必须区分模型生成、工具执行和外部事务,写操作要用 idempotency key 或业务唯一键。

错误五:主线程 time.sleep 阻塞整个循环。单用户命令行能接受,多用户服务应改成异步等待或任务队列,并让取消信号、进程重启能中断重试。

错误六:字符串分类太脆弱。"429" 可能出现在普通错误文本里,"prompt" 和 "long" 也可能误判。生产实现优先用结构化异常类型、HTTP status、错误 code 与 header,建立“可重试、需变更请求、需人工处理、永久失败”的分类表。

6. 把错误变成可观测执行路径

恢复机制本身也会失败,所以它必须可观测。建议把每次恢复统一记成一个事件,包含 attempt、category、decision、delay_ms、model_before、model_after、request_id 和 outcome。这样一次任务为什么等了两分钟、为什么换模型、哪次压缩后成功,都能从 trace 还原。只打印“retry 3/10”没法支持费用核算和故障聚合。

如果你还在调接入和错误分类,先去 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite 拿 Key,再对着 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 核对错误码和 finish_reason 字段;想先手动验证模型返回,用 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 发一条请求看真实响应;如果要把这套恢复逻辑长期跑在编码或 Agent 任务上,https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite 的 Coding Plan 更适合持续调用场景。

不过恢复机制只能保住一次运行,任务目标仍主要存在于当前 messages 和进程状态里。终端一关、历史一压缩,或者换个会话,“先完成 A 再开始 B”的依赖关系就没有独立载体了。下一篇要做的,是给 Task 一个持久化结构,让目标第一次脱离对话历史保存下来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询