用脚本按 Claude Code plan 模式切换模型
Claude Code 在 plan 模式(计划模式)做规划、退出后做执行。很多团队希望 plan 阶段走强推理模型、执行阶段落性价比模型——但 Claude Code 客户端本身没有「按模式选模型」的开关。可在网关侧用脚本识别对话里注入的 plan 模式标记,据此 context.useModel 切换路由:plan 态留在当前(高质量)模型,退出 plan 后切到执行模型。
前置:先读 写第一个脚本变换 了解基本结构、挂载方式、Context API。
plan 模式标记怎么判定
Claude Code 的 plan 模式(计划模式)会在对话里注入标记:进入时通过 <system-reminder> 在当前轮末尾消息注入 Plan mode is active,退出时注入 Exited Plan Mode。一旦进入过 plan 模式,进入标记会永久留在对话历史里——所以不能用"是否包含该字符串"判断当前状态(退出后历史里仍能匹配到旧的进入标记,会永远误判为 plan 模式)。正确做法是从末尾消息向前扫描,取最后出现的标记作为当前状态。
plan 模式是 Claude Code(Anthropic 协议)特有的概念。如果入口模型同时服务其他协议的客户端,必须先判断 context.sourceProtocol——非 Anthropic 请求里不会有这两个标记,全文扫描会误判为"非 plan"而把流量切走。脚本开头先放过非 Anthropic 请求。
挂在哪里:入口模型的 before 槽
本例把脚本挂在入口模型(plan 模式所用的模型)的 before 槽(request_transform_before):plan 模式中不切换、保持当前模型;退出 plan 模式时 useModel 切到执行模型。
挂 before 槽是因为脚本要读客户端原始 Anthropic body 里的标记文本(after 槽拿到的是已翻译的上游格式,标记位置已变),且 plan→exec 是跨模型路由切换(before 槽支持跨协议切换,after 槽的 useModel 只能同协议)。before/after 两槽的完整说明见 脚本 API 参考。
控制台操作:上游与模型 → 编辑入口模型(plan 阶段所用的模型)→ 高级设置 → 转换脚本(JavaScript) → 请求 页签 → 运行于选 翻译前(Before),粘贴下面的脚本。
控制台实际界面(截图为英文 UI,中文 UI 对应「高级设置 / 转换脚本(JavaScript) / 请求 / 翻译前」):

注意 Runs 必须停在 Before、页签停在 Request——after 槽拿到的是翻译后的上游 body,读不到客户端注入的 plan 标记。
完整脚本
function transform(body, context) {
if (context.sourceProtocol !== "Anthropic") {
return body;
}
// 执行模型:网关里配好的模型名(与客户端在 `model` 里请求的名字一致),
// 不是「上游名/模型名」——useModel 按网关模型名解析路由。
const EXEC_MODEL = "qwen3.7-max";
const PLAN = /plan mode is active/i;
const EXIT = /exited plan mode/i;
// 标记在串中末次出现的位置;PLAN 与 EXIT 同现时,靠后者代表当前状态。
const lastIdx = (s, re) => {
let idx = -1, m;
const g = new RegExp(re.source, "gi");
while ((m = g.exec(s))) idx = m.index;
return idx;
};
// 从末尾取首个含标记的块——它必含全局最后的标记,块内再比两标记先后。
const lastMark = (content) => {
const blocks = typeof content === "string" ? [content]
: Array.isArray(content) ? content.map(b => (b && typeof b.text === "string") ? b.text : "") : [];
for (let i = blocks.length - 1; i >= 0; i--) {
const s = blocks[i];
if (!s) continue;
const p = PLAN.test(s), e = EXIT.test(s);
if (!p && !e) continue;
return (p && e) ? (lastIdx(s, PLAN) > lastIdx(s, EXIT) ? "plan" : "exit") : (p ? "plan" : "exit");
}
return null;
};
// 标记注入在最新一条消息的 <system-reminder>:进入为 "Plan mode is active",
// 退出为 "Exited Plan Mode"。进入标记永久留在历史里,故当前状态由最后一个含
// 标记的消息决定——从末尾向前取首个含标记的消息,比较其内两标记的位置判定。
const messages = body.messages || [];
for (let i = messages.length - 1; i >= 0; i--) {
const m = lastMark(messages[i].content);
if (m) {
if (m === "exit") context.useModel(EXEC_MODEL);
return body;
}
}
// 所有消息都没有标记——回退到 system prompt 判断。
if (lastMark(body.system) === "plan") return body;
// 兜底:任何地方都没有标记 ⇒ 不在 plan 模式 ⇒ 切执行模型。
context.useModel(EXEC_MODEL);
return body;
}要点:
- 必须放在 before 槽(
request_transform_before):脚本要读客户端原始 Anthropic body 里的标记文本,且 plan→exec 是跨模型路由切换。 context.sourceProtocol的取值是"Anthropic"/"OpenAIChatCompletions"/"Google"这种驼峰写法(不是anthropic这种小写形式)。脚本开头用!== "Anthropic"放过非 Claude Code 流量。- 挂在入口模型上时,plan 态不调
useModel,请求落在本模型,本模型的request_payload规则(如prompt_cache_key)正好生效;exec 态切走后应用的是目标模型的规则,所以这类规则要在两个模型上各配一份。 - Claude Code 还会发旁路请求(如对话标题生成),这类请求不携带 plan 标记、也不是 plan 对话轮,按"无标记=非 plan"切到执行模型,属正常且无害。
- 从末尾向前逐消息扫描:标记只注入在当前轮末尾消息里,故从末尾向前取首个含标记的消息、只比较该消息内的标记位置即可判定当前状态。
- 大小写不敏感靠正则
/i标志:/plan mode is active/i直接匹配注入文本;.test/.exec原地扫描,不复制整条消息。lastIdx用exec循环取末次匹配位置,比较同消息内两标记先后即得当前状态。
脚本内存上限与 OOM
每个脚本在独立的 QuickJS 沙箱里执行,堆上限由 SCRIPT_MEMORY_LIMIT_MB 控制(默认 64MB;改动需重启,不热生效)。脚本超过该上限即抛 out of memory,变换按错误模式兜底——默认的 log-and-continue 会静默丢弃本次脚本的全部副作用(包括 context.useModel),请求按原 body 继续,外在表现就是"脚本没生效 / 路由没切"。
脚本层救不了 body → JS 的固有转换内存:网关把请求体送进沙箱时,tools / messages 等结构(例如 Claude Code 的几十个工具定义)会先占一份内存,这是脚本层无法消除的 floor。若超大 body 仍 OOM,先调高 SCRIPT_MEMORY_LIMIT_MB;若希望彻底绕开 JS 内存墙、按文本标记做条件路由,需要执行引擎层面的原生能力(尚未实现,列入后续规划)。
验证
配好后分别发一条 plan 态、一条退出 plan 后的请求,到控制台 → 日志 看模型列:
- 退出 plan 后被
useModel切走的请求,模型名前会挂SW标签(script useModel swap),悬停可见切换来源;模型列显示执行模型(如qwen3.7-max)。 - plan 态请求不切换,模型列仍是入口模型、无
SW标签。
两类请求的落点符合预期即脚本生效。若该切没切,先查是否 OOM 被静默丢弃(见上)。
常见问题
Q:退出 plan 模式后没切走 / 一直被判成 plan 态? 多半用了「是否包含 Plan mode is active」判断,或脚本挂在了 after 模式槽。进入标记永久留在历史里,退出后旧标记仍能匹配到,会永远误判为 plan 态。改成从末尾向前取末次标记;同时确认脚本挂在 before 槽——after 槽拿到的是上游格式 body,读不到客户端注入的标记文本位置。
Q:非 Claude Code 的客户端流量也被切到执行模型了? 漏了脚本开头的 context.sourceProtocol !== "Anthropic" 放行分支。其他协议的 body 里不会有这两个标记,会被兜底逻辑判成「非 plan」而切走。先判协议、非 Anthropic 直接 return body 放过。
Q:脚本看起来没生效,日志也没报错? 脚本 OOM 时按 log-and-continue 静默丢弃全部副作用(含 context.useModel),外在表现就是「路由没切」。Claude Code 的几十个工具定义会先占 body→JS 的固有转换内存,这是脚本层消除不了的 floor。先调高 SCRIPT_MEMORY_LIMIT_MB;见本页内存上限说明与 脚本配置。
下一步:用脚本做协议翻译修正 看其余 before / after 槽实战例子;脚本 API 参考 看 context.useModel 与完整内置函数表;脚本性能与内存 看开销来源与 after 槽 OOM 反模式。
