Zero 架构 · 第二十课

两种可复用指令,喂进循环的两端:模型拉取 vs 人类宏展开

第十九课的 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 模板展开
作用域仅用户级项目级 覆盖 用户级
额外 frontmattername/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,不必强迫作者写占位符。

作用域:用户命令有「项目级覆盖」,skill 没有

用户命令扫两个目录:项目的 .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)——用户命令补充内建,而非篡夺。

额外一手:用户命令能换模型/换 agent

用户命令的 frontmatter 除了 description,还认 modelagent/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 解析,却服务于相反的发起者。

我是你的老师 —— 随时问我。 适合现在追问: 「用户命令 frontmatter 里的 agent/mode 是怎么把这一回合路由到某个 specialist 的?」、 「MCP server 也能通过 prompts/list 提供curated prompt(prompts.go:57)——它和用户命令这种本地宏又差在哪?」, 或者换个大块:「swarm/多 agent 编排的骨架——多个 agent 怎么被拉起、分工、汇合?」