本文是《你选了 Opus,但干活的是 Haiku》 的后续。上一篇只讲了”模型路由”一层——声明层 md 文件和执行层
getAgentModel()之间的关系。这一篇挖它没展开的三条线:activeAgents覆盖链、slash command 自带的model字段、以及隐式 fork 子线程。本文所有引用基于 Claude Code v2.1.88 反混淆源码(sherry255/civil-engineering-cloud-claude-code-source-v2.1.88)。
写完上一篇后,有个问题一直放不下。上一篇的整个论证框架是:
1 | plugin md 里写的 model |
这条主线对大多数 plugin subagent 都适用。但它有三个盲点。第一,主 agent 看到的 agent 列表根本不是”扫描 plugin 目录得到的”——它是一个多源合并的结果,plugin 只是其中一层。第二,slash command 的 frontmatter 里除了 model 字段可以引导子 agent,它还可以直接改本次 slash command 触发的 query 模型(只针对这一轮,不是永久切换会话)。第三,不是所有委托都走命名 subagent——有一条叫 fork 的隐藏路径完全绕开 plugin frontmatter 分析。
这三条线都不属于”模型路由”的范畴,它们属于覆盖、暴露、委托这三个另外的维度。前一篇讲的是 Claude Code 作为”路由器”怎么工作,这一篇讲的是它作为一个完整调度系统的另外三个侧面。
一、activeAgents 的六层覆盖链
上一篇留的坑
上一篇有一段说”plugin md 里写了 model: sonnet,就是 sonnet”,然后用 hook 日志去追踪实际调用。这个叙述暗示了一件事:plugin 文件的 frontmatter 是 agent 配置的最终来源。
这个暗示是错的。
真相:六层合并
src/tools/AgentTool/loadAgentsDir.ts:193-221:
1 | export function getActiveAgentsFromList( |
合并顺序,从先写到后写:
1 | built-in → plugin → userSettings → projectSettings → flagSettings → policySettings |
Map.set() 是后写的覆盖前写的。所以优先级是反过来的,从低到高:
1 | built-in < plugin < userSettings < projectSettings < flagSettings < policySettings |
也就是说:前提是同一个 agentType 在多层都存在定义,最终生效的是 policySettings 那一层——如果 policy 层没有这个 agent,它根本不参与合并,不会影响其他层的定义。
这意味着什么
场景 A:覆盖内置 Explore
上一篇讲过,built-in 层的 Explore agent 对外部用户是 model: 'haiku'。但 built-in 是最低优先级。你可以在 ~/.claude/agents/Explore.md 放一个同名 agent:
1 | --- |
注意 description 字段是必须的。parseAgentFromMarkdown() 在缺 name 或 description 时会直接返回 null(src/tools/AgentTool/loadAgentsDir.ts:549-561),agent 不会被加载,也就不会出现在 activeAgents 里。没有 description 的 md 文件会被当作”co-located reference documentation” 静默跳过。
这是 userSettings 层,优先级高于 built-in。合并后 activeAgents.find(a => a.agentType === 'Explore') 返回的是你这份,model 是 opus。
场景 B:项目级覆盖用户级
你在 ~/.claude/agents/ 放了一个自定义 code-reviewer,团队成员 A 觉得不合适,在项目 .claude/agents/code-reviewer.md 里再写了一份。同一台机器跑同一个项目时,生效的是项目级(projectSettings 优先级高于 userSettings)。
场景 C:企业策略反压用户
policySettings 是最高优先级——企业 IT 可以下发一套 managed agent,强制覆盖用户和项目级别的所有自定义。这是企业部署里的控制点,不是用户绕得过的。
一个容易写歪的推论
不要把这写成”用户总能覆盖 plugin”。覆盖链是双向的:
- userSettings 能覆盖 plugin 和 built-in → 用户有自由
- policySettings 能覆盖 userSettings → 企业有控制
所以”我只要在 .claude/agents/ 里放一份就能搞定”只对个人用户成立。在企业环境下,IT 管理员下发的 policy agent 会反过来压你的用户层文件。
对模型路由的反向修正
上一篇”情况对照表”里有一行叫 Explore(搜代码/找文件)。那一行严格说是”built-in 层的 Explore”——如果你自己写了一份 ~/.claude/agents/Explore.md,表格里那一行对你无效,你看到的 Explore 是你自己那份。
上一篇的 hook 日志建议也需要补充:如果你在 hook 日志里看到 subagent_type=Explore,那个 Explore 是最终合并后的版本。想知道它具体来自哪一层,需要看 AgentDefinition.source 字段,但 hook 只能拿到 tool_input,拿不到这个元数据。
二、slash command 自带的 model 字段
被忽略的路径
上一篇讲”slash command 是工作流编排 prompt”——命令正文用”Launch a haiku agent”这种语言引导主模型调用子 agent。所有分析都围绕”命令正文怎么影响子 agent”。
但 slash command 的 frontmatter 本身还有一个 model 字段,决定这次 slash command 触发的本轮 query 用什么主模型——只针对这一轮,不是永久切换整个会话。
证据链
先看命令的类型定义,src/types/command.ts:
1 | export interface Command { |
然后看 src/utils/processUserInput/processSlashCommand.tsx:908-920:
1 | return { |
processSlashCommand 处理完命令后,返回的对象里直接有 model: command.model。这个字段会被传给本次 query——也就是说,这次 slash command 触发的本轮 query 就跑命令 frontmatter 里写的那个模型。执行完这一轮后,下一轮对话会回到用户的 /model 设置,它不是永久的会话级切换。
这不是”引导主模型去传参给子 agent”,这是在这一轮里换掉主模型本身。
一个例子
假设你写了一个自定义命令 ~/.claude/commands/deep-review.md:
1 | --- |
场景:你现在 /model sonnet 的状态,敲 /deep-review。
/model选的是 Sonnet → 主对话默认是 Sonnet- 但
/deep-review的 frontmatter 写了model: opus processSlashCommand返回model: 'opus'给这次 query- 这次
/deep-review触发的这轮 query 跑 Opus,不管/model选的是什么 /deep-review执行完后,下一轮对话继续回到/model sonnet——frontmatter 的 model 字段只影响这一轮,不是会话级切换
这是一条用户很容易忽略的路径。你以为改主模型只能靠 /model,其实 slash command 作者可以在 frontmatter 里替你切——哪怕只切一轮。
和上一篇的机制对比
上一篇讲的 /code-review 是另一种情况。那个命令的 frontmatter 里没有 model 字段,所以这一轮的主模型不变;但正文用”Launch a haiku agent / Opus bug agent”引导主模型在调子 agent 时传对应的 model 参数。
两条路径的层级是不同的:
| 路径 | 作用对象 | 源码位置 |
|---|---|---|
slash command frontmatter model |
本次命令触发的这一轮 query 的主模型(只针对本轮) | processSlashCommand.tsx:864, 917 |
| slash command 正文里写 “Launch a haiku agent” | 子 agent 的模型(通过引导主模型给 Agent/Task 工具传 model 参数) |
无硬编码位置,是 prompt 引导 |
上一篇只讲了第二条。第一条是这次要补的。
三、disableModelInvocation:能力暴露边界
和 model 配套的另一个字段
和 command.model 一起出现在 Command 类型里的,还有一个字段叫 disableModelInvocation(src/types/command.ts:189):
1 | disableModelInvocation?: boolean // Whether to disable this command from being invoked by models |
这两个字段控制两个完全独立的维度:
userInvocable: true→ 用户在终端输入/xxx时可以触发disableModelInvocation: true→ 主模型不能通过 SkillTool 主动调用这个命令
四种组合
这两个字段交叉组合出四种命令类型:
userInvocable |
disableModelInvocation |
行为 |
|---|---|---|
true |
false |
正常 slash command:用户可调,模型也能主动想到用 |
true |
true |
只允许人手触发:用户可调,但模型”不知道这个招式存在” |
false |
false |
只给模型用的内部命令:用户不可见,模型可主动调 |
false |
true |
完全隐藏:两边都调不了(主要用于 legacy/deprecated) |
实际例子:debug 和 batch
src/skills/bundled/debug.ts:21-23:
1 | // disableModelInvocation so that the user has to explicitly request it in |
注释说得很清楚:debug 这个技能需要用户显式请求,不能让模型主动发起。所以尽管 debug 在技能列表里存在,主模型的 SkillTool 列表里看不到它。
同样的 src/skills/bundled/batch.ts:109 也是 disableModelInvocation: true。
为什么这不是”模型路由”
这个机制决定的不是”调用时用什么模型”,而是模型在它的 SkillTool 可用列表里能看到什么。它控制的是 LLM 的”世界观”——你装的一个 plugin 可能带了 10 条命令,但其中 3 条标了 disableModelInvocation: true,主模型根本不知道那 3 条存在,自然也不会主动想到用。
用上一篇的术语说,这是能力暴露边界,不是路由。但它和路由在行为上有相似的效果:用户觉得”某某命令为什么不触发?” 答案可能不是”路由错了”,而是”主模型从头就没看到这条命令”。
src/tools/SkillTool/SkillTool.ts:412:
1 | if (foundCommand.disableModelInvocation) { |
调用时还有一层保护——就算模型通过某种方式尝试调用一个 disableModelInvocation: true 的命令,SkillTool 会在执行前拒掉。
四、隐式 fork subagent:绕开 frontmatter 分析的暗线
不是所有委托都走命名 subagent
前面所有分析都假设:主 agent 调 Agent/Task 工具 → 传 subagent_type → 按 agentType 匹配 → 执行。
但有一条路径 subagent_type 可以是空的。
Feature gate
src/tools/AgentTool/forkSubagent.ts:33-42:
1 | /** |
三个前提都满足才启用:
FORK_SUBAGENTfeature flag 为 true(这是 build-time 或 GrowthBook 控制的实验标志)- 不在 coordinator 模式
- 不在 non-interactive session(SDK 调用模式不算)
所以 fork 不是默认路径。大多数用户的大多数会话走的还是上一篇讲的”命名 subagent”路径。
合成 agent 定义
src/tools/AgentTool/forkSubagent.ts:60-71:
1 | export const FORK_AGENT = { |
几个关键字段:
agentType: 'fork':合成名字,用于 analytics 标记,不出现在用户可见的 agent 列表里tools: ['*']withuseExactTools:fork 子进程获得父进程的完整工具池(字面相同,不是白名单)model: 'inherit':继承父模型permissionMode: 'bubble':权限请求冒泡回父终端,不是独立权限上下文getSystemPrompt: () => '':这个函数返回空字符串,因为 fork 子 agent 不重新渲染 system prompt——它字节复用父的renderedSystemPrompt
为什么要这样设计
注释里直接给了答案:
1 | /** |
核心目的是 prompt cache 共享。Anthropic 推理端对长 prompt 的计费和延迟优化依赖 prompt cache——如果两次请求的前缀字节完全一致,后一次就能命中缓存,省 token、降延迟。
fork 路径要求父 agent 和 fork 子 agent 的 API 请求前缀字节完全一致。所以:
- 工具池不能变 →
tools: ['*']且字面复用 - system prompt 不能重新渲染 → 直接复用父的 bytes(GrowthBook cold→warm 会让 prompt 正文微妙变化)
- 模型不能变 →
inherit - 上下文长度需要一致 → 继承父
这条路径的存在是个成本优化 hack,不是一个通用的委托机制。
对上一篇的意义
上一篇第三节画了主 agent 决策调用 subagent 的流程图,假设每次调用都要匹配 agentType。fork 路径完全不走这条流程:
- 主 agent 调 Agent/Task 工具,
subagent_type空 isForkSubagentEnabled()返回 true- 走 fork 分支,
selectedAgent = FORK_AGENT(见AgentTool.tsx:332-335) - 不读任何 plugin frontmatter
- 不在
activeAgents列表里查找 - 不经过
getAgentModel()的 frontmatter 解析分支 - 直接复用父的一切
所以上一篇讲的模型路由机制,对 fork 路径是没有任何约束力的。fork 子 agent 永远继承父模型,永远拥有父的完整工具池,永远用父的 system prompt。
递归保护
如果 fork 子 agent 再尝试调用 Agent/Task 工具触发新的 fork 会怎样?
src/tools/AgentTool/AgentTool.tsx:332-334:
1 | if (toolUseContext.options.querySource === `agent:builtin:${FORK_AGENT.agentType}` || isInForkChild(toolUseContext.messages)) { |
fork 子 agent 的工具池里保留着 Agent 工具(为了 prompt cache 字节一致不能拿掉),所以需要在调用时运行时拒绝——防止无限递归 fork。
怎么在 hook 日志里识别 fork
上一篇给的 hook 例子长这样:
1 | "command": "jq -c '{tool_name, subagent_type: (.tool_input.subagent_type // null), model: (.tool_input.model // null), prompt: (.tool_input.prompt // null)}' >> /tmp/agent-calls.log" |
如果你看到一行 subagent_type: null,那就是 fork 路径被触发了。如果 subagent_type 是具体的 agentType 字符串(比如 "Explore" 或 "feature-dev:code-reviewer"),那是正常的命名 subagent 路径。
五、三条线合起来看
上一篇讲的是 Claude Code 作为模型路由器怎么工作。这一篇讲的是它作为完整调度系统的另外三个维度:
| 维度 | 关键机制 | 控制点 |
|---|---|---|
| 路由(上一篇) | getAgentModel() 优先级解析 |
CLAUDE_CODE_SUBAGENT_MODEL、Agent/Task 工具 model 参数、md frontmatter |
| 覆盖 | activeAgents 六层合并 |
~/.claude/agents/、.claude/agents/、policy settings |
| 暴露 | commands/skills 的 disableModelInvocation 和 userInvocable |
frontmatter 字段(Command/Skill 层通用,不只是 slash command) |
| 隐式委托 | FORK_AGENT feature gate | FORK_SUBAGENT flag |
四者的组合决定了你的某条指令最终跑在哪个模型上、由哪个 agent 执行、在哪个权限上下文里。单看任何一条都不够:
- 单看路由:会忽略你自己的
.claude/agents/文件对内置 agent 的覆盖 - 单看覆盖:会忽略 slash command 的 frontmatter
model能在一轮对话里直接换主模型 - 单看暴露:会忽略 fork 路径完全绕开整个分析框架
- 单看委托:会忽略 policy settings 能反压用户自定义
实际应用:想精确控制 Opus 用在哪
如果你想在日常编码里让”高价值决策走 Opus、低价值搜索走 Haiku”,单靠 /model opus + CLAUDE_CODE_SUBAGENT_MODEL 做不到——因为这两个旋钮太粗。细粒度的控制需要:
- 把 built-in Explore 的默认行为显式挪到自己可控层:在
~/.claude/agents/Explore.md写一份同名 agent(含必需的description字段),model可以写 haiku、inherit、或任何你想要的值。默认外部构建下 built-in Explore 本来就是 Haiku,这一步不是为了”改”,而是为了显式化——把模型选择从一个不可见的编译时常量挪到一个可读可改的文件里,后续想调就能调。 - 用
~/.claude/commands/写自定义 slash command,在特定命令的 frontmatter 里指定model: opus——只有调用这条命令的这一轮才切到 Opus,后续对话自动回到/model设置 - 接受 fork 路径不受控——它总是继承父模型,你没办法让 fork 子 agent 跑和父不同的模型
这三条配合起来才是”细粒度模型控制”,比单纯设 CLAUDE_CODE_SUBAGENT_MODEL 精确得多。
附录:源码引用
A. loadAgentsDir.ts:193-221 —— getActiveAgentsFromList 合并逻辑
B. forkSubagent.ts:33-42 —— isForkSubagentEnabled feature gate
C. forkSubagent.ts:60-71 —— FORK_AGENT 合成定义
D. AgentTool.tsx:332-335 —— fork 路径选择和递归保护
E. processSlashCommand.tsx:864, 917 —— command.model 作为本次 query 的主模型返回(只影响这一轮 query,不是会话级切换)
F. types/command.ts:189 —— disableModelInvocation 和 userInvocable 定义
G. SkillTool.ts:412 —— 调用时对 disableModelInvocation 的拒绝检查
H. skills/bundled/debug.ts:21-23 —— 实际应用 disableModelInvocation: true 的例子
结语
写完上一篇时我以为 Claude Code 的模型调度已经讲清楚了。读完这三条线之后才意识到:模型路由只是台面上那一层,底下还压着覆盖、暴露、隐式委托三个维度。
如果把 Claude Code 的调度架构画成一个图,会是这样的:
1 | 用户指令 |
你能控制的旋钮只有左上那几个(主 agent 和 slash command 层)。一旦进入委托分支,后续走哪条路径、用什么模型,取决于 plugin 作者、Anthropic 工程师、feature flag 组合的一连串决策。
“我选了 Opus,实际用什么模型” 这个问题的完整答案,不是一句话,是一个需要看四个维度的矩阵。