尧图精选

失败不是异常是数据:构建稳定AI Agent工具运行时

🕒 发布时间:2026/10/1 19:39:51 📁 来源:尧图网络
做工具运行时这个系列从 P01 写到 P04前面几篇分别聊了多步调用的编排、并发控制、还有上下文管理。今天这篇想认真聊聊失败。做 AI Agent、接模型工具调用的同行应该都有同感工具失败不是小概率事件而是常态。模型去请求一个接口网络抖动、服务端 502、证书校验不过、返回一堆 HTML 而不是 JSON这些都是每天的日常。你写个 try-except 把异常捕获住任务继续跑以为这样就完了真正决定一个工具运行时能不能上线、一个 Agent 产品能不能稳定输出恰恰就看你拿“失败”怎么办。这也是 P04 想聊透的核心思路工具运行时里失败不是异常失败是数据。1. 为什么工具运行时必须把“失败”当成数据来对待1.1 LLM 工具调用场景里失败不是“异常”而是常态输入传统后端开发里失败是 exception它的语义是“当前控制流中断跳转到异常处理逻辑”。但在 LLM 工具调用场景里这个语义完全不对。为什么因为工具调用的失败频率高到不能把它当成小概率事件。模型本身是概率性的工具的远端服务是分布式的再加上网络环境不可控一个 Agent 每跑一个任务可能要经历多次外部调用其中一次超时、一次限流、一次返回格式不符合预期都是很正常的事。如果你把失败当异常处理思维上就是“应该尽量少失败失败是 bug”。而实际上等模型把工具调用做多了你会发现工具链越复杂失败就越接近必然事件只是每次失败的点不一样而已。把失败当数据意味着换一种视角一次失败调用本身携带了大量有效信息。它记录了这次请求“因为什么没有成功”、目标服务的当前状态、网络的健康状况、接口返回的原始内容。这些信息不是用来丢弃的而是用来做后续决策的。比如一个 502 错误它本质上在告诉你“网关层有问题”可能是上游服务超时也可能是负载均衡把请求打到了一个不健康的节点。再比如证书验证失败它在告诉你 TLS 握手阶段就已经挂了域名、证书链、系统时间至少有一个环节出了问题。这些信号如果只是被捕获后打一行日志那它们就浪费了如果把它们数据化、结构化喂给模型模型就能在下一次决策中避开同样的坑。1.2 传统 try-except 思维在 Agent 场景里的三重失效我们写 Python、写 Java 习惯了 try-except但在 Agent 工具运行时里这套思维有很明显的三个失效点。第一个失效点是它只能保证“不崩”不能保证“继续走对”。异常处理的目的是中断当前操作、避免进程崩溃它不会告诉你下一步该调哪个工具、该换哪个参数、该不该降级到备选服务。Agent 需要的是下一步动作而异常只告诉你上一步失败了信息维度完全不够。第二个失效点是异常信息通常被记录下来但模型看不到。大多数工具函数的内部实现是调用远端接口远端挂了抛一个 exception外层捕获后打日志程序继续跑。这个日志人类运维会看但正在执行任务的 Agent 完全感知不到这次失败。也就是说同一个任务如果多次遇到同一个失败Agent 每次都是从零开始猜没有任何记忆和依据。第三个失效点更关键异常处理是静态设计而 Agent 面临的失败是动态演化的。你可以在代码里写十个 catch 分支但真实的失败样式可能有一百种而且很多失败是复合的——超时之后重试重试返回 502502 的响应体里又是 HTML 不是 JSON。你不可能靠人工枚举把所有这些场景都 catch 完。只有把失败转成数据用模型自己的推理能力去理解、去分类、去决策这个系统才具备应对未知失败的能力。1.3 “失败是数据”带来的三个核心收益第一是可观测性。失败一旦变成结构化数据它就可以被统计、被追踪、被回放。你可以知道今天哪个工具失败率最高哪个时间段网络最不稳定哪种错误类型占了多少比例。没有这些数据你调优 Agent 就全靠玄学。第二是可纠错性。把失败数据组装成反馈在下一轮 Tool Call 之前让模型看到“上一次为什么失败”模型就有机会自己修正。比如重试时换个参数、换个降级方案、干脆换一个工具。整个 Agent 就有了一个闭环的学习能力而不是每次失败都白失败。第三是可评估性。工具运行时做得好不好不能只看任务成功率还要看失败率、失败类型分布、平均纠错轮次、重试放大系数这些指标。只有把失败变成数据你才能给自己维护的这套工具链做量化体检。2. 失败的类型学从 502 到证书验证、解析失败把一个失败数据化第一步不是写代码而是先对失败做分类。我平时把工具运行时的失败分成四层每一层的处理思路和重试策略都不一样。最近大家都在聊的错误信息里其实就能找到很典型的例子比如“获取首页数据失败: exception: 伺服器错误 502: !doctype htmlhtml lang”以及“建立安全连接失败 由于不能验证所收到的数据是否可信,无法显示您想要查看的页面”。这两个都是很好的失败样本。2.1 网络层失败502、超时、连接重置网络层失败是所有失败里最常见的也是数据化收益最明显的。502 Bad Gateway 通常是网关或代理拿不到上游的有效响应它可能的原因很多上游超时、上游崩溃、代理配置错误。但有个很坑的点很多网关在返回 502 的时候响应体并不是标准 JSON而是像那行热词里那样直接给你一坨 HTML比如!doctype htmlhtml lang...。如果你不做处理直接把整个响应体当错误字符串丢出来那这个数据非常脏既有 HTML 标签又有异常堆栈模型的注意力很容易被噪声带偏。网络层失败的数据化至少要拆成几个字段错误类型timeout 还是 http_error、HTTP 状态码502、504、429、原始响应体的前 N 个字符、重试建议比如 502 建议退避重试超时建议增大超时上限。超时也分连接超时和读超时这两种的语义完全不同连接超时是目标服务根本连不上读超时是连上了但响应太慢。数据化的时候不能只写一句“timeout”要把阶段标清楚。连接重置也很有意思。TCP 层 RST 包往往意味着服务端直接断了连接可能是进程被杀、连接数打满、也可能是防火墙在干预。这种失败即使重试也很难立刻恢复所以我把连接重置归类为“不建议立即重试”的类型数据里应该标记一个较长的退避时间。2.2 安全层失败证书验证失败、TLS 握手失败安全层失败是另一类容易被忽视但很致命的问题。那行热词里“建立安全连接失败 由于不能验证所收到的数据是否可信,无法显示您想要查看的页面”翻译成技术语言就是 SSL/TLS 证书链校验失败。这类失败发生的时候请求根本不会真正到达业务接口因为 HTTPS 握手阶段就把请求挡住了。常见原因有三个证书过期、证书域名不匹配、中间代理或本地根证书库缺失。证书过期这个很典型尤其是第三方 API 的测试环境证书经常出现过期后接口还能 ping 通但一请求就报证书错误。域名不匹配则常常发生在你用了 IP 直连、但证书是为域名签发的场景。还有一个坑是公司内网代理做 HTTPS 拦截如果代理的根证书没有装到运行环境里你的代码在本地好好的部署到容器里就突然证书验证失败。这类失败的数据化重点不是记录“证书验证失败”这几个字而是要记录更细的信息哪个域名、证书是否过期、过期时间是什么时候、是不是自签名证书、有没有走代理。有了这些字段排查才能快。更重要的是这类失败不应该盲目重试因为重试一百次结果都是一样的除非你先更新证书或者改环境配置。数据里应该给出修复建议字段比如“更新中间证书”或者“更换请求域名”。2.3 解析层失败拿到的不是你要的结构解析层失败是 LLM 工具调用里非常常见、又非常“脏”的一类失败。很多工具接口正常返回的时候是 JSON但出问题的时候返回的是 HTML、文本、甚至是一个空文件。模型明明会解析 JSON但给它一坨 HTML解析必然失败。这行热词里“获取首页数据失败: exception: 伺服器错误 502: !doctype htmlhtml lang”就是个典型报错信息里一半是异常前缀一半是 HTML 片段这种混合文本对模型和日志系统都不友好。解析层失败的数据化核心动作是把“原始响应”和“解析错误”分开存。原始响应单独放到一个字段哪怕它是 HTML也有诊断价值解析错误的字段只存结构化描述比如“expecting JSON object but got text/html”。同时要记录响应头的 Content-Type这个信息非常有用——看到 text/html 你就知道上游返回了错误页面看到 application/json 但解析失败则可能是字符编码问题或者大 JSON 被截断。对这一类失败重试策略要看场景。如果是 502 带过来的 HTML 错误页重试可能有效因为上游网关可能恢复如果是接口本身换了返回格式重试就没有任何意义。把解析失败数据化再加上 Content-Type、响应长度、原始片段模型或规则引擎就能作出更合理的重试判断。2.4 业务层失败业务错误码、限流、鉴权网络层、安全层、解析层都过了还会遇到业务层的失败。这类失败在 HTTP 层面上可能返回 200但响应体里的业务 code 告诉你“没权限”“余额不足”“数据不存在”。业务错误码是最理想的数据因为它本身就是结构化的但很多工具运行时没有处理好这一层直接把整个 JSON 响应当成成功结果返回给了模型模型看到业务 code 是 10001 却不知道什么意思。业务失败的另一个典型是限流HTTP 429 或业务层“Too Many Requests”。限流数据里最关键的字段是 Retry-After 头它告诉你要等多久。把这些数据化之后运行时可以做精确的退避而不是拍脑袋等三秒。鉴权失败则是 token 过期、签名错误这类遇到之后应该走重新换取凭证的流程而不是重试原请求。业务层失败数据化的关键是给每个错误码维护一个元信息表这个错误码的含义、是否幂等、建议动作是重试还是终止。元信息表可以是静态配置也可以由模型动态解读但在数据层面至少要保证“业务码”“错误消息”“建议动作”三个字段是结构化的。3. 实操设计一个能把失败变成数据的工具运行时聊完类型学我们进入实操环节。我会用 Python 写一个极简但完整的工具运行时示例重点展示如何把失败捕获、结构化、重试、回流给模型。这个示例我在项目里实际用了很久做 Agent 工具调用的朋友可以直接参照改。3.1 定义统一的 ToolResult 结构失败数据化的第一步是让所有工具调用的返回结果都走同一个结构。不管成功还是失败返回的都是 ToolResult而不是又一个 exception。我平时是这样定义的from dataclasses import dataclass, field from typing import Any, Optional from enum import Enum import time class FailureCategory(str, Enum): NETWORK network SECURITY security PARSING parsing BUSINESS business UNKNOWN unknown dataclass class ToolResult: success: bool category: Optional[FailureCategory] None http_status: Optional[int] None error_code: Optional[str] None error_message: str raw_response: Optional[str] None suggested_action: str duration_ms: float 0.0 retry_after: Optional[float] None timestamp: float field(default_factorytime.time) tool_name: str extra: dict field(default_factorydict)这里最关键的是category和suggested_action。category就是 2 里讲的四层分类它决定了后续重试策略怎么选suggested_action是给模型或者规则引擎的一个建议比如 “retry_backoff”“retry_exact”“refresh_credential”“abort”。有了这两个字段失败就从一个异常的副产物变成了一个可供决策的数据样本。3.2 写一个统一的工具调用包装器有了数据结构接下来就要保证所有工具调用都经过同一个包装器。我封装了一个call_tool函数它做四件事记录耗时、按分类捕获失败、生成结构化 ToolResult、按策略重试。代码大致长这样import requests import time import json def classify_failure(exc: Exception, resp: Optional[requests.Response]) - Tuple[FailureCategory, str]: if isinstance(exc, requests.exceptions.Timeout): return FailureCategory.NETWORK, timeout if isinstance(exc, requests.exceptions.SSLError): return FailureCategory.SECURITY, ssl_certificate_verification_failed if isinstance(exc, requests.exceptions.ConnectionError): return FailureCategory.NETWORK, connection_reset if resp is not None and resp.status_code 500: return FailureCategory.NETWORK, fhttp_{resp.status_code} if resp is not None and resp.status_code 429: return FailureCategory.BUSINESS, rate_limited return FailureCategory.UNKNOWN, unknown_error def parse_retry_after(resp: Optional[requests.Response]) - Optional[float]: if resp is None: return None raw resp.headers.get(Retry-After) if raw is None: return None try: return float(raw) except ValueError: # HTTP-date 格式简化处理直接用当前时间差 return 5.0 def call_tool( tool_name: str, method: str, url: str, *, headers: Optional[dict] None, json_body: Optional[dict] None, max_retries: int 2, base_delay: float 1.0, ): last_result: Optional[ToolResult] None for attempt in range(max_retries 1): start time.time() resp None try: resp requests.request( method, url, headersheaders, jsonjson_body, timeout(3.0, 10.0) ) content_type resp.headers.get(Content-Type, ) if application/json not in content_type: raise ValueError(fExpected JSON, got {content_type}) payload resp.json() duration_ms (time.time() - start) * 1000 last_result ToolResult( successTrue, duration_msduration_ms, raw_responsejson.dumps(payload, ensure_asciiFalse), tool_nametool_name, ) return last_result except Exception as exc: duration_ms (time.time() - start) * 1000 category, err_name classify_failure(exc, resp) http_status resp.status_code if resp is not None else None raw_text if resp is not None: try: raw_text resp.text[:500] except Exception: raw_text unreadable body retry_after parse_retry_after(resp) suggested_action retry_backoff if category in (FailureCategory.SECURITY,): suggested_action abort if category FailureCategory.BUSINESS and err_name rate_limited: suggested_action retry_exact if retry_after is not None: delay retry_after else: delay base_delay * (2 ** attempt) last_result ToolResult( successFalse, categorycategory, http_statushttp_status, error_codeerr_name, error_messagestr(exc)[:300], raw_responseraw_text, suggested_actionsuggested_action, duration_msduration_ms, retry_afterretry_after, timestamptime.time(), tool_nametool_name, extra{attempt: attempt}, ) if attempt max_retries and suggested_action.startswith(retry): time.sleep(delay) return last_result这个包装器做了一个很重要的设计重试的顺序和参数不是写死的而是根据失败数据动态决定。超时了就退避重试证书错误就直接 abort限流按 Retry-After 精确等待。这样每次失败产生的 ToolResult都构成了一次决策依据。3.3 重试与降级用数据而不是拍脑袋决定重试很多团队的重试策略就是“失败了就立刻重试”这不是重试这是赌博。数据化的重试至少要考虑三个参数最大重试次数、重试间隔、是否允许降级。最大重试次数不能太大。我一般默认 2 次也就是总共最多发 3 个请求。为什么是 2 而不是 5因为大部分瞬时网络故障在一两次退避后就能恢复如果两次还不行说明问题不是瞬时的继续重试只是给对端服务增加压力。重试间隔用指数退避base_delay * (2 ** attempt)这是最常用的方案。第一次失败等 1 秒第二次失败等 2 秒。这里有个容易忽略的点如果失败数据里有retry_after一定要优先用服务端告诉你的时间而不是自己的退避公式。限流场景下服务端明确告诉你 3 秒后再试你却等 1 秒就冲上去那第三次必然还是 429。降级策略则是失败数据驱动的高级用法。比如调用主搜索接口 502数据里标记“上游不可用”这时可以自动降级到备用的冷缓存接口或者切换到一个更基础的数据源。降级决策也要记录成 ToolResult这样 Agent 能知道自己走的是降级路径最终结果可能质量略低但这部分信息对整个任务的推理是有帮助的。3.4 把失败结构化后喂回给模型失败数据化的最终目的是让模型能利用这些数据做自我修正。我在实际项目中会用一段独立的 prompt 模块把失败数据回灌给模型。举例如下假设 Agent 的第一次 Tool Call 失败了下一个 LLM 推理轮次的 prompt 里我不会简单地说“工具调失败了请重试”而是这样组织上一轮工具调用结果如下 - 工具名称search_products - 是否成功否 - 分类network - HTTP状态码502 - 错误摘要网关错误上游服务超时 - 原始响应片段!doctype htmlhtml lang... - 建议动作指数退避后重试或切换备用数据源 请基于以上信息决定下一步动作。你可以1) 按建议动作重试2) 切换到备用工具3) 修正请求参数后重试4) 终止本任务并向上层报告失败原因。把失败数据这样喂给模型效果和我早期直接把 exception 字符串塞给模型完全不同。模型能清晰理解“网络层失败”和“证书校验失败”的区别它更能自己判断“要不要重试”还是“该不该换个方向”。这里有个细节要提醒喂给模型的失败数据要尽量结构化、尽量短。原始响应只截前几百字符就够了不要把一个 10MB 的 HTML 错误页全塞给它。模型不是守着全文检索它需要的是决策所需的关键字段。3.5 失败数据的采集与度量失败变成数据之后一定要做采集和度量。我的习惯是每一个 ToolResult 都写结构化日志至少包含工具名、失败分类、错误码、HTTP 状态、耗时、是否有重试、最终是否成功。这样每天结束就能算出一批指标工具成功率、失败分类占比、平均耗时、重试率。采集到数据之后可以做几个简单有效的分析。比如按小时统计成功率和耗时的关系能看出是不是每天晚上某个上游服务就不稳定按错误码聚合能知道 top3 的错误是什么按工具维度对比能找出哪个工具拖累了整个 Agent 的表现。这些分析不需要多复杂的平台ES、ClickHouse 甚至一张 Postgres 表都能做但前提是——失败数据必须被结构化地写下来而不是散落在 stdout 里。4. 常见问题与排查实录失败数据化的五个坑代码写完、日志打上不等于大功告成。我在实际落地过程中踩过不少坑这里整理五个最常见的给大家排雷。4.1 上下文爆炸失败日志全塞给模型第一个坑是矫枉过正。知道了“失败要喂回给模型”之后有人把整个失败数据对象、原始响应、堆栈全部拼进 prompt。结果上下文被各种 HTML 标签和异常堆栈占满模型反而不知道重点在哪。解决方法是给模型看的失败数据必须是提炼过的决策摘要。原始响应留到日志里给人看模型只需要知道分类、错误码、建议动作。我自己用的规则是喂给模型的原始文本不超过 200 到 300 字验证下来推理质量明显更好。4.2 敏感信息泄漏API key、token 进了失败数据第二个坑很隐蔽。很多 HTTP 请求失败后打印原始响应或请求信息时会把 header 里的 Authorization token、query 参数里的签名、甚至重定向 URL 里的 session id 一并记录下来。失败数据一旦进入日志系统或喂给模型这些敏感信息就失控了。我的处理是两层第一层进入 ToolResult 之前先对请求头和参数做脱敏处理Authorization: Bearer ****这种第二层日志系统里对 raw_response 字段设置只读权限只有少数运维能看到完整内容。在把失败数据喂给模型之前还要过一道过滤函数把含 token、密钥、手机号的字符串直接替换成占位符。安全这件事宁可保守。4.3 重试风暴失败数据引发疯狂重试第三个坑是重试策略驱动得过于激进。如果你的包装器把每个失败都标记成“可以重试”那一个模型任务可能在某一个工具上连续重试 5 次把上游服务打到限流反而引发更大面积的失败。我见过一个真实事故某个工具的 500 响应触发了自动重试重试又触发了另一个 bug导致 3 分钟内同一个接口被打了上千次。规避方法很朴素max_retries 必须全局统一且不能太大重试必须配合 jitter随机抖动防止所有 Agent 实例在同一时刻发起重试对于非幂等操作重试次数直接设为 0。工具运行时里应当有一张表维护每个工具的幂等性写操作默认不自动重试只记录失败数据并报告给上层。4.4 有数据无闭环只收不用白白浪费第四个坑是数据采集了但没接入决策链路。有些团队把失败数据写到日志然后就没有然后了Agent 该盲目重试还是盲目重试。这是最可惜的浪费。失败数据只有回流到决策体系里才叫数据化否则就是新的垃圾日志。我的建议是每一步都检查闭环失败发生 → 生成结构化数据 → 规则引擎或模型读取数据 → 做出动作重试/降级/终止 → 动作结果再被记录。你不需要一开始就做复杂的闭环可以先做一个简单的规则映射错误码为 X 则动作 Y。等数据积累多了再把规则引擎升级成模型决策。4.5 从热词看真实错误特征如何快速定位最后分享一个排查技巧。回到我们开头看到的两个热词错误信息这种信息我看了太多遍基本上扫一眼就能定位问题方向。比如获取首页数据失败: exception: 伺服器错误 502: !doctype htmlhtml lang这类错误有几个特征错误消息里同时出现了异常类和 502 状态码说明你的工具运行时没有做分类直接把异常字符串原样拼接响应体是一整段 HTML说明你对 Content-Type 没有校验拿到非 JSON 就乱塞语言混杂是因为日志中间件、网关和业务框架各自吐了一段。这种单行错误要把数据拆清楚需要先定位是网络层 502再把 HTML 原始响应单独隔离最后重新生成结构化的错误码。再比如建立安全连接失败 由于不能验证所收到的数据是否可信,无法显示您想要查看的页面这个一看就是 TLS 证书链校验失败而且错误提示出自浏览器或系统网络栈不是你的代码。遇到这种错误第一件事不是改重试参数而是检查运行环境的证书库、系统时间、以及有没有代理在中间做 TLS 拦截。判断是证书过期还是证书不匹配可以抓一下openssl s_client -connect host:443 -servername host的输出看证书的 valid 时间范围。排查这些错误我有一个习惯先收集 100 条同类失败数据按错误码和 raw_response 前缀做聚类再看每种聚类的占比。十次失败里有七次是 502你该去催上游九次是证书失败你该查环境配置。没有数据聚类就开始调那才是真的盲人摸象。做了这么久工具运行时我最大的体会是失败一定会发生你无法让它不发生但你可以决定它发生后留下什么。把失败当数据之后那些每天吓你一跳的 502、证书错误、解析异常慢慢就变成了一堆可分析、可预测、可规避的信号。以前我排查 Agent 问题靠猜现在我直接查失败数据的分布十分钟就能定位到是上游不稳定还是证书过期效率完全不一样。如果让我只留一个建议给正在做 Agent 工具链的你不要急着写复杂的重试框架先把失败数据结构化做好再把数据回流到推理决策里。这两步做完你的工具运行时就已经比大多数裸奔的 Agent demo 稳一个量级了。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →