Agent平台线上超时故障复盘:从线程池耗尽到异步编排与熔断实践
凌晨 2 点 47 分手机把我从床上拽了起来。值班群里的告警弹了十几条Agent Platform 的线上入口大量超时用户页面转不出结果网关的 504 一片一片地刷屏。我套上衣服坐到电脑前这一坐就是 7 小时。先说清楚背景。我们团队做的是面向企业内部的 Agent Platform——一个把大模型能力和企业工具串联起来的智能任务编排平台。业务方把需求交过来平台负责拆步骤、选工具、调 LLM、汇总结果。比如“把上个季度的成交数据整理成经营分析日报并推送到部门企业微信群”这一句话背后要经历意图拆分、数据查询、大模型总结、群机器人推送四五个环节。平台在业务链路里的位置很关键它一超时下游全卡住。这篇文章不是来复盘“升级了版本就好了”这种虚话我会把这次线上超时故障从现象、看板数据、根因定位到修复方案、配置细节和事后排查方法完整写下来。里面涉及的部分代码和参数是这次修复的实际落地方案可以直接照着抄。1. Agent Platform 是什么为什么它天生容易超时1.1 一套 Agent Platform 的核心模块拆解在讲故障之前得先把 Agent Platform 的架构说清楚。很多人对 Agent 平台的理解还停留在“一个聊天界面后面接了个大模型”但实际生产级平台要复杂得多。它至少要包含几个模块任务接收层、编排引擎、工具注册中心、LLM 网关、状态存储和监控告警。任务接收层负责接入业务方的请求校验参数、生成任务 ID、判断用户权限。编排引擎是大脑负责把用户意图转成可执行步骤也就是常说的 planner executor 模式。工具注册中心维护所有可调用的外部能力比如企业内部的查询 API、报表服务、消息推送服务每个工具都要声明名称、参数 schema、调用地址、超时预期。LLM 网关统一封装对各家大模型服务的调用承担鉴权、限流、重试和成本统计。状态存储记录每个任务的执行快照方便中途断点续跑和前端展示进度。监控告警则把任务量、成功率、各环节耗时汇总成指标。这一套模块连接起来后平台的调用链比普通 Web 接口长得多。普通 HTTP 接口是“进来一个请求查一次库返一个结果”而 Agent 任务可能是“进来一个请求循环调用 3 次 LLM、4 次工具每两次调用之间还有路由判断”。这就意味着平台的单次请求延迟天然是叠加出来的而不是某个单一环节决定的。我画了任务数据结构的基本 schema帮助理解平台在跑什么class AgentTask: task_id: str # 全局唯一任务 ID intent: str # 用户原始意图 plan: list[Step] # 编排后的步骤列表 context: dict # 上下文步骤间传递数据 status: str # pending/running/success/failed created_at: int updated_at: int trace_id: str # 全链路追踪 ID这种长任务模型决定了它跟普通接口的运维思维不一样后面所有故障也都是围绕这条链路展开的。1.2 超时风险的三个源头Agent Platform 的超时问题来源基本可以归成三类这三类在这次故障里全踩中了。第一类是外部 LLM 调用的延迟不确定性。大模型生成 token 是逐个输出的输出 500 个字的响应网络好也要好几秒遇到高峰排队十几秒甚至几十秒都可能。而且各家模型服务的耗时分布非常宽p50 可能只有 2 秒p99 能到 30 秒。普通接口可以做 1 秒超时预算但 Agent 平台的核心调用天然是慢的超时配小了任务直接失败配大了请求线程容易被拖死这个尺度很难拿捏。第二类是工具调用的上游依赖太深。平台要调企业内部的各种服务而企业服务的质量参差不齐。有的接口设计时只考虑正常情况慢查询能拖到 30 秒下游再挂个批量任务锁库整个调用链就卡死了。Agent 平台作为“中间人”下游任何一个工具变成慢速服务平台都要替它买单。第三类是编排过程中的串行累积。一个任务要按顺序执行 5 个步骤只要其中一步耗 10 秒总耗时就是 10 秒起步。如果步骤之间有依赖没法并行延迟就是线性叠加。更麻烦的是编排引擎里往往有重试逻辑某一步失败了会重试重试又会把耗时再翻倍。用户看到的现象就是请求发过去页面转圈转了很久要么成功要么超时。我把三类风险对耗时的影响做了一个估算表按一次常见的企业分析类任务来算环节正常耗时异常耗时调用次数异常总耗时意图识别 LLM 调用1.5s15s115s报表工具查询0.8s45s290s总结 LLM 调用2s30s130s推送服务0.3s5s15s这张表的意思是任何一个环节出现尾部延迟整个任务的耗时就会被无限拉长。而生产环境里“每个环节都出点小问题”才是常态于是任务超时就成了必然事件。好架构背景交代完了。下面进入正题还原这次故障的现场。2. 故障现场还原一次真实的线上超时2.1 故障是从哪个入口开始爆发的这次故障不是无缘无故的。业务方前一天刚把平台接到一个外部合作渠道做了一个面向渠道用户的公开入口原计划先灰度 5% 流量观察两天。灰度上线后的第一晚渠道方做了一场推广直播流量瞬间比平时高了近 10 倍。凌晨 2 点半左右入口的请求量从平时每秒 30 个左右蹿到每秒 320 个。我们在灰度前做过一轮简单的压测当时按 200 QPS 双倍容量打接口还能扛住所以我对 320 QPS 有一定预期。但压测用的是模拟数据模拟的是“LLM 耗时正常、工具响应正常”的晴天场景完全没有覆盖“部分请求耗时突增线程被长时间占用”的雨天场景。压测没有暴露问题真实流量一来几个隐藏的条件凑到一起故障就爆了。先说故障现象。表现最明显的是网关层/api/v1/agent/execute这个同步接口的超时率达到 34%请求 30 秒内拿不到响应网关直接返回 504。用户侧更惨部分任务实际上在后台已经执行完了但因为 HTTP 响应迟迟回不去前端拿不到结果用户在页面反复刷新又产生大量重试请求。整个链路的请求量进一步被放大。2.2 第一波排查从监控里看到了什么告警响起来之后我按排查超时的标准流程先看了四张图延迟分布、错误率、线程池活跃数、外部依赖耗时。延迟分布图里/api/v1/agent/execute的 p95 从平时的 4 秒涨到 28 秒p99 直接顶到了 45 秒红线以上全是超时惩罚。错误率图里504 和 500 混在一起错误率最高到 34%。线程池的监控最有意思应用进程的 CPU 只有 40% 左右并不高但名为agent-executor的线程池活跃线程数拉满到了 200队列积压任务数持续上涨。看到这个组合基本可以断定不是计算资源不够而是线程被长时间阻塞的任务占满了。再看外部依赖的耗时。监控大盘上专门挂了 LLM 网关和核心工具的调用耗时。当时 LLM 网关的 p99 延迟确实有波动涨到了 20 秒多工具服务里有一个报表生成接口耗时从正常 0.8 秒涨到了 45 秒。两个异常点同时存在哪个才是根因光看聚合指标分不清接下来就得靠链路追踪逐条拆了。2.3 抽丝剥茧根因是怎么一步步锁定的我调出当时耗时最长的几十个请求一条条看分布式追踪的 span 数据。大部分慢请求的结构高度一致入口 span 时间很长下面第一个子 span 是报表工具调用占了整个请求耗时的 80%。也就是说用户等了 40 多秒等的主要是那一次报表查询其它环节反而是次要矛盾。报表工具为什么慢运维同事定位到是 SQL 的问题报表服务有个核心查询平时走索引只要几百毫秒但当天后台的统计批处理任务把一张大表锁住了查询长时间拿不到锁只能等待。批处理任务本身要跑 40 分钟这个窗口内所有查询都被拖住。换句话说一个和我们平台毫无关系的下游批量任务成了这次事故的“导火索”。找到慢 span 之后再回看线程池积压的问题就清楚了。Agent Platform 的同步接口采用“请求线程直接驱动任务执行”的模式一个 HTTP 请求进线程池线程就死等所有步骤跑完。报表查询慢到 45 秒线程就被占 45 秒。线程池 200 个线程正常请求耗时 4~5 秒时能支撑的 QPS 和请求耗时 45 秒时能支撑的 QPS 完全是两个量级。流量一来线程池很快耗尽新请求直接排队甚至被拒绝用户端看到的就是超时。还有一个我事后才发现的放大器网关的失败重试策略。网关对 5xx 错误会自动重试 2 次且没有退避时间。请求超时后被网关重放重放的请求再次进入线程池继续排队排队超过阈值又失败再次触发重试。失效请求被不断放大相当于故障期间实际注入平台的请求量是用户请求量的两三倍这加速了线程池的耗尽也让 LLM 网关承受了额外压力导致 LLM 调用延迟也出现异常。所以这次超时的根因链是这样的下游报表工具的慢查询卡住关键步骤 → 同步模型下线程被长时间占用 → 高流量下线程池耗尽 → 新请求大面积超时 → 网关无退避重试放大流量 → 雪上加霜。这里面没有一个环节是特别高深的技术事故但串在一起就变成了线上事故。2.4 复盘这次超时的完整因果链条为了避免下次再漏判我把这次的因果链整理成了一张表格故障排查时可以按表核对阶段触发条件表现本次是否命中下游变慢工具服务出现慢查询或锁等待单个工具耗时从 0.8s 涨到 45s是线程被占同步执行模型线程死等所有步骤线程池活跃数打满 200是流量放大高并发 请求排队请求队列积压成功率下降是重试风暴网关自动重试无退避实际注入流量翻倍是上游被拖慢LLM 网关并发升高LLM p99 从 5s 涨到 20s是这个链条里只要打断任何一环事故都不会发展成大面积超时。后面的修复本质上就是逐环去打补丁。3. 修复方案把串行阻塞改成异步编排加熔断兜底3.1 第一刀给所有外部调用上超时和熔断修复启动的第一件事不是重构架构而是先把止血措施做掉——给所有外部调用加上合理的超时、重试上限和熔断。这一步的目的是哪怕下游再出慢查询平台也要能做到“快速失败”而不是“无限死等”。超时的配置需要分级。我给这次修复定了一个超时预算表直接在配置中心维护调用类型连接超时等待超时读单次总超时重试次数LLM 流式调用3s120s按流180s1LLM 非流式调用3s30s35s1核心工具调用2s8s10s2指数退避非核心工具调用1s5s6s0推送/通知类1s3s4s1这个表有几个值得解释的地方。第一流式 LLM 调用的读超时不能按“3 秒内必须返回全部内容”来设否则正常的长文本生成也会被打断。流式场景的正确做法是设置空闲超时只要流还在持续输出 token就不算超时一旦流空闲超过 60 秒才判定卡住。我用的是 httpx 的配置它对流式响应可以分开配读超时和整体超时。# httpx 客户端超时配置示例 timeout httpx.Timeout( connect3.0, # 连接超时 3 秒 read60.0, # 读超时 60 秒流式下是空闲超时 write5.0, # 写超时 5 秒 pool5.0 # 连接池获取超时 )第二工具的读超时设成 8 秒是因为我们把平台对工具的 SLA 定成了“每个工具单次调用最多贡献 10 秒延迟”。超过 8 秒还没返回大概率是下游出问题了与其多等 40 秒不如快速失败走降级。这里的取舍逻辑是一个任务里通常有 3~5 个工具调用如果每个工具都可以拖 45 秒一次任务就完全不可控了。重试方面我把原来的“无条件重试 2 次”改成了“可重试错误才重试 指数退避 抖动”。连接超时、5xx、限流这类临时性错误才重试业务参数错误、4xx 不重试。重试间隔按 500ms、1s、2s 递增并加随机抖动避免大批请求同时重试。用 Python 的 tenacity 库实现非常干净from tenacity import retry, stop_after_attempt, wait_random_exponential, retry_if_exception_type retry( stopstop_after_attempt(3), # 最多 3 次尝试 waitwait_random_exponential(multiplier0.5, max2.0), # 指数退避抖动 retryretry_if_exception_type((ConnectTimeout, RemoteProtocolError, HTTPStatusError)), ) async def call_report_tool(payload): ...熔断我直接用了平台的治理组件对每个下游工具单独配置熔断规则连续失败 5 次触发熔断熔断后快速失败 30 秒然后进入半开状态放少量请求探测。这个机制保证了一个工具持续变慢时平台能直接跳过它而不是让所有任务都堵在同一个工具上。这套止血措施上线后故障立即缓解。但我知道这只是“让系统在故障时不至于崩溃”并没有解决用户真正要的东西任务依然可能要用户等很久。所以第二刀才是关键。3.2 第二刀任务异步化HTTP 线程不再被拖死第二刀是把同步接口改成异步任务模式这是这次改造的核心。先说思路。原来的模型是“请求线程同步跑完整条 Agent 编排链”用户的一次 HTTP 请求要等服务端把全部 5 个步骤跑完才返回结果。这个模型在编排链很短、外部依赖稳定的场景下没问题但 Agent 平台天然是长任务同步模型等于把“慢”从外部依赖直接传导给用户和服务器线程。改造后的模型是“提交任务 → 立刻返回任务 ID → 后台 Worker 执行编排 → 用户通过轮询或流式接口获取进度和结果”。HTTP 请求线程只做接受任务和入库两件事几毫秒就释放。真正的编排逻辑跑到独立的 Worker 进程里不占 HTTP 线程池。这个改动用的是一个轻量的任务分发方案Redis Stream 做任务队列Python 异步 Worker 消费。为什么不用消息中间件因为我们团队当时不想为这个场景再引入一套 KafkaRedis Stream 足够支撑这个体量的任务吞吐且部署简单。选型的逻辑是任务队列的核心诉求是“提交快、消费快、失败能重试”Redis Stream 的 ACK 机制和消费者组完全够用。核心实现分三段。第一段是提交接口接收任务后只做校验和入库然后投递到队列立刻返回 202 和 task_idfrom fastapi import FastAPI from pydantic import BaseModel app FastAPI() class TaskSubmitRequest(BaseModel): intent: str user_id: str app.post(/api/v1/agent/tasks, status_code202) async def submit_task(req: TaskSubmitRequest): task_id generate_task_id() await task_store.save(Task(idtask_id, statuspending, intentreq.intent)) await task_queue.push(task_id) # 投递到 Redis Stream return {task_id: task_id, status: pending}第二段是 Worker 消费循环。Worker 持有一个 OpenAI 兼容的异步客户端负责编排步骤。每次执行一个步骤把步骤状态实时写回存储并往事件流里推送一条进度事件async def worker_loop(): while True: messages await task_queue.consume() for _, task_id in messages: await execute_task(task_id) await task_queue.ack(task_id) async def execute_task(task_id: str): task await task_store.get(task_id) await publish_event(task_id, {type: step_start, step: plan}) plan await llm_client.plan(task.intent) # 按计划逐步执行 for step in plan: await publish_event(task_id, {type: step_running, step: step.name}) result await execute_step(step) task.context[step.name] result await publish_event(task_id, {type: step_done, step: step.name, summary: result.summary}) await task_store.mark_success(task_id) await publish_event(task_id, {type: task_done, result: task.result})第三段是查询接口和事件流接口。查询接口就是普通的 GET 查库返回任务状态和结果。事件流接口用 SSE 推送进度我们接下来会讲到。这里有三个坑必须提醒。第一Worker 进程要单独部署、独立扩容不能和 API 进程混布否则任务堆积时又把 API 线程拖死了。第二任务状态写入必须有幂等性因为 Worker 重试时可能重复执行步骤我们给每个 step 加了 step_id写入时按 step_id 去重。第三队列消费要设置并发上限防止 Worker 一启动就把积压任务全部拉起来打爆下游依赖。我们把 Worker 的并发控制在 30单任务内部步骤按依赖并行但整体并发有度。改造后HTTP 接口的 p95 稳定在 150 毫秒左右因为接口只做“接单”不做“执行”。线程池不再被拖死故障的土壤没了。3.3 第三刀流式返回让用户先看到进度异步化解决了服务器端的线程占用但对用户来说体验还是“提交后干等着”。一个复杂 Agent 任务在后台要跑 20 到 40 秒如果页面什么都不显示用户第一反应就是“卡了”然后刷新、重试制造新的流量。所以我加了 SSE 事件流接口把任务的执行进度实时推给前端。前端一拿到 task_id就打开一个 SSE 连接页面按事件更新状态第一步在做什么、第二步成功没有、最后的结果是什么。这种体验很像 LLM 产品的流式打字效果——用户看到的不是一片空白而是持续有进度等待的焦虑感大幅下降。SSE 用 FastAPI 的 StreamingResponse 实现思路是开一个异步生成器从事件流里读消息并 yield 给客户端from fastapi.responses import StreamingResponse import json app.get(/api/v1/agent/tasks/{task_id}/events) async def task_events(task_id: str): async def event_generator(): async for event in event_store.subscribe(task_id): yield fdata: {json.dumps(event, ensure_asciiFalse)}\n\n return StreamingResponse(event_generator(), media_typetext/event-stream)事件流的设计要按“能续传”来考虑。我们的事件存储是 Redis 里的一个 list为每个任务保留最近 200 条事件客户端断线重连可以把 missing 的事件从 last_event_id 开始补推。实现上不复杂但对用户体验和排障很有价值——前端刷新后还能恢复进度而不是重新提交任务。要注意的是很多网关和反向代理默认会缓冲 SSE 响应导致客户端收不到增量数据只能等整个响应结束才一次性看到全部内容那就等于没流式。我这次就踩了这个坑SSE 在本地测试正常走网关后数据被缓冲了。解决方法是调整网关配置关闭对 SSE 路径的缓冲并设置较长的读超时避免连接因空闲被切断。3.4 第四刀让重试机制不再帮倒忙前面提到这次故障有一个放大器是网关的无脑重试。这一刀要单独治理重试。我梳理了平台上所有的重试逻辑发现有三层重试网关层、平台 SDK 层、编排引擎内部重试。三层叠加最坏情况下一个用户请求可能被放大成 8 次实际执行这就是重试风暴的来源。治理原则是每一层只负责自己的重试职责而且最多只允许一个层面做重试。我们把重试策略重新收敛如下层级是否重试策略网关不负责业务重试只做连接层重试超时即返回 5xxAPI 层不重试提交接口 202 后任务已异步化不需要重试Worker 编排负责重试按工具类型区分指数退避最多 2 次工具调用 SDK放弃重试完全交由编排层统一控制还有一个细节幂等。既然允许重试就必须有幂等保护。我们给每次任务生成了 task_id 和 trace_id工具调用带上这两个 ID下游可以用它在业务侧去重。这个设计不仅防止重复扣减、重复推送也让我们在查日志时能快速串联同一次任务的多次尝试。重试治理之后故障期间那种“越重试越堵、越堵越重试”的恶性循环就被打断了。3.5 修复效果与性能数据全部改造完成并观察一周后我记录下修复前后的核心指标对比指标故障期修复后HTTP 接口 p95 耗时28s150ms网关超时率50434%0.2%线程池活跃峰值200打满60 左右复杂任务从提交到结果用户无感知等待首条事件 200ms完成约 25s客服投诉量单日 47 起降至 0关键变化是任务总时长没有变短多少——复杂任务还是要 25 秒——但用户感知完全不同了。以前是“点一下没反应转圈 40 秒后失败”现在是“点一下立刻拿到任务号页面不断滚动进度80 秒后看到完成结果”。超时不再是阻塞问题而是进度问题。4. 复盘线上超时排查的通用方法论4.1 排查超时问题先看这四张图这次故障处理完我做了一套超时排查的标准化动作分享出来给同行参考。遇到线上超时先别急着翻代码先把四张图打开第一张是延迟分布图。看 p50、p95、p99 三条线。如果 p50 正常而 p99 很高说明是个别尾部请求慢重点找“慢请求的共同特征”如果 p50 都很高说明是全链路整体变慢更像是容量或者依赖降级问题。第二张是线程池或连接池监控图。看活跃线程数、队列深度。线程池打满但 CPU 不高几乎可以断定是外部 IO 阻塞型问题线程都在等下游。CPU 和线程池同时打满则是计算型问题或资源不足。第三张是外部调用依赖图。平台所有依赖方——LLM 网关、工具服务、数据库——都单独画耗时曲线。找出先开始上涨的那条曲线它通常是源头后面跟着涨的都是被传导的。这次故障里工具服务先涨LLM 网关后涨前者才是导火索。第四张是错误与重试统计图。看 5xx 分布、重试请求占比。如果重试占比明显高于正常水平说明存在重试放大要在处理超时之外单独治理重试策略。这四张图打开的顺序也很重要先延迟图判断范围再依赖图找源头再线程池图确认机制最后重试图查放大器。顺序反过来容易误判比如先看到 LLM 网关变慢就以为是模型服务的问题实际那只是被放大出来的次生故障。4.2 比超时本身更隐蔽的四个坑这次改造过程中踩了几个隐藏很深的坑都写在这里能帮大家少走弯路。第一个坑是“配置了超时但没生效”。我们最开始给 LLM SDK 配了 10 秒读超时但线上实测发现调用有时能跑到 30 秒。查下来发现SDK 内部对连接和读取的超时配置是分开的我们只改了连接超时读取超时用的还是默认值 30 秒。这个问题的教训是改完超时配置一定要用“主动制造慢请求”的方式验证比如把下游服务停掉再请求看超时时间是否真的生效。第二个坑是“超时后线程没有及时释放”。Python 的 httpx、Java 的 OkHttp 这类客户端库在超时触发后通常能主动完成清理。但如果你在代码里用了asyncio.gather并发调用多个工具其中一个超时抛异常另外几个协程并不会自动取消会继续跑直到完成。这样线程虽然拿到了异常但底层的连接和协程还在被占用。解决方案是超时后必须显式取消比如用asyncio.timeout包裹并发调用超时立刻取消所有子协程。第三个坑是“数据缓冲吞掉了流式效果”。SSE 在本地环境一切正常走网关后用户还是感觉像同步请求原因是网关层默认开启了响应缓冲。这问题排查起来很费劲因为服务端日志显示事件一直在发客户端却收不到。建议上线的第一件事就是用 curl 验证curl -N --no-buffer http://localhost:8080/api/v1/agent/tasks/test/events能实时看到多行事件输出才说明流式链路是通的。第四个坑是“自查工具拖垮了平台”。我们给每个请求都加了耗时日志日志里带上了工具返回的完整内容高峰期日志量暴涨反而拖慢了主流程。后来把所有大字段做了裁剪和脱敏日志只保留耗时、状态码、trace_id、摘要日志量降了 80%。4.3 沉淀下来的一份故障检查清单最后给大家一份可以直接抄的检查清单。以后只要是 Agent 平台或类似长链路服务的线上超时按这个清单逐项核对大部分问题都能定位到。确认超时范围是单入口还是全入口是单用户还是全量用户范围决定排查方向。查看延迟分布p50/p95/p99 的走势判断是尾部问题还是整体问题。查看外部依赖LLM、工具、数据库三条耗时曲线先涨的是源头。查看线程池活跃线程、队列深度、拒绝次数判断是否被 IO 阻塞拖死。查看重试和幂等重试占请求的比例有没有无退避重试。验证超时配置真实制造一次慢请求确认超时生效且线程及时释放。确认流式链路用curl -N验证 SSE 是否被网关缓冲。准备降级方案工具熔断链路是否可用任务失败时用户是否有明确提示。这份清单不是一次性用的我建议把它做成值班手册任何同事接手都能照着操作。故障处理最怕的就是“只有当事人知道怎么查”把排查过程标准化团队才不会在凌晨手忙脚乱。这次故障处理完我个人感受最深的一点是Agent 平台的架构设计不能按普通 API 的标准来。普通 API 讲究“快”Agent 平台讲究“稳、可预期、可降级”。快是目标但永远要假设外部依赖会变慢、会失败、会拖住你的线程。把同步阻塞改成异步编排、把无脑重试改成有策略的重试、把干等改成进度可感知这三件事做完平台的稳定性才真正立得住。最后分享一个小技巧故障结束后别急着庆祝花半天把根因链画清楚、把改进项落到值班手册里下次再出问题团队能少熬好几个小时。