构建轻量级工具运行时:AST发现+自注册+中央分发机制实践
在接手一个中等规模的 DevOps 工具链重构时我最头疼的问题不是写代码而是工具之间的协作方式。早期为了图省事所有脚本都堆在一个目录里靠文件名前缀区分功能调用关系全靠文档约定维护。后来工具数量到了十几个又引入了一个简单的配置文件来声明入口但每次新增工具都要改配置、改文档、通知所有人稍不注意就漏改或者该错运行时报错说找不到某个工具追了半天发现是配置路径拼错了。当时团队里有人提议引入一套“能自己发现工具、自己注册、统一分发”的运行时这就是后来我们花了两周时间落地的一套轻量级工具运行时模型代号 Hermes。它不是现成的框架而是基于AST 发现 自注册 中央分发三种机制组合的一套基础设施。核心思路很简单用静态扫描的方式替代人工登记用代码内声明的方式替代外部配置文件用一个统一入口替代到处直接调用。这套模型最终把新增工具的平均接入时间从半小时压缩到了三分钟左右而且基本消灭了“工具找不到”“参数对不上”这类低级故障。这篇内容适合正在搭建内部工具平台、或者维护多个子工具/插件体系的开发者和架构师参考。我会把这套系统的拆解思路、关键实现、以及实际运行中踩过的坑一次性讲清楚。代码示例是实际系统的简化版换掉业务相关部分后可以直接套用。1. 整体设计思路为什么这三大机制缺一不可1.1 明确要解决的核心痛点在动手设计 Hermes 之前我们先梳理了当时工具链存在的三个最直接的痛点。第一个痛点是人工登记必然产生维护摩擦。每个新工具上线需要创建脚本文件、编写入口函数、修改注册表或配置文件、更新调用方文档。任何一个环节遗漏工具要么不可见要么可见不可用。当工具数量超过二十个之后这套流程的出错率明显上升。第二个痛点是运行时的调用方太过分散。不同工具散落在脚本、自动化任务、Web 后台等多个入口中互相之间还可能存在依赖。一旦某个工具的入参格式调整所有调用点都要跟着改。这就导致“改一行代码改半天调用链”的尴尬局面。第三个痛点是无法动态感知工具的新增或者变更。工具链是活的每天可能都有脚本合并、参数调整、输出格式变化。静态的配置登记方式完全不支持运行时感知变更要么定期扫描文件碰运气要么人工盯着 commits。这三个痛点直接决定了我们需要的机制不用手动登记的发现机制、调用方不直接依赖具体实现的注册机制、以及能统一收口调用的分发层。于是“AST 发现 自注册 中央分发”这套组合就成了最自然的解——AST 扫描负责发现运行时注册负责宣告分发器负责统一路由。1.2 三种机制的职责边界与协作关系这套体系里三种机制各管一段边界非常清晰AST 发现在编译前或构建初期扫描源码目录静态解析出有哪些工具声明、工具具有什么元数据和能力特征生成一份“工具注册清单”。自注册在工具代码被加载时通过装饰器或注册函数把工具的实际执行函数挂载到运行时提供的注册表中完成能力和实现的一一绑定。中央分发所有对工具的调用请求统一进入一个分发器入口分发器根据名称、上下文或参数特征去注册表查找匹配的实现执行调用并返回结果。三者协作的典型时序是工具作者写代码时通过一个注册函数声明工具身份与能力运行时启动时先由 AST 扫描器对源码目录做静态分析得到一个预估的工具清单用于校验和提示随后加载模块触发自注册把真实实现填入注册表外部调用方只调用分发器的统一接口分发器查到注册表后完成调用。这个设计最大的好处是发现与注册解耦。AST 发现解决的是“有什么工具”的认知问题自注册解决的是“如何把实现放进来”的接入问题。前者侧重于静态可分析性后者侧重于运行时动态性。两者结合既能提前发现潜在的工具列表又能保持运行的灵活性。1.3 为什么选择静态 AST 而非运行时仅扫描单独使用运行时动态扫描比如遍历目录加载所有.tool.js文件也能实现工具发现但我们最终选择了以 AST 为主、运行动态加载为辅的混合策略原因有三个。第一静态分析可以在不上线、不执行的情况下完成全量校验。AST 扫描在 CI 阶段就能执行可以提前拦截“工具元数据不合法”“缺少必要声明字段”“工具名重复”这类问题反馈速度远优于运行时才报错。第二静态扫描可以生成索引和文档。扫描结果可以直接生成工具 API 列表、变更 diff对大型团队协作非常有帮助。第三静态信息支持更丰富的能力分析。通过解析 AST我们可以识别出工具函数依赖了哪些外部模块、标注了哪些能力标签这些信息在运行时动态加载中很难干净地获取尤其是异步依赖注入的场景。当然纯静态分析也有一些盲区比如动态生成文件、字符串拼接的代码路径、间接依赖等场景。所以最终落地是“AST 扫 运行时补”双轨模式扫描生成的清单作为基线和校验依据自注册负责最后兜底。这套组合里两个层面互相弥补比任何单一机制都健壮。2. 工具声明与自注册机制实现细节2.1 工具元数据声明格式工具的统一声明是所有上层机制的基础。我们在 Hermes 里定义了一个建议性的元数据格式工具作者需要遵循这个结构来声明自己的工具身份。一个标准的工具单元由四个核心部分组成name是工具在分发系统中的唯一标识description是用于发现和索引的功能描述tags是能力标签数组方便按领域分组查找handler是真正执行工具逻辑的异步函数。// hermese-core/types.ts export interface HermesToolMeta { name: string; version?: string; description: string; tags?: string[]; author?: string; params?: Recordstring, ParamSpec; handler: (ctx: ToolContext, args: Recordstring, any) Promiseunknown; }params字段是可选的参数描述用于声明工具接受哪些入参、各自的类型和是否必填。虽然没有类型系统那么严格但它在分发器的参数校验环节很有用也让 AST 扫描器可以做静态的接口预检。我们在实际项目中要求每个工具必须声明name和handlerdescription强烈建议必填其他字段视运行场景按需补充。2.2 自注册的两种典型实现装饰器与注册函数自注册机制最常见的实现方案有两种装饰器Decorator方式和注册函数方式。两种方案在 Hermes 中都支持因为不同团队对代码风格的偏好不同而且底层注册表机制是一样的。装饰器方案看起来更简洁特别适合用面向对象的风格组织工具类或者一组相关函数。我们在源码里提供了一个RegisterTool装饰器工具作者只要把它套在核心执行函数上并传入元数据对象即可import { RegisterTool, ToolContext } from hermese-core; RegisterTool({ name: git.mergeCheck, description: 检查两个分支的合并冲突风险, tags: [git, devops], params: { base: { type: string, required: true }, target: { type: string, required: true }, }, }) export async function mergeCheck(ctx: ToolContext, args: { base: string; target: string }) { // 工具执行逻辑 const conflicts await detectMergeConflicts(args.base, args.target); return { hasConflicts: conflicts.length 0, conflictCount: conflicts.length }; }装饰器本质上是编译期语法糖运行时会在模块加载阶段被转译成注册调用。因此装饰器方式天然适合“加载即注册”的自注册语义前提是工具所在的代码文件必须在运行时被 import 到进程中。注册函数方式则更直白适合不想引入装饰器编译配置的场景。在registrar.ts里导出一个registerTool函数工具文件显式调用它// tools/notify.tool.ts import { registerTool } from hermese-core; export async function handleNotify(ctx: ToolContext, args: { channel: string; message: string }) { await sendNotification(args.channel, args.message); return { delivered: true, channel: args.channel }; } registerTool({ name: notify.send, description: 发送通知到指定渠道, tags: [notify, alert], handler: handleNotify, });这两种方式最终都汇聚到同一个注册表实例中用一个ToolRegistry类来管理。注册表的实现极简本质是一个 Map 加几个增删查的方法但有几个细节值得注意注册表需要支持防重复注册同一个 name 只能注册一次如果重复注册可以覆盖但必须打印警告日志避免静默吞掉冲突。注册表应该记录每个工具的注册时间、版本号、来源模块路径方便后续排查注册来源混乱的问题。注册表需要有反注册能力在一些动态卸载、热更新的场景会用到虽然日常很少触发但没有这个能力会导致模块替换后旧逻辑残留在表里。// runtime/registry.ts import { HermesToolMeta } from ./types; class ToolRegistry { private items new Mapstring, HermesToolMeta { registeredAt: number; sourcePath?: string }(); register(meta: HermesToolMeta, sourcePath?: string): void { const now Date.now(); if (this.items.has(meta.name)) { const prev this.items.get(meta.name)!; console.warn( [hermes] tool ${meta.name} re-registered, previous from ${prev.sourcePath}, new from ${sourcePath} ); } this.items.set(meta.name, { ...meta, registeredAt: now, sourcePath }); } unregister(name: string): boolean { return this.items.delete(name); } get(name: string) { return this.items.get(name); } list() { return Array.from(this.items.entries()).map(([name, meta]) meta); } } export const registry new ToolRegistry();自注册机制最大的优势是去除集中式配置文件。没有自注册之前每次加工具都要改一个全局tool.config.json然后祈祷没有改错格式有自注册以后工具就是带着身份信息的一段代码放进模块目录就会出现在系统里。这让工具链具备了真正的可插拔性——新增工具不需要动任何既有文件删除工具也只需要移除对应代码文件。注意自注册并不意味着“不需要构建配置”。工具文件仍然需要被构建系统纳入打包范围且如果采用装饰器方案需要确保转译器配置正确否则装饰器会被静默移除注册代码不会执行。2.3 运行时启动时自注册如何编排为了让多个工具文件在运行时被主动加载Hermes 约定工具模块统一放在项目的tools/目录并在 CLI 入口中用一次集中导入来触发所有注册// cli.ts import { loadToolModules } from hermese-core; import { registry } from ./runtime/registry; import { HermesDispatcher } from ./runtime/dispatcher; async function bootstrap() { // 1. 注册前执行静态扫描生成基线清单 const baseline await scanToolSources(); // 2. 集中加载 tools 目录下所有工具模块触发各自的注册逻辑 const toolModules await loadToolModules(./tools); // 3. 校验注册结果与基线清单的一致性 validateRegistryConsistency(registry, baseline); // 4. 初始化中央分发器 const dispatcher new HermesDispatcher(registry); // 5. 启动服务 await dispatcher.start(); }loadToolModules的实现可以是动态 import 或者是通配符加载取决于项目采用的构建工具。实际项目里我们用了一种比较朴素但对构建工具友好的写法在工具目录放置一个入口文件显式 import 所有工具模块。// tools/index.ts import ./git.mergeCheck.tool; import ./git.branchClean.tool; import ./notify.send.tool; import ./artifact.push.tool;这样每个工具模块在上层入口启动时都会被加载一次装饰器或注册函数随之执行注册表便完成了填充。自注册执行得越早越好所以它会被放在服务进程启动的第一阶段。注册完成之后分发器才开始接受调用请求这保证了“注册”与“服务”两个阶段不会互相穿插而出错。3. AST 发现引擎的深度实现3.1 AST 扫描的总体流程与架构AST 发现是整个系统中技术含量最高、也是最容易出bug的部分。它的核心目标是在不运行工具代码的前提下从源码中提取出“可能注册了哪些工具、这些工具宣称了什么能力”。我们实现的扫描器整体流程分为四步遍历指定目录收集满足扩展名约定的源文件列表对每个文件分别解析成抽象语法树在 AST 中遍历节点匹配工具的声明模式将匹配到的工具信息汇总成基线清单供后续校验和索引使用这个扫描过程需要文件系统模块、语法解析器和 AST 遍历器的协同。我们并没有自己从零写 JS parser而是直接复用了稳定的编译器基础设施这部分是在性能和可维护性上最明智的取舍。如果你打算自己实现同类的扫描能力建议优先复用成熟基础库而不是手写语法分析。3.2 针对装饰器调用模式的 AST 提取方案我们先针对装饰器写法做 AST 提取。在一个使用了RegisterTool({...})的 TS 文件里AST 结构大约如下极简化表示SourceFile └─ ExportNamedDeclaration └─ FunctionDeclaration (name: mergeCheck) ├─ Decorator │ └─ CallExpression (callee: RegisterTool, args: [ObjectLiteralExpression]) └─ ...扫描器的任务就是遍历所有 Export 节点检查其子节点是否有 DecoratorDecorator 的 callee 名称是否是RegisterTool如果是则提取紧随之后的ObjectLiteralExpression的属性键值对得到name、description、tags、params等字段再结合被装饰函数的名字。一个简化版的装饰器扫描实现如下// discover/scanDecorators.ts import { parse, traverse, NodePath } from babel/core; export function extractToolsFromDecorators(source: string, filePath: string): ScannedTool[] { const ast parse(source, { plugins: [typescript, decorators-legacy], sourceType: module }); const results: ScannedTool[] []; traverse(ast, { ExportNamedDeclaration(path) { const declaration path.node.declaration; if (!declaration || declaration.type ! FunctionDeclaration) return; const decorators (declaration as any).decorators; if (!decorators || decorators.length 0) return; for (const decorator of decorators) { const expr decorator.expression; if (expr.type CallExpression (expr.callee as any).name RegisterTool) { const metaObj expr.arguments[0]; if (metaObj metaObj.type ObjectExpression) { const meta extractObjectExpression(metaObj); results.push({ sourceFile: filePath, declaredName: meta.name || declaration.id?.name, description: meta.description || , tags: meta.tags || [], params: meta.params || {}, handlerName: declaration.id?.name, }); } } } }, }); return results; }关键点在extractObjectExpression函数上它需要把 ObjectExpression 里的StringLiteral、ArrayExpression、ObjectExpression等节点分别翻译成元数据对象。属性名可以直接拿key.name或key.value属性值需要根据节点类型做转换。潜在的风险是元数据里如果出现函数表达式、模板字符串等动态内容静态提取会失真。所以在实际项目中我们明确约定工具元数据字段只允许字面量不允许引用变量或函数调用。这个限制起初被同事吐槽不灵活但后来事实证明它保证了 AST 发现的确定性也让注册表能够安全地序列化和对比。3.3 针对注册函数调用模式的 AST 提取方案注册函数方式没有装饰器节点可找但 AST 模式同样很明确需要匹配的是顶层callExpression其 callee 名称为registerTool的节点。在处理注册函数调用时我们不再只看 Export 语句而是在整个文件顶层去找这种调用表达式并且提取第一个对象字面参数。核心逻辑碎片如下// discover/scanRegisterCalls.ts export function extractToolsFromRegisterCalls(source: string, filePath: string): ScannedTool[] { const ast parse(source, { plugins: [typescript], sourceType: module }); const results: ScannedTool[] []; traverse(ast, { CallExpression(path) { const callee path.node.callee; if (callee.type Identifier callee.name registerTool) { const arg path.node.arguments[0]; if (arg arg.type ObjectExpression) { const meta extractObjectExpression(arg); results.push({ sourceFile: filePath, declaredName: meta.name, description: meta.description || , tags: meta.tags || [], params: meta.params || {}, handlerName: meta.handler ? extractFunctionName(meta.handler) : undefined, }); } } }, }); return results; }需要注意registerTool可能是从hermese-core导入的也可能被重命名为其他本地名字。如果团队习惯用别名 import扫描器光靠名称匹配就不够稳了。更稳妥的做法是在解析 AST 时维护 import 映射表先扫描文件顶部的 import 声明建立本地别名 - 真实来源的映射然后匹配callee时不仅认名字也认来源。这个细节要不要做取决于你对扫描精度的要求。我们内部一开始没做后来出现过一个工具因为依赖方式不同而被漏扫的 case花了半小时定位最后还是把 import 解析补上了。3.4 扫描结果的聚合与差异校验逐个文件扫描完成之后还需要聚合与校验。聚合就是把多个文件提取到的工具列表合并成一个总清单按工具名去重。如果出现重名扫描器应产生告警而不是静默丢弃——重名在自注册中大概率意味着一次意外覆盖。校验阶段我们用基线清单与实际注册表做一次差异比对检查场景包括清单里有一个工具但注册表里没有说明自注册环节出了问题比如模块没被加载注册表里有一个工具但清单里没有说明该模块的写法不符合扫描约定可能存在注册函数在深层代码而非顶层调用的情况同一个工具在两个来源的元数据不一致说明代码被修改后没有同步扫描这步比对能在系统启动时就暴露问题而不是等到调用该工具时才报“not found”。我们运维时对这套校验的依赖度非常高它让“工具悄然消失”这类问题最多在启动阶段就会被拦截。提示AST 扫描不必追求覆盖 100% 的代码形态重点是覆盖约定之内的标准形态然后靠自注册兜底。如果发现某个边缘写法总是漏扫优先检查这个工具模块的注册方式是否符合约定而不是无限扩展扫描器的匹配规则。4. 中央分发器路由、调用与节流4.1 分发器的整体接口设计分发层是 Hermes 体系中对调用方最透明的部分。外部模块原则上不直接 import 具体工具函数而是统一调用Dispatcher.dispatch或等价的 CLI 命令。分发器接口经历了三轮迭代后最终稳定成以下形态// runtime/dispatcher.ts export interface DispatchRequest { tool: string; args?: Recordstring, unknown; context?: DispatchContext; } export interface DispatchResult { ok: boolean; data?: unknown; error?: { code: string; message: string; }; durationMs: number; } export interface DispatchContext { traceId?: string; workspace?: string; user?: string; timeoutMs?: number; }dispatch方法接收工具名、参数字典和一个可选上下文对象。上下文里可以携带调用来源标记、超时控制、链路追踪 ID。所有工具调用都返回一个统一的结构包含成功标记、实际数据或错误信息、耗时这样外部调用方就不用再自行处理各种工具间不一致的返回风格了。export class HermesDispatcher { constructor(private registry: ToolRegistry) {} async dispatch(req: DispatchRequest): PromiseDispatchResult { const started Date.now(); const toolMeta this.registry.get(req.tool); if (!toolMeta) { return this.fail(TOOL_NOT_FOUND, tool ${req.tool} is not registered, started); } // 参数校验 if (toolMeta.params) { const invalid this.validateParams(toolMeta.params, req.args ?? {}); if (invalid.length) { return this.fail(INVALID_PARAMS, param check failed: ${invalid.join(, )}, started); } } // 超时与执行 try { const result await this.withTimeout( toolMeta.handler(this.buildToolContext(req.context), req.args ?? {}), req.context?.timeoutMs ?? DEFAULT_TIMEOUT_MS ); return { ok: true, data: result, durationMs: Date.now() - started }; } catch (e) { return this.fail(TOOL_EXEC_ERROR, (e as Error).message, started); } } private fail(code: string, message: string, started: number): DispatchResult { return { ok: false, error: { code, message }, durationMs: Date.now() - started }; } // ... }分发器屏蔽了底层工具的差异提供一个统一的 Promise 型接口。调用方不再关心工具是同步函数还是异步函数、是本地脚本还是远程调用因为这些在注册时已经被封装在了 handler 内部。4.2 路由策略与版本匹配中央分发的核心逻辑路由按工具名查找虽然简单但在较复杂的场景下还不够。实际操作里我们至少需要区分三种路由模式。第一是名称精确匹配。这是最简单的 route适用于系统内部确认性非常高的调用。唯一值得注意的是大小写规范化我们约定统一使用namespace.toolName风格比如git.mergeCheck、notify.send避免不断嵌套目录带来的命名混乱。第二是前缀模式匹配。有些调用方希望一次触发一组工具比如artifact.*对应所有与产物构建相关的工具。分发器支持传入artifact.*的通配pattern内部会去注册表里 reverse-search 所有 name 前缀匹配的工具并依序执行。这对批量数据回刷、批量通知、批量清理类的运维场景特别实用。第三是能力标签匹配。注册元数据里的tags字段就是为此设计的。调用方可以这样请求find all tools tagged with report and pdf分发器按标签索引返回候选工具列表再由调用方决定执行哪些。这种模式适合做工具推荐、动态编排和自定义工作流拼接。随着工具链的增长同一工具的多版本共存问题也会浮现。我们的处理方式是注册表只保留当前有效版本旧版本通过 Git 历史保留必要时使用nameversion形式注册。在注册阶段同一 name 重复注册会覆盖旧版本并记录警告日志这既保证了注册表的稳定又能让分发明细查到覆盖痕迹。如果你需要完整的多版本并存能力则要把version纳入路由键即注册和查询都允许name1.2.3形式但一般来说内部工具链不需要走到这个复杂度。4.3 参数校验与上下文传递机制validateParams的实现看起来不复杂但它是很多运行时错误的第一道防线。我们对每个params字段支持typestring/number/boolean/array/object、required、enum和validate回调函数。这些信息可以来自 AST 扫描提取的静态声明也可以在注册时动态补充。// runtime/paramValidator.ts function validateParams(specs: Recordstring, ParamSpec, args: Recordstring, any): string[] { const errors: string[] []; for (const [key, spec] of Object.entries(specs)) { if (spec.required args[key] undefined) { errors.push(missing required param ${key}); continue; } if (args[key] ! undefined spec.type) { const ok checkType(args[key], spec.type); if (!ok) errors.push(param ${key} expects ${spec.type}, got ${typeof args[key]}); } } return errors; }参数校验的粒度不应过度细化。我见过有些团队给参数加了一堆复杂约束最终花在参数定义上的时间比工具实现本身还多。我的经验是只校验影响工具执行的必要约束——类型、必填、少许枚举值就够了。超出这个范围把校验交给工具函数内部的业务逻辑去处理反而更清晰。上下文传递方面分发器会把DispatchContext封装成工具执行时的ToolContext主要承担三类信息运行环境信息工作目录、临时目录、环境变量可观测信息traceId、调用来源、耗时以及I/O 辅助能力日志器、告警器、文件读写封装。这样每个工具不需要自己处理“我该怎么写日志”“我该怎么上报耗时”这些基础能力由上下文统一提供。我在实际设计中踩过的坑是早期上下文里直接透传了全局配置对象导致工具代码里到处都是ctx.config.xxx的耦合。后来改为只暴露四个维度目录、日志、时间、链路标识工具的测试难度和可复用性立即改善了很多。上下文不是工具箱暴露得越少越好。4.4 调用治理超时、并发限制与重试策略除了基础分发一个好的分发器还需要做调用治理。我们在 Hermes 里实现了三个必要的治理策略。超时控制是最基本的。每个工具调用都必须有明确的超时上限默认值可以按工具的风险级别在不同维度上覆盖。我们使用的方案很简单用Promise.race同时跑实际调用和一个定时器超时则返回TOOL_TIMEOUT错误。注意即便如此超时后的底层函数往往仍在运行这时要把异步的副作用处理好不能因为响应超时就认为工具被真正终止了。async function withTimeoutT(promise: PromiseT, ms: number): PromiseT { let timer: ReturnTypetypeof setTimeout; const timeoutPromise new PromiseT((_, reject) { timer setTimeout(() reject(new Error(timeout after ${ms}ms)), ms); }); return Promise.race([promise, timeoutPromise]).finally(() clearTimeout(timer!)); }并发限制针对两类情形一类是某工具本身是外部 API 网关并发过高会把它打爆另一类是系统内存在大量小工具同时触发可能导致线程池打满。我们给注册元数据增加了一个可选的调用级参数比如maxConcurrency分发器内部维护一个简单的计数器超出阈值的请求进入等待队列或者直接拒绝。具体选哪种策略要看业务容忍度等待适合批量任务直接拒绝适合实时用户请求。重试策略要谨慎因为幂等性不是默认属性。我们只在工具元数据里显式标注支持幂等retryable: true时才允许自动重试重试次数默认最多2次两次之间按150ms random(50ms)的简单退避间隔。额外的重试逻辑会显著拖慢整体调用链路所以能用简单指数退避就不用复杂算法。4.5 分发器的可观测性集成最后值得聊的是可观测性。分发器是所有工具调用的咽喉部位流量、错误、耗时、参数分布都在这里汇集这是天然的监控埋点位置绝不能浪费。我们在dispatch方法中固定埋了三个阶段查表阶段、参数校验阶段、实际执行阶段。每个阶段都输出耗时统计和结果状态最终再汇总成DispatchResult。日志格式统一为 JSON包含 tool 名称、调用方 traceId、入参摘要根据配置决定记录哪些字段避免泄露敏感数据、耗时、返回码。除此之外分发器还维护一组简单的内存指标总调用数、成功数、失败数、p99耗时、最近错误列表暴露在/metrics端点供监控系统周期拉取。这一层监控落地之后我们再也没出现过“某个工具悄悄失效三天没人发现”的情况。只要分发器有流量它就能第一时间暴露工具群整体的健康状态。5. 常见问题与排查技巧实录5.1 “工具找不到”类问题排查在自注册体系里工具找不到是最常见的报错类型可能的原因却五花八门。排查思路可以按下面的表格逐项核对现象可能原因排查方式调用返回TOOL_NOT_FOUND工具模块未被加载检查tools/index.ts是否 import 对应文件调用返回TOOL_NOT_FOUND注册函数有语法错误被吞掉在注册入口打开 verbose 日志确认注册表条目数调用返回TOOL_NOT_FOUND工具名拼写或命名空间不一致调用registry.list()查看当前已注册清单AST 扫描有记录但注册表没有装饰器转译后未保留检查构建产物中装饰器相关代码是否存在覆盖注册未发生崩溃同名工具重复注册但被静默覆盖检查 warn 日志中re-registered记录第一次部署后遇到最典型的情况就是“AST 扫描有但运行时没有”。原因是我们有个工具文件用了动态 import 的写法代码在分支条件里才去注册导致启动时未执行注册函数。后来约定所有registerTool调用必须在模块顶层同步执行这个坑才算彻底堵住。技巧给分发器加一个 debug 接口允许运行时查看注册表全量快照以及每个工具对应的 sourcePath。排查“找不到”时这个快照基本能一步定位到是“没加载”还是“没注册到预期名字”。5.2 AST 扫描漏扫与误扫的处理AST 扫描器很容易出现两类精度问题。第一类是漏扫典型原因是出现了我们约定之外的代码形态。比如工具不是从顶层导出而是嵌套在类内部又比如registerTool被重新绑定到别名后调用再比如工具定义写在了一个被常量包装的函数内部。这些情况在正常约定中不该发生但如果团队里有人走捷径就可能踩坑。第二类是误扫。典型原因是同名函数或同名变量出现在 AST 中但实际并不是工具注册调用。比如某个业务代码里也有一个名为registerTool的普通函数就很容易被扫描器误判。我们缓解这个问题的方式很简单扫描器只扫描约定的目录tools/业务代码目录完全忽略。这个目录边界约定价值很大能避免 90% 以上的误扫问题。如果确认某个工具必须写在约定目录之外更好的做法是把工具文件复制/移动到约定目录或者额外调整扫描配置而不是堆扫描器的黑名单。5.3 运行时“参数对不上”问题排查参数问题主要集中在三种情况调用方传了多余的参数调用方没传必填参数传入值的类型和声明不符。分发器做参数校验最大的优势是可以统一暴露错误码。前端/脚本收到INVALID_PARAMS就知道是调用侧问题而不是工具实现的问题。在用参数校验时我建议把params的定义尽量和 AST 扫描保持一致。否则会出现一种很尴尬的局面AST 扫描器告诉文档系统某工具只需要两个参数但分发器运行时实际要求三个参数。文档与运行行为不一致会让所有调用方都头疼。一个简单对策是在启动校验时对比 AST 扫描提取的params与注册表实际params不一致就输出警告。5.4 热更新与重新注册的实践经验工具代码会不断变化于是“如何让新版本工具代码生效”是个非常实际的问题。在我们的体系里最简单可靠的方式是重启进程——重新加载所有工具模块并触发重新注册这个过程通常只需要两三秒对内部工具链的代价微乎其微。后来我们发现部分低频率工具的重启会给调用方造成无谓的中断于是实现了一个轻量级的热更新方案虽然不完美但满足日常需求。核心思路是扫描器对所有工具模块统一监听文件变化事件当发现某个工具文件变化后先清除该工具在注册表中的旧条目动态导入该文件的新版本重新触发注册函数注册完成后再次做一次一致性校验并将结果上报热更新的坑在于工具的依赖引用如果发生了变化旧依赖可能仍然存在于模块缓存中导致“看着代码是新版本跑起来还是旧逻辑”。我们在热更新前会强制清除该模块及其依赖链上的模块缓存但这只在极简的依赖结构下才可靠。所以在生产环境我们仍然以重启进程为主热更新只作为一种开发期体验优化手段。5.5 扫描耗时与性能问题当工具模块数量增长到上百个AST 扫描的耗时可能从毫秒级增长到秒级这会让 CI 环节显得拖沓。我们的优化方案主要有三个并发扫描利用多核 CPU 并行解析文件而不是串行遍历。实测在 4 核机器上并发扫描的效率提升接近 3 倍。增量扫描CI 中只扫描有 git diff 的文件但前提是要缓存上一次的扫描结果并能够正确合并差异。这个实现起来复杂度偏高但收益也显著。扫描即缓存扫描器会输出一份 JSON 缓存文件缓存内容包括文件 hash 和提取到的工具元数据。只有文件 hash 变化时才重新解析。这三个优化做完后百级工具规模的扫描耗时基本降到 500ms 以内几乎不影响整体体验。我个人建议先做并发再做缓存增量扫描如果非必要可以不碰实现成本与风险不成比例。6. 这套体系能带来什么收益以及给新项目落地的建议6.1 收益复盘量化数据与体验改善结合我们自己的落地数据这套运行时体系带来的收益比较直观。在接入 Hermes 之前新增一个内部工具的流程包括创建脚本、手动在配置表里登记路由、编写调用文档、让至少三个调用方代码加入对工具的直接依赖。每一步都有可能出错平均耗时半小时以上。迁移到 Hermes 之后新增工具只需要两步把工具文件放到tools/目录并在tools/index.ts中补充一行 import。启动系统后分发器就能自动路由到这个新工具调用方甚至不需要知道工具文件的位置。从故障数据看原来最常出现的“工具找不到”和“参数对不上”类事件基本清零。原因也很简单人工登记这一环节被彻底移除命名和参数的最终裁决权归于注册表本身调用方和实现方都由中央分发器统一收口。从团队协作的角度工具作者可以快速自查新工具是否生效后端和前端也不再需要频繁同步工具列表。只要打开注册表快照接口所有人都能看到当前系统支持什么、参数是什么、示例是什么。文件即文档、注册表即 API 列表、分发器即唯一网关这三个认知一旦在团队里建立很多沟通成本都会自动消失。6.2 给准备落地类似系统的团队的建议如果你准备在内部搭建类似的工具运行时有几点来自实战的建议值得提前考虑。第一不要一开始就设计过重的元数据与能力模型。我们的第一版元数据只有 name、handler、description 三个字段后面的 tags、params、version、作者等都是逐步根据真实需求加进去的。一开始就堆上大量可选字段只会让工具作者产生逆反心理反而不愿意认真填写声明信息。第二目录边界与代码形态约定必须写进 README 或 CONTRIBUTING 文档。AST 扫描和自注册都依赖于团队遵循统一的声明风格。没有书面约定新成员很容易把工具写成扫描器无法识别的形态各种问题随之而来。第三优先做好分发器的可观测性而不是优先做各种高级路由策略。只要你能看到每次调用的耗时、失败码和 tool 分布你就能知道系统现在的状态然后才能谈优化。高级路由模式说到底只是优化手段不是第一优先级。第四中央分发器不是银弹。它解决了“调用方与实现方解耦”的问题但同时也让所有调用都集中到一个入口这个入口本身必须高可用。如果你的工具链分布在不同物理节点上中央分发器还需要处理网络延迟、节点故障复杂度相比单机场景有明显上升。可以先在单机范围内做好再考虑分布式扩展。7. 实际运行中最后想补的三件小事这三个经验不算大架构层面的东西但对日常维护很有帮助。第一件是关于日志的。自注册做的越“自动化”维护者越容易丢失对系统状态的感知。所以我们在启动阶段特意打印一行汇总日志注册了多少个工具、哪些是从 AST 扫描出来的、哪些是纯运行时注册、有没有重复覆盖。这行日志在排查问题时几乎是第一信息来源。第二件是关于工具函数的入参与出参。我强烈建议所有工具返回值都使用 JSON 可序列化的结构别把类实例、函数句柄、Buffer 裸对象直接抛给调用方。因为分发层的日志、追踪和跨进程传递都建立在对结果做序列化的前提上。如果工具返回了不可序列化的对象Observer 看不到结果调用方拿到内存句柄也没有意义。我们的约定是“万物 JSON否则按 error 处理”一段时间后会清理掉不少隐蔽的兼容性问题。第三件是关于删除工具。很多人只关注新增工具但删除工具引发的连锁反应往往更让人头疼。我们建立了“软下线”机制把一个工具从入口文件中注释掉之前先把它标记为 deprecated再通过分发器返回一个明确的TOOL_DEPRECATED状态码。这样调用方还能感知到变化而不是忽然收到一个莫名其妙的“not found”。等所有调用方都切换完成后再彻底移除这个模块。这一个细节短期内看不出好处长期维护时能帮大忙。Hermes 这套运行时体系并不复杂它只是把很多团队正在手工做的“登记、寻找、调用”三个动作标准化了。它的价值不在于炫技而在于把工具之间的协作摩擦降到最低。如果你们团队也面临着工具数量上升后集成成本失控的问题花一两周时间搭建一个类似的能力底座我觉得是值得的。用上了之后你就很难再回到“改一处注册表通知三个群”的时代了。