AI Agent 系统的容错设计实践

现在市面上的 Agent 教程太多了,要么太浅要么太碎。

之前一直关注的博主三元同学最近出了Super Agent 实践课,这门课从「底层实现级」角度拆解真实产品的工程决策,帮你建立完整的 Agent 知识体系,覆盖六大方向。

这门课学习下来让我对 Agent 有了全面的认知,下面是我的学习笔记

往期学习笔记

吃透 AI Agent 开发

  1. 系统认知 Agent 六大支柱
  2. Agent循环原理
  3. 大模型底层机制与Agent开发

Super Agent 实战课

  1. 写一个轻量级本地 Mock Model
  2. 如何手写一个 AI Agent 工具调用循环(Tool Loop)
  3. AI Agent 系统的容错设计实践

在一个普通的后端服务里,"容错"通常指的是网络请求失败后怎么重试、超时怎么处理。但当服务的主体变成一个会自主决策、多轮调用工具的 Agent 之后,"故障"的定义也变了------它不再只是一次 HTTP 请求的失败,还可能是模型陷入了一种"技术上没有报错,但逻辑上原地打转"的状态。

这篇文章基于一个简单的 Agent Demo(用 AI SDK 实现的工具调用循环),拆解其中的容错设计。整个系统叠了三层防护,外加工具执行层面的兜底,层层递进,缺一不可。

第一层:网络请求容错------先分类,再决定要不要重试

最基础的一层,应对的是 LLM API 调用本身的失败:限流、服务过载、网络抖动。这类失败的关键不是"要不要重试",而是先判断"这次失败值不值得重试":

typescript 复制代码
export const isRetryable = (error: unknown): boolean => {
  if (!(error instanceof Error)) return false;
  const message = error.message;
  const statusMatch = message.match(/(\d{3})/);
  if (statusMatch) {
    const status = parseInt(statusMatch[1]);
    if ([429, 529, 408].includes(status)) return true;
    if (status >= 500 && status < 600) return true;
    if (status >= 400 && status < 500) return false;
  }
  if (message.includes("ECONNRESET") || message.includes("EPIPE")) return true;
  if (message.includes("ETIMEDOUT") || message.includes("timeout")) return true;
  if (message.includes("fetch failed") || message.includes("network")) return true;
  if (message.includes("No output generated")) return true;
  return false;
};

这段代码本质上是一份错误分类表:

  • 429(限流)、408(请求超时)、529(Anthropic 的过载错误码) :虽然落在 4xx 区间,但明确标记为可重试,所以要写在通用 4xx 判断之前,不然会被后面 status >= 400 && status < 500 → false 的规则误伤。
  • 5xx:服务端错误大概率是瞬时的,重试有意义。
  • 其余 4xx:请求本身有问题(参数错、鉴权失败),重试只会拿到一模一样的失败,直接放弃更划算。
  • ECONNRESET / ETIMEDOUT / fetch failed 等:网络层的瞬时抖动,值得重试。

这里有一个值得留意的取舍:判断状态码的方式是从 error.message 里用正则抠出一个三位数字,而不是读一个结构化的 error.status 字段。这个做法的前提是"错误信息里恰好包含真实状态码"------如果上游库把错误规整成了结构化类型,直接读字段显然更稳健;但很多时候拿到手的就是一个只有 message 的原生 Error,正则匹配是没有结构化信息时的务实退路。

判断完"值不值得重试",接下来才是"怎么重试":

typescript 复制代码
export function calculateDelay(attempt: number, baseMs = 500, maxMs = 30000) {
  const exponential = baseMs * Math.pow(2, attempt - 1);
  const capped = Math.min(exponential, maxMs);
  return Math.max(0, Math.round(capped + (Math.random() * 2 - 1) * capped * 0.25));
}

指数退避(每次翻倍)叠加封顶(maxMs),再加上 ±25% 的随机抖动。抖动不是可有可无的装饰------如果多个客户端在同一时刻遇到限流,且都按固定间隔重试,重试请求会重新扎堆,形成新一轮拥堵;抖动把这些重试请求在时间上错开,是低成本但很有效的解法。

在 Agent 主循环里,这套逻辑包了一层 for 循环:

typescript 复制代码
for (let attempt = 1; ; attempt++) {
  try {
    const result = streamText({ model, system, tools, messages, maxRetries: 0 /* ... */ });
    for await (const part of result.fullStream) {
      /* ... */
    }
    stepResponse = await result.response;
    break;
  } catch (error) {
    if (attempt > MAX_RETRIES || !isRetryable(error as Error)) throw error;
    const delay = calculateDelay(attempt, 2000);
    await sleep(delay);
    hasToolCall = false;
    fullText = "";
    shouldBreak = false;
    lastToolCall = null;
  }
}

两个细节值得注意:

  1. streamText 显式传了 maxRetries: 0,把 AI SDK 自带的重试关掉了。如果不关,两套重试逻辑叠在一起,实际重试次数和延迟会变得难以预测。既然要自己控制退避策略和错误分类,就要把重试逻辑完全接管过来,而不是两边都开着。
  2. 重试前重置了 fullTexthasToolCall 这些局部状态。失败发生在流式响应的中途------上一次尝试可能已经吐出了一部分文本、记录了一次工具调用,这些"半成品"状态如果不清空,重试成功后会和新一轮的结果叠在一起,导致重复文本或者幽灵工具调用。重试一个流式请求,本质上是重试整个流的生命周期,而不只是重新发一次请求。

第二层:Agent 循环容错------模型也会"卡住",而且不报错

第一层解决的是"请求失败了怎么办",但 Agent 系统还有一类更隐蔽的故障:请求全部成功,模型也在正常输出,但它反复调用同一个工具、拿到同样的结果,逻辑上毫无进展。这种"卡住"不会抛异常,传统的重试机制完全捕捉不到。

这段逻辑的核心,是把每一次工具调用变成一个可比较的指纹:

typescript 复制代码
const stableStringify = (value: unknown): string => {
  if (value === null || typeof value !== "object") return JSON.stringify(value);
  if (Array.isArray(value)) return `[${value.sort().map(stableStringify).join(",")}]`;
  const keys = Object.keys(value).sort();
  return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify((value as any)[k])}`).join(",")}}`;
};

export function hashToolCall(toolName: string, params: unknown): string {
  return `${toolName}:${hash(stableStringify(params))}`;
}

对参数对象的 key 排序之后再序列化,是为了让 {city: "北京", unit: "c"}{unit: "c", city: "北京"} 这种字段顺序不同、语义相同的调用产出同一个 hash------否则哈希对比会因为字段顺序的偶然差异而失效。

有了"调用指纹"和"结果指纹",系统在一个长度为 30 条的滑动历史窗口里维护了三种检测器:

  • 完全重复(generic_repeat):同一个工具、同一组参数在窗口里出现的次数达到阈值------警告线是 5 次,熔断线是 8 次。
  • 乒乓循环(ping_pong):在两个不同调用之间来回切换,阈值同样是 5 次警告、8 次熔断。Demo 里专门写了一个测试场景来复现这种情况------交替查询"北京"和"上海"的天气,模拟模型在两个选项之间反复横跳、既不收敛也不报错的状态。
  • 无进展熔断(circuit breaker):同一个调用、同一组参数,连续多次拿到完全相同的结果,阈值给到了 10 次------这是三者里最严格的一种判断:不只是"调用重复",而是"调用重复且没有获得任何新信息",所以给了它最高的门槛和"熔断"这个更强硬的名字。

三种检测器背后是同一套分级响应模式:

typescript 复制代码
if (recentCount >= WARNING_THRESHOLD) {
  return { stuck: true, level: "warning", detector: "generic_repeat", count: recentCount, message: `...` };
}

达到警告阈值时,系统不会立刻掐断循环,而是往对话历史里插入一条系统提示,让模型自己意识到"在重复"、尝试换个思路:

typescript 复制代码
messages.push({
  role: "user" as const,
  content: `【系统提醒】${detection.message}。请换一个思路解决问题,不要重复同样的操作`,
});

只有当行为持续到更高的阈值,才会真正终止循环。这个"先提醒、不行再熔断"的两级响应,和第一层"先重试、超过次数再放弃"的思路是同构的------只是第一层作用在网络请求上,这一层作用在模型的行为模式上。容错的对象,从"请求"扩展成了"决策"。

第三层:硬性步数上限------保证一定会停下来

即便有了循环检测,也不能百分百保证覆盖所有卡住的情况------检测逻辑本身也可能有覆盖不到的模式。所以整个 Agent 循环还兜了一层最朴素的保底:

typescript 复制代码
const MAX_STEPS = 15;
while (step < MAX_STEPS) {
  step++;
  // ...
  if (shouldBreak) break; // 循环检测触发熔断
  messages.push(...stepResponse.messages);
  if (!hasToolCall) break; // 模型主动结束(没有新的工具调用)
  // 否则进入下一步
}

整个循环有三条退出路径:模型自己判断任务完成(没有新的工具调用)、循环检测判定熔断、达到步数上限强制停止。前两条路径都依赖"判断逻辑是对的"这个前提,而步数上限不依赖任何判断------它只是一个纯粹的计数器。这一层不追求聪明,只追求兜底:不管前面的检测逻辑设计得多完善,Agent 消耗的资源和时间始终有一个确定的上界。

工具执行也要优雅降级

容错不只发生在 Agent 主循环这一层,工具本身的实现也遵循同样的原则------出错时返回一段能解释情况的文本,而不是把异常直接抛出去:

typescript 复制代码
execute: async ({ expression }: { expression: string }) => {
  try {
    const result = new Function(`return ${expression}`)();
    return `${expression} = ${result}`;
  } catch {
    return `无法计算: ${expression}`;
  }
},

如果这里不 catch,一个非法表达式会让异常直接冒到 Agent 主循环之外,导致整个进程崩溃。返回一段描述失败的文本,本质上是把"工具执行失败"也变成了一种正常的工具返回结果------模型能看到这个结果,进而决定是换个表达式重试,还是向用户说明算不出来。天气工具对未知城市的处理是同样的思路:

typescript 复制代码
return mockWeather[city] || `${city}:暂无数据`;

工具层面永远不应该让异常直接穿透到 Agent 循环之外------它应该被转换成一个模型可以"看懂"、并据此继续决策的结果。

用 Mock 模型给容错逻辑做故障演练

容错代码有一个天然的尴尬:它存在的意义就是处理"很难稳定复现"的情况------限流什么时候触发、网络什么时候抖动,都不是能主动控制的。这个 Demo 里的 Mock 模型专门解决了这个问题,把两类故障做成了可以按需触发的固定剧本:

typescript 复制代码
if (text.includes("测试重试") || text.includes("test retry")) {
  retryTestCount++;
  if (retryTestCount <= 2) {
    throw new Error("429 Too Many Requests - Rate limit exceeded");
  }
  // 第三次固定成功
}

if (text.includes("测试死循环") || text.includes("test dead loop")) {
  return { toolName: "get_weather", args: { city: alternating() === 2 ? "北京" : "上海" } };
}

前两次调用固定抛出 429,第三次固定成功,用来验证重试逻辑是否生效;后者固定在两个城市之间交替,用来验证乒乓循环检测的阈值设置得对不对。这样一来,重试机制和循环检测都不用碰真实 API、也不用祈祷网络刚好抖一下才能验证。容错代码本身也是需要测试的代码,而测试它最好的方式,是把它要应对的故障场景做成确定性的、可重复触发的模拟。

小结

把这几层放在一起看,能提炼出几条比较通用的设计原则:

  1. 先分类,再重试:不是所有失败都值得重试,判断"重试是否可能成功"永远是第一步。
  2. 退避要带抖动:固定间隔的重试在规模化之后会自我放大成拥堵,随机抖动是低成本的解法。
  3. Agent 的故障不止是异常:一次成功的、没有报错的工具调用,也可能是"卡住"的信号,这类故障需要专门的行为层检测。
  4. 分级响应,先给一次自我修正的机会:无论是网络重试还是循环提醒,"警告优先、无效再强制停止"这套两级响应,都比一遇到问题就直接终止更稳健。
  5. 兜底不需要聪明,只需要确定:步数上限这种最朴素的保护,恰恰是因为不依赖任何"判断是否正确"的前提,才能作为整个系统最后的安全网。
  6. 异常不应该穿透执行边界:无论是工具还是 Agent 步骤,失败都应该被转换成调用方能理解、能继续决策的结果,而不是直接抛出去。
  7. 容错逻辑本身需要可测试性:把难以复现的故障场景做成确定性的 mock,才能真正验证容错代码是否真的奏效。

从网络层的重试,到行为层的循环检测,再到步数上限这个最后的保险丝------这套设计思路本质上是同一个原则在不同层级上的重复应用:先尝试识别和分类问题,给系统一次自我修正的机会,再在必要时果断兜底。

学习完成于 2026-06-28

基于Agent 不能这么脆------循环检测、API 容错与 Token 预算学习笔记整理

相关推荐
洛卡卡了1 小时前
Claude Code rules 要怎么用,团队协作时如何统一代码规范呢?
面试·agent·claude
乘风gg3 小时前
多 Agent 不是万能的!搞懂这 5 个原则,少走 1 年弯路!
前端·agent·ai编程
暮霭c4 小时前
Al 帮我写交易策略,三道关决定能不能跑
agent·ai编程·vibecoding
沉默王二5 小时前
IDEA 爽用 Claude Code 的终极方案,太丝滑。
agent·ai编程·claude
TrisighT5 小时前
DevEco Code 写鸿蒙 ArkTS 确实快,但我试了三天后把默认引擎换成了 Cursor
ai编程·harmonyos·cursor
Quz5 小时前
在 Obsidian 中嵌入 Claude Code 的实践记录
人工智能·claude
feiyu_gao5 小时前
一个人 + AI:246 commits 做出设计系统 CLI 的故事
前端·ai编程·交互设计
tenxiaodao5 小时前
手摸手给 Claude Code 换芯:GLM + Headroom + cc-switch 省钱接入指南
claude