ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Hermes Tools Runtime:自注册、AST发现与中央分发体系

Hermes Tools Runtime:自注册、AST发现与中央分发体系 Hermes Tools Runtime自注册、AST 发现与中央分发体系如果一个运行时里只有五六个工具接入新工具就是改一行注册表的事情谁做都行。可一旦工具数量到几十个、上百个最痛的根本不是工具本身怎么写而是“系统怎么知道有这个工具”。我最早踩过一个很典型的问题同事交付了一个新工具代码写得没毛病就是忘了在全局注册文件里登记结果线上请求一直报工具不存在查了两小时。后来我把 Hermes Tools Runtime 这套运行时方案落地之后这个方向的问题基本绝迹了。核心就三件事自注册让工具自己申报身份AST 发现让系统在代码还没运行时就看清全部家底中央分发让请求到达后有统一的入口找到正确的工具。这篇文章会把这套机制的完整设计思路、落地细节和踩坑记录都写出来适合正在做插件化框架、工具平台或者运行时编排体系的同学无论是自己想搭一套还是想理解同类架构的原理都能直接参考。1. 从手工登记到自注册工具接管的第一个拐点1.1 手工维护注册表为什么撑不住先回到最初的做法。我们早期维护的是一个集中式的注册文件里面是一个很长的数组每个工具占一条export const registry [ { name: text.summarize, handler: SummarizeTool }, { name: image.resize, handler: ResizeTool }, ];工具少于 10 个时还好因为人脑能记住改起来也不容易冲突。但当工具数量涨到 20 个以上问题就开始集中爆发。最典型的是多人并行开发时的合并冲突两个人各自加了一个新工具改同一个文件一 merge 就冲突解决一次冲突要小心翼翼生怕把别人的记录删了。更隐蔽的问题是记录和代码脱节——工具改了导出路径注册表里还写着旧路径运行时发现模块不存在报错信息还指向注册表而不是代码本身。用一句话概括手工登记的本质是把“工具存在”这个事实放在了一个中央文件里而事实的源头其实应该存在于工具自己的代码里。任何事实只要存在两份就会不一致。这个想法很简单但当时推动改造时还是有不少阻力大家都觉得“不就加一行吗”直到连续踩了几次漏登记的坑才达成共识。1.2 三种自注册方式的对比与选择所谓自注册就是让工具模块在被加载时自己把元数据交给运行时而不是由运行时在全局文件里找它。我实践下来有三种主流实现方式这里直接放对比方式核心写法优点缺点装饰器声明在类或函数上标注元数据声明位置与实现紧邻可读性好依赖语言的装饰器能力扫描时需特殊处理注册表 API 显式调用工具模块里主动调用注册函数灵活、支持动态计算模块没被执行就漏注册问题隐蔽约定式声明文件同目录下放独立配置文件结构简单、极易静态分析多一层文件开发者会觉得麻烦我们最终选择的是装饰器为主、约定式文件为辅。装饰器方式的好处在于看到工具源码就能同时看到工具的身份信息不需要跳到别的文件去对照。实际代码长这样import { registerTool } from hermes/runtime; registerTool({ name: text.summarize, version: 1.0.0, category: text, tags: [nlp, summary], }) export class SummarizeTool { async run(ctx: ToolContextSummarizeInput) { // 工具的核心逻辑 } }这个装饰器做的事其实特别简单把一个元数据对象 push 进运行时内部的全局注册表。不过它的价值不在技术难度而在工程习惯——工具身份跟着代码走接入流程就退出了历史舞台。新工具上线只需要写一类代码不再需要额外维护一份列表。1.3 自注册还不够系统缺的是“提前看到”自注册解决了“登记”的痛点但很快带出了下一个问题运行时怎么知道有哪些工具模块需要被加载有一种做法是启动时扫描整个源码目录逐个 import 执行一遍让每个模块自己触发装饰器。这个方案听上去可行但是有几个隐患一是无法控制加载顺序工具 A 引用了工具 B 就很容易加载失败二是 import 的成本高工具一多冷启动时间直线上升三是如果某些模块有副作用扫描执行可能导致意外行为。所以我们引入了第二条腿——AST 发现。它负责在构建期就把所有工具提前找出来生成一份工具清单运行时只按这份清单加载。这等于把自注册的“盲目运行”变成了“目标明确的报到”。自注册是运行时的身份申报AST 发现是构建期的提前摸底两条腿一起走路才有了整套体系的稳定性。2. AST 发现代码还没跑起来就先摸清家底2.1 为什么不用正则而是用 AST第一次听到 AST 发现这个方案时团队里有同事直接问这场景用正则匹配name: text.xxx不就完了吗确实如果工具数量少、写法单一正则完全够用。但真实代码比这要乱得多。我遇到过的几种情况工具名是拼接出来的比如name: image. format装饰器参数被抽取成了一个常量对象配置里混着状态字段。用正则去匹配一段语法树级的嵌套结构基本上会误报漏报满天飞。AST 的核心优势是它解构后的代码不是“字符串”而是“结构”。解析引擎已经帮你区分好了哪个节点是对象属性、哪个节点是字符串字面量、哪段是注释你再也不需要靠字符串匹配猜代码意图。比如想提取装饰器参数里的 name 字段你只需要定位到 Decorator 节点走进参数节点里的 ObjectExpression把 properties 拉出来即可。这种做法的稳定性是正则完全比不上的。2.2 一个最小可用的 AST 工具扫描器我用 Babel 的解析器演示一个最小可用的扫描逻辑实际产出工具可以做得更完善但这个骨架已经能覆盖大部分静态声明场景import { parse } from babel/parser; import traverse from babel/traverse; export function discoverToolsByAST(files: string[]) { const found: ToolMeta[] []; for (const file of files) { const ast parse(readFile(file), { sourceType: module, plugins: [decorators-legacy, typescript], }); traverse(ast, { Decorator(path) { const expr path.node.expression; if (expr.type ! CallExpression) return; const callee expr.callee; if (callee.type Identifier callee.name registerTool) { const arg expr.arguments[0]; if (arg arg.type ObjectExpression) { found.push(extractToolMeta(arg)); } } }, }); } return found; }关键是extractToolMeta那一步去遍历 ObjectExpression 的 properties把name、version、category、handlerFile这些字段提取成一份结构化数据。代码量不大但有一个细节值得注意扫描器只认结构不执行代码。这意味着工具名如果是动态计算出来的扫描器就无法确定具体值。这一点我后面有一整节展开因为它是 AST 发现最大的边界。2.3 扫描产物不是代码清单而是工具契约AST 扫描结束之后输出不能是一份“我看到了哪些文件”的文件列表而应该是工具契约。我们生成的是一份tools.generated.json示意如下{ tools: [ { name: text.summarize, version: 1.0.0, category: text, handlerFile: src/tools/text/summarize.ts }, { name: image.resize, version: 1.2.0, category: image, handlerFile: src/tools/image/resize.ts } ] }把扫描结果落实成一份构建产物非常关键因为它可以参与很多事情CI 阶段做工具名重复检测和版本冲突检测启动时只需要按 handlerFile 批量加载模块而不再需要全文扫描甚至可以直接从这份 JSON 生成接口文档给前端团队当能力目录看。这份契约是静态世界和动态世界之间的桥梁是整套体系能够稳定运作的定海神针。2.4 AST 发现与自注册的分工预约与报到很多人理解这两者时会混淆觉得好像有了 AST 发现就不需要自注册了或者有了自注册就不需要 AST 发现了。我用一个类比来说明AST 发现是预约自注册是报到。预约的意思是构建期系统通过扫描知道“这里有一个工具它叫这个名字在这个文件里”相当于帮你排好了名单报到则是运行时加载这个文件装饰器执行工具真正进入注册表。如果没有预约报到就变成了盲目的遍历如果没有报到预约名单永远只是纸面数据工具没有活起来。这里还有个经验值得分享我们最初把 AST 发现的结果直接当成注册表用跑起来没问题但工具 A 引工具 B 的跨工具调用会查不到 B 的运行时实例。后来把“契约 JSON”和“运行时注册表”分开契约负责路由寻址注册表负责实例管理两者都维护一份 name 到 handler 的映射但定位完全不同架构才顺过来。3. 中央分发器请求来了之后怎么找到对的工具3.1 分发器到底在分发什么假设运行时有上百个工具系统收到了一个请求请求里写着“帮我执行 text.summarize参数是某段文本”。没有中央分发器时调用方得自己知道 tools 模块的路径、自己 import、自己拼参数。这种调用方式最大的问题是耦合每个调用方都得理解工具的内部接口工具一旦改名或者换版本所有调用方跟着改。中央分发器的本质是一个路由器它做的事情是把“请求”和“工具实现”之间的耦合切开。调用方只需要告诉分发器三样东西要调用的工具名、期望的版本区间、业务参数。分发器负责查注册表、做路由排序、处理参数校验、调用工具、拉回结果。对于调用方来说它不需要关心工具到底在哪个文件里是本地模块还是远程服务甚至不用关心它是不是已经换了新版本。3.2 路由规则与版本优先级分发器内部的路由逻辑要设计得很明确否则工具数量一多就会出现“为什么请求打到了一个我没想到的工具上”这种玄学问题。我实践下来路由优先级大概是这样优先级匹配方式说明1精确名称匹配工具名与注册名完全一致2版本范围匹配semver 范围内取最高可用版本3标签归类匹配按 category 或 tags 做兜底4默认工具降级以上都失败返回默认实现一个简化版的路由实现是这样function dispatch(req: DispatchRequest): ToolRuntime { if (req.version) { const byRange registry.matchVersion(req.toolName, req.version); if (byRange.length 0) return pickBest(byRange); } const exact registry.getByName(req.toolName); if (exact) return exact; return registry.matchByTag(req.tag ?? default); }这里最值得说的一点是语义化版本和默认版本的区别。一个请求如果明确指定了^1.0.0分发器就只在兼容版本里选如果没指定版本则直接打给当前标记为默认的工具版本。这套规则后来帮我们做灰度帮了大忙把默认版本从 1.0 切到 1.1零改动就能把流量逐步引到新工具上。这个在最后一章会细讲。3.3 上下文透传与异常隔离分发器还有一个容易被忽略但非常重要的职责上下文管理和异常隔离。一次请求从外部进入通常会附带不少上下文信息——请求 ID、用户身份、超时预算、仪表盘追踪 ID。这些信息如果靠每个工具自己传参很快就会漏一截。我们选择在分发器这一层建立一个统一的 ToolContext所有工具调用都从同一个上下文读信息子工具调用也通过上下文继续透传。异常隔离方面我踩过一次比较惨的坑一个工具内部抛了异常导致整个进程崩溃其他工具也一起不可用。后来在分发器层给每个工具调用包了一层隔离边界超时、异常、资源占用都限制在单次调用之内。分发器不允许任何单个工具的故障外溢这是它作为中央入口必须具备的鲁棒性。可以理解成前台服务台不管里面哪家公司出了事服务台接电话、转接的动作不能停。4. 一次请求的完整旅程扫描、注册、匹配、分发4.1 构建期写一个工具并生成契约假设我现在要新增一个image.resize工具。流程从写代码开始在src/tools/image/resize.ts里写上装饰器和实现registerTool({ name: image.resize, version: 1.2.0, category: image, tags: [resize, thumbnail], }) export class ResizeTool { async run(ctx: ToolContextResizeInput) { // do resize } }代码 push 到仓库之后CI 里跑一遍hermes discover --output tools.generated.json。AST 扫描器会对源码做语法分析定位到registerTool装饰器提取出工具元数据写进契约 JSON。如果这时工具名重复了CI 会直接红掉而不是等线上跑挂了再说。这一步的重要性在于它把工具发现变成了构建期可检测的环节。4.2 启动期契约加载与自注册服务启动时Hermes Runtime 先读tools.generated.json拿到全部工具的 handlerFile 路径然后按清单加载模块。加载的过程中每个模块顶层的装饰器代码会执行自注册的作用在此刻显现每个工具把自己推进全局注册表。这里有个顺序上的细节注册表的结构是先用契约 JSON 建立“空壳”——即 key 是工具名value 暂时为空自注册执行后才把真正的 handler 实例挂上去。相当于是先摆好姓名牌等人来入座。如果发现某个空壳迟迟没有入座启动器会在完成阶段做一次缺失报告直接定位到“哪个文件没有正确注册”。你猜这种情况最常见的诱因是什么后来查下来大多数是装饰器写漏了或者 import 路径写错了。4.3 运行期请求匹配与执行线上来了一个请求{ action: image.resize, version: ^1.0.0, params: { width: 800, height: 600 } }请求先到网关网关转发给 Hermes Runtime 的分发器。分发器先从注册表里按工具名image.resize查找再根据版本区间^1.0.0过滤掉不兼容版本当前只有1.2.0于是命中。分发器校验 params 结构创建 ToolContext把请求 ID 和超时预算放进去调用ResizeTool.run(ctx)拿到结果后统一打包返回给调用方。整个过程对调用方来说完全是黑盒它不需要知道image.resize的代码在哪个文件里、是类还是函数实现、是本地代码还是远程服务。这种封装带来的直接好处是未来即使把这个工具整体迁移成独立服务调用方一行都不用改。4.4 四阶段全链路时间线把上面的完整流程归纳成一张阶段表方便对照各环节的产物与作用阶段触发点核心动作产出/变化构建期开发者提交代码AST 扫描识别装饰器生成tools.generated.json契约CI 期扫描完成后检查重复名、版本冲突通过或失败启动期服务启动按契约加载模块、执行自注册注册表出现完整工具条目运行期外部请求到达分发器匹配路由、执行工具返回结果或超时/异常这套链路拆开看每一环都不复杂难的是让四个环节衔接顺滑不脱节。我现在每次新接入一个工具都是跑通这一整条链路再做验收确保从代码到线上请求是一条完整通路。5. 这套架构落地的坑我都替你踩过了5.1 AST 对动态代码的失明先说最核心的坑。AST 扫描有一个天生边界它只能发现源代码里写死的静态结构。如果工具名是拼接出来的比如const prefix image.; const toolName prefix resize; registerTool({ name: toolName }) export class ResizeTool {}这样的代码在 AST 扫描器眼里就是一团变量引用没法确定toolName到底是什么值。我们第一次碰到这种情况时工具在契约里缺了一条但运行时却能正常注册于是出现了一种诡异状态系统里确实有这个工具但分发器查不到请求永远进不来。应对方式我给两条。第一对动态属性要求工具开发者尽量拆成静态结构尤其是 name 这种路由用的第一关键字写死是最稳妥的。第二给动态场景留一个后门比如允许一个显式的约定式补充文件专门登记 AST 扫不到的情况// hermes.explicit.tools.ts export default { tools: [{ name: image.resize, version: 1.2.0, handlerFile: src/tools/image/resize.ts }], };扫描器会合并 AST 结果和显式清单显式清单优先级更高。这样既保留了 AST 的自动性又给那些“无法静态确定”的场景留了逃生出口。这是一个取舍问题在强制静态和完全自由之间我选择了一条中间路线。5.2 模块循环依赖把注册顺序打乱自注册机制看似简单但如果工具模块之间有引用关系装饰器执行的时机就变得很关键。我们遇到过工具 A 的装饰器在定义时引用了工具 B 导出的一个常量而工具 B 又在模块顶层引用了工具 A 的某个类型。模块加载产生了循环依赖结果是 A 还没注册完获取 B 的导出就拿到了 undefined装饰器直接报错。排查过程印象很深刻一开始以为是工具写错了后来定位到是模块环路。解决方式是从机制上限制——注册阶段只允许访问字符串和对象字面量不允许跨模块读取运行时对象。装饰器的参数在实践上应该是一份完全静态的元数据任何需要运行时计算的东西都放到run方法里去做。这其实是一条非常务实的经验自注册要安全就必须保证它不依赖执行顺序。5.3 工具多了之后冷启动变慢工具数量到 200 个以上时我们注意到冷启动越来越慢测一下时间主要花在了 import 每个工具模块上。虽然模块本身不执行复杂逻辑但 200 个文件的源码解析、模块初始化累计起来就是不小的开销。解决办法是做了两级注册表。第一级是轻量元数据只包含工具名、版本、category、handlerFile 这种纯字符串信息服务启动时所有工具都要进一级第二级才是真正的 handler 实例只有第一次被分发到的时候才加载。等于是把“报到”和“就位”拆开了。这个改动把冷启动时间降掉了将近一半而且好处是那些从来不会被命中的冷门工具可能一整天都不需要真正加载进内存。5.4 可观测性与调试技巧这类系统调试起来最头疼的问题就是“为什么请求没走到我期望的工具”。我的经验是必须在运行时暴露三个可观测面第一注册表快照。提供一个hermes registry dump命令能一次性输出当前注册表全量状态包括工具名、版本、是否已加载实现、命中次数。没这个命令时排查问题全靠猜有它之后大部分问题 10 分钟内能定位。第二分发器 Trace。每个请求上带一个 traceId分发器在各阶段打点从请求进来到工具执行结束每一段的耗时都能看到。遇到慢请求时可以直接拉出链路看到底是路由计算慢还是工具本身执行慢。第三扫描结果 golden test。AST 扫描器生成的契约 JSON 提交一份基准文件到仓库每次 CI 跑完扫描后会自动 diff多一个工具、少一个工具、改一个版本都会在 PR 里显示出来。这个做法很便宜但防止工具清单被意外改动非常有效。6. 后续演进热插拔、按需加载与灰度6.1 两级注册表支持按需加载前面提到两级注册表解决了冷启动问题但它更大的价值是可以做热插拔的基础。因为一级注册表只有轻量元数据运行时完全可以在不重启的情况下通过管理接口往一级注册表里注入一个新的工具条目。下一条请求到达分发器时发现该工具还没有二级实例触发一次懒加载工具就自动上线了。这个能力对业务来说非常重要。我们曾经有过一个场景需要快速上线一个临时工具处理某类特殊请求如果按旧的流程走要重新发版、重新拉起服务至少要等十几分钟。有了两级注册表之后向管理接口提交一条注册指令几秒钟内工具就能被下一次请求命中。下线同理只是把一级条目摘掉并释放对应的二级实例。6.2 版本默认值与灰度切换前面提到分发器支持按版本区间匹配这里说下灰度。我们的做法是给每个工具维护一个defaultVersion字段不指定版本时请求打到这个默认版本。灰度时只需要把defaultVersion从 1.0 切到 1.1新流量自然打到新版本老版本仍然可以通过显式指定版本访问。这个设计最大的优点是灰度粒度极细。有些请求需要保持稳定可以一直指定老版本没有指定版本的请求则跟着默认版本走。如果灰度过程中发现新版本有问题把defaultVersion切回去即可所有携带版本的请求完全不受影响。这种“默认版本 显式版本”的双轨制比直接全局替换要安全太多。6.3 从单进程分发到集群分发单进程内的中央分发器做得很顺之后自然会遇到横向扩展的问题。多个服务实例各自维护一份注册表如果某实例注册表不一致请求结果就会不一致。我们的演进方向是AST 发现生成的契约 JSON 作为中央配置发布到配置中心所有节点从配置中心同步元数据。运行时各节点保持本地注册表但元数据源头唯一。这一步做完之后工具的接入流程彻底变成了代码仓库里的一个动作写代码、提交、CI 扫描契约、发布配置。节点从配置中心拉到新契约按需加载新工具。对于外部调用方来说它看到的仍然只是那个稳定的分发接口完全感知不到背后的集群变化。这也是我认为这套架构最有价值的地方——内部无论怎么演化外部契约一直保持稳定。我个人在落地这套体系时最深的体会是架构设计的重点往往不在某个炫酷机制本身而在于怎么把“新增一个工具”这个动作的认知成本降到最低。如果每个新工具的接入都需要开发者理解注册表、加载顺序、路由规则那这个平台终究是给人添麻烦的而不是给人省麻烦的。最后再分享一个小技巧每次工具数量有大的变动时跑一遍 discover 命令看 diff观察工具列表的演化。时间久了你会对这个系统里哪些能力在被持续使用、哪些在慢慢消亡形成非常直观的感觉这是任何监控面板都替代不了的。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进