ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

深入学Agent Harness工程(11):Error Recovery把错误变成执行路径——TaoToken统一Key下的RecoveryState与指数退避配置骨架

深入学Agent Harness工程(11):Error Recovery把错误变成执行路径——TaoToken统一Key下的RecoveryState与指数退避配置骨架 1. 一次请求失败为什么整条 Agent 链就断了Agent Harness 跑到第 11 篇这个位置很多人会卡在同一个坎上System Prompt 已经能随工具、工作区、Memory 动态重组了工具注册表也接好了可只要模型调用抛一次异常整个循环就退出前面攒的上下文全白费。Error Recovery 要解决的就是这件事——把错误从“终止信号”变成“另一条执行路径”。它适合已经写过基础 Agent Loop、正在被 429、输出截断、上下文超长反复打断的开发者。我先把问题拆开看。同一条 chat_completion 主链上至少会撞到四类性质完全不同的故障模型正常返回但 finish_reasonlength说明输出在当前 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_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite 创建然后到 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 核对当前支持的模型名和 OpenAI-compatible 的 base_url 写法。控制台入口在 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 模型对话调试页在 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 可以先用它手动发一条请求确认 Key 和模型名对得上再进代码。注意错误恢复依赖服务返回真实的 status、error code 和 header。先用模型对话页确认通道正常再去调 RecoveryState能省掉一半“到底是网络还是代码”的排查时间。环境变量建议这样放主模型和 fallback 分开命名方便后面切换时读取export TAOTOKEN_API_KEYsk-你的key export TAOTOKEN_BASE_URLhttps://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_afterNone, cfgNone): 优先采用 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, cfgcfg) 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, cfgcfg)) continue raise raise RuntimeError(Max retries exceeded)这里有个我踩过的坑调用 with_retry 时如果用lambda mtmax_tokens, mdlstate[recovery][current_model]: chat_completion(...)默认参数会在创建 lambda 那一刻把模型名冻结下来。即使后面把 current_model 改成 fallback同一次 with_retry 里后续重试用的还是旧模型日志显示“switching”请求根本没换。正确做法是让 lambda 在执行时再读状态response with_retry( lambda: chat_completion( modelstate[recovery][current_model], # 执行时读取不冻结 systemsystem, messagesmessages, toolsopenai_tools(TOOLS), max_tokensmax_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_reasonlengthhas_escalatedFalse上调 token 上限相同消息、更大预算再次 length已上调、续写 0保存文本并追加续写新增 assistant 与 usercontext_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_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite 拿 Key再对着 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 核对错误码和 finish_reason 字段想先手动验证模型返回用 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 发一条请求看真实响应如果要把这套恢复逻辑长期跑在编码或 Agent 任务上https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite 的 Coding Plan 更适合持续调用场景。不过恢复机制只能保住一次运行任务目标仍主要存在于当前 messages 和进程状态里。终端一关、历史一压缩或者换个会话“先完成 A 再开始 B”的依赖关系就没有独立载体了。下一篇要做的是给 Task 一个持久化结构让目标第一次脱离对话历史保存下来。
返回列表