Agent 工具循环调用的解决办法

Agent 工具循环调用的解决办法

目录

最近笔者在做自己的Agent项目,就遇到了工具循环调用的问题。模型陷入了一个奇怪的循环:反复调用相同的工具,参数几乎一样,结果也一样,但它就是停不下来。这个问题的根源在于 AgentLoop 的设计本身。循环的退出条件是"模型不再调用工具",但如果模型一直觉得自己还没做完,循环就不会停。我们需要在循环里加一些机制,让 Agent 在打转的时候能被拉住。

循环回顾

先回顾一下 AgentLoop 的基本结构:

python 复制代码
def agent_loop(query, max_turns=20):
    messages = [{"role": "user", "content": query}]

    for turn in range(max_turns):
        resp = client.messages.create(
            model="claude-sonnet-4-20250514",
            messages=messages,
            tools=tools,
        )
        messages.append({"role": "assistant", "content": resp.content})

        if resp.stop_reason != "tool_use":
            break

        tool_results = []
        for block in resp.content:
            if block.type == "tool_use":
                output = dispatch_tool(block.name, block.input)
                tool_results.append(make_tool_result(block.id, output))

        messages.append({"role": "user", "content": tool_results})
    else:
        print("达到最大轮次,停止执行")

max_turns=20 是最基础的保护。但这个保护太粗暴了:20 轮正常推理可能刚好够用,而一个死循环可能在第 5 轮就已经开始了。等到第 20 轮才停,中间 15 轮的 Token 全浪费了。所以我们需要更精细的检测手段。

为什么会陷入循环调用

模型陷入循环的原因通常有几种:

工具返回了模型无法处理的信息。 比如执行一个命令报了权限错误,模型决定换一种写法再试,结果还是权限错误。它看到了错误信息,但没有"理解"这是权限问题而非写法问题,于是不断换写法重试。

任务本身没有终止信号。 比如"帮我优化这段代码",模型改了一版,觉得还能再改,于是继续改。每一轮它都觉得自己在"优化",但其实改动越来越小,甚至在来回改。

上下文太长导致早期信息被忽略。 模型在第 3 轮已经确认文件存在,但到了第 15 轮,这条信息被推到了上下文的边缘,它又去检查了一遍文件是否存在。

这些情况的共同点是:模型每一轮都认为自己在做"正确的事",但从外部视角看,它其实是在原地踏步。

三种检测手段

重复调用检测

最直接的检测方式:记录最近几次工具调用的签名,如果连续几次完全相同,说明 Agent 卡住了。

python 复制代码
import hashlib
from collections import deque

class LoopDetector:
    def __init__(self, window=3):
        self.recent_calls = deque(maxlen=window)

    def record(self, tool_name: str, tool_input: dict) -> bool:
        """记录一次工具调用,返回是否检测到循环"""
        # 把工具名和参数序列化后取哈希,作为调用的签名
        call_str = f"{tool_name}:{sorted(tool_input.items())}"
        signature = hashlib.md5(call_str.encode()).hexdigest()
        self.recent_calls.append(signature)

        # 窗口填满后,检查是否全部相同
        if len(self.recent_calls) == self.recent_calls.maxlen:
            if len(set(self.recent_calls)) == 1:
                return True
        return False

window=3 表示检测最近 3 次调用。如果 3 次调用的签名完全一样,判定为循环。这个窗口大小可以根据场景调整:太小容易误判(有些任务确实需要连续调用同一个工具),太大则浪费几轮才检测到。

把它集成到 AgentLoop 里:

python 复制代码
def agent_loop(query, max_turns=20):
    messages = [{"role": "user", "content": query}]
    detector = LoopDetector(window=3)

    for turn in range(max_turns):
        resp = client.messages.create(
            model="claude-sonnet-4-20250514",
            messages=messages,
            tools=tools,
        )
        messages.append({"role": "assistant", "content": resp.content})

        if resp.stop_reason != "tool_use":
            break

        tool_results = []
        for block in resp.content:
            if block.type == "tool_use":
                output = dispatch_tool(block.name, block.input)
                tool_results.append(make_tool_result(block.id, output))

                # 记录调用并检测循环
                if detector.record(block.name, block.input):
                    tool_results.append(make_tool_result(
                        block.id,
                        "检测到重复调用,请换一种方式处理,或者直接告诉用户当前遇到的问题。"
                    ))

        messages.append({"role": "user", "content": tool_results})

检测到循环后,不是直接中断,而是给模型一个提示:"你已经连续三次做了同样的事,换个思路或者告诉用户。"这样模型有机会自行调整,比直接杀掉循环更温和。

相似调用检测

有时候参数不完全一样,但非常接近。比如模型第一次执行 cat /tmp/test.txt,第二次执行 cat /tmp/test.txt (末尾多个空格),签名不同但本质一样。

可以加一层模糊匹配:

python 复制代码
class FuzzyLoopDetector:
    def __init__(self, window=3, similarity_threshold=0.9):
        self.recent_calls = deque(maxlen=window)

    def _normalize(self, tool_name: str, tool_input: dict) -> str:
        """标准化调用参数,去掉首尾空格等干扰"""
        command = tool_input.get("command", "")
        if isinstance(command, str):
            command = command.strip()
        return f"{tool_name}:{command}"

    def record(self, tool_name: str, tool_input: dict) -> bool:
        normalized = self._normalize(tool_name, tool_input)
        self.recent_calls.append(normalized)

        if len(self.recent_calls) == self.recent_calls.maxlen:
            if len(set(self.recent_calls)) == 1:
                return True
        return False

标准化之后再去比较,可以过滤掉空格、大小写这类微小差异。

累计调用次数检测

还有一种情况:模型没有连续调用同一个工具,但它在整个任务中反复调用了十几次同一个工具。这不算"连续重复",但同样说明有问题。

python 复制代码
class CallCounter:
    def __init__(self, max_calls_per_tool=10):
        self.counts = {}
        self.max_calls = max_calls_per_tool

    def record(self, tool_name: str, tool_input: dict) -> bool:
        """记录调用次数,返回是否超过阈值"""
        self.counts[tool_name] = self.counts.get(tool_name, 0) + 1
        if self.counts[tool_name] > self.max_calls:
            return True
        return False

这个检测粒度更粗,但能兜底一些"非连续但高频"的循环。

三种阻断策略

检测到循环之后,下一步是决定怎么办。直接中断是最简单的,但是会直接截断前端AI消息的输出,用户看到的是一个输出一半的消息,体验感极差。

软阻断:提示模型

前面代码里已经展示了这个思路。检测到循环后,往工具结果里注入一条提示,告诉模型它在重复。模型收到提示后,有机会改变策略。

python 复制代码
if detector.record(block.name, block.input):
    output = (
        f"原始输出: {output}\n\n"
        f"---\n"
        f"[系统提示] 你已经连续多次执行相同的工具调用,结果没有变化。"
        f"请重新评估当前情况,考虑换一种方式,或者向用户说明遇到的困难。"
    )

这种方式的好处是不中断 Agent 的推理流程,给它一个自我修正的机会。

硬阻断:强制退出

如果软阻断之后模型还是继续循环,就需要硬阻断了。

python 复制代码
def agent_loop(query, max_turns=20):
    messages = [{"role": "user", "content": query}]
    detector = LoopDetector(window=3)
    soft_block_count = 0
    MAX_SOFT_BLOCKS = 2  # 最多容忍两次软阻断

    for turn in range(max_turns):
        resp = client.messages.create(
            model="claude-sonnet-4-20250514",
            messages=messages,
            tools=tools,
        )
        messages.append({"role": "assistant", "content": resp.content})

        if resp.stop_reason != "tool_use":
            break

        tool_results = []
        for block in resp.content:
            if block.type == "tool_use":
                output = dispatch_tool(block.name, block.input)

                if detector.record(block.name, block.input):
                    soft_block_count += 1
                    if soft_block_count >= MAX_SOFT_BLOCKS:
                        return "任务无法完成:Agent 陷入重复调用循环,请检查任务描述或提供更多上下文。"
                    output = (
                        f"原始输出: {output}\n\n"
                        f"[系统提示] 检测到重复调用,请调整策略。"
                    )

                tool_results.append(make_tool_result(block.id, output))

        messages.append({"role": "user", "content": tool_results})

先软阻断两次,给模型自我修正的机会。如果两次之后还在循环,直接退出并告知用户。

用 Hook 拦截

如果你的 Agent 框架支持 Hook,循环检测可以做成一个 PreToolUse Hook,和主循环逻辑解耦:

python 复制代码
# 循环检测 Hook
detector = LoopDetector(window=3)

def detect_loop(tool_name, tool_input):
    if detector.record(tool_name, tool_input):
        return "检测到重复调用,请换一种方式处理。"
    return None

register_hook("PreToolUse", detect_loop)

Hook 的好处是可插拔。开发阶段开着循环检测,测试阶段可以关掉,不影响主循环代码。而且你可以把检测逻辑做得很复杂(比如加上语义相似度判断),主循环完全不需要改动。

和其他机制的配合

循环检测不是孤立的,它需要和其他保护机制配合使用。

机制 作用时机 粒度 优点 缺点
max_turns 循环外 简单可靠 浪费中间轮次
重复调用检测 每次工具调用 及时发现 窗口大小需调参
累计次数检测 每次工具调用 兜底高频调用 不区分正常/异常
Hook 拦截 每次工具调用 可插拔、可组合 依赖框架支持

实际项目中,这几个机制通常是叠加使用的。max_turns 是最后的兜底,重复调用检测是主要手段,Hook 负责把检测逻辑和主循环解耦。

更好的做法

循环检测是事后补救。更好的做法是从源头减少循环发生的概率。

优化 System Prompt。 在系统指令里明确告诉模型:如果连续两次尝试同一个操作结果不变,应该停下来分析原因,而不是继续尝试。这类指引能从源头降低循环的概率。

控制工具返回的信息量。 工具返回的结果越清晰,模型越容易判断下一步该怎么做。比如一个命令执行失败,返回的信息里如果包含"权限不足",模型就知道换个思路;如果只返回一个模糊的"执行失败",模型可能会反复换写法重试。

上下文压缩。 对话太长时,早期的关键信息可能被淹没。定期压缩上下文,让模型始终能看到最重要的信息,减少因为"忘了之前的结论"而重复操作的情况。

小结

Agent 工具循环调用的本质是:模型每一轮都认为自己在做正确的事,但从外部看它在原地踏步。解决方案是在循环里加一层"旁观者",通过签名比对检测重复调用,先提示模型自行调整,不行就强制中断。这个旁观者可以用独立模块实现,也可以做成 Hook 插入循环。