Zero 架构 · 第二十课
第十九课的 skill 和这一课的用户 slash 命令(usercommands
usercommands.go:17)长得几乎一样:都是磁盘上带 frontmatter 的 markdown 文件。
新手极易把它俩当同一个东西。但它们在 agent 循环里进入的位置相反——一个是模型在回合中途主动拉取,一个是人敲完命令、在到达模型之前就被展开成普通 prompt。看懂这个「谁发起、在哪一层」的分野,就看懂了 agent 的两种扩展入口。
对齐 mission:一个 coding agent 要让用户注入可复用指令,有两个截然不同的需求。① 模型自己判断「这活儿该照某套规矩来」——那规矩得让模型能按需取(skill)。
② 用户想把一段冗长的常用请求存成快捷方式,敲 /release v1.2 就展开——那是人发起的宏,模型根本不需要知道有个命令(user command)。同一份 markdown 文件基底,喂进循环的两端。
| skill(第十九课) | 用户命令 | |
|---|---|---|
| 谁发起 | 模型(工具调用) | 人(敲 /name) |
| 在哪层解析 | 回合中途,注册表分发 | 到模型之前,TUI 里 |
| 进入形态 | 工具 Output(消息流里) | 一条普通用户 prompt |
| 模型知情否 | 知道:是它自己调的 | 不知道:只见展开后的文本 |
| 正文处理 | 原样返回 | $ARGUMENTS/$1..$9 模板展开 |
| 作用域 | 仅用户级 | 项目级 覆盖 用户级 |
| 额外 frontmatter | name/description | 还带 model/agent 路由 |
用户敲的 /release v1.2 若不是内建命令,就落到 handleUserCommand
user_commands.go:20。它做三件事,全在 TUI 客户端完成:
name, args := splitUserCommand(raw) // "/release v1.2" → name, "v1.2"
cmd, ok := m.lookupUserCommand(name) // 查文件命令表 user_commands.go:25
prompt := usercommands.Expand(cmd.Template, args) // 模板展开成文本 :29
next, teaCmd := m.launchPrompt(prompt) // 当作普通 prompt 发起 :33
关键在最后一步:展开后的文本走 launchPrompt,和用户手打一段话完全一样。模型收到的只是一段 prompt,
它根本不知道背后有个 /release 命令。所以用户命令本质是客户端的文本宏——像 shell 的 alias,在请求出门前就替换掉了,循环、模型、注册表都无需为它增加任何概念。
$ARGUMENTS / $1..$9 / $$
Expand usercommands.go:146
把 $ARGUMENTS 换成整串参数、$1..$9 换成第 N 个空格分隔位置参数、$$ 换成字面 $。
一个巧妙的兜底:模板里若一个 $ 占位符都没有,就把原始参数另起一行追加到模板后
(usercommands.go:150)——这样连最简单的「命令 + 用户补充」也能work,不必强迫作者写占位符。
用户命令扫两个目录:项目的 .zero/commands/ 和用户的 <config>/zero/commands/
(DefaultPaths usercommands.go:45)。
Load usercommands.go:60 先装用户、再装项目,同名时项目条目覆盖用户条目——
于是一个 repo 能把团队工作流签进版本库,且盖过个人的同名命令(与 specialist 的 scope 优先级同规矩)。
这与第十九课 skill 的用户级唯一 + 字典序去重是有意的分野:skill 是「模型能力」,当前不做项目级;用户命令天生是「团队可共享的工作流」,所以要项目 > 用户的覆盖语义。不同的发起者,决定了不同的作用域设计。
validCommandName usercommands.go:107
只认小写字母/数字/连字符——这样一个乱起名的文件无法靠奇怪字符去影子覆盖一个内建命令。
注意查找顺序:内建命令先匹配,不中才落到 handleUserCommand(user_commands.go:11)——用户命令补充内建,而非篡夺。
用户命令的 frontmatter 除了 description,还认 model 和 agent/mode
(parseCommand usercommands.go:119)——因为它是「发起一整个回合」的入口,自然可以指定这回合用哪个模型、路由到哪个 specialist。
skill 没有这种字段,因为它只是把一段文字塞进当前回合,无权改变回合本身的参数。能携带什么元数据,由它在循环里的角色决定。
skill 和用户命令共享几乎相同的「目录 + frontmatter + markdown」实现(连 splitFrontmatter 都是彼此的镜像,
usercommands.go:185),但它们服务于相反的发起者:
skill 是模型伸手去取的能力(工具,回合中途,模型知情),用户命令是人敲下就展开的宏(客户端文本替换,到模型前完成,模型无感)。
判断某个新需求该做成哪一个,只问一句话:「这该由模型决定用不用,还是由人决定发不发?」
用户敲的 /release v1.2 命令,是在 agent 循环的哪一层被处理的?
skill 和用户命令在「谁发起」上的根本区别是什么?
为什么用户命令有「项目级覆盖用户级」,而 skill 只有用户级?
less +20 ../zero/internal/tui/user_commands.go # handleUserCommand:split→lookup→Expand→launchPrompt
less +146 ../zero/internal/usercommands/usercommands.go # Expand:$ARGUMENTS/$1..$9/$$ + 无占位符兜底
less +60 ../zero/internal/usercommands/usercommands.go # Load:先用户后项目,项目覆盖同名
less +107 ../zero/internal/usercommands/usercommands.go # validCommandName:防影子覆盖内建命令
less +119 ../zero/internal/usercommands/usercommands.go # parseCommand:model/agent 路由 frontmatter
读的顺序:先看 handleUserCommand 那四行——Expand 完就 launchPrompt,是理解「命令在到模型前就变回普通 prompt」的关键。
再看 Expand 的占位符替换和「无 $ 就追加参数」的兜底。最后对照第十九课的 skill.go:同样的 frontmatter 解析,却服务于相反的发起者。
agent/mode 是怎么把这一回合路由到某个 specialist 的?」、
「MCP server 也能通过 prompts/list 提供curated prompt(prompts.go:57)——它和用户命令这种本地宏又差在哪?」,
或者换个大块:「swarm/多 agent 编排的骨架——多个 agent 怎么被拉起、分工、汇合?」