数据截至 (上游 commit e2d772072efa)
多模型编排:角色与模型包
30 秒导读: 同一个编码任务里,"想清楚要怎么改"、"把代码写出来"、"把这段话变成文件 diff"、"给这次改动起个名字"其实是很不一样的活,对模型的要求也不同。Plandex 把这些活拆成 9 个角色(ModelRole),再用一份 模型包(ModelPack) 声明"哪个角色用哪个模型",最后让 所有厂商模型都统一走一个 LiteLLM 代理。结果:你能用 Sonnet 规划、o3 审架构、o4-mini 干杂活,而且随时整包替换——代码几乎不用动。
本章只讲组合模型这件事在代码里怎么落地。至于每个角色具体在主循环 / 构建流水线里什么时候被调用,见 01-tell-loop.md 和 02-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.go、server/model/client.go |
| LiteLLM 代理 | 把统一格式的请求转发到各厂商真实 API | server/litellm_proxy.py、server/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 | 职责(原文直译) | 直觉:难度 / 频率 |
|---|---|---|---|
ModelRolePlanner | planner | 回应提示、制定计划 | 最重,要强模型 |
ModelRoleArchitect | architect | 做高层计划、并用项目地图决定加载哪些上下文 | 重,见 03 |
ModelRoleCoder | coder | 写代码实现计划 | 重 |
ModelRoleBuilder | builder | 把计划构建成文件 diff | 中,量大,见 02 |
ModelRoleWholeFileBuilder | whole-file-builder | 通过重写整个文件来构建 diff | 中 |
ModelRolePlanSummary | summarizer | 当对话超过 max-convo-tokens 时做摘要 | 轻~中 |
ModelRoleName | names | 给 plan 起名字 | 极轻 |
ModelRoleCommitMsg | commit-messages | 写 commit message | 极轻 |
ModelRoleExecStatus | auto-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) |
|---|---|---|
| planner | anthropic/claude-sonnet-4 | google/gemini-2.5-pro → google/gemini-pro-1.5 |
| architect | anthropic/claude-sonnet-4 | 同上 |
| coder | anthropic/claude-sonnet-4 | openai/gpt-4.1 |
| builder | openai/o4-mini-medium | (strongModel: openai/o4-mini-high) |
| whole-file-builder | openai/o4-mini-medium | — |
| summarizer | openai/o4-mini-low | — |
| names | openai/gpt-4.1-mini | — |
| commit-messages | openai/gpt-4.1-mini | — |
| auto-continue | openai/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):openai、openrouter、anthropic、anthropic-pro(Claude Max 订阅)、google-ai-studio、google-vertex、azure-openai、aws-bedrock、deepseek、perplexity、ollama、custom。每个渠道有一份配置 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-high、cheap 包写 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.go的Providers),同一模型多渠道冗余、还能自动插 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 味道。
O3PlannerSchema在ai_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.go | ModelRole、AllModelRoles、ModelRoleDescriptions |
| 角色默认采样参数 | app/shared/ai_models_config.go | DefaultConfigByRole |
| 模型包(schema + 内置预设) | app/shared/ai_models_packs.go | DailyDriverSchema、getModelRoleConfig、BuiltInModelPacks |
| 包/角色数据结构 + 回落 | app/shared/ai_models_data_models.go | ModelPackSchemaRoles、ToModelPack、GetCoder/GetArchitect、GetProviderComposite |
| 模型能力参数表 | app/shared/ai_models_available.go | BuiltInModels、GetAvailableModel、AnthropicLatestModelNameMap |
| 渠道枚举与配置 | app/shared/ai_models_providers.go | ModelProvider、BuiltInModelProviderConfigs、GetProvidersForAuthVarsWithModelId |
| 鉴权→渠道解析 | app/shared/ai_models_credentials.go | GetModelProviderOptions、Condense |
| 大上下文 / 输出选档 | app/shared/ai_models_large_context.go | GetRoleForInputTokens、GetRoleForOutputTokens |
| 错误降级阶梯 | app/shared/ai_models_errors.go | GetFallbackForModelError、GetProviderFallback |
| 兼容性(预留位) | app/shared/ai_models_compatibility.go | RequiredCompatibilityByRole、FilterBuiltInCompatibleModels |
| token 估算 | app/shared/tokens.go | GetNumTokensEstimate、EstimatedBytesPerToken |
| 服务端角色调用入口 | app/server/model/model_request.go | ModelRequest |
| 流式请求 + 渠道解析 | app/server/model/client.go | CreateChatCompletionStream、createChatCompletionStreamExtended、InitClients |
| 重试/降级循环 | app/server/model/client_stream.go | CreateChatCompletionWithInternalStream、withStreamingRetries |
| LiteLLM 代理托管 | app/server/model/litellm.go | EnsureLiteLLM、startLiteLLMServer |
| LiteLLM 转发服务 | app/server/litellm_proxy.py | passthrough、normalise_for_ollama、_oauth_get_hdrs |