
1. 从一条波浪号说起HelloAGENTS 的技能路由到底在解决什么问题第一次看到 HelloAGENTS 这套东西的人大概率会被两个东西同时绊住一个是14 项技能这个数字另一个是那个看起来像家目录符号的~命令。很多人第一反应是——这不就是个命令行工具吗怎么还搞出技能这种拟人化的说法~不是 Linux 里代表用户主目录的符号吗怎么跑到这里变成命令前缀了我一开始也是这么想的直到自己把整套结构拆开跑了一遍才意识到这两个设计其实是同一个问题的两面当你的智能体能力越来越多、触发方式越来越杂的时候怎么让用户想干什么和系统该调用什么之间保持一条清晰、可预测、可扩展的映射链路。这就是命令路由要解决的核心问题而 14 项技能则是这条链路上被路由的具体目标。先把概念对齐一下避免后面越看越糊。HelloAGENTS 里的技能skill指的是一组封装好的、有明确输入输出边界的能力单元。它可能是一次代码生成、一次文件读写、一次命令执行、一次结构化数据抽取也可能是一次多步骤的任务编排。你可以把它理解成一个个插件每个插件只干一件事但干得很专。而~命令是用户和这些技能之间的触发入口——你在对话或命令行里敲下~开头的指令路由层负责把它翻译成该调用哪个技能、传什么参数、按什么顺序执行。为什么非要用~而不是直接用自然语言这是很多人会问的第一个问题。答案其实很朴素自然语言适合表达意图但不适合表达精确的调用契约。你说帮我把这个文件里的函数重命名一下模型能理解但它不确定你是要重命名全部还是部分、要不要保留备份、要不要同步改调用处。而~rename --scopeall --backup这种形式把意图压缩成了确定性的指令路由层不需要猜直接分发即可。~这个符号本身没有魔法它只是一个低冲突前缀——在绝大多数文本和代码里行首出现~的概率极低所以用它做命令起始标记几乎不会和正文内容混淆。这一点和很多 CLI 工具用/做命令前缀是同一个思路只不过/在路径里太常见冲突率高~更干净。那 14 项技能是怎么凑出来的这里要泼一盆冷水14 不是一个设计出来的完美数字而是能力边界收敛后的自然结果。我拆过它的技能清单大致可以归成几类文件与目录操作类、代码理解与生成类、命令执行与进程管理类、数据解析与转换类、任务编排与状态查询类。每一类下面再细分最后收敛到 14 个。这个数字会随着版本变化所以你在看任何一篇讲 HelloAGENTS 的文章时都不要死记14这个数而要记住它的分类逻辑——分类逻辑稳定具体数量会浮动。提示如果你是从 Codex CLI 那套体系过来的会发现在命令路由这件事上两者思路相通但实现不同。Codex CLI 更偏向一个入口 参数分支HelloAGENTS 更偏向前缀路由 技能注册表。理解这个差异后面看配置结构会顺很多。适合谁来读这篇三类人。第一类是把 HelloAGENTS 当日常工具用、但一直没搞懂~命令背后逻辑的人第二类是想自己扩展技能、往路由表里加东西的开发者第三类是做智能体编排、想借鉴这套路由设计思路的工程同学。不管你是哪类接下来的内容都会从结构长什么样讲到为什么这么设计再到你自己怎么改尽量把每个决策背后的理由说透。2. 14 项技能不是拍脑袋定的能力分层与注册机制2.1 技能为什么要分层而不是平铺 14 个很多人第一次接触技能系统会本能地想不就是 14 个功能吗平铺一个列表不就行了为什么要分层我一开始也这么觉得直到技能数量从个位数涨到两位数问题就暴露了——平铺列表在数量少的时候清晰在数量多的时候会变成一锅粥。你想想如果 14 个技能全平铺用户要记住 14 个名字路由层要做 14 路匹配扩展的时候还要担心命名冲突。分层之后每一层只解决一个维度的分类问题认知负担立刻降下来。HelloAGENTS 的分层大致是这样的最上层是能力域domain比如文件域、代码域、执行域、数据域、编排域中间层是技能skill每个技能归属于一个能力域最下层是动作action一个技能可以暴露多个动作。举个具体例子文件域下面有file技能file技能又暴露read、write、list、move这几个动作。用户敲~file read --pathxxx路由层先按file找到技能再按read找到动作最后把参数透传给动作处理器。这种三层结构的好处在于扩展时只动一层。你要加一个新技能只需要在对应能力域下注册你要给已有技能加动作只需要在技能内部加 action你要调整路由规则只需要改路由层不用碰技能实现。这就是典型的关注点分离说起来简单但真正落地的时候很多人会把三层揉成一层最后改一处崩三处。2.2 技能注册表长什么样技能注册表是整套路由的核心数据结构。它本质上就是一张映射表把技能标识映射到技能实现 元信息。元信息里通常包含技能名、所属能力域、支持的动作列表、每个动作的参数 schema、是否需要前置条件比如是否需要文件已存在、执行超时、是否可并行等。我用一个简化后的结构来说明实际字段会更多但核心就这些{ file: { domain: filesystem, actions: { read: { params: [path, encoding], requires: [path_exists], timeout: 5000, parallel: true }, write: { params: [path, content, mode], requires: [], timeout: 10000, parallel: false } } } }这张表有几个设计细节值得说。第一requires字段声明了前置条件路由层在执行前会先校验不满足就直接拒绝而不是把错误抛给技能实现——校验前置条件比事后报错便宜得多。第二parallel字段声明了该动作能否并行执行这直接影响编排层的调度策略。第三timeout是每个动作独立的不是全局统一因为读文件和跑一个长任务的时间量级完全不同。注意注册表里的params顺序不代表调用顺序参数是通过名字匹配的不是位置匹配。这一点和很多老式 CLI 不一样别踩这个坑。2.3 14 项技能的分类逻辑拆解把 14 项技能按能力域归类大致是这么分布的具体名称会随版本变化这里讲的是分类思路能力域技能数量约典型能力文件系统3读写、遍历、移动复制代码处理3解析、生成、重构命令执行2单命令、管道组合数据转换3结构化解析、格式互转、抽取任务编排2串行编排、状态查询系统信息1环境探测、能力自检这个分布不是均匀的而是按使用频率加权的。文件系统和代码处理占了将近一半因为这两类是日常最高频的操作。命令执行只有 2 个因为命令执行本身风险高、变数大宁可少而精。任务编排只有 2 个因为编排是元能力它调用的是其他技能不需要自己实现太多。我特别想说的是系统信息这一类。很多人会忽略它觉得探测环境算什么技能。但实际用下来这一类恰恰是路由层做决策的依据——比如路由层要知道当前环境有没有某个命令、某个路径是否可写、某个依赖是否就绪才能决定某个技能能不能执行。没有自检能力的技能系统等于闭着眼睛开车。2.4 技能之间的依赖与冲突怎么处理14 个技能不是孤立的它们之间有依赖也有冲突。依赖好理解比如重构技能依赖解析技能先跑一遍。冲突则容易被忽略比如两个技能同时要写同一个文件或者一个技能要删目录、另一个技能正在读里面的文件。HelloAGENTS 处理依赖的方式是显式声明 编排层排序。技能不自己调用别的技能而是把依赖写进元信息由编排层统一拓扑排序。这样做的好处是依赖关系集中可见不会散落在各个技能实现里。处理冲突的方式是资源锁 串行化。对同一个资源文件、目录、进程的操作如果声明了parallel: false编排层会强制串行。这里有个实操心得不要为了追求并行而滥用parallel: true。我见过有人把所有读操作都标成可并行结果在机械硬盘上并行读反而比串行慢因为磁头来回寻道。并行不是免费的它只在真正独立且资源不冲突的场景下才有收益。3.~命令路由的完整链路从敲下回车到技能执行3.1 路由链路的五个阶段用户敲下~file read --path./a.txt到技能真正执行中间经过五个阶段。把这五个阶段搞清楚你对整套系统的理解就通了八成。第一阶段是词法解析。路由层先把输入按空格和引号切分成 token识别出~前缀、技能名、动作名、参数列表。这一步要处理引号嵌套、转义字符、参数值里带空格等边界情况。很多路由 bug 都出在这一步比如路径里有空格没加引号直接被切成两段。第二阶段是技能匹配。用技能名去注册表里查查不到就报未知技能。这里有个细节技能名匹配是精确匹配还是前缀匹配HelloAGENTS 用的是精确匹配因为前缀匹配会带来歧义——file和filesystem如果都注册了前缀匹配就不知道该选哪个。精确匹配虽然要求用户记全名但行为可预测。第三阶段是动作匹配。在技能内部查动作名同样精确匹配。动作不存在就报技能不支持该动作并列出该技能支持的动作列表——这个提示很重要能帮用户快速纠正。第四阶段是参数校验。按动作的paramsschema 校验参数缺必填参数、参数类型不对、参数值越界都在这一步拦下。校验通过后还要检查requires声明的前置条件。第五阶段是执行与回传。把校验后的参数透传给动作处理器执行收集结果按统一格式回传。执行过程中的日志、错误、耗时都会被记录方便排查。3.2 为什么用~而不是/或!这个问题值得单独说因为它是整套设计里最容易被质疑的点。/是斜杠命令的常见前缀!是很多工具里执行 shell 的标记为什么 HelloAGENTS 偏偏选了~第一个原因是冲突率。/在文件路径、URL、正则表达式里到处都是如果用它做命令前缀路由层就得额外判断这个/是命令还是路径。!在代码里是逻辑非在 Markdown 里是图片标记冲突也不少。~在行首出现的概率极低冲突率最低。第二个原因是语义联想。~在 shell 里代表家目录有回到起点、回到自己的地盘的意味用在命令前缀上暗示这是你自己的指令空间语义上是顺的。第三个原因是可读性。~在视觉上比/和!更轻不会和正文抢注意力。你在一段文字里看到~file read一眼就知道这是命令看到/file read还得想一下这是不是路径。提示如果你要自己设计一套命令前缀选符号的原则就三条——冲突率低、语义顺、视觉轻。这三条满足两条就能用三条都满足就是好设计。3.3 参数传递的三种形态~命令的参数传递有三种形态理解它们能帮你写出更准确的调用。第一种是键值对形态最常见比如--path./a.txt --encodingutf-8。键值对的好处是自解释参数顺序无所谓缺点是啰嗦。第二种是位置参数形态比如~file read ./a.txt。位置参数简洁但要求用户记住参数顺序且一旦技能升级加了参数老调用可能错位。HelloAGENTS 对位置参数的支持是有限支持——只有声明了位置顺序的动作才允许否则一律要求键值对。第三种是管道形态比如~file list | ~data filter --pattern*.log。管道把上一个技能的输出作为下一个技能的输入是编排的基础。管道形态对数据格式有要求通常约定用 JSON 或行文本否则下游技能没法解析。三种形态可以混用但混用时要小心优先级。HelloAGENTS 的解析顺序是先切管道再切参数最后切位置参数。这个顺序保证了管道不会被参数里的|误伤。3.4 路由失败时的错误信息设计路由失败是常态不是异常。用户敲错技能名、参数写错、前置条件不满足都会失败。错误信息设计得好不好直接决定用户能不能自己解决问题。HelloAGENTS 的错误信息遵循三个原则。第一是说清楚哪里错了不是笼统的命令无效而是未知技能fil你是否想输入file——带纠错建议。第二是给出下一步比如技能file不支持动作delete支持的动作有read、write、list、move。第三是不泄露内部实现错误信息里不出现堆栈、不出现内部变量名只暴露用户能理解的概念。我踩过的一个坑是早期版本的错误信息把内部注册表的键名直接吐出来了用户看到一堆下划线命名完全不知道对应哪个技能。后来改成用展示名体验立刻好了。错误信息是给用户看的不是给开发者看的这个边界要划清。4. 技能与路由的配置落地从零搭一套可跑的结构4.1 目录结构怎么摆要让这套东西跑起来目录结构得先摆对。我推荐的结构是这样的helloagents/ ├── skills/ │ ├── filesystem/ │ │ ├── file.js │ │ └── manifest.json │ ├── code/ │ │ ├── parse.js │ │ └── manifest.json │ └── ... ├── router/ │ ├── lexer.js │ ├── matcher.js │ └── dispatcher.js ├── registry.json └── config.json每个技能一个目录目录里放实现文件和manifest.json。manifest 描述这个技能的元信息注册表在启动时扫描所有 manifest 汇总生成。这样做的好处是技能自包含加一个技能就是加一个目录删一个技能就是删一个目录不用改中心配置。router/目录放路由三件套词法解析、匹配、分发。这三块分开写方便单独测试。registry.json是汇总后的注册表可以启动时生成也可以预生成。config.json放全局配置比如默认超时、日志级别、并行度上限。4.2 manifest 的字段设计manifest 是技能的身份证字段设计要克制不要什么都往里塞。我建议的核心字段是这些{ name: file, domain: filesystem, version: 1.0.0, actions: { read: { params: { path: { type: string, required: true }, encoding: { type: string, default: utf-8 } }, requires: [path_exists], timeout: 5000, parallel: true } } }params用对象而不是数组因为对象能带类型、默认值、是否必填这些信息数组做不到。requires用字符串数组每个字符串对应一个校验器名字校验器在路由层注册。version字段别省技能升级时靠它做兼容判断。注意params里的default只在参数缺失时生效如果用户显式传了空字符串不会用默认值。这个行为要写进文档否则用户会困惑。4.3 路由层的核心代码骨架路由层的核心逻辑不复杂难的是边界处理。我给一个骨架重点看注释里的边界说明function route(input, registry) { // 1. 词法解析处理引号、转义、管道 const tokens lex(input); if (tokens[0] ! ~) { throw new RouteError(NOT_A_COMMAND, 输入不是 ~ 命令); } // 2. 技能匹配精确匹配失败给纠错建议 const skillName tokens[1]; const skill registry[skillName]; if (!skill) { throw new RouteError(UNKNOWN_SKILL, suggest(skillName, registry)); } // 3. 动作匹配 const actionName tokens[2]; const action skill.actions[actionName]; if (!action) { throw new RouteError(UNKNOWN_ACTION, Object.keys(skill.actions)); } // 4. 参数校验类型、必填、默认值 const params parseParams(tokens.slice(3), action.params); validateParams(params, action.params); // 5. 前置条件校验 for (const req of action.requires) { if (!checkRequirement(req, params)) { throw new RouteError(REQUIREMENT_FAILED, req); } } // 6. 分发执行 return dispatch(skill, action, params); }这段代码里suggest函数做纠错建议用的是编辑距离阈值设 2 比较合适——太松会给出无关建议太紧又给不出建议。parseParams要处理--keyvalue和--key value两种写法后者容易和位置参数混淆要小心。4.4 启动时的注册表构建注册表构建发生在启动阶段做三件事扫描技能目录、读取 manifest、校验并汇总。校验这一步别省我见过太多因为 manifest 写错导致运行时才报错的案例。校验内容包括技能名是否重复、动作名是否重复、参数类型是否合法、requires引用的校验器是否存在。构建完成后把注册表缓存起来运行时只读。如果支持热加载就在文件变化时重建注册表但重建期间要保证正在执行的调用不受影响——通常做法是双缓冲新注册表构建完再原子切换。5. 实测中暴露的问题与排查链路5.1 参数解析的引号陷阱我实测遇到的第一个坑是引号。用户输入~file write --path./my file.txt --contenthello world看起来没问题但早期版本的 lexer 在处理嵌套引号时会把--content的值截断。排查过程是这样的先打印 token 列表发现--content的值只剩hello说明空格被当成了分隔符。根因是 lexer 的状态机在遇到引号时没有正确进入引号内状态遇到空格就切了。修复方案是重写 lexer 的状态机明确区分引号内和引号外两种状态引号内的空格不切分。同时加了一条规则引号必须成对不成对直接报错而不是默默按未闭合处理。默默容错比直接报错更危险因为它会让用户以为命令成功了。5.2 前置条件校验的顺序问题第二个坑是前置条件校验的顺序。有个技能声明了requires: [path_exists, path_writable]用户传了一个不存在的路径期望报路径不存在结果报的是路径不可写。原因是校验器按数组顺序执行但path_writable对不存在的路径也会返回 false于是先报了这个。修复方案是给校验器加优先级或者让校验器自己处理前置条件不满足的情况——path_writable在路径不存在时应该返回跳过而不是失败。这个坑的教训是校验器之间可能有隐含依赖声明顺序不等于执行顺序要显式处理。5.3 并行执行的资源竞争第三个坑是并行。编排层允许并行执行多个技能但两个技能同时写同一个日志文件导致日志内容交错。排查时先看日志发现行与行之间穿插确认是并发写。根因是日志写入没有加锁。修复方案是给日志写入加互斥锁或者每个技能写自己的日志文件最后合并。我选了后者因为加锁会影响并行度而分文件写没有竞争。并行的收益来自无竞争一旦引入锁并行的意义就打折了。5.4 排查链路的一般方法把上面几个坑抽象一下排查链路可以总结成四步复现、定位、归因、验证。复现要最小化输入把无关参数去掉定位要打印中间状态token 列表、校验结果、执行日志都要看归因要找到代码里的具体行而不是停在大概是这里验证要构造反例确认修复后反例不再触发。这套方法不新鲜但真正按这个走的人不多。大多数人卡在定位这一步因为没打印中间状态只能靠猜。打印中间状态的成本很低收益很高别省。6. 扩展技能与自定义路由的实操建议6.1 加一个新技能的最小步骤加技能的最小步骤是四步建目录、写 manifest、写实现、重启或热加载。听起来简单但有几个细节容易漏。第一技能名要全局唯一且尽量短。短名字好记但太短容易冲突建议 3 到 8 个字符。第二动作名用动词read、write、list这种别用doRead这种驼峰命令行的世界里下划线和短横线更常见。第三实现里不要自己解析参数参数已经由路由层解析好传进来了技能只管用。第四错误要抛结构化错误带错误码别抛裸字符串。6.2 自定义路由规则的边界路由规则可以自定义但边界要清楚。你能改的是前缀符号、技能匹配策略、参数解析规则、错误信息模板。你不能改的是技能的实现逻辑、注册表的核心结构、执行的分发机制。改路由规则是改怎么找不是改找到之后干什么这个边界别越。如果你确实需要改分发机制那说明你要的不是自定义路由而是换一套架构。这时候应该重新评估需求而不是硬改。6.3 性能与可维护性的取舍技能多了之后注册表会变大匹配会变慢。优化手段有几种给技能名建索引、按能力域分片、缓存匹配结果。但我要提醒的是在技能数量没到几百之前别做这些优化。过早优化会让代码复杂收益却微乎其微。我实测过14 个技能的注册表匹配耗时在微秒级完全不是瓶颈。真正的瓶颈通常在技能实现本身比如读大文件、跑长命令。优化要优化瓶颈不是优化看起来应该优化的地方。6.4 版本兼容的处理技能会升级升级就可能不兼容。处理兼容的常见做法是manifest 里带version路由层按版本做参数适配。比如read动作在 1.0 版本只有path参数2.0 加了encoding路由层在调用 1.0 技能时不传encoding调用 2.0 时才传。另一种做法是保留旧动作名新增动作用新名字比如read保留新增readEx。这种做法简单粗暴但会让动作列表越来越长。我倾向于第一种因为它在路由层统一处理技能实现不用管兼容。提示不管用哪种兼容方案都要在文档里写清楚哪个版本开始支持哪个参数否则用户升级后会一脸懵。7. 关于这套路由设计我自己的几点体会用了一段时间之后我对 HelloAGENTS 这套技能加路由的设计最大的感受是它把能力和触发这两件事分得很干净。技能只管能力路由只管触发中间靠注册表连接。这个分法看起来朴素但真正做到的项目不多大多数项目会把两者揉在一起最后改一处崩三处。第二个感受是**~这个前缀选得确实好**。我一开始觉得它有点怪用久了发现它几乎不会和任何内容冲突而且视觉上很轻。如果你要设计自己的命令系统这个符号值得考虑。第三个感受是关于错误信息的。这套系统的错误信息做得比大多数工具好会给你纠错建议、会列出可选动作。但我也遇到过它给的建议不靠谱的时候比如技能名差得太远编辑距离算出来的建议完全是另一个领域的技能。所以纠错建议要设阈值宁可不说也别乱说。最后一个体会是关于扩展的。加技能的门槛低是好事但门槛低也意味着容易加太多。我见过有人往系统里塞了几十个技能结果自己都记不住哪个是哪个。技能不是越多越好够用就行多了反而是负担。定期清理不用的技能比不断加新技能更重要。如果你正在用这套东西或者打算自己搭一套类似的我的建议是先跑通最小闭环——一个技能、一条路由、一次成功调用然后再往上加。别一上来就设计 14 个技能那是结果不是起点。