这个系列的第六篇不打算再画架构图讲大道理。先记录一个让我连续熬了两晚的现场v1版本的Agent在演示环境里跑通了所有指定用例逻辑链路走得非常顺结果正式环境上线48小时内我连续处理了三起事故每一件都是最初设计时完全没考虑的场面。也就是从那一刻开始我真正意识到Agent v2的核心课题早就不是能不能跑而是——当它跑起来之后能不能在真实流量下活下来、能不能在出问题时被快速定位、能不能在成本失控前主动踩下刹车。这三起事故把整个项目硬生生逼进了工程深水区。如果你也在做一个看起来已经能跑的Agent系统那这篇文章值得你泡杯茶慢慢看。我不会按教科书顺序讲就按我踩坑的先后顺序把这些真实问题和对应解法一条条写清楚。1. v1的能跑只在温室里成立上线48小时的三个故障现场1.1 故障一上下文串号把任务A的历史塞给了任务B第一个工单在上午十点进来的。用户反馈说他在Agent对话里看到的上一轮操作记录明明是他自己没做过的事情。一开始我以为是前端缓存查了一圈发现不是——是服务端把不同任务的历史消息串到了一起。排查到最后根因非常简单v1的上下文对象被设计成了进程级单例。也就是说不管哪个任务进来用的都是同一个内存里的消息列表。单用户跑Demo的时候当然没事因为同一时刻只有一个任务在写数据但正式环境一有并发两个任务同时往里写消息A任务的下一条上下文就拿到了B任务的历史。这类问题在单测里是绝对跑不出来的因为你不会用两个线程同时操作同一个引用去测一个理论上单用户使用的功能。这个故障也让我明白了一个道理Agent的上下文不是普通的HTTP会话数据它是一个任务从开始到结束的全部执行记忆。它必须按任务隔离而不能按请求共享。任何把上下文设计成公共变量的做法本质上是把并发安全的责任全部丢给了调用方这在多任务并发的Agent系统里几乎等于埋雷。v2里我们将上下文对象彻底收编为任务级实体每个任务有独立的上下文存储进程内只保留当前活跃任务的引用杜绝跨任务复用。1.2 故障二模型接口持续超时任务池整体卡死下午三点模型供应商的接口开始抖动。单次请求的响应时间从正常的几百毫秒拉长到几十秒而且迟迟不返回。v1里我们根本没有给外部请求设置超时上限所有请求都抱着一定能等到响应的期望傻等。结果是线程池里的线程全部被慢请求占满新的任务请求进不来任务队列越堆越高二十分钟内堆了三万多条。更糟的是健康检查也因为这个线程池的阻塞而失败服务被调度系统反复杀掉重启导致任务机反复横跳——那天的告警群直接变成了连续刷屏的瀑布。这次事故的经验非常直接外部依赖必须预设超时而且超时值不能靠拍脑袋。你可以先统计过去两周该工具接口的耗时分布取P99分位数作为一个合理阈值再决定你要设成P99的1.5倍还是2倍。关键是必须有上限这件事不能妥协。没有超时的调用本质上等于把系统的可用性交给了一个你控制不了的第三方进程。1.3 故障三Agent陷入循环调用成本被一夜打爆如果说前两个故障是系统不稳第三个故障就是钱包出血。晚上十一点成本预警邮件弹出来我打开后台一看某几个任务的Token消耗量已经是正常水平的几十倍。追踪之后发现某个Agent在处理任务时进入了死循环调用某个工具工具返回错误Agent把这些错误信息重新塞给模型让模型重新规划模型给出的新方案里还是那个工具于是又调用又报错又规划……一个简单的抓取任务实际循环了七十多次模型调用才被手动终止。演示环境里工具几乎不会失败所以这个循环路径根本没被触发。但真实环境里工具报错是家常便饭没有步数上限和成本熔断就等于是裸奔。这个故障我印象太深了。从那以后我在所有Agent项目的设计文档里都加了一条任何Agent执行流程必须定义最大步数、最大工具调用次数、单任务Token预算上限而且在超出后要主动终止并落一个失败状态绝不能把怎么结束的选择权完全交给模型。这三起事故合在一起给v2定下了改造方向上下文要按任务隔离、外部调用要控制超时和重试、Agent执行必须有步数上限和成本护栏。围绕这三个方向接下来几个月的开发基本都在和状态可靠性可观测性较劲。2. 状态必须重写从无状态请求到任务级状态机2.1 无状态设计为什么在Agent场景中不成立v1把Agent当成一个普通的无状态HTTP服务来写每个请求进来从数据库捞历史消息拼一段上下文发给模型接口拿到回复再存回去。做问答类Demo的时候这套模式一点问题都没有。但一旦Agent承担的是一段需要跨多轮、调多工具的任务比如整理昨天的销售报告写一段总结再发送给对应负责人最后在日历里创建一个提醒事情就变了。这类任务不是一个请求就能完成的。它需要分阶段执行每个阶段之间可能有工具返回的中间结果可能有等待用户确认的暂停点还可能因为某个工具失败而需要改走另一条路径。如果你还把它当成无状态请求那么一旦进程重启、网络闪断、任务被重新调度之前的执行进度就全丢了。更麻烦的是多个重复的调度线程可能会同时处理同一个任务造成工具被执行两次。所以v2的第一个重构决定是把每个Agent任务当成一个长期运行的实体。它有唯一的任务ID、有生命周期、有状态迁移规则。整个系统不再是处理请求而是推进任务状态。2.2 任务状态机的结构与落盘设计我给每个任务定义了一组基础状态PENDING等待执行、RUNNINGAgent决策中、WAITING_TOOL等待工具返回、WAITING_USER等待用户确认、COMPLETED正常完成、FAILED执行失败、ABORTED被人工终止。状态之间不允许随意跳转必须通过明确的事件触发迁移。事件原状态新状态说明task_created-PENDING新任务进入队列executor_startedPENDINGRUNNING调度器取出任务开始执行tool_invokedRUNNINGWAITING_TOOLAgent决定调用外部工具tool_succeededWAITING_TOOLRUNNING工具返回结果继续决策tool_failedWAITING_TOOLRUNNING工具报错Agent需重新规划user_input_requestedRUNNINGWAITING_USER需要用户补充信息user_input_receivedWAITING_USERRUNNING收到用户输入继续执行task_completedRUNNINGCOMPLETED所有步骤完成abort_requested任意非终态ABORTED用户或管理员终止timeout_reached任意非终态FAILED超过限定时间强制失败状态机不只是给运维看的一个展示板。它也直接参与了并发控制数据库中同一个任务ID只允许一个活跃执行者持有状态机本身就是一把分布式锁。当一个调度节点想要执行某任务时必须先尝试把任务的状态从PENDING迁移到RUNNING迁移成功才真正开始干活。如果有人重复调度第二次迁移会因为当前状态不是PENDING而失败从机制上避免了重复执行。2.3 状态持久化与进程重启兜底状态只存在内存里肯定不行。v2把所有状态变更做成事件日志每次迁移都追加一条task_id 事件名 事件数据 时间戳写入K-V存储。同时周期性生成一个最新快照保存任务当前状态、上下文摘要、已执行步骤和Token消耗。这样即使进程崩溃重启后也能从快照恢复再用事件日志补最近的变动。恢复逻辑有一个容易被忽略的坑不是所有未完成的任务都适合自动续跑。如果任务卡在等待外部工具返回而进程已经崩溃过了那之前那次工具调用到底成功没有你并不确定。我的做法是扫描所有非终态任务如果距离上次更新时间超过了预设的TTL直接标记为FAILED并记录原因如果还在TTL内重新提交执行队列但必须在工具调用层配合幂等键后面第三章细说防止同一个工具被再次真实执行。def resume_pending_tasks(): tasks state_store.scan(status__in[RUNNING, WAITING_TOOL]) for task in tasks: if task.updated_at now() - TTL: state_store.transition(task.id, FAILED, reasonrestart_timeout) else: executor.submit(run_task, task.id)这段代码看起来简单但它解决的是进程重启后Agent任务凭什么不丢这个根本问题。演示环境下你不会去想进程崩溃的事生产环境里它几乎每个月都会发生一次。3. 调用层的深水区超时、重试、幂等与级联熔断3.1 超时风暴一个接口抖动如何拖垮任务池v1留下的第二个教训是没有超时控制。v2里我把所有外部调用分成了三类分别设置策略模型接口调用、内部工具调用、子Agent调度。每一类都单独配置连接超时、读取超时和最大重试次数不能一把梭。超时值的确定我建议直接看历史耗时分布而不是凭感觉设。比如某个工具过去14天P99耗时是200毫秒那超时设成500毫秒是合理的给慢请求留了一点余地又不至于让整个任务被一个异常请求拖死。如果你设成200毫秒那1%的正常请求会被误杀如果你设成“无限”那一次抖动就能把线程池打满重演v1的事故。dependencies: llm_api: connect_timeout: 3s read_timeout: 30s max_retries: 2 tool_fetcher: connect_timeout: 2s read_timeout: 5s max_retries: 1 tool_email_sender: connect_timeout: 5s read_timeout: 30s max_retries: 0注意看上面配置里tool_email_sender的max_retries是0。因为发邮件不是幂等操作——一旦超时重试用户可能收到两封一模一样的邮件这种副作用比重试带来的好处可怕得多。而像搜索、读取这类只读操作重试一把是安全的。所以重试策略的第一个判断标准不是这个请求重不重要而是重试是否会产生额外副作用。3.2 幂等补偿重试必须能安全地重复执行上面说的超时其实埋了另一个更深的问题一个请求超时了不代表后端没有执行。比如发邮件的接口可能邮件服务端已经收到了指令并发送成功只是响应包在网络传输中丢了客户端这边表现为超时。这时候如果你盲目重试邮件就会发两次。v2的解决办法是引入幂等层核心思路是先记意图再执行动作。在调用任何可能产生副作用的工具之前系统先根据任务ID、步骤索引和工具参数生成一个幂等键把这个意图记录写入存储。真正的执行请求带着这个幂等键发给工具服务端工具服务端收到后先查这个键是否处理过处理过就直接返回第一次的结果没处理过才真正执行。def call_tool_with_idempotency(tool, payload): key hash(f{task_id}:{step_index}:{tool.name}:{json.dumps(payload)}) cached idempotency_store.get(key) if cached: return cached result tool.execute(payload, request_idkey) idempotency_store.set(key, result, ttl86400) return result这个模式和分布式系统里的幂等消费是一回事。它的价值在于有了这层保护上面的超时重试策略才敢真正打开。否则你每次重试都是在赌上次其实没成功这个赌注在生产环境里迟早会让你输一次。3.3 级联熔断从单点限流到全链路保护超时和重试是控制单次调用的行为熔断解决的是某个依赖已经病入膏肓时系统如何大面积自保的问题。v2里每个外部依赖都配了一个熔断器状态在关闭→打开→半开之间流转。当某个工具连续报错的次数超过阈值熔断器打开后续请求不再真的打过去而是直接快速失败返回一个当前依赖不可用的信号。Agent收到这个信号后可以选择备用工具也可以直接告诉用户这个功能暂时不可用但不允许继续反复试同一个坏掉的依赖。熔断还要分级。简单说就是不能因为一个用户的刷量请求让全系统的熔断阈值都被打穿。我建议至少分两层全局熔断和按用户维度的限流。全局熔断保护的是依赖本身是否健康用户级限流保护的是个别异常流量不能把共享配额吃光。这就像小区的水管——总闸要在源头拦住跑水但每户也得有个阀门不然一家漏水全楼停水。另一个容易忽略的点是熔断不只要在调用工具这一层做任务队列入口也要做。如果上游任务已经堆积了几万条继续往里塞新任务只会让系统更糟。应该在上游入口直接拒绝超额任务并返回明确提示让调用方知道当前负载已满稍后再试。4. 可观测性补齐Trace、审计与Token成本账本4.1 一次决策的完整调用链还原Agent系统的排障难度比普通接口服务高出一个量级。普通服务一次请求对应一次处理出问题翻日志就行Agent系统一次用户输入可能触发三四次模型调用、十几次工具调用中间还夹杂着Agent自己的重试和重新规划。没有可观测性的时候出了问题你除了看Prompt文本什么都做不了根本定位不了是哪一步决策让Agent跑偏了。v2给每个任务分配一个trace_id每执行一步决策就生成一个span内部带上parent_span_id来串联父子关系。日志里必须出现这些字段task_id、step_index、span_id、type模型调用/工具调用/子Agent、tool_name、status、duration_ms、token_usage。{ timestamp: 2025-01-01T00:00:00Z, trace_id: trace-123, task_id: task-456, step_index: 3, span_id: span-789, parent_span_id: span-456, type: tool_call, tool_name: fetcher, status: timeout, duration_ms: 6000, token_usage: 850 }排障的时候我只需要按trace_id把这一条线上的日志全部捞出来就能回答三个问题任务现在走到哪一步了每一步花了多长时间哪一步是失败源头没有这套东西时我排一个故障平均要两小时补齐之后十分钟内基本能定位问题。做这件事的成本并不高但它必须从第一天就做而不是等事故来了再补。4.2 Token成本账本每一笔花销都要归因成本在Agent系统里不是运营侧的课题它直接影响架构设计。一次任务如果Token消耗失控你再多的高可用设计都是白搭因为系统本身就不可持续。v2里的每次模型调用都会记录模型名、输入Token数、输出Token数、任务ID、步骤索引。每天凌晨跑一个聚合任务按任务维度做成本排序把单任务Token消耗超过预设阈值的全部拉出来打标签。SELECT task_id, DATE(created_at) AS day, SUM(prompt_tokens completion_tokens) AS tokens_total, SUM(cost_estimate) AS cost_total FROM token_usage_log WHERE created_at CURDATE() - INTERVAL 1 DAY GROUP BY task_id, day ORDER BY cost_total DESC LIMIT 100;我第一次跑这个查询就发现了个别任务的Token消耗是正常均值的80倍追进去一看还是老问题——工具失败后Agent反复重试没有快速失败机制。所以成本账本并不是财务部门的事它就是工程质量的探针Token消耗异常的背后往往藏着代码逻辑缺陷。成本账本还要和告警联动。我建议设两级第一级是警告单任务Token超过预算的80%就通知第二级是熔断单任务Token超过预算上限就主动终止并落为FAILED。这比事后看账单要有效得多。4.3 告警不止看错误率还要看步数异常很多Agent系统的告警还是传统服务的三板斧错误率、响应时间、CPU。但Agent系统有几个特有的指标不看必出事故。第一个是单任务步数正常任务一般5到10步就结束如果某个任务跑到20步以上大概率是进了循环需要立即告警。第二个是重试率单个依赖的重试比例异常升高往往意味着它正在经历抖动要准备触发熔断。第三个是上下文长度增长曲线如果任务的上下文Token在几轮内指数级上涨通常是把中间过程全塞进去了这个任务迟早会在上下文上限撞车。这些指标组合使用效果更好。单看错误率你可能只看到工具有几次失败把错误率、重试率、步数三个指标叠在一起才能看清工具失败→Agent重试→步数飙升→成本打爆这个完整的灾难链条。5. 决定上线成败的工程细节Prompt版本化、沙箱隔离与回归集5.1 Prompt不是一个字符串而是带版本的资产v1里我管理Prompt的方式非常原始每个版本复制一份文本丢在共享文档里上线的时候手动粘到代码中。那Red模式的痛苦经历过的人都知道——改了一个逗号第二天线上Behavior变了想回滚却找不到上一个版本在哪里。v2之后Prompt被当成了和代码同级的资产。每条Prompt有唯一ID、版本号、适用模型列表、变更记录和上线状态。发布时和代码一起走完整的部署流程所以可以随时指定某条Prompt的某个版本重新推送。同时也支持A/B对比不同任务可以指向同一逻辑ID下的不同版本用真实流量来比较效果。id: planner_v7 model_compatibility: - model-a-1.0 - model-b-2.0 content: | 你是一个任务规划Agent。请把用户目标拆成不超过三个步骤。 每一步必须包含步骤说明、依赖工具、预期输出。 如果工具执行失败调用备用工具或向用户说明原因最多重试一次。 safety_rules: max_steps: 8 forbidden_tools: [admin_delete, mass_email]之所以要专门标注model_compatibility是因为不同模型的指令服从度差异很大。同一段Prompt在一个模型上表现完美换一个模型可能就完全失控。手改Prompt来适配模型不可怕可怕的是没有记录、没有回滚点导致线上出了问题只能靠猜。5.2 Agent的工具执行必须沙箱化Agent的能力是通过工具调用体现的但能力越强越要限制它的活动范围。v2里所有工具调用都统一走沙箱通道工具能访问哪些网络地址、哪些文件目录、哪些环境变量全部白名单化。内部工具按权限分组低信任的工具只能在一个临时目录里读写临时文件用完即销毁。资源使用也被限制单次执行超过内存或CPU上限就会被强制杀掉。这个设计最初被有些人嫌重但后来的事实证明它救过我们好几回。比如某个网页采集工具在解析到异常页面时会产生一个超大临时文件如果没有沙箱和磁盘配额这个文件会直接写到宿主机的临时目录把服务磁盘打满。沙箱环境下它只会把工具实例的磁盘打满然后被强制终止宿主进程毫发无损。沙箱化的另一个价值是行为审计。沙箱会把工具所有网络请求的域名、端口、目标IP全部记录在案一旦出现异常外联Ruby模式可以立刻定位。这对于一个需要长期运行的Agent服务来说不是可有可无的功能而是上线底线。5.3 回归集把上次能跑变成自动化断言最后一个工程化重点是回归集。如果你经常改Prompt或者调Agent流程没有回归集几乎是灾难。人工手动点一遍Demo花费半小时还容易漏掉边界场景更严重的是人测的时候往往记不住上一个版本具体表现所以到底有没有回归全凭印象。v2的做法是从真实历史数据里挑出一批代表性用例组成固定的评测集。覆盖几类场景正常多轮任务、工具失败后的重规划、用户中途取消、上下文超长、高并发下的状态隔离。每次改动代码或Prompt之后自动跑一遍这个评测集断言输出结构、工具调用顺序和总Token消耗上限。def test_report_task(): result agent.run(整理昨天的销售报告并发给A同学) assert result.status completed assert result.steps [search_docs, summarize, send_email] assert result.total_tokens 5000这个回归集的判断力远超人的耐心它能稳定发现三类问题新的Prompt让模型漏了一步工具调用、某个超时配置导致任务被提前失败、某个改动让Token消耗暴增但功能看起来正常。严格执行每次变更必跑回归的纪律之后线上回归事故的数量肉眼可见地降了下来。最后分享一点个人体会。v2上线之后我最大的变化不是代码写得更好看了而是对能跑的定义彻底改变。以前觉得Demo通了就是完成了现在则认为一个系统只有做到在失败时仍然可控、在被投诉时能快速定位、在成本扩大前有闸门可拉才算真正达到了能上线的标准。如果你正准备把一个Agent系统推向生产我的建议是别急着叠新功能先把状态、幂等、Trace、回归集这四件事做扎实。深水区里的每一步都需要这些基础来兜底。