Sherry's Blog


  • 首页

  • 关于我

  • 标签

  • 目录

  • 吐槽

  • 文章列表

OpenClaw:从入门到放弃

发表于 2026-04-27 | 分类于 技术

因为罗福莉的安利,我再一次尝试使用 openclaw,
又一次放弃了。

“帮我抓一下 B 站热门视频”
推理花了 10 万 token
(8.8k input / 2.5k visible output / 97.3k reasoning)
这还是太贵了。

顶级模型是基础,
顶级工具还是 codex、cc。
openclaw 只能算个半成品玩具。
便宜的模型 + openclaw,远不如直接使用贵的模型的 agent。

回到我的提示词,
其实一行 shell 或者普通的爬虫工具就行了,
连 LLM token 都不需要。
事实上,大部分人的日常定时任务场景,使用普通爬虫、固定的 DAG/flow 编排,或者复用既有代码,往往是更优解。

想要做大而全的工具,
又想要自主记忆管理,
又要避免上下文污染,
又想省 token,
其实很难 trade-off。

大而全必然意味着臃肿 + 复杂,
单一场景 + 扁平化记忆管理意味着效率高。

cc 的做法更接近后者,再加上分层的模型使用策略。
公开信息里能确认的是,它会把一部分简单或后台任务交给 Haiku 这类 small/fast model。
这种细分在 coding 场景里优化得还算很好。

openclaw 不想再用了。
非常难配置,费劲。
一堆 bug。
一条消息回复了我 n 次。。
基本的 UI 也非常难用。。
虽然 UI 也不重要,
但是至少得像样吧。
毕竟 UI 是它相比较 cc 最大的特别之处了。

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

发表于 2026-04-15 | 分类于 技术 , agent , claude-code

本文是《你选了 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() 在缺 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
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 类型里的,还有一个字段叫 disableModelInvocation(src/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 的 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 做不到——因为这两个旋钮太粗。细粒度的控制需要:

  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 —— disableModelInvocation 和 userInvocable 定义
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,实际用什么模型” 这个问题的完整答案,不是一句话,是一个需要看四个维度的矩阵。

12…24

54 日志
17 分类
44 标签
© 2026 Sherry