做 Agent Platform 的人估计都体会过这种场景:线上一切平稳,突然某个下午告警群开始刷屏,用户说任务提交了半天没反应,你打开监控面板发现 P99 已经从平时的 300ms 直接飙到了 12 秒。我这次踩的就是典型的一例。表面上看是一个工具接口变慢了,实际上牵出了平台设计上的三个深层问题:缺少端到端超时预算、线程池没有隔离、Agent 重试机制在放大故障。这篇文章把整个排查过程、根因分析和修复方案完整复盘一遍,希望能给同样在做 Agent 编排平台、智能体框架或者复杂异步任务系统的朋友一些参考。文章不会太长篇大论讲原理,重点放在"我当时看到了什么、怎么定位、怎么改的"上。
1. 架构布局:超时故障为什么会发生在这里
1.1 平台的基本组成
先交代一下我们这套 Agent Platform 的现状。它不是单体的机器人服务,而是一个多层编排系统,专门负责把用户的自然语言请求拆解成可执行的子任务,再交给不同的 Agent 去完成。核心分这么几层:
| 层级 | 职责 | 关键组件 |
|---|---|---|
| 网关层 | 对外 API 入口、鉴权、限流、任务 ID 生成 | API Gateway |
| 编排层 | 任务拆解、子任务状态流转、依赖关系管理 | Orchestrator |
| 执行层 | 运行 Agent 主循环,调用 LLM 和各类工具 | Runner / Worker |
| 工具层 | 封装内部服务调用、HTTP 接口、数据库操作 | Tool Registry |
| 记忆层 | 会话记录、向量检索、KV 状态存储 | Memory Store |
执行层是整个平台最忙的地方。一个 Agent 不是简单做一次"请求-响应",它会进入一个循环:"推理 → 决定调哪个工具 → 调用工具 → 观察结果 → 继续推理",直到任务完成或达到上限。这个循环每一轮都可能涉及到一次或多次 LLM 调用,加上一次或多次工具调用。所以一次用户请求,在系统内部往往会被放大成十几次甚至几十次内部调用。这个特点为后面的超时故障埋下了很重要的伏笔。
1.2 一次正常请求的调用链
一个正常的任务大概长这样:
- 用户在网关层提交请求:"分析这几个数据源,生成一份周报"。
- 编排层把任务拆分:先查数据 A、再查数据 B、再调模型生成摘要、最后调报表工具渲染。
- 执行层启动一个 Runner,按顺序执行子任务。每执行一个工具调用,Runner 会等待结果返回。
- 工具层把内部 HTTP/RPC 请求发出去,拿到数据后交回给 Runner。
- Agent 可能还会根据返回结果决定"是否需要补充查询",于是再进入下一轮循环。
这个链条里,每一层都有一段等待时间。正常情况下,模型推理平均 1~2 秒,工具调用平均 50~200ms,整体 P99 能维持在 300ms 左右,用户无感。但如果任何一个环节出现延迟,会直接导致 Agent 循环的某一轮"卡住",而且因为 Agent 有反思和纠错机制,一旦某次调用失败或超时,它可能不会立刻退出,而是重新规划再试一次。这个"再试一次",就是超时放大效应的起点。
1.3 架构上的隐患
复盘之后,我认为当时架构里有三个隐患,属于"平时没问题,一出事就是大事"的类型:
- 每个环节有独立超时,但全局没有端到端超时预算。网关层设置了整体超时 15s,编排层每个子任务 10s,工具层每个调用 5s,模型层 10s。表面上每层都有兜底,但这些时间是叠加的,不是取最大。一次用户请求如果经历"模型 5s + 工具 5s + 模型 5s + 工具 5s",单看每一层都没超时,用户侧却可能已经等了 20 多秒。
- 执行层线程池是共享的,工具连接池也是共享的。所有工具调用共用一个 HttpClient 连接池,所有 Runner 线程共用一个线程池。这种设计在依赖全部健康时挺高效,但只要有一个工具变慢,它占住的连接和线程就会挤占其他工具的份额,造成"一根筷子坏了一桌饭"。
- 重试机制没有感知剩余预算。Agent 的反思机制会在工具调用失败时触发重试,重试次数有上限(3 次),但重试发生时不会去检查用户请求还剩多少时间。结果就是,一次本来可以在 3 秒内失败返回的任务,因为重试被拖到了 20 秒以上。
这三个隐患,大部分 Agent 平台早期都会有。如果你正在做类似的系统,我建议先对照检查一下,别等到出故障再回头改。
2. 故障现场:监控告警与用户感知
2.1 故障现象描述
那天下午 14:20 左右,监控开始报警。我先看到的不是平台自己的监控,而是网关层报了一堆 504 Gateway Timeout。紧接着用户群里陆续有人反馈:"提交任务后一直没有结果""像是在转圈,转了很久最后报错了"。
我调出监控面板,几个关键数字触目惊心:
| 指标 | 正常值 | 故障时 |
|---|---|---|
| 网关层 P99 耗时 | 300ms | 12s |
| 网关层 5xx 错误率 | 0.1% | 4.8% |
| 执行层活跃线程数 | 15~30 | 200+(接近上限) |
| 工具调用平均 RT | 50~200ms | 6~8s |
| 模型服务调用 RT | 1~2s | 1.5s(正常) |
用户集中的吐槽场景都指向同一个能力:一个会调用内部数据查询接口的分析类 Agent。这个 Agent 的逻辑是,用户输入诉求后,它会自动判断需要查询哪些指标,然后调用一个数据查询工具去拉数,最后再让模型生成结论。
2.2 第一轮排查:网关、编排层、模型服务
最开始我按顺序做排除法:
第一步,看网关日志。大量超时日志都标注了downstream timeout,说明请求不是被网关自身限流或拦截的,而是下游没在规定时间内返回。
第二步,看编排层。CPU 40% 左右,内存正常,GC 也没有明显停顿。看起来编排层自身负载并不高,问题应该不在任务拆分逻辑上。
第三步,看模型服务。这是我最担心的一环,因为如果 LLM 推理服务抖动,整个平台都会变慢。但当时模型服务的 RT 和错误率都在正常范围内,没有出现大规模波动。
所以问题几乎可以确定不在模型层,而在工具链的某个环节。然后我在工具监控里看到了那个数据查询接口:它的 P95 耗时从平时的 200ms 猛增到了 8 秒左右,成功率倒是没怎么下降,但请求队列肉眼可见地越来越长。
2.3 最误导人的第一印象
当时我的第一反应,也是很多人的第一反应:"肯定是数据库慢查询,赶紧让 DBA 去看。" 运维同学查了一下,确实在慢查询日志里找到了一条耗时很长的聚合查询。这条查询平时几十毫秒完成,当天因为一个用户导入了一批大表数据,统计信息过期,执行计划选错索引,导致跑了几秒。
我们一度以为定位到了根因,让 DBA 把查询优化了一下,索引加上,慢 SQL 消失。但诡异的是,工具接口的 RT 并没有恢复正常,错误率甚至还在缓慢爬升。
这时候我才意识到,我们把"慢"和"堵"混为一谈了。
慢,是下游服务本身处理能力下降;堵,是上游资源被占用、请求根本发不出去。那条慢 SQL 只是点燃了引线,真正让故障持续扩大的,是连接池和线程池被占满之后的排队效应。当所有连接都被慢请求占据时,其他工具的正常请求也得排队等连接,整个平台的 RT 自然全线飙升。
3. 顺着调用链挖根因:重试放大与线程阻塞
3.1 链路追踪还原事实
我们平台在研发阶段就接入了全链路追踪,每个请求都有 traceId。故障复盘时,我在链路追踪系统里拉了一条最典型的失败链路,终于看清了完整画面。
链路显示,用户的一个简单请求"查一下上个月某指标趋势",进入编排层后被拆成了 4 个子任务。第一个子任务调用数据查询工具,正常情况下应该 200ms 返回。但那段时间工具接口 RT 已经飙到 8s,第一次调用直接吃到了单步超时上限——我们当时给工具层设置的超时是 5s。
第一次调用失败后,Agent 的反思机制触发了重试。重试不是立刻开始,而是经过了一轮"模型重新规划",这一轮模型推理花了 1.5s,然后重新发起工具调用,又等了 5s,失败。然后 Agent 再次规划,再次重试。整个过程叠加下来:
- 第一次工具调用:5s(超时)
- 第一次模型反思:1.5s
- 第二次工具调用:5s(超时)
- 第二次模型反思:1.5s
- 第三次工具调用:5s(超时)
- 最终返回失败:总耗时约 18s
而网关层超时设置是 15s,所以用户在 15s 时就已经看到了 504。但平台内部的 Runner 线程还在傻傻地跑着几轮无效的重试,继续占着线程和连接资源不肯释放。这就是我们线上故障持续时间那么长的直接原因——不是每个请求都这样,但只要流量稍微上来,资源就会被这些"已经注定要失败但还在重试"的请求占满。
3.2 线程 Dump 与连接池真相
链路追踪把行为讲清楚了,但还没有回答一个核心问题:为什么那个工具接口变慢之后,整个平台全部变慢?别的工具也没惹谁。
我抓了几次线程 Dump。线程 Dump 显示,执行层有 200 多个线程处在 WAITING 状态,堆栈全部指向同一个位置:httpclient连接池的leaseConnection。也就是说,大量 Runner 线程并不是在调用工具,而是卡在"等待获取连接"这一步,连接还没拿到,更别提发请求了。
再去查 HttpClient 连接池配置,真相大白:整个执行层共用一个 HttpClient 实例,连接池maxTotal是 200,maxPerRoute是 200。那会儿数据查询工具因为慢 SQL 导致 RT 变长,连接被一个个占住不放。由于它是平台里使用频率最高的工具,很快 200 个连接几乎全部被它占据。其他所有工具——包括模型调用、记忆存储、报表渲染——全都排起了长队。
线程池也被波及:执行层的线程池 max 是 256,当线程都在等待连接时,新的任务进来没有空闲线程,开始在队列里排队。用户侧的感受就是"提交任务一直接不了"。
总结一下我当时看到的因果链,大概是这样的:
| 阶段 | 表现 | 原因 |
|---|---|---|
| 1 | 某个工具接口 RT 从 200ms 涨到 8s | 下游数据库慢查询 |
| 2 | 工具调用开始超时 | 单步超时 5s,但整体无预算控制 |
| 3 | Agent 反思触发重试,放大调用次数 | 重试不知晓剩余时间 |
| 4 | 连接池被慢请求占满 | 所有工具共用一个连接池 |
| 5 | 线程池被排队线程占满 | 所有 Runner 线程共用一个线程池 |
| 6 | 非相关工具也全部变慢 | 资源竞争导致全链路拥堵 |
3.3 根因定性
故障结束后,我们把问题归成两类:触发条件和放大条件。
触发条件比较明确:下游数据服务因为执行计划误判出现了秒级慢查询。这个属于外部依赖偶发抖动,单靠优化 SQL 能解决眼前,但拦不住下一次。
放大条件才是真正需要改造的,也是 Agent Platform 这种多跳架构所特有的:
- 整体没有端到端超时预算。各层超时是"串联叠加"的关系,而不是整体约束。
- Agent 重试和反思机制没有时间感知。平台赋予了 Agent"自动反思、尝试补救"的能力,却没有告诉它"你已经没有时间了,立刻失败返回"。
- 线程和连接资源没做隔离。一个慢工具能占满全部共享资源,影响所有其他工具。
- 没有熔断降级。当一个工具已经明显不健康时,平台没有主动切断流量,还在不断把新请求打进这个故障点。
一句话总结就是:外部依赖不健康是导火索,平台自身的四个缺陷把一个小抖动放大成了全站事故。
3.4 为什么之前的压测没暴露
事后有人问我:"你们不是做了压测吗?为什么没测出来?" 这个问题值得好好想一下。我们当时确实做了压测,而且压测报告还挺漂亮。但复盘后发现压测场景和真实场景有几个关键差异:
- 压测时所有依赖都是健康的。我们没有注入过"某个下游服务偶发变慢 10 倍"的故障场景。
- 压测时 Agent 的调用模式太单一。我们主要模拟了"单次工具调用"和"模型直连"的场景,但真实用户请求会触发多轮 Agent 循环、反思、重试。这种"调用放大"在压测里根本没有被建模。
- 压测时没有关注长尾连接占用。我们看的是平均 RT 和 QPS,没有专门去观察连接池在高延迟场景下的占用情况和线程排队情况。
后来我们补了一课:Agent 平台这类多跳系统,压测一定要加入故障注入,主动打断某个依赖,看系统能不能扛住。否则压测只能证明"健康情况下能跑多快",证明不了"依赖不健康时会不会雪崩"。
4. 修复方案:超时预算、隔离与熔断三板斧
4.1 建立端到端超时预算
第一件事,是所有修改里优先级最高的:给每个进入平台的请求建立端到端 deadline,并且让 deadline 贯穿整条调用链。
我们的做法是,在网关层接受请求时,根据用户可接受的响应时间(比如 8 秒)生成一个AgentContext,里面保存着 deadline 时间戳。这个 context 会随着任务分解、Agent 循环、工具调用一直往下传。每次真正发起外部调用前,都要先用"剩余时间"计算这次调用最多还能等多久。
伪代码大概长这样:
// 进入网关时创建上下文 AgentContext ctx = new AgentContext(); ctx.setDeadlineNanos(System.nanoTime() + TimeUnit.SECONDS.toNanos(8)); // 在任何外部调用点,先算剩余预算 long remainingNanos = ctx.remainingNanos(); if (remainingNanos <= 0) { throw new DeadlineExceededException("全局预算已耗尽,拒绝本次调用"); } // 实际超时 = min(单步超时, 剩余预算) long callTimeoutNanos = Math.min(tool.defaultTimeoutNanos, remainingNanos); // 用这个值去设置 HTTP 客户端的 read timeout / connect timeout tool.invoke(request, callTimeoutNanos);关键点在于,这里的超时是动态的,不是写死的。如果前面已经花了 6 秒,后面任何一步调用都不可能再等 5 秒,最多只能等剩余的那 2 秒。这样无论调用链多长,用户侧的总耗时永远不会超过 8 秒。
模型调用同样要走这个逻辑。LLM 的推理时间不可控,但预算前必须先问一句"剩余预算还够不够让模型做一次推理",不够就直接失败返回,绝不进入下一轮。
4.2 线程池与连接池隔离
第二件事,是对资源做隔离,避免再次发生"一个慢工具堵死所有工具"的情况。
我们按工具的重要性和调用特征把它们分成了几组,每一组分配独立的线程池和独立的 HttpClient 连接池:
| 工具组 | 典型场景 | 线程池核心/最大 | 队列长度 | 连接池 maxPerRoute |
|---|---|---|---|---|
| 核心链路组 | 模型调用、会话读取、主任务编排 | 64 / 128 | 256 | 64 |
| 数据查询组 | 报表查询、指标拉取、DB 访问 | 32 / 64 | 128 | 32 |
| 辅助功能组 | 邮件通知、日志归档、低优先级操作 | 8 / 16 | 64 | 16 |
| 高风险外部组 | 调用第三方服务、不可控供应商 API | 8 / 8 | 32 | 8 |
这么一拆之后,就算数据查询组被打满,核心链路组仍然有独立的线程和连接可以使用,模型调用和任务编排不会跟着遭殃。用户收到的最坏结果,也只会是"分析类功能变慢",而不是"整个平台卡死"。
这里有一个实际调整中的细节:线程池拆开之后,要注意队列积压时的丢弃策略。我们给高风险外部组的队列设置了有界队列,满了之后直接拒绝,配合下面的熔断逻辑,让失败的请求快速失败,而不是无限排队。
4.3 重试策略修正
第三件事,是修正 Agent 的重试机制。这是 Agent Platform 和普通 Web 服务最不一样的地方。
普通 Web 服务可以在超时后安全重试,但 Agent 平台的重试必须回答三个问题:
- 这个工具是幂等的吗?非幂等操作(比如"创建订单""扣减库存")一律禁止自动重试,只能让用户手动重新提交。幂等操作(如"查询数据""获取状态")可以重试。
- 剩余预算够不够?如果剩余预算小于重试一次的成本(通常是单步超时 + 模型反思时间),直接放弃重试。我们内部定了一个规则:剩余预算小于 1.5 倍单步超时时,禁止重试。
- 重试之间要不要退避?一定要。我们加了指数退避加上随机抖动,第一次重试等待 200ms + 随机 0~100ms,第二次 400ms + 随机 0~200ms。不能因为"刚好超时"导致所有请求在同一时刻疯狂重试,形成重试风暴。
同时,对 Agent 循环本身也加了预算约束。每个 Agent 循环开始前会问一次"剩余预算还有多少",低于阈值时直接跳过反思和重新规划,走快速失败分支。不管你这次推理能得出什么结论,用户已经等不起了,先把错误交出去比什么都强。
4.4 熔断与降级
第四件事,是给工具层加熔断降级。这个在普通微服务里是标配,但在 Agent 平台里配置起来需要一点针对性。
我们给每个工具组配了一个熔断器,采用滑动窗口统计。规则大致是:最近 10 秒内,错误率超过 20% 或者平均 RT 超过 2 秒,熔断器打开,后续请求直接走降级路径,不再真实发起调用。熔断打开后持续 30 秒,然后进入半开状态,允许少量请求透传探活。
熔断打开时,Agent 的降级路径有几种选择:
- 如果有缓存数据,直接用缓存结果,并在最后汇报里标明"数据可能不是最新"。
- 如果不需要精确结果,返回一个默认的兜底值或部分结果。
- 如果任务强依赖这个工具,就直接终止该子任务,并把失败原因写入 trace,让用户知道是哪一步出了问题。
这里要给做 Agent 平台的朋友提个醒:熔断器和 Agent 的反思机制是有冲突的。如果 Agent 在调用一个已经熔断的工具时还在反复"思考要不要换个参数再试一次",熔断就失去了意义。所以熔断状态必须暴露给编排层,编排层看到工具处于熔断状态时,要跳过这个子任务或直接走降级,而不是把它当成一个普通失败丢给 Agent 去反思。
4.5 上线验证与灰度
这些改动涉及执行层的核心路径,不能一次性全量上。我们的上线步骤大概是:
第一轮:先把超时预算逻辑在灰度环境跑通,用录制回放的方式验证"所有外部调用的超时值都变成 min(单步, 剩余预算)",确认没有出现预算耗尽后连接不释放的 bug。
第二轮:开放到内部用户流量,观察 P99 和线程池活跃数是否回落。这阶段我们发现一个之前没预料到的问题:有些工具本身需要较长的执行时间(比如导出大文件),在预算紧张时会被快速失败。所以后面又调整了预算分配策略,对"长耗时工具"单独允许更长预算,但全局仍然设有硬上限。
第三轮:做了一次故障演练,强制把下游数据服务限速到原来的 1/10,观察平台会不会再次雪崩。结果熔断在 10 秒内生效,数据查询组请求被快速拒绝,其他核心链路正常,整体 P99 维持在 900ms 以内,比故障前的 8 秒好了一个数量级。
回切之后,我们连续观察了一周。故障那天的场景没有再现,P99 稳定在 400~500ms 之间,错误率回到 0.2% 以下。
5. 复盘清单:Agent 平台超时治理还能怎么做
5.1 核心配置基准
这次踩坑之后,我梳理了一份我们平台现在的超时和资源治理配置清单,可以当作一份基础模板。如果你也在做类似系统,照着这个思路校准未必完全适用,但方向大概率是对的:
| 配置项 | 我们当前的值 | 设计思路 |
|---|---|---|
| 端到端用户超时预算 | 8s | 取产品可接受时间上限的 80%,留 20% 给网络传输和网关排队 |
| 模型单次调用超时 | 10s,实际受剩余预算约束 | 模型推理属于高价值调用,预算充足时可以多等 |
| 核心工具单次超时 | 5s | 大部分内部工具 200ms 内返回,5s 已经是很宽松的兜底 |
| 辅助工具单次超时 | 3s | 辅助功能可以接受失败,不必等太久 |
| 幂等工具重试次数 | 最多 1 次 | 超过 1 次几乎都是徒劳,只会放大压力 |
| 连接池分配 | 按工具组拆分 | 核心/数据/辅助/外部四个池,互不干扰 |
| 熔断阈值 | 10s 窗口错误率 > 20% 或平均 RT > 2s | 宁可误伤一个工具,也不让它拖垮平台 |
| 熔断恢复时间 | 30s 后半开 | 给下游留出恢复窗口 |
5.2 几点实操体会
再分享几个不一定写进架构设计文档、但实际排查中特别有用的点。
第一,日志里一定要带上 deadline 的剩余值。光记录"超时了"没用,我要知道是"单步超时触发"还是"全局预算触发"。现在我们的每条失败日志都会附带remainingMs字段,一眼就能看出是不是上游预算消耗太多导致的连锁反应。
第二,排查超时要分清楚"慢"和"堵"。慢是下游服务响应慢,堵是上游资源不够。这两个问题的现象都可能表现为你的服务 RT 变高,但处理方法完全不同。看线程 Dump 是最直接的鉴别方式:如果线程都卡在等待连接,说明堵;如果线程都在等下游的 IO 响应,说明慢。
第三,重试一定要加上限,并且重试前先检查剩余预算。我看到很多系统犯同一个错误:重试次数设置了 3 次,但第 2、3 次重试发生时,用户的请求其实早就应该超时了。重试不是越挫越勇,而是要在有限成本内解决问题,预算不足时快速失败才是对用户最大的尊重。
5.3 后续可以优化的方向
这次故障解决的从 8 秒回到 500ms,但超时治理这件事其实还没结束。我目前在尝试的几个方向,也顺便提一下:
一是预算感知调度。如果要拆的子任务很多,而剩余预算只够完成一半,编排层应该主动降低任务优先级,先做最重要的那部分,低价值的"补充分析""额外验证"直接跳过。这个规则可以做成平台的通用策略,而不是让每个 Agent 自己决定。
二是把熔断信息反馈给模型。现在的降级路径是预先写好的,但更灵活的做法是:当某个工具熔断时,把"该工具当前不可用,请尝试其他路径或直接作答"写进模型推理的提示词里,让模型自己决定是换工具还是放弃。这个在内部实验过,确实能让 Agent 表现得更智能,但要注意控制 token 消耗和推理延迟。
三是动态超时自适应。单步超时可不可以根据最近一段时间的工具 RT 分布动态调整?比如某个工具连续 10 分钟都维持在 50ms,超时降至 1s;一旦它开始抖动,超时自动放宽到 3s。这样既能保护用户体验,又能给工具留出喘息空间,目前还在灰度测试阶段。
最后说一句个人体会。这次故障让我把 Agent Platform 的超时问题,从"HTTP 层的问题"重新理解成"编排层的问题"。Agent 平台里一次用户请求背后可能藏着十几次内部调用,任何一层慢都会被循环和重试机制放大。做这类系统,单步超时再严谨也不够,必须把端到端预算、资源隔离、熔断降级当成平台级能力来做。后来我们每次做任务编排设计,第一条评审规则就是:trace 里能不能看到剩余预算,排不上队的时候能不能快速失败。记住,普通的服务可以被容忍慢,但 Agent 平台不能因为某个工具慢了就让所有人的任务都卡死。希望这篇复盘对你有用。