Claude Code 不只是 model routing:覆盖链、slash command 自带 model、隐式 fork

本文是《你选了 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
2
3
4
5
plugin md 里写的 model

getAgentModel() 按优先级解析

最终跑什么模型

这条主线对大多数 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
export function getActiveAgentsFromList(
allAgents: AgentDefinition[],
): AgentDefinition[] {
const builtInAgents = allAgents.filter(a => a.source === 'built-in')
const pluginAgents = allAgents.filter(a => a.source === 'plugin')
const userAgents = allAgents.filter(a => a.source === 'userSettings')
const projectAgents = allAgents.filter(a => a.source === 'projectSettings')
const managedAgents = allAgents.filter(a => a.source === 'policySettings')
const flagAgents = allAgents.filter(a => a.source === 'flagSettings')

const agentGroups = [
builtInAgents,
pluginAgents,
userAgents,
projectAgents,
flagAgents,
managedAgents,
]

const agentMap = new Map<string, AgentDefinition>()

for (const agents of agentGroups) {
for (const agent of agents) {
agentMap.set(agent.agentType, agent) // 后写的覆盖前写的
}
}

return Array.from(agentMap.values())
}

合并顺序,从先写后写

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
2
3
4
5
6
7
8
9
10
---
name: Explore
description: File/code search specialist for navigating codebases. Use this when you need to find files by patterns, search code for keywords, or answer questions about the codebase.
model: opus
---

You are a file search specialist for Claude Code. You excel at thoroughly
navigating and exploring codebases.

Complete the user's search request efficiently and report your findings clearly.

注意 description 字段是必须的parseAgentFromMarkdown() 在缺 namedescription 时会直接返回 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
2
3
4
5
6
7
8
export interface Command {
// ...
model?: ModelAlias | string
effort?: EffortLevel
disableModelInvocation?: boolean
userInvocable?: boolean
// ...
}

然后看 src/utils/processUserInput/processSlashCommand.tsx:908-920

1
2
3
4
5
6
7
8
return {
messages,
shouldQuery: true,
allowedTools: additionalAllowedTools,
model: command.model, // ← 这里
effort: command.effort,
command
}

processSlashCommand 处理完命令后,返回的对象里直接有 model: command.model。这个字段会被传给本次 query——也就是说,这次 slash command 触发的本轮 query 就跑命令 frontmatter 里写的那个模型。执行完这一轮后,下一轮对话会回到用户的 /model 设置,它不是永久的会话级切换。

这不是”引导主模型去传参给子 agent”,这是在这一轮里换掉主模型本身

一个例子

假设你写了一个自定义命令 ~/.claude/commands/deep-review.md

1
2
3
4
5
6
7
---
description: 架构深度评审
model: opus
effort: high
---

对当前这段代码做一个深度架构评审,关注...

场景:你现在 /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 类型里的,还有一个字段叫 disableModelInvocationsrc/types/command.ts:189):

1
2
disableModelInvocation?: boolean  // Whether to disable this command from being invoked by models
userInvocable?: boolean // Whether users can invoke this skill by typing /skill-name

这两个字段控制两个完全独立的维度:

  • 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
2
3
// disableModelInvocation so that the user has to explicitly request it in
// the first place (since it's a somewhat heavy/invasive action).
disableModelInvocation: true,

注释说得很清楚: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
2
3
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* Fork subagent feature gate.
*
* When enabled:
* - `subagent_type` becomes optional on the Agent tool schema
* - Omitting `subagent_type` triggers an implicit fork: the child inherits
* the parent's full conversation context and system prompt
* ...
*/
export function isForkSubagentEnabled(): boolean {
if (feature('FORK_SUBAGENT')) {
if (isCoordinatorMode()) return false
if (getIsNonInteractiveSession()) return false
return true
}
return false
}

三个前提都满足才启用:

  1. FORK_SUBAGENT feature flag 为 true(这是 build-time 或 GrowthBook 控制的实验标志)
  2. 不在 coordinator 模式
  3. 不在 non-interactive session(SDK 调用模式不算)

所以 fork 不是默认路径。大多数用户的大多数会话走的还是上一篇讲的”命名 subagent”路径。

合成 agent 定义

src/tools/AgentTool/forkSubagent.ts:60-71

1
2
3
4
5
6
7
8
9
10
11
12
export const FORK_AGENT = {
agentType: FORK_SUBAGENT_TYPE, // 'fork'
whenToUse:
'Implicit fork — inherits full conversation context. Not selectable via subagent_type; triggered by omitting subagent_type when the fork experiment is active.',
tools: ['*'],
maxTurns: 200,
model: 'inherit',
permissionMode: 'bubble',
source: 'built-in',
baseDir: 'built-in',
getSystemPrompt: () => '', // 不使用,见下文
} satisfies BuiltInAgentDefinition

几个关键字段:

  • agentType: 'fork':合成名字,用于 analytics 标记,不出现在用户可见的 agent 列表里
  • tools: ['*'] with useExactTools:fork 子进程获得父进程的完整工具池(字面相同,不是白名单)
  • model: 'inherit':继承父模型
  • permissionMode: 'bubble':权限请求冒泡回父终端,不是独立权限上下文
  • getSystemPrompt: () => '':这个函数返回空字符串,因为 fork 子 agent 不重新渲染 system prompt——它字节复用父的 renderedSystemPrompt

为什么要这样设计

注释里直接给了答案:

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* ... `tools: ['*']` with `useExactTools` means the fork
* child receives the parent's exact tool pool (for cache-identical API
* prefixes). `permissionMode: 'bubble'` surfaces permission prompts to the
* parent terminal. `model: 'inherit'` keeps the parent's model for context
* length parity.
*
* The getSystemPrompt here is unused: the fork path passes
* `override.systemPrompt` with the parent's already-rendered system prompt
* bytes, threaded via `toolUseContext.renderedSystemPrompt`. Reconstructing
* by re-calling getSystemPrompt() can diverge (GrowthBook cold→warm) and
* bust the prompt cache; threading the rendered bytes is byte-exact.
*/

核心目的是 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
2
3
if (toolUseContext.options.querySource === `agent:builtin:${FORK_AGENT.agentType}` || isInForkChild(toolUseContext.messages)) {
throw new Error('Fork is not available inside a forked worker. Complete your task directly using your tools.');
}

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 的 disableModelInvocationuserInvocable 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 做不到——因为这两个旋钮太粗。细粒度的控制需要:

  1. 把 built-in Explore 的默认行为显式挪到自己可控层:在 ~/.claude/agents/Explore.md 写一份同名 agent(含必需的 description 字段),model 可以写 haiku、inherit、或任何你想要的值。默认外部构建下 built-in Explore 本来就是 Haiku,这一步不是为了”改”,而是为了显式化——把模型选择从一个不可见的编译时常量挪到一个可读可改的文件里,后续想调就能调。
  2. ~/.claude/commands/ 写自定义 slash command,在特定命令的 frontmatter 里指定 model: opus——只有调用这条命令的这一轮才切到 Opus,后续对话自动回到 /model 设置
  3. 接受 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 —— disableModelInvocationuserInvocable 定义
G. SkillTool.ts:412 —— 调用时对 disableModelInvocation 的拒绝检查
H. skills/bundled/debug.ts:21-23 —— 实际应用 disableModelInvocation: true 的例子


结语

写完上一篇时我以为 Claude Code 的模型调度已经讲清楚了。读完这三条线之后才意识到:模型路由只是台面上那一层,底下还压着覆盖、暴露、隐式委托三个维度。

如果把 Claude Code 的调度架构画成一个图,会是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
用户指令

slash command 的 frontmatter model ──┐
├── 决定本轮 query 的主模型
`/model` 设置 ──────────────────────┘ (command 的 model 只影响这一轮,
之后回到 `/model` 设置)

主 agent 执行

主 agent 决定是否委托

┌─→ 命名 subagent(传 subagent_type)
│ ↓
│ activeAgents 合并后查找(built-in/plugin/user/project/flag/policy)
│ ↓
│ getAgentModel() 解析(env var / tool param / frontmatter / inherit)
│ ↓
│ 执行

└─→ 隐式 fork(不传 subagent_type,且 flag 开启)

FORK_AGENT 字节复用父

执行

你能控制的旋钮只有左上那几个(主 agent 和 slash command 层)。一旦进入委托分支,后续走哪条路径、用什么模型,取决于 plugin 作者、Anthropic 工程师、feature flag 组合的一连串决策。

“我选了 Opus,实际用什么模型” 这个问题的完整答案,不是一句话,是一个需要看四个维度的矩阵。