☰
Agent平台超时故障复盘:一个SocketTimeout引发的线程池雪崩
2026/10/9 7:04:58 网站建设 项目流程

开发 Agent Platform 这一年多里,我自认为对超时、重试、熔断这些基础概念已经形成了肌肉记忆,直到那天凌晨两点,监控面板上一条条刺眼的红色告警把我从椅子上拽起来。线上超时故障不是没遇到过,但这次不同:它不是某一个接口超时,而是整个 Agent 编排链路像多米诺骨牌一样连环倒下,平台入口的响应时间从 200ms 一路爬到 30s,健康检查也开始飘红。当时我的第一反应是“数据库又被慢查询拖垮了?”——但事后证明,这个直觉完全把我带偏了。如果你也在做类似 Agent Platform 这类面向智能体编排的平台,或者正在被疑难线上超时折磨,这篇复盘也许能帮你省下一个通宵。

1. 故障初现:凌晨两点,告警铃声和消失的 P95

1.1 告警触发:我的第一反应是数据库慢查询

故障是从一条业务线的人工反馈开始的。那段时间我们刚上线了一个面向内部员工的“文档问答 Agent”,用户每天通过对话框提交问题,平台底层会自动拆解任务、调大模型、查知识库、调用外部解析服务,最终把答案流式返回给前端。上线后的前两周一切正常,P95 稳定在 1.2s 左右,直到那个周四晚上。

凌晨 1:47,监控系统连续推送了三轮告警:

  • /agent/run接口超时率突破 35%,并且在持续上升
  • 核心 Agent 服务的线程池活跃线程数从正常的 120 跳到 800,接近最大值
  • P95 延迟曲线从 1.2s 直接飙到 30s,P99 直接“消失”在图表外(超过 60s 的记录被网关截断)

说实话,看到这个告警组合的第一眼,我的直觉是数据库那边出事了。因为过去半年里,我们另外两个老项目出过的线上超时几乎都和慢 SQL 有关——要么是索引没走上,要么是连接池被慢查询打满。于是我先打开数据库监控面板,结果却很意外:数据库 CPU 只有 20%,慢查询日志里一条都没增加,活动连接数甚至比平时还低。Redis 的延迟也在正常范围。

1.2 复现现场:到底是谁慢了

数据库没问题,那就直接看链路。我登录线上跳板机,把采样到的 Trace ID 拉出来,一条条点开看拓扑图。结果发现耗时几乎全部堆积在“Agent 编排器”这个节点上,而它下游的大模型调用(LLM)平均耗时只有 2-3s,也没有明显异常。真正的问题出现在“外部工具执行器”这个子模块上。

这里简单交代一下我们的架构:Agent Platform 接收到用户请求后,会进入一个 DAG 编排器,把任务分解成若干个节点,每个节点可能是一个 LLM 调用、一个知识库检索、或者一个外部工具调用。外部工具执行器是我们封装的一层适配器,用来对接第三方服务,比如文档解析服务、天气查询、企业内部 API 等。

从 Trace 里看到的现象是:外部工具执行器向某个第三方解析服务发起的 HTTP 调用,在故障期间平均响应时间从 50ms 变成了 15s 左右。而由于编排器在等待这个调用完成时“卡住”了,整条链路的剩余节点全部排队等待,最终表现为用户侧的会话一直转圈。我立刻登上两台异常节点做了线程 dump,看到高频栈集中在java.net.SocketInputStream.socketRead0,也就是线程阻塞在读取网络响应的阶段,没有任何 CPU 消耗,纯等 I/O。

到这里,问题的轮廓已经清晰了:不是数据库,不是 LLM,是外部工具调用的超时行为把线程拖死,进而导致整个平台超时。

2. 由表及里的排查路径:从网关到服务再到线程池

2.1 网关层的假象:超时前的“正常”响应

第一次排查时,网关日志给了我一个误导性的信号。在服务已经完全陷入等待时,网关层显示很多请求的状态码是 200,只是耗时很长。这意味着什么?意味着我们的网关在等待后端时没有超时中断,而是死等到底。后端线程池虽然已经耗尽,但请求并没有被立刻拒绝,只是因为排队而延迟。

于是问题变成了:为什么线程池会耗尽?为什么一个外部工具调用要等 15s 才返回?这必须逐层拆解。

先看 HTTP 客户端的配置。我们的外部工具执行器用的是 Apache HttpClient(后来换成了带连接池的 OkHttp),当时的配置是:

RequestConfig.custom() .setConnectTimeout(5000) // 建立连接 5s .setSocketTimeout(0) // 读取数据 0 表示无限等待 .build();

SocketTimeout设为 0 是开发初期的遗留习惯,当时理由是“外部服务偶尔慢,但最终总会返回,希望等待成功结果”。这个看似无害的设定,在正常情况下没有问题,但一旦下游服务出现长时间挂起,它就会让所有调用者无限期等待线程。外部解析服务在故障期间抛出的不是错误码,而是 TCP 连接保持、数据迟迟不来,我们的客户端就一直处于socketRead0状态。

2.2 服务链路追踪:发现慢调用集中在 Agent 编排环节

链路追踪系统帮我们锁定了问题边界。通过 Trace ID 对比同一时间段内正常请求和慢请求的拓扑,可以发现差异非常明显:

  • 正常请求:编排器耗时 400ms,外部工具 50ms,LLM 调用 2s,总时长约 2.6s
  • 慢请求:编排器耗时 18s,外部工具 15s,LLM 调用 2s,总时长约 21s

编排器的 18s 里有 15s 是在等外部工具。另外的 3s 是在等待一个被线程池排队阻塞的“子节点任务”。也就是说,外部工具慢并不是唯一问题,线程池队列堆积后,原本不慢的 LLM 调用因为拿不到执行线程,也被迫排队,整体形成了雪崩。

线程池的配置当时是coreSize=200、maxSize=400、队列容量BlockingQueue无界。无界队列是一个非常经典的坑:它不会拒绝任务,但会无限排队,导致排队的请求越积越多,超时成为一个必然结果。因为我们用无界队列时,任务只进不出,只要有大量慢任务占住了线程,新任务就只能等在队列里,从而表现为“所有请求都超时”。

为了让读者对这条链路有更直观的了解,我整理了下表:

层级故障表现根因线索
入口网关大量 504/502,部分 200 但耗时极长网关死等,没有主动超时切断
编排服务线程池活跃数飙升,P95 拉长同步阻塞等待外部工具调用
外部工具执行器HTTP 调用 15s 以上无返回SocketTimeout=0,无限等待读响应
LLM 调用平均耗时 2s,但出现排队等待线程池被慢调用耗尽,新任务排队

2.3 线程 dump 与诱因确认:一个“看不见”的等待层

如果你遇到类似问题,我强烈建议在故障期间做一次多实例的线程 dump,而不是等到事后。线程 dump 是最直接能证明“线程在哪等”的证据。

我们抓了三个节点各两份 dump,间隔 5 秒,主要看两类栈:

  • java.lang.Thread.State: RUNNABLE但栈顶是socketRead0,说明线程在进行网络读,不是在计算
  • java.util.concurrent.ThreadPoolExecutor相关的排队状态,说明有大量任务在 worker 队列里

其中超过 85% 的线程都阻塞在socketRead0上,而且大多是同一个第三方服务地址。这个第三方服务在故障前 20 分钟刚刚发布了新版本,内部开始处理一个长时间的批处理任务,处理期间持有连接不释放。服务端的“假死”配合客户端的“无限超时”,直接把我们拖进了深渊。

等到这里,我才彻底放弃“数据库慢查询”的猜测。整个故障的本质就是:一个外部依赖异常的慢,加上客户端不设超时,加上线程池无界队列,最终把 Agent 编排平台全面击穿。

3. 根因分析:Agent Platform 任务编排中的一处同步阻塞

3.1 为什么智能体对话会拖垮整个平台的吞吐

为什么这个问题在普通的 API 网关项目里没那么严重,在 Agent Platform 里却会造成整个平台不可用?因为 Agent 编排的调用模式很特殊。

一个普通的业务接口通常是线性请求:接收参数 -> 查 DB -> 调外部 API -> 返回,路径清晰,耗时有限。但 Agent Platform 不一样,一次用户输入可能触发 5-10 个子任务的编排,这些子任务可能是并行的、串行的、或者是条件判断后动态生成的。编排器普遍采用“同步编排”的方式:主线程派发子任务,然后CompletableFuture.allOf(...).join()等待所有子任务完成,拿到所有结果后再继续。

这种模式在子任务都正常时效率很高,因为并行能压缩整体耗时。但问题在于,allOf().join()是一个不设超时上限的阻塞等待。如果其中某一个子任务因为外部依赖慢而卡住,主线程就会一直等。在最开始的故障里,我们外部工具执行器没有设 read 超时,同时编排器也没有给“整组任务”设最大执行时间,于是单个用户请求最坏情况下可以占用线程 15s、30s 甚至更久。

3.2 代码里的罪魁祸首:一个看似无害的同步等待

我们的编排核心当时长这样(简化版):

List<CompletableFuture<AgentResult>> futures = tasks.stream() .map(task -> CompletableFuture.supplyAsync(() -> executeTask(task), agentExecutor)) .collect(Collectors.toList()); // 等待所有任务完成,不设超时 CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();

这段代码看起来很正常:把任务丢线程池,然后并行执行,最后汇总结果。问题恰恰藏在最后那一个join()上。

join()在子任务全部完成前会一直阻塞当前线程。如果某个子任务内部去调外部工具,而外部工具又因为 SocketTimeout=0 无限等待,那么当前线程就陷入了不可控的等待。更可怕的是,这种等待不占用 CPU,系统 CPU 看起来还很健康,所以连自动告警都很难通过负载指标触发,只有线程池活跃数这种稍冷门的指标能捕捉到异常。

3.3 请求超时设置与超时重试的叠加效应

除了同步阻塞,我们的重试机制也在伤口上撒了一把盐。当时使用的是 Spring Retry 的默认配置,对 5xx 错误会重试 3 次,重试间隔采用指数退避(1s、2s、4s)。如果外部工具一直 5xx,每个请求最多要花掉 1+2+4+最终请求的时间,约 7s 以上。而在故障期间,外部工具并不是立刻返回 5xx,而是长时间无响应,等于每个重试的请求也继承了无限 read 超时的基因,导致一次请求占线程的时间被放大到 45s 甚至更久。

这个细节是个教训:设置重试之前一定要先设置超时。没有超时上限的重试,本质上是把故障请求复制成多份,每个副本都在继续占用线程资源。故障发生时,重试不会帮你恢复,只会让你更快地耗尽线程池。

3.4 无界线程池队列:雪崩的最后一块拼图

我们的 Agent 编排线程池配置如下:

ThreadPoolExecutor agentExecutor = new ThreadPoolExecutor( 200, 400, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>() // 无界队列 );

无界队列意味着当线程数达到 maxPoolSize 时,新任务不会触发拒绝策略,而是乖乖排到队列里等待。正常情况下这是一个好策略,因为可以平滑突发流量。但一旦线程池里的线程被慢任务占满,队列就会无限增长,响应时间随之线性恶化。

故障期间,队列里堆积了超过 3000 个任务。这些排队任务中的大部分原本只需要 2-3s 就能完成(比如 LLM 调用),但因为没有线程可用,只能干等 30s 甚至更久。等到服务重启时,队列中的任务全部丢失,用户侧表现为对话中断。

所以这个故障的完整因果链是:

  1. 第三方解析服务发版后处理变慢,TCP 连接不返回数据
  2. HTTP 客户端没有任何读超时设置,线程无限等待
  3. 编排器allOf().join()不设整体超时,主线程被拖住
  4. 线程池无界队列快速堆积,新请求全部排队
  5. 重试机制放大等待时长,线程池更快耗尽
  6. 最终所有 agent 接口超时雪崩

4. 修复方案与压测验证:从应急止血到长效治理

4.1 止血:快速调整超时阈值和熔断开关

故障发生后,我们的第一优先级不是优雅重构,而是先恢复线上。当时的应急操作按以下顺序执行:

  1. 把外部工具执行器所有 HTTP 客户端的SocketTimeout从 0 改成 3000ms
  2. 在编排器主线程的join()处加一个 8s 的强制总超时(先硬编码,后续参数化)
  3. 关闭 Spring Retry 对 Agent 编排链路的自动重试(或者把重试次数降到 1)
  4. 重启核心服务,清空线程池和队列

这套操作执行完,线上超时率在 15 分钟内从 35% 降到 2% 以内,P95 回到 3s 以下。虽然还不够理想,但已经可用了。

这里有个小技巧想分享:在紧急止血时,不要一上来就重构线程池或者改框架,因为那是大规模变更,会引入新的风险。正确的顺序是先切断故障源(调短超时/熔断),再清理积压(重启),最后再做根治性重构。

4.2 根治:异步化解耦与线程池隔离

止血完成后,我们用了两周时间做了一次系统性重构。核心改动有三块。

第一块,统一 HTTP 客户端超时规范。所有外部调用必须显式设置连接超时和读取超时,默认值设为 500ms / 3000ms,特殊接口可以调大,但必须经过审批。为此我们封装了一个统一的客户端工厂,不允许再在业务代码里裸 new HTTP client。

HttpClient httpClient = HttpClientBuilder.create() .setConnectionTimeToLive(30, TimeUnit.SECONDS) .setDefaultRequestConfig(RequestConfig.custom() .setConnectTimeout(500) .setSocketTimeout(3000) .setConnectionRequestTimeout(1000) .build()) .build();

第二块,编排器从“同步等待所有子任务”改为“可超时的异步聚合”。用CompletableFuture的orTimeout或者get(timeout, unit)来替代盲目的join()。如果超时未完成,就对未能完成的子任务执行降级策略——返回一个默认值或者标记为失败,而不是让整个请求无限等下去。

CompletableFuture<Void> all = CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])); try { all.get(8, TimeUnit.SECONDS); } catch (TimeoutException e) { // 降级:未完成的任务返回默认响应,并记录指标 for (CompletableFuture<AgentResult> future : futures) { if (!future.isDone()) { // 给未来任务挂一个回调,结果返回后丢弃 future.whenComplete((v, ex) -> log.warn("dropped late result: {}", v)); } } }

同时,调整了线程池配置,把无界队列换成有界队列,最大容量 500,并且定义拒绝策略:

new ThreadPoolExecutor.CallerRunsPolicy()

CallerRunsPolicy在队列满时会让提交任务的线程自己执行任务,这在某些场景下可以起到天然背压效果。但在 Agent 编排中,我们最终还是选了AbortPolicy加自定义兜底,因为CallerRunsPolicy会让 Web 请求线程被编排任务占住,也要小心。

第三块,线程池隔离。把 Agent 编排线程池和普通 API 处理线程池分开,同时给外部工具调用单独分配了一个“外部 IO 线程池”,最大线程数设得比较小。这样即使外部工具全部卡死,受影响的范围被限制在这个 IO 池内,不会耗尽 Web 线程,平台其他接口依然可用。

4.3 压测验证:恢复后重新评估容量

修复完后,我们做了两轮压测,目的是验证两个问题:一是超时设置有没有副作用,二是整体容量是否恢复。

第一轮压测是“正常情况下的容量测试”:模拟平时 5 倍流量,每个外部调用 Mock 成 50ms 返回,结果 P95 稳定在 1.1s,线程池活跃数在 200-250 之间,无排队。这说明超时设置并没有降低正常吞吐。

第二轮压测是“故障模拟”:把外部工具 Mock 成 100% 延迟 30s 返回,观察平台表现。修复后结果如下:

场景线程池活跃数超时率P95 响应时间受影响范围
修复前(无超时)800+,队列无限增长35%+30s+全平台不可用
修复后(超时 3s)稳定在 280-3200.6%2.8s仅调用外部工具的会话受影响

可以看到,线程池活跃数被严格限制住了,超时率降到 0.6%,P95 稳定在 2.8s。剩下那 0.6% 的超时主要是 LLM 模型在极端情况下的排队,属于可接受范围。更关键的是,故障期间普通 API 接口完全不受到影响,这正是线程池隔离带来的收益。

5. 写在故障之后:Agent 平台超时治理的四条铁律

5.1 超时参数不是拍脑袋定出来的

经过这次故障,我们整理了一套超时参数标准,而不是再像以前一样“从网上抄一段配置就完事”。标准如下:

  • 外部工具调用:连接超时 500ms,读取超时 3s,整体调用超时 3.5s
  • 单个子任务最大执行时间:5s(包含内部多次重试的总时长)
  • 编排器整组任务超时:8s(也就是allOf.get()的上限)
  • 入口网关超时:接口级统一 10s,比服务端整体超时略大即可

关键原则是:服务端内部各层超时要逐步收紧,呈金字塔结构。底层超时应该比上层短,这样上层才能在底层失败时快速接管做出降级决策。反过来如果底层超时比上层还长,那上层超时就是一个摆设。

比如我们的链路是:入口 HTTP 接口 10s > 编排器总超时 8s > 单个子任务 5s > 外部调用 3s > 连接超时 0.5s。这层约束关系保证了无论下游怎么抖动,都不会无限蔓延。

5.2 监控必须能度量“端到端体验”而不是指标健康

这次故障里,数据库 CPU、Redis 延迟、JVM 内存这些“经典健康指标”全部正常,但我们平台已经快挂了。真正暴露问题的是:

  • 超时率:接口/外部依赖两个维度的超时比例
  • P95/P99 延迟:比平均值敏感得多,平均值会被“大多数正常请求”稀释
  • 线程池状态:活跃线程数、队列积压数、拒绝次数
  • “等待中的外部调用数”:这个指标当时我们没接,事后才补上。它能直接反映外部依赖卡死了多少请求

给所有做 Agent 平台的朋友一个建议:监控面板上至少要放一张“依赖调用等待时间分布图”,如果一个外部服务在 10s 内大量请求都在等待,那你就该提前告警了,不用等服务真的无响应。

5.3 依赖调用的降级预案要提前演练

Agent Platform 比普通 Web 服务更依赖多种外部能力:LLM、知识库、工具执行器。任意一个依赖故障都可能导致平台不可用。我们这次只是超时,如果第三方服务直接 5xx,我们当时也没有一个成熟的降级方案。

事后我们为每种外部依赖都设计了降级策略:

  • LLM 超时:返回“模型暂时繁忙,请稍后再试”,而不是让用户看到空白对话框
  • 知识库检索超时:跳过检索,直接走模型自身知识回答
  • 外部工具超时:如果工具不是关键路径,直接返回“该功能暂不可用”;如果是关键路径,尝试用一个简单模型直接回答

这些降级策略也需要在线下演练,最好是配合故障注入工具定期做一次“混沌测试”,否则大概率是纸面预案,用到时才手忙脚乱。

5.4 经验沉淀:故障复盘文档应该怎么写

这次故障的复盘文档我写了将近 5000 字,但核心只有三页:时间线、根因链、改进清单。写复盘文档最重要的不是把细节记录下来,而是要形成可执行的改进项。我们最终把改进项转换成了 6 个 Jira 任务,每个任务都带验收标准:

  1. 统一 HTTP 客户端超时配置并全局替换遗留代码
  2. 编排器接入总超时控制,并为超时子任务提供降级回调
  3. 线程池队列改为有界队列,并导出告警指标
  4. 增加“外部依赖等待时间”监控指标
  5. 为 LLM 和外部工具调用增加熔断器
  6. 建立每周一次的故障注入演练计划

其中 1-4 在故障后三周内完成,5 在第六周完成,6 现在一直在坚持做。

最后再分享一个小技巧

排查这类外部依赖导致的超时问题时,有两个命令非常实用,一个是jstack抓线程栈,另一个是netstat看当前进程的网络连接状态。故障期间我在每个节点上跑了:

jstack <pid> > thread_dump_$(date +%s).txt netstat -antp | grep <pid> | grep ESTABLISHED | wc -l

netstat统计出的 ESTABLISHED 连接数如果远远高于正常基线,并且和线程 dump 里socketRead0的线程数基本吻合,那基本可以实锤是“外部调用读超时问题”。这套组合拳比单纯看监控图表要快得多。

这次故障让我们整个团队都对超时、降级、线程池隔离有了更深刻的理解。Agent Platform 这类系统,核心业务的本质是“在一个不确定的世界里按时交付结果”,不确定来自模型延迟、工具抖动、第三方 API 不稳定,而我们能做的,就是在每一层都设好护栏,让故障的影响范围始终可控。希望这篇复盘能让你在下次线上告警响起时,少一点手忙脚乱,多一份从容。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询