开源编程 Agent pi 的本地模型接入:路由服务器与自定义服务商
本文基于 pi v0.82.1(仓库 commit 027a584,2026-07-28)梳理,该项目仍在快速迭代,具体行为以官方文档 https://pi.dev/docs/latest 与仓库最新代码为准。
pi 并没有为本地推理单开一套后端,它对 llama.cpp 的支持本身就是一个内置扩展,走的是任何人都能调用的 pi.registerProvider()。 换句话说,你在文档里读到的「本地模型接入路线」和「注册自定义服务商」不是两件事,是同一个机制的两个入口——一个官方替你写好了,另一个留给你自己写。搞清楚这层关系,你才知道遇到 Ollama、vLLM、公司内网网关这些场景时该抄哪一段。
pi 是 earendil-works 的开源编程 Agent,MIT 许可证,主仓库在 https://github.com/earendil-works/pi ,截至 2026 年 7 月 GitHub 上约 8 万 star。下面讲的每一处路径和配置项都能在仓库里当场翻到。
站内已经有两篇讲通用方法论的文章:国产 API 免费档对比 谈的是怎么挑一个便宜可用的远端服务,模型路由策略 谈的是多模型之间怎么分派任务;本篇不重复这些判断,只讲一个具体项目把「接入」这件事落到了什么形态、代码在哪、边界在哪。
一、本地模型接进编程 Agent,卡点从来不是”能不能连上”
你自己起一个 OpenAI 兼容的本地服务并不难,难的是让 Agent 侧认这个模型。一个编程 Agent 至少要知道四件事:这个模型的 API 形态是什么、它支不支持工具调用、它的上下文能吃多少、它的请求体有哪些字段跟标准不一样。缺任何一项,表现就是「连上了但工具调不动」或者「跑几轮就报错」。
pi 把这四件事收敛成了一个 Model 描述:id、api、input、contextWindow、maxTokens、cost,外加一个专门装兼容性差异的 compat。字段清单在 packages/coding-agent/docs/models.md 的 Model Configuration 一节里列得很全。所谓「接入一个本地模型」,本质就是想办法把这份描述交给 pi,剩下的流式解析、工具调用、上下文管理由框架统一处理。
于是路线的差别只在于:这份描述是你手写的、程序读远端接口拉的,还是官方扩展替你动态生成的。
二、路线一:llama.cpp 路由服务器,官方替你写好的那一条
packages/coding-agent/docs/llama-cpp.md 讲的是这条。它依赖 llama.cpp 的 router server——路由模式下一个服务进程管理整个模型目录,按需加载和卸载 GGUF 模型。启动方式是不带 --model/-m 启 llama-server,官方文档给的命令是:
llama-server \
--models-dir ~/models \
--no-models-autoload \
--jinja \
--host 127.0.0.1 \
--port 8080 \
-ngl 999 \
-c 32768
其中 --jinja 是编程 Agent 场景的关键项,文档写明它启用兼容的聊天模板和工具调用;--no-models-autoload 让加载动作全部由后面的 /llama 显式发起。文档也说明了模型目录布局:单文件模型可以直接放在目录下,多模态和多分片模型要各自放进子目录,手工加文件之后需要重启路由。
pi 这边只要两步。先配置服务商:
/login llama.cpp
它会问你路由地址和可选的 API key,默认地址是 http://127.0.0.1:8080。也可以不走交互,用环境变量:
export LLAMA_BASE_URL=http://127.0.0.1:8080
export LLAMA_API_KEY=optional-secret
pi
然后用 /llama 管理模型:选中未加载的加载,选中已加载的卸载,选 Download model… 可以搜 Hugging Face 并挑量化版本,owner/repository[:quant] 这种精确写法也认。文档明确了一条克制的行为约定——pi 不会静默卸载模型,也从不删除模型文件;因为路由可能被别的客户端共用,/llama 每次显示的都是路由的当前状态。
最容易被忽略的是最后一句:只有已加载的模型才会出现在 /model 里。加载完还得再跑一次 /model 把它选进当前会话。
三、这条路线的代码长什么样,以及它凭什么等于自定义服务商
翻开 packages/coding-agent/src/extensions/index.ts 就一行关键内容:builtInExtensions 数组里装着 llama.cpp 这个扩展,标了 hidden: true。这个数组在 main.ts 里会跟嵌入方传进来的扩展合并成同一份工厂列表,扩展工厂的签名也是同一个:接收一个 ExtensionAPI 对象。换句话说,官方这份 llama.cpp 扩展能用的接口,就是你自己写扩展时能用的那套接口,hidden: true 的作用只是让它不出现在启动时的扩展清单里,不占用户视线。
再看 packages/coding-agent/src/extensions/llama/index.ts 的入口函数,第一件事就是:
export default function llamaExtension(pi: ExtensionAPI): void {
const provider = createLlamaProvider();
pi.registerProvider(provider.provider);
而 pi.registerProvider() 正是 packages/coding-agent/docs/custom-provider.md 开篇介绍的那个接口。文档里写得很直白:扩展可以注册一个完整的 pi-ai Provider,也可以用旧的 provider-config 形式;需要自定义认证、过滤、刷新或流式行为时,用完整 Provider。llama.cpp 扩展需要的恰好就是「刷新」——模型目录是动态的,所以它实现了 getModels() 和 refreshModels(),在 /llama 里加载完模型后调用 setCatalog() 更新目录,再触发一次模型注册表刷新。
packages/coding-agent/src/extensions/llama/provider.ts 里的 toPiModel() 就是上一节说的「生成模型描述」那步。它把路由返回的模型信息翻译成 pi 的 Model:上下文取路由上报的 n_ctx 或 n_ctx_train,读不到才退回内置默认值;maxTokens 直接取同一个上下文数值,没有另外设一个更小的输出上限;input 根据 architecture.input_modalities 里有没有 image 决定是否声明图片输入;reasoning 一律写 false,也就是从这条路进来的模型不会被当成支持扩展思考的模型对待,哪怕它本身会输出思考内容;cost 四项全填 0,因为本地推理没有账单;兼容性则写死了一组:
compat: {
supportsStore: false,
supportsDeveloperRole: false,
supportsReasoningEffort: false,
supportsUsageInStreaming: false,
supportsStrictMode: false,
maxTokensField: "max_tokens",
},
这组值不是随便填的,它对应的正是 models.md 里反复提醒的那几个本地服务器常见差异:很多 OpenAI 兼容服务不认 developer 角色,不认 reasoning_effort,用的是 max_tokens 而不是 max_completion_tokens。官方扩展替 llama.cpp 用户把这几格勾好了,你接别的本地服务器时得自己判断。
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| llama.cpp 接入文档 | 路由服务器启动参数、模型目录布局、/llama 用法、排错 | packages/coding-agent/docs/llama-cpp.md | 第一次把本地模型接进来时 |
| 自定义服务商文档 | registerProvider 两种形态、API 类型表、OAuth、自定义流式 | packages/coding-agent/docs/custom-provider.md | 要接非 llama.cpp 的服务或公司网关时 |
| models.json 文档 | 声明式加服务商与模型,compat 字段全表 | packages/coding-agent/docs/models.md | 只想改配置、不想写代码时 |
| llama 扩展入口 | 注册 Provider、实现 /llama 命令、加载/卸载/下载流程 | packages/coding-agent/src/extensions/llama/index.ts | 想照着抄一个动态服务商时 |
| llama Provider 定义 | 认证解析、模型目录刷新、模型描述翻译 | packages/coding-agent/src/extensions/llama/provider.ts | 排查模型为什么不出现在 /model 时 |
| llama HTTP 客户端 | 与路由通信、URL 规范化、加载进度轮询 | packages/coding-agent/src/extensions/llama/client.ts | 地址写法出问题、或想知道请求打到哪时 |
| 内置扩展清单 | 声明 llama.cpp 扩展随 pi 一起加载 | packages/coding-agent/src/extensions/index.ts | 想确认它是不是”内置特权”时 |
四、路线二:不写代码,用 models.json 挂一个 OpenAI 兼容端点
如果你的本地服务不是 llama.cpp 路由,packages/coding-agent/docs/providers.md 的 Custom Providers 一节给了两个明确分叉:能说 OpenAI Completions、OpenAI Responses、Anthropic Messages、Google Generative AI 之一的,走 models.json;需要自定义 API 实现或 OAuth 流程的,才写扩展。
models.json 放在 ~/.pi/agent/models.json,最小形态每个模型只要一个 id:
{
"providers": {
"ollama": {
"baseUrl": "http://localhost:11434/v1",
"api": "openai-completions",
"apiKey": "ollama",
"models": [
{ "id": "llama3.1:8b" },
{ "id": "qwen2.5-coder:7b" }
]
}
}
}
这里有个细节值得先记下:apiKey 填的是占位符。文档解释了原因——Ollama 会忽略这个值,但 pi 仍然把模型视作「需要认证后才能出现在 /model」,所以无鉴权的本地服务要么留一个假值,要么用 /login 给这个服务商存一个 key,要么在选模型时带 --api-key。
compat 可以写在服务商级别(对该服务商所有模型生效),也可以写在模型级别覆盖。文档点名了这套写法常用于 Ollama、vLLM、SGLang 一类 OpenAI 兼容服务器。还有一条省事的性质:这个文件每次打开 /model 都会重新读取,会话中途改完不用重启。
五、路线三:写扩展,什么时候值得
custom-provider.md 把适用场景列在最前面:走公司代理或 API 网关、自建或私有部署的端点、需要 OAuth/SSO 的企业服务商、非标准 LLM API 需要自己实现流式。前两类里最轻的一种是只改地址,不重新定义模型:
// All Anthropic requests now go through your proxy
pi.registerProvider("anthropic", {
baseUrl: "https://proxy.example.com"
});
文档说明,只给 baseUrl 和/或 headers、不给 models 时,该服务商原有的全部模型都保留,只是换了端点;一旦给了 models,它会替换该服务商的全部模型。这个语义差别很容易踩。
顺带说清一件事:能不能改地址,和改完能不能用,是两回事。各家模型服务商对服务区域、账号资格、计费与限流各有各的规定,且会调整,具体以你要接的那家官方最新说明为准,本文不代任何一家做承诺,也不推荐具体中转渠道。改 baseUrl 这条路子本身是中性的,它解决的是「请求该发到哪」,解决不了「你有没有资格用」。
如果模型列表得从远端拉,文档给的做法是用 async 扩展工厂:在工厂里 fetch 一次服务的模型接口,把结果映射成模型数组再注册。文档特别注明,pi 会等待工厂完成后才继续启动,所以这样注册的服务商在交互式启动阶段和 pi --list-models 里都能用。llama.cpp 扩展走的是同一个方向的另一种写法:它没把模型目录的获取塞进会话开始事件,而是放进 Provider 自己的 refreshModels(),由模型注册表在需要时调用——refreshModels() 里还带了一层本地缓存,先从 context.store 读上次记下的模型列表,只有在允许联网、凭据有效且拿得到地址时才真去请求路由,请求成功后再把结果写回。这样即使路由暂时没起,模型列表也不至于一片空白。
再往深一层是 streamSimple,为非标准 API 自己实现流式。文档给了完整的事件序列(start、text/thinking/toolcall 的 start-delta-end、done 或 error)、内容块累积方式、工具调用 JSON 的边收边解析,还列了六个内置服务商实现作为参照。这一层不是「接本地模型」该干的事,除非你的推理服务连 OpenAI Completions 都不兼容。
六、边界与代价:它明确不管什么
它不管你的模型行不行。 接入层只负责把模型描述交给框架,模型本身能不能稳定输出可解析的工具调用、能不能在多轮改代码中不跑偏,接入机制一概不保证。--jinja 只是让服务端启用兼容模板与工具调用能力,不等于任何 GGUF 模型都能胜任编程 Agent 的工具循环。
它不管显存和速度。 llama.cpp 文档里的排错项写得很实在:加载失败或吃内存太多,就调小 -c 或者卸载另一个模型。这是纯粹的资源账,pi 不会替你调度。上下文预算怎么花才划算是另一个话题,可以看 Agent 上下文预算。
它对模型状态的口径不完全一致。 读代码能看到:/llama 的交互层把 loaded 和 sleeping 都算作已加载,而注册给 pi 模型目录的 setCatalog() 只筛 status.value === "loaded"。所以一个处于 sleeping 的模型在 /llama 里显示为已加载,却未必同步出现在 /model 的可选列表里。这不是文档承诺的行为,是当前代码的事实,遇到时别怀疑自己。
环境变量和存储凭据的覆盖范围也不同。 provider.ts 里 resolve() 会依次看凭据里的 LLAMA_BASE_URL 和进程环境变量,LLAMA_API_KEY 读不到时兜底成 local;但 refreshModels() 的联网刷新分支只读凭据里的地址,凭据里没有就直接返回。纯靠 export 环境变量起 pi 的人,模型目录的自动刷新未必按你预期发生。
成本统计对本地模型没有意义。 toPiModel() 把 cost 四项写成 0,本地推理确实不产生 API 账单,但你的电费和机器占用也就统计不到。想看 token 花在哪,得靠别的口径。
它不替你做路由决策。 本地模型和云端模型同时可用时,什么任务派给谁,pi 给的是 /model 这个手动开关,选择逻辑在你脑子里。这块的判断框架见 模型路由策略。
七、上手与避坑清单
服务端起成了单模型模式。 llama.cpp 只要带上 --model、-m 或 -hf,就是单模型模式而不是路由模式,/llama 会拿不到模型列表。避法:启动命令里彻底不出现这三个参数,起完先 curl http://127.0.0.1:8080/models 确认能列出目录。
加载了模型却在 /model 里找不到。 这是顺序问题:模型目录是加载后才同步给 pi 的。避法:先 /llama 加载,再 /model 选择;顺手确认目标模型的状态是 loaded 而不是 sleeping 或 loading。
新拷进模型目录的文件不出现。 文档写明手动添加文件后需要重启路由,pi 这边刷新再多次也没用。避法:改完目录先重启 llama-server,再回 pi 里跑 /llama。
服务端设了 key,pi 这边没设。 客户端只有在拿到 key 的情况下才会给请求加上 Authorization: Bearer 头,所以服务端 --api-key 与 pi 里存的值必须一致。避法:要么两边都设并保持一致,要么两边都不设、同时守住 --host 127.0.0.1 只开本机访问。
地址写法带了 /v1、结尾多了斜杠、或者带了查询串。 client.ts 里的 normalizeLlamaServerUrl() 会去掉尾部斜杠和结尾的 /v1、清空 query 和 hash,并且只接受 http 与 https,其它协议直接抛错。规范化之后,真正打给模型的推理端点是由代码在这个根地址后面自己补 /v1 拼出来的,所以你手填的 /v1 要么被吃掉、要么让你困惑地址到底变成了什么。避法:填服务根地址就行,别自己拼 /v1;协议只写 http 或 https,其它前缀会直接抛错。
把 models 在两处的语义混为一谈。 这是最容易串线的一处:同样叫 models,配置文件和扩展接口的规则并不一样。models.json 里给一个内置服务商写 models,走的是合并——内置模型保留,自定义模型按 id 逐条 upsert,id 撞上内置的才替换那一条,id 是新的就并排加进来;而扩展里 pi.registerProvider() 一旦带上 models,替换掉的是该服务商的整份模型列表。避法:只想改端点就别写 models;合并规则以 models.md 的 Overriding Built-in Providers 一节为准;只想微调某个已有模型的字段——name、reasoning、thinkingLevelMap、input、cost、contextWindow、maxTokens、headers、compat——用 modelOverrides,它按模型 id 打补丁,不动模型列表本身,认不出的 id 直接忽略。
本地服务器认不出 pi 发的字段。 典型症状是报 developer 角色不支持,或者拒收 reasoning_effort、max_completion_tokens。避法:照 models.md 的 compat 表逐项对齐,最省事的参照物就是官方 llama 扩展写死的那六个值。
非交互模式下敲 /llama。 代码里对 ctx.mode !== "tui" 直接给一条提示就返回。避法:模型管理在交互式界面里做,脚本化场景走配置文件或环境变量。
收束
把这条线捋直了,判断就简单了:用 llama.cpp 路由 → 照 llama-cpp.md 走,什么都不用写;用别的 OpenAI 兼容本地服务 → 写 ~/.pi/agent/models.json,重点花在 compat 上;需要动态模型列表、企业 SSO 或非标准流式 → 才轮到写扩展,而 src/extensions/llama/ 这三个文件就是现成的完整样本。
接下来该读哪个文件,也按这个顺序:docs/providers.md 看全局分叉,docs/models.md 看字段表,docs/custom-provider.md 看接口,最后才去读 src/extensions/llama/provider.ts 看一个真实实现怎么落地。想对比 pi 与其它 Agent 框架在扩展点设计上的取向差异,可以再看 Agent 框架对比。
上手前给自己过一遍:路由是不是真的在路由模式、目标模型状态是不是 loaded、/model 里选中的是不是它、key 两边是否一致、compat 有没有按你的服务器实际情况改过。这五条都点头,再谈模型效果好不好的事。
本篇属于一个把开源编程 Agent 项目 pi 逐层拆开讲的系列,整体地图见开源编程 Agent pi 是什么;沿着这条线往下,还可以看 把开源编程 Agent pi 调顺手 和 开源编程 Agent pi 的终端界面为什么不闪。