跳转至

800行代码实现 Open Claw 的 Tool、消息总线、子Agent管理架构

Ch01.237 800行代码实现 Open Claw 的 Tool、消息总线、子Agent管理架构

📊 Level ⭐⭐ | 15.1KB | entities/open-claw-tool-bus-subagent-architecture.md

这篇文章记录对 Open Claw 中 Tool、消息总线和子 Agent 管理架构的研究学习,以及一个最小可运行实现。 本文想说明的技术观点是对于 Tool 调用、消息分发、子 Agent 管理这三类 Agent 系统里的核心组件,优先采用薄抽象、显式控制流和贴近模型 API 的实现方式,往往比引入多层中间件更容易获得工程上的确定性。系统边界更清晰,运行路径更容易追踪,问题更容易定位,也更适合作为后续扩展 Memory、调度和持久化能力的基础。 引言 这是一个基于 Anthropic Claude API 的 Agent 框架,用 TypeScript 编写,运行在单进程 Node.js 环境中。 本文记录其中四个核心模块的实现:工具系统(Tool layer)、消息总线(MessageBus)、子 Agent 管理(SubagentManager)、REPL 主循环。不涉及上层 Bot 接入层、持久化、Context / Memory 系统。 框架不依赖 LangChain 或其他 Agent 框架,直接基于 Anthropic SDK 构建。选择这条路的原因很简单:中间层越薄,调试越容易,对 API 行为的控制越精确。 基础设施:Tool 抽象与 ToolRegistry

\n|

** ▐Tool 抽象类 **\n

一个工具由四个要素组成: namedescriptioninput_schemaexecute 。 export abstract class Tool { abstract readonly name: string; abstract readonly description: string; abstract readonly input_schema: AnthropicTool["input_schema"]; abstract execute(args: Record): Promise; toSchema(): AnthropicTool { return { name: this.name, description: this.description, input_schema: this.input_schema, }; }} input_schema 的类型直接取自 @anthropic-ai/sdkTool 类型定义。 toSchema() 将实例转换为 Anthropic API 要求的 function calling schema。没有中间层转换,SDK 类型就是唯一的 schema 定义。 这里有一个刻意的取舍:schema 使 用运行时普通对象定义,而非 Zod 等库。好处是零额外依赖、直接对齐 SDK 类型。代价是没有运行时参数校验——LLM 传入的参数如果类型不对,只能靠 execute 内部的 as 断言和实际调用时的错误来 兜底。对于当前规模,这个取舍可以接受。

▐ ** ** ToolRegistry

注册表本身是一个 Map<string, Tool> : export class ToolRegistry { private tools = new Map(); register(tool: Tool) { this.tools.set(tool.name, tool); } async execute(name: string, args: Record) { const tool = this.tools.get(name); if (!tool) throw new Error(Tool "${name}" not found); return tool.execute(args); } getToolDefinition(): AnthropicTool[] { return Array.from(this.tools.values()).map((tool) => tool.toSchema()); } exclude(names: string[]): ToolRegistry { const excludeSet = new Set(names); const filtered = new ToolRegistry(); for (const [name, tool] of this.tools) { if (!excludeSet.has(name)) { filtered.register(tool); } } return filtered; }} exclude() 是为子 Agent 设计的。子 Agent 不应该持有 spawn (避免递归创建子 Agent)、 message (避免直接向用户发消息)等工具,所以需要从主 Agent 的工具集中排除特定工具,生成一个受限子集。 exclude() 返回新的 ToolRegistry 实例, 不修改原注册表。 内置工具一览

** ▐文件操作 **\n

\n|\n ** ReadFileTool ** — 读取文件内容 , 动态 import("node:fs/promises") 加载模块。 ** WriteFileTool ** — 写入文件。写入前调用 mkdir(dirname(path), { recursive: true }) 自动创建父目录,避免因目录不存在而失败。 ** EditFileTool ** — 精确文本替换。核心逻辑: const occurrences = content.split(oldText).length - 1;if (occurrences === 0) { return Error: old_text not found in ${filePath};}if (occurrences > 1) { return Warning: old_text found ${occurrences} times in ${filePath}. Please provide a more unique text snippet. No changes made.;}const updated = content.replace(oldText, newText ", "total_lines": 75

深度分析

薄抽象层策略的工程价值

Open Claw 选择直接基于 Anthropic SDK 构建,拒绝 LangChain 等中间层,这一决策背后有深刻的工程逻辑。 当框架层变薄时,API 行为的控制精度直接提升。在调试场景中,开发者可以直接观察到 SDK 发出的原始请求和接收的原始响应,无需穿透多层框架抽象。以 Tool 调用为例:当 LLM 返回一个 function call 时,执行路径是确定的——ToolRegistry.execute()tool.execute() → 返回值。没有任何隐式的中间件链、钩子或转换层打断这个路径。 这种透明性对于问题定位至关重要。当一个 Tool 调用失败时,错误一定发生在 execute() 内部,要么是工具本身的逻辑问题,要么是参数类型不匹配——而不需要排查"是不是框架的某个钩子拦截了"。

ToolRegistry.exclude() 的能力边界控制

exclude() 方法的设计体现了最小权限原则在 Agent 系统中的应用。 主 Agent 的工具集通过 exclude(["spawn", "message", "edit_file", "cron"]) 生成子 Agent 的受限工具集,这个过程有几个关键特性: 不可逆性:原注册表不被修改,子 Agent 持有的是一个新的 ToolRegistry 实例。这意味着即使子 Agent 获得了工具集的引用,它也无法影响主 Agent 的工具注册状态。 显式性:需要排除了什么工具、为什么排除,这些决策是代码层面显式表达的。当新引入一个危险工具时,开发者必须显式地将它加入排除列表,否则就会遗漏。 组合性exclude() 可以链式调用,构建出不同能力的子 Agent。比如可以为"只读分析 Agent"排除 write_fileedit_filespawn,为"执行 Agent"保留部分写入能力。 这个设计相比动态权限控制(如基于策略的配置)更容易追踪和推理,适合在单一进程内构建多租户 Agent 场景。

MessageBus 的双模式消费设计

MessageBus 同时支持 subscribe() 实时回调和 drain() 轮询取走两种消费模式,这个设计反映了对不同运行时场景的适配。 实时回调模式适合常驻服务(如 Bot),消息到达时立即触发处理函数,延迟最低。代价是 handler 执行期间如果存在异步操作,消息处理是串行的。 轮询 drain 模式适合同步消费场景(如 REPL),可以在主循环的特定节点统一消费积压消息,配合互斥锁可以安全地合并到主 Agent 的处理流程中。 路由规则"有订阅者走回调,无订阅者入队列"保证了消息不会同时走两条路径。这是一个简单但重要的决策——如果消息被同时投递到回调和队列,consumer 需要自行去重,增加了状态管理的复杂度。

并发模型与历史记录一致性

Open Claw 的并发控制核心问题是:用户输入和子 Agent 回传都会触发 agent.run(),但 history 数组是共享的。 布尔互斥锁 processing 是这个问题的简洁解法。在 Node.js 单线程模型下,这个布尔标志不会出现竞态——因为事件循环是单线程的,同一时刻只有一个 async 函数在执行。当用户输入到达时,processing = true 锁住主循环;子 Agent 结果到达时,如果 processing 为 true,就只能入队等待。 这个方案的局限在于扩展到多用户时暴露:每个会话需要独立的 history 和独立的 pendingSubagentResults 队列,布尔锁需要升级为会话级的锁或队列管理。当前实现是单用户 REPL 场景的合理折中。

子 Agent 无状态设计的适用边界

子 Agent 每次 spawn 从零开始构建消息,不携带历史上下文。这个设计选择背后有明确的适用边界判断。 适合的场景:

  • 并行任务拆分:搜索、分析、计算等独立子任务,主 Agent 负责任务分发,子 Agent 并行执行后汇总
  • 沙箱隔离:子 Agent 执行危险操作时不会污染主 Agent 的上下文
  • 资源清理:任务完成后自动清理状态,没有跨任务的状态泄漏 不适合的场景:

  • 需要积累知识的任务:比如持续阅读代码库并记住已分析的文件

  • 多轮对话子任务:如果子任务本身需要多轮交互,每次都从零开始会导致上下文丢失
  • 状态依赖的任务:子 Agent 需要读取前一个子 Agent 的输出作为输入时,需要主 Agent 显式中转 这个边界认识很重要。当业务场景超出这个边界时,需要在框架层引入 Memory 或上下文管理能力,而 Open Claw 的当前实现有意回避了这个复杂度。

ExecTool 的三层安全防护

ExecTool 实现了正则黑名单 → 超时控制 → 输出截断的三层防护。 这个分层设计值得注意: 正则黑名单是最外层,阻止已知的高危模式。但正如文章所指出的,LLM 可以通过变量展开、别名、管道组合等方式绕过正则检测。这个防护层的定位是"最低防线",而非"不可逾越的边界"。 超时控制和输出截断是资源层面的保护。30 秒超时 + 2MB buffer + 10000 字符输出截断,这三个数字构成了子 Agent 执行命令的资源天花板。这些限制是硬性的,不会被 LLM 的"花言巧语"绕过。 这个三层设计的启示是:安全防护需要分层,不同层次的防护解决不同维度的问题。纯靠正则黑名单无法防范所有攻击,但完全放弃黑名单也会让常规危险命令畅通无阻。

实践启示

1. 从薄框架开始,保持可替换性

如果你的 Agent 系统需要深度定制 Tool 行为或调试困难的场景,考虑直接从模型 SDK 构建,而不是通过 LangChain 等框架。薄框架不意味着"没有架构",而是让架构更显式、更容易追踪。 同时,即使选择了薄框架,也要保持接入层的可替换性(MessageTool 的 sendCallback 设计是一个好示范)。今天跑在 REPL,明天可能要跑在 Bot 上,不应该为此重写核心逻辑。

2. 能力边界用排除而非授予

在设计子 Agent 或受限 Agent 时,用 exclude() 排除高危工具比显式授予低危工具更安全。 原因是:当你引入新工具时,如果用排除法,遗漏的只是"这个工具有风险";如果用授予法,遗漏的是"这个工具本应该授予"。前者漏网的是少数,后者漏网的是多数。

3. 消息总线的消费模式要匹配运行时特征

选择 subscribe() 还是 drain(),取决于你的运行时特征。 如果是常驻服务(Bot、服务器),用 subscribe() 让消息处理更及时;如果是事件驱动或批处理场景,drain() 的按需消费更灵活。 更重要的是,一旦选定模式,就要让框架的其他部分配合这个选择。Open Claw 的 tryDrainPending() 配合布尔锁,就是为了把 drain() 模式安全地嵌入到 REPL 主循环中。

4. 并发控制从简单方案开始

布尔互斥锁在单线程模型下是有效的简单方案。 不要在需求不明确时就引入复杂的并发控制框架(信号量、Actor 模型等),但要确保你的方案在需要升级时有清晰的升级路径。 Open Claw 的互斥锁限制是:多用户场景需要每个会话独立的 processing 状态和队列管理。这个限制已经足够清晰,当系统需要支持多用户时,开发者知道需要改什么。

5. 安全靠分层,不靠单一防线

ExecTool 的三层防护说明了一个通用原则:安全设计不能依赖单一防线。 正则黑名单防不住所有攻击,但它降低了"手滑"式危险操作的频率;资源限制确保了即使攻击成功,损害也是有限的。 这个原则延伸到其他安全敏感组件:文件操作工具需要路径遍历检查,WebFetchTool 需要域名黑名单,MessageTool 需要频道/接收者验证。每层防线拦截不同维度的风险,多层叠加才是有效的防护。

相关实体