开源编程 Agent pi 的模型解析链路:从你写的名字到真正发出的请求

2026-07-29

本文基于 pi v0.82.1(仓库 commit 027a584,2026-07-28)梳理,该项目仍在快速迭代,具体行为以官方文档 https://pi.dev/docs/latest 与仓库最新代码为准。

在 pi 里,你敲进 --model 后面的那串字符从来不是一个模型 ID,而是一个待解析的模式。同一串字,在你的机器和同事的机器上可能落到两个不同的模型上,因为解析结果取决于当时哪些模型在候选集里、哪些服务商配好了凭据。理解这条链路,比记住某个模型名更有用——线上那些「我明明选了 A,账单却记在 B 上」的怪事,多半出在这里。

pi 是 earendil-works 开源的编程 Agent,MIT 许可证,主仓库在 https://github.com/earendil-works/pi ,截至 2026-07 在 GitHub 上约 8 万 star。下面提到的行为全部来自 packages/coding-agent 这个包里的源码,你可以自己打开对照。

站内已经有两篇讲通用方法论的文章:模型别名的风险讲的是别名指向漂移这类问题该怎么防,模型路由策略讲的是多模型场景下路由该怎么设计。这篇不重复那些原则,它只做一件事——把一个真实项目里这套东西落地成了什么样,逐层拆给你看。方法论告诉你「应该」,源码告诉你「实际是」。

一、你写的那串字,先被当成模式去撞

解析入口在 packages/coding-agent/src/core/model-resolver.ts。核心函数 tryMatchModel 分两步走。

第一步是 findExactModelReferenceMatch,它按三种形态依次试:完整的 provider/modelId 规范引用、以第一个斜杠切分出的「服务商 + 模型 ID」组合、以及裸的模型 ID。三种形态都有一条共同的硬规则——只有唯一命中才算数。命中多个时函数直接返回 undefined,不做任何猜测。这一条在多服务商场景下相当关键:同一个模型 ID 可能同时出现在多家聚合服务商的目录里,pi 宁可让精确匹配失败,也不替你挑一个。

第二步是精确匹配失败后的退化:对模型的 idname 做不区分大小写的子串包含匹配。这一步会捞回一堆候选,于是需要一个排序规则来收敛。规则写在 isAlias 里:

function isAlias(id: string): boolean {
	// Check if ID ends with -latest
	if (id.endsWith("-latest")) return true;

	// Check if ID ends with a date pattern (-YYYYMMDD)
	const datePattern = /-\d{8}$/;
	return !datePattern.test(id);
}

-latest 结尾、或者结尾不是八位日期的,都算别名。别名优先于带日期的固定版本;有多个别名时按 localeCompare 倒序排,取排在最前面的那个;一个别名都没有时,取日期最大的那个版本。

这条排序规则决定了模糊输入的最终归宿。你写一个短串,拿到的大概率是一个会随服务商更新而漂移的别名,而不是钉死的日期版本。这正是别名风险在具体项目里的落地形态。

parseModelPattern 再往上包了一层递归,处理冒号后缀。pi 支持在模式后面直接跟思考档位,形如 模式:档位;合法档位在 packages/coding-agent/src/cli/args.tsVALID_THINKING_LEVELS 里定义,共七档:offminimallowmediumhighxhighmax。麻烦在于有些模型 ID 本身就带冒号后缀。pi 的处理顺序是:先拿完整模式去撞,撞不上再从最后一个冒号切开,后缀是合法档位就递归解析前缀,不是合法档位则分情况——

allowInvalidThinkingLevelFallback 这个选项区分了两种调用场景。--models 做模型范围限定时默认允许回退,仅记一条警告;而 resolveCliModel 解析 --model 时显式传了 false,注释写得很直白:strict 模式下把无法识别的后缀当成模型 ID 的一部分并直接失败,避免误解析到另一个模型。同一套解析函数,在「限定范围」和「指定这一次用哪个」两个场景下的容错程度是不一样的。

二、候选集从哪来:注册表其实是运行时

很多人会以为 model-registry.ts 是注册表主体。打开 packages/coding-agent/src/core/model-registry.ts 会发现它很薄,文件里那段注释交代得很清楚:这是暴露给扩展的同步兼容门面,coding-agent 内部直接用 ModelRuntimeModelRegistry 的方法基本都是一行转发。

真正干活的是 packages/coding-agent/src/core/model-runtime.ts 里的 ModelRuntime。它维护四组来源:内置服务商目录、models.json 里的用户配置、扩展通过 registerProvider 注册的配置、以及扩展注册的原生 Provider 对象。providerIds() 把这四组的 key 并成一个集合,逐个走 recomposeProvider

这里有一个容易忽略的短路:如果某个服务商既没有 models.json 配置也没有扩展覆盖,recomposeProvider 会直接把内置对象原样塞进去,代码注释说明了原因——没有覆盖层时使用未经改动的内置实现,保证它的鉴权、登录、流式行为完全一致。只有存在覆盖时才走组装路径。这是个务实的设计:不给没必要的场景引入组装层的行为差异。

另一个必须分清的是两个列表。getModels() 返回全部模型,getAvailable() 只返回所在服务商已配置鉴权的模型。谁用哪个,是有讲究的:resolveModelScopeWithDiagnosticsgetAvailable,而 resolveCliModel 里明确用了全量列表,注释解释是为了让 --api-key 能用于首次配置。所以「--models 提示没有匹配」和「--model 找不到」这两个报错,背后查的根本不是同一个池子。

网络这块也有个开关:ModelRuntime.create 里,是否允许联网刷新模型目录取决于 PI_OFFLINE 环境变量是否设置,以及调用方是否传了 allowModelNetwork。刷新结果落在 models.json 同目录下的 models-store.json。断网环境里模型列表为什么和昨天不一样,先查这两处。

三、组装器:三层叠加与最后那把钥匙

packages/coding-agent/src/core/provider-composer.ts 是这条链路上信息量最大的文件。composeModelProvider 把三层叠成一个 Provider,顺序在 getModels 闭包里写死:

applyModelsJsonmodels.json 的服务商配置盖到内置模型上(baseUrl 覆盖、compat 深合并,models 数组按 id 做 upsert);再 applyExtension 让扩展替换模型列表;如果扩展带 OAuth 且实现了 modifyModels,用凭据再过一遍;最后才是 modelOverrides。源码注释把这个次序说明白了:models.jsonmodelOverrides 是最顶层的用户配置层,在自定义模型 upsert、扩展替换、OAuth 投影之后统一应用一次。

这个次序是可以被利用的。你想微调某个模型的 contextWindowmaxTokensreasoningthinkingLevelMapcost,用 modelOverrides 比在 models 里重新定义整个模型更稳。后者走的是 modelFromJsonapi 必须能从模型级、服务商级或该服务商已有模型这三处里取到一个,baseUrl 同理,任意一个三处皆空就当场抛错;contextWindowmaxTokens 写成非正数也会被拦下。modelOverrides 则是在既有模型上做字段级替换,thinkingLevelMapcompat 走的还是合并而不是整体顶掉,改一个字段不用把整份定义抄一遍。

composeModelProvider 里还有一行看着多余的调用:组装完成后立刻执行一次 getModels(),注释说明是为了让注册或重载阶段立即暴露结构性错误。配置写错时你会在启动时就看到,而不是等到第一次发请求。

鉴权组装分成 composeApiKeyAuthcomposeOAuthAuth 两条。前者产出 logincheckresolve 三个回调,check 只判断「配没配」,resolve 才真正取值。两条最后都汇进同一个收口函数:

function withConfiguredAuth(
	auth: ModelAuth,
	headers: Record<string, string> | undefined,
	authHeader: boolean,
): ModelAuth {
	let mergedHeaders: ProviderHeaders | undefined =
		auth.headers || headers ? { ...auth.headers, ...headers } : undefined;
	if (authHeader) {
		if (!auth.apiKey) throw new Error("authHeader requires a resolved API key");
		mergedHeaders = { ...mergedHeaders, Authorization: `Bearer ${auth.apiKey}` };
	}
	return { ...auth, headers: mergedHeaders };
}

authHeader 为真时才把密钥拼成 Authorization: Bearer 头。这个开关在 models.json 的服务商配置里,也能由扩展指定。它存在的意义是伺候那些「按 OpenAI 兼容格式说话、但鉴权走标准 Bearer 头」的自建端点。密钥没解析出来时抛的那句错,ModelRegistry 会翻译成更可读的「找不到某服务商的 API key」——这是同一个错误在两层里的两副面孔,排查时别被绕晕。

密钥值本身不必是明文。resolve-config-value.ts 支持 $VAR${VAR} 模板,也支持命令形式的取值,$$ 用于转义。组装过程会先把配置里用到的环境变量名收集齐、再统一取值,然后才做替换。关于密钥怎么存怎么轮换,API 密钥的安全管理有更完整的讨论。

最后一步在 ModelRuntime.prepareRequest:合并鉴权头与调用方传入的头(mergeHeaders 会按大小写不敏感的方式去重,后来者覆盖同名头)、执行可选的 transformHeaders、合并环境变量,并且在鉴权结果带了 baseUrl 时用它覆盖模型自带的地址。走到这里,请求才算组装完毕。

组成部分它负责什么仓库位置你什么时候会碰到它
模式解析把你写的字符串撞成一个具体模型,处理别名优先与冒号后缀packages/coding-agent/src/core/model-resolver.ts选中的模型不是你要的那个;--models 报没有匹配
运行时注册表维护全量与「已配鉴权」两个模型列表,聚合内置、配置、扩展四类来源packages/coding-agent/src/core/model-runtime.ts模型列表和预期不符;断网时目录变化
扩展门面给扩展用的同步 API,转发到运行时并翻译错误信息packages/coding-agent/src/core/model-registry.ts写扩展、读那句「找不到 API key」的报错
服务商组装三层叠加成一个 Provider,组装鉴权与请求头packages/coding-agent/src/core/provider-composer.tsmodels.json;接自建端点;调 authHeader
缓存统计按上一次请求的提示词规模反推这次多付了多少packages/coding-agent/src/core/cache-stats.ts中途换模型后界面弹出缓存未命中提示

四、没写全时会发生什么:五级瀑布与那个悄悄的兜底

findInitialModel 的选择顺序在函数注释里列成了五级:命令行参数最优先;其次是模型范围里的第一个(继续会话时跳过);再次是从会话里恢复;然后是设置里保存的默认值;最后才是扫一遍可用模型。扫这一遍时它会先按内置默认表逐个服务商找默认模型,都找不到才取列表里的第一个。思考档位在没有任何指定时用 DEFAULT_THINKING_LEVEL,值是 medium

真正需要留神的是 buildFallbackModel。当你明确给了服务商、但模型 ID 在该服务商下没匹配上时,pi 不会报错退出,而是拿这个服务商的默认模型当模板,把 idname 换成你写的字符串,造出一个「自定义模型 ID」,同时给一条警告。模板的挑法也写死了:先在该服务商的模型里找内置默认表指定的那一个,找不到就取第一个;该服务商一个模型都没有时才返回空。这个设计是为了让你能用上目录里还没收录的新模型,代价是——除了 ID 和名字,其余字段全部继承自模板(唯一的例外是你顺带指定了非 off 的思考档位,此时 reasoning 会被置真)。costcontextWindowmaxTokenscompat 都是模板的。请求本身多半能发出去,但基于这些字段的成本统计和上下文预算就都是错的。这类隐性偏差比上下文预算算错更难发现,因为它不报错。

模型换来换去还有一笔缓存账。cache-stats.tsdetectMiss 先在「上一次请求的提示词 token 数」和「这次请求的提示词 token 数」里取小的那个,再减去这次的缓存读取量,差值超过噪声地板才计入——地板的存在是因为缓存断点本身有粒度,零星几百 token 的偏差算不上浪费。多付的钱不是照标价重算,而是拿这条消息自己的费用明细反推:实际付费部分的单价减去缓存读取的单价,乘以没命中的 token 数。scan 函数里有段注释交代了豁免规则:压缩和分支摘要会把上一次请求清空,因为上下文确实变了;而模型切换不豁免,它会让整个提示词重新计费,必须计入。交互界面据此在会话里打出「模型切换后缓存未命中」的提示。文件里另有注释提到某家服务商的默认缓存生存期是五分钟,各家规则不同且会调整,以官方最新说明为准。

顺带一提,detectMiss 还处理了一种边界:某些服务商只上报缓存读取、不上报写入。代码用一个粘性标记记住「这段扫描里曾经有过缓存活动」,以此区分「这次全部未命中」和「这家根本不报缓存」。前者要计,后者一律不计。一百多行代码里塞进这种区分,说明作者是拿真实账单对过的。

五、边界与代价:它明确不管的事

这套链路的定位很克制,边界也很清楚。

它不做路由决策。整个 model-resolver.ts 里没有任何按任务难度、负载或历史成功率挑模型的逻辑。选中哪个,只由你写的字符串、当前候选集和那套优先级决定。运行时失败后自动换一家、按任务分层派发这类事,都不在这条链路的职责里,得靠上层自己搭——多模型 fallback 设计讲的就是这一层。

模糊匹配换来的便利要付确定性的代价。子串匹配加上别名优先,意味着同一串输入的结果依赖于「此刻候选集里有什么」。服务商更新目录、你新配了一家凭据、扩展注册了新模型,都可能让昨天还好好的短串今天落到别处。这不是 bug,是模糊匹配的固有属性。

兜底模型的元数据不可信。上面说的 buildFallbackModel 只保证请求能发出去,不保证任何统计口径正确。把它当成一条应急通道,不要当成常规用法。

缓存统计是估算。噪声地板、服务商上报差异、缓存生存期都会影响结果,它给的是量级参考而不是对账依据。真要对账,去查服务商的账单。

它完全不管可达性。海外模型服务商官方对中国大陆存在区域限制、不支持直连,能不能连上是网络层的事,跟这条链路无关。市面上存在第三方中转服务,这里不做背书也不给具体渠道,只提醒一点:中转通常意味着自定义 baseUrl 和自定义鉴权头,正好落在 models.json 那几个配置项的作用范围里——也正因为如此,配置写错的排查成本会更高。

六、上手与避坑清单

1. 别把模糊短串写进团队共享的脚本或文档。 会踩,是因为模糊串的解析结果依赖每台机器当时的候选集和已配凭据,你本地跑通不代表同事跑通。避法是在共享配置里一律写完整的 provider/modelId,让 findExactModelReferenceMatch 的第一条路径就命中,跳过所有退化逻辑。

2. 模型 ID 里带冒号时,思考档位后缀要格外小心。 会踩,是因为解析从最后一个冒号切开,而某些聚合服务商的模型 ID 本身就带冒号变体后缀。避法是先不带任何后缀确认这个模型能被解析到,再决定要不要追加档位;档位也可以走 --thinking 单独指定,绕开歧义。

3. --model 里带斜杠时,前缀会被优先当作服务商。 会踩,是因为解析器发现斜杠前那段能匹配上已知服务商名,就按「服务商 + 模型」拆,而某些模型的 ID 本身以服务商名开头。源码为此写了两段补救逻辑:一段在服务商推断命中但未配置鉴权时,优先选择那个凭据齐全的原始 ID 精确匹配;另一段在服务商内找不到时,回退到用完整输入去全量匹配。避法是明确用 --provider 指定,别让它去猜。

4. 模型名拼错不一定报错。 会踩,是因为只要服务商是对的,buildFallbackModel 就会造一个同名模型让请求发出去,你只会看到一行警告。避法是把那行「Using custom model id」的警告当错误看,别在滚动的日志里划过去;先用 --list-models 核一遍准确 ID。

5. models.json 里给某个服务商写了空壳配置会直接抛错。 会踩,是因为 applyModelsJson 要求服务商配置至少带上一件实事——报错文案点名的是 baseUrlheaderscompatmodelOverridesmodels 这五项,代码里 apiKeyoauth 和显式写了值的 authHeader 同样算数,全空才抛。另有一条:配了 OAuth 却没给 baseUrl 同样抛错。抛出的异常不会让进程挂掉,而是被 recomposeProvider 接住记进 compositionErrors,同时把该服务商退回内置版本——所以你看到的现象往往不是启动失败,而是「改了配置像没生效」。避法是删配置时整块删掉,不要留下只剩 name 的残骸。

6. 会话中途换模型的代价比你以为的大。 会踩,是因为整个提示词会重新计费,而界面提示只在超过阈值时才弹。避法是把模型选择放在任务开始前定死;确实要换,就在压缩之后换——那时上下文本来就要重建,两笔代价合成一笔。这个取舍在token 成本优化里有更系统的算法。

7. 自建端点接不上时,先分清是缺密钥还是缺头。 会踩,是因为两种失败的报错长得像。避法是记住分工:authHeader 只控制要不要拼 Authorization: Bearer,服务商级 headers 和模型级 headers 走的是另一条合并路径,模型级优先。两处都空、端点又要求鉴权头,就会撞上那句「需要已解析的 API key」。

收束:三个自检点

下次再遇到「选中的模型不对」,按顺序问三句就够了:我写的这串字是精确引用还是模糊模式?它撞的是全量列表还是已配鉴权的那个列表?最终生效的配置是三层叠加后的哪一层? 三个问题分别对应 model-resolver.tsmodel-runtime.tsprovider-composer.ts,答案都能在这三个文件里当场读到。

想继续往下看,建议按这个顺序:先读 model-resolver.ts,它最短也最独立;再读 provider-composer.tscomposeModelProvider,把三层叠加的次序看透;最后读 model-runtime.tsprepareRequest,那是请求真正成型的地方。cache-stats.ts 可以放到最后当甜点,一百多行代码把「缓存到底浪费了多少」这件事讲得很干净。

本篇属于一个把开源编程 Agent 项目 pi 逐层拆开讲的系列,整体地图见开源编程 Agent pi 是什么;沿着这条线往下,还可以看 开源编程 Agent pi 的钩子与可观测性开源编程 Agent pi 的可持久化运行

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。