带 Agent 的项目做得越多,越发现一个没人愿意明说的真相:大部分 Agent 不是被模型笨死的,而是被错误处理不到位拖死的。
我接手过不少所谓的“生产级” Agent 项目,代码结构挺漂亮,工具调用也花哨,但一上真实流量就露馅。用户一句含糊的话,模型给你返回一段带着多余 Markdown 的 JSON;上游 API 偶发抖动,Agent 就卡死在重试循环里;更常见的是,整个会话状态全放在内存里,服务一重启,用户上下文全没了。这些问题没一个跟“模型聪不聪明”有关,全是工程问题。
所以我对“构建可靠的 AI Agent”这件事有个非常明确的态度:模型决定 Agent 的上限,错误处理与容错机制决定 Agent 的下限。你不需要一个每次都答对的模型,你需要一个就算答错了也能优雅恢复、不至于把整个流程搞崩的系统。这篇文章我就把我在实际项目里踩过、填过、总结过的东西拿出来聊聊,从错误分类到容错设计,再到可复现的代码实现和排查实录,希望能帮正在搞 Agent 开发的你少走几个大坑。
适合谁来读?如果你正在用 LangGraph、OpenAI Function Calling 或类似框架做 Agent 落地,而且项目已经过了 Demo 阶段、开始考虑真实用户和数据,这篇文章就是写给你的。
1. 为什么 AI Agent 的错误处理比传统软件难一个量级
先说清一个问题:传统后端服务的错误处理,大家都会做。try-catch 包住、重试三次、超时熔断、写日志、上报监控,这一套在普通接口服务里已经非常成熟。但到了 AI Agent 这里,这套经验直接失效了一大半。
1.1 普通程序的错误模型在 Agent 身上不成立
传统程序的错误是可枚举的。网络超时、数据库连接失败、参数校验不通过——这些都是确定性的,你提前就能列出来,然后逐个写处理逻辑。Agent 不是这样。Agent 的错误来源里,很大一块是模型输出本身不可控。
比如你让 LLM 从用户输入里抽取结构化参数,它可能给你多打一个换行,可能在 JSON 外面包了```代码块,可能把字段名从你定义的user_name改成了username,更极端的情况是直接输出一段解释性文字而不是数据。这些不是“异常”,不是抛出来的 exception,而是看似正常但实际不可用的输出。
普通程序的错误模型是“函数返回了非预期值”,Agent 的错误模型是“函数返回了一个看起来能用、实际上埋着雷的值”。你没法用传统方式去穷举这些错误,因为它本质上不是固定集合,而是概率分布。
1.2 Agent 出错的成本会通过“级联放大”翻倍
传统服务里,一个模块出错,最多影响它自己或下游一个依赖。Agent 不一样,它有循环、有状态、有工具调用链,一个环节错了,后面所有步骤都会基于错误前提继续跑。
我打过一个很贴切的比方:普通程序像是流水线,一个工位出问题就停机报警;Agent 像是开车,导航出一个错,你不在下个路口纠正,就会一路错到不知道什么地方去。
具体来说有三个层级的放大:
- 第一层:模型输出错,导致工具调用参数错,工具实际执行了错误操作,比如把 A 用户的订单关单了。
- 第二层:模型发现工具结果不对劲,会产生自我怀疑,然后反复调用工具去验证,形成死循环,浪费时间和 token。
- 第三层:错误状态被写入了会话上下文或数据库,后续所有轮次都在污染数据上推理,用户怎么纠正都拉不回来。
这就是我说“错误处理决定下限”的原因:Agent 的错误处理不是防御性编程,而是核心业务逻辑的一部分。你如果不把它当一等公民来设计,后面一定会在生产环境里付出十倍代价。
2. 把 Agent 的错误分类理清楚,才有资格谈容错
容错设计的第一步不是我教你几招重试技巧,而是先把“错”这个东西分类清楚。不同类型的错误,处理策略完全不同。你拿处理网络超时的思路去处理模型幻觉,那是拿感冒药治骨折,方向就错了。
我自己习惯把 Agent 的错误分成三大类:模型层、工具层、编排层。
2.1 模型层错误:幻觉、截断、格式崩坏
这一层是 Agent 独有的,也是新手最容易忽略的。
格式崩坏是最常见也最好处理的。模型输出的 JSON 不合法、XML 缺闭合标签、代码块前后缀不一致。这类错误我有两个处理办法:
- 能用解析器修就修——比如 JSON 解析失败时,尝试用修复型解析库,或者在字符串上做去 Markdown 包裹、去多余逗号等预处理。
- 修不好就重试——把格式要求重新强调一遍,告诉模型“上一次输出不符合格式要求,请重新输出纯 JSON”。实测下来,这类错误的重试成功率非常高。
截断错误是另一个高频坑。无论你上下文窗口多大,模型输出总有可能撞到 max tokens 上限,结果就是推理到一半被硬生生切断,经常是 JSON 少了一半、代码少了半边括号。
应对思路有三条路可选:
- 第一条,也是我强烈建议的:用结构化输出模式或受控生成。现在主流模型都支持 JSON mode、函数调用等等,能约束模型输出的结构,从源头上把截断造成的格式崩坏扛掉一大半。
- 第二条,调大输出上限,但治标不治本。
- 第三条,都断了,检测截断标志,分段续写或重新规划输出。
幻觉是最难搞的。模型一本正经地胡说八道,说了一个不存在的文件路径、虚构了一个 API 返回结果。这在技术上没有银弹,工程上的应对思路就是交叉验证:工具调用后拿真实返回值回来,跟模型说预判不一致就驳回,让模型基于事实重新组织输出。说白了,错误处理救不了幻觉,但可以拦下幻觉造成的后果。
2.2 工具层错误:调用失败、参数错配、权限不足
Agent 存在的意义就是操作工具,所以工具调用链上的错误几乎必然发生。
工具本身挂了。网络超时、HTTP 5xx、第三方 API 限额,这类错误在技术上讲最“传统”,重试、指数退避、熔断这些老一套直接搬过来用就好。但有一点必须提醒:重试前要确认工具是否幂等。如果一个创建订单的接口不支持幂等,你重试三次可能就建了三张订单。非幂等操作的重试,必须带上请求唯一 ID 让接口做去重,或者退而求其次,先查询状态再决定要不要重发。
参数错配。模型生成的工具参数格式对,但值不对,比如把日期传成了 13 月 45 日,或者把一个不存在的用户 ID 传给了查询接口。这类错误的重试收益很低,你再让模型重试,它大概率还是基于同样的幻觉生成同样的错参数。我的处理方案是:把工具返回的校验错误作为新的上下文喂回去,明确告诉模型“参数month的值 13 不合法,合法范围是 1 到 12”,引导它自我修正。
权限不足。工具在业务环境里跑,鉴权失败这块错误处理特别容易让人抓狂,因为报错信息经常会误导模型。建议的做法是:报错信息里显式写明“权限不足,不要重试同一操作”,防止模型傻乎乎地把同一请求重发五遍,把审计日志刷成一片红。
2.3 编排层错误:状态丢失、死循环、上下文污染
Agent 的多步编排,是错误处理最容易被忽略,但出错后最痛的地方。
状态丢失。Agent 跑着跑着进程崩溃了,或者多个请求并发复用了同一个内存状态,导致状态互相覆盖。解决方案就是持久化,后面第 3 节我会详细讲怎么落地。
死循环。Agent 反复执行同一批工具调用,每次都报错,却一直不退出。这是生产环境里最耗钱的一种错误,token 哗哗烧,任务就是完不成。你需要硬性的循环上限,比如单次任务最多 15 步,超过就强制终止,再写一段终止摘要,让上层判断要不要换个策略重启任务。
上下文污染。前面错误步骤产生的脏数据进入了后续所有推理。这个最阴。错误处理通常会在某一步被“优雅处理掉”,但错误信息滞留在上下文窗口里,模型后续动不动就参考它。
破解办法是:把错误处理块设计成临时的、局部的作用域,出错时先隔离,修复后再在合适的位置,把修复后的结果合并回主上下文。是不是听着像编程语言里的块级作用域了?对,治理 Agent,本质上就是一种面向过程的程序设计,只是把执行体换成了模型。
3. 容错机制的工程化设计:超时、重试、降级、恢复
讲完错误分类,下面进入可落地的工程化部分。我把容错设计拆成四块:超时控制、重试策略、降级方案、状态恢复。这四块各有侧重,组合起来才能撑住生产环境。
3.1 超时与重试:别让 Agent 永远等下去
在 Agent 里,超时不是一个点,是一条线上每一个环节都要做。
首先是模型调用超时。我见过太多项目用默认超时,结果碰上模型服务端排队,一个请求挂两分钟,整个 Agent 卡死。建议模型调用超时控制在 30 秒到 60 秒,超过直接放弃,走降级或重试策略。
其次是工具调用超时。第三方的接口更不可控,建议工具超时控制在 10 到 20 秒,配合重试。注意这里的重试必须考虑幂等性,前面提过,不再展开。
最后是整体任务超时。一个完整的 Agent 任务从开始到结束,给一个硬性时间预算,比如 5 分钟,超过就触发熔断,中止流程并返回当前进展。这个机制特别重要,它保证了“最坏情况下用户也能拿到一个交代”,而不是无限转圈。
重试策略上,我强烈建议不要用固定间隔死重试。做一个简单的退避策略:第一次失败等 1 秒,第二次 2 秒,第三次 4 秒,最多四次,超时了就放弃,给上层返回错误。如果同一个 Agent 在短时间内频繁触发重试,要加一个全局的熔断开关——比如一分钟内超过十次失败,就暂停对外部工具调用五分钟,避免把上游接口彻底打崩。
3.2 降级与回退:把损失控制在最小范围
降级的核心思想是:主路径挂了,至少还有一条次优路径能响应用户。
比如,Agent 在调用付费的知识库搜索工具时失败,降级方案可以是直接退化为让模型基于自身知识回答问题,并在返回结果里注明“由于知识库暂时不可用,回答可能不够准确”。这虽然牺牲了准确性,但保住了用户体验的底线。
再比如,Agent 的完整任务流程需要用到五个工具,中间某个工具挂了。设计良好的容错机制会跳过这个工具的步骤,用默认值或启发式规则补齐部分功能,同时告知用户部分数据为空,而不是整个任务失败。
降级还有一个容易被忽视的场景:模型服务本身出了问题。你的主模型超时,可以降级到备用模型,哪怕备用模型的推理能力强一点弱一点都行,先用起来,保响应。很多公司就是靠这个机制在高峰期扛住流量,而不是把所有鸡蛋放一个篮子里。
设计降级方案时,我有个原则:降级必须对用户透明但坦诚。不要偷偷降级还不告诉用户,那样用户拿到的结果有缺陷却毫不知情,信任感崩得比失败还快。
3.3 状态持久化与恢复:让 Agent 拥有“断点续跑”能力
我今年恶意足足地踩过一个坑:一个多轮客服 Agent,状态全部保存在内存里。某次发版重启,所有在线的会话当场丢了,用户发现自己聊到一半,Agent 突然失忆了。那一次之后,我就把状态持久化提到了最高优先级。
Agent 的状态说白了就是两样东西:会话上下文(历史消息、当前节点、已执行的工具调用)和业务快照(重要变量、中间结果的检查点)。
持久化方案上,工程上最常用的是把状态序列化成 JSON,存进 Redis 或数据库。以 LangGraph 为例,它的BaseCheckpointSaver接口天生就是为了干这个的。我用 PostgreSQL 存过,也用 Redis 存过,各有优劣:
- Redis:读写快,适合高并发短期会话,但要注意给 key 设置合理的过期时间,不然积一堆垃圾数据。
- PostgreSQL:写起来稍微慢一点,但是可以做复杂的查询、统计、审计,适合需要长期留存会话数据的业务。
真正落地的时候,检查点(checkpoint)粒度特别关键。我建议在以下节点强制存一次状态:
- 每完成一个工具调用后
- 每完成一个 Agent 节点(比如“意图识别”“信息收集”“执行操作”)之后
- 整个任务完成时
这样,一旦进程崩溃或任务中断,新的实例可以从最近的检查点恢复,而不是从头开始。恢复的成本是 O(1) 级别的,但对用户体验的提升是质的飞跃。
除了崩溃恢复,检查点还有一个隐藏价值:重放调试。线上某个 Agent 出问题把用户惹毛了,你把它的检查点全部捞出来,一步步回放每一步的工具调用、模型输出、状态变更,就能精准定位是哪一步开始歪掉的。这比看日志猜原因不知道高到哪里去了。
4. 实操:一个可复现的容错 Agent 实现
理论讲了一堆,给出我自己改造过的容错 Agent 示例。我用的是 LangGraph 框架,但核心思路换成其他编排框架也成立,重点看设计。
4.1 整体设计:以 LangGraph 为例搭建基础流程
演示场景:一个给用户查询订单状态的 Agent。流程总共三步:对话解析提取订单号 -> 调用订单查询 API -> 整理结果回复用户。
直接用代码描述状态图:
from typing import TypedDict, Optional, Literal from langgraph.graph import StateGraph, END class OrderState(TypedDict): messages: list # 对话历史 order_id: Optional[str] # 提取到的订单号 api_result: Optional[dict] # 查询结果 error_info: Optional[str] # 错误记录 step_count: int # 步数计数器,防死循环 recovered: bool # 是否走过了恢复逻辑 def parse_order_node(state: OrderState) -> OrderState: """从用户输入中提取订单号,带重试和格式修复""" ... def query_order_api(state: OrderState) -> OrderState: """调用订单查询工具,带超时和降级""" ... def response_node(state: OrderState) -> OrderState: """生成回复,基于查询结果""" ... # 构图 graph = StateGraph(OrderState) graph.add_node("parse", parse_order_node) graph.add_node("query", query_order_api) graph.add_node("respond", response_node) graph.set_entry_point("parse") graph.add_edge("parse", "query") graph.add_edge("query", "respond") graph.add_edge("respond", END)这个基础图里没有任何错误处理,下面一节我逐步给它加上容错能力。
4.2 关键代码:重试、超时、兜底、状态恢复
先解决模型输出格式修复与重试。在parse_order_node里,我写了一个循环:先让模型抽取订单号,如果解析失败,把错误信息追加到提示词里重试,最多三次。
from pydantic import BaseModel import json class OrderExtract(BaseModel): order_id: str PROMPT_TEMPLATE = """你是订单解析助手,从用户消息中提取订单号。 订单号格式为纯数字,长度 10 位。 只输出 JSON,不要输出任何解释文字。 用户消息:{message} {fmt_error_hint} """ def call_model_with_retry(message: str, max_retries: int = 3): for attempt in range(max_retries): hint = "" if attempt > 0: hint = f"你上次的输出格式不符合要求,请严格按照纯 JSON 格式输出,并确保 order_id 是 10 位数字。" prompt = PROMPT_TEMPLATE.format(message=message, fmt_error_hint=hint) try: raw_output = llm.invoke(prompt, response_format={"type": "json_object"}) parsed = json.loads(raw_output) return OrderExtract(**parsed) except (json.JSONDecodeError, ValidationError) as e: # 记录这次失败,用于排查 logger.warning(f"第{attempt + 1}次解析失败: {e}, 原始输出: {raw_output}") continue return None解析三次都失败,就返回None,交给上层走无法处理的兜底路径,而不是在这条路上一路黑到底。
再看工具调用的超时与重试。核心要点是:每次调用单独起超时控制,重试次数有限,并在所有重试都失败后走降级分支。
import time import requests def query_order_api_with_timeout(order_id: str) -> dict: """查询订单 API,带超时重试,最终失败走降级""" last_error = None for attempt in range(3): try: resp = requests.get( f"https://api.example.com/orders/{order_id}", timeout=(3, 5) # (连接超时, 读超时) ) if resp.status_code == 200: data = resp.json() return {"success": True, "data": data} # 5xx 或限流,可以重试 if resp.status_code in (429, 500, 502, 503, 504): last_error = f"订单API返回 {resp.status_code}" time.sleep(2 ** attempt) # 指数退避 continue # 4xx 一般是参数或权限问题,重试没有意义 return {"success": False, "error": f"订单API返回 {resp.status_code}: {resp.text}", "retryable": False} except requests.Timeout: last_error = "订单API读超时" time.sleep(2 ** attempt) except requests.ConnectionError: last_error = "订单API连接失败" time.sleep(2 ** attempt) # 全部重试失败 return {"success": False, "error": last_error, "retryable": True}这里我做了两个关键决策,后面想清楚为什么会节约大量时间:4xx 不重试,5xx 和超时才重试。4xx 说明请求本身有问题,再重试一百遍也是同样的失败,纯粹浪费时间和上游资源。而 429、5xx 这些是临时性故障,退避重试的性价比高很多。
接下来是状态持久化。我直接用 LangGraph 的SqliteSaver把每次节点的状态快照存下来:
from langgraph.checkpoint.sqlite import SqliteSaver # 初始化持久化存储 saver = SqliteSaver.from_conn_string("agent_state.db") # 把检查点 saver 挂到图上 compiled_graph = graph.compile(checkpointer=saver)这样整个对象compiled_graph的每一步执行都会被记录。任务中途崩溃了,用同一个thread_id可以从中断处恢复执行:
config = {"configurable": {"thread_id": "user_12345_session_678"}} # 第一次执行 result = compiled_graph.invoke( {"messages": [{"role": "user", "content": "帮我查下订单 1234567890 到哪了"}]}, config=config ) # 假设进程崩溃,重启后用同样的 thread_id 恢复 result = compiled_graph.invoke( {"messages": []}, # 不需要重新传历史 config=config )这里各位注意了,invoke传入的config中 thread_id,是恢复的核心,也是检查点机制读取上下文的钥匙,务必保证同一个会话用同一个线程 ID,并且在多层任务里复用即可。
最后给整个任务加步数保护和整体超时:
from langgraph.graph import END def should_continue(state: OrderState) -> Literal["continue", "end"]: # 超过最大步数就强制终止 if state["step_count"] >= 15: return "end" # 其他条件判断... return "continue" graph.add_conditional_edges("query", should_continue, { "continue": "respond", "end": END })这里的理念是:Agent 再聪明也不能让它无限跑下去。步数上限不是无奈妥协,是对系统和用户的保护,大大削减了成本失控的尾部风险。
4.3 压测与验证:主动把错误注入到系统里
写完代码不算完,容错能力是要靠故障注入来验证的。我经常在测试环境干下面这些事:
- 把订单 API 的地址改成不存在的 URL,验证连接失败的重试路径
- 用一个构造的响应让接口响应体里不包含
data字段,验证解析分支 - 把模型温度调到最高,故意输入模糊信息,看格式修复逻辑能不能兜住
- 杀掉正在跑的进程,然后用线程 ID 恢复,测试检查点恢复是否有效
常做的自动化故障注入测试,可以用unittest.mock完成:mock 掉 requests.get,让它抛超时,然后断言 Agent 走了降级分支、返回了“系统暂时无法查询”的提示而不是直接崩溃。
from unittest.mock import patch def test_query_api_timeout_then_fallback(): with patch("requests.get", side_effect=requests.Timeout): result = query_order_api_with_timeout("1234567890") assert result["success"] is False assert result["retryable"] is True这种测试写起来不难,但价值极高。每次改完 Agent 代码,先把这些“错误剧本”跑一遍,再跑正常路径,就能确保不会出现“修了一个 bug,引入两个新 bug”的情况。
5. 常见问题与排查技巧实录
理论、代码都有了,最后来点实际排查中的一手经验。这些场景都是在线上或灰度阶段真实碰到过的,我觉得对后来人特别有参考价值。
5.1 现象:Agent 陷入死循环,跑个不停
某个测试 Agent 在用户连续追问后,开始反复调用搜索工具,每次都返回同样的结果,但它就是不结束。看日志发现:模型每轮都觉得自己“少查了一个东西”,于是再查一次,结果一样,又开始自我怀疑。这就是典型的没有循环退出条件的病。
排查过程:先看 checkpoint 里记录的 step_count,发现那个会话已经跑了 40 多步。然后手工重放前几步,发现其实是第 3 步的工具返回结果里有段措辞触发了模型的自我怀疑。
解决思路:
- 加步数上限,强制终止(前面代码里的
should_continue就是干这个的) - 在工具返回里加一行
confidence字段,告诉模型“这是权威结果,不要再重复查询” - 更重要的是,调整提示词策略:明确告诉模型“你在一次任务中最多执行 3 次搜索,如果 3 次结果一致,直接基于最近一次结果回答”
那种问题,很多时候不是模型笨,是你在提示词和编排上没给它“见好就收”的许可。Agent 需要被授权“停止”,就像开车的人需要知道什么时候该进停车场。
5.2 现象:工具调用成功率上去了,但业务成功率没变
有一阵子我优化了重试逻辑,工具调用的成功率从 90% 升到了 98%,但最终业务成功率几乎没动。一开始很困惑,后来深扒数据才发现:剩下的 2% 失败里,有一大半不是工具挂了,而是模型给的参数值根本是幻觉。
举个例子,用户说“帮我看看上个星期的订单”,模型提取日期时猜了一个“2024-05-13”,那周的订单根本不存在,接口返回 404,工具调用“失败”。重试两次,模型还是拿同一个幻觉日期去查,结果当然还是失败。
排查过程:把失败样本全部拉出来,发现 404 的比例高得离谱,再一看,参数值全是模型臆造出来的,并不是从历史消息里正确推导出来的。这就是典型的参数的逻辑校验问题。
解决思路:在调用工具之前,先做一次参数合理性校验。对于日期,检查是不是周末;对于 ID,检查格式和长度;对于枚举值,检查是否在合法集合里。校验不过就直接回退到“反问用户确认信息”,而不是把错误参数喂给工具,浪费一次调用。我在很多项目里把“参数校验”作为一个独立节点放在“工具调用节点”前面,算是性价比非常高的一个改动。
5.3 现象:恢复后上下文错乱,用户说“你忘了我们刚才聊的”
这个问题是我们版本迭代时踩的。部署了检查点机制之后,某次测试发现:任务中断后恢复,模型回复的用户内容和上下文对不上,好像它是从一个错位的状态重新开始的。
排查过程:检查点里的 messages 变量明明在,但模型回复时引用的顺序不对。最后发现问题是恢复时没有把检查点里的完整事件加载回来,而是只加载了一部分。具体说,LangGraph 的 checkpointer 恢复时,它从 checkpoint 的 state 里回到最新的节点,但是如果有节点没有把对后续有影响的局部状态写进全局面板(state),恢复之后那部分就丢了。
解决思路:所有重要信息,必须在进入下一个节点前显式写入全局面板,不能放在局部变量里。这是一个设计原则,不是调试琐事。比如你在节点里临时计算了一个applicable_discount,一定要记得state["final_discount"] = applicable_discount,否则一旦走到恢复流程,这个值就无据可循了。
到现在为止,我项目里每次开发新的 Agent 节点,大概率会同步做三件事:节点内部逻辑、状态写入检查、错误注入测试。这套流程跑顺了之后,线上故障率骤降。最后再分享一个小技巧:在 Agent 的日志里,把“模型输出”和“解析结果”同时打出来,线上出问题的时候,你能一眼看出是模型抽风了还是解析逻辑错了,这个习惯帮我省掉过无数个排查的深夜。