AI Agent为什么反复调用同一个工具?从调用指纹、幂等键到终止条件完整排查

文章摘要

Agent连续调用同一工具,是生产环境中最常见也最烧钱的故障之一。根因可能来自Observation没有进入上下文、工具结果缺少状态、模型误判失败、调用缺少幂等控制,或者终止条件不明确。本文提供一套从日志定位、调用指纹、重复检测、幂等键、状态摘要到最大步数控制的完整治理方案。

一、典型故障现象

用户提出:

查询订单A1001并告诉我是否已经发货。

Agent轨迹却变成:

Step 1:调用query_order(A1001)
Step 2:调用query_order(A1001)
Step 3:调用query_order(A1001)
Step 4:调用query_order(A1001)
……

如果工具只是查询,问题主要是延迟和成本。如果工具具有副作用,例如:

  • 创建订单;
  • 发送短信;
  • 提交审批;
  • 扣减库存;
  • 发起支付;

重复调用就可能造成真实业务事故。

二、先区分“合理重试”和“无意义重复”

以下情况属于合理重试:

  • 网络超时;
  • 服务返回临时错误;
  • 限流后等待;
  • 工具明确声明操作未完成;
  • 第一次调用结果无法解析。

以下情况通常属于无意义重复:

  • 参数完全相同;
  • 上一次已经成功;
  • Observation已返回完整数据;
  • 没有新增信息;
  • 重复调用不会改变结果。

建议给每次工具调用生成指纹:

import hashlib
import json

def build_call_fingerprint(tool_name: str, arguments: dict) -> str:
    normalized = json.dumps(
        arguments,
        ensure_ascii=False,
        sort_keys=True,
        separators=(",", ":")
    )
    raw = f"{tool_name}:{normalized}"
    return hashlib.sha256(raw.encode("utf-8")).hexdigest()

相同工具、相同标准化参数会得到相同指纹。

三、根因一:工具结果没有进入下一轮上下文

错误流程:

模型生成工具调用
→ 系统执行工具
→ 结果只写日志
→ 下一轮模型不知道已经执行

正确流程:

模型生成工具调用
→ 系统执行工具
→ 将结果写入消息历史
→ 模型根据Observation继续判断

示例消息:

{
  "role": "tool",
  "tool_call_id": "call_123",
  "name": "query_order",
  "content": "{\"orderId\":\"A1001\",\"status\":\"SHIPPED\"}"
}

要检查:

  • tool_call_id是否匹配;
  • 工具结果是否被截断;
  • JSON是否有效;
  • 下一轮是否带上了这条Tool Message;
  • 上下文裁剪是否误删最近Observation。

四、根因二:工具返回值缺少明确状态

不推荐返回:

处理完成

推荐返回结构化结果:

{
  "success": true,
  "code": "ORDER_FOUND",
  "data": {
    "orderId": "A1001",
    "status": "SHIPPED",
    "trackingNo": "SF123456"
  },
  "retryable": false,
  "message": "订单已发货"
}

关键字段包括:

  • success:是否成功;
  • code:机器可识别状态;
  • retryable:是否允许重试;
  • data:完成任务所需数据;
  • message:给模型的解释。

五、根因三:System Prompt没有定义终止规则

Agent Prompt不应只写:

你可以使用工具完成任务。

还应明确:

当工具已经返回完成用户问题所需的信息时,必须停止调用工具并直接回答。

不得使用相同参数重复调用同一工具,除非上一次结果明确标记retryable=true。

如果连续两次调用没有获得新信息,必须停止并说明无法继续。

模型提示不是硬约束,但可以降低重复概率。

六、增加重复调用检测器

from dataclasses import dataclass, field

@dataclass
class ToolCallGuard:
    max_same_call: int = 1
    counts: dict[str, int] = field(default_factory=dict)

    def before_call(self, tool_name: str, arguments: dict) -> None:
        fingerprint = build_call_fingerprint(tool_name, arguments)
        count = self.counts.get(fingerprint, 0)

        if count >= self.max_same_call:
            raise RuntimeError(
                f"检测到重复工具调用:{tool_name},参数={arguments}"
            )

        self.counts[fingerprint] = count + 1

执行前调用:

guard.before_call(tool_name, arguments)
result = tool_registry.execute(tool_name, arguments)

生产环境不要只抛异常,还可以返回受控Observation:

{
  "success": false,
  "code": "DUPLICATE_TOOL_CALL_BLOCKED",
  "retryable": false,
  "message": "相同参数的工具已经成功调用,不允许重复执行"
}

七、有副作用的工具必须使用幂等键

def create_ticket(
    user_id: str,
    subject: str,
    idempotency_key: str
) -> dict:
    existing = ticket_repository.find_by_idempotency_key(
        idempotency_key
    )

    if existing:
        return {
            "success": True,
            "code": "ALREADY_CREATED",
            "data": existing
        }

    ticket = ticket_repository.create(
        user_id=user_id,
        subject=subject,
        idempotency_key=idempotency_key
    )

    return {
        "success": True,
        "code": "CREATED",
        "data": ticket
    }

幂等键应由确定性代码生成,不建议完全交给模型:

会话ID
+业务动作
+业务对象
+用户确认版本

八、空结果不等于工具失败

推荐返回:

{
  "success": true,
  "code": "NO_DATA",
  "retryable": false,
  "data": [],
  "message": "查询执行成功,但没有匹配记录"
}

模型由此知道应该向用户解释“未查询到”,而不是继续调用。

九、避免多层重复重试

常见结构:

HTTP客户端重试3次
工具执行器重试3次
Agent再次决定重试3次

理论上一次故障可能触发:

3 × 3 × 3 = 27次请求

必须明确各层职责:

层级 处理内容
HTTP层 短暂网络错误
工具层 业务可重试错误
Agent层 根据任务语义决定是否换方案

统一预算:

from dataclasses import dataclass

@dataclass
class ExecutionBudget:
    max_steps: int = 12
    max_tool_calls: int = 8
    max_retries: int = 3

十、增加“是否获得新信息”判断

工具结果可以包含:

result_version
data_hash
updated_at
def is_new_observation(previous: dict | None, current: dict) -> bool:
    if previous is None:
        return True

    return previous.get("data_hash") != current.get("data_hash")

连续没有新增信息时终止:

if no_progress_count >= 2:
    return {
        "status": "STOPPED_NO_PROGRESS",
        "message": "连续两次工具调用没有获得新信息"
    }

十一、最大步数是最后一道保险

for step in range(MAX_STEPS):
    action = agent.next_action(state)

    if action.type == "final":
        return action.answer

    execute_tool(action)

raise RuntimeError("Agent超过最大执行步数")

不要写:

while True:
    ...

十二、建议记录哪些日志

每次调用至少记录:

trace_id
conversation_id
step_no
tool_name
normalized_arguments
call_fingerprint
idempotency_key
tool_status
retryable
duration_ms
data_hash
token_usage

定位重复调用时,先画出:

模型决策
→ 工具参数
→ 工具结果
→ 下一轮输入

不要只看最终报错。

十三、完整治理顺序

1. 确认Observation进入上下文
2. 规范工具返回结构
3. 明确Prompt终止规则
4. 生成调用指纹
5. 阻止相同调用重复执行
6. 副作用工具增加幂等键
7. 区分空结果与调用失败
8. 统一重试预算
9. 检测连续无进展
10. 设置最大执行步数

总结

Agent反复调用同一工具,通常不是单一模型问题,而是:

上下文
+工具协议
+状态管理
+重试策略
+幂等控制

共同失效的结果。

模型层可以通过Prompt降低概率,但生产级系统必须用确定性代码阻止重复调用和副作用。