跳到主要内容

多模型编排:角色与模型包

30 秒导读: 同一个编码任务里,"想清楚要怎么改"、"把代码写出来"、"把这段话变成文件 diff"、"给这次改动起个名字"其实是很不一样的活,对模型的要求也不同。Plandex 把这些活拆成 9 个角色(ModelRole),再用一份 模型包(ModelPack) 声明"哪个角色用哪个模型",最后让所有厂商模型都统一走一个 LiteLLM 代理。结果:你能用 Sonnet 规划、o3 审架构、o4-mini 干杂活,而且随时整包替换——代码几乎不用动。

本章只讲组合模型这件事在代码里怎么落地。至于每个角色具体在主循环 / 构建流水线里什么时候被调用,见 01-tell-loop.md02-build-apply.md;architect 角色怎么用项目地图挑上下文的算法,见 03-context-maps.md


1. 这是什么(零基础也能懂)

一句话定义: 一套"把一个大任务拆成若干功能插槽、每个插槽独立指派模型"的机制。

它解决的痛点。 假设你只能给整个编码 agent 配一个模型,你会很纠结:

  • 配个最强最贵的(Opus / o3),那么"给 plan 起个名字"这种一秒钟的小事也在烧钱、还慢;
  • 配个便宜快的(gpt-4.1-mini),那么真正难的架构规划又扛不住。

Plandex 的答案是:别只配一个。 把任务拆成角色,难的角色配强模型、杂活配便宜模型,各取所长。

三个核心名词,先建立直觉:

名词是什么类比
ModelRole(角色)一个"功能插槽",如"规划者""写代码者""起名字者"剧组里的工种(导演 / 编剧 / 场记)
ModelPack(模型包)一份"角色 → 具体模型"的完整指派表一份选角名单(谁演导演、谁演场记)
Provider(提供方)同一个模型可以从哪家渠道调(OpenAI 直连 / OpenRouter / Bedrock…)同一位演员的多个经纪公司

用起来什么样。 用户基本不碰底层,只是"换一整包":

# 切换到内置的 "reasoning" 包:规划和写码都用带思考的 sonnet-4
plandex set-model reasoning

# 或者只把「规划者」这一个角色换成 gemini-2.5-pro,其它角色不变
plandex set-model planner google/gemini-2.5-pro

一句话类比: 把 ModelRole 想成函数签名(功能契约),把 ModelPack 想成一份依赖注入配置——同一个契约,注入哪个实现由配置说了算。


2. 顶层全景(它大概怎么转)

整个机制是一条从抽象到具体的解析链:代码里某处说"我需要 planner 干活",最终变成"向某个 URL 发一个具体模型的 HTTP 请求"。

代码里的一个需求 一份配置 一层抽象 统一出口
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ "我要 planner"│ ───▶│ ModelPack │ ───▶ │ Provider 抽象 │ ───▶ │ LiteLLM 代理 │
│ 这个角色干活 │ │ 角色→模型 映射 │ │ 模型→渠道 映射 │ │ localhost:4000│
└──────────────┘ └──────────────┘ └──────────────┘ └──────┬───────┘
ModelRole "planner = "sonnet-4 可从 │ 统一 OpenAI 格式
(功能插槽) sonnet-4" anthropic/openrouter/ ▼
bedrock/vertex 拿" 各家真实 API

怎么读这张图: 从左到右是"越来越具体"。左边是功能(要什么活),中间两层负责把功能翻译成一个具体模型 + 一个具体渠道,右边让所有渠道长得一样(都是 OpenAI 兼容接口)。

各部件一句话职责:

部件干什么在哪个文件
ModelRole定义 9 个功能插槽及其职责shared/ai_models_roles.go
ModelPackSchema / ModelPack一份"角色→模型"映射;内置若干预设shared/ai_models_packs.go
BuiltInModels每个模型的能力参数(token 上限、输出格式…)shared/ai_models_available.go
ModelProvider + provider 配置模型可从哪些渠道调、各渠道怎么鉴权shared/ai_models_providers.go
ModelRequest / CreateChatCompletionStream服务端把角色解析成具体请求并发出server/model/model_request.goserver/model/client.go
LiteLLM 代理把统一格式的请求转发到各厂商真实 APIserver/litellm_proxy.pyserver/model/litellm.go

主线走一遍(高层): 服务端某段代码拿着一个 ModelRoleConfig(某角色在当前包里的配置)→ 估算输入 token,必要时切到大上下文备胎 → 解析出"具体模型 + 具体渠道"(composite)→ 组装成 OpenAI 兼容的流式请求 → 发到 localhost:4000 的 LiteLLM 代理 → 代理再转给 Anthropic / Google / DeepSeek 等真实 API。


3. 核心原理(逐个机制,由浅入深)

3.1 九个角色 = 九个功能插槽

它要解决的小问题: 一个编码任务里到底有几种"活",各自对模型有什么不同要求?

Plandex 把它固定为 9 个角色,用一个字符串枚举定义,并配了人话职责描述:

// shared/ai_models_roles.go:6 —— ModelRole 是个字符串类型的枚举
ModelRolePlanner ModelRole = "planner"
ModelRoleCoder ModelRole = "coder"
ModelRoleArchitect ModelRole = "architect"
ModelRolePlanSummary ModelRole = "summarizer"
ModelRoleBuilder ModelRole = "builder"
ModelRoleWholeFileBuilder ModelRole = "whole-file-builder"
ModelRoleName ModelRole = "names"
ModelRoleCommitMsg ModelRole = "commit-messages"
ModelRoleExecStatus ModelRole = "auto-continue"

ModelRoleDescriptions(shared/ai_models_roles.go:19)给出每个角色的官方职责。整理成表:

角色常量字符串 id职责(原文直译)直觉:难度 / 频率
ModelRolePlannerplanner回应提示、制定计划最重,要强模型
ModelRoleArchitectarchitect做高层计划、并用项目地图决定加载哪些上下文重,见 03
ModelRoleCodercoder写代码实现计划
ModelRoleBuilderbuilder把计划构建成文件 diff中,量大,见 02
ModelRoleWholeFileBuilderwhole-file-builder通过重写整个文件来构建 diff
ModelRolePlanSummarysummarizer当对话超过 max-convo-tokens 时做摘要轻~中
ModelRoleNamenames给 plan 起名字极轻
ModelRoleCommitMsgcommit-messages写 commit message极轻
ModelRoleExecStatusauto-continue判断是否自动继续极轻,要稳

关键点:角色 = 功能插槽,与"用哪个模型"完全解耦。 这里没有任何具体模型名——角色只定义"要什么活",不定义"谁来干"。AllModelRoles(ai_models_roles.go:17)把 9 个角色收成一个切片,供遍历用。

每个角色还自带一套默认采样参数(温度 / top-p),体现"活的性质":

// shared/ai_models_config.go:3 —— DefaultConfigByRole:角色决定"创造性"
ModelRolePlanner: { Temperature: 0.3, TopP: 0.3 }, // 规划,略确定
ModelRoleBuilder: { Temperature: 0.1, TopP: 0.1 }, // 生成 diff,要极确定
ModelRoleName: { Temperature: 0.8, TopP: 0.5 }, // 起名字,可以放飞

判断一下就懂:构建 diff 的 builder 温度 0.1(不能乱),起名字的 names 温度 0.8(随便发挥)。这层默认值在角色配置没显式指定时兜底(见 3.2 的 toModelRoleConfig)。

3.2 模型包 = 一套"角色 → 模型"的配置

它要解决的小问题: 有了 9 个空插槽,谁来填?怎么让"填法"能一键整套切换?

答案是 ModelPack:一份把 9 个角色全部填满的配置。数据结构是 ModelPackSchemaRoles,每个字段对应一个角色:

// shared/ai_models_data_models.go:797 —— 一个包 = 每个角色一格
type ModelPackSchemaRoles struct {
Planner ModelRoleConfigSchema `json:"planner"`
Coder *ModelRoleConfigSchema `json:"coder,omitempty"` // 可选,缺省回落到 planner
PlanSummary ModelRoleConfigSchema `json:"planSummary"`
Builder ModelRoleConfigSchema `json:"builder"`
WholeFileBuilder *ModelRoleConfigSchema `json:"wholeFileBuilder,omitempty"` // 可选,缺省回落到 builder
Namer ModelRoleConfigSchema `json:"namer"`
CommitMsg ModelRoleConfigSchema `json:"commitMsg"`
ExecStatus ModelRoleConfigSchema `json:"execStatus"`
Architect *ModelRoleConfigSchema `json:"contextLoader,omitempty"` // 注意 JSON 名还叫 contextLoader
LocalProvider ModelProvider `json:"localProvider,omitempty"`
}

注意三处可选角色(Coder/WholeFileBuilder/Architect)——没填就回落到别的角色:

// shared/ai_models_data_models.go:936 —— 缺省回落逻辑
func (m *ModelPack) GetCoder() ModelRoleConfig {
if m.Coder == nil { return m.Planner.ModelRoleConfig } // 没配 coder 就用 planner
return *m.Coder
}
func (m *ModelPack) GetWholeFileBuilder() ModelRoleConfig {
if m.WholeFileBuilder == nil { return m.Builder } // 没配就用 builder
return *m.WholeFileBuilder
}
func (m *ModelPack) GetArchitect() ModelRoleConfig {
if m.Architect == nil { return m.Planner.ModelRoleConfig } // 没配就用 planner
return *m.Architect
}

小考古:Architect 的 JSON 标签是 contextLoader(ai_models_data_models.go:807),暴露了这个角色早先叫"上下文加载器"——正对应它"用项目地图挑上下文"的职责(03)。

一个真实的包长什么样。 看默认包 daily-driver(shared/ai_models_packs.go:129),它用一个小工厂 getModelRoleConfig(role, modelId, ...)(ai_models_packs.go:47)给每格填模型 id:

// shared/ai_models_packs.go:129 —— daily-driver 包(节选)
DailyDriverSchema = ModelPackSchema{
Name: "daily-driver",
ModelPackSchemaRoles: ModelPackSchemaRoles{
Planner: getModelRoleConfig(ModelRolePlanner, "anthropic/claude-sonnet-4",
getLargeContextFallback(ModelRolePlanner, "google/gemini-2.5-pro", ...)), // 大上下文备胎
Coder: Pointer(getModelRoleConfig(ModelRoleCoder, "anthropic/claude-sonnet-4", ...)),
Builder: defaultBuilder, // openai/o4-mini-medium
Namer: getModelRoleConfig(ModelRoleName, "openai/gpt-4.1-mini"),
CommitMsg: getModelRoleConfig(ModelRoleCommitMsg, "openai/gpt-4.1-mini"),
// ... 其余角色略
},
}

daily-driver 的 9 格摊开,就能一眼看到"混搭三家、按难度分档"的思路:

角色指派模型备胎(large-context fallback)
planneranthropic/claude-sonnet-4google/gemini-2.5-progoogle/gemini-pro-1.5
architectanthropic/claude-sonnet-4同上
coderanthropic/claude-sonnet-4openai/gpt-4.1
builderopenai/o4-mini-medium(strongModel: openai/o4-mini-high)
whole-file-builderopenai/o4-mini-medium
summarizeropenai/o4-mini-low
namesopenai/gpt-4.1-mini
commit-messagesopenai/gpt-4.1-mini
auto-continueopenai/o4-mini-low

内置了一整排预设包,覆盖不同取向(ai_models_packs.go 的各 Schema + BuiltInModelPacks):

包名取向planner 用什么
daily-driver速度/质量/成本平衡(默认)claude-sonnet-4
reasoning规划+写码都开思考claude-sonnet-4-thinking
strong难任务,慢也行openai/o3-high
cheap省钱,做简单任务openai/o4-mini-medium
oss最佳开源模型混搭deepseek/r1
ollama / ollama-daily / ollama-oss本地模型(实验)qwen3-32b-local 等
anthropic / openai / google单一厂商全家桶各家旗舰
gemini-planner/opus-planner/o3-planner/r1-planner/perplexity-planner只换 planner,其余走默认对应模型

Schema 到运行时对象的转换。 上面写的都是 ModelPackSchema(声明式、含可选指针);init() 里统一调 ToModelPack()(ai_models_data_models.go:872)把它变成运行时的 ModelPack。转换时每个角色配置经 toModelRoleConfig(ai_models_data_models.go:433)展开——这里就是 3.1 那套角色默认温度兜底的地方:

// shared/ai_models_data_models.go:458 —— 角色没显式设温度就用 DefaultConfigByRole
config := DefaultConfigByRole[role]
if temperature == nil { temperature = &config.Temperature }
if topP == nil { topP = &config.TopP }

启动即自检。 init() 结尾会遍历每个包引用的所有 model id,若某个 id 在 BuiltInBaseModelsById 里查不到就直接 panic(ai_models_packs.go:445)——保证"包里写的模型都真实存在",把配置错误挡在启动阶段。

3.3 Provider 抽象:一个模型 id,多个渠道

它要解决的小问题: 包里写的是 anthropic/claude-sonnet-4 这样的逻辑模型 id,但同一个模型可能从 Anthropic 直连、也能从 OpenRouter、Bedrock、Vertex 拿——到底走哪家?

Plandex 把"模型是什么"和"从哪家渠道调"分成两层:

  • 模型层 BuiltInModels(shared/ai_models_available.go:49):一个模型的能力参数(上下文上限、输出格式、是否支持缓存…),与渠道无关。
  • 渠道层:每个模型列出它 Providers(能从哪些渠道拿到)+ 各渠道的 ModelName(在那家的真名)。
// shared/ai_models_available.go:170 —— 一个模型 + 它的多个渠道(节选)
{
ModelTag: "anthropic/claude-sonnet-4", Publisher: ModelPublisherAnthropic,
BaseModelShared: BaseModelShared{ MaxTokens: 200000, ... },
Providers: []BaseModelUsesProvider{
{Provider: ModelProviderAnthropic, ModelName: "anthropic/claude-sonnet-4-0"},
{Provider: ModelProviderAmazonBedrock, ModelName: "anthropic.claude-sonnet-4-20250514-v1:0"},
{Provider: ModelProviderGoogleVertex, ModelName: "vertex_ai/claude-sonnet-4@20250514"},
{Provider: ModelProviderOpenRouter, ModelName: "anthropic/claude-sonnet-4"},
},
}

所有渠道ModelProvider 枚举列出(shared/ai_models_providers.go:38):openaiopenrouteranthropicanthropic-pro(Claude Max 订阅)、google-ai-studiogoogle-vertexazure-openaiaws-bedrockdeepseekperplexityollamacustom。每个渠道有一份配置 ModelProviderConfigSchema,说明它的 BaseUrl 和鉴权方式:

// shared/ai_models_providers.go:116 —— 渠道配置(节选)
ModelProviderOpenAI: {
BaseUrl: OpenAIV1BaseUrl, // 直连 api.openai.com
ApiKeyEnvVar: OpenAIEnvVar, // 认 OPENAI_API_KEY
},
ModelProviderAnthropic: {
BaseUrl: LiteLLMBaseUrl, // 走本地 LiteLLM 代理(见 3.4)
ApiKeyEnvVar: AnthropicApiKeyEnvVar,
},
ModelProviderOllama: {
BaseUrl: LiteLLMBaseUrl, SkipAuth: true, LocalOnly: true, // 本地模型免鉴权
},

"我有哪些 key,就只给我能用的渠道"。 解析从用户手上有哪些鉴权变量倒推:GetProvidersForAuthVars(ai_models_providers.go:229)扫一遍所有渠道,把"必填鉴权变量都齐了"的渠道留下;再 GetProvidersForAuthVarsWithModelId(ai_models_providers.go:290)与"这个模型 id 支持的渠道"求交集,得到该模型实际可用的渠道列表(有序,第一个是首选)。

最终把"渠道 + 模型 id"压成一个字符串键,叫 composite(如 openrouter、或自定义渠道的 custom|myproxy),运行时用它当 client 的索引键:

// shared/ai_models_data_models.go:618
func (m ModelRoleConfig) GetProviderComposite(...) string {
baseModelConfig := m.GetBaseModelConfig(...) // 解析出"具体模型 + 首选渠道"
return baseModelConfig.ToComposite() // → "provider" 或 "provider|customName"
}

一个巧妙的顺手活:init()(ai_models_available.go:597)在装载时,凡是带 Anthropic 直连渠道的模型,都会自动在最前面插一个 Claude Max 订阅渠道(anthropic-pro),让订阅用户优先走订阅额度。

3.4 LiteLLM:让所有厂商长成一个样

它要解决的小问题: Anthropic、Google、DeepSeek、Bedrock… 每家 API 的请求/鉴权/流式格式都不一样。服务端不想为每家写一套 client。

做法:统一走一个 LiteLLM 代理。 除了 OpenAI 直连,几乎所有渠道的 BaseUrl 都指向同一个地址:

// shared/ai_models_providers.go:9
const LiteLLMBaseUrl = "http://localhost:4000/v1" // 与 plandex 服务端跑在同一容器里

服务端这边只会说一种话——OpenAI 兼容的流式格式createChatCompletionStreamExtended(server/model/client.go:216)把请求 JSON 化后,永远 POST 到 baseUrl + "/chat/completions"(client.go:336),不管背后是哪家。

代理那头是个 FastAPI 小服务 litellm_proxy.py,核心就一个 passthrough 路由:

# server/litellm_proxy.py:64 —— 收到统一格式,交给 litellm.completion 转发到真实厂商
@app.post("/v1/chat/completions")
async def passthrough(request: Request):
payload = await request.json()
api_key = payload.pop("api_key", None) or request.headers.get("Authorization")
payload = normalise_for_ollama(payload) # 本地模型的小适配
if payload.get("stream"):
response_stream = completion(api_key=api_key, **payload) # ← litellm 干脏活
# ... 逐块转成 SSE "data: {...}" 吐回去

completion(**payload) 是 LiteLLM 库的统一入口:同一份 OpenAI 格式的 payload,它负责翻译成 Anthropic / Vertex / Bedrock 各自的真实调用。厂商差异(鉴权头、参数名、字段结构)全被它和这个小代理吸收掉了。例如 Ollama 不吃 top_p/temperature/tools,normalise_for_ollama(litellm_proxy.py:167)就在转发前把这些参数删掉。

代理由 Go 侧托管生命周期。 EnsureLiteLLM(server/model/litellm.go:20)用 sync.Once 保证只启动一次:先健康检查 localhost:4000/health,没跑就 startLiteLLMServer(litellm.go:106)用 uvicorn 拉起来,轮询到健康为止。


4. 深入实现:一次"角色调用"如何落到具体请求

把前面几层串起来,走一条真实路径。入口是服务端的 ModelRequest(server/model/model_request.go:52),它拿到的就是某个角色在当前包里的配置 ModelRoleConfig

第一步:按输入量选档(可能换模型)。 先估算这次请求的输入 token,然后调 GetRoleForInputTokens(shared/ai_models_large_context.go:49)——如果输入超过当前模型的 MaxTokens,就顺着 LargeContextFallback 链往下切到能装下的备胎:

// server/model/model_request.go:87 —— 输入太大就自动降级到大上下文备胎
config := modelConfig.GetRoleForInputTokens(inputTokensEstimate, settings)
modelConfig = &config

这就是 daily-driver 里 planner 配了 sonnet-4 → gemini-2.5-pro → gemini-pro-1.5 的用处:sonnet 装不下(200k),就自动升到能吃 1M/2M 的 Gemini。同理 GetRoleForOutputTokens(ai_models_large_context.go:78)按预估输出量选档。

第二步:组装 OpenAI 兼容请求。 用解析出的 baseModelConfig 填模型名、按角色填温度/top-p(除非该模型 RoleParamsDisabled),再交给流式发送层。

第三步:解析渠道 + 发送 + 带重试阶梯。 CreateChatCompletionStream(server/model/client.go:107)先算 composite 取到对应 client,再把整个发送包在 withStreamingRetries 里。每次尝试都调 GetFallbackForModelError(shared/ai_models_errors.go:49)决定这一发用哪个配置,形成一条容错阶梯:

一次请求失败后,按错误类型走不同备胎:
┌─ ErrContextTooLong ─▶ LargeContextFallback(换大上下文模型)
请求出错 ModelError ─────┤
└─ 不可重试 / 重试超限 ─┬─ 有 ErrorFallback ─▶ 换 error 备胎模型
└─ 没有 ────────────▶ GetProviderFallback
(同模型换个渠道:
优先 OpenRouter)

GetProviderFallback(ai_models_errors.go:111)的巧思:同一个模型 id,如果鉴权栈里有 OpenRouter,就优先切到 OpenRouter(因为它自带内部路由/多家冗余,最抗抖动);否则退到列表里第二个渠道。模型没变,只是换了条路。

第四步:出容器。 请求 POST 到 localhost:4000/v1/chat/completions,由 3.4 的 LiteLLM 代理转发到真实厂商,SSE 流式吐回。

一个鉴权层面的细节:如果走的是 Claude Max 订阅渠道(HasClaudeMaxAuth),client.go:300 会往请求里塞一条 "You are Claude Code..." 的系统消息,并加上 anthropic-beta/anthropic-product 头——代理侧 _oauth_get_hdrs(litellm_proxy.py:6)再据 OAuth token 前缀去掉 x-api-key,把请求伪装成官方 Claude Code 客户端。


5. token 与兼容性约束(为什么解析不只是"查表")

为什么要 ReservedOutputTokens。 有些模型的 MaxTokens(输入上限)和 MaxOutputTokens同一个池子,如果不预留,输出就没地方放。Plandex 给每个模型设 ReservedOutputTokens,有效输入上限 = MaxTokens − ReservedOutputTokens。这段设计意图在 ai_models_available.go:9 的大段注释里讲得很清楚(以 o3-mini 为例:200k 里预留 40k 给输出+推理,有效输入 160k)。3.4/第 4 步里的"选档"判断,用的就是这些数。

推理档位(reasoning effort)也编码在模型 id 里。 像 o3 / o4-mini 通过 Variants 派生出 -high/-medium/-low 三个 id(ai_models_available.go:61),各自 ReasoningEffort 不同。所以 strong 包写 openai/o3-highcheap 包写 openai/o4-mini-medium——同一底模、不同思考强度,被当成不同 model id 来指派。Anthropic 的 -thinking-hidden 之类同理(思考预算 + 是否回传思考)。

输出格式按厂商偏好切换。 每个模型带 PreferredOutputFormat:OpenAI 系偏好 tool-call-json,其余大多用 xml(ai_models_available.go 各条目)——因为很多非 OpenAI 模型"号称支持 JSON 但不可靠"(见 ai_models_available.go:34 注释)。

兼容性检查目前是"预留位"。 有一张 RequiredCompatibilityByRole(shared/ai_models_compatibility.go:7),但 v2 里 FilterBuiltInCompatibleModels(ai_models_compatibility.go:19)其实直接放行所有模型——代码注释明说"v2 暂不需要兼容性检查,保留以备将来"。诚实地说:现在它不做实际过滤。

token 估算本身。 输入 token 用 tiktoken 的 gpt-4o 编码器统一估(shared/tokens.go:14),再按模型的 TokenEstimatePaddingPct 加一点余量(Anthropic 系加 10%,见 ai_models_large_context.go:57)——宁可高估,避免真调用时超限。


6. 巧妙之处(可借鉴的技术)

  • 角色/模型彻底解耦。 角色只定义"功能契约 + 默认采样参数"(ai_models_roles.go + ai_models_config.go),模型指派全在包里(ai_models_packs.go)。换模型 = 换配置,不碰业务代码——教科书级的依赖注入。
  • 两级模型抽象(id ↔ 渠道)。 逻辑 id 与"从哪家拿"分离(ai_models_available.goProviders),同一模型多渠道冗余、还能自动插 Claude Max 订阅渠道(ai_models_available.go:597)。
  • 一个进程内的 LiteLLM 代理消化所有厂商差异。 服务端只说 OpenAI 一种方言(client.go:336),厂商翻译外包给 litellm.completion(litellm_proxy.py:115),Go 侧还负责它的启停(litellm.go:20)。
  • 多维度自动降级阶梯。 上下文超限→大上下文备胎;调用失败→error 备胎;都没有→换渠道优先 OpenRouter(ai_models_errors.go + ai_models_large_context.go)。三条链正交,互不干扰。
  • 启动即校验配置。 包里引用的每个 model id 必须真实存在,否则 panic(ai_models_packs.go:445);模型条目缺关键字段也 panic(ai_models_available.go:664)。错误挡在启动。

7. 边界与局限(诚实)

  • 兼容性过滤形同虚设。 RequiredCompatibilityByRole 全为空、过滤函数直接放行(ai_models_compatibility.go)——若你给某角色配了不支持图片/工具调用的模型,这一层不会拦你
  • 源码里有重复定义的 bug 味道。 O3PlannerSchemaai_models_packs.go 里被赋值了两次(:335 和 :353),第一次那份(planner 写成 anthropic/opus-4、描述还写着 Opus)被第二次(openai/o3-medium)整个覆盖——最终生效的是后者,前者是死代码。
  • 强依赖一个本地 Python 代理。 除 OpenAI 直连外,一切都要 localhost:4000 的 LiteLLM 活着(litellm.go);代理没起来 / 版本不兼容,整条模型调用就断。
  • provider fallback 只兜一层。 GetProviderFallback 只尝试一个备用渠道(ai_models_errors.go:114 要求至少两个渠道,只取一个),不是穷举所有渠道。
  • 角色数量写死为 9。 想新增一种"活"必须改枚举、改 ModelPackSchemaRoles 结构、改所有内置包——不是运行时可扩展的插件体系。

8. 横向对比(同 shelf 兄弟)

同为编码 agent,多数项目(如典型的 aider 类工具)用单模型 + 可选弱模型的两档结构;Plandex 的差异化在于把编码流程细分到 9 个角色,并提供整包预设 + 逐角色覆盖 + 多级自动降级,再用 LiteLLM 做 provider 无关层。代价是配置面更大、依赖一个常驻代理。想看这些角色在实际流程里怎么协作,顺着 01-tell-loop.md(planner/architect/coder)和 02-build-apply.md(builder/whole-file-builder)读。

9. 代码地图(导航索引)

主题文件路径关键符号
9 个角色定义与职责app/shared/ai_models_roles.goModelRoleAllModelRolesModelRoleDescriptions
角色默认采样参数app/shared/ai_models_config.goDefaultConfigByRole
模型包(schema + 内置预设)app/shared/ai_models_packs.goDailyDriverSchemagetModelRoleConfigBuiltInModelPacks
包/角色数据结构 + 回落app/shared/ai_models_data_models.goModelPackSchemaRolesToModelPackGetCoder/GetArchitectGetProviderComposite
模型能力参数表app/shared/ai_models_available.goBuiltInModelsGetAvailableModelAnthropicLatestModelNameMap
渠道枚举与配置app/shared/ai_models_providers.goModelProviderBuiltInModelProviderConfigsGetProvidersForAuthVarsWithModelId
鉴权→渠道解析app/shared/ai_models_credentials.goGetModelProviderOptionsCondense
大上下文 / 输出选档app/shared/ai_models_large_context.goGetRoleForInputTokensGetRoleForOutputTokens
错误降级阶梯app/shared/ai_models_errors.goGetFallbackForModelErrorGetProviderFallback
兼容性(预留位)app/shared/ai_models_compatibility.goRequiredCompatibilityByRoleFilterBuiltInCompatibleModels
token 估算app/shared/tokens.goGetNumTokensEstimateEstimatedBytesPerToken
服务端角色调用入口app/server/model/model_request.goModelRequest
流式请求 + 渠道解析app/server/model/client.goCreateChatCompletionStreamcreateChatCompletionStreamExtendedInitClients
重试/降级循环app/server/model/client_stream.goCreateChatCompletionWithInternalStreamwithStreamingRetries
LiteLLM 代理托管app/server/model/litellm.goEnsureLiteLLMstartLiteLLMServer
LiteLLM 转发服务app/server/litellm_proxy.pypassthroughnormalise_for_ollama_oauth_get_hdrs