给开源编程 Agent pi 接入自定义模型服务:填什么,哪里最容易配错

2026-07-29

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

给 pi 接一个它没内置的模型服务,真正的工作量不在写代码,而在把「对方这个 API 到底哪儿不标准」说清楚。 pi 让你填的每一个字段,本质上都是在向你确认一条关于对方服务的事实:它的 system 角色叫什么、它认不认 reasoning_effort、它超长时报什么错。你填错的地方,pi 不会替你猜,它会照着你说的发请求,然后让你在某个具体的坏掉的场景里才发现。

pi 是 earendil-works 开源的编程 Agent,MIT 许可证,主仓库 https://github.com/earendil-works/pi ,截至 2026 年 7 月在 GitHub 上约 8 万 star。下面讲的全部来自它仓库里的文档与示例扩展。

站内已有的 AI 大模型 API 聚合/中转平台怎么选?SiliconFlow/OpenRouter 与直连对比 讲的是聚合平台与中转服务该怎么选,多模型回退怎么设计:别等主力挂了才想起来 讲的是多模型兜底的通用设计思路,都是方法论层面的。本篇不重复那些判断,只做一件事:看 pi 这一个具体项目,把「接一个新模型服务」这件事拆成了哪些必须由你提供的信息,以及这些信息填歪之后会以什么形态爆出来。

一、先分清两条入口,选错会白写一堆代码

pi 提供的是两条并列的路,不是一条路的两个阶段。

第一条是配置文件。~/.pi/agent/models.json 里按 providers 声明,适用于对方服务已经能说 pi 支持的四种 API 之一:openai-completionsopenai-responsesanthropic-messagesgoogle-generative-ai。Ollama、LM Studio、vLLM,以及任何在这几种协议上做兼容的网关,都走这条路。packages/coding-agent/docs/models.md 里最小的例子只需要给每个模型一个 id

{
  "providers": {
    "ollama": {
      "baseUrl": "http://localhost:11434/v1",
      "api": "openai-completions",
      "apiKey": "ollama",
      "models": [
        { "id": "llama3.1:8b" },
        { "id": "qwen2.5-coder:7b" }
      ]
    }
  }
}

这个文件在你每次打开 /model 时重新加载,会话中改完不用重启。

第二条是扩展。packages/coding-agent/docs/custom-provider.md 开篇就把扩展这条路的适用范围框死了四类:走公司代理或 API 网关、指向自托管或私有部署的端点、需要 OAuth/SSO 登录流程、对方是非标准 LLM API 需要你自己实现流式。这四类里任何一条命中,配置文件就不够了,你得写 pi.registerProvider()

扩展的放置位置有讲究。packages/coding-agent/docs/extensions.md 明确写了:放在 ~/.pi/agent/extensions/(全局)或 .pi/extensions/(项目级)才会被自动发现,也才能用 /reload 热重载;pi -e ./path.ts 只适合临时试跑。同一份文档还有一句安全提醒值得你在给团队推之前读一遍:扩展以你的完整系统权限运行,能执行任意代码,只装你信得过的来源。

二、pi 向你索取的信息清单

把两条路合起来看,pi 要的东西可以归成这么几块。

组成部分它负责什么对应仓库位置你什么时候会碰到它
baseUrl请求发到哪儿;定义模型时必填docs/models.mddocs/custom-provider.md接任何非内置服务的第一步
api选哪套流式实现来编解码请求与响应docs/custom-provider.md 的 API Types 表对方号称兼容某家协议时
apiKey / headers凭据与自定义请求头,共用一套取值语法docs/models.md 的 Value Resolution 一节密钥不想明文落盘时
models[]模型清单及其能力元数据docs/models.md 的 Model Configuration 表新增模型、或改窗口与定价元数据
compat声明对方在哪些细节上跟标准不一致docs/models.md 的两节 Compatibility请求 400、思考块丢失、工具调用失败时
thinkingLevelMap把 pi 的思考档位映射到对方的取值docs/models.md 的 Thinking Level Map 一节对方只支持部分推理档位时
oauth / streamSimple登录流程与自定义流式实现examples/extensions/custom-provider-anthropic/index.ts走 SSO,或对方 API 非标准
溢出错误模式决定超长时能否自动压缩重试packages/ai/src/utils/overflow.ts长会话跑着跑着直接报错中断时

必填与默认值这块,文档把每个字段的必填与否、默认值都列进了表,值得逐条对一遍。provider 层面,定义模型时 baseUrl 必填,api 在 provider 或 model 两级至少给一个。apiKey 反而不是加载文件的前提——凭据可以来自 /loginauth.json 或命令行 --api-key;没配任何凭据时,模型仍会加载,只是在 /model--list-models 里保持不可用状态。凭据的解析顺序在 packages/coding-agent/docs/providers.md 里给了明确的四级:命令行 --api-keyauth.json 条目、环境变量、models.json 里的自定义 provider 密钥。你在环境变量里改了半天没生效,多半是 auth.json 里还压着一条旧的。

模型层面只有 id 必填。name 不填就等于 idreasoning 默认关;input 默认只有文本;contextWindowmaxTokenscost 都有各自的默认值,文档表格里写着具体数字,照抄即可。这里有个容易误解的点:name 只参与模型匹配(比如 --model 的模式匹配)和二级说明文字,底部状态栏和模型列表主体显示的始终是 id。你把 name 改得再漂亮,界面上看到的还是那串 id。

三、值解析语法:一个符号写错,密钥就变成了字面量

apiKey 和自定义请求头共用同一套取值语法,两条路(models.json 与扩展注册)完全一致,规则一共四条:

  • ! 开头,整个值当命令执行,取 stdout。文档举的例子是从 macOS 钥匙串或 1Password 里读。
  • $ENV_VAR${ENV_VAR} 做环境变量插值,可以嵌在更长的字符串里。
  • $$ 输出一个字面的 $$! 输出一个字面的 ! 且不触发命令执行。
  • 其余按字面值使用。

第三条和第四条组合起来就是最常见的翻车点。文档专门点了名:像 MY_API_KEY 这样一个全大写的裸字符串,pi 按字面量处理,不会去查同名环境变量。你以为配了个变量引用,实际拿去请求的密钥就是 MY_API_KEY 这串字面量本身。同理,$FOO_BAR 指的是变量 FOO_BAR;如果你想要的是变量 FOO 拼上字面文本 _BAR,必须写成 ${FOO}_BAR。环境变量不存在时,这个值算作未解析。

命令执行那条还有个时序问题。models.json 里的 !command 是在请求时解析的,而且 pi 明确表示不为任意命令内置 TTL、陈旧值复用或失败恢复逻辑——文档给的理由是不同命令需要不同的缓存与失败策略,pi 无从推断哪种才对。所以如果你的取密钥命令慢、贵、或者本身有限流,得你自己包一层脚本去做缓存。另有一个不对称:/model 的可用性检查只看凭据是否配置,不会执行 shell 命令。这意味着模型在列表里显示为可用,不代表那条命令跑得通,真实失败会推迟到你发第一条消息时才出现。密钥这块该怎么存怎么轮,API Key 怎么管才不泄漏 里讲过通用做法,这里只强调 pi 把执行时机交还给了你。

四、最容易配错的几处

下面每条都写清为什么会踩,以及怎么避。

models 数组的语义,两条路不一样。 在扩展里 pi.registerProvider() 一旦提供了 models,它会替换该 provider 下的全部既有模型;而 models.json 对内置 provider 是合并语义——内置模型保留,自定义模型按 id upsert,id 撞上就替换那一条。踩法很典型:你只是想给内置的某家 provider 加一个新模型,结果在扩展里顺手带上了 models,其余模型集体消失。避法是记住那条边界——只改端点或请求头就别写 models,两者都不写 models 时,该 provider 的既有模型会连同新端点一起保留;只想调个别模型的元数据,用 models.jsonmodelOverrides,它支持 namereasoningthinkingLevelMapinputcost(可局部)、contextWindowmaxTokensheaderscompat,且对未知 id 直接忽略。

本地服务不需要密钥,却因为没填密钥而在 /model 里找不到。 Ollama 之类的服务器根本不校验 key,你自然不会填。但 pi 把「是否配了 auth」当成模型能否出现在选择器里的前提。避法在文档里写死了三选一:留一个假值(示例里就是字符串 ollama)、用 /login 给该 provider 存一个 key、或者选模型时带 --api-key

「OpenAI 兼容」这四个字兼容不到底。 很多自建服务器不认 reasoning 类模型用的 developer 角色,也不认 reasoning_effort。表现是请求直接 400,或者思考完全不生效。避法是把 compat.supportsDeveloperRole 设为 false(pi 改发 system),必要时再把 compat.supportsReasoningEffort 也设为 falsecompat 可以写在 provider 级作用于全部模型,也可以写在 model 级覆盖单个,两级同时存在时会合并。文档点名 Ollama、vLLM、SGLang 这类服务器常需要这么配。

思考格式与档位映射靠猜必错。 compat.thinkingFormat 是个枚举,不同后端要的字段完全不同:openrouterreasoning: { effort }togetherreasoning: { enabled }(开了 supportsReasoningEffort 时还会附带 reasoning_effort),qwen 是 DashScope 那种顶层 enable_thinking,本地 Qwen 兼容服务器要读 chat_template_kwargs.enable_thinking 的则用 qwen-chat-template,需要可配置模板参数的(比如 vLLM 后面的 DeepSeek V3.x)用 chat-template 配合 chatTemplateKwargs。档位这边用 model 级的 thinkingLevelMap:值写字符串表示该档支持并发这个值,写 null 表示不支持、在界面里隐藏,整个字段省略则标准档位走默认映射、xhighmax 视为不支持。映射表允许有洞,一个模型可以只暴露 highmax 而不暴露 xhigh。旧配置里的 compat.reasoningEffortMap 已经迁移到了模型级的 thinkingLevelMap

Anthropic 兼容端点有几个默认值是反直觉的。 pi 默认会发逐工具的 eager_input_streaming: true,代理或兼容后端不认这个字段就得把 supportsEagerToolInputStreaming 设为 false,pi 会改用旧的细粒度工具流式 beta 头。allowEmptySignature 只给那些确实会吐空思考签名、并且回放时还要求带着的服务开——真正的 Anthropic 端点会拒绝空签名。forceAdaptiveThinking 是给那些上游要求自适应思考的模型用的,内置模型会自动设,你自己定义的别名或代理得手动打开。还有 supportsStrictTools:内置 Anthropic 模型在元数据里是开的,自定义的 Anthropic 兼容模型默认关,端点接受严格 JSON Schema 工具定义时要自己置 true

动态拉模型列表放错了生命周期。 如果模型清单要从远端接口拿,正确做法是把扩展的工厂函数写成 async,在工厂里 fetch 完再注册。pi 会等工厂返回才继续启动,这样交互式启动和 pi --list-models 都能看到这批模型。放到 session_start 里就晚了。

上下文溢出识别不了,自动压缩就不会触发。 这条最隐蔽。pi 的自动压缩重试有个前提:它得认出这次失败是溢出。判定发生在最终成型的助手消息上,条件是 stopReason === "error"errorMessage 命中已知模式。packages/ai/src/utils/overflow.ts 里维护着一张长长的模式表,注释里逐个列了各家的原始报错句式,从 Anthropic 的 prompt is too long、OpenAI 的 exceeds the context window,到 llama.cpp 的 exceeds the available context size、LM Studio 的 greater than the context length。如果你接的服务报的话术不在表里,pi 只会把它当成一次普通错误。

官方给的解法是在注册 provider 的同一个扩展里做归一化:挂一个 message_end 处理器,把消息的 errorMessage 改写成以 pi 认得的短语开头,最保险的是通用兜底 context_length_exceeded。文档同时给了三条护栏,每一条都对应一种真实的误伤:用 message.providerctx.model?.provider 把改写限制在自己的 provider 上;匹配你自己服务的专有模式而不是 pi 的通用模式;已经含 context_length_exceeded 时直接跳过,保证幂等。第二条护栏的理由说得很直白——把限流类错误(rate limittoo many requests)也改写成溢出,会让 pi 去做压缩,而不是走它本该走的退避重试。这一点在同一个文件里有对应的实现:NON_OVERFLOW_PATTERNS 就是专门把这类消息从溢出判定里排除掉的。想理解上下文窗口本身怎么回事,可以看 上下文窗口是什么?为什么 AI 会「忘记」前面说的话

五、这个设计放弃了什么

pi 的取向很清楚:能声明的都做成声明式字段,声明不了的直接把控制权整个交出去,中间不做智能推断。代价也就在这儿。

它明确不管的事。 密钥获取命令的缓存、TTL 与失败复用,pi 说了不做,理由是无从推断你要哪种策略。/model 的可用性判断不执行命令,所以那里的「可用」只是「配了凭据」的意思。它也不管你接的这家服务额度够不够、合规不合规、稳不稳定——这些在字段里没有对应物。

自己实现 streamSimple 的成本被低估得最多。packages/coding-agent/examples/extensions/custom-provider-anthropic/index.ts 那份示例能看清工作量:你要自己维护事件序列(start、各类 content 块的 start/delta/end、最后的 done 或 error),要自己按块索引找回对应的内容块,要自己累积工具调用的 JSON 分片并容错解析,要自己填 usage 四项再调 calculateCost,要自己处理 abort 信号,还要保证流结束时 stopReason 不能停在 pending。pi 给的补偿是测试可以照抄:文档列了一整张表,从基础流式、token 统计、abort 处理,到空响应、上下文溢出、图片输入、Unicode 代理对、无结果的工具调用、跨 provider 上下文交接,都能从内置 provider 的测试文件改一改直接用在你的实现上。

不适用的场景。 如果对方服务连稳定的流式语义都没有(比如只给一次性返回、或者事件顺序随缘),你在 streamSimple 里补出来的那层适配会变成长期维护负担,每次对方小改都得跟。如果你只是想在几家常规服务之间做故障转移,那属于路由策略问题,用 provider 机制硬凑不划算,多模型回退怎么设计:别等主力挂了才想起来 那篇讲的思路更对口。另外,注册进来的 provider 出问题时排查链路比内置的长一截,配置、扩展加载、流式实现三层都可能是源头,排查手法可以参考 多智能体调试为什么这么难

还有一层是环境。 主流海外模型服务商官方对中国大陆存在区域限制,不支持直连;市面上确实存在第三方中转,但可靠性、合规性与数据流向都需要你自己评估,这里不做任何推荐。各家的接入规则与限制条款不同且会调整,以官方最新说明为准。

六、按顺序走一遍,别跳步

接一家新服务时,这个顺序能把返工降到最低:

  1. 先判断对方讲的是不是那四种协议之一。是,走 models.json;不是,或者要 OAuth,才动扩展。
  2. 只填 baseUrl 加一个模型 id,先让它出现在 --list-models 里。这一步不通,后面全是白搭。
  3. /model 选中,发一条最简单的消息。挂了先看是不是密钥语法问题——检查有没有漏 $,检查 auth.json 里有没有旧条目在压着环境变量。
  4. 跑一轮真的会调工具的任务。工具调用是兼容性问题的集中地带,compat 里有一批字段专为它准备:工具结果要不要带 namerequiresToolResultName)、工具结果后是否必须再跟一条助手消息(requiresAssistantAfterToolResult)、端点收不收严格 JSON Schema 的工具定义(supportsStrictModesupportsStrictTools),都是各自独立的开关。
  5. 打开 reasoning 再跑一轮,确认 thinkingFormatthinkingLevelMap 配对。
  6. 最后造一个超长上下文,看 pi 是自动压缩重试了,还是直接报错停住。停住就说明该加 message_end 归一化了。

要继续往深里读,路径是这样的:先把 packages/coding-agent/docs/models.md 的两张 compat 表通读一遍,你的大部分怪问题答案都在里面;确定要写扩展了,再读 packages/coding-agent/docs/custom-provider.mdpackages/coding-agent/examples/extensions/ 下那两个 provider 示例——一个从零写了自己的流式实现并自带 OAuth 流程,另一个同样走 OAuth,但流式那层直接调用内置 API 的实现、只把请求指向自家网关。两者的改造成本完全不是一个量级,你的场景多半更接近后者。真要动 streamSimple 之前,packages/ai/src/utils/overflow.ts 那份注释也别跳过,它等于一份「各家服务在极限情况下会说什么话」的实测记录。

本篇属于一个把开源编程 Agent 项目 pi 逐层拆开讲的系列,整体地图见开源编程 Agent pi 是什么;沿着这条线往下,还可以看 开源编程 Agent pi 的服务端包开源编程 Agent pi 的评测包

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