资讯动态

Agent平台线上故障复盘:线程池打满背后的超时与隔离陷阱

发布时间:2026/10/27 23:43:46 来源:尧图企业网站定制
周五下午生产告警群突然炸了。我开发的 Agent Platform 上线不到一个月API 网关的 P99 延迟从平时的 2 秒左右直接冲到 15 秒以上超时错误率一度超过 20%后台任务大量卡在“执行中”状态。我第一反应是数据库被慢查询拖垮了可打开监控一看CPU、内存、磁盘全都平得不像话。那一瞬间我真的怀疑是监控误报。当然那不是误报。这趟真实的线上超时故障排查最终把我带到一个从架构设计第一天就埋下的坑跟前也让我把“外部依赖调用要有超时、要隔离、要异步”这三件事从听过变成真信。这篇就完整复盘一下Agent Platform 到底做了什么、故障现场什么样、我是怎么从“线程池打满”一路追到根因的以及最后用了哪几步把系统救回来。如果你在做的也是智能体平台、工作流引擎、异步任务系统或者任何需要编排多个外部依赖的后端服务这篇的排查思路和修复清单应该能直接抄作业。1. 这个Agent Platform到底做了什么长什么样1.1 平台定位把“编排智能体”做成平台能力Agent Platform 说白了就是让业务方用一句自然语言描述目标平台帮你去拆解任务、调用大模型、执行工具、检索知识库最后拼装成完整回答。你可以把它理解成一个面向智能体场景的“任务调度中间层”业务方不需要关心大模型怎么调、工具怎么接只需要在平台上注册一个 Agent描述清楚“这个 Agent 能干什么”剩下的事全交给平台。举个例子业务方想做一个“周报生成助手”它需要先去内部系统拉取本周的项目数据再把数据交给大模型润色成周报最后推送到某个协作群。在 Agent Platform 上这就是一个由“数据查询工具 大模型调用 推送动作”组成的编排任务。没有平台的时候业务方得自己把这三段逻辑写在一起还要处理鉴权、重试、超时、限流这些跟业务无关的破事。有了平台这些都用声明式配置完成。1.2 整体架构与关键链路平台整体分四层。接入层负责接收请求、鉴权、限流编排引擎负责把目标拆成 DAG按节点执行工具中心负责管理所有已注册工具的定义和调用模型网关统一封装各家大模型服务屏蔽上游差异。技术栈选型上我们用的是 Spring Boot 3.2 Java 17 Redis RabbitMQ PostgreSQL。选 Java 不是因为它最炫而是因为团队技术栈统一、三方生态成熟、部署运维体系都是现成的。Agent Platform 这种系统本质上是一个重 IO 的编排系统Java 的线程模型和连接池生态在这种场景下非常成熟排查问题时有大量工具可用。核心链路是这样的外部请求进来编排引擎先做意图解析和任务拆解生成一张 DAG然后按拓扑顺序执行节点。每个节点要么调用大模型要么调用某个注册过的工具工具又可能去请求内部服务或第三方 API。最终结果经过组装后返回给调用方。这条链路里有一个很容易被忽略的特性它天然依赖多个慢服务一个普通 Agent 可能需要先后调用 3 到 4 个工具每个工具平均耗时 2 秒加上一次大模型调用 4 秒单请求串行下来就是 10 秒以上。1.3 初版为什么选了同步编排现在回过头看初版选择同步编排全链路同步阻塞等待结果确实是整个事故的伏笔。但当时做这个决定也有当时的原因MVP 要快速上线同步 HTTP 调用是心智负担最低的方案。请求进来编排引擎一个 foreach 把节点跑完最后 return 结果代码简单、排查链路短、前端也只需要一次请求。但同步编排有一个致命问题每个请求会长时间占用一个工作线程。如果单个请求平均耗时 8 秒Tomcat 默认 200 个线程理论上只能扛住 25 QPS 的请求多了就得排队。对于普通 CRUD 接口这完全不是问题但对于 Agent 编排这种东西它天生就是慢请求同步编排等于把“线程池容量”变成整个系统的容量天花板。2. 故障现场服务没挂但请求全堵在门口2.1 报警是怎么来的故障发生在下午三点多。先是监控告警触发紧接着客服那边开始接到业务方反馈部分 Agent 任务点了执行之后一直转圈页面长时间没有结果。我拉出监控面板看到一组非常刺眼的数据网关 P99 从 2.1 秒飙升到 15 秒以上错误率从 0.1% 以下跳到 23%任务完成率跌到 45% 左右。当时的监控表格大概是这样的指标平时均值故障期间说明网关 P99 延迟2.1s15s劣化严重错误率0.1%23%超时为主Tomcat 活跃线程数20~50200打满线程池耗尽任务完成率99.9%~45%大量任务中断外部 LLM 调用 P994s8s慢依赖放大2.2 几个让我“迷惑”的监控现象最先让我困惑的是服务本身的健康状况指标全部正常。CPU 使用率在 30% 左右内存没有明显增长GC 频率和平常差不多数据库的连接池和慢查询也都正常Redis 缓存命中率没有异常波动。但另一头的表现却是调用方反馈响应极慢新增请求一直在转圈。这两种现象同时出现通常说明一个问题——请求不是被某个计算瓶颈卡住而是在等某个外部东西比如等待别的服务返回、等待队列里的线程让出资源或者等待拿一条数据库连接。用生活里的例子类比就像一个餐厅后厨炉灶全是好的、食材也充足但所有顾客都被堵在门口因为服务员全跑去等一个迟迟不归的外卖配送员没人接新的单。2.3 影响范围确认进一步确认影响范围后我发现不是所有租户都受影响只有那些 Agent 配置了多个工具调用、尤其是带外部系统查询的租户大面积超时。只配置了大模型问答的 Agent 受影响不大。这个现象很关键它基本排除了平台自身基础组件出问题的可能性把怀疑方向引到了“工具调用”这一段。3. 排查从线程池打满一路追到根因3.1 常规手段全排除了DB、缓存、GC排查的第一步当然是先排除底层设施。我查了数据库的连接池使用情况、慢 SQL、锁等待全部正常Redis 的读写延迟和命中率也正常GC 日志看下来没有频繁 Full GC没有明显的停顿。正如前面监控显示的那样基础组件没有毛病。这一步确认完之后我心里其实有点发毛。因为基础组件正常、CPU 和内存正常却出现大面积超时往往是应用自身逻辑出了问题而且这个坑往往藏得更深。比如某个阻塞调用、某段搞死线程池的代码、某些不合理的连接池参数。这类问题不像数据库慢查询那么直观必须顺着调用链一层一层挖。3.2 链路追踪和线程dump锁定工具调用层我们的系统接入了全链路追踪这一步帮了大忙。我随机抽了几条慢 trace发现耗时几乎都集中在“tool_call”这个环节上。trace 显示编排引擎在调用第三方统计接口时等待时间占了整条链路的 80% 以上。与此同时我打了两次线程 dump间隔 15 秒对比发现大量 http-nio 线程的堆栈停留在 HTTP 客户端的响应读取阶段。线程栈核心部分的典型长相是这样的http-nio-8080-exec-47 #67 waiting on condition at java.net.SocketInputStream.socketRead0 at java.net.SocketInputStream.socketRead at org.apache.http.impl.io.SessionInputBufferImpl.streamRead at org.apache.http.impl.io.ContentLengthInputStream.read at org.apache.http.conn.EofSensorInputStream.read at com.example.platform.gateway.http.HttpToolInvoker.invoke(HttpToolInvoker.java:87)这种堆栈形态非常经典线程不是被锁阻塞而是卡在 Socket 读取上说明它在等远端返回响应。换句话说线程本身没有死锁它是在等一个迟迟不来的 HTTP 响应。更关键的是这种线程数量非常多把 Tomcat 的线程池全部占满了。新请求进来拿不到线程只能排队等待分配这解释了为什么服务健康指标都正常、但整体响应却慢得离谱。3.3 根因三层问题叠加成一个“排队危机”根因不是单一问题而是三层问题叠加在了一起。第一层编排过程是同步串行的。一个 Agent 在同一个线程里依次调用多个工具总耗时等于所有外部调用耗时之和。任何一次外部依赖变慢都会直接线性地拖垮整个编排请求。第二层外部依赖的慢被无限放大了。当时某个第三方统计接口在业务高峰期发生了限流部分请求被拒绝或长时间挂起但我们的 HTTP 客户端没有配置 readTimeout也就是 socketTimeout0。这个配置的意思是只要连接建立了客户端会无限期等下去直到对端返回或者底层连接被断掉。外部服务限流时恰恰是“连接建立、响应不返回”的状态于是一堆线程就挂在那里傻等一挂就是几分钟。第三层连接池参数和下游限流阈值完全不匹配。我们全局只配了一个 HTTP 连接池maxTotal200、maxPerRoute100看起来很大。但被调用的第三方接口限流阈值只有 50 并发超过 50 的请求要么被拒绝要么被挂起。连接池里大量连接被慢响应占着不放其他请求就连连接都拿不到只能阻塞在“从连接池租借连接”这一步。线程池满 连接池也满整个编排引擎就等于被“双向堵死”了。用餐厅来类比就是顾客点的是需要多个厨师协作的复杂菜品但其中一个厨师去做一道慢菜还不告诉别人什么时候能好于是所有服务员都堵在这一个厨师旁边干等其他人连下单都没人理。餐厅本身没倒但容量已经瘫痪了。3.4 为什么之前压测没发现这里必须补一刀这套系统上线前做过压测但压测方案太干净了。我们模拟的第三方接口都是“稳定响应”根本没有做慢依赖模拟也就是没有模拟外部接口变慢、超时、限流的情况。真实线上环境里外部系统的抖动才是常态。一次压测如果不注入外部故障它只能证明“理想路况下车跑得快”完全证明不了“堵车时车还能不能开”。4. 修复方案超时、隔离、异步三管齐下4.1 第一天先把所有超时参数补齐故障当晚要止血第一个动作就是给所有外部调用补上超时参数。这一步是最快的“还债”也是后面所有优化的基础。之前没配超时就像借了一笔高利贷现在终于要连本带利还回去。我给 HTTP 客户端统一配置了三类超时每一类的语义都很清晰RequestConfig requestConfig RequestConfig.custom() .setConnectTimeout(3000) // 建立 TCP 连接的超时3 秒 .setConnectionRequestTimeout(3000) // 从连接池租借连接的超时3 秒 .setSocketTimeout(5000) // 等待对端响应的超时5 秒 .build();这三个参数里最容易被漏掉的就是 SocketTimeout而它恰恰是这次故障的元凶之一。另外一个注意点是连接池的连接租借也要设超时。当时 pool 里的连接被慢响应占满如果 connectionRequestTimeout 为 0拿不到连接的线程就会在池子外面无限排队这同样会拖死线程池。至于超时值怎么定不能拍脑袋。我的建议是按依赖的 P99 耗时来决定readTimeout 取依赖 P99 的 2 倍左右同时设一个硬上限。比如外部大模型服务的 P99 是 8 秒给它 20 秒的读超时是合理的因为大模型调用确实慢普通内部工具 P99 在 500 毫秒左右给 5 秒即可第三方接口不稳定统一按 3 到 5 秒兜底。超时不是为了惩罚慢的依赖而是为了给系统一个“不等了”的止损边界。4.2 编排引擎异步化从“一个线程等到底”改成“事件驱动”补齐超时是止血异步化才是治本。同步编排的模式下一个请求把一个线程从开头占到最后中间全是等待这本质上就是浪费线程资源。异步化改造的目标是把“人肉同步等待”变成“提交任务异步回调”。改造的第一步是把接口从“单次同步执行”改成“双阶段模式”submit 接口接收请求快速校验后把任务持久化并投递到消息队列立即返回 taskId执行状态通过轮询或回调机制获取。前端页面从转圈等结果变成“任务已提交正在执行可以稍后查看结果”。任务状态模型是最重要的设计点必须显式建模不只是内存中的一个变量。我们的状态机设计为PENDING - RUNNING - SUCCEEDED |- FAILED |- TIMED_OUT |- CANCELEDWorker 侧消费消息后按状态机推进并把结果写回存储。改造后核心代码大概长这样// 提交阶段立即返回任务 ID public SubmitResponse submit(AgentRequest request) { AgentTask task buildTask(request); agentTaskRepository.save(task); taskProducer.send(new ExecuteAgentCommand(task.getId())); return SubmitResponse.of(task.getId()); } // Worker 消费阶段执行任务并更新状态 public void onExecuteAgentCommand(ExecuteAgentCommand cmd) { String taskId cmd.getTaskId(); taskService.markRunning(taskId); try { AgentResult result taskExecutor.executeWithTimeout(taskId, 30, TimeUnit.SECONDS); taskService.markCompleted(taskId, result); notifyCallback(taskId, result); } catch (TimeoutException e) { taskService.markTimedOut(taskId); } catch (Exception e) { taskService.markFailed(taskId, e); } }这里有几个细节值得单独说。第一消息消费要做幂等。我用了 Redis 的 SETNX 做任务执行标记同一个 taskId 只允许一个 Worker 开始执行防止消息重复投递导致同一个 Agent 被跑两遍。智能体平台的执行是有外部副作用的重复执行可能意味着重复调用外部工具影响很大。第二同一个 DAG 里的并行节点要实现真正的并行而不是串行。我们当时用 CompletableFuture 对同层节点做了并行化总耗时从“所有节点之和”降到“最长节点耗时”。但要特别注意supplyAsync 不能直接用默认的 ForkJoinPool得用独立的线程池否则公共池会被编排任务拖垮。ListCompletableFutureToolResponse futures tools.stream() .map(tool - CompletableFuture.supplyAsync( () - toolClient.invokeWithTimeout(tool, task.getParams()), toolExecutor)) .collect(Collectors.toList()); CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) .get(30, TimeUnit.SECONDS);第三异步化之后要有一个总体的执行超时。我们给整条 Agent 编排链路设了 30 秒的硬上限超过就标记 TIMED_OUT然后走降级结果返回。这样即使某个子节点没有及时超时整体也不会被拖死。4.3 连接池和线程池的“舱壁隔离”异步化解决了“线程被长期占用”的问题但还没有解决“慢依赖影响整个系统”的问题。一个第三方接口变慢如果不做隔离它依然会拖慢所有依赖同一个连接池、同一个线程池的请求。这就是舱壁隔离要做的事。先看连接池参数怎么算。之前 maxTotal200 的问题在于它只是看起来很大但下游峰值并发只有 50多余的连接请求全堵在池外排队。连接池不是越大越好你最多同时打出去的请求数不会超过下游愿意接的并发。拿一个第三方接口限流 50 来说你的 maxPerRoute 就应该设在 50 左右多一点可以按 1.2 倍预留到 60再多没有意义。maxTotal 按所有下游限流之和再乘一个 1.5 的安全系数来定。连接池真正的价值不是容纳更多并发而是提供稳定的连接复用它不能解决并发问题但用它”无限放大并发“只会制造排队。线程池隔离也是同样的思路。我们把依赖分组大模型调用一组线程池、工具调用一组线程池、外部 HTTP 调用一组线程池彼此容量独立。某个线程池被打满只影响对应的那一类依赖不会把整个 Tomcat 的线程池一起拖下水。线程池容量怎么定学术点讲IO 密集型线程池可以按“核心线程数 QPS × 平均耗时”来估算。假设某个外部工具调用平均耗时 2 秒目标承载 30 QPS那至少需要 60 个核心线程。但我们不想为了一个慢工具养太多线程所以配合前面说的超时和熔断把线程池上限压到 8 到 16 个。池子小一点配合快速失败效果远好于池子大但无限等待。4.4 慢依赖熔断与快速失败隔离只是把故障限制在一个舱里熔断才是把故障快速暴露出来的机制。我们接入了 Resilience4j给每个关键外部依赖都配了独立的熔断和线程池隔离。配置大概长这样resilience4j.threadpoolbulkhead: instances: llm: maxThreadPoolSize: 8 coreThreadPoolSize: 4 queueCapacity: 4 tool-api: maxThreadPoolSize: 12 coreThreadPoolSize: 6 queueCapacity: 6 resilience4j.circuitbreaker: instances: llm: slidingWindowSize: 20 failureRateThreshold: 50 waitDurationInOpenState: 30s resilience4j.timelimiter: instances: llm: timeoutDuration: 20s这里我给每个依赖单独配置了信号量或线程池隔离当某个依赖的线程池满了请求不会继续排队等待而是直接快速失败。熔断器打开后后续请求不会再进入真正的调用直接走降级逻辑。对 Agent Platform 来说降级逻辑通常是一段兜底文案比如“该工具暂时不可用已跳过此步骤”同时把失败原因记录到任务上下文里用户可以直观看到哪一步出了问题。这一轮改完故障期间的“无限期等待”被彻底消灭了。最差的情况是任务失败并返回错误原因但不会出现整个平台被一个慢接口拖到瘫痪的状况。4.5 上线后的效果改造分了两步上线。第一步先上超时参数和熔断配置这一步当天晚上就完成效果非常明显错误率先降了下来因为大量挂死线程被释放线程池不再占满新请求可以正常得到处理。第二步异步化改造花了一周时间上线后整体表现才达到预期指标故障期间修复后网关 P99 延迟15s2.8s错误率23%0.5%Tomcat 活跃线程数200打满30~60任务完成率~45%99.5%P99 没有回到故障前的 2.1 秒而是稳定在 2.8 秒左右原因是异步化之后部分请求从实时返回变成了“任务状态查询”查询路径本身引入了额外开销。但作为代价系统容量的上限不再被线程数量卡死整体能承载的并发任务数反而高了很多。5. 经验清单这些坑你都值得避一避5.1 这次故障最大的三个教训第一个教训任何外部调用都必须有超时。这个道理说出来谁都懂但真正落到代码里很容易被忽略。尤其是 readTimeout很多项目只配了连接超时因为“连接都建立了应该很快返回吧”。可现实里最多的情况恰恰是连接建立了但响应不回来。没有 readTimeout一个慢接口就能让整条链路的线程堆死。我给团队立了一条军规任何出网调用三个超时必须配齐缺一个都不允许合代码。第二个教训线程池打满只是症状不是根因。排查线上问题要顺着“线程在等什么”去追而不是急着把线程池调大。这次故障如果只看到线程池满就加线程Tomcat 线程数加到 500也只是把问题掩盖了外部接口继续慢500 个线程迟早也给你占满。线程让出来之后接新请求的容量问题才真正解决。第三个教训同步编排省事但慢依赖场景必须异步。Agent 平台这类系统本质上就是“慢服务的编排者”天然不适合同步阻塞模型。如果你也在设计类似平台我建议从第一版就把异步执行作为默认姿态可以提供一个同步接口用于调试但主链路必须异步化。后端异步化虽然增加了前端轮询或回调的成本但它换来的是系统吞吐量的数量级提升。5.2 给Agent类系统的线上检查清单这次故障之后我把排查和修复过程中的要点整理成了一份检查清单新接入的 Agent 工具上线前必须逐条过一遍检查项推荐配置或做法检查目的connectTimeout1000~3000ms避免建立连接阶段卡死readTimeout依赖 P99 的 2 倍设硬上限避免响应阶段无限等待connectionRequestTimeout1000~3000ms避免连接池租借无限排队连接池 maxExpected与下游限流对齐预留 1.2~1.5 倍避免连接排队线程池隔离按依赖分组容量单独计算舱壁隔离熔断器slidingWindow 20、失败率 50%快速失败降级策略兜底文案或跳过节点提升可用性压测仿真注入慢依赖/超时/限流验证最坏场景这份清单不只是给 Agent 平台用的任何依赖外部服务的后端系统都能照搬。5.3 一点实操心得这次故障之后我把“依赖调用统一封装”做成了平台级基础设施。所有外部工具调用必须走同一个 ClientClient 从配置中心拉取超时和连接池参数可以动态调整。新接入一个工具时默认继承一套安全参数如果工具方有特殊诉求再单独覆盖。这样既保证了“最少安全基线不被绕过”又让性能参数可以基于线上监控数据持续调优。另外一个心得是遇到线上故障不要急着去甩锅或者找责任人先把“为什么会发生”和“怎么保证不再发生”想清楚。我们事后复盘的时候发现这次故障的每一个环节单独看都不算严重但串联起来就成了一个系统性事故没有超时、没有隔离、没有异步化、压测没模拟慢依赖。这四个问题只要有一个提前解决这次事故大概率不会以这种形式发生。做技术的人最怕的不是出问题而是问题出了之后只想着删代码、调参数却没有真正把手伸到系统设计这一层去补漏洞。这趟线上超时故障排查下来我最大的感受是Agent 平台的价值在于编排但编排的本质是和“不确定性”共存。外部服务什么时候变慢、什么时候拒绝你、什么时候给你返回一个乱格式的响应都是不可控的。系统设计要做的不是假设外部永远正常而是假设它一定会出问题然后把超时、隔离、熔断、降级做成默认能力。谁先把这些想清楚谁的系统就能在真实流量下活得久一点。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑