项目开源地址:https://github.com/GuoBug/PatchCat
在线体验:https://guobug.github.io/PatchCat/
往期回顾:
📖 《从 0 到 1 打造 AI 提示流编排器:给大模型装上手和脚!Agent 节点与 Tools 工具调用体系设计与实战(开源系列 09)》
📖 《从 0 到 1 打造 AI 提示流编排器:鞋里有沙走不远,怎么让画布连线真正顺手?(开源系列 11)》
📖 《从 0 到 1 打造 AI 提示流编排器:把 AI 引擎塞进华硕路由器!Merlin 插件与轻量边缘网关实战(开源系列 12)》
一、 深夜的心惊肉跳:当 Agent 开始失控
不知道有多少独立开发者或团队遇到过这样惊魂的一幕:
你写好了一个带自主工具调用的 ReAct Agent,满心欢喜地点击运行,然后转身去泡了杯咖啡。
等你回到工位前,赫然发现控制台正在以每秒几次的高频疯狂刷屏——
大模型因为外部接口返回了一个意料之外的报错字段,开始陷入“调用失败 ➔ 盲目重试 ➔ 再次失败 ➔ 变换参数继续乱试”的无限死循环。浏览器风扇狂转,API 计费后台的折线图以近乎垂直的角度向上飙升……
这是许多初次把 Agent 引入实际业务的团队最昂贵的一堂学费。
在前面的系列文章中,我们讨论过如何给大模型装上手和脚(ReAct 循环)、怎样在画布上丝滑拖拽连线,甚至怎样把引擎塞进千兆路由器。但作为一名关注系统稳定性和真实成本的 Product Engineer,我深知一个残酷的工程现实:
Demo 可以天马行空,但生产环境必须严防死守。大模型是概率引擎,永远存在不可预测的边界 Cases。如果我们不给自主 Agent 套上严密的工程项圈,它随时可能变成脱缰的野马,刷爆你的信用卡,搞垮你的服务。
在打磨 PatchCat v0.4.4 的过程中,我和 AI 搭档重点攻关了这套“运行时防御性纵深架构”。今天就来彻底拆解:我们是如何用确定性的系统契约,把脱缰的 Agent 牢牢关在安全轨道里的。
二、 四重运行时防御纵深全景
为了彻底终结“失控循环”与“账单雪崩”,我们在 PatchCat 的执行内核中构建了层层递进的四重防线:
+-------------------------------------------------------------------------+
| Agent Runtime Defense-in-Depth |
| |
| [ Layer 1: Max Token Budget Ceiling ] |
| --> Continuous usage accumulation; instantaneous hard brake. |
| |
| [ Layer 2: Tool Deadlock Breaker (Circuit Breaker) ] |
| --> Signature hash (tool:args) tracking. |
| --> N=2: Inject corrective System Hint (Soft reflection). |
| --> N=3: Hard trip circuit breaker, force synthesis. |
| |
| [ Layer 3: Dual Timeout Watchdogs ] |
| --> JS Sandbox: 5s strict execution cutoff. |
| --> HTTP Tools: 30s `AbortSignal.timeout` network kill switch. |
| |
| [ Layer 4: Centralized Defaults & Sanitized Migration ] |
| --> Zero magic numbers (`runtime-defaults.ts`). |
| --> Safe sanitized sharing (Key-stripped) vs Full device migration. |
+-------------------------------------------------------------------------+
三、 第一道防线:Token 预算硬顶(Max Token Budget)
很多开源框架防死循环的方式非常原始——仅仅限制迭代轮数(比如 maxIterations: 10)。
但这存在一个极具欺骗性的盲区:轮数少,不代表消耗的 Token 少!
如果大模型在某一轮调用的工具返回了一整篇 20,000 字的冗长 HTML,或者大模型陷入逻辑旋涡开始吐出数千字的长篇大论,哪怕仅仅运行 3 轮,瞬间消耗的 Token 也能达到数万甚至数十万,费用直接爆表。
(一)设计思考与实现机制
在 PatchCat 中,我们在 AgentNodeConfig 与全局设置中正式引入了 maxTokenBudget(Token 消耗硬顶):
- 灵活默认:设为
0代表不设上限(适合本地免费跑 Ollama 的极客);对于商业付费模型,用户可以设定如5000或10000的硬预算; - 波次实时累计:在 Agent 的每一轮
Think ➔ Act ➔ Observe循环中,流式解析器(SSE Client)都会精确抓取服务端返回的usage元数据,毫秒级累加至totalUsage.total; - 优雅阻断,绝不崩盘:
// src/engine/browser-engine.ts
// ── Safeguard: Token Budget Limiter ─────────────────────────
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; // 优雅刹车,终止 ReAct 循环
}
关键细节:当触发 Token 预算红线时,引擎不会直接抛出异常中断整张画布,而是把当前已经推理出来的结果与中断警告一并打包返回。这样下游的聚合节点、保存节点依然能够接收到已有的半成品数据,最大限度保住已经花钱算出来的成果。
四、 人机共创关键节点:工具调用死循环破局器(Deadlock Breaker)
如果说 Token 预算是最后的“保险丝”,那么工具调用的死循环破局器就是我们在人机协同中推演出的最精妙的“减震器”。
(一)工程痛点:如何区分“正常重试”与“智障死循环”?
在开发初期,我向 AI 搭档提出了一个很尖锐的设计诉求:
“我们必须防住 Agent 连续多次调用同一个工具的死循环。但我发现,在真实业务里,Agent 调用两次同一个接口可能是合理的——比如它第一次查天气查错了城市名,第二次换了个参数重新查;或者在做状态轮询。我们怎么做到既能掐死死循环,又不会把正常的自我纠错一棍子打死?”
这正是一个典型的业务痛点。如果搞一刀切(只要重复调用就报错),Agent 的灵活性就会被严重阉割;但如果不设防,模型常常会用一模一样的错误参数连续调十几遍,直到轮数耗尽。
(二)人机双向共创推演
我和 AI 搭档深入推演了底层状态机,最终设计出一套“调用指纹 + 阶梯式响应(Graduated Hint to Hard Trip)”模型:
- 精确调用签名指纹(Signature Hash):
每次工具调用时,动态拼接工具名称与其 JSON 参数字符串:
const callSig =${toolName}:${toolArgsStr};
只有当工具名称和入参完全一模一样时,才判定为“重复调用”,计数器自增;一旦参数发生变化,计数器立刻归零。 - N = 2:软性系统修正提示(Soft System Reflection Hint):
当检测到模型以完全相同的参数连续调用了 2 次时,系统不中断它,而是在上下文对话历史中悄悄插入一条高优先级的系统指导信息:// 注入软提示,引导大模型跳出死胡同 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.]`, });大模型看到这条提示后,通常会瞬间“恍然大悟”,意识到自己陷入了死胡同,从而主动改变策略或直接总结输出。
- N = 3:硬熔断跳闸(Hard Circuit Breaker):
如果模型对系统提示置若罔闻,第 3 次依然执迷不悟地发起相同调用,状态机判定其已彻底丧失自愈能力。熔断器瞬间跳闸: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`; 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; }
这种“先礼后兵”的阶梯设计,在实测中成功化解了 90% 以上的大模型死循环,既保留了 Agent 自主调整参数的弹性,又筑起了绝对防死锁的高墙。
五、 第三道防线:双超时看门狗(Watchdog)
除了逻辑死锁,系统级卡死同样致命。
在 PatchCat 的工作流中,存在两大高风险的外部执行场景:
- JavaScript 脚本沙箱:用户或 Agent 动态生成的 JavaScript 转换代码;
- 动态 HTTP 节点:访问外部的第三方 RESTful API。
如果有人写了一个 while(true) 死循环,或者外部 API 服务由于网络故障陷入长达十几分钟的静默挂起,整个浏览器主线程或编排队列就会被活活拖死。
(一)沙箱 5 秒强制熔断
对于客户端浏览器沙箱内的 JavaScript 代码执行,我们设定了严格的 SANDBOX_TIMEOUT_SECONDS: 5。底层的 Web Worker 执行器挂载了独立的定时看门狗,一旦超过 5 秒未返回,立即无条件强制终止 Worker 线程,返回明确的超时堆栈,画布绝不冻结。
(二)现代标准的 AbortSignal.timeout
对于 HTTP 节点和 Agent 内置的 builtin_http 工具,传统的 setTimeout 清理往往容易出现闭包泄漏。我们在 v0.4.4 中全面重构为现代浏览器原生支持的 AbortSignal.timeout(ms):
const timeoutMs = (settings.runtimeProtection?.toolTimeoutSeconds ?? 30) * 1000;
const response = await fetch(url, {
...options,
signal: AbortSignal.timeout(timeoutMs),
});
只要网络连接挂起超过配置时间(默认 30 秒),底层 TCP 连接会被直接掐断并触发 TimeoutError,随后进入 PatchCat 的错误恢复机制。
六、 第四道防线:告别魔法数字与敏感凭证安全迁移
任何可靠的系统工程,底线都在于细节的严谨与对用户隐私的敬畏。
(一)根治 Magic Numbers:集中式 runtime-defaults.ts
在项目早期,代码里难免散落着一些看似随意的数字:防抖是 800ms、重试是 1.5s、Agent 上限是 10……这种魔法数字随着系统复杂度上升,会变成灾难性的隐式负债。
在 v0.4.4 中,我们将全系统的运行时参数统一抽离至 src/config/runtime-defaults.ts,并严格界定了每个参数的物理安全边界:
| 参数项 | 默认值 | 安全范围 (Min ~ Max) | 作用场景 |
|---|---|---|---|
AUTOSAVE_DEBOUNCE_MS |
800 ms | 200 ms ~ 3000 ms | 画布草稿自动保存防抖 |
SANDBOX_TIMEOUT_SECONDS |
5 s | 1 s ~ 60 s | 代码节点执行时间硬顶 |
TOOL_EXECUTION_TIMEOUT_SECONDS |
30 s | 5 s ~ 300 s | HTTP 工具请求超时截断 |
AGENT_LOOP_DETECTION_THRESHOLD |
3 次 | 2 次 ~ 10 次 | 相同工具调用熔断阈值 |
LLM_MAX_RETRIES |
1 次 | 0 次 ~ 3 次 | 瞬态网络波动自动重试 |
(二)配置备份的双模安全契约
很多开发者经常想要把自己的工作流或设置分享到社区或发给同事,但常常手滑把带有自己真实 API Key 的配置文件一起导出发了出去,造成严重的密钥泄露。
我们在设置中心设计了严密的双模导出契约:
- 脱敏分享版(Sanitized Export):一键导出时,系统递归遍历所有 Provider,对
apiKey字段强制置空("")。你可以安全地把 JSON 发到 GitHub 或论坛求助,完全无后顾之忧; - 全量迁移版(Full Migration):只有在用户明确需要跨设备换电脑时,才允许导出携带完整密钥的密文配置。
并在设置中心的危险操作区(清空所有缓存、删除全部工作流)加入了严格的单语言口令核验:英文模式下必须输入大写 CLEAR CACHE,中文模式下必须输入 清空缓存,绝不给键盘误触留任何犯错空间。
七、 严谨验证:223+ 项全绿自动化测试
在这次运行时风控重构完成后,我和 AI 搭档编写了包含 12 项复杂场景的独立测试套件 tests/agent-runtime-guard.node.test.ts:
- 测试参数在边界极值下的校验逻辑;
- 构造完全相同的重复工具调用,验证第 2 次成功捕获
System Hint、第 3 次精准跳闸Deadlock Breaker; - 模拟 Token 消耗超标,验证循环毫秒级阻断与上下文保护;
- 测试脱敏导出对敏感 Key 的 100% 抹除。
在本地测试终端中,全量 223 项前端与调度核心测试一次性全部通过(100% Passing)。
八、 总结:确定性是 AI 工程化的第一生产力
很多时候,人们容易被各种“炫酷、颠覆、全自主”的 AI 营销话术所吸引,认为只要给大模型足够大的自由度,它就能搞定一切。
但真正的 Product Engineer 知道:软件工程发展了几十年所积累下来的容错、熔断、看门狗、沙箱与确定性状态机理念,非但没有过时,反而在 AI 时代变得前所未有地重要。
大模型越是聪明、越是发散,系统的外围底座就越要像花岗岩一样坚硬可靠。
把 Token 预算锁死、把重复调用的死循环掐断、把挂起超时的请求掐死——这些看似不那么“性感”的脏活累活,恰恰是让一个 AI 工具能够真正走出 Demo 玩具阶段、在真实生产环境中让人睡个安稳觉的核心护城河。
本项目已全量开源,文中所述的风控状态机逻辑与看门狗代码均已合并至主干。欢迎同行与架构师朋友一起探讨交流,求批评、求指教!