尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Agent工具调用失败处理:从异常到结构化数据的工程实践

Agent工具调用失败处理:从异常到结构化数据的工程实践 1. 工具运行时的核心设计哲学1.1 为什么“失败是数据”不是一句口号做 Agent 开发的人迟早会撞上一个绕不过去的坎工具调用失败了然后呢大部分人的第一反应是——重试。重试不行就报错报错不行就终止。这个思路在传统后端开发里没毛病接口挂了就重试重试超限就抛异常天经地义。但放到 Agent 场景里这套逻辑会直接把你的智能体变成一个“玻璃心”——一次工具调用失败整个任务链断裂用户看到的就是一句冷冰冰的“执行出错请重试”。我在实际搭建 Agent 的过程中踩过这个坑。早期版本里我写了一个查天气的工具API 偶尔超时Agent 拿到超时错误后直接放弃了整个对话用户问“北京明天天气怎么样适合穿什么”它回一句“抱歉我无法获取天气信息”。但用户真正想要的是穿搭建议天气只是中间步骤。如果我把“超时”这个失败当作一条数据喂回给模型模型完全可以决定换个方式查、用历史数据推断、或者直接告诉用户“天气数据暂时拿不到但根据季节和一般规律建议你……”这就是“失败是数据”的核心含义工具运行时的每一次失败不是流程的终点而是下一轮推理的输入。1.2 传统工具调用 vs Agent 工具运行时的本质差异要理解这个设计得先看清楚 Agent 工具运行时和传统函数调用之间的根本区别。传统函数调用是这样的你调一个函数传参数拿返回值。返回值要么是成功的结果要么是异常。异常就是异常它不属于“结果”的一部分它是流程控制的一部分。Agent 工具运行时不一样。Agent 的每一次工具调用本质上是在和模型进行一轮“对话”。模型说“我要调这个工具参数是这些”运行时执行完把结果——不管成功还是失败——作为一条新的消息塞回对话历史然后模型基于这条新消息决定下一步做什么。这个差异带来的直接后果是失败信息必须被结构化必须能被模型理解必须携带足够的上下文让模型做出下一步决策。我见过太多 Agent 项目工具报错就返回一个{error: something went wrong}模型拿到这个信息完全懵——它不知道是参数错了、网络超时了、还是权限不够了。它唯一能做的就是重试同样的调用然后再次失败陷入死循环。1.3 失败分类哪些失败该吞哪些该吐不是所有失败都值得喂回给模型。我在实践中把工具失败分成三类失败类型典型场景处理策略可恢复失败网络超时、限流、临时不可用结构化返回让模型决定重试或换路参数错误参数格式不对、缺少必填项返回具体校验信息模型可自我修正不可恢复失败权限不足、资源不存在、逻辑死锁返回明确原因引导模型放弃该路径关键判断标准是这个失败信息能不能帮助模型做出更好的下一步决策能就喂回去不能就吞掉并返回一个更通用的提示。举个例子JSON Schema 校验失败你返回参数 age 应为整数实际收到字符串 25模型看到这个信息下一轮大概率会把25改成25。但如果你返回参数校验失败模型只能瞎猜。2. 工具运行时的核心架构拆解2.1 一次工具调用的完整生命周期要落地“失败是数据”这个理念得先搞清楚一次工具调用从发起到结束中间到底经历了什么。我把它拆成六个阶段第一阶段意图识别与工具选择。模型根据当前对话上下文决定是否需要调用工具以及调用哪个工具。这个阶段模型输出的是一个结构化的调用请求通常包含工具名和参数。第二阶段参数校验。运行时拿到调用请求后第一件事不是执行而是校验。用 JSON Schema 对参数做类型检查、必填项检查、范围检查。这一步能拦掉大量低级错误。第三阶段执行前准备。包括权限检查、资源锁定、超时设置、重试策略加载。这一步决定了工具能不能跑、怎么跑。第四阶段实际执行。调用底层函数或外部服务拿到原始结果或原始异常。第五阶段结果归一化。把成功结果和失败异常统一转换成模型能理解的结构化数据。这是“失败是数据”落地的关键环节。第六阶段回填对话历史。把归一化后的结果作为一条新消息追加到对话历史中触发模型的下一轮推理。这六个阶段里第二和第五阶段是最容易被忽视的。很多人只关注“怎么调”不关注“怎么校验”和“怎么返回”。2.2 JSON Schema不只是参数校验更是契约JSON Schema 在工具运行时里的角色远不止“校验参数”这么简单。它实际上是模型和工具之间的契约。我刚开始写工具定义的时候Schema 写得很随意type: object加几个properties就完事了。结果模型经常传一些莫名其妙的参数进来比如该传数组的传了字符串该传枚举值的传了自由文本。后来我把 Schema 写严格了情况立刻好转。一个完整的工具 Schema 应该包含这些信息{ name: query_weather, description: 查询指定城市的天气信息返回当前天气和未来三天预报, parameters: { type: object, properties: { city: { type: string, description: 城市名称如北京、上海 }, date: { type: string, format: date, description: 查询日期格式 YYYY-MM-DD默认为今天 }, unit: { type: string, enum: [celsius, fahrenheit], default: celsius, description: 温度单位 } }, required: [city] } }这里有几个细节值得说description不是写给人看的是写给模型看的。模型靠它来判断什么时候该调这个工具、参数该怎么填。描述写得越清楚模型调用越准确。enum是约束模型输出的利器。能用枚举就别用自由文本能限定格式就别放任。required明确告诉模型哪些参数必须提供减少“缺参数”类失败。实操心得Schema 的 description 字段我一般会写两遍——一遍给模型看说明用途一遍给自己看说明边界条件。模型看不懂边界条件但你自己维护的时候需要。2.3 运行时状态机从 pending 到 resolved 的完整流转工具运行时的内部状态管理我建议用一个显式的状态机来做。每个工具调用实例在任意时刻处于以下状态之一pending已创建等待执行validating参数校验中executing执行中succeeded执行成功failed执行失败可恢复aborted执行中止不可恢复timeout执行超时状态流转的规则是pending → validating → executing → succeeded/failed/timeout。failed 状态可以重新进入 pending重试aborted 是终态。为什么要用状态机因为“失败是数据”要求你能区分“这次失败是暂时的还是永久的”。状态机让这个判断变得明确——failed 可以重试aborted 不行。我在一个多工具协作的 Agent 项目里就是因为没有状态机导致一个已经权限不足的工具被反复重试了七次白白烧了一堆 token。后来加了状态机aborted 状态直接阻断重试路径问题解决。3. 失败数据的结构化设计与实操3.1 失败返回体的标准结构失败返回体长什么样直接决定了模型能不能用好这个信息。我经过多次迭代最终固定下来一个结构{ status: failed, error_type: timeout, error_code: TOOL_TIMEOUT_001, message: 查询天气服务在 5000ms 内未响应, retryable: true, suggestion: 可以尝试缩短查询范围或稍后重试, context: { tool_name: query_weather, attempt: 1, max_attempts: 3, elapsed_ms: 5000 } }这个结构里每个字段都有明确用途status让模型一眼知道这次调用没成功error_type失败的大类模型可以据此选择策略error_code精确的错误码方便排查和日志分析message人类可读的描述模型也会读retryable明确告诉模型能不能重试避免瞎试suggestion给模型的建议这是提升 Agent 智能感的关键context执行上下文帮助模型理解失败发生的场景注意suggestion字段不要写得太具体否则模型会机械照搬。写方向性的建议让模型自己决定具体怎么做。3.2 错误码体系的设计原则错误码不是随便编的。我建议按“领域 类型 序号”三段式来设计领域TOOL工具层、PARAM参数层、AUTH权限层、NET网络层类型TIMEOUT、INVALID、MISSING、DENIED、CONFLICT序号三位数字从 001 开始比如PARAM_INVALID_003表示参数层第三个无效参数错误。这套体系的好处是模型可以通过错误码前缀快速判断失败性质。看到PARAM_开头它知道要改参数看到NET_开头它知道要等或换路看到AUTH_开头它知道这条路走不通了。我在实际项目里维护了一张错误码对照表每次新增工具时同步更新。这张表后来成了排查线上问题的第一手资料——用户反馈 Agent 行为异常我先看错误码分布基本能定位到是哪类失败导致的。3.3 把失败信息喂回模型的三种方式失败信息怎么喂回给模型有讲究。我试过三种方式各有适用场景方式一直接追加到对话历史。把失败返回体作为一条tool角色的消息追加进去。这是最标准的方式适用于大多数场景。模型在下一轮推理时能看到完整的失败信息。方式二包装成系统提示。把失败信息包装成一条system消息强调其重要性。适用于需要模型特别关注某类失败的场景比如连续失败三次后用系统提示告诉模型“该工具已连续失败请考虑替代方案”。方式三摘要后注入。当失败信息很长时先做摘要再注入。适用于失败返回体包含大量堆栈信息的场景避免占用过多上下文窗口。我一般默认用方式一只有在需要强调或信息过长时才用方式二和方式三。方式二用多了会让模型对系统提示脱敏反而降低效果。4. 重试策略与降级路径的工程实现4.1 指数退避重试的正确打开方式重试不是简单地“再来一次”。我见过最粗暴的重试是for i in range(3): try: call() except: pass这种重试在 Agent 场景里是灾难——它不考虑失败原因不考虑时间成本不考虑模型是否还在等。正确的重试策略应该包含这些要素退避算法指数退避基础延迟 500ms每次翻倍加随机抖动最大重试次数默认 3 次可配置可重试错误白名单只有特定错误码才重试总超时预算整个重试过程不能超过某个总时长import time import random def retry_with_backoff(func, max_attempts3, base_delay0.5, max_total10.0): start time.time() for attempt in range(max_attempts): try: return func() except RetryableError as e: elapsed time.time() - start if elapsed max_total: raise if attempt max_attempts - 1: raise delay base_delay * (2 ** attempt) random.uniform(0, 0.1) time.sleep(delay) raise MaxRetriesExceeded()这段代码的关键在于max_total参数。没有总超时预算的重试在 Agent 场景里会让模型等太久用户体验极差。4.2 降级路径当重试也救不了的时候重试失败之后怎么办直接报错那“失败是数据”就白做了。正确的做法是走降级路径。降级路径分三种工具级降级换一个功能相似的工具。比如查天气的主工具挂了切到备用数据源。这需要你在工具注册时就维护好“等价工具”的映射关系。参数级降级放宽参数限制。比如精确查询失败改成模糊查询实时数据拿不到改用缓存数据。策略级降级改变整体策略。比如从“必须拿到数据才能回答”降级为“基于已有信息给出建议”。我在一个电商客服 Agent 里用过策略级降级。用户问“我的订单到哪了”物流查询工具挂了Agent 没有直接说“查不到”而是回复“物流系统暂时繁忙根据你的下单时间预计明天送达你可以稍后再查”。用户满意度反而比直接报错高。4.3 重试与降级的决策树什么时候重试什么时候降级什么时候放弃我画了一棵决策树实际跑下来效果不错失败发生 → 检查retryable字段retryabletrue→ 检查重试次数是否超限未超限 → 退避后重试已超限 → 检查是否有降级路径有降级路径 → 执行降级无降级路径 → 返回最终失败引导模型放弃该路径这棵树的关键在于第 4 步。降级路径不是自动执行的而是作为“建议”喂回给模型让模型决定是否走。因为降级本身可能带来副作用模型需要综合判断。5. 常见问题与排查技巧实录5.1 模型陷入重试死循环怎么办这是最常见的问题。模型拿到失败信息后反复重试同一个调用烧光 token 也没解决问题。排查思路先看失败返回体里的retryable字段是不是一直是true。如果是说明你的重试策略没有正确标记“不可重试”的失败。再看suggestion字段如果建议太模糊模型会倾向于重试而不是换路。解决方法在运行时层面加一个“同一工具连续失败计数器”。连续失败超过阈值我一般设 3 次强制把retryable置为false并在返回体里加一条action_required: switch_strategy明确告诉模型必须换路。5.2 失败信息太长导致上下文爆炸有些工具的失败返回体包含完整堆栈动辄几千 token。喂回给模型后上下文窗口迅速被占满。解决方法在归一化阶段做截断和摘要。堆栈信息只保留最顶层的三行其余用... (truncated)代替。同时把详细堆栈写到日志里需要时再查。我一般会设一个阈值失败返回体超过 500 token 就触发摘要。摘要用规则做不用模型做——模型做摘要太慢而且可能引入新的不确定性。5.3 参数校验失败但模型不改参数模型拿到“参数 age 应为整数”的提示后下一轮还是传字符串。这种情况通常是因为 Schema 的 description 写得不够清楚或者模型对参数格式的理解有偏差。解决方法在失败返回体里直接给出正确格式的示例。比如message: 参数 age 应为整数正确示例25。模型看到具体示例修正概率大幅提升。5.4 工具执行成功但结果为空这不是失败但比失败更麻烦。工具返回了空结果模型不知道是“真的没有数据”还是“查询出了问题”。解决方法在结果归一化阶段对空结果做特殊标记。返回{status: succeeded, data: null, empty_reason: no_match}让模型知道这是正常的空结果不是异常。5.5 常见问题速查表问题现象可能原因排查方向解决手段模型反复重试同一工具retryable 标记错误检查失败返回体加连续失败计数器上下文窗口被占满失败信息过长检查返回体大小截断摘要模型不改参数提示不具体检查 message 字段加正确示例空结果被当异常缺少空结果标记检查归一化逻辑加 empty_reason重试耗时过长缺少总超时预算检查重试配置加 max_total降级路径不生效降级建议太模糊检查 suggestion写方向性建议6. 从失败数据到 Agent 能力提升6.1 失败日志的二次利用失败数据不只是喂给模型的也是喂给你自己的。我习惯把每次工具失败都记一条结构化日志包含工具名、错误码、参数、耗时、重试次数。攒一段时间后这些日志能告诉你很多事哪个工具最不稳定需要优化哪类参数最容易出错Schema 需要调整哪个时间段失败率最高可能是外部服务的问题我在一个项目里通过日志发现某个查询工具在每天上午 9 点到 10 点失败率飙升排查后发现是外部服务在这个时间段做批量任务导致响应变慢。后来我把这个时间段的调用改成了异步问题解决。6.2 用失败数据训练模型的工具使用能力如果你在做模型微调失败数据是极好的训练素材。把“失败返回体 模型的正确修正”作为一对训练样本能让模型学会更好地处理工具失败。我试过用这种方式微调一个小模型专门处理工具调用场景。微调后模型在遇到参数错误时主动修正的概率从 40% 提升到了 75%。这个提升在 Agent 场景里非常可观因为参数错误是最常见的失败类型。6.3 失败数据的监控与告警生产环境的 Agent必须有失败监控。我一般设三个告警阈值单工具失败率超过 10%告警单次对话内失败次数超过 5 次告警不可恢复失败aborted出现立即告警告警不是目的快速定位和修复才是。所以告警信息里要带足够的上下文——工具名、错误码、最近几次的失败详情。7. 一个完整的工具运行时实现示例7.1 核心代码结构把前面讲的东西串起来一个最小可用的工具运行时大概长这样class ToolRuntime: def __init__(self, tools, max_retries3, total_timeout10.0): self.tools tools self.max_retries max_retries self.total_timeout total_timeout self.failure_counter {} def execute(self, tool_name, params): tool self.tools.get(tool_name) if not tool: return self._build_failure(TOOL_MISSING_001, 工具不存在, False) validation self._validate(tool.schema, params) if not validation[valid]: return self._build_failure( PARAM_INVALID_001, validation[message], True, suggestion请根据提示修正参数后重试 ) start time.time() for attempt in range(self.max_retries): try: result tool.call(params) self.failure_counter[tool_name] 0 return self._build_success(result) except RetryableError as e: if time.time() - start self.total_timeout: return self._build_failure(TOOL_TIMEOUT_001, str(e), False) self._record_failure(tool_name) if self.failure_counter.get(tool_name, 0) 3: return self._build_failure( TOOL_LOOP_001, 该工具连续失败建议切换策略, False, suggestion请考虑使用其他工具或改变查询方式 ) time.sleep(0.5 * (2 ** attempt)) except FatalError as e: return self._build_failure(TOOL_ABORT_001, str(e), False) return self._build_failure(TOOL_MAXRETRY_001, 重试次数超限, False)7.2 关键设计点说明这段代码里有几个设计点值得展开失败计数器是全局的不是单次调用的。这样能跨调用追踪同一工具的连续失败情况避免模型在多次调用之间“钻空子”。总超时预算是硬约束。不管重试多少次总耗时不能超过total_timeout。这是保护用户体验的底线。失败返回体统一由_build_failure构造。保证所有失败返回体的结构一致模型不需要处理多种格式。成功返回体也走归一化。_build_success把原始结果包装成标准结构和失败返回体保持对称。7.3 和 Agent 主循环的对接工具运行时不是孤立的它要和 Agent 的主循环对接。对接方式很简单主循环拿到模型输出的工具调用请求交给运行时执行运行时返回结构化结果主循环把结果追加到对话历史触发下一轮推理。def agent_loop(messages, tools, model): while True: response model.chat(messages, toolstools) if response.tool_calls: for call in response.tool_calls: result runtime.execute(call.name, call.params) messages.append({ role: tool, tool_call_id: call.id, content: json.dumps(result) }) else: return response.content这个循环里result不管是成功还是失败都会被追加到messages里。这就是“失败是数据”在代码层面的体现——失败和成功走的是同一条路没有特殊分支。8. 几个容易踩的坑和我的应对8.1 不要把异常直接序列化Python 的异常对象不能直接 JSON 序列化。我见过有人写json.dumps({error: str(e)})结果str(e)里包含换行和特殊字符模型读起来很费劲。正确做法是提取异常的关键信息重新组织成结构化数据。异常类型、异常消息、发生位置这三样就够了其余的都写日志。8.2 不要忽略“部分成功”有些工具调用会返回部分成功的结果。比如批量查询十个城市八个成功两个失败。这种情况不要简单标记为失败而是返回一个包含成功和失败明细的结构让模型自己决定怎么处理。我一般用{status: partial, succeeded: [...], failed: [...]}这种结构。模型看到 partial会知道不是全盘失败可以基于成功部分继续推理。8.3 不要让重试阻塞主循环同步重试会阻塞 Agent 的主循环导致模型等待。如果重试耗时较长考虑改成异步重试——先把失败返回给模型让模型继续做其他事重试结果出来后再注入。这个改动比较大适合对响应速度要求高的场景。一般场景下同步重试加总超时预算就够了。8.4 不要忘记清理失败计数器失败计数器是全局的如果不清理一个工具偶尔失败一次计数器累加最终触发“连续失败”误判。我的做法是成功一次就清零同时设一个时间窗口超过窗口的失败记录自动过期。这个细节很小但不注意会带来很诡异的 bug——明明工具已经恢复正常了Agent 却还在说“该工具连续失败”。9. 工具运行时的测试策略9.1 失败路径必须单独测大部分人的测试只覆盖成功路径失败路径靠“线上碰”。这在 Agent 场景里很危险因为失败处理逻辑比成功处理复杂得多。我的做法是给每个工具写三组测试全成功、部分失败、全失败。全失败那组要覆盖各种错误类型——超时、参数错误、权限不足、资源不存在。9.2 用 mock 模拟各种失败真实的外部服务很难稳定复现特定失败。所以测试时用 mock人为制造各种失败场景。def test_timeout_returns_retryable(): mock_tool MockTool(side_effectTimeoutError()) runtime ToolRuntime({mock: mock_tool}) result runtime.execute(mock, {}) assert result[status] failed assert result[error_type] timeout assert result[retryable] is True这种测试跑起来快覆盖全是保证失败处理逻辑正确的关键。9.3 端到端测试要包含失败注入单元测试之外还要做端到端测试。端到端测试里要主动注入失败——比如让某个工具在第三次调用时必定失败看 Agent 能不能正确降级。我一般用环境变量控制失败注入测试环境开启生产环境关闭。这样同一套代码既能测失败路径又不影响生产。10. 我对“失败是数据”的几点个人体会做 Agent 开发这两年我越来越觉得“失败是数据”不只是一个技术方案更是一种设计思维。它要求你在设计工具的时候就把失败当成一等公民来对待而不是事后补一个 try-catch。我早期做 Agent 的时候工具定义写得很随意失败处理基本靠“报错就重试”。结果就是 Agent 看起来很笨——遇到一点挫折就放弃或者陷入无意义的重复。后来把失败数据结构化、把重试策略精细化、把降级路径显式化Agent 的“韧性”明显上来了。有一个细节我印象很深。之前有个用户问“帮我找一下附近评分最高的川菜馆”地图工具返回了空结果。旧版本 Agent 直接说“没找到”。新版本里空结果被标记为empty_reason: no_match模型看到这个标记后主动改问“要不要扩大搜索范围到整个城市”用户说好第二次查询就成功了。这个体验的提升就来自于把“空结果”也当成一种数据来处理。还有一点体会是失败数据的价值会随时间累积。刚开始你可能只是为了解决当下的失败但攒了几个月日志后你会发现这些数据能告诉你很多关于工具设计、模型行为、用户需求的信息。我现在每次优化 Agent第一件事就是翻最近的失败日志比看成功日志有用得多。最后分享一个小技巧如果你不确定某个失败该不该喂回给模型就问自己一个问题——“如果我是模型看到这条信息能不能做出比‘重试’更好的决策”能就喂不能就吞掉返回一个更通用的提示。这个判断标准我用了很久基本没出过错。
返回列表