开发 Agent Platform,踩了一次真实的线上超时故障
下午两点半,手机连着震了七八次,全是告警群的消息。打开监控面板看到可用性从 99.99% 直线跌到 90% 附近,第一反应是模型供应商又出问题了——毕竟 Agent 平台对外的体验几乎完全绑定在大模型接口的稳定性上。结果这次猜错了,问题出在我们自己的调用链上,准确地说,是一次"不太起眼的第三方工具调用"把整个平台拖进了超时风暴。
我做的这个项目是一个多智能体调度平台,核心工作就是编排任务:拿到用户请求后做意图解析、拆解成步骤,再依次调用大模型和各类工具(检索资料、执行代码、拉取第三方数据),最后把结果整合返回。听上去不复杂,但真正跑到线上后,各种隐藏问题才会暴露出来。这篇就完整复盘这次线上超时故障,从表象、误判、根因,到修复方案和设计教训,都摊开讲清楚。如果你是做 Agent、做平台、做异步任务编排的,应该都能找到有用的东西。
1. 从告警到失控:故障发生时的第一现场
1.1 告警指标长什么样
先说告警。当时同时触发了三类监控:
- 可用性告警:核心接口成功率从 99.99% 掉到 90% 左右,持续 3 分钟未恢复;
- 延迟告警:P95 延迟从正常时的 1.5 秒飙升到 18 秒,P99 直接超过 40 秒;
- 线程池告警:核心调度线程池活跃线程数接近最大值,队列任务数快速积压。
看到这三类告警同时亮起,第一判断就是某个下游依赖出问题了。因为 Agent 平台和普通 CRUD 服务不一样,它没有简单的"缓存可以扛"这层缓冲——每次请求背后是一长串实时调用链,任何一个环节变慢,整个请求都跟着慢。
1.2 Agent 平台请求链路的特点
我们的一个典型 Agent 任务会经历这样的循环:
- 接收用户请求,做意图识别和任务规划;
- 根据规划结果调用大模型生成下一步动作;
- 如果动作需要工具,就调用对应的工具接口(检索、计算、第三方 API);
- 拿到工具结果后,再次交给大模型,让它决定下一步;
- 循环直到任务完成,或者到达全局步数上限。
所以一个看似简单的用户问题,底层可能串了四五次大模型调用,中间还夹着好几次工具调用。这意味着一次用户请求的成败,取决于链条上最慢的那个环节,而每个环节的网络开销、超时设置、重试策略,都会产生叠加效应。
1.3 从现象到初步定位
故障当时,我们的第一轮排查动作是:
- 打开业务日志,看到大量
TaskRejectedException,说明线程池已经拒绝新任务; - 打开全链路追踪系统,发现大量调用链的耗时集中在"某第三方数据服务"的 HTTP 调用上;
- 查看该第三方服务的调用统计,P99 从正常时的 800ms 暴涨到 32 秒。
到这里,表象已经很清楚了:下游的一个数据服务响应变慢,拖垮了上游的整个线程池。但这只是"表象归因",真正的根因藏在为什么它会这么快拖垮全局,以及为什么我们没有在第一时间拦截住。
2. 第一次误判:把矛头指向了外部模型服务
2.1 为什么第一反应是"模型供应商又挂了"
Agent 平台的日常运维中,大模型服务的稳定性是我们最敏感的一根神经。接口动不动就限流、超时、甚至返回异常,都是家常便饭。所以故障发生后,团队前十分钟几乎都扑在检查模型服务的调用数据上。
当时检查了几个点:
- 模型服务的错误率:正常,没有明显上升;
- 模型服务延迟:P95 维持在 2~3 秒,和平时差不多;
- 模型鉴权是否出问题:没有异常记录。
也就是说,模型服务并不是这次的瓶颈。但我仍然提了工单去问,因为 Agent 平台的体验太依赖模型了,谁也说不准是不是对方内部有小概率故障,只是我们这边的监控粒度看不到。
2.2 错误归因带来的时间成本
大概过了十分钟,才有人喊了一句:"你们看全链路追踪,那个第三方数据服务是不是有问题?"这时候我们才把视线从模型服务移开,开始仔细看全链路数据。
事后复盘,这十分钟其实非常宝贵。误判方向本身不可怕,可怕的是整个团队在错误的方向上反复确认,而线上故障还在持续。这也是监控设计的一个教训:光有告警还不够,告警必须尽量带上"方向性"的信息,否则大家在慌乱中会按惯性猜测。
2.3 真正有用的第一手线索
后来我们是怎么快速锁定的?靠的是全链路追踪里的两个字段:
- 耗时分布:故障期间,新发起的请求中有 70% 以上的耗时集中在某个 HTTP Span 上;
- 错误类型:该 Span 的错误主要是
Read timed out,说明是客户端读到超时,不是对方返回失败。
这几乎可以断定:对方接口本身还在响应,但响应速度极慢,导致我们的客户端在等待中不断堆积。于是真正的排查才刚开始:为什么一个下游变慢会让整个平台几近瘫痪?
3. 真正的根因:线程池耗尽与调用链的放大效应
3.1 排查链路:从线程池到 JVM 到网络层
我们按以下顺序逐一排查:
- 看线程池状态:核心调度线程池配置的核心线程数为 200,最大线程数为 400,队列容量 1000。故障时活跃线程数长期维持在 380 以上,队列持续打满,新任务直接被拒绝;
- 看线程堆栈(jstack):抓了一次线程 dump,发现 70% 以上的工作线程阻塞在同一个第三方 HTTP 调用的 socket 读等待中;
- 看连接池:下游连接池共 50 个连接,全部被占满,等待获取连接的线程在排队;
- 看超时配置:核心调度线程池对外部工具调用使用的超时时间默认是 60 秒,当时那个第三方服务单次响应已经达到 30~60 秒,一个线程一个请求就要等满 60 秒才能释放。
到这里,根因已经很清晰了:局部下游变慢 + 过长的超时时间 + 无差别的重试策略 = 线程池迅速耗尽。
3.2 重试风暴:看起来没多少流量,实际上翻了几倍
还有一个隐蔽问题:重试。
我们当时的工具调用模块里,对部分第三方服务设置了失败重试,默认重试 2 次。本来在正常情况下这没什么,但当对方服务开始变慢时,重试变成了灾难:
- 第一次调用慢到 30 秒超时;
- 失败后立刻重试,又等 30 秒;
- 第二次重试再等 30 秒。
一次工具调用最长可能吃掉 90 秒,而这段时间内,线程一直挂在这次任务上,无法处理任何新请求。上游还在不断发起新请求,每个都往线程池里占一个位置,然后全部卡在等待中。这就是经典的线程池饥饿现象。
3.3 用表格复盘参数配置的问题
我把故障前后的关键配置整理成了一张表,方便大家对照看问题出在哪:
| 配置项 | 故障前 | 故障中 | 问题分析 |
|---|---|---|---|
| 工具调用超时 | 60 秒(全局默认) | 无法自动缩短 | 超时过长,线程长时间占用 |
| 重试次数 | 失败重试 2 次 | 每次失败都重试 | 放大下游压力,倍增等待时间 |
| 连接池大小 | 50 | 50,全部占满 | 下游变慢时连接池迅速耗尽 |
| 任务队列 | 有界队列 1000 | 持续打满 | 新任务被拒绝,可用性下降 |
| 线程池隔离 | 无,核心线程池共用 | 所有任务共用 | 单点变慢拖垮全局 |
这些配置单独看都不是致命问题,但组合在一起,就成了一个放大器:下游慢一点,整个系统就翻车。
3.4 Agent 场景为什么会放大这类故障
普通 Web 服务遇到下游变慢,最多就是请求变慢、用户排队等待。但 Agent 平台不一样,一个 Agent 任务内部有循环——它会反复调用大模型、反复调用工具,一次任务内可能包含 5~10 次外部调用。
这意味着:假设一个下游工具变慢导致单次调用耗时从 1 秒变成 30 秒,那一个原本只需要 5 次工具调用的 Agent 任务,整体耗时可能从 5 秒恶化到 150 秒。而在这 150 秒内,线程池中的所有线程都在为一个任务服务。同样的下游故障,Agent 平台的放大倍数远高于普通服务,这是 Agent 类平台在超时设计上必须特别警惕的原因。
4. 修复方案:超时治理、隔离舱室与服务降级
4.1 给所有外部调用设置"分类型超时"
第一件事,是取消那个全局默认 60 秒的"懒人配置"。我们对所有外部调用按照类型和用途重新梳理了超时时间:
| 调用类型 | 推荐超时 | 理由 |
|---|---|---|
| 大模型推理调用 | 30 秒 | 模型推理本身耗时长,但超过 30 秒大概率是网络或服务问题 |
| 实时工具调用(检索/查数) | 5 秒 | 工具响应通常快,5 秒足够覆盖绝大多数情况 |
| 非核心工具调用(辅助信息) | 3 秒 | 拿不到就丢弃,不影响主流程 |
| 整 Agent 任务上限 | 60~120 秒 | 防止单个任务无限循环,全局兜底 |
这些超时值不是拍脑袋定的,我们是参考了线上 P99 延迟的分布,取"正常情况下的 P99 加上一定余量"。比如工具调用的 P99 是 800ms,设置 5 秒超时就是留了约 6 倍余量,既不会误杀正常请求,又能在故障时快速释放线程。
4.2 重试策略:只看幂等,且必须走退避
重试必须有三个前提:
- 只对幂等操作重试:读操作、单纯的检索操作可以重试;写操作、有副作用的操作(比如下单、发消息)坚决不重试;
- 限制重试次数:最多 1 次,超过就直接放弃;
- 重试必须带退避+抖动:第一次失败后至少等待 500ms 再重试,随机加 0~200ms 抖动,避免同一时刻大量请求集中重试。
这个改动非常关键。故障期间,最早的 2 次重试策略让请求数直接翻了三倍;改成最多 1 次重试后,请求量最多只会翻一倍,而且退避机制能给下游留出恢复窗口,不会形成对下游的二次冲击。
4.3 线程池隔离:按依赖的重要程度拆池
原来的问题是所有任务共用同一个核心线程池,任何一个依赖变慢都会占满全部线程。我们重新设计了线程池结构:
- 核心编排线程池:负责 Agent 的主循环,只做调度和编排,不做任何网络 IO 等待;
- 大模型调用线程池:单独一个池,专门处理模型推理调用,配独立超时和连接池;
- 工具调用线程池:再单独一个池,按工具类别拆分为多个小组,比如检索类、计算类、第三方数据类。
这样做的好处是:某个工具组的线程池被打满时,其他组的任务仍然可以正常运行。用一句通俗的话说,就像一栋楼里每户装了独立电表,一家跳闸不至于整栋楼停电。
4.4 信号量隔离与舱壁模式
线程池隔离之外,还有一个更轻量级的方案:信号量(Semaphore)。它的特点是只控制并发数,不额外占用线程资源。
我们在每个工具调用的入口加了一个"并发信号量"。比如某第三方数据服务最多允许 20 个并发调用,超出并发上限的请求直接快速失败(Fail Fast),而不是排队等待。这有两个直接效果:
- 下游变慢时,最多只有 20 个线程被这个服务拖住,不会继续蔓延;
- 超出上限的请求秒败,让调用方尽快走降级逻辑,而不是把用户挂在那里等 60 秒。
这就是舱壁模式的核心思想:把对某个依赖的访问限制在一个"隔间"里,即使它炸了,也只影响这一个隔间。
4.5 降级策略:拿不到结果也要让 Agent 走下去
第三步是降级。Agent 平台有个天然优势:任务流程本身就是弹性的。工具拿不到结果时,可以有两种降级方式:
- 空结果降级:告诉大模型"这次检索没拿到数据,请基于已有知识回答",让流程继续;
- 默认值降级:某些参数类查询(比如汇率、基准值),直接返回一个默认值并标注"数据延迟"。
降级方案实施后,即使第三方服务完全不可用,用户任务也不会卡死,只是结果质量会略降。对于绝大多数场景,"给结果但不够好"远胜于"一直等然后报错"。
4.6 全局任务超时兜底
最后加了一个总闸:任何单个 Agent 任务,整体耗时超过 90 秒就强制终止。这个时间从任务开始算起,不管内部循环多少次、调了多少工具,到了时间就掐断,返回给用户一个"任务处理超时"的明确提示,后台再慢慢补跑或重试。
这一步是为了防止"死循环"——比如大模型一直规划同一个动作、工具一直返回异常数据导致 Agent 反复重试,没有全局兜底的话,单个任务可能吃住一个线程几个小时。
5. 复盘清单:Agent 类系统设计超时机制的几条铁律
5.1 第三方服务的 SLA 绝不等于我们的超时上限
这是这次故障最深刻的教训。我们当时对那个第三方数据服务的判断是"SLA 稳定、平均延迟低",所以没有专门给它设计超时策略,而是套用了全局默认值。结果它一旦抖动,我们连反应时间都没有。
所有外部依赖,哪怕是响应速度一直很快的,也必须有自己的超时配置和并发上限。稳定性是动态的,不是静态的。
5.2 超时必须分层,越往下越短
一个好的超时体系应该是金字塔结构:
- 底层每个网络 IO 调用都有短超时(3~10 秒);
- 中间每个 Agent 步骤有中粒度超时(比如 20 秒);
- 顶层整个任务有全局超时(60~120 秒)。
每层超时要比上层短,这样才会形成"快速失败,向上反馈"的传导机制。如果反过来——任务层 30 秒、调用层 60 秒——那任务层兜底就失效了。超时是层层预警,不是最后兜底。
5.3 全链路追踪是排查 Agent 故障的第一生产力
这次故障如果没有全链路追踪,我们大概率还要在"模型供应商是否出问题"上浪费更多时间。Agent 平台的调用链长、环节多,没有可靠的 trace 系统,排查故障基本只能靠猜。
建议至少做到:每次外部调用都记录独立的 span,包含耗时、结果、重试次数;每个 Agent 任务记录完整的调用链上下文。这在平时可能看不出用处,故障发生时就是救命稻草。
5.4 演练不能只演"成功路径",要演"依赖故障"
我们之前做过很多次演练,但演练的大多是服务自身故障、机器宕机、流量突发,很少演练"某个看似不重要的第三方服务变慢"的场景。这次故障恰恰是这种边缘场景。
之后我们把混沌工程加入了常态化演练:随机选一个下游依赖,人为注入 10 秒延迟,观察系统是否能自动降级、快速恢复。一个不敢拔掉电源的系统,就永远不知道自己有多脆弱。
5.5 并发和排队是两回事,别混在一起
这里的经验是:并发控制要做到"宁可拒绝,不要排队"。对于 Agent 平台这种延迟敏感的编排系统,队列并不能提高吞吐,只会让大量请求一起等待,然后把迟到的错误又进一步放大。我们后来把大多数调用场景改成"信号量 + 快速失败"模式,宁可让一小部分请求直接报错重试,也不要让大量请求排队等死。
写在最后:这次故障给我带来的改变
故障修复后的一个月里,我又回看了很多遍当时的 trace 和线程 dump。说实话,这类问题在 Agent 平台里几乎不可能完全避免——你的系统一定会有某个依赖,在一个意想不到的时间点,突然变慢。技术方案其实都是通用工程手段,真正难的是把它们落实到每一个调用细节中。
我现在设计任何一个小工具调用,都会先问三个问题:如果这个调用要等 30 秒,系统会怎样?如果这个调用被重试两次,流量会翻几倍?如果这个调用完全不可用,任务能不能降级?
那次故障之后,我给自己定了一条规矩:每次接入新的外部服务,第一件事不是写业务代码,而是写超时配置、并发上限、降级策略、trace 埋点——代码之后可以慢慢补,这四样东西少了任何一个,都别上线。