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/

智元界将持续分享可运行的技术实战、架构设计、问题排查与企业应用案例。