Spring AI遇到429或超时后为什么重复执行工具?重试边界与幂等完整排查
文章摘要
AI接口出现429、超时或连接中断后,开发者通常会增加自动重试。但在Tool Calling场景中,如果重试包裹了整个Agent流程,退款、发送邮件、创建工单、写数据库等工具可能被重复执行。更隐蔽的情况是模型请求超时,但工具其实已经完成;客户端重试后,模型再次发起相同工具调用。本文从模型层、Agent层、工具层和HTTP层四个重试边界出发,给出幂等键、状态机、结果查询和可重试错误分类的完整方案。
一、典型事故
用户说:
给客户创建一个售后工单
执行链路:
模型选择create_ticket
→ 工具创建工单成功
→ 返回模型时连接超时
→ Agent整体自动重试
→ 再次调用create_ticket
→ 创建第二个工单
从用户视角只发了一次请求,系统却产生两个业务对象。
如果工具是:
- 退款;
- 支付;
- 发券;
- 发邮件;
- 删除数据;
- 创建订单;
后果会更严重。
二、为什么“重试一次”会跨越多个层级
一个AI请求可能同时存在:
网关重试
HTTP客户端重试
Spring AI Provider重试
Resilience4j重试
Agent步骤重试
工具SDK重试
消息队列重投
如果每层都重试3次,最坏情况不是3次,而可能是乘法放大。
例如:
网关2次
× 应用3次
× 工具SDK3次
= 18次潜在调用
必须明确每层的职责。
三、四种重试边界
1. 模型调用重试
适合:
- 429;
- 暂时性5xx;
- 连接建立失败;
- 无副作用的模型请求。
风险:
如果模型调用发生在工具执行后,重试可能重新生成工具调用。
2. Agent步骤重试
适合:
- 结构化输出解析失败;
- 计划校验失败;
- 可恢复的推理错误。
风险:
整个步骤可能包含多个工具副作用。
3. 工具调用重试
适合:
- 只读查询;
- 明确幂等写入;
- 服务端支持幂等键。
4. 业务流程重试
适合:
- 有持久化状态机;
- 可以查询当前执行状态;
- 能从检查点继续。
不能简单重新运行整个流程。
四、哪些错误可以自动重试
通常可重试
429 rate_limit_exceeded
502
503
504
连接被拒绝
短暂DNS失败
读超时且确认无副作用
通常不可直接重试
400参数错误
401认证失败
403权限不足
404资源不存在
insufficient_quota
内容安全拒绝
业务校验失败
状态未知
最危险的是:
请求超时
超时只说明客户端没有按时收到结果,并不说明服务端没有执行。
写操作超时后应该:
先查询执行状态
→ 再决定是否重试
五、幂等键必须在模型之外生成
不要让模型自己生成随机幂等键。
模型可能每次重试都生成不同值。
正确做法:
业务请求进入
→ 应用生成operationId
→ 同一个业务动作的所有重试复用
例如:
String idempotencyKey = String.join(
":",
tenantId,
conversationId,
requestId,
"create_ticket"
);
如果一次请求中允许创建多个工单,还要加入业务对象标识或步骤编号。
六、工具服务端如何实现幂等
表结构:
CREATE TABLE tool_idempotency (
idempotency_key VARCHAR(200) PRIMARY KEY,
tool_name VARCHAR(100) NOT NULL,
request_hash VARCHAR(128) NOT NULL,
status VARCHAR(30) NOT NULL,
result_json TEXT,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
状态:
PROCESSING
SUCCEEDED
FAILED_RETRYABLE
FAILED_FINAL
执行流程:
收到请求
→ 插入PROCESSING
→ 已存在则读取状态
→ SUCCEEDED直接返回历史结果
→ PROCESSING返回处理中
→ 可重试失败按规则执行
伪代码:
@Transactional
public ToolResult execute(
String key,
ToolRequest request
) {
Optional existing =
repository.findById(key);
if (existing.isPresent()) {
return restore(existing.get(), request);
}
repository.insertProcessing(
key,
hash(request)
);
try {
ToolResult result = doExecute(request);
repository.markSucceeded(key, result);
return result;
}
catch (RuntimeException ex) {
repository.markFailed(key, ex);
throw ex;
}
}
七、相同幂等键但参数不同怎么办
攻击或代码错误可能发送:
相同key
+不同参数
例如第一次退款100元,第二次使用同一个key退款200元。
服务端必须比较request_hash。
如果不同:
返回409 Conflict
不能把第二次请求当成第一次的成功结果。
八、模型返回的tool_call_id能不能当幂等键
不建议单独使用。
tool_call_id通常只在一次模型响应中唯一。
Agent整体重试后,模型可能生成新的ID。
更稳定的是:
业务operationId
+工具名
+步骤ID
可以把tool_call_id作为追踪字段,而不是唯一业务幂等依据。
九、重试应该包裹哪一层
错误:
@Retry(name = "ai")
public String runAgent(String message) {
return agent.run(message);
}
如果agent.run()内部执行写工具,整个流程会重跑。
更安全:
模型只读推理调用
→ 可重试
工具写操作
→ 幂等执行
最终回答生成
→ 可重试,但复用工具结果
将流程持久化:
PLANNED
TOOL_EXECUTED
ANSWER_GENERATING
COMPLETED
最终回答失败后,从TOOL_EXECUTED继续,不再重复执行工具。
十、检查点设计
public record AgentCheckpoint(
String executionId,
String state,
String toolName,
String toolResultLocation,
int modelAttempt,
int toolAttempt
) {
}
执行:
模型选工具
→ 保存计划
→ 工具执行
→ 保存结果
→ 模型生成回答
任何一步失败都从最近检查点恢复。
十一、只读工具是否可以随便重试
只读工具通常风险较低,但仍可能有:
- 外部API计费;
- 强限流;
- 数据查询压力;
- 非稳定快照;
- 重复下载大文件。
建议设置:
最大重试次数
指数退避
随机抖动
总超时
并发限制
十二、指数退避与Jitter
固定间隔:
1秒、1秒、1秒
大量实例会同时重试,造成惊群。
推荐:
1秒
2秒
4秒
并加入随机抖动。
Resilience4j示例:
resilience4j:
retry:
instances:
aiModel:
max-attempts: 3
wait-duration: 1s
enable-exponential-backoff: true
exponential-backoff-multiplier: 2
retry-exceptions:
- java.io.IOException
- java.util.concurrent.TimeoutException
异常列表需要按实际Provider SDK调整。
十三、429的Retry-After要不要遵守
如果响应提供:
Retry-After: 10
应优先遵守。
但还要区分:
rate_limit_exceeded
→ 等待后重试
insufficient_quota
→ 不重试
两者都可能是HTTP 429。
十四、熔断器应该包在哪里
建议在模型Provider适配层设置熔断:
业务Service
→ ModelGateway
→ Circuit Breaker
→ Provider
不要用一个熔断器同时覆盖:
- 模型;
- 向量库;
- 所有工具;
否则其中一个工具失败会关闭整个AI系统。
按依赖隔离:
openai-chat
qdrant-search
order-tool
mail-tool
十五、降级策略
模型不可用:
Sol
→ Terra
→ Luna
→ 规则模板
RAG不可用:
生成回答
→ 降级为关键词搜索结果
写工具不可用:
自动执行
→ 创建待办
→ 转人工
降级不能绕过审批和权限。
十六、需要记录哪些指标
model_retry_count
tool_retry_count
agent_restart_count
idempotency_hit_count
idempotency_conflict_count
unknown_execution_status_count
circuit_breaker_open_count
fallback_model_count
duplicate_business_object_count
重点告警:
同一operationId出现多个业务对象
十七、完整排查清单
□ 是否同时存在多层重试
□ 重试是否包裹整个Agent
□ 写工具是否支持幂等键
□ 相同业务动作是否复用同一个key
□ 是否保存request_hash
□ 超时后是否先查询状态
□ 最终回答失败是否重复执行工具
□ tool_call_id是否被误作唯一幂等键
□ 429是否区分限流与额度不足
□ 熔断器是否按依赖隔离
□ 是否有检查点和状态机
总结
Tool Calling场景中,最大的错误不是“没有重试”,而是:
在错误的边界重试
生产级方案应该做到:
模型调用可重试
+工具写操作幂等
+业务流程有检查点
+超时先查状态
+最终回答复用工具结果
只有把模型推理和业务副作用分开,自动重试才不会变成重复执行。
延伸阅读
如果你正在关注企业级 AI 应用、Spring AI、RAG、Agent 与 MCP 工程化落地,欢迎访问 智元界:
https://www.zyentor.com/
智元界将持续分享可运行的技术实战、架构设计、问题排查与企业应用案例。