HelloAGENTS 技能路由机制解析:~命令匹配与优先级设计
1. 从一条波浪号说起HelloAGENTS 的技能路由到底在解决什么问题第一次翻 HelloAGENTS 源码的人十有八九会在同一个地方卡住明明装了 14 个技能敲进去的却是一条以~开头的短命令中间没有任何显式的技能名它凭什么知道我要调哪个更让人挠头的是有些命令长得像路径有些像参数有些干脆就是一个孤零零的符号可它们全都能被正确分发到对应的技能上。这套东西的本质是一个前缀路由 技能注册表的组合。~不是装饰它是路由的入口标记14 项技能不是并列堆在一起的文件夹而是一张带优先级和匹配规则的注册表。理解这两点后面所有的组织方式都会变得顺理成章。我把它拆成一句话HelloAGENTS 用~作为统一入口前缀把用户输入解析成命令 参数两段再拿命令去技能注册表里做匹配命中后把参数透传给对应技能的执行体。听起来简单但真正决定它好不好用的是匹配规则怎么定、优先级怎么排、参数怎么切、冲突怎么解。这四件事任何一件没处理好14 项技能就会互相打架。这篇文章适合三类人看一是正在用 HelloAGENTS 但只会照着文档敲命令、想搞明白背后逻辑的人二是想给自己的 CLI 工具设计一套类似技能路由机制的开发者三是被技能装了却调不动命令冲突了不知道谁赢这类问题折磨过的人。我会把 14 项技能的组织方式、~命令的解析流程、匹配优先级、参数切分、冲突排查全部摊开讲并且给出可以直接对照复现的规则表和排查清单。需要先说明一点HelloAGENTS 的具体实现细节会随版本演进下面涉及的结构、字段名、匹配顺序是基于这类技能路由系统的常见工程实践做的合理还原重点在于把为什么这么设计讲透而不是死抠某一行源码。你拿这套思路去读实际代码会发现对得上。2. 14 项技能是怎么被组织进一张注册表的2.1 技能不是文件夹是带元数据的注册项很多人对技能的第一反应是不就是一堆脚本放在 skills 目录里吗调用的时候按文件名找不就行了。这个想法在小规模下能跑通一旦技能数量上到十几个、每个技能还有别名和参数形态纯靠文件名匹配立刻崩盘。HelloAGENTS 的做法是给每个技能建一条注册项注册项里至少包含这几个字段字段作用举例name技能的唯一标识code-reviewaliases别名列表支持多种叫法cr,reviewtrigger触发命令即~后面跟的那段~crparams参数定义决定怎么切分输入path,--levelpriority匹配优先级数字越小越先匹配10handler实际执行体函数或脚本路径handlers/review.js这张表是整套路由的地基。~命令进来之后系统不是去遍历目录而是拿命令段去这张注册表里查。查表比遍历目录快得多更重要的是可控——你可以精确决定哪个技能优先、哪个别名指向谁、哪个参数是必填。我见过有人图省事直接用文件名当触发命令结果技能一多就出现~test到底指单元测试还是指测试数据生成这种歧义。注册表模式从根上避免了这个问题因为触发命令是你显式声明的不是从文件名猜的。2.2 为什么是 14 项而不是 10 项或 20 项14 这个数字不是拍脑袋定的。技能路由系统有一个隐形的容量上限取决于人类短时记忆能记住多少条命令。心理学上有个经典结论是 7±2但在 CLI 场景里因为有别名和补全辅助实际能舒适承载的数量会往上走一些。14 项大致落在一个记得住、又不至于太单薄的区间。更关键的是这 14 项技能在功能上是有分层的不是平铺的 14 个并列项。按职责可以分成四组输入处理组负责读取文件、解析路径、抓取上下文比如读取指定文件内容、扫描目录结构。分析与生成组核心生产力技能比如代码审查、文档生成、重构建议。执行与操作组会真正改动文件系统或调用外部命令的技能比如批量重命名、格式化。辅助与元技能组管理技能本身的技能比如列出所有技能、查看某技能用法。分层的意义在于优先级可以按组设定。输入处理组的技能通常优先级更高因为它们经常被其他技能间接调用执行操作组优先级偏低因为它们有副作用不该轻易被误触发。这个设计思路和很多命令路由系统是一致的越安全、越常用的越靠前越危险、越少用的越靠后。2.3 注册表的加载顺序与覆盖规则注册表不是一次性写死的它支持多层加载常见的是三层内置层随 HelloAGENTS 一起发布的默认技能优先级基准值。用户层用户自己添加的技能可以覆盖内置层的同名技能。项目层当前项目目录下的局部技能配置优先级最高。加载顺序是内置 → 用户 → 项目后加载的同名项覆盖先加载的。这个顺序背后的逻辑很直白越贴近当前上下文的配置越应该说了算。你在某个项目里临时改了一个技能的触发命令不应该影响你在其他项目里的使用习惯。这里有个容易踩的坑覆盖是整项覆盖还是字段合并如果是整项覆盖你在项目层只想改 priority结果忘了写 handler技能就直接废了。稳妥的做法是字段级合并只覆盖你显式声明的字段。HelloAGENTS 这类系统通常采用字段合并但你在自定义技能时最好还是把关键字段写全别赌合并策略。提示自定义技能时哪怕只改一个字段也建议把 name、trigger、handler 三个核心字段补全避免因合并策略差异导致技能失效。3.~命令的解析流程从字符串到技能调用3.1 第一步识别前缀切出命令段和参数段用户敲进来的原始输入第一步要做的是前缀识别。只有以~开头的输入才会进入技能路由其他输入走正常对话或默认处理。这个设计的好处是零歧义你不用担心一句普通的话被误判成命令。识别到~之后紧接着要做的是切分。切分规则通常是~后面第一个连续的非空白字符序列是命令段剩下的全部是参数段。举个例子~cr src/utils/parser.js --level strict切出来是命令段cr参数段src/utils/parser.js --level strict注意参数段是原样保留的路由层不解析参数含义只负责把它透传给命中的技能。这是一个非常重要的职责划分路由层只管找谁不管干什么。参数怎么解释、哪些是位置参数、哪些是选项参数全部由技能自己的 handler 决定。这样设计的好处是解耦。新增一个技能不需要改路由层的解析逻辑技能想支持什么奇怪的参数格式路由层也不拦着。坏处是参数校验被推迟到技能内部如果技能写得糙用户可能收到一个很晚才报出来的错误。所以成熟的做法是在注册项里声明 params 元数据路由层做一次轻量预校验比如必填参数有没有、选项名拼写对不对把明显错误挡在前面。3.2 第二步命令段匹配精确优先于模糊拿到命令段cr之后就要去注册表里找匹配项。匹配不是简单的字符串相等而是一个多级匹配过程顺序大致如下精确匹配 trigger命令段和某个技能的 trigger 完全相等。精确匹配 aliases命令段和某个技能的别名完全相等。前缀匹配命令段是某个 trigger 的前缀且没有更精确的匹配。模糊匹配编辑距离在阈值内用于容错拼写错误。这个顺序的核心原则是精确优先。为什么前缀匹配要排在精确匹配之后因为如果有个技能 trigger 是c另一个是cr用户输入cr时如果前缀匹配优先可能会先命中c然后把它当成c加参数r这就错了。精确匹配先跑一遍cr直接命中问题消失。模糊匹配是最后兜底的而且必须谨慎。编辑距离阈值设大了~cr可能被模糊匹配到~cd用户想审查代码结果执行了切换目录。稳妥的做法是模糊匹配只在没有任何精确或前缀命中时才启用并且给出明确的确认提示而不是直接执行。3.3 第三步优先级裁决解决多命中即便有精确匹配也可能出现多个技能同时命中同一个命令段的情况——比如两个技能都声明了别名r。这时候就要靠 priority 裁决。裁决规则很简单priority 数值小的赢数值相同则后加载的赢项目层 用户层 内置层。这个规则要写死在文档里让用户可预期。最怕的是裁决规则不透明用户敲一个命令有时候命中 A 有时候命中 B那就没法用了。我在实际配置里会把核心技能的 priority 设成 10 的倍数留出插入空间。比如输入处理组 10、20、30分析组 100、110执行组 200、210。这样以后想在两组之间插一个新技能直接取中间值就行不用大改一片。3.4 第四步参数透传与执行命中技能后路由层把参数段原样交给 handler同时附上一些上下文信息比如当前工作目录、原始输入、命中的技能名。handler 拿到这些之后自己解析参数、执行逻辑、返回结果。这里有个细节值得说参数段要不要做引号处理如果用户输入~gen my file.txt参数段里的引号是保留还是剥掉常见做法是保留原始字符串由 handler 自己决定怎么处理引号。因为不同技能对引号的需求不一样有的要当字面量有的要去掉。路由层统一剥引号反而会破坏某些技能的行为。4. 匹配优先级与冲突处理14 项技能不打架的关键4.1 优先级设计的三个原则14 项技能要和平共处优先级设计必须守住三条线安全优先只读技能优先于写入技能。读取文件、分析代码这类没有副作用的技能优先级可以高批量修改、删除、格式化这类有副作用的优先级压低避免误触发。高频优先天天用的技能排在前面。路由匹配虽然快但优先级高的先被检查理论上响应更快更重要的是符合直觉。具体优先触发命令更长的、更具体的技能优先于更短的、更泛的。~review应该优先于~r因为用户输入review时意图更明确。这三条原则有时候会冲突。比如一个高频但有副作用的技能按高频该靠前按安全该靠后。我的处理方式是安全原则一票否决只要有副作用优先级就不能进第一梯队高频可以通过别名来弥补比如给它一个短别名方便输入但优先级仍然压低。4.2 冲突的三种典型形态技能冲突不是只有两个技能抢同一个命令这一种实际会遇到三类冲突类型表现解决思路命令冲突两个技能 trigger 或别名相同改别名或调 priority 明确胜负前缀冲突一个命令是另一个的前缀精确匹配优先必要时禁用前缀匹配参数冲突参数段被错误切分明确分隔符或在注册项声明参数模式命令冲突最好解决改个名字就行。前缀冲突最隐蔽因为平时不报错只在特定输入下才暴露。参数冲突最烦人因为路由层切分错了技能内部再怎么处理都救不回来。4.3 前缀冲突的实战排查前缀冲突的典型场景技能 A 的 trigger 是~doc技能 B 的 trigger 是~docgen。用户输入~docgen file.md如果系统先做前缀匹配可能命中 A把gen当成参数传给 A结果 A 一脸懵。排查这类问题的办法是打印匹配轨迹。在路由层加一个调试开关把每一步匹配的结果打出来精确匹配试了哪些、前缀匹配试了哪些、最终命中谁。我一般会在配置里留一个~debug之类的元技能专门用来查看最近一次路由的完整决策过程。这个技能本身也是 14 项之一属于辅助组。注意前缀匹配一定要设置最长匹配优先即当多个技能的前缀都命中时选命令段最长的那个。~docgen同时是~doc和~docgen的前缀选长的那个命中 B问题解决。4.4 优先级表怎么落地成配置说了这么多原则最终要落到配置上。一个可用的优先级配置大概长这样skills: - name: read-file trigger: ~read priority: 10 - name: scan-dir trigger: ~scan priority: 20 - name: code-review trigger: ~cr aliases: [review] priority: 100 - name: doc-gen trigger: ~docgen priority: 110 - name: batch-rename trigger: ~rename priority: 200 - name: format-all trigger: ~fmt priority: 210数值留空隙、按组分布、有副作用的靠后这三条在这份配置里都能看到。你照着这个骨架填自己的 14 项技能基本不会出大问题。5. 实操从零搭一套能跑的技能路由5.1 环境准备与目录结构先明确目录结构这是后面所有配置的落脚点helloagents/ skills/ builtin/ # 内置技能 user/ # 用户技能 config/ skills.yaml # 注册表配置 router/ index.js # 路由核心 handlers/ read.js review.js ...内置技能和用户技能分目录放加载时按顺序读天然实现覆盖。config 里放注册表router 里放解析逻辑handlers 里放执行体。职责清晰改哪块找哪块。5.2 注册表的加载与合并加载逻辑用伪代码表示大概是这样function loadRegistry() { const layers [builtin, user, project]; const registry new Map(); for (const layer of layers) { const config readConfig(config/${layer}.yaml); for (const skill of config.skills) { const existing registry.get(skill.name); if (existing) { // 字段级合并后加载的覆盖同名字段 registry.set(skill.name, { ...existing, ...skill }); } else { registry.set(skill.name, skill); } } } return registry; }关键点是{ ...existing, ...skill }这个字段级合并。它保证了项目层只改 priority 时handler 不会丢。如果你用的是整项覆盖这里就要改成直接 set但那样风险大不推荐。5.3 路由解析的核心实现路由解析分四步对应前面讲的流程function route(input, registry) { // 1. 前缀识别 if (!input.startsWith(~)) return null; // 2. 切分命令段和参数段 const rest input.slice(1).trim(); const spaceIdx rest.search(/\s/); const cmd spaceIdx -1 ? rest : rest.slice(0, spaceIdx); const args spaceIdx -1 ? : rest.slice(spaceIdx 1); // 3. 多级匹配 const candidates []; for (const skill of registry.values()) { if (skill.trigger ~ cmd) candidates.push({ skill, score: 3 }); else if (skill.aliases?.includes(cmd)) candidates.push({ skill, score: 2 }); else if (skill.trigger.startsWith(~ cmd)) candidates.push({ skill, score: 1 }); } if (candidates.length 0) return { error: no-match, cmd }; // 4. 优先级裁决 candidates.sort((a, b) { if (b.score ! a.score) return b.score - a.score; // 匹配度优先 return a.skill.priority - b.skill.priority; // 再比优先级 }); const winner candidates[0].skill; return { skill: winner, args }; }这段代码里有两个排序维度先比匹配度精确 别名 前缀再比 priority。这个顺序很重要匹配度必须优先于 priority否则一个低优先级的精确匹配会被高优先级的前缀匹配挤掉那就本末倒置了。5.4 参数切分的边界处理参数切分最容易出问题的地方是引号和转义。用户输入~gen a b.txt如果按空格切会切成a和b.txt两段全错。稳妥的做法是路由层不做细切只切出命令段参数段整段透传由 handler 用成熟的参数解析库处理。如果非要在路由层做预校验至少要做到引号感知遇到引号就跳过里面的空格。这个逻辑不难写但很多人偷懒不写结果带空格的路径全废。function splitArgs(argStr) { const args []; let current ; let inQuote false; for (const ch of argStr) { if (ch ) { inQuote !inQuote; continue; } if (ch !inQuote) { if (current) args.push(current); current ; } else { current ch; } } if (current) args.push(current); return args; }这段代码处理了引号内的空格a b.txt会被正确当成一个参数。转义字符的处理更复杂一般交给 handler路由层做到引号感知就够了。5.5 一个完整技能的定义示例把上面所有东西串起来定义一个技能大概是这样- name: code-review trigger: ~cr aliases: [review, r] priority: 100 params: - name: path required: true type: path - name: level required: false default: normal enum: [normal, strict] handler: handlers/review.js description: 对指定文件或目录做代码审查有了这份定义路由层知道怎么匹配预校验知道要检查什么handler 知道参数长什么样。三方各司其职14 项技能按这个模板铺开就是一套完整可用的路由系统。6. 常见问题与排查技巧实录6.1 命令敲了没反应怎么定位这是最高频的问题。排查顺序建议固定成一条链确认前缀输入是不是以~开头中文输入法下~可能变成全角这是最常见的坑。确认命令段~后面第一个词是不是注册表里存在的 trigger 或别名确认加载技能所在层的配置有没有被正确加载项目层配置路径对不对确认优先级是不是被另一个同命令的技能抢走了我一般会先跑一个列出所有已加载技能的元技能把当前生效的注册表打出来。如果列表里没有你要的技能问题在加载如果有但匹配不上问题在命令段或优先级。6.2 全角半角问题一个被低估的坑中文环境下~和、-和、和这些字符长得像但编码不同。用户以为敲的是半角实际是全角路由层匹配不上。解决办法有两个一是路由层做字符归一化把常见全角符号转成半角再匹配二是给出明确的错误提示告诉用户检测到全角字符请切换输入法。我倾向于两个都做。归一化能救大部分情况提示能帮用户建立正确习惯。归一化的时候注意别把参数段里的全角字符也转了有些技能就是要处理全角文本只归一化命令段即可。6.3 参数被吃掉切分错误的典型表现用户输入~cr src/a.js --level strict结果技能只收到了src/a.js--level strict不见了。这种问题通常是切分逻辑把第一个空格之后的内容当成了命令的一部分或者 handler 解析参数时提前截断。排查方法是打印原始输入和切分结果。在路由层加日志把cmd和args分别打出来。如果args是对的但技能没收到问题在 handler如果args本身就少了问题在切分。6.4 技能冲突速查表把常见冲突和对应解法整理成一张表遇到问题直接查现象可能原因快速验证解决命令命中错误的技能别名重复列出所有别名改别名或调 priority短命令抢了长命令前缀匹配顺序错打印匹配轨迹精确匹配优先最长前缀优先项目配置不生效加载顺序或路径错打印已加载技能检查项目层配置路径改了 priority 没变化缓存未刷新重启或清缓存加缓存失效逻辑参数带空格被切开引号未处理输入带空格路径测试引号感知切分6.5 几个我踩过的坑坑一别名太短导致误触发。我给一个技能配了别名s结果用户想输入~scan时手快打成~s直接触发了另一个技能。后来我把所有单字母别名全删了最短两个字母。坑二priority 用连续整数。一开始我用 1、2、3、4 排优先级后来想在中间插一个技能只能把后面全部重排。改成 10 的倍数之后插入再也不用动别人。坑三模糊匹配太激进。开了编辑距离为 2 的模糊匹配结果~fmt被匹配到~fnt一个不存在的技能系统还贴心地执行了格式化。后来把模糊匹配阈值降到 1并且只在无其他命中时启用还加了确认提示。坑四项目层配置覆盖了 handler。有次在项目层只想改 priority结果配置里没写 handler字段合并策略又没生效技能直接报 handler not found。从那以后我自定义技能一律写全核心字段。提示调试路由问题时先开日志看匹配轨迹再动手改配置。凭感觉改配置往往越改越乱。7. 技能路由的扩展思路与个人体会14 项技能不是终点。当你想加第 15 项、第 20 项的时候路由系统能不能扛住取决于前面这些设计有没有做到位。我的经验是只要守住三条线扩展到二三十项也不会乱注册表集中管理、匹配度优先于优先级、有副作用的技能一律压低优先级。再往深了走还有几个可以玩的方向。一是技能组合让一个命令触发多个技能按顺序执行比如~crfmt先审查再格式化这需要在路由层支持命令段的组合语法。二是上下文感知路由同一个命令在不同项目里命中不同技能靠项目层配置实现本质是加载顺序的延伸。三是技能自描述每个技能暴露自己的用法说明路由层自动生成帮助文档省得手动维护。我个人在实际操作中的体会是路由系统最值钱的不是匹配算法多聪明而是行为可预期。用户敲一个命令能稳定地命中同一个技能出错时能拿到清晰的提示这比任何花哨的模糊匹配都重要。我宁愿多花时间把优先级表和冲突规则写清楚也不愿意让系统猜用户想干什么。猜对了用户不会感激猜错了用户直接弃用。最后分享一个小技巧给你的路由系统加一个干跑模式输入命令后不真正执行只打印会命中哪个技能、会传什么参数。这个模式在调试和教学时特别好用也能帮新用户建立对命令体系的直觉。等你把 14 项技能的路由都摸透了再回头看那些一开始觉得莫名其妙的~命令会发现每一条背后都有清晰的匹配逻辑在支撑。