项目开源地址:https://github.com/GuoBug/PatchCat
在线体验:https://guobug.github.io/PatchCat/
往期回顾:
📖 《从 0 到 1 打造 AI 提示流编排器:失败了别全盘重来!逆向 BFS 拓扑回溯与 DAG 检查点断点续跑(开源系列 18)》
📖 《从 0 到 1 打造 AI 提示流编排器:告别“有进无出的上下文黑洞” —— 双锚点滑动窗口与原子事务裁剪实战(开源系列 17)》
📖 《从 0 到 1 打造 AI 提示流编排器:用一次“假药对照”,我们在大模型自愈中抓出了真凶(番外系列 2)》
📖 《从 0 到 1 打造 AI 提示流编排器:合规暴涨 23.9% 语义却跌 7.1%?自愈病理诊断与双轴归因报告(番外系列 1)》
📖 《从 0 到 1 打造 AI 提示流编排器:代码越写越脏?架构纯度清洗与 42 样本统计检验复盘(开源系列 16)》
📖 《从 0 到 1 打造 AI 提示流编排器:别把大模型当机械拼图!Case #7 字段冻结陷阱与代码回滚复盘(开源系列 15)》
📖 《从 0 到 1 打造 AI 提示流编排器:搞 Eval 评测前,先让输出闭合!结构化约束与业务契约防线(开源系列 14)》
导读:在构建自主 ReAct 智能体时,最令开发者头疼的问题莫过于“原地鬼打墙”:大语言模型因工具返回的空结果或格式异常产生认知惯性,反复发起完全相同的工具调用,直至烧光配额或超时报错。本文复盘 PatchCat 在端侧落地 Agent 运行时死锁防护的完整思考:我们如何通过轻量签名捕获重复调用,为何设计“2 次柔性引导 + 3 次硬熔断”的双道防御梯级,以及如何在浏览器端平衡防御灵敏度与异步轮询的合法空间。

一、 生产痛点:Agent 原地“鬼打墙”与工程暗礁
在引入自主 ReAct 循环后,节点获得了根据用户目标自主规划、探索环境与调用工具的能力。然而,脱离了严格拓扑约束的智能体,也带来了全新的系统不稳定性。
在无防护的早期测试中,我们频繁观察到一种被称为“同构死循环”的现象:
- 智能体为了完成统计任务,调用数据库查询工具
query_db({"status": "pending"}); - 工具按预期返回了空数组
[]; - 大语言模型在自回归生成下一步思考时,未能形成有效的新假说,直接再次发起一模一样的工具调用
query_db({"status": "pending"}); - 工具再次返回空数组,模型再次重放调用。
由于缺少物理外部干预,自回归模型陷入典型的模式坍塌(Mode Collapse)。在这种死循环中,画布节点处于持续运行状态,控制台日志疯狂刷屏,昂贵的输出 Token 呈指数级累加。
面对这种失控状态,粗放的超时截断往往治标不治本:
- 延时滞后:如果仅依赖单次请求的 HTTP 超时(如 30 秒),连续 10 轮循环将耗费整整 5 分钟,用户早已判定系统卡死并关闭页面;
- 资金无底洞:当用户配置了高阶思考模型,短时间内反复堆叠重复上下文,一次死锁可能瞬间消耗数万 Token;
- 体验割裂:简单粗暴地在第 1 次重复时直接抛出异常,又会误伤合理的数据轮询逻辑(例如查询一个耗时 2 秒生成的外部报表状态)。
我们必须在引擎底层构筑一套兼顾灵活性与绝对防御的防护机制。
二、 关键节点双向共创:一刀切阻断与合法轮询的博弈
在确定防护策略的技术评审中,团队内部围绕“如何判定 Agent 是否陷入死锁”展开了推演。
1. 两种极端方案的局限
起初,我们考虑过两种常见做法:
第一种是极端的一刀切方案:只要检测到相邻两次工具名称和入参相同,立刻中止流程。 但在 AI 伙伴对真实工具生态的推演中,这一假设被迅速推翻:在异步任务中,轮询是基础模式。如果外部任务需要 1 秒钟处理,Agent 发起两次相同的查询调用完全属于合规操作。若一刀切阻断,Agent 将丧失最基本的异步等待能力。
第二种是消极的配额耗尽方案:允许 Agent 重试 5 次甚至 10 次,直到达到最大迭代轮数(maxIterations)。
这同样不可接受:在绝大多数业务场景下,如果连续 3 次获得相同结果且参数没有任何变化,模型在后续轮次中靠自身随机采样打破模式死锁的概率极低,多余的重试除了浪费算力毫无价值。
2. 作者郭强(GuoBug)提出渐进式分级防御
面对灵活性与安全性的两难,作者郭强从产品工程师视角提出了梯级防御思路:
既然无法在第 2 次调用时 100% 确认模型是故意轮询还是陷入了死循环,那就把知情权和选择权交还给模型。第 2 次相同调用时给出警告提示,第 3 次相同调用时执行强制物理熔断。
这一判断直接确立了 PatchCat 的双道防御机制:不是直接扼杀,而是通过轻量签名比对,在第 2 次相同调用时触发柔性引导,在第 3 次相同调用时触发看门狗硬熔断。
| 评估维度 | 方案 A: 单次重复立即报错 | 方案 B: 仅依赖最大迭代上限 | 方案 C: 渐进式双道防御 (采纳) |
|---|---|---|---|
| 异步轮询支持 | 完全不支持,直接误杀 | 支持,但极度浪费算力 | 容纳 2 次合法连续探测 |
| 算力损耗控制 | 极佳(0 冗余调用) | 极差(消耗全部迭代配额) | 优异(最多损耗 3 次轻量调用) |
| 自主纠偏几率 | 0%(无反思机会) | 极低(模型缺乏外部刺激) | 高(通过系统提示唤醒反思) |
| 空间复杂度 | $O(N)$ 历史全记录 | $O(1)$ 仅计数器 | $O(1)$ 仅暂存上一次签名 |

三、 核心架构深度拆解:双道防御状态转移与签名比对
整个机制被内置在 PatchCat 的执行引擎 src/engine/browser-engine.ts 中,贯穿整个自主 ReAct 循环。
1. 签名计算与极简状态暂存
在每一次模型返回工具调用请求时,调度器首先提取工具名称与其入参字符串,组装为调用签名:
\[callSig = toolName + ":" + toolArgsStr\]为了避免维护庞大的历史树导致内存膨胀,引擎采用线性前序比对:
// src/engine/browser-engine.ts:2285
const callSig = `${toolName}:${toolArgsStr}`;
if (callSig === lastCallSig) {
consecutiveIdenticalCount++;
} else {
lastCallSig = callSig;
consecutiveIdenticalCount = 1;
}
调度器仅跟踪连续同构调用次数。一旦 Agent 切换了工具名称或修改了哪怕一个查询参数,计数器便会立即重置为 1,确保防御逻辑不会干扰正常的发散式探索。
2. 第一道防线:第 2 次调用的柔性引导
当检测到计数器等于系统配置的提示阈值(AGENT_LOOP_DETECTION_HINT_THRESHOLD = 2)时,系统判定当前处于“潜在死锁警戒区”。
此时,调度器并不中断工具执行,允许工具正常向底层派发并获取响应。与此同时,调度器向对话上下文静默追加一条用户角色的自纠偏提示:
// src/engine/browser-engine.ts:2311
else if (consecutiveIdenticalCount === RUNTIME_DEFAULTS.AGENT_LOOP_DETECTION_HINT_THRESHOLD) {
messages.push({
role: 'user',
content: `[System Hint: You invoked tool "${toolName}" twice with identical parameters. If polling or awaiting state change, continue; otherwise synthesize your final answer.]`,
});
}
这条系统提示起到了唤醒自省的作用:如果 Agent 正在等待异步状态变化,它可以选择继续;如果它只是因为局部注意力陷阱而重复发问,这条提示会迫使它重新审视推理路径,从而跳出死循环。
3. 第二道防线:第 3 次调用的看门狗硬熔断
如果模型忽略了第 2 次的自纠偏提示,在第 3 轮迭代中依然执拗地给出发起同构调用的指令,计数器便会达到熔断阈值(loopDetectionThreshold = 3)。
此时,看门狗拦截对实际工具的派发,直接激活物理截断逻辑:
// src/engine/browser-engine.ts:2294
if (consecutiveIdenticalCount >= loopDetectionThreshold) {
isDeadlockTripped = true;
const deadlockMsg = `\n[Agent Deadlock Protection] Tripped: ${consecutiveIdenticalCount} consecutive identical calls to "${toolName}". Terminating loop to prevent token waste.\n`;
if (onChunk) {
onChunk({
delta: deadlockMsg,
fullContent: finalResponse + deadlockMsg,
});
}
finalResponse += deadlockMsg;
messages.push({
role: 'tool',
tool_call_id: tc.id,
content: `Observation: Execution halted by Deadlock Breaker (${consecutiveIdenticalCount} identical calls). Please synthesize conclusion immediately.`,
});
break;
}
在状态机流转中,看门狗执行了三项操作:
- 置位全局熔断标志
isDeadlockTripped = true,使外部外层循环在当前轮次彻底退出; - 构造符合协议格式的虚构观测结果,明确告知模型执行已被系统中止,指令其立刻根据前序已有信息进行总结;
- 将保护警报实时推送至前端流式输出通道,让调试者第一时间获知熔断原因。
四、 工程代价与边界权衡(Trade-offs)
在软件架构中,任何安全机制都有其作用边界与工程开销。在AI 工作流编排(AI Workflow Orchestration)与确定性工作流的落地过程中,我们梳理出三项关键边界:
1. 签名漂移:JSON 序列化键序风险与性能权衡
当前我们采用极简的字符串拼接 callSig = ${toolName}:${toolArgsStr}。这种实现的优势在于空间复杂度为 $O(1)$,单次比对耗时处于微秒级。
但该实现存在一个潜在边界:若大语言模型在生成 JSON 入参时调整了键的顺序(例如第一轮输出 {"page":1,"size":10},第二轮输出 {"size":10,"page":1}),字符串强比对会出现漏判(签名漂移)。
要彻底解决键序问题,必须先解析 JSON,对键进行字典序重排,再计算哈希。我们在端侧基准测试中对比了两种方案:引入全量 AST 解析与递归排序,会给浏览器的主线程带来额外的解析开销。
权衡之下,当前版本优先保障极低延迟,将键序规整的任务交由上游 Schema 模板定义;在后续升级中,可为特定高敏感节点提供规范化选项。
2. 双重锁:Token 预算兜底防线
签名比对虽然能精准击穿连续同构死锁,却无法防范交替死锁(例如模型在 Tool_A 与 Tool_B 之间循环往复)。
为了防御这种高级死锁,我们在引擎中并联了第二道底线机制 —— Token 预算硬限额(maxTokenBudget):
// src/engine/browser-engine.ts:2258
if (maxTokenBudget > 0 && totalUsage.total >= maxTokenBudget) {
const budgetMsg = `\n[Agent Token Budget Exceeded] Total ${totalUsage.total} tokens reached budget limit of ${maxTokenBudget}. Terminating loop.\n`;
if (onChunk) {
onChunk({
delta: budgetMsg,
fullContent: finalResponse + budgetMsg,
});
}
finalResponse += budgetMsg;
break;
}
无论是参数微调的伪装循环,还是多工具震荡死锁,只要累积消耗的 Token 触达用户设定的红线,引擎均会立即切断循环,守护用户的账户余额。

3. 单步看门狗协作:防范主线程假死
除了循环层面的死锁,单次工具调用的挂起同样致命。PatchCat 配套提供了单步超时看门狗:
- 代码沙箱节点:Web Worker 配合 5 秒倒计时,一旦代码包含
while(true)直接销毁 Worker 线程; - 网络工具节点:依托原生
AbortSignal.timeout(30s)实施网络级硬截断。
三者联动,在整个DAG 状态机(DAG Engine / State Machine)中形成从单步执行、连续同构到全局配额的三级防御矩阵。
五、 自动化测试实证与极限场景压测
在测试套件 tests/agent-runtime-guard.node.test.ts 中,我们针对死锁检测与运行时防护编排了自动化验证:
// 验证死锁检测阈值与默认配置契约
it('verifies all centralized runtime constants exist and fall within safe boundaries', () => {
assert.strictEqual(RUNTIME_DEFAULTS.AGENT_LOOP_DETECTION_THRESHOLD, 3);
assert.strictEqual(RUNTIME_DEFAULTS.AGENT_LOOP_DETECTION_HINT_THRESHOLD, 2);
assert.strictEqual(RUNTIME_DEFAULTS.AGENT_TOKEN_BUDGET, 0); // 0 = unlimited
});
it('verifies Agent default node config inherits from RUNTIME_DEFAULTS', () => {
const config = getDefaultNodeConfig('agent') as AgentNodeConfig;
assert.strictEqual(config.maxTokenBudget, 0);
assert.strictEqual(config.loopDetectionEnabled, true);
assert.strictEqual(config.loopDetectionThreshold, 3);
assert.strictEqual(config.maxIterations, RUNTIME_DEFAULTS.AGENT_DEFAULT_MAX_ITERATIONS);
});
测试用例覆盖了以下四个核心场景:
- 默认继承测试:确保新拖拽创建的 Agent 节点自动开启死锁检测,且阈值严格锁定为提示 2 次、熔断 3 次;
- 动态覆盖测试:验证节点级配置可覆盖全局默认值,支持特定批处理场景临时提高容忍度;
- 配置迁移与脱敏测试:验证配置备份与恢复过程中,安全保护参数毫发无损地流转;
- 语言锁死测试:在危险区清空操作中验证大小写与双语精准匹配,防范误触操作。
全工程 368 项自动化测试在本地与 CI 流水线中持续保持 100% 通过(Node.js 原生测试套件 368/368 全部绿灯通过)。
六、 总结:从不确定性到工程确定性
作为兼顾底层工程与产品体验的系统设计者,我们在构建 AI 提示流编排器时不断审视一个根本问题:智能体的自主性与系统的确定性之间,究竟该如何平衡?
大语言模型的推理过程本质上充满概率与随机性。如果我们完全剥夺它的决策空间,智能体就退化成了僵硬的传统脚本;但如果我们放任它的探索,概率陷阱又会让生产系统在原地打转中崩溃。
PatchCat 践行的原则是:在非确定性的模型内核之外,包裹一层绝对确定性的工程防护轨道。 通过轻量精准的签名比对、恰到好处的自省引导,以及坚决果断的熔断截门,我们既给予了模型探索未知的宽容度,又守住了系统可用性与资金安全的底线。
在 AI 编排的真实世界里,优雅的系统不在于从不犯错,而在于当错误发生时,能以极小的代价从容恢复。
下一篇预告
📖 《从 0 到 1 打造 AI 提示流编排器:大模型也能秒级自检!Flow Preflight 语法静态分析与画布连线自查(开源系列 20)》
关于作者
郭强 (GuoBug),Product Engineer,做平台工程也做业务增长。目前主要在折腾 AI 工作流编排、DAG 状态机与确定性系统架构。
开源项目与主页:https://github.com/GuoBug · https://guobug.github.io
欢迎就工作流引擎架构、拓扑调度和低门槛开发体验交流指教。