Appearance
超时控制:三层超时一个都不能少
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: 60s5.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. 生产避坑
- 单次超时不要终止整个任务。把它转成 Observation 让模型继续——它很可能基于已有信息就能给出答案。直接终止等于前面花的钱全白费。这是「降级」和「失败」的区别。
- 总超时要按任务类型分别配置。所有任务共用一个值会导致:简单任务的超时太长(浪费资源),复杂任务太短(频繁失败)。用一个
Map<任务类型, Duration>的配置表。 - 超时不等于没花钱。服务端可能已经完成了生成。做成本对账时必须把这一块单独标注为「不确定」,否则月底永远对不上账,且排查时无从下手。
8. 延伸与锚点
- 思考题:超时后重试,会不会让成本翻倍、甚至把一次慢请求变成两次?(答案在下一课时:重试与 Token 放大陷阱)
- 代码锚点:
git checkout ch03-07-timeout - 下一课时:03-08 重试与 Token 放大陷阱
- 对应课件:L03-07 超时控制