
这几年的工具链项目做下来我越来越确定一件事真正拖垮模块化系统的往往不是业务逻辑变复杂而是“工具之间怎么被组织、被发现、被调用”这件事没人好好设计。我最近在模拟项目X的JavaScript运行时里把内建工具层从“一个大数组遍历注册”重构成了三段式架构代号就叫 Hermes Tools Runtime自注册、AST发现、中央分发派系。改造之后新增一个工具不再需要碰主线代码哪些调用点该用哪个工具编译阶段就能提前标出来运行期所有工具请求统一走一个分发口。这篇文章把整个设计思路、关键代码、踩坑记录都放出来适合正在做插件体系、脚本引擎、大型CLI底座的同学参考。1. 整体架构三阶段的职责边界与数据流1.1 为什么要把“工具管理”拆成三件事第一版实现很简单一个全局数组每个工具模块导出name和handler运行时启动时遍历数组注册。规模小的时候确实爽但随着工具数量增加到几十个问题就开始暴露了——模块加载顺序互相影响、有的工具想“按需加载”却又怕注册不进去、调用侧只能靠硬编码字符串匹配改一个名字要全局搜索替换。后来我把这件事拆成了三个独立阶段各管一段自注册Self-Registration工具模块主动声明“我是谁、我能干什么、我适合什么场景”不依赖中央配置文件。AST发现AST Discovery在编译期扫描用户代码的语法树把“哪些位置调用了工具”提前找出来形成一份调用点清单。中央分发派系Central Dispatch运行期收到调用请求后根据调用点携带的上下文把请求路由给最合适的工具实现。用生活化的类比就是自注册等于每个工具自己走进仓库、贴上能力标签AST发现等于开工前先看图纸标出哪些工序需要哪些工具中央分发等于工人到统一领料窗口报工序号窗口把对应的工具递出来。三者一旦解耦任何一环升级都不会牵动另外两环。1.2 三阶段之间的数据契约三段式设计最关键的不是代码怎么分层而是数据流怎么定。我压了很久才定下三个核心数据结构它们分别对应三个阶段的产品数据结构产生阶段核心字段ToolRegistration自注册name、version、match、priority、loadDiscoveryResultAST发现file、nodeId、calleeName、args、positionDispatchRequest运行期组装toolName、context、args、selectors阶段之间的传递原则是只交数据不交对象。比如 AST 发现阶段会记录调用点在哪个文件的第几行但不会把编译器内部的节点引用直接传给运行期避免跨阶段持有内部对象导致的内存泄漏和耦合。这个数据契约定了以后整个架构就稳了。后面所有优化都在不破坏这三个结构的前提下进行比如给ToolRegistration加tags字段、给DispatchRequest加traceId字段模块之间的接口一直没变过。2. 自注册机制让工具自己走进系统2.1 注册接口与元信息模型自注册的核心是设计一个足够表达“工具能力”的注册接口。我参考了插件系统里常见的做法但做了一点收敛注册时就区分meta和impl两层。// types.ts export interface ToolRegistrationP unknown, C unknown { name: string; version: string; priority: number; // 数值越大越优先 match?: (req: DispatchRequest) boolean; // 能力匹配函数 tags?: string[]; load: () Promise{ apply: (ctx: ToolContextC, P) Promiseunknown; }; }load是异步的返回的是工具的真实实现。这么设计有个实际考量很多工具依赖第三方库如果启动时全部加载首屏时间会很难看。所以注册阶段只登记元信息真正干活时才把实现动态加载进来。工具模块的写法就是一个普通文件里调一次注册函数// tools/yaml-tool.ts import { registerTool } from ../registry; registerTool({ name: yaml-reader, version: 1.0.0, priority: 100, tags: [config, yaml], match: (req) req.args[0]?.endsWith(.yaml) true, load: async () { const { parse } await import(yaml); return { apply: async (ctx) { const content ctx.readFile(ctx.args[0]); return parse(content); }, }; }, });有人在群里问我为什么match和apply要分开因为match是轻量计算可能在一次请求里被调用几十次放核心函数里不合适而apply只调用一次即使慢一点也影响有限。这个拆分让注册表能快速过滤掉大量不相关的工具。2.2 延迟装载与失败隔离自注册最重要的工程价值是做到了“依赖故障隔离”。举个例子某个工具依赖了 Windows 平台的专用 API在 Linux 上注册时不应该报错。因为注册阶段只登记了load函数根本没有执行它所以平台不兼容的模块也能安全注册等到真正被分发时再按需处理。这里有个细节load失败怎么办不能让它把整个进程拖死。我在注册表里加了一个errorBus每个工具的装载异常都被记录下来并标记该工具为“不可用”状态// registry.ts async function resolveTool(name: string) { const reg registry.get(name); if (!reg) return undefined; if (reg.status loading-failed) return undefined; try { const { apply } await reg.load(); reg.status ready; return { apply }; } catch (err) { reg.status loading-failed; errorBus.emit(tool:load-failed, { name, error: err }); return undefined; } }这样即使某个工具有 bug也只影响它自己分发器会将它跳过不会让整体调用链崩溃。2.3 注册窗口与初始化时序自注册机制有个隐性坑就是注册的“时间窗”。如果工具注册发生在第一次分发请求之后那这个工具就永远没机会被新请求看到。我当时的解决方案是引入两阶段初始化启动阶段把所有工具模块await import()一遍触发注册。注册完成后再freezeRegistry()冻结注册表之后不允许新增。// runtime-entry.ts export async function initRuntime(toolModules: string[]) { await Promise.all(toolModules.map((m) import(m))); registry.freeze(); }冻结注册表有两个好处一是所有分发请求面对的是一份稳定的工具列表避免请求过程中工具集合变化导致结果不一致二是可以在冻结后构建一次索引缓存把每个 tag 对应的工具列表预先算好提升分发速度。实际工作中如果确有动态安装新工具的需求我会把冻结改成“安全期/动态期”双模式动态期注册的工具只能影响后续请求不能追溯历史请求。不过这个需求不常见第一版先冻结准没错。3. AST发现在语法树层把使用点找出来3.1 为什么要用AST而不是运行时探测工具光注册还不够还得让系统知道“业务代码里哪里用了你”。最原始的做法是运行时维护一个Proxy拦截所有函数调用发现是工具调用再分发——这个方案的缺点是动态拦截太重而且没法在编译期就给开发者警告。AST发现的思路完全不同代码在编译成字节码之前先交给一个分析器把语法树里所有“像是工具调用”的节点挑出来。为什么用AST而不是字符串匹配举个例子业务代码里可能写的是const cfg utils.config(app.yaml);字符串匹配只能匹配到utils.config这个字符串但 AST 分析能拿到完整的调用表达式callee 是utils.config参数是app.yaml调用位置在第 3 行第 12 列。这些结构化信息让后续分发可以做到精细化路由比如按参数后缀判断用 YAML 解析器还是 JSON 解析器。还有一个优势是可回溯性。发现结果可以序列化成 JSON 存起来当某个调用点出现问题时通过file nodeId能直接定位回源码位置。3.2 匹配规则与发现PipelineAST发现器的实现不复杂就是遍历语法树对CallExpression节点做规则匹配。规则引擎的核心是一个shouldCaptureCall(node)函数// discover.ts import { parse } from babel/parser; import traverse, { NodePath } from babel/traverse; import * as t from babel/types; export interface DiscoveryRule { calleePattern: string | RegExp; minArgs?: number; selectors?: Recordstring, string; } export function discoverCode(source: string, rules: DiscoveryRule[]): DiscoveryResult[] { const ast parse(source, { sourceType: module, plugins: [typescript] }); const results: DiscoveryResult[] []; const isMatch (calleeName: string, rule: DiscoveryRule) { if (typeof rule.calleePattern string) return calleeName rule.calleePattern; return rule.calleePattern.test(calleeName); }; traverse(ast, { CallExpression(path: NodePatht.CallExpression) { const nodePath path.node; const calleeName t.isMemberExpression(nodePath.callee) ? ${nodePath.callee.object.name}.${nodePath.callee.property.name} : t.isIdentifier(nodePath.callee) ? nodePath.callee.name : ; const matchedRule rules.find((r) isMatch(calleeName, r)); if (!matchedRule) return; results.push({ file: path.hub.file.opts.filename || anonymous, nodeId: String(nodePath.start), calleeName, args: nodePath.arguments.map((arg) { if (t.isStringLiteral(arg)) return arg.value; if (t.isNumericLiteral(arg)) return arg.value; return source.slice(arg.start, arg.end); }), position: { line: nodePath.loc.start.line, column: nodePath.loc.start.column, }, }); }, }); return results; }这个实现的匹配纬度可以组合按calleePattern匹配函数名按minArgs过滤参数个数还可以通过selectors对特定实参做二次校验。实际用下来我建议把发现规则收敛在独立的配置文件里不要散落在代码各处。比如discovery.config.ts里集中定义export const DISCOVERY_RULES [ { calleePattern: /^utils\.(config|readSettings)$/, minArgs: 1 }, { calleePattern: /^tool\.dispatch$/, minArgs: 2 }, { calleePattern: runPipeline, minArgs: 1 }, ];统一管理的好处是当新增一个工具入口时只需要在这个文件里加一行规则改动点非常集中。3.3 静态发现的边界与兜底策略AST发现虽然好但我必须坦白它的边界静态分析永远无法覆盖动态调用。比如这段代码const fnName determineToolName(); // 运行时才知道 utils[fnName]();AST 根本不知道fnName的值是多少也没法预判它最终会调用哪个工具。所以这套体系里我始终保留了一个“运行时兜底发现”的接口// dispatch.ts 里的兜底分支 export async function dispatchTool( toolName: string, args: unknown[], ctx: ToolContext ): Promiseunknown { // 动态调用没有 AST 结果就用工具名直接找注册表 const tool registry.get(toolName); if (tool) { return runTool(tool, args, ctx); } throw new ToolNotFoundError(toolName); }这里还有个性能技巧发现阶段生成的DiscoveryResult[]可以序列化后做持久化缓存。因为 AST 扫描本身是 CPU 密集型操作扫描一次项目可能要几百毫秒但如果代码没变这个结果完全可以复用。我用的策略是把文件哈希作为缓存 key代码没变就直接读缓存跳过扫描。4. 中央分发派系统一入口、优先级与上下文4.1 分发路由与阶段化流水线中央分发派系是整个运行时的心脏它接收所有工具调用请求完成“找到最合适的工具”和“执行并返回结果”两件事。分发器的核心是一个阶段化流水线// dispatch.ts export async function resolveAndDispatch( req: DispatchRequest, registry: Registry ): PromiseDispatchOutcome { const candidates registry.filter((reg) { if (req.toolName reg.name ! req.toolName) return false; if (reg.match !reg.match(req)) return false; return true; }); candidates.sort((a, b) b.priority - a.priority); // 按优先级逐个尝试直到有工具成功处理 for (const reg of candidates) { const outcome await tryApplyTool(reg, req); if (outcome.status success) return outcome; if (outcome.status abort) return outcome; // 主动终止 } return { status: not-found }; }流水线的每一步都有明确的语义先是“过滤”把明显不匹配的工具剔掉然后是“排序”让高优先级的工具靠前最后是“执行”在这里对每个候补工具做容错处理——如果前一个抛错可以在错误类型允许时进入下一个候选避免一个工具报错导致整个请求失败。简单说这个分发器就是“责任链 策略模式”的组合体但它比教科书版本更贴近真实场景因为执行过程是异步的所以责任链的交接点是Promisevoid每个工具都是独立异步执行不会我 await 你、你 await 我的互相卡住。4.2 优先级设计与冲突消解优先级是整个机制里最容易斗智斗勇的地方。我第一版用的是简单排序谁注册晚谁靠前。后来发现靠注册顺序决定优先级完全不可靠因为工具模块的加载顺序是受构建工具影响的换一个打包器结果可能就全变了。后来我把优先级细化成三级显式优先级通过priority字段声明值范围 0~1000越高越优先隐式优先级由工具的tags和调用点的selectors匹配度决定注册顺序兜底作为相同优先级的平局裁决。实际项目中我见过一个分布式配置系统里同时注册了“本机配置读取”和“远端配置拉取”两个工具二者都声明了 100 优先级结果本机读取永远被远端拉取压制导致本地调试时配置一直不对。解决办法是给工具匹配函数降权——通过参数里的mode: local精确命中本机读取工具让它显式胜出。所以优先级设计里的核心原则是显式声明大于隐式推断隐式推断大于注册顺序。也就是代码里写清楚的优先级设置优先级数值越小影响越小但如果没写系统按调用点的 tag 匹配度判断例如调用点声明了tags [local]那么有该标签的工具自动提前如果标签也不匹配才按注册先后排。这样设计以后几乎不存在两个工具完全平手的情况。4.3 上下文隔离与执行策略中央分发派系里还藏着一个不太起眼但极其重要的细节上下文隔离。每个请求进来时运行时创建一个全新的ToolContext,它包含了一个自增的requestId用于链路追踪一个只读的参数快照一个工具之间传递数据的共享区域一个abortController用来实现超时控制。export interface ToolContextI unknown, O unknown { requestId: number; args: I[]; shared: Mapstring, unknown; signal: AbortSignal; readFile: (path: string) string; writeFile: (path: string, data: string) void; }为什么强调“隔离”因为分发出去的工具实际是共享同一个进程安全的。如果工具 A 往globalThis上挂了临时变量工具 B 读到了就会产生诡异的 bug。所以我把所有可能产生副作用的操作都收敛到ToolContext上工具之间不允许直接访问对方的返回值——返回值的传递必须经过分发器。执行策略上我还支持两种模式exclusive互斥执行和pipeline流水线执行。互斥模式适合“配置读取”这类场景最终只返回一个结果流水线模式适合“日志处理”这类场景上一个工具的输出作为下一个工具的输入。模式由注册表里的mode字段来控制兼顾了灵活性和可控性。5. 实操拆解从零搭一个可用版本5.1 目录结构与核心类型光讲理论没用我实操演示一下怎么从空目录把 Hermes Tools Runtime 的最小可运行版本搭起来。首先是目录结构hermes-runtime/ src/ types.ts registry.ts discover.ts dispatch.ts index.ts tools/ yaml-tool.ts remote-tool.ts test/ sample.js package.json tsconfig.jsontypes.ts我前面已经给过主要接口了补充一个DispatchOutcome类型export type DispatchOutcome | { status: success; value: unknown; usedTool: string } | { status: abort; reason: string; usedTool: string } | { status: not-found };所有类型定义集中放在一个文件里方便后续做 d.ts 导出和 SDK 接入。模块之间只通过类型引用避免循环 import。5.2 AST发现扫描器的落地实现discover.ts的完整代码比较长我给出关键部分。这里我增加了一个新规则不光是精确匹配函数名还支持按 member expression 层级匹配function calleeNames(path: NodePatht.CallExpression): string[] { const names: string[] []; let node: t.CallExpression[callee] path.node.callee; while (t.isMemberExpression(node)) { const objName t.isIdentifier(node.object) ? node.object.name : ?; const propName t.isIdentifier(node.property) ? node.property.name : ?; names.unshift(${objName}.${propName}); node node.object; } if (t.isIdentifier(node)) { names.unshift(node.name); } return names; }这样做的好处是utils.config(a.yaml)可以匹配到utils.config同时也能匹配到utils.config.remote(a.yaml)这样的链式调用。工具匹配函数里就可以写不同的calleePattern来分别处理。然后把规则匹配和异步加载组合起来就得到一个完整的“发现 分发”接口// index.ts import { discoverCode } from ./discover; import { resolveAndDispatch } from ./dispatch; export async function executeTooledCode( source: string, rules: DiscoveryRule[], defaultCtx: ToolContext ) { const discoveries discoverCode(source, rules); // AST发现结果可以直接用于构建调用点画像 const toolMap new Mapstring, DiscoveryResult[](); for (const d of discoveries) { const list toolMap.get(d.calleeName) ?? []; list.push(d); toolMap.set(d.calleeName, list); } const outputs: Array{ callee: string; value: unknown; status: string } []; for (const d of discoveries) { const outcome await resolveAndDispatch( { toolName: d.calleeName, args: d.args, selectors: {} }, defaultCtx ); outputs.push({ callee: d.calleeName, value: outcome.status success ? outcome.value : undefined, status: outcome.status, }); } return outputs; }executeTooledCode是外部用户唯一需要看到的入口它把 AST发现和中央分发串起来内部两个阶段完全黑盒隔离。5.3 自注册与中央分发串联运行最后是运行端。我写一个测试样例模拟工具加载和调用的完整链路。工具注册放在tools/目录里然后启动入口统一导入// tools/yaml-tool.ts import { registerTool } from ../src/registry; registerTool({ name: yaml-reader, version: 1.0.0, priority: 100, match: (req) String(req.args[0]).endsWith(.yaml), load: async () ({ apply: async (ctx) { const filePath ctx.args[0] as string; const content ctx.readFile(filePath); return { source: content, parsed: true }; }, }), }); // tools/remote-tool.ts import { registerTool } from ../src/registry; registerTool({ name: remote-config, version: 1.0.0, priority: 80, match: (req) req.toolName remote-config || req.args[0]?.startsWith(http), load: async () ({ apply: async (ctx) { const url new URL(ctx.args[0] as string); return { url: url.href, fetched: true }; }, }), });在主入口里先导入工具模块然后执行样例代码// main.ts import { initRuntime, executeTooledCode } from ./index; import ./tools/yaml-tool; import ./tools/remote-tool; const mockCtx: ToolContext { requestId: 0, args: [], shared: new Map(), signal: new AbortController().signal, readFile: (p) mock content of ${p}, writeFile: () {}, }; async function main() { await initRuntime([./tools/yaml-tool.ts, ./tools/remote-tool.ts]); const code const local utils.config(app.yaml); const remote utils.config(https://example.com/app.yaml); ; const outputs await executeTooledCode(code, [ { calleePattern: /^utils\.config$/, minArgs: 1 }, ], mockCtx); console.log(JSON.stringify(outputs, null, 2)); } main();输出结果[ { callee: utils.config, value: { source: mock content of app.yaml, parsed: true }, status: success }, { callee: utils.config, value: { url: https://example.com/app.yaml, fetched: true }, status: success } ]两次调用都走到了utils.config但第一个参数以.yaml结尾命中了yaml-reader第二个参数是 URL默认没命中本地工具然后分发器沿着候选链继续找到了remote-config——注意这中间remote-config的match最开始判断req.toolName remote-config并不成立因为toolName是utils.config但第二个条件args[0].startsWith(http)让它挂上了钩。这正是中央分发派系灵活性的体现一个工具既可以被显式调用也可以在别的工具不匹配时兜底接管。6. 常见问题与排查实录6.1 自注册缺席工具明明写了却不生效自注册最大的坑是“工具没被加载”。我遇到过两次代码里写了registerTool运行时分发时却说找不到。最后定位原因工具模块没有被任何入口文件 import所以初始化阶段根本没有执行到注册函数。排查手法是给注册表加一个调试端点把当前已注册的工具列表暴露出来// registry.ts export function dumpRegistry(): Array{ name: string; status: string } { return [...registry.entries()].map(([name, reg]) ({ name, status: reg.status ?? pending, })); }一旦发现某个工具缺失优先检查 import 链。这个现象在 monorepo 里尤其高频工具包没有被人从入口文件引过注册自然落空。6.2 AST发现误报编译期收录了不该收录的路径AST扫描是“静态盲扫”它分不清代码分支会不会执行到。比如if (process.env.DISABLE_TOOLS ! 1) { utils.config(app.yaml); }AST 发现器照样会把这一行收进结果。如果后续配合流水线执行就会在 DISABLE_TOOLS1 的情况下也执行工具调用造成和预期不符。我的处理方式是在规则里加入分支可达性判断或者简单一点用环境变量在分发阶段再做一次过滤。折中方案是用 AST 的父节点链检查是否在if分支内是的话给DiscoveryResult加一个conditional标志由分发器决定是否忽略。6.3 分发顺序倒挂默认实现把业务实现覆盖了这个坑我前面提过再补充一个实际例子。有一套通用日志工具集注册了一个默认的 JSON 日志工具优先级 50。后来业务方加了一个 console 日志工具优先级 100。结果全系统的日志输出全部变成 console 格式反而没人想要的分发器却把默认工具挤掉了——因为默认工具匹配函数写得太宽任何日志请求都命中。观察这个问题的技巧是分发器记录“每个请求命中了谁、还有哪些候选被跳过”。把路由日志打开看到多余的命中就能意识到是匹配条件太宽还是优先级倒挂。修复通常是在默认工具的match里加一个更精确的条件比如排除业务方的特定 tag。6.4 排查工具配置速查现象可能原因推荐排查动作工具不生效注册缺失dumpRegistry 检查是否注册成功工具加载失败依赖报错看 errorBus 里的 load-failed 事件AST 发现漏掉调用动态调用无法静态发现改用运行时兜底的 dispatchToolAST 发现误收路径条件分支被收录给 DiscoveryResult 打 conditional 标记分发一直 not-foundmatch 条件没对上在 dispatch 入口打印请求 Args 和候选列表工具优先级倒挂match 范围过宽收敛 match 条件缩窄能力边界还有一个我自己习惯用的高级技巧在分发器中埋一个采样器随机记录 1% 的请求上下文和最终结果写到独立日志文件。等到线上出问题时通过requestId把发现结果、路由日志、工具执行日志串起来五分钟内就能定位故障发生在哪个阶段。对三阶段架构来说这个观测能力比什么优化都管用。7. 这个架构还可以往哪里延伸我在实际使用中最大的体会是自注册、AST发现、中央分发这三个名词听起来像三个独立的东西但它们实际上是一条数据流水线上的三个环节。自注册告诉系统“我有什么能力”AST发现告诉系统“哪些代码位置需要能力”中央分发负责把能力匹配到位置上。三者的数据契约一旦稳定扩展就会变得非常顺畅。这套设计目前已经在模拟项目X里跑了大半年新增工具的平均耗时从原来的改配置、改数组、查日志半小时降到了只写一个工具文件、注册一行、测试一遍。后续我打算把它往两个方向扩展一是把 AST 发现结果输出成标准的调用点镜像让构建插件也能复用它做静态分析二是给中央分发器加一层“动态插桩能力”在线上按需注册降级工具如熔断变体、灰度版本等进一步提升运行时自愈能力。如果你也在折腾自己的工具链底座按这三个阶段搭骨架绝对靠谱。开始时不需要做得太复杂先把registerTool和dispatchTool跑通就已经解决了一大半问题AST 发现可以随着项目的语法规则稳定后再慢慢加。真正的架构竞争力不是一开始堆了多少功能而是数据流能不能清晰到让每个新增的工具都“无感接入”。