Skip to content

超时控制:三层超时一个都不能少 ​

1. 本节产出 ​

三层超时机制:单次 LLM 调用超时、单个工具执行超时、整个 Agent 运行总超时。每一层超时都有明确的降级行为,且超时后已消耗的 Token 可查(不会出现「不知道花了多少钱」)。

2. 前置依赖 ​

3. 为什么 Agent 的超时比普通服务复杂 ​

普通服务:一个请求一个超时值就够了。

Agent 的问题在于一个用户请求内部包含多次外部调用:

用户点一次「帮我处理这笔退货」
   └─ 内部:
      模型调用 1(2s)
      工具执行(查订单 0.3s)
      模型调用 2(3s)
      工具执行(查物流 0.5s)
      模型调用 3(4s)
      ...
      ─────────────────
      总计可能 60s+

只设一个总超时的问题:超时发生时你不知道是哪一环慢,也无法做局部降级(比如「工具慢就跳过这个工具,继续用已有信息」)。

只设单次超时的问题:每次都不超时,但累积起来很久,用户等不了,且预算早已超支。

所以必须三层都有。

4. 核心原理 ​

4.1 三层超时的取值与行为 ​

层典型取值超时后行为
单次 LLM 调用30~60s重试一次;再失败则该轮以「模型无响应」作为 Observation
单次工具执行5~30s(按工具定)返回「工具超时」文本,让模型换方式
整个运行3~10 分钟终止任务,返回已完成部分 + 过程记录

关键设计:下面两层的超时不应该终止整个任务,而是转成 Observation 让模型继续。这才是「优雅降级」。

4.2 超时后的成本可见性 ​

问题:超时了,那这次调用到底花了多少钱?

答案:多数情况下不知道(响应没回来,没有 usage)
处理:
  1. 用输入 Token 的估算值记账(输出按 0 记)
  2. 标记该笔为「不确定」,在报表里单独标注
  3. 如果厂商支持异步查询用量,事后校准

这一点在成本治理时必须说清:超时请求不是免费的,服务端可能已经生成完了。账单对不上时,这是首要排查点(见 02-18)。

4.3 超时与重试的关系 ​

单次超时 → 重试一次(可能有效,因为模型响应有随机性)
         → 再超时 → 不重试了,转降级

Agent 场景下重试要更保守:因为一次重试不只是多一次调用,还会让整个任务的耗时和成本翻倍。经验:Agent 内的单次调用最多重试 1 次,不要像普通服务那样重试 3 次。

4.4 总超时的设置依据 ​

总超时 = 预期轮次 × 单轮平均耗时 × 安全系数(1.5~2)

例:预期 8 轮 × 4s × 2 = 64s → 设 90s

要按任务类型分别设:简单查询任务 60s,复杂调研任务 5 分钟。用一个配置表按任务类型映射,所有任务共用一个值是偷懒。

5. 代码走查 ​

5.1 配置 ​

yaml
agent:
  timeout:
    llm-call: 45s         # 单次模型调用
    tool-default: 15s     # 工具默认
    total: 180s           # 整个运行
  tools:
    queryOrder:
      timeout: 8s         # 特定工具覆盖
    exportReport:
      timeout: 60s

5.2 LLM 调用超时 ​

java
// src/main/java/com/example/harness/guard/TimeoutGuard.java
@Component
public class TimeoutGuard {

    private final Duration llmTimeout;

    public String callWithTimeout(Supplier<String> llmCall) {
        try {
            return CompletableFuture.supplyAsync(llmCall)
                    .get(llmTimeout.toMillis(), TimeUnit.MILLISECONDS);
        } catch (TimeoutException e) {
            // 关键:超时不抛给上层,转成可继续的信号
            log.warn("LLM 调用超时 {}ms", llmTimeout.toMillis());
            Metrics.recordTimeout("llm");
            return null;                    // 由调用方转成 Observation
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

调用超时后返回 null 而不是抛异常,是为了让循环能继续——把「模型超时」作为一个 Observation 告诉模型,它可能会选择结束任务。

5.3 工具超时 ​

java
// src/main/java/com/example/harness/tool/SafeToolExecutor.java
@Component
public class SafeToolExecutor {

    public ToolResult execute(ToolMeta meta, String toolName, Map<String, Object> args) {
        Duration timeout = meta.timeout();

        try {
            String out = CompletableFuture
                    .supplyAsync(() -> invoke(toolName, args))
                    .get(timeout.toMillis(), TimeUnit.MILLISECONDS);

            return ToolResult.ok(truncate(out));
        } catch (TimeoutException e) {
            log.warn("工具超时:{} {}ms", toolName, timeout.toMillis());
            // 返回「可行动」的信息,让模型知道该换方式
            return ToolResult.fail(
                    "TIMEOUT:工具 %s 执行超过 %d 秒未完成。请改用其他工具,或基于已有信息继续。"
                            .formatted(toolName, timeout.toSeconds()));
        } catch (Exception e) {
            return ToolResult.fail("ERROR:" + rootMessage(e));
        }
    }
}

5.4 总超时 ​

java
// src/main/java/com/example/harness/agent/TimeoutAwareRunner.java
@Service
public class TimeoutAwareRunner {

    public AgentResult run(String runId, String goal, Duration totalTimeout) {
        Instant deadline = Instant.now().plus(totalTimeout);

        for (int i = 1; i <= maxIterations; i++) {
            // 每轮开始前检查总时限
            if (Instant.now().isAfter(deadline)) {
                log.warn("运行 {} 达到总超时 {}s,已完成 {} 轮",
                        runId, totalTimeout.toSeconds(), i - 1);
                return AgentResult.timeout(partialAnswer(i - 1));
            }

            Duration remaining = Duration.between(Instant.now(), deadline);
            // 剩余时间不足一轮时不再发起新调用
            if (remaining.minus(Duration.ofSeconds(20)).isNegative()) {
                return AgentResult.timeout(partialAnswer(i - 1));
            }

            ...
        }
    }
}

「剩余时间不足就不再发起新调用」这个判断很重要:否则你会在总超时前一刻发起一次 45s 的模型调用,然后被硬中断——既浪费钱又拿不到结果。

5.5 超时指标 ​

java
meters.counter("agent.timeout", "layer", "llm").increment();
meters.counter("agent.timeout", "layer", "tool").increment();
meters.counter("agent.timeout", "layer", "total").increment();
meters.timer("agent.run.duration").record(...);

6. 跑起来 ​

bash
git checkout ch03-07-timeout
mvn -q test -Dtest=TimeoutGuardTest

用可配置的故障注入验证三层:

bash
# 1. LLM 超时(注入一个 sleep 60s 的假模型,超时设 2s)
curl -X POST http://localhost:8080/api/agent/run \
  -d '{"goal":"测试","fault":"slow-llm"}'
# 期望:单轮超时被记为 Observation,任务继续,最终返回未完成但有过程记录

# 2. 工具超时(注入慢工具)
curl -X POST http://localhost:8080/api/agent/run \
  -d '{"goal":"查订单","fault":"slow-tool"}'
# 期望:Observation 为「TIMEOUT:工具...请改用其他工具」

# 3. 总超时(每轮都慢)
curl -X POST http://localhost:8080/api/agent/run \
  -d '{"goal":"复杂调研","fault":"slow-all"}'
# 期望:到达总超时返回 TIMEOUT 状态 + 已完成部分

# 4. 查指标
curl http://localhost:8080/actuator/prometheus | grep agent_timeout
检查项通过标准
单层超时不中断任务LLM/工具超时后任务继续
总超时生效到达后返回已完成部分,不无限运行
剩余时间判断不发起注定被中断的调用
成本可查超时运行仍记录已用 Token(标记不确定)
指标可见三层超时分别计数

7. 生产避坑 ​

  1. 单次超时不要终止整个任务。把它转成 Observation 让模型继续——它很可能基于已有信息就能给出答案。直接终止等于前面花的钱全白费。这是「降级」和「失败」的区别。
  2. 总超时要按任务类型分别配置。所有任务共用一个值会导致:简单任务的超时太长(浪费资源),复杂任务太短(频繁失败)。用一个 Map<任务类型, Duration> 的配置表。
  3. 超时不等于没花钱。服务端可能已经完成了生成。做成本对账时必须把这一块单独标注为「不确定」,否则月底永远对不上账,且排查时无从下手。

8. 延伸与锚点 ​

  • 思考题:超时后重试,会不会让成本翻倍、甚至把一次慢请求变成两次?(答案在下一课时:重试与 Token 放大陷阱)
  • 代码锚点:git checkout ch03-07-timeout
  • 下一课时:03-08 重试与 Token 放大陷阱
  • 对应课件:L03-07 超时控制