> 原始 Markdown 孪生体（构建期从源 Markdown 生成）。渲染页：https://docs.gatellm.io/zh-CN/howto/script-switch-model-by-plan-mode · 文档索引：https://docs.gatellm.io/zh-CN/llms.txt


# 用脚本按 Claude Code plan 模式切换模型

Claude Code 在 plan 模式（计划模式）做规划、退出后做执行。很多团队希望 plan 阶段走强推理模型、执行阶段落性价比模型——但 Claude Code 客户端本身没有「按模式选模型」的开关。可在网关侧用脚本识别对话里注入的 plan 模式标记，据此 `context.useModel` 切换路由：plan 态留在当前（高质量）模型，退出 plan 后切到执行模型。

前置：先读 [写第一个脚本变换](/zh-CN/howto/write-script-transform.md) 了解基本结构、挂载方式、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 参考](/zh-CN/reference/scripting-api.md)。

控制台操作：上游与模型 → 编辑**入口模型**（plan 阶段所用的模型）→ **高级设置** → **转换脚本(JavaScript)** → **请求** 页签 → 运行于选 **翻译前（Before）**，粘贴下面的脚本。

控制台实际界面（截图为英文 UI，中文 UI 对应「高级设置 / 转换脚本(JavaScript) / 请求 / 翻译前」）：

![Transform (JavaScript) 面板：Runs 选 Before、页签选 Request，编辑器内粘贴脚本；Script Error Mode 保持默认 Log and Continue](/images/usecases/transform-script-before-request.png)

*注意 **Runs 必须停在 Before、页签停在 Request**——after 槽拿到的是翻译后的上游 body，读不到客户端注入的 plan 标记。*

## 完整脚本

```javascript
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` 循环取末次匹配位置，比较同消息内两标记先后即得当前状态。

::: warning 脚本内存上限与 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 后的请求，到控制台 → [日志](/zh-CN/console/logs-viewer.md) 看模型列：

- 退出 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`；见本页内存上限说明与 [脚本配置](/zh-CN/reference/scripting-config.md)。

**下一步**：[用脚本做协议翻译修正](/zh-CN/howto/script-protocol-translate.md) 看其余 before / after 槽实战例子；[脚本 API 参考](/zh-CN/reference/scripting-api.md) 看 `context.useModel` 与完整内置函数表；[脚本性能与内存](/zh-CN/practices/script-performance.md) 看开销来源与 after 槽 OOM 反模式。
