☰
失败不是异常是数据:构建稳定AI Agent工具运行时
2026/10/1 19:39:40 网站建设 项目流程

做工具运行时这个系列,从 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 分支,但真实的失败样式可能有一百种,而且很多失败是复合的——超时之后重试,重试返回 502,502 的响应体里又是 HTML 不是 JSON。你不可能靠人工枚举把所有这些场景都 catch 完。只有把失败转成数据,用模型自己的推理能力去理解、去分类、去决策,这个系统才具备应对未知失败的能力。

1.3 “失败是数据”带来的三个核心收益

第一是可观测性。失败一旦变成结构化数据,它就可以被统计、被追踪、被回放。你可以知道今天哪个工具失败率最高,哪个时间段网络最不稳定,哪种错误类型占了多少比例。没有这些数据,你调优 Agent 就全靠玄学。

第二是可纠错性。把失败数据组装成反馈,在下一轮 Tool Call 之前让模型看到“上一次为什么失败”,模型就有机会自己修正。比如重试时换个参数、换个降级方案、干脆换一个工具。整个 Agent 就有了一个闭环的学习能力,而不是每次失败都白失败。

第三是可评估性。工具运行时做得好不好,不能只看任务成功率,还要看失败率、失败类型分布、平均纠错轮次、重试放大系数这些指标。只有把失败变成数据,你才能给自己维护的这套工具链做量化体检。

2. 失败的类型学:从 502 到证书验证、解析失败

把一个失败数据化,第一步不是写代码,而是先对失败做分类。我平时把工具运行时的失败分成四层,每一层的处理思路和重试策略都不一样。最近大家都在聊的错误信息里,其实就能找到很典型的例子,比如“获取首页数据失败: exception: 伺服器错误 502: <!doctype html><html lang=”,以及“建立安全连接失败 由于不能验证所收到的数据是否可信,无法显示您想要查看的页面”。这两个都是很好的失败样本。

2.1 网络层失败:502、超时、连接重置

网络层失败是所有失败里最常见的,也是数据化收益最明显的。502 Bad Gateway 通常是网关或代理拿不到上游的有效响应,它可能的原因很多:上游超时、上游崩溃、代理配置错误。但有个很坑的点:很多网关在返回 502 的时候,响应体并不是标准 JSON,而是像那行热词里那样,直接给你一坨 HTML,比如<!doctype html><html 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 html><html 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_factory=time.time) tool_name: str = "" extra: dict = field(default_factory=dict)

这里最关键的是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, f"http_{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, headers=headers, json=json_body, timeout=(3.0, 10.0) ) content_type = resp.headers.get("Content-Type", "") if "application/json" not in content_type: raise ValueError(f"Expected JSON, got {content_type}") payload = resp.json() duration_ms = (time.time() - start) * 1000 last_result = ToolResult( success=True, duration_ms=duration_ms, raw_response=json.dumps(payload, ensure_ascii=False), tool_name=tool_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( success=False, category=category, http_status=http_status, error_code=err_name, error_message=str(exc)[:300], raw_response=raw_text, suggested_action=suggested_action, duration_ms=duration_ms, retry_after=retry_after, timestamp=time.time(), tool_name=tool_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 html><html 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 html><html 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 稳一个量级了。

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

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

立即咨询