Agent Platform线上504故障复盘:LLM调用链超时治理与稳定性实践
1. 一次线上504把我从“平台幻觉”里拽了出来
做 Agent Platform 这件事,我一开始是带着很强的技术乐观情绪的。多智能体编排、工具调用、LLM 路由、会话记忆、流式输出,这些词堆在一起,很容易让人产生一种“我在造下一代基础设施”的错觉。直到某个周二下午,监控面板上 504 的曲线像被人拽着头发往上拉,我才意识到,平台这东西,平时不炸不代表它稳,只是流量还没到那个临界点。
我们这套 Agent Platform 的核心链路并不复杂:用户在前端发起一个任务,网关把请求转给编排服务,编排服务根据任务类型选择不同的 LLM 后端,可能是 deepseek,也可能是其他模型,然后调用工具、拼上下文、做多轮推理,最后把结果流式吐回去。听起来很顺,对吧?问题就出在“听起来很顺”这五个字上。真实线上环境里,任何一个环节的延迟抖动,都会被放大成用户侧的超时。
这篇文章我想聊的不是“如何从零搭建一个 Agent Platform”这种大而全的教程,而是围绕这次真实的线上超时故障,把 Agent Platform 在 LLM 场景下的超时问题拆开讲透。包括 504 到底是怎么产生的、LLM 调用链里哪些地方最容易埋雷、deepseek 这类模型在长上下文下的响应特征、以及我们最后是怎么把超时率从两位数压到可接受范围的。如果你正在做 LLM 应用、Agent 编排、或者任何涉及大模型调用的后端服务,这篇内容应该能帮你少走一些弯路。
先说结论:Agent Platform 的超时问题,从来不是“把 timeout 调大一点”就能解决的。它是一套涉及网关、编排、模型调用、工具执行、流式传输、重试策略的系统工程。下面我按故障发生的时间线,把每个环节拆开讲。
2. 故障现场还原:504 是怎么一步步爬上来的
2.1 监控告警的第一现场
那天下午两点十七分,告警群开始响。先是网关层 504 比例从 0.3% 跳到 4%,然后五分钟内冲到 11%。同时编排服务的 P99 延迟从 1.8 秒涨到 23 秒。有意思的是,LLM 供应商侧的监控显示 deepseek 的 API 响应时间只是从平均 2.1 秒涨到 3.4 秒,并没有出现大规模不可用。也就是说,问题不完全在模型侧,而是在我们自己的链路上。
我当时的第一个判断是:某个工具调用卡住了。因为 Agent Platform 和普通 API 最大的区别在于,它不是一次请求一次响应,而是一个请求可能触发多次模型调用和多次工具调用。任何一次工具调用超时,都会把整个请求的耗时往上推。后来查日志发现,确实有一个外部 HTTP 工具在特定参数下会卡到 30 秒才返回,而我们的编排层默认超时是 60 秒,网关层是 30 秒。这就形成了一个尴尬的局面:网关已经放弃了,编排层还在傻等。
2.2 超时链路的分层拆解
为了把问题说清楚,我先把 Agent Platform 的典型调用链画出来。注意,这里不用图,我用文字描述,你脑子里过一遍就行。
用户请求先到 API 网关,网关做鉴权和限流,然后转发给编排服务。编排服务拿到请求后,会做几件事:加载会话历史、构建 prompt、选择模型、调用 LLM、解析模型输出、决定是否调用工具、调用工具、把工具结果再喂给模型、循环直到模型输出最终答案。最后把结果通过 SSE 或 WebSocket 流式返回给网关,网关再转发给用户。
这条链路上,至少有六个地方可能超时:网关等待编排服务的超时、编排服务等待 LLM 的超时、LLM 自身推理的超时、工具调用的超时、流式传输的空闲超时、以及客户端自身的超时。任何一个环节的 timeout 设置不匹配,都会导致上游提前放弃,而下游还在继续跑,浪费资源不说,还会让日志变得极其难查。
我们那次故障,核心问题就是网关 30 秒、编排 60 秒、工具 30 秒、LLM 45 秒,这四个数字之间没有形成合理的梯度。网关最先放弃,用户看到 504,但编排服务还在等工具返回,等到了之后又继续调 LLM,最后把结果写到一个已经断开的连接上。整个过程就像你打电话给客服,客服还在帮你查,你已经挂了,但客服还在对着空气说话。
2.3 为什么 LLM 场景下超时更棘手
普通 Web 服务的超时相对好处理,因为响应时间通常比较稳定,P99 和 P50 差距不会太离谱。但 LLM 调用不一样,它的延迟分布非常长尾。同一个 prompt,同样的模型,有时候 1.5 秒返回,有时候 8 秒返回,遇到长上下文或者复杂推理,20 秒以上也不罕见。deepseek 这类模型在处理长文本时,首 token 延迟和总生成时间都会明显上升。
更麻烦的是,Agent 场景下模型调用不是一次性的。一个任务可能触发三到五轮模型调用,每轮都有延迟抖动。如果每轮 P99 是 5 秒,五轮叠加起来,P99 就可能到 25 秒。这还没算工具调用的时间。所以你在设计超时策略时,不能只看单次调用的延迟,要看整个 Agent 执行链的累积延迟。
我后来做了一个简单的估算:假设单轮 LLM 调用 P50 是 2 秒、P99 是 6 秒,工具调用 P50 是 0.5 秒、P99 是 3 秒,一个任务平均 3 轮模型调用加 2 次工具调用。那么 P50 总耗时大约是 3×2 + 2×0.5 = 7 秒,P99 总耗时大约是 3×6 + 2×3 = 24 秒。这意味着如果你的网关超时设成 30 秒,P99 用户已经站在悬崖边上了。任何一点额外的抖动,都会把他们推下去。
3. 核心细节解析:Agent Platform 超时问题的五个关键点
3.1 网关超时与编排超时的梯度设计
网关和编排服务之间的超时关系,必须遵循“上游大于下游”的原则。也就是说,网关等待编排服务的超时,应该比编排服务内部所有操作的总超时略大一点。这样编排服务有机会自己完成清理和返回,而不是被网关强行切断。
我们之前的配置是反过来的:网关 30 秒,编排 60 秒。这导致网关先超时,编排还在跑。后来改成网关 90 秒,编排内部总预算 75 秒,留 15 秒给网络传输和序列化。编排内部的 75 秒再拆分成:LLM 调用总预算 45 秒、工具调用总预算 20 秒、其他开销 10 秒。这样每一层都有明确的预算,不会出现某一层无限等待的情况。
这里有个经验:不要给单个 LLM 调用设置太死的超时,而是给整个 Agent 执行链设置总预算。因为单次调用超时后重试,可能会让总耗时更长。更好的做法是,在编排层维护一个 deadline,每次调用前检查剩余时间,如果不够就提前降级或者返回部分结果。
3.2 LLM 调用的流式与非流式超时差异
deepseek 这类模型支持流式输出,这对 Agent Platform 来说既是好事也是坏事。好事是用户可以更快看到首 token,体验更好;坏事是流式连接的空闲超时很难设置。如果设置太短,模型思考时间稍长就会断;设置太长,遇到真正卡死的连接又会占用资源。
我们的做法是区分两种超时:首 token 超时和空闲超时。首 token 超时设置为 15 秒,意思是如果 15 秒内没有收到任何 token,就认为这次调用失败,触发重试或降级。空闲超时设置为 30 秒,意思是如果两个 token 之间间隔超过 30 秒,就认为连接异常,主动断开。这两个值是根据 deepseek 在实际负载下的表现调出来的,不同模型可能需要调整。
另外,非流式调用和流式调用的超时策略也不一样。非流式调用通常用于工具参数解析或者短文本生成,超时可以设短一点,比如 20 秒。流式调用用于最终答案生成,超时可以设长一点,但要有空闲检测。我们后来把这两类调用分开配置,效果明显好很多。
3.3 工具调用的超时与熔断
Agent Platform 的工具调用是最容易出问题的地方,因为工具往往是外部服务,你控制不了它的延迟。我们那次故障的导火索就是一个外部 HTTP 工具在特定参数下卡了 30 秒。后来我们给所有工具调用加了三层保护:单次调用超时、重试次数限制、熔断器。
单次调用超时根据工具类型设置,查询类工具 5 秒,写入类工具 10 秒,复杂计算类工具 20 秒。重试次数最多 2 次,而且只在超时或 5xx 错误时重试,4xx 不重试。熔断器用简单的滑动窗口,如果某个工具在 10 秒内失败超过 5 次,就熔断 30 秒,期间所有调用直接返回降级结果。
这里有个细节:工具调用的超时时间要算进编排层的总预算里。如果总预算只剩 8 秒,而工具超时是 10 秒,那就应该直接跳过这个工具,而不是等它超时。我们在编排层加了一个 checkDeadline 函数,每次调用前都检查剩余时间,不够就走降级逻辑。
3.4 重试策略与幂等性
超时后的重试是一把双刃剑。对于 LLM 调用,重试可能会导致重复生成,浪费 token 和时间。对于工具调用,重试可能会导致重复写入,产生脏数据。所以重试必须和幂等性一起考虑。
我们的策略是:LLM 调用只在首 token 超时或连接错误时重试,而且最多重试一次。如果已经收到部分 token,就不重试,而是把已有内容返回给用户,并标记为不完整。工具调用则要求所有写操作必须带幂等键,重试时用同一个键,服务端做去重。读操作可以放心重试。
还有一个容易被忽略的点:重试的时间也要算进总预算。如果总预算只剩 5 秒,重试一次至少需要 3 秒,那重试就没有意义。所以我们在重试前也会检查 deadline,不够就直接放弃。
3.5 客户端超时与服务端超时的配合
很多团队只关注服务端超时,忽略了客户端超时。实际上,如果客户端设置的超时比服务端短,用户会在服务端还在处理时就断开连接,导致服务端的计算结果无处可送。我们后来在前端也做了调整,把默认超时从 30 秒改成 120 秒,并且加了进度提示,让用户知道任务还在执行。
对于流式接口,客户端要能处理连接中断和重连。我们用的是 SSE,前端在连接断开后会尝试重连,并带上 lastEventId,服务端从上次中断的地方继续推送。这样即使中间有网络抖动,用户也不会丢失全部结果。
4. 实操过程:从故障排查到超时体系重建
4.1 第一步:给全链路加上耗时打点
故障发生后的第一件事,不是急着改配置,而是先把链路看清楚。我们在网关、编排、LLM 调用、工具调用四个层次都加上了详细的耗时打点,每个请求生成一个 traceId,所有日志都带上这个 ID。这样就能清楚地看到,一个请求在哪个环节花了多少时间。
打点数据收集了一天之后,我们发现了几个之前没注意到的问题。第一,会话历史加载在某些大会话下会花 2 到 3 秒,因为要从数据库读大量消息并做 token 计算。第二,prompt 构建阶段在做模板渲染时,如果模板复杂,也会花 1 秒以上。第三,LLM 调用的首 token 延迟在下午高峰期明显上升,从平均 1.2 秒涨到 3.5 秒。这些细节在故障之前都是盲区。
4.2 第二步:重新设计超时预算表
有了打点数据,我们重新设计了一张超时预算表。这张表的核心思想是:从用户请求的总预算出发,逐层分配,每一层都不能超过上一层的预算。
| 层级 | 超时项 | 预算 | 说明 |
|---|---|---|---|
| 网关 | 等待编排服务 | 90s | 包含网络传输和序列化 |
| 编排 | 总执行预算 | 75s | 从收到请求到返回最后字节 |
| 编排 | 会话加载 | 5s | 超时则使用空历史 |
| 编排 | prompt 构建 | 3s | 超时则使用简化模板 |
| 编排 | LLM 调用总预算 | 45s | 多轮调用共享 |
| 编排 | 工具调用总预算 | 20s | 多工具共享 |
| 编排 | 其他开销 | 2s | 日志、监控等 |
| LLM | 首 token 超时 | 15s | 超时触发重试或降级 |
| LLM | 空闲超时 | 30s | 流式调用中 token 间隔 |
| 工具 | 单次调用超时 | 5-20s | 按工具类型区分 |
| 客户端 | 请求超时 | 120s | 前端默认值 |
这张表不是拍脑袋定的,而是根据实际打点数据的 P99 再加一定余量算出来的。比如 LLM 调用总预算 45 秒,是因为我们观察到 3 轮调用的 P99 大约是 24 秒,留出接近一倍的余量,应对突发抖动。
4.3 第三步:在编排层实现 deadline 传播
光有预算表还不够,关键是要在代码里实现 deadline 的传播。我们的做法是,在请求进入编排服务时,创建一个 Deadline 对象,记录绝对截止时间。然后把这个对象传递给所有下游调用,包括 LLM 调用和工具调用。
每次调用前,先检查当前时间是否已经超过 deadline,如果超过,直接返回超时错误,不再发起调用。如果剩余时间小于调用的最小预期耗时,也直接降级。比如剩余时间只有 3 秒,而 LLM 调用最少需要 5 秒,那就直接返回“系统繁忙,请稍后重试”,而不是硬着头皮调用。
这个机制看起来简单,但效果非常明显。它避免了大量“注定超时”的调用,减少了资源浪费,也让用户更快得到反馈。我们上线这个机制后,网关 504 比例从 11% 降到了 2% 以下。
4.4 第四步:给 deepseek 调用加上自适应超时
deepseek 的响应时间在不同负载下差异很大,固定超时很难适应。我们后来做了一个简单的自适应策略:记录最近 100 次调用的首 token 延迟,计算 P95,然后把首 token 超时设置为 P95 的 1.5 倍,但不超过 20 秒。这样在低峰期超时更短,快速失败;在高峰期超时自动放宽,减少误杀。
这个策略的实现不复杂,用一个滑动窗口记录延迟,每次调用前更新超时值。注意要加一个下限,比如 8 秒,避免低峰期超时太短导致正常调用被误判。也要加一个上限,比如 20 秒,避免高峰期超时太长导致用户等太久。
4.5 第五步:压测验证与灰度上线
改完超时体系后,我们没有直接全量上线,而是先做了一轮压测。压测的重点不是看系统能扛多少 QPS,而是看在不同负载下,超时比例和 P99 延迟的变化。我们模拟了三种场景:正常负载、1.5 倍负载、2 倍负载,分别观察 504 比例和用户侧超时比例。
压测发现,在 2 倍负载下,LLM 调用的首 token 延迟会明显上升,自适应超时策略能有效减少误杀。但工具调用的熔断器在高压下会频繁触发,导致部分任务降级。后来我们把熔断阈值从 5 次失败调到 10 次,并增加了半开状态,让熔断后有机会恢复。
灰度上线分了三批:先 5% 流量,观察一天;再 30%,观察两天;最后全量。每批都重点看 504 比例、P99 延迟、用户反馈。整个过程大约一周,没有出现明显回退。
5. 常见问题与排查技巧实录
5.1 超时问题速查表
| 现象 | 可能原因 | 排查方法 | 解决思路 |
|---|---|---|---|
| 网关 504 比例突增 | 编排层某环节卡住 | 查 traceId,看哪个环节耗时最长 | 给该环节加超时和降级 |
| LLM 调用频繁超时 | 模型侧负载高或 prompt 太长 | 看首 token 延迟和输入 token 数 | 缩短 prompt,调整超时,加降级 |
| 工具调用超时 | 外部服务慢或网络抖动 | 看工具侧监控和调用日志 | 加熔断,设合理超时,重试 |
| 流式连接中断 | 空闲超时太短或网络不稳 | 看 token 间隔和客户端日志 | 调整空闲超时,加心跳 |
| 重试后仍然超时 | 总预算不足或重试策略不当 | 看重试次数和剩余预算 | 减少重试,提前降级 |
| 客户端超时但服务端成功 | 客户端超时太短 | 对比客户端和服务端超时配置 | 调大客户端超时,加进度提示 |
5.2 几个容易踩的坑
第一个坑是“超时时间设成一样的”。很多人图省事,网关、编排、LLM、工具都设 30 秒。结果就是所有环节同时超时,根本分不清是谁的问题。正确的做法是让超时时间形成梯度,上游比下游大,给下游留出清理和返回的时间。
第二个坑是“只设超时不设降级”。超时之后如果只是返回错误,用户体验很差。更好的做法是准备降级方案,比如 LLM 超时后返回缓存结果或简化答案,工具超时后跳过该工具继续执行。降级不一定完美,但比直接报错好。
第三个坑是“忽略流式调用的空闲超时”。非流式调用只要设一个总超时就行,流式调用必须同时设首 token 超时和空闲超时。否则模型思考时间一长,连接就断了,用户看到一半的结果没了。
第四个坑是“重试没有幂等保护”。工具调用重试时,如果写操作没有幂等键,可能会重复写入。我们后来要求所有写工具必须支持幂等键,否则不允许重试。
第五个坑是“没有全链路 traceId”。超时问题最难的是定位,如果没有统一的 traceId,日志散落在各个服务里,根本串不起来。我们从故障之后就强制所有服务传递 traceId,现在排查问题快了很多。
5.3 一个真实的排查案例
有一次用户反馈“任务一直转圈,最后报超时”。我们拿到 traceId 后,发现请求在编排层卡了 50 秒,但 LLM 调用只花了 3 秒,工具调用花了 2 秒。剩下的 45 秒去哪了?后来查代码发现,是在做会话历史加载时,有一个数据库查询没有加索引,在大会话下全表扫描,花了 40 多秒。这个问题在打点之前完全看不到,因为大家都以为时间花在 LLM 上。
修复方法很简单,给会话表的 session_id 加索引,查询时间从 40 秒降到 50 毫秒。但这个问题的发现过程说明,没有细致的打点,你根本不知道时间花在哪。所以我现在逢人就说,做 Agent Platform,先把打点做好,再谈优化。
6. 一些关于 Agent Platform 稳定性的个人体会
这次故障之后,我对 Agent Platform 的稳定性有了几个新的认识。第一,LLM 应用的超时问题比传统 Web 服务复杂得多,因为延迟分布长尾、调用链多轮、外部依赖不可控。你不能用传统的那套超时策略,必须针对 LLM 场景做专门设计。
第二,超时不是孤立的问题,它和降级、重试、熔断、幂等性紧密相关。单独调一个 timeout 参数解决不了问题,必须有一套完整的容错体系。这套体系的核心是 deadline 传播和预算分配,让每个环节都知道自己有多少时间,不够就提前放弃。
第三,可观测性是基础。没有全链路 traceId 和细致的耗时打点,你就是在黑暗中修车。我们后来把打点做得很细,每个 LLM 调用、每个工具调用、每个数据库查询都有耗时记录,排查问题的效率提升了一个数量级。
第四,自适应策略比固定配置更靠谱。LLM 的延迟会随负载变化,固定超时要么太松要么太紧。用滑动窗口做自适应超时,能更好地适应实际负载。这个策略实现不复杂,但效果很好。
最后再分享一个小技巧:在编排层加一个“超时预警”机制。当某个请求的已耗时超过总预算的 70% 时,就打一条警告日志,并开始准备降级结果。这样即使最后真的超时,也能快速返回一个可用的降级答案,而不是让用户干等。这个机制我们上线后,用户侧的超时投诉少了很多。
Agent Platform 这条路还很长,超时问题只是其中一课。但这一课让我明白,做平台不能只看功能,还要看它在压力下的表现。功能决定用户会不会来,稳定性决定用户会不会留。