跳到主要内容

Agent 链中的截止时间传播:第三跳时你的 p95 SLO 发生了什么

· 阅读需 11 分钟
Tian Pan
Software Engineer

大多数构建多步 agent 管道的工程师会在第一次生产故障后约两周发现同一个问题:他们在 API 网关设置了 5 秒超时,但 agent 管道有四跳,而整个系统的行为就好像根本没有超时一样。第三跳的 agent 不知道上游调用方三秒前就已放弃等待,它继续运行、继续调用工具、继续生成 token——而用户早已离开。

这不是配置错误,而是结构性问题。延迟约束默认不会跨 agent 边界传播,主流编排框架也没有任何一个让截止时间传播变得容易。结果是一类看起来像延迟问题、实则是上下文传播问题的故障。

在你写第一行代码之前就会击垮 SLO 的数学原理

从最简单的情形开始:一个有五个顺序 agent 跳的管道。每跳的 p95 延迟为 1 秒。整个管道的 p95 延迟是多少?

直觉上的答案是 5 秒。正确答案比这更糟。

如果每跳独立满足其 p95 目标的概率为 95%,那么五跳同时满足目标的概率为 0.95^5 ≈ 0.77。这意味着大约四分之一的请求会超出单跳 p95——不是因为某个服务慢,而是因为概率相乘。

这种效应在尾部会进一步放大。在 p99 时,单跳 99% 变成五跳 (0.99)^5 ≈ 0.95——意味着你的五跳管道的 p99 等于单跳的 p95。如果你有 p99 SLO 承诺,在测量任何东西之前就已陷入麻烦。

真实生产管道会通过三种方式使情况更糟:

  • 各跳并不独立。共享 GPU 基础设施、速率限制队列和上游 API 依赖会造成关联性减速,同时影响所有跳。
  • 每跳通常引入协调开销:序列化、上下文组装、工具调度和响应解析,这些都不会体现在模型推理延迟中。
  • 单跳重试会消耗下游跳已无法使用的时间——而大多数框架实现单跳重试逻辑时完全不感知完整请求剩余的预算。

实际结果:在单元测试中看起来很快的管道,只有在负载下所有这些效应叠加时,才会暴露其真实 p95。

为什么截止时间比超时更适合分布式系统

这里的术语很重要。超时是一个持续时间:"等待不超过 3 秒。"截止时间是一个绝对时间戳:"此请求必须在 14:32:07.500Z 之前完成。"当请求跨越服务边界时,两者的区别虽然微妙但至关重要。

设想一个用户请求在 T=0 时进入系统,SLO 为 5 秒。编排器花了 800ms 规划一次工具调用。如果你向第一个 agent 传入 5 秒超时,该 agent 有完整的 5 秒——但你只剩 4.2 秒了。传递超时并不考虑已经过去的时间。

如果你传入截止时间——T+5s 的绝对时间戳——每个下游服务可以通过从截止时间减去当前时间来精确计算还剩多少时间。这正是 gRPC 的做法。grpc-timeout 头携带一个表示"还剩多少时间"的值,在每跳根据已消耗时间重新计算。当截止时间到期时,链中的每个服务都可以独立检测并取消自己的工作。

这是 agent 管道正确的心智模型。截止时间属于请求,而不是跳。

编排框架实际上如何处理这个问题(剧透:很差)

LangChain 使用 asyncio.wait_for() 支持单工具超时,LangGraph 通过环境变量提供后台任务超时配置。两个框架都不会在链中传播截止时间。当 LangGraph 中的工具超时时,超时仅适用于该工具调用本身。链不知道还剩多少总预算;它会以全新超时调度另一次工具调用,就好像时钟重置了一样。

CrewAI 在 Agent 类上公开了 max_execution_time 参数。内部使用 future.cancel()——但这只能取消尚未启动的任务。已在运行的 LLM 调用无论 future.cancel() 返回什么,都会继续完成,留下孤立线程在 agent "取消"后继续消耗 token。多个已开放的问题记录了这一行为导致的资源泄漏和孤立线程。

OpenAI Agents SDK 将 SSE 读取超时与 HTTP 操作超时分开,而 post_writer 方法中有一个已记录的 bug,导致超时参数无法传播到实际维护流式连接的组件。结果是超时配置看起来正确,但实际上不会按预期触发。

Temporal 被一些团队用于持久 agent 编排,有更微妙的故障模式。当一个长时间运行的 LLM 推理活动在心跳超时内没有发送心跳时,Temporal 会将该活动标记为超时并在另一个 worker 上重试——而原始活动仍在运行。现在有两个并发 LLM 调用产生相同的输出,状态变更发生了两次。修复需要在推理期间显式调用 activity.RecordHeartbeat(),而默认情况下没有任何框架包装器会这样做。

共同主题:每个框架将超时实现为本地关注点,没有任何机制让请求范围的截止时间传播整个链并在每个活跃跳触发取消。

没有人会为之预算的 Token 浪费问题

被放弃的请求代价高昂。点击返回按钮或刷新页面的用户,并不会取消你的 agent 管道正在为他们做的工作。管道运行到完成,生成完整响应,然后丢弃结果——但你为每个 token 付出了代价。

一个有据可查的生产案例:43% 的月度 LLM API 支出——2,800 美元中的 1,200 多美元——被追踪到了在前端断开连接后仍运行到完成的请求。促成因素是工具调用重试循环在前端断开后仍继续调用昂贵工具,以及在重试中无边界增长的上下文窗口,使每次后续尝试比上一次更昂贵。

重试模式尤其危险。如果工具调用失败,大多数框架会重试它。每次重试都会重新发送完整的累积上下文加上新的工具调用——在多跳管道中,第二跳的重试消耗了第三跳无法收回的时间。下游效应是重试级联:第二跳重试耗尽预算,第三跳得到截断的窗口,第三跳失败,第三跳重试,当管道自然终止时,用户超时早在几秒前就已触发。

数学很快就会叠加。一个每跳使用默认指数退避重试两次的五跳管道,在单个失败请求上可以消耗预期 token 预算的 3 倍。乘以超时请求的比例,总成本影响是可测量的。

修复它的架构

加载中…
References:Let's stay in touch and Follow me for more thoughts and updates