[转][译] 从零开始构建 OpenClaw — 第四部分(工具循环检测)

4 阅读20分钟

[转][译] 从零开始构建 OpenClaw — 第一部分(智能体核心)

[转][译] 从零开始构建 OpenClaw — 第二部分(技能插件系统)

[转][译] 从零开始构建 OpenClaw — 第三部分(元技能)

[转][译] 从零开始构建 OpenClaw — 第四部分(工具循环检测)

[转][译] 从零开始构建 OpenClaw — 第五部分(对话压缩)

原文:Building Openclaw from Scratch — Part 4 (Tool Loop Detection)

在这个部分,我们将添加一个断路器,用于检测 AI 智能体是否陷入死循环并阻止它,同时添加一个/verbose命令以实现实时可观察性——大约需要 150 行 TypeScript 代码。

image.png

第1部分,我们构建了 openclaw-mini——一个具有工具调用 REPL、多提供商 LLM 支持、网络工具和上下文感知系统提示词的终端 AI 编码智能体。在第2部分,我们添加了一个技能插件系统,允许任何人使用 Markdown 文件教会智能体新的工作流程。在第3部分,我们构建了一个元技能,允许智能体在会话中途为自己创建新的技能,并支持热重载,以便它们可以立即使用。

在本篇帖子中,我们将解决一个问题,这个问题只有在你构建了一个智能体并使用它一段时间后才会发现:智能体会陷入死循环。

它调用同一个工具。得到相同的结果。再次调用它。相同的结果。再次。二十次。三十次。消耗着 API 令牌,毫无进展,完全意识不到自己陷入了死循环。

这并不是我们代码中的错误。这是基于 LLM 的智能体与工具交互时的一种已知失效模式——虽然现代模型中较为罕见,但确实足够真实,以至于你需要一个安全网。而修复方案来自你可能意想不到的地方:分布式系统。

迄今为止我们构建的内容:基础

如果你跟上了第 1-3 部分,你构建了一个终端智能体,它包含:

一个调用工具的 REPL 循环——用户输入提示词,LLM 调用工具(读取、写入、编辑、bash、网络搜索、网络获取),结果反馈回来,循环继续直到任务完成。

多提供商 LLM 支持 — Anthropic、OpenAI、Google、Groq、XAI、Mistral、OpenRouter、Cerebras 和 Ollama。

自定义网络工具 — web_fetch(Mozilla 可读性)和 web_search(Brave 搜索或 Perplexity)。

技能插件系统 — 基于 Markdown 的工作流程从三个目录加载,具有渐进式披露(系统提示词中包含名称,按需提供完整说明)。

上下文感知系统提示词 — 自动从您的项目中加载 CONTEXT.md、CLAUDE.md、SOUL.md。

一个自我扩展的技能创建器——一种元技能,允许智能体在会话中编写新的 SKILL.md 文件并热重载它们,这样智能体就可以在不重启的情况下自学新的工作流程。

这是第 3 部分后的启动横幅:

┌ openclaw-mini
│ model: anthropic/claude-sonnet-4-20250514workspace: /Users/you/project
│ session: mini-1719432000000context: CLAUDE.mdtools: read, bash, edit, write, web_fetch, web_search
│ skills: git-commit, code-review, summarize, weather, skill-creator
└ /new /think /model /skills /quit

这是一个具有可扩展工作流程的强大智能体。但在它运行在真实任务上——编辑文件、运行测试套件、轮询进程——我们不断遇到同样的问题。

问题:智能体卡住

一个现实检查:现代大型语言模型比你想象的更聪明

事情是这样的——如果你今天尝试用现代模型如 Claude-opus 或 GPT-5.2 来重现这些循环,你可能真的不会陷入循环。我们测试过。让智能体去请求一个宕机的服务器,它会尝试一次,也许两次,然后说“服务器似乎离线了”。让它读取一个不存在的文件,它尝试几次后会放弃并建议创建它。

现代大型语言模型在识别自己卡住的情况方面已经做得相当出色。元认知能力已经提升到足以让模型自身的推理捕获大多数明显的失败模式。

那么,为什么要构建这个呢?

因为“大多数”并不等于“全部”。仍然发生的那些循环是微妙而难以察觉的——不寻常的工具组合、长时间会话导致上下文变得嘈杂、模型认为自己在进步但实际上并没有的编辑-验证循环。这些边缘情况通常出现在凌晨 2 点进行的复杂重构任务中,而不是在快速演示中。

原始的 openclaw 在生产环境中运行这个流程,因为那些边缘情况确实会在大规模应用中发生。当用户在多个不同的代码库中运行数小时的会话时,这些奇怪的循环就会浮现出来。这是一种防御性基础设施——就像一个你希望永远不需要的安全带,但当你需要时,你会庆幸它的存在。

为什么会发生这种情况

要理解这一点,你需要了解工具调用循环的工作原理:

User prompt → LLM thinks → calls a tool → gets result →
LLM thinks → calls another tool → ... → final answer

LLM 决定调用哪个工具以及传递什么参数。它读取结果,进行推理,并决定下一步做什么。这种方法效果出奇地好——直到模型的推理遇到死胡同。

LLM 默认是乐观的。当工具调用失败时,模型的本能不是放弃——而是重试。这种乐观通常是优点。你希望智能体在遇到第一个麻烦迹象时不放弃。但当情况确实无法解决时,它就变成了缺点。

现代模型比以前更能捕捉到这一点——它们通常在几次尝试后就能识别明显的死胡同。但它们并不完美。在长时间的会话中,复杂的多工具工作流程中,或者当失败是微妙的(编辑“成功”但并未解决根本问题)时,模型仍然可能错过模式。上下文窗口包含历史记录,但模型的元认知有限——尤其是在卡住的迹象不明显时。

陷入困境的三种模式

在构建和测试 openclaw-mini 的过程中,我们发现了三种不同的失败模式:

  1. 锤子循环——同一个工具,相同的参数,相同的结果,一次又一次。智能体在等待某些变化。但不会变化。

  2. 乒乓球循环——两个工具交替进行,没有进展。读取一个文件,进行一个无效的编辑,再次读取,进行相同的编辑。两个工具,零进展,无限循环。

  3. 检查循环——等待一个不会发生的外部状态变化。检查服务器是否正常,检查进程是否完成,检查文件是否出现。

这个问题最严重的领域

如果你正在构建任何使用工具的 AI 智能体,这些是循环最常发生的类别:

文件系统操作 — 读取尚未存在的文件。监视不会发生的文件更改。在权限错误时重试。文件系统是最常见的“乐观重试”循环来源。

Shell / 进程执行 — 运行确定性地失败的命令。错误的二进制文件、缺失的依赖项、语法错误。模型尝试相同的命令,希望得到不同的结果。轮询长时间运行的进程以完成是另一个经典案例。

HTTP / API 调用 — 状态为宕机、速率限制或返回认证错误的端点。模型看到 500 或 403 错误时会认为“让我再试一次”,因为在人类经验中,这些错误通常是暂时的。当 API 密钥错误时,重试并不能解决问题。

编辑-验证循环 — 最狡猾的模式。智能体编辑代码,运行测试,测试失败,再次编辑——但做出了同样的无效更改。或者更糟:修复了一个问题,引入了另一个问题,修复那个问题,再次引入第一个问题,并无限循环。

搜索与发现 — 搜索一个不存在的函数。智能体尝试不同的搜索词,它们都返回空结果,并继续搜索同一事物的各种变体。

解决方案:为工具调用实现断路器

修复方案来自分布式系统。当一个微服务不断调用下游的故障服务时,你不会让它无限重试。你会使用断路器——一种检测重复失败并在调用浪费更多资源之前停止调用的模式。

我们将其应用于 AI 智能体工具调用。整个实现是一个单文件: src/tool-loop-detection.ts ,约 150 行 TypeScript 代码。

设计

架构分为三个部分:

  1. 过去 30 次工具调用滑动窗口(调用内容+返回内容)
  2. 四个模式检测器扫描窗口以检测卡顿行为
  3. 一个工具包装器,拦截每个 execute() 调用进行预检
type ToolCallRecord = {
  key: string;        // "read:/path/to/file" — what was called
  toolName: string;
  resultKey?: string; // hash of the result — what came back
};

type DetectionResult =
  | { stuck: false }
  | { stuck: true; level: "warning" | "critical"; message: string };

实现

让我们逐一讲解每个部分。

1. 稳定哈希

第一个挑战:如何知道两个工具调用是否“相同”? read({ path: "/foo" }) 和 read({ path: "/foo" }) 应该匹配,但 JavaScript 对象没有保证的键顺序。 { a: 1, b: 2 } 和 { b: 2, a: 1 } 使用 JSON.stringify 会产生不同的字符串。

解决方案是一个稳定序列化器,它在转换为字符串之前对键进行排序:

function stableStringify(value: unknown): string {
  if (value === null || typeof value !== "object") {
    return JSON.stringify(value);
  }
  if (Array.isArray(value)) {
    return `[${value.map(stableStringify).join(",")}]`;
  }
  const obj = value as Record<string, unknown>;
  const keys = Object.keys(obj).sort();  // ← sorted keys
  return `{${keys.map((k) =>
    `${JSON.stringify(k)}:${stableStringify(obj[k])}`
  ).join(",")}}`;
}

对键进行排序,递归进入嵌套对象,无论插入顺序如何,你都会得到一个确定的字符串。这就是加密签名中规范 JSON 的背后概念——你需要一个稳定的表示形式来可靠地比较事物。

有两个辅助函数从这个构建键:

function toolKey(name: string, params: unknown): string {
  return `${name}:${stableStringify(params)}`;
}

function outcomeKey(result: unknown, error: unknown): string | undefined {
  if (error !== undefined) {
    return `error:${error instanceof Error ? error.message : String(error)}`;
  }
  if (result === undefined) return undefined;
  // Extract text content from tool results for stable comparison
  if (typeof result === "object" && result !== null &&
      "content" in result && Array.isArray((result as any).content)) {
    const text = (result as any).content
      .filter((e: any) => typeof e?.text === "string")
      .map((e: any) => e.text)
      .join("\n").trim();
    return `text:${text}`;
  }
  return stableStringify(result);
}

2. 滑动窗口

我们将最后 30 次工具调用保存在一个数组中。当记录新的调用时,旧的调用会被移除:

record(name: string, params: unknown, result?: unknown, error?: unknown): void {
  this.history.push({
    key: toolKey(name, params),
    toolName: name,
    resultKey: outcomeKey(result, error),
  });
  if (this.history.length > HISTORY_SIZE) this.history.shift();
}

有限的内存。无论会话运行多长时间,我们最多只存储 30 条记录。窗口足够大以捕捉模式,但又足够小,以至于 100 轮前的调用不会产生干扰。

3. 四个具有渐进式升级的检测器

这里就变得有趣了。我们运行四个检测器,每个检测器寻找不同的卡顿模式,每个检测器的严重程度逐渐增加:

检测器 1:通用重复(仅警告)

最简单的检查。统计最近 30 次调用中有多少次与当前的工具+参数匹配:

const repeatCount = this.history.filter((h) => h.key === key).length;
if (repeatCount >= WARNING_THRESHOLD) → WARNING

这可以捕获 Hammer 循环。如果你在最近 30 次调用中调用了 read("/tmp/config.json") 10 次,可能出了一些问题。但我们不会阻止——也许有正当的理由。我们只是发出警告。

检测器 2:无进度关键(阻止)

更严格。这个检测器从最近的调用开始向后扫描,并计算工具返回完全相同结果的连续调用次数:

private getNoProgressStreak(key: string): number {
  let streak = 0;
  let expectedResult: string | undefined;
  for (let i = this.history.length - 1; i >= 0; i--) {
    const h = this.history[i]!;
    if (h.key !== key) continue;
    if (!h.resultKey) continue;
    if (!expectedResult) {
      expectedResult = h.resultKey;
      streak = 1;
      continue;
    }
    if (h.resultKey !== expectedResult) break;
    streak++;
  }
  return streak;
}

如果 streak >= 20 ,我们将完全阻止调用。工具不会执行。相反,智能体将收到一个错误:“BLOCKED:使用相同的参数和结果调用 read 20 次。停止重试。”

关键区别:通用重复说“你频繁调用这个。”无进展说“你频繁调用这个,每次都得到相同的答案。”后者是陷入困境的更强证据。

检测器 3:乒乓球(警告→阻止)

这检测交替模式—— A, B, A, B, A, B... ——其中双方都返回稳定结果:

private getPingPongCount(currentKey: string): number {
  if (this.history.length < 2) return 0;
  const last = this.history[this.history.length - 1]!;
  if (last.key === currentKey) return 0; // must alternate
  const otherKey = last.key;

  let count = 0;
  let resultA: string | undefined;
  let resultB: string | undefined;
  let noProgress = true;

  for (let i = this.history.length - 1; i >= 0; i--) {
    const expected = count % 2 === 0 ? otherKey : currentKey;
    const h = this.history[i]!;
    if (h.key !== expected) break;

    // Track whether outcomes are stable (no progress)
    if (h.resultKey) {
      if (h.key === currentKey) {
        if (!resultA) resultA = h.resultKey;
        else if (resultA !== h.resultKey) noProgress = false;
      } else {
        if (!resultB) resultB = h.resultKey;
        else if (resultB !== h.resultKey) noProgress = false;
      }
    }
    count++;
  }

  if (count < 2 || !noProgress) return 0;
  return count + 1; // +1 for the current call
}    // Track whether outcomes are stable (no progress)
    if (h.resultKey) {
      if (h.key === currentKey) {
        if (!resultA) resultA = h.resultKey;
        else if (resultA !== h.resultKey) noProgress = false;
      } else {
        if (!resultB) resultB = h.resultKey;
        else if (resultB !== h.resultKey) noProgress = false;
      }
    }
    count++;
  }

  if (count < 2 || !noProgress) return 0;
  return count + 1; // +1 for the current call
}

警告在 10 次交替时触发,在 20 次时阻断。关键在于,它只有在两个工具都返回稳定结果时才会触发——如果编辑实际上每次都在改变文件,结果就会不同,检测器将保持静默。

检测器 4:全局断路器(始终阻断)

核选项。如果任何单个工具调用模式达到 30 次相同的无进展重复,我们将无条件阻断。这能捕获其他检测器遗漏的所有情况。这是终极安全网。

if (streak >= CIRCUIT_BREAKER_THRESHOLD) {
  return {
    stuck: true,
    level: "critical",
    message: `BLOCKED: ${name} repeated ${streak} times with no progress.
              Circuit breaker triggered — stop retrying and report the issue.`,
  };
}

4. 升级模型

阈值形成了一种有意升级:

Calls 1-9:   Normal operation. No intervention.
Call 10:     ⚠ Warning printed to terminal. Tool still runs.
Calls 11-19: Warnings continue (deduped to prevent spam).
Call 20:     🛑 BLOCKED. Tool does not execute. Error sent to LLM.
Call 30:     🛑 Circuit breaker. Absolute hard stop.

为什么不在 10 的时候直接阻止?因为有时智能体正在以检测器无法看到的方式取得进展。警告信息会给人类操作员一个预警。在 20 时阻止是明确的“你卡住了。”

5. 警告信息去重

你不想有 10 条相同的警告信息淹没终端。一个桶系统可以处理这个问题:

private shouldWarn(name: string, params: unknown): boolean {
  const key = toolKey(name, params);
  const count = this.history.filter((h) => h.key === key).length;
  const bucket = Math.floor(count / WARNING_BUCKET_SIZE);
  const lastBucket = this.warningBuckets.get(key) ?? -1;
  if (bucket <= lastBucket) return false;
  this.warningBuckets.set(key, bucket);
  return true;
}

当计数超过 10 时会收到一个警告,超过 20 时(在代码块之前)会收到另一个警告。在 10 和 20 之间不会收到十个警告。

6. 工具包装器(装饰器模式)

集成点是围绕每个工具的 execute() 函数的装饰器:

wrapTool<T extends { name: string; execute: (...args: any[]) => any }>(tool: T): T {
  const detector = this;
  const originalExecute = tool.execute;
  return {
    ...tool,
    execute: async (toolCallId: string, params: unknown, ...rest: any[]) => {
      // Pre-flight: check for loops
      const check = detector.detect(tool.name, params);
      if (check.stuck && check.level === "critical") {
        detector.record(tool.name, params);
        throw new Error(check.message);  // LLM sees this as tool error
      }
      if (check.stuck && check.level === "warning") {
        if (detector.shouldWarn(tool.name, params)) {
          console.error(`[loop-detection] ${check.message}`);  // Human sees this
        }
      }

 // Execute normally and record the outcome
      try {
        const result = await originalExecute.call(tool, toolCallId, params, ...rest);
        detector.record(tool.name, params, result);
        return result;
      } catch (err) {
        detector.record(tool.name, params, undefined, err);
        throw err;
      }
    },
  };
}

其余代码库不知道循环检测的存在。这是 Express 中间件、Python 装饰器和 Java 面向切面编程中使用的相同模式——拦截、检查、委托。

详细日志记录:洞察智能体内部

循环检测可以保护智能体免受自身影响。但我们还想实时查看智能体在做什么——它正在调用哪些工具,这些工具耗时多久,何时在思考,何时决定调用工具。

PI SDK 在会话上提供了一个事件订阅系统:

session.subscribe((event) => {
  // Fires for every agent lifecycle event
});

我们通过一个 /verbose 命令钩子来切换实时日志记录的开启和关闭:

if (trimmed === "/verbose") {
  verbose = !verbose;
  console.log(`Verbose logging: ${verbose ? "on" : "off"}`);
  continue;
}

当开启详细模式时,订阅者会记录四种类型的事件:

session.subscribe((event: any) => {
  if (!verbose) return;
  switch (event.type) {
    case "turn_start":
      turnCount++;
      console.error(`[turn ${turnCount}]`);
      break;
    case "tool_execution_start":
      toolTimers.set(event.toolCallId, Date.now());
      console.error(`  [tool] ${event.toolName} ${JSON.stringify(event.args)}`);
      break;
    case "tool_execution_end": {
      const elapsed = ((Date.now() - started) / 1000).toFixed(1);
      const status = event.isError ? "✗" : "✓";
      console.error(`  [tool] ${event.toolName} ${status} ${elapsed}s`);
      break;
    }
    case "message_update": {
      const sub = event.assistantMessageEvent;
      if (sub?.type === "thinking_start") console.error("  [thinking...]");
      else if (sub?.type === "toolcall_start") console.error("  [deciding tool call...]");
      break;
    }
  }
});

实际效果如下:

> Read the package.json and tell me what this project does
[turn 1]
  [deciding tool call...]
  [tool] read {"file_path":"/Users/you/project/package.json"}
  [tool] read ✓ 0.1s
[turn 2]
This project is a terminal-based AI coding agent called openclaw-mini...
(3.2s)

以及一个更复杂的提示词,触发多个工具:

> Look at the source files and search for any TODO comments
[turn 1]
  [deciding tool call...]
  [tool] bash {"command":"find src -name '*.ts' | head -20"}
  [tool] bash ✓ 0.1s
[turn 2]
  [deciding tool call...]
  [tool] bash {"command":"grep -rn 'TODO' src/"}
  [tool] bash ✓ 0.0s
[turn 3]
  [deciding tool call...]
  [tool] read {"file_path":"/Users/you/project/src/entry.ts","offset":42,"limit":10}
  [tool] read ✓ 0.0s
[turn 4]
I found 3 TODO comments in your codebase...
(5.1s)

每个事件都会以暗色文本记录到 stderr,以免干扰智能体的实际输出。默认情况下是关闭的——在任何会话中途都可以通过 /verbose 切换。

与 entry.ts 集成

对主文件的更改是紧凑的。在 entry.ts 中,我们在将所有工具(包括 SDK 的内置工具和我们的自定义 Web 工具)传递给 createAgentSession() 之前将它们包装起来:

import { createCodingTools } from "@mariozechner/pi-coding-agent";
import { ToolLoopDetector } from "./tool-loop-detection.js";

// Create the detector (once, persists across prompts in a session)
const loopDetector = new ToolLoopDetector();
// Inside the REPL loop, wrap all tools before session creation:
const wrappedBuiltInTools = createCodingTools(workspaceDir)
  .map((t) => loopDetector.wrapTool(t));
const wrappedCustomTools = customTools
  .map((t: any) => loopDetector.wrapTool(t));
const { session } = await createAgentSession({
  // ...
  tools: wrappedBuiltInTools,        // wrapped built-in tools
  customTools: wrappedCustomTools,    // wrapped custom tools
  // ...
});

每个工具——文件读取、bash、编辑、写入、网络搜索、网络获取——现在都有一个断路器。并且在 /new 会话中,检测器会重置:

if (trimmed === "/new") {
  sessionId = `mini-${Date.now()}`;
  sessionFile = resolveSessionFile(sessionId);
  loopDetector.reset();  // ← clear loop detection state
  continue;
}

启动横幅现在显示 /verbose :

┌ openclaw-mini
│ model: anthropic/claude-sonnet-4-20250514workspace: /Users/you/project
│ session: mini-1719432000000context: CLAUDE.mdtools: read, bash, edit, write, web_fetch, web_search
│ skills: git-commit, code-review, summarize, weather, skill-creator
└ /new /think /model /skills /verbose /quit                  ← new

所有这一切是如何结合在一起的

这是从工具调用到检测的完整流程:

Agent wants to call read("/tmp/config.json")
  │
  ├─ wrapTool intercepts execute()
  │
  ├─ Pre-flight: detect("read", { path: "/tmp/config.json" })
  │    │
  │    ├─ Build key: "read:{"path":"/tmp/config.json"}"
  │    ├─ Scan history for this key
  │    │
  │    ├─ Check 1: Global circuit breaker (streak ≥ 30?) → no
  │    ├─ Check 2: No-progress critical (streak ≥ 20?)  → no
  │    ├─ Check 3: Ping-pong pattern (count ≥ 10?)      → no
  │    └─ Check 4: Generic repeat (count ≥ 10?)         → YES (count = 12)
  │         └─ Return { stuck: true, level: "warning" }
  │
  ├─ Level = warning → print to terminal, continue executing
  │    └─ console.error("[loop-detection] WARNING: read called 12 times...")
  │
  ├─ Execute original read("/tmp/config.json")
  │    └─ Returns "file not found"
  │
  ├─ Record outcome: key + resultKey("file not found")
  │    └─ Push to sliding window (evict oldest if > 30)
  │
  └─ Return result to LLM
       └─ LLM sees "file not found" (again)
           └─ Next call: detect() → streak = 20CRITICALthrow Error
               └─ LLM sees: "BLOCKED: read called 20 times..."
                   └─ LLM stops retrying

Meanwhile, if /verbose is on:
  [turn 12]
    [deciding tool call...]
    [tool] read {"file_path":"/tmp/config.json"}
    [tool] read ✓ 0.0s          ← human sees each call in real time

发生了什么:差异

整个工具循环检测和详细日志记录系统都包含在这些更改中:

  • 新建文件: src/tool-loop-detection.ts — 150 行。包含稳定哈希、滑动窗口、4 个检测器、渐进式升级、警告去重和工具包装的 ToolLoopDetector 类。
  • 修改: src/entry.ts — 添加了约 50 行。导入检测器,创建实例,包装所有工具, /verbose 命令行与 session.subscribe() 事件日志记录,在 /new 时重置。

没有新的依赖项。没有新的构建步骤。当工具不循环时没有运行时开销。

为什么这种架构有效

正常使用中没有误报——阈值(10 次警告,20 次阻止)设置得足够高,以至于正常的对话永远不会触发它们。你需要连续 30 次中有 10 次调用完全相同的工具并使用完全相同的参数。正常工作具有自然的变化性——我们通过长时间、多工具对话的测试,从未遇到过误报。

结果导向,而不仅仅是调用导向——检测器不仅仅统计调用次数。它会哈希结果。如果你连续调用 read("app.ts") 15 次,但文件在调用之间发生变化(因为你正在编辑它),进度检测器将保持静默。它只有在调用产生相同结果时才会阻止。

对智能体不可见——装饰器模式意味着 LLM、SDK 以及工具本身都不知道循环检测的存在。它纯粹是基础设施。

自我纠正 — 当检测器阻止一个调用时,它会抛出一个错误,LLM 读取该错误。模型看到“BLOCKED: 你卡住了”,并调整其行为。检测教会了智能体停止。

按需可见 — /verbose 在你需要时提供对智能体决策过程的完全可见性,在你不需要时保持静默。没有日志文件,没有单独的仪表板——只需在终端中切换即可。

内存限制 — 30 个条目的滑动窗口意味着内存使用量与会话长度无关而保持恒定。旧的调用会自动被移除。

无法捕获的内容

有一个值得指出的问题。我们要求智能体执行:“运行 curl localhost:9000 直到启动。”

   /verbose 显示的结果如下:

> Run curl localhost 9000 until it's up
[turn 1]
  [deciding tool call...]
  [tool] bash {"command":"until curl -sf http://localhost:9000/ >/dev/null; do echo "Waiting..."; sleep 1; done; echo "up""}

然后…什么也没有发生。智能体写了一个包含无限 until 循环的单个 bash 命令。一次工具调用。它永远阻塞,等待一个不运行的服务器。断路器从未触发,因为没有重复——循环存在于 bash 命令内部,而不是在工具调用层。

这是基于模式检测循环的基本限制:它只能看到工具调用之间的发生情况,而无法看到工具调用内部的情况。检测器会监视 A, A, A, A —— 但这是一个永远不会返回的单个 A 。

完整的 openclaw 在完全不同的层级处理这个问题:进程级别的超时保护。进程监视器使用两个独立的计时器监视每个启动的命令:

  • 总体超时 — 在可配置的时间内终止进程,无论输出如何
  • 无输出超时 — 如果进程长时间没有产生 stdout/stderr 则终止(只要有活动就会重置)

两者都使用 SIGKILL 终止进程。断路器防止无用的重复调用。进程监视器防止从未返回的单次调用。在生产环境中,两者都是必需的。

可迁移到生产系统的概念

本实现中的所有内容都直接映射到生产分布式系统中的模式:

智能体概念生产等价物工具调用滑动窗口速率限制器令牌桶无进度连续检测健康检查失败计数渐进式升级(警告→阻止)指数退避的重试策略 30 个全局断路器 Netflix Hystrix / resilience4j 稳定哈希用于比较一致性哈希、缓存键警告去重桶日志聚合、告警节流工具包装装饰器中间件、拦截器、AOPsession.subscribe()事件日志分布式追踪、OpenTelemetry/详细切换功能标志、调试日志级别

如果你为微服务构建了断路器,你已经构建了这些。如果你还没有,为 AI 智能体构建它是一个很好的学习方式——这些模式足够小,可以容纳在你的脑海中,具体到足以测试,并且可以直接迁移到任何组件可能会陷入失败循环的系统中。

下一步是什么

工具循环检测和详细日志记录是智能体可靠性基础设施的第一部分。还有更多需要构建:

  • 令牌预算跟踪——了解会话已花费多少并限制失控成本
  • 对话压缩——在上下文窗口填满时总结旧消息
  • 并行工具执行 — 并发运行独立的工具调用,而不是按顺序运行
  • 结构化权限 — 危险操作的审批工作流(文件删除、强制推送)

这是一个小型的基础设施,但它意味着你不必担心智能体在糟糕的日子里耗尽资源。而且没有详细的日志记录,你就像在黑暗中飞行。

openclaw-mini 的完整源代码(包括工具循环检测)可以在 GitHub 上找到。第一部分涵盖了构建基础智能体,第二部分涵盖了技能插件系统,第三部分涵盖了自我扩展的技能创建器,而本文则涵盖了使智能体能够抵抗其自身的故障模式。

如果你在自己的智能体构建中遇到了循环问题,我很乐意听到你的情况。