跳到主要内容

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 倍。乘以超时请求的比例,总成本影响是可测量的。

修复它的架构

核心变化是将截止时间视为请求上下文的一等属性,而不是各个组件上的超时配置。

在入口点一次性设置截止时间。 你的 API 网关或请求处理器根据 SLO 目标计算绝对截止时间:deadline = now + budget。将这个截止时间——而不是超时持续时间——传递给每个下游调用。

通过每跳传播它。 在 Go 中,context.WithDeadline() 原生处理这个问题:将上下文传递给每个下游调用,当截止时间到期时取消会自动流动。在 Python 中,你需要显式传播截止时间值,并在每跳用 remaining = deadline - time.time() 重新计算剩余时间。在基于 HTTP 的 agent 框架中,添加每个 agent 读取并遵守的 X-Request-Deadline 头。

在昂贵操作之前检查剩余时间。 在开始长时间运行的工具调用或 LLM 推理之前,检查是否有足够的预算来合理完成它。如果 remaining < estimated_cost,以"截止时间超出"错误快速失败,而不是开始你无法完成的工作。这是 gRPC 服务内部实现的模式,它能防止消耗 token 而不产生有用输出的已开始但注定失败的工作级联。

实现结构化取消。 当截止时间触发时,取消必须向下传播,而不仅仅是停止等待结果。这意味着:

  • HTTP 客户端:将 AbortSignal 转发给底层 fetch 调用,使连接终止
  • LLM API 调用:将中止信号传递给流式 API;大多数提供商会在中间停止生成
  • 后台工具调用:维护正在进行操作的注册表并显式取消它们

为开销预留预算。 来自 SLO 优先开发的有用启发法:为计划操作分配不超过总预算的 80%,保留 20% 作为协调开销、序列化和错误处理的储备。对于有三个等权重 agent 跳的 5 秒 SLO,每跳获得大约 1.3 秒,而不是 1.67 秒。

预算感知管道的样子

一个具体示例,SLO 为 4 秒,三个跳:

入口:记录 deadline = now + 4000ms

第 1 跳(规划器):budget = min(1500ms, remaining)
→ 执行,记录已用时间,传递剩余截止时间

第 2 跳(检索器):remaining = deadline - now ≈ 2400ms,budget = min(1500ms, remaining)
→ 如果 remaining < 300ms,立即返回 deadline_exceeded
→ 执行,记录已用时间

第 3 跳(生成器):remaining = deadline - now ≈ 900ms
→ 如果 remaining < 500ms(最小可行生成时间),返回 deadline_exceeded
→ 根据剩余时间估算约束 max_tokens

在每跳,发生两件事:根据最小可行执行窗口检查截止时间,并将剩余预算向前传递。第三跳不会获得全新的 4 秒窗口——它得到的是链条没有花掉的部分。

这种结构还会暴露真实的成本数据。当各跳在同一阶段持续返回 deadline_exceeded 时,你就知道预算去哪了。当工具调用始终消耗跳预算的 80% 时,那就是优化工作应该集中的地方。

执行它所需的监控

没有可观察性的截止时间传播是不完整的。值得追踪的信号:

  • 跳级延迟分布(不仅仅是管道总计)。单跳的 p50/p95/p99 帮助你了解哪些阶段是预算消耗者。
  • 每跳的截止时间超出率。如果第三跳在 15% 的请求上返回 deadline_exceeded,说明管道的上游跳运行时间过长——而不是第三跳速度慢。
  • 被放弃请求的 token 支出。将请求完成状态与 token 使用量关联,量化截止时间到期后仍运行的请求的成本。
  • 取消传播成功率。确认当前端客户端断开连接时,你的后端管道实际上会取消。许多团队假设这会发生;很少有人验证过。

缺少这些数据是截止时间问题通常以"成本高于预期"或"某些用户看到响应慢"而不是作为结构性故障出现的原因。

默认值是错误的

大多数 agent 编排框架默认没有截止时间传播。每跳从头开始获得其配置的超时。用户可见的 SLO 存在于 API 网关配置中,在整个栈的其他地方都不存在。

对于简单的单跳 agent 调用,这可能没问题。对于有超过两跳的管道、任何层的重试,或消耗大量延迟预算的工具调用,默认值是保证走向尾部延迟爆炸和意外 token 成本的路径。

修复并不复杂——这是一个纪律问题。截止时间必须被视为请求的属性,而不是服务的属性。一旦这个心智模型到位,实现就自然地遵循你的栈已经使用的分布式上下文机制。

从监控开始:添加单跳延迟追踪并将其与管道结果关联。数据会准确告诉你预算去哪了。然后在入口点添加截止时间传播并向前传递。那些注定失败的工作级联——它们是大多数 agent 超时故障的特征——将会停止,随之而来的 token 成本也会消失。

References:Let's stay in touch and Follow me for more thoughts and updates