Agent 可靠性架构:状态机、幂等键与工具调用治理

发布时间:2026/9/17 22:20:29
Agent 可靠性架构:状态机、幂等键与工具调用治理 做 Agent 项目这一年多我最怕的不是模型答得不好而是它答得半好不坏——该调的工具调了该写的库也写了偏偏在最后一步超时重试一次数据写了两遍。这种故障在传统后端里几乎不会出现因为你写的代码是确定的输入 A 必然产出 B而 Agent 软件的每一步决策都来自模型的自由发挥它像一个每天换一遍性格的实习生你永远不知道今天的它会不会把删除草稿理解成删除全部。所以我后来花了很大精力专门去做 Agent 软件可靠性设计从编排层到工具层、从状态机到评测流水线几乎重写了一版架构。这篇就把我踩过的坑、改过的架构、以及那些文档里不会写的细节完整地分享出来。适合正在做 agent 开发、或者准备把 demo 推向生产环境的同学参考不管你是刚入门还是已经在带团队应该都能捞到几条能直接抄的东西。1. 先认清一件事Agent 坏了往往不是崩了传统服务的故障很直白进程挂了、端口不通、数据库连不上监控一报警值班同学看一眼日志就知道问题在哪。Agent 软件的故障是另一种形态它不崩它跑偏。接口全部返回 200日志里每一条 tool_call 都写着 success但最终交付给用户的结果是错的、重复的、或者只完成了一半。更麻烦的是这类问题往往要等到用户投诉才被发现而此时线上已经积累了上百条脏数据。所以我一直跟团队说做 Agent 的可靠性设计第一个要转变的观念就是把完成和正确完成当成两件不同的事前者是 HTTP 层面的成功后者才是业务层面的成功而我们需要为后者单独设计一整套校验和兜底机制。1.1 Agent 与传统后端的可靠性差异到底在哪要设计可靠性先得承认 Agent 软件和普通后端根本不是同一类东西。普通后端的执行路径是编译期就定死的你可以画出精确的调用图做完整的分支覆盖测试。Agent 的执行路径是运行期由模型现编的同一个用户请求今天走三步明天可能走七步工具调用顺序都可能不一样。这就带来三个直接后果第一路径不可枚举你没法靠穷举来测试第二失败不连续同一次会话里第 2 步失败、第 5 步重试成功、第 6 步又拿到过期缓存这种复合故障很难复现第三成本是变量一次跑偏可能烧掉平时二十倍的 token。我在第二个项目里专门统计过把同一批任务跑两遍执行步数的方差能达到均值的 40%个别长尾任务步数是均值的 3 倍以上。这个数据直接决定了后面的架构选择——任何假设步数固定的设计都是危险的比如按固定步数分配超时预算、按固定次数做重试全都得改。1.2 不确定性是从哪几个环节渗进来的很多人一提 Agent 不确定性就只想到模型幻觉其实渗透点至少有五个每一个都需要独立的防护。环节典型不确定性后果意图理解把相似指令理解成不同任务走错流程越走越远规划拆解步数、顺序每次不同超时预算算不准工具选择该用 A 工具却选了 B参数不匹配报错或脏写参数生成数值、日期、ID 格式错调用失败或写入错误数据结果自评认为已完成但实际没有提前终止任务残缺我最常遇到的是第四和第五个。参数格式错相对好办加一层 schema 校验就能挡住大部分麻烦的是自评环节模型经常在只完成了 60% 的情况下信心满满地宣布结束。后来我的做法是不信任模型的自评结论只信任可验证的状态比如目标数据库里是否已存在这条记录这种可以被程序检查的事实。1.3 把可靠拆成可验收的指标要可靠是个没法验证的目标必须拆成数字。我一般会跟业务方一起定四类指标而且每一项都要能在线统计出来。注意不要只定成功率一个指标那会把部分成功重复写入这类最恶心的问题藏起来。任务完成率最终校验通过的任务数 / 总任务数这个是最核心的。重复副作用率同一幂等键下发生多次写操作的比例目标压到 0.1% 以下。单任务成本分位P50 / P95 / P99 的 token 消耗与工具调用次数用来发现长尾。失败可归因率失败任务中能被自动分类到明确原因的比例低于 80% 说明可观测性不够。这四项里第二项最容易被忽略也最致命。我见过一个团队因为重试机制设计不当把给客户发的通知短信重复发了三遍用户直接投诉到老板那里。这件事之后我所有的写操作类工具都强制要求带幂等键没有例外。2. 架构层给不确定性留出隔离带指标定完了接下来是架构。我个人的原则很简单把确定的部分做得尽量确定让不确定的部分被关在笼子里。模型是笼子里那只随时可能乱跑的动物它只能通过有限、受控的接口与外界交互而笼子的边界、门锁、监控全都要由我们这些写代码的人来设计。这套思路落到具体架构上就是三层切分、状态机化、记忆分层和副作用隔离四件事下面逐个展开。2.1 编排层、能力层、控制层的三层切分很多人做 agent 开发时习惯把所有逻辑塞在一个大循环里模型输出什么就执行什么简单直接但到了生产环境就是灾难。我现在的标准结构是三层编排层只负责决定下一步做什么输入是当前状态和可用能力的描述输出是一个结构化的动作指令不直接碰任何外部系统。能力层也就是常说的 skill是一堆边界清晰、可单独测试的函数每个都声明自己的输入 schema、输出 schema、超时、是否幂等、失败是否可重试。控制层夹在中间负责校验、限流、重试、熔断、审计所有跨层的调用都要从这里过。这里顺便说下 skill 和 agent 的区别这是我被问最多的问题。我的理解是skill 是无状态的、可被确定性验证的能力单元输入输出都固定agent 是有状态、会做决策的执行体。前者可以写单元测试后者只能做场景评测。搞清楚这个边界之后你的代码结构会一下子清晰很多——所有需要猜的东西放 agent所有需要准的东西放 skill。具体到代码上一个 skill 的声明大概长这样from dataclasses import dataclass, field from typing import Any, Callable dataclass class SkillSpec: name: str func: Callable[..., Any] input_schema: dict output_schema: dict timeout_s: float 8.0 idempotent: bool False max_retries: int 2 side_effect: str none # none / read / write / external tags: list field(default_factorylist)side_effect这个字段看着不起眼但它是我整套设计的枢纽控制层会根据它决定要不要生成幂等键、要不要做结果校验、要不要写审计日志。没有这个字段后面所有的幂等和审计都无从谈起。2.2 把执行流程写成状态机模型负责决策但流程的骨架必须由代码掌控。我的做法是给每个任务类型定义一台状态机状态是有限的转移条件明确模型只能在允许的转移里做选择。举个实际的例子一个整理资料并归档的任务状态机大概是INIT - PLANNING - EXECUTING - VERIFYING - DONE \- EXECUTING - FAILED_RETRYABLE - EXECUTING \- FAILED_FATAL - ABORTEDEXECUTING是唯一允许模型自由发挥的状态它可以在里面调任意多个 skill但只要它宣布我做完了流程就强制进入VERIFYING由程序而不是模型来判断是否真的完成。这个设计救过我至少三次因为模型的自评一旦被程序校验替代提前终止的故障率直接从 12% 降到了 2% 以内。引入状态机还有两个附加好处。一是超时预算可以按状态分配PLANNING给 15 秒EXECUTING给 120 秒VERIFYING给 10 秒超过就转入失败分支不会出现一个任务把整个会话卡死的情况。二是断点续跑变得自然每个状态进入和退出时都可以落一次检查点重启后从最近的状态恢复不用重放全部历史。2.3 记忆分层工作记忆、会话记忆、长期记忆agent 记忆这一块最容易出问题的地方是什么都往上下文里塞。我见过一个项目每轮对话都把历史的全部工具返回结果拼进 prompt跑到第八轮的时候上下文已经三万 token响应又慢又贵而且模型开始在旧数据里考古答非所问。我现在固定分三层来管工作记忆当前任务的中间结果存在内存里任务结束即释放只保留最近 N 条N 一般取 8 到 12。会话记忆跨轮的对话要点做摘要压缩后存数据库每轮只注入摘要不注入原文。长期记忆用户偏好、领域知识走向量检索只在需要时召回 Top-K。关键在于每一层的写入都要有明确的触发条件和容量上限。工作记忆超过阈值就滚动截断会话记忆每 5 轮做一次摘要合并长期记忆写入前先做去重判定。我踩过的一次坑就是长期记忆没去重同一个用户的偏好被重复写入几十次检索时 Top-5 全是重复内容等于白白浪费了上下文。2.4 副作用隔离与幂等键设计所有对外的写操作我都要求走同一套封装核心是三步先查、再写、后验。def idempotent_write(ctx, key: str, payload: dict): ledger_key fledger:{key} if cache.get(ledger_key): # 第一步查台账 return cache.get(ledger_key) # 已经执行过直接返回旧结果 lock acquire_lock(ledger_key, ttl30) # 加锁防并发 try: if cache.get(ledger_key): return cache.get(ledger_key) result do_write(payload) # 第二步真正写入 cache.set(ledger_key, result, ttl86400) # 第三步写台账 return result finally: release_lock(lock)幂等键的构造也讲究我一般用hash(user_id task_id step_id skill_name normalized_args)。这里normalized_args要先做归一化比如把时间戳截断到分钟、把浮点数保留两位否则参数稍有不同就会生成新键幂等形同虚设。这个细节我是在一次事故后才想明白的同一个写操作因为参数里带了毫秒级时间戳重试时生成了不同的幂等键结果写了两次。3. 关键环节的实操落地架构讲完下面进入真正动手的部分。这一节我挑四个最容易出事、也最能体现功力的环节工具调用治理、结构化输出校验、检查点续跑、预算硬闸门。每一个我都会给出我实际在用的参数和代码参数不是凭空来的会说明计算过程你可以按自己业务的情况调整。3.1 工具调用的超时、重试与熔断工具调用是 Agent 与外界交互的唯一出口这里的可靠性直接决定整体成败。我的参数配置逻辑是这样的先统计每个 skill 的历史耗时分布拿到 P50 和 P99超时时间取 P99 的 1.5 倍但不低于 3 秒。举个例子某查询接口 P50 是 320msP99 是 1.8s那超时就设 2.7 秒取整 3 秒。为什么不设更长因为 Agent 任务里通常要调很多次工具如果每个都给 30 秒超时任务总耗时会失控。宁可快速失败让上层决定重试还是换路径。调用类型超时最大重试退避策略熔断阈值只读查询3s2指数退避 200ms 起连续 10 次失败写入类8s1固定 500ms连续 3 次失败外部长耗时30s0不重试直接转异步连续 5 次失败模型推理45s1固定 1s连续 5 次失败写入类为什么只重试一次因为重试本身有风险——如果第一次调用其实成功了只是响应丢了重试就可能造成重复写入。所以写入类必须配合上一节的幂等键才有重试的资格没幂等键的一律不重试。熔断这块我用的是滑动窗口计数窗口 60 秒。一旦某个 skill 进入熔断状态编排层会收到一个明确的能力不可用信号转而去走备选路径或者直接把任务标为可重试失败而不是傻等在那里把整个任务的预算耗光。这个机制上线之后因为单个依赖抖动导致的任务失败率下降了差不多一半。3.2 结构化输出校验与修复回路模型输出 JSON 出错是家常便饭少个括号、多个逗号、把数字写成字符串各种花样都有。我的做法是三道防线。第一道约束解码能用 JSON Schema 约束的接口尽量开约束能从源头减少格式错误。第二道宽松解析写一个容错解析器先把 markdown 代码块标记剥掉再尝试修复常见的尾逗号、单引号问题。第三道修复回路解析失败时把错误信息连同原始输出一起回给模型让它重写最多两轮。def parse_with_repair(raw: str, schema: dict, llm, max_rounds: int 2): candidate strip_code_fence(raw) for i in range(max_rounds 1): try: data json.loads(candidate) validate(data, schema) # 校验类型、必填、枚举 return data except (JSONDecodeError, ValidationError) as e: if i max_rounds: raise candidate llm.fix( originalcandidate, errorstr(e), schemaschema, ) raise RuntimeError(unreachable)这里有个经验修复提示词里一定要带 schema只给错误信息不带结构说明模型修两轮还是修不对的概率很高。另外修复回路本身也要算进成本预算不能无限修。三道防线下来我实测的格式错误率从最初的 7% 左右降到了 0.3% 以下。剩下那 0.3% 基本都是模型返回了语义错误但格式合法的内容比如把枚举值写成了近义词这种只能靠下一节的校验来兜。3.3 检查点与断点续跑长任务必须能续跑否则一次网络抖动就白跑十分钟用户重试一次又得从头开始成本直接翻倍。我的检查点策略是按状态落盘不按步数落盘。具体做法是状态机每次转移时把当前状态名、已完成的步骤列表、关键中间结果、剩余预算序列化成 JSON写到一个带版本号的对象存储路径下。路径用checkpoint/{task_id}/{version}.json保留最近 3 个版本方便回滚到上一个健康状态。序列化的时候有三个坑要避开。一是不要序列化整个对话历史只存结构和结论历史可以重新构造全存会让检查点膨胀到几 MB。二是要存版本号schema 变了之后旧检查点必须能被识别并安全丢弃否则反序列化就是一个隐藏故障源。三是写入要原子化先写临时文件再改名避免写到一半进程挂了留下半个损坏文件——我就因为这个问题丢过一次检查点续跑时反序列化报错排查了两个小时。续跑时的逻辑也要想清楚是重放未完成的步骤还是从断点直接继续我选的是后者因为重放会带来重复副作用而断点继续只需要保证断点后的步骤是幂等的即可。3.4 步数与成本预算的硬闸门Agent 最贵的故障不是报错是陷入循环慢慢烧钱。我遇到过一次模型在两个工具之间来回调用每轮都觉得自己在推进实际是在原地打转跑了 47 步才被我们手动杀掉那一次会话烧掉的钱够跑正常任务两百次。所以预算必须是硬闸门不能只是提示词里写一句请节约使用工具。我的做法是三层限制任何一层触发都会强制终止或转人工步数上限按任务类型的 P95 步数乘以 2 设比如常规任务 P95 是 9 步上限就设 18 步。token 上限按任务类型的历史 P99 消耗乘以 1.5 设超出后禁止再发起模型调用。时间上限从任务开始算总墙钟时间超过 5 分钟直接终止并落失败。三层之外还有一层重复检测如果最近 3 步的 (skill_name, 归一化参数) 三元组出现重复直接判定为循环立刻终止。这一条是我最后加的也是最有效的加上之后循环类故障基本绝迹。4. 可观测性与评测看不见的故障最贵前面讲的都是防,这一节讲看。可靠性设计里有个残酷的现实你无法修复你观测不到的问题。Agent 的执行链路又长又深一次任务可能跨越十几个组件、几十次调用如果没有完整的 trace出问题时你只能靠猜。所以我把可观测性当成和幂等同等重要的基础设施来做投入了不少时间。4.1 全链路 Trace 的埋点方式埋点的核心是给每一次执行分配贯穿全局的 ID并且把上下文一直往下传。我的命名规范是trace_id一次用户请求、task_id一个任务、step_id一步决策、call_id一次具体调用四层嵌套。每个 span 至少要记录这些字段类型model/tool/control、名称、开始结束时间、状态、输入摘要、输出摘要、token 消耗、错误码。摘要要截断长文本只存前 500 字符加长度否则日志存储成本会失控。{ trace_id: t-8f3a21, task_id: task-0091, step_id: step-04, call_id: call-7732, type: tool, name: query_order, start_ms: 1736420000123, duration_ms: 412, status: ok, input_digest: {\order_id\:\***\,\date\:\2025-01-09\}, output_digest: {\count\:3,\total\:128.5}, retry: 0, idempotent_key: h-9c1f..., tokens: null }有了这套数据很多以前想都不敢想的问题变得可查了。比如哪些 skill 的 P99 最拖后腿、哪类任务的重复调用率最高、模型在哪个状态最容易输出格式错误全都可以直接从 trace 里聚合出来。我每周会看一次这些聚合指标用它来驱动下一轮的优化效果比拍脑袋改提示词强太多。4.2 离线评测集与回归流水线没有评测集的 Agent 项目改一次提示词就是一次赌博。我的做法是维护一个分层评测集分三档冒烟集20 条最核心的场景每次改动都跑5 分钟内出结果。回归集200 条覆盖主要分支和边界每天跑一次。对抗集50 条专门构造的刁钻输入比如超长文本、模糊指令、多意图混杂每周跑一次。评分方式我用的是混合判定能用程序断言的绝不用模型评分。比如最终是否写入了目标记录金额是否等于预期值这些直接写成断言。只有回复是否得体摘要是否抓住了重点这类主观项才用模型评分而且要用固定的评分模板和固定的评审模型保证横向可比。回归流水线上线之后有个意外收获我们发现了三个长期潜伏的问题都是之前靠人工测试从没触发过的边缘场景。比如某个 skill 在参数为空数组时会进入死循环线上从没出现过因为业务上不会传空数组但在对抗集里一次就被抓出来了。4.3 灰度发布与回滚策略Agent 的改动比普通服务更容易出事因为你改的不只是代码还有提示词、模型版本、工具描述任何一处变动都可能让整体行为漂移。所以我的发布策略是小流量灰度 双跑对比 快速回滚。灰度的分流键我一般用 user_id 的哈希保证同一个用户始终落在同一个桶里避免体验不一致。第一档 5%观察 24 小时重点看任务完成率和重复副作用率没有异常再放到 25%、50%、100%。双跑对比是更狠的一招把新版本和旧版本同时跑在历史任务集上对比完成率和成本差异超过预设阈值就直接拦下来不发布。这一招拦下过好几次看起来没问题的改动比如有一次只是改了工具描述里的一个措辞模型调用顺序全变了成本涨了 30%。回滚策略要提前准备好包括提示词版本、模型版本、配置项的版本全部可一键切换。我要求回滚时间不超过 3 分钟为此专门做了个配置中心所有可变量都在里面不写死在代码里。5. 故障排查实录与避坑清单讲了这么多设计最后落到最实用的部分出问题了怎么查、怎么修。我把自己过去一年记录的故障整理成了一份速查表还有几条让我直接改架构的教训这些内容在任何官方文档里都找不到都是一次次深夜排查换来的。5.1 高频故障速查表现象最可能的原因排查动作修复手段任务提前结束模型自评误判查 VERIFYING 状态的断言结果用程序断言替代模型自评同一操作执行两次幂等键参数未归一化对比两次调用的 key参数归一化 台账响应越来越慢上下文累积过多查每轮 prompt token 数记忆分层 滚动截断格式错误反复出现修复提示词缺 schema看修复轮次的错误信息提示词补 schema 与示例陷入循环无重复检测看最近 3 步调用序列加重复检测硬中断执行中途报错终止检查点损坏或版本不符尝试反序列化检查点原子写入 版本校验某类任务集中失败依赖服务抖动或熔断看该 skill 的失败率曲线熔断 备选路径成本突然飙升长尾任务拖累 P99看成本分位与步数分布预算硬闸门这张表我贴在了团队 wiki 首页新人值班时按表排查平均定位时间从原来的四十分钟压到了十分钟以内。5.2 几条让我改架构的教训第一条永远不要相信模型说已完成。我们早期完全靠模型自评来结束任务结果有大约一成的任务实际只完成了部分工作但因为模型说完成了流程就结束了用户拿到一个残缺的结果。改成程序断言之后这个比例直接掉到 2%。现在我的原则是能写成断言的绝不问模型。第二条重试之前先问自己重试安全吗。我最惨的一次事故就是无脑重试导致的重复写入排查时看着台账里两条几乎一样的记录心情非常复杂。后来所有写操作的封装里都强制要求提供幂等键没有键的代码在 review 阶段就会被拦下来。第三条超时预算要按状态分配不能一刀切。早期我们给整个任务设了一个 120 秒的总超时结果经常出现规划阶段慢悠悠花了 90 秒执行阶段只剩 30 秒的情况前面浪费时间后面被迫失败。改成按状态分配之后每一步都有明确的节奏整体成功率提升非常明显。第四条改动要可回滚包括提示词。有一次只是微调了一句工具描述模型行为就整体漂移了。这件事让我意识到提示词本身就是代码必须纳入版本管理和灰度发布不能随手改。5.3 最后再分享一个小技巧如果只能给一条建议我会说先给你的 Agent 加一个旁路记录器把所有模型输入输出原样存下来采样比例 1% 到 5%。这个东西成本很低但价值极高。遇到难复现的问题时你能拿着真实的输入输出在本地重放比对着日志猜快十倍。我们靠这个记录器定位过好几个只在特定输入组合下才出现的诡异问题其中有一个是模型在输入包含特定符号组合时会输出不完整的 JSON如果只看线上日志根本发现不了。另外一个我最近在试的方向是给 Agent 加影子执行在正式执行之前先让一个新流程在一个隔离环境里跑一遍把预计的调用序列和成本算出来超过阈值就提前告警而不是等到真的烧完钱。这个方法目前还在小范围验证初步效果不错等数据再积累一段我再单独整理出来分享。做 Agent 这块架构上的投入永远比提示词上的雕花回报高这是我踩了无数坑之后最真实的感受。