# Max 模式：多候选与裁判

> 实验性的 max 智能体怎样开启，每一步怎样并行生成只提议、不执行的候选，裁判怎样挑选，赢家怎样回放进会话，以及降级、重试与成本记账。

- 作者：David（道雾轩）
- 专栏：MiMo Code 源码解读（https://daiw.net/manual/mimo-code.md）
- 最后更新：2026-09-17
- 原文：https://daiw.net/manual/mimo-code/max-mode
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

普通智能体每一步只采样一次，这一次想偏了，工具就照着偏的方向执行下去。Max 模式改成“先想几遍，再挑一遍去做”：同一步并行发出多份相同的请求，每份候选只写思考、正文和打算调用的工具，什么都不执行；再用一次裁判调用挑出其中一份，交给处理器真正执行。这是 MiMo 新增的能力，上游 OpenCode 没有对应实现，主体在 `packages/opencode/src/session/max-mode.ts`，赢家的回放在处理器里。

小米的博客（2026-06-10）把它列为“计算层”的第一项，称默认 5 个候选，在 SWE-Bench Pro 上比单次采样提升 10–20%，代价是大约 4–5 倍的 token 消耗。提升幅度与成本倍数是博客的说法，代码里无从核实；代码能确认的是默认候选数确实是 5（`DEFAULT_CANDIDATES`，`packages/opencode/src/session/max-mode.ts:18`）。

## 怎么用

1. 在配置里声明 `experimental.maxMode`（`packages/opencode/src/config/config.ts:454`）。写个空对象就行；`candidates` 是每步的候选数，正整数，默认 5，例如 `"experimental": { "maxMode": { "candidates": 3 } }`。
2. 只有声明了这一项，`max` 智能体才会注册（`packages/opencode/src/agent/agent.ts:153`）。它是 primary 智能体，描述里说权限与 build 相同，实际是默认规则加 `question: "allow"` 再叠用户配置，比 build 少一条 `plan_exit: "allow"`，所以默认看不到 `plan_exit` 工具。
3. 切换过去：TUI 里按 Tab 循环切换智能体（`agent_cycle`，`packages/opencode/src/config/keybinds.ts:64`）；或者启动时带 `--agent max`（`packages/opencode/src/cli/cmd/tui/thread.ts:189`，`mimo run` 的同名选项在 `packages/opencode/src/cli/cmd/run.ts:258`）；或者把 `default_agent` 设为 `max`。
4. 候选与裁判用同一个智能体、同一个模型，在 `agent.max` 下配置的 `temperature` 会同时作用于两者（`agent.ts:477`）。

运行时会话的忙碌状态会依次带上 `thinking — 5 candidates` 与 `judging 5 candidates` 两句说明（`max-mode.ts:346`、`max-mode.ts:373`），赢家的思考开头会多一行 `[max mode] selected candidate 2 of 5` 式的说明。

## 一步的流程

`packages/opencode/src/session/prompt.ts:4533` 判定：智能体名是 `max`、配置里有 `maxMode`、本步不是 `json_schema` 结构化输出，三者同时满足，本步才交给 `MaxMode.runMaxStep`，否则照常 `handle.process`（`prompt.ts:4592`）。两者接收同一份参数对象，`runMaxStep` 只额外拿到处理器句柄、LLM 服务、候选数、重试配置、状态回调与重试事件回调。fork 子智能体那条路径（`prompt.ts:4297`）不看这个开关，`max` 派出去的其他子智能体也照常单次采样。

```mermaid
flowchart LR
  P["同一份请求，工具去掉 execute"] --> C1["候选 1"]
  P --> C2["候选 2"]
  P --> CN["候选 N"]
  C1 --> S{"存活几个"}
  C2 --> S
  CN --> S
  S -->|0 个| F["退回普通 handle.process"]
  S -->|1 个| W["赢家"]
  S -->|多个| J["裁判：只看候选，回一个序号"]
  J --> W
  W --> R["handle.replay：写入思考与正文，逐个执行工具"]
```

## 候选：只提议，不执行

`runCandidate`（`max-mode.ts:124`）用与普通路径相同的系统提示、消息和可见工具发起请求，唯一的改动是把每个工具的 `execute` 去掉（`toSchemaOnlyTools`，`max-mode.ts:100`）。按注释的说法，AI SDK 遇到没有 `execute` 的工具调用会结束这一步、只发出 `tool-call` 事件，正好就是“只提议”。候选在内存里累积思考、正文、拟调用的工具、结束原因和用量，不写数据库，不发总线事件。

所以候选之间不需要 worktree 一类的隔离：它们不执行工具、不写会话，请求内容也完全相同，彼此的差别只来自采样的随机性。反过来，候选也看不到自己所提工具的执行结果——每个候选只规划“下一步”，多步任务仍由主循环一步步推进，每一步重新来一轮多候选。

`runMaxStep`（`max-mode.ts:336`）让 N 个候选同时跑，并发度就是 N（`max-mode.ts:347`）。单个候选出错不会拖垮整步：它先按 `max-candidate` 范围重试，最终仍失败就返回 null，只记一条 `candidate failed` 日志。注释里记着两个坑：AI SDK 把流中途的连接重置当作一个 `error` 部件来结束流、并不抛错，候选必须主动把它转成失败，重试才会触发（`max-mode.ts:180`）；深层的 SSE 超时以 defect 的形式冒出来，要先转成普通失败（`max-mode.ts:197`），否则会绕过重试，直接打断整个会话。

每个候选还各带一个流内复读监控（见[复读检测与重试](https://daiw.net/manual/mimo-code/loop-recovery)），任何一个候选命中，整步直接返回 `text-repeat`（`max-mode.ts:351`）。

## 裁判：只看候选，回一个序号

存活候选多于一个时，`judge`（`max-mode.ts:261`）把每个候选渲染成一段：标题 `### Candidate 序号`，下面是思考、正文、拟调用的工具及其 JSON 参数（`renderCandidate`，`max-mode.ts:220`），全部拼进一条用户消息，要求只回一个整数。裁判的系统提示在 `max-mode.ts:235`：

```ts
const JUDGE_SYSTEM = [
  "You are a judge selecting the single best next step for a coding agent.",
  "You will see several independent candidate drafts for the SAME step. Each candidate contains its reasoning, its message text, and the tool calls it proposes to make next.",
  "Pick the ONE candidate that has the most correct, grounded, and useful next step. Prefer candidates whose reasoning is sound and whose proposed tool calls are appropriate and safe.",
  "Respond with ONLY the integer index of the winning candidate (e.g. `2`). No other text.",
].join("\n")
```

几条边界：

- 裁判请求里只有这一条消息，不带会话历史，它对任务的了解全部来自候选自己的思考和正文。工具表为空，`toolChoice` 为 `none`。
- 裁判没有传 `prebuiltSystem`，所以 `llm.ts` 会按 `max` 智能体重新组装一份系统提示（智能体的基础提示词等），再把 `JUDGE_SYSTEM` 追加在末尾（`packages/opencode/src/session/llm.ts:459`），而不是只用这四句。
- 从回复里取第一个整数；没有整数或越界就选 0（`parseJudgeIndex`，`max-mode.ts:247`）。裁判重试后仍然失败，也选 0。只剩一个候选时根本不调用裁判（`max-mode.ts:264`）。
- 代码没有给裁判单独设温度。候选与裁判的温度都走 `llm.ts` 的常规取值：模型支持温度参数时，取智能体配置的 `temperature`，否则按模型给默认值（`llm.ts:576`），例如 ID 含 `mimo` 的模型默认 1.0（`packages/opencode/src/provider/transform.ts:1178`）。博客所说的“低温裁判”，在代码里找不到对应。

## 赢家：回放进会话

裁判选定后，`runMaxStep` 调用处理器的 `replay`（`packages/opencode/src/session/processor.ts:913`），把赢家当作一次刚刚发生的真实流重演一遍：依次向 `handleEvent` 喂入 start、start-step、思考和正文事件；对每个拟调用的工具发出 tool-input-start 与 tool-call，调用真实工具的 `execute`，再按结果发 tool-result 或 tool-error；最后用赢家自己的用量发 finish-step。走的是同一个 `handleEvent`，所以工具部件的生命周期、权限询问、`doom_loop` 检查、快照与补丁都和普通路径一样。不同之处有三点：

- 思考开头拼上选择说明（序号从 1 数，分母是存活候选数，`processor.ts:940`）；思考的开始时间被往前推到候选阶段开始时（`processor.ts:947`），界面上显示的思考时长就是“全部候选加裁判”的墙钟时间。
- 工具按提议顺序逐个执行，遇到上下文溢出或因权限被拒而阻塞，剩下的就不再执行（`processor.ts:987`）。回放期间用户中断，会通过 AbortController 中止正在执行的工具（`processor.ts:1092`）。
- 落选候选不写进任何地方，日志里只有一条 `max step`，记录候选数、存活数、赢家序号和工具调用数（`max-mode.ts:376`）。

## 降级与重试

| 情况 | 结果 |
| --- | --- |
| 任一候选触发流内复读 | 整步返回 `text-repeat`，按 REMIND、REPLAN、终止的顺序处理 |
| 全部候选失败 | 记 `all candidates failed` 日志，退回一次普通的 `handle.process`（`max-mode.ts:354`） |
| 裁判失败或回复无法解析 | 选第 0 个存活候选 |
| 某个候选失败 | 不参与评选，其余照常 |

候选与裁判分别按 `max-candidate`、`max-judge` 范围重试（`retryPolicy`，`max-mode.ts:79`），默认预算是 3 次、3 分钟、500 毫秒起步；不过 `budgetFor` 先判网络错误，连接类错误走的是持续模式的 `network` 预算，细节见上一篇。每次重试通过 `onRetry` 发布 `session.retry.attempt` 事件（`prompt.ts:4603`），范围字段标明是候选还是裁判。因为候选与裁判只在内存里累积，重来不会重复写入任何东西，这也是它们能比普通流更放心地整条重试的原因。

`runMaxStep` 接收但不使用 `toolChoice`，注释的理由是候选总是只提议、结构化输出也不会进这条分支（`max-mode.ts:61`）。由此有个边角：如果在 `agent.max` 下配了 `steps`，到最后一步时普通路径会强制 `toolChoice: "none"`，候选却照样拿得到工具。

## 成本怎么记

`max-mode.ts:383` 的注释把一步的花费拆成两部分：赢家的用量真正进入历史，只有它决定消息上的 `tokens`，这个字段喂给溢出判定、修剪和压缩；落选候选与裁判是实打实的花费，却不占上下文，记为 `overhead`。计算在 `max-mode.ts:394`：

```ts
const overheadUsages = [...survivors.filter((_, i) => i !== pick).map((c) => c.usage), judgeUsage]
const overhead = overheadUsages.reduce(
  (acc, u) => {
    if (!u) return acc
    const g = Session.getUsage({ model: input.model, usage: u })
    acc.cost += g.cost
    acc.tokensIn += g.tokens.input + g.tokens.cache.read + g.tokens.cache.write
    acc.tokensOut += g.tokens.output + g.tokens.reasoning
    return acc
  },
  { cost: 0, tokensIn: 0, tokensOut: 0 },
)
```

回放结束时，`overhead.cost` 加到消息的 `cost` 上（`processor.ts:1067`），输入输出 token 计入智能体指标，并额外发一条 `finish_reason` 为 `max-mode-overhead` 的 `ModelCall` 指标（`processor.ts:1076`）。这样费用与指标反映真实开销，上下文估算却不会误以为窗口满了好几倍而过早压缩。按这个结构，每一步要发 N 次候选请求外加 1 次裁判请求（只剩一个候选时省掉裁判），而裁判的输入包含全部候选的思考与正文；最终失败的候选拿不到用量，不计入 `overhead`。

## 与博客对照

- 默认 5 个候选：与代码一致。
- 每个候选独立完成思考与工具规划、但不真正执行：与代码一致。
- 裁判用低温：代码没有为裁判单独设温度，裁判与候选同智能体、同模型、同一套温度取值。
- SWE-Bench Pro 提升 10–20%、token 成本约 4–5 倍：博客给出的数字，代码与仓库文档里都没有可供核对的依据。

下一篇：[定时任务与循环](https://daiw.net/manual/mimo-code/cron)——让提示词按 cron 表达式或自定的节奏，在未来某个时刻自己送进会话。
