1. 从一条波浪号说起:HelloAGENTS 的技能路由到底在解决什么问题
第一次看到 HelloAGENTS 这套东西的人,大概率会被两个东西同时绊住:一个是"14 项技能"这个数字,另一个是那个看起来像家目录符号的~命令。很多人第一反应是——这不就是个命令行工具吗,怎么还搞出"技能"这种拟人化的说法?~不是 Linux 里代表用户主目录的符号吗,怎么跑到这里变成命令前缀了?
我一开始也是这么想的,直到自己把整套结构拆开跑了一遍,才意识到这两个设计其实是同一个问题的两面:当你的智能体能力越来越多、触发方式越来越杂的时候,怎么让"用户想干什么"和"系统该调用什么"之间保持一条清晰、可预测、可扩展的映射链路。这就是命令路由要解决的核心问题,而 14 项技能则是这条链路上被路由的具体目标。
先把概念对齐一下,避免后面越看越糊。HelloAGENTS 里的"技能"(skill),指的是一组封装好的、有明确输入输出边界的能力单元。它可能是一次代码生成、一次文件读写、一次命令执行、一次结构化数据抽取,也可能是一次多步骤的任务编排。你可以把它理解成一个个"插件",每个插件只干一件事,但干得很专。而~命令,是用户和这些技能之间的触发入口——你在对话或命令行里敲下~开头的指令,路由层负责把它翻译成"该调用哪个技能、传什么参数、按什么顺序执行"。
为什么非要用~而不是直接用自然语言?这是很多人会问的第一个问题。答案其实很朴素:自然语言适合表达意图,但不适合表达精确的调用契约。你说"帮我把这个文件里的函数重命名一下",模型能理解,但它不确定你是要重命名全部还是部分、要不要保留备份、要不要同步改调用处。而~rename --scope=all --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 --path=xxx,路由层先按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 --encoding=utf-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要处理--key=value和--key value两种写法,后者容易和位置参数混淆,要小心。
4.4 启动时的注册表构建
注册表构建发生在启动阶段,做三件事:扫描技能目录、读取 manifest、校验并汇总。校验这一步别省,我见过太多因为 manifest 写错导致运行时才报错的案例。校验内容包括:技能名是否重复、动作名是否重复、参数类型是否合法、requires引用的校验器是否存在。
构建完成后,把注册表缓存起来,运行时只读。如果支持热加载,就在文件变化时重建注册表,但重建期间要保证正在执行的调用不受影响——通常做法是双缓冲,新注册表构建完再原子切换。
5. 实测中暴露的问题与排查链路
5.1 参数解析的引号陷阱
我实测遇到的第一个坑是引号。用户输入~file write --path="./my file.txt" --content="hello 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 个技能,那是结果,不是起点。