给智能体配置和切换模型
建立统一的模型配置入口,理解 Provider 与模型的分工,用目录查询确认可用模型,再把调用入口交给运行时。
给智能体配置和切换模型
一个智能体接好模型后,不应该每换一家服务就重写工具和对话代码。可以把连接集中在一个模块里:读取配置、注册服务、找到模型,再把调用入口交给运行时。
本篇从已经能运行 TypeScript 的条件开始,建立一个可切换模型的小项目。后续文章沿用它处理不同服务、兼容接口、流式响应与工具调用。
建立练习项目
本系列使用 Pi 的模型层和 Agent Core 作为具体实现,依赖固定为 1.5.0。研究参考的本地源码标记为 1.3.0,示例最终以安装的 1.5.0 类型声明核验,不能把不同版本接口直接混用。
mkdir model-lab
cd model-lab
npm init -y
npm pkg set type=module
npm install --save-exact @earendil-works/pi-ai@1.5.0 @earendil-works/pi-agent-core@1.5.0
npm install --save-dev --save-exact tsx@4.23.15 typescript@7.0.2 @types/node@26.6.6
mkdir src使用 Node.js 22.19.0 或更高版本。在 .gitignore 中加入 .env 和 node_modules/。新建 .env:
PROVIDER=anthropic
MODEL_ID=claude-sonnet-4-6
ANTHROPIC_API_KEY=replace-with-your-keyKey 是你在对应服务申请的开发者凭据。模型 ID 需要同时存在于客户端目录、服务端且对账号开放,不能只看显示名称。
Provider 到底是什么
Provider 是 Pi 模型层的运行时单元(runtime unit)。官方 README 的原文是:
A provider is the runtime unit: it owns its model catalog, its auth (API key resolution, OAuth flows), and its stream behavior. A
Modelscollection holds providers and routes every request to the provider that owns the model.
译成中文:Provider 是运行时单元,它拥有自己的模型目录、自己的认证方式(API Key 解析、OAuth 流程)和自己的流式行为。Models 集合持有这些 Provider,并把每次请求路由到拥有该模型的 Provider。
一个 Provider 承担三件事,缺一不可:
Protocol 这一层是独立的概念。anthropic-messages、openai-responses、openai-completions 是 API(wire protocol,线上协议),多个 Provider 会共用同一个 API——xAI、Groq、Cerebras、OpenRouter 大多走 openai-completions。Provider 名字不能当成协议名用。
从 packages/ai/src/models.ts 的 Provider 接口可以直接读出这三条职责的字段:
export interface Provider<TApi extends Api = Api> {
readonly id: string;
readonly name: string;
readonly baseUrl?: string;
readonly headers?: ProviderHeaders;
readonly auth: ProviderAuth;
getModels(): readonly Model<TApi>[];
getAllModels?(): readonly ProviderModel<TApi>[];
refreshModels?(context: RefreshModelsContext): Promise<void>;
filterModels?(models: readonly Model<TApi>[], credential: Credential | undefined): readonly Model<TApi>[];
stream<T extends TApi>(model: Model<T>, context: TranscriptContext, options?: ApiStreamOptions<T>): AssistantMessageEventStream;
streamSimple(model: Model<TApi>, context: TranscriptContext, options?: SimpleStreamOptions): AssistantMessageEventStream;
// 以及可选的 fetchDeferred / cancelDeferred / generateImages / classify
}id 是注册和查找用的键,name 是显示名。auth 是必填的——源码注释写得很直接:
Required: at least one of
apiKey/oauth. Every provider has auth semantics — even providers with only ambient credentials (env vars, AWS profiles, ADC files) and keyless local servers provideapiKeyauth whoseresolve()reports whether the provider is configured.
也就是说,即使一个 Provider 没有任何可配置的密钥(只读环境变量、AWS profile,或本地无认证服务),它也必须提供 apiKey 认证方法,由 resolve() 报告是否配置好了。
创建共同连接入口
新建 src/connection.ts:
import { type AssistantMessage, createModels } from "@earendil-works/pi-ai"
import { anthropicProvider } from "@earendil-works/pi-ai/providers/anthropic"
import { googleProvider } from "@earendil-works/pi-ai/providers/google"
import { openaiProvider } from "@earendil-works/pi-ai/providers/openai"
export function createConnection() {
const provider = process.env.PROVIDER || "anthropic"
const models = createModels()
if (provider === "anthropic") {
models.setProvider(anthropicProvider())
} else if (provider === "openai") {
models.setProvider(openaiProvider())
} else if (provider === "google") {
models.setProvider(googleProvider())
} else {
throw new Error(`未注册的服务:${provider}`)
}
const modelId = process.env.MODEL_ID
if (!modelId) {
throw new Error("请设置 MODEL_ID")
}
const model = models.getModel(provider, modelId)
if (!model) {
throw new Error(`目录中不存在:${provider}/${modelId}`)
}
return { models, model }
}
export function responseText(message: AssistantMessage) {
if (message.stopReason === "error" || message.stopReason === "aborted") {
throw new Error(message.errorMessage || message.stopReason)
}
if (message.stopReason === "length") {
throw new Error("输出被截断,请调整任务或输出预算")
}
return message.content
.filter((block) => block.type === "text")
.map((block) => block.text)
.join("")
}createModels() 返回的是 MutableModels——可变的 Provider 集合。它继承 Models 的全部只读能力,额外多三个写方法:
export interface MutableModels extends Models {
/** Upsert/replace by provider.id. Provider ids are unique. */
setProvider(provider: Provider): void;
deleteProvider(id: string): void;
clearProviders(): void;
}setProvider() 是"upsert"(有则替换,无则插入),不是追加。所以同一个 id 注册两次不会产生两份 Provider,后者覆盖前者。
这份代码先导入三种内置 Provider,但每次只注册选中的一个,便于入门练习。注册模型目录不发起一次真实模型推理,也不代表认证已经通过。
responseText() 把错误、中止和输出截断从正常文本中分开。工具调用消息可能没有文本,本函数不把它当最终答案;工具篇会单独处理这种结果。
模型目录从哪里来
模型目录不是运行时从服务拉取的,它编译进包里,分三层:
model-catalog.ts 里的 flattenChatModelCatalog() 只做一件事——按模型类型过滤并建立 id 索引:
function flattenModelCatalog(groups: ModelGroups, type: ModelType): Record<string, object> {
return Object.fromEntries(
Object.values(groups)
.flatMap((models) => Object.values(models))
.filter((model) => (model as { type?: unknown }).type === type)
.map((model) => [(model as { id: string }).id, model]),
);
}原始数据的来源在 packages/ai/catalog/sources/:主源是 models.dev 的 v2 目录(MODELS_DEV_URL = "https://models.dev/experimental/v2.0/api.json"),另外还抓 OpenRouter、Vercel AI Gateway 和 NVIDIA NIM 的实时列表。执行 npm run generate-models 可以重新生成。
动态目录是另一回事。models-store.ts 里的 ModelsStore 接口负责持久化运行时抓到的模型列表:
export interface ModelsStoreEntry {
models: readonly AnyModel[];
lastModified?: number;
checkedAt?: number;
etag?: string;
}
export interface ModelsStore {
read(providerId: string, options?): Promise<ModelsStoreEntry | undefined>;
write(providerId: string, entry: ModelsStoreEntry, options?): Promise<void>;
delete(providerId: string, options?): Promise<void>;
}etag 存的是远端目录的 ETag 头,原样保存(含引号),下次回传为 If-None-Match。createModels() 默认用 InMemoryModelsStore,程序重启即失效;需要跨进程复用就自己实现 ModelsStore。
先检查目录,再发起请求
新建 src/select-model.ts:
import { createConnection, responseText } from "./connection.js"
const { models, model } = createConnection()
console.log("当前模型:", model.provider, model.id, model.api)
console.table(
models
.getModels(model.provider)
.slice(0, 10)
.map((item) => ({
id: item.id,
contextWindow: item.contextWindow,
image: item.input.includes("image"),
reasoning: item.reasoning,
})),
)
const result = await models.completeSimple(
model,
{
messages: [{ role: "user", content: "用一句话解释函数参数。", timestamp: Date.now() }],
},
{ signal: AbortSignal.timeout(30_000), maxTokens: 256 },
)
console.log(responseText(result))运行:
node --env-file=.env --import tsx src/select-model.tsgetModels(provider) 返回的是聊天模型(Model<Api>),即可以走 stream() 的那些。要拿全部类型,用 getAllModels(provider):
const images = models.getModelsOfType("image", "openrouter");
const flux = models.getModelOfType("image", "openrouter", "black-forest-labs/flux.2-pro");
const jev = models.getModelOfType("classifier", "typesafe", "jev-latest");
const everything = models.getAllModels("openrouter");三个读方法的区别值得记牢:
AnyModel 是 ModelTypeMap[ModelType],混合列表里每一项可能是聊天、图片或分类器模型。不要直接比较 model.type——聊天模型的 type 是可选的,缺省就是聊天模型。源码注释写明:
export interface Model<TApi extends Api> extends BaseModel<TApi> {
/**
* Optional: chat is the default model type, so models without `type` are chat
* models. Narrow mixed model lists with `isModelType()` instead of comparing
* `type` directly.
*/
type?: "chat";
// ...
}判断用 isModelType() 或 getModelType():
import { isModelType } from "@earendil-works/pi-ai";
for (const model of models.getAllModels()) {
if (isModelType(model, "image")) {
// model: ImageModel<ImageApi>
}
}前十条目录只用于观察,可用模型可能多于十个。检查实际选择的 Provider、ID 和 API,再检查是否得到正常文本。如果本地目录没有目标模型,先核对版本与名称;不要盲目重复网络请求。
一次性注册所有内置 Provider
逐个导入 Provider 工厂适合只接一家服务的场景。如果需要全部,packages/ai/src/providers/all.ts 提供了现成入口:
import { builtinModels, builtinProviders, getBuiltinModel } from "@earendil-works/pi-ai/providers/all";
// 内置 Provider 共 43 个
const providers = builtinProviders();
// 一次性注册全部
const models = builtinModels();
// 绕过集合直接读生成目录,带完整字面量类型
const model = getBuiltinModel("openai", "gpt-4o-mini");builtinModels() 内部就是 createModels() 加一轮 setProvider()。它会导入全部目录和全部 Provider 工厂,是"重"入口;builtinModels() 接受的选项和 createModels() 相同。
不经过集合的直接读取用 getBuiltinModel()、getBuiltinModels()、getBuiltinImageModel()、getBuiltinClassifierModel()、getBuiltinProviders() 和 getAllBuiltinModels()。这些函数不依赖任何 Models 集合,适合做模型 ID 的补全和脚本化检查。
凭据从哪里来
Provider 的认证逻辑在 packages/ai/src/auth/resolve.ts。核心规则是:已存储的凭据拥有这个 Provider,环境变量只在没有存储凭据时才被查询。源码注释:
/**
* Auth resolution shared by all operations in a `Models` collection.
* A stored credential owns the provider: ambient/env is consulted only when
* nothing is stored. No silent env fallback after a failed refresh or for a
* credential type without a matching handler.
*/意思是:刷新失败之后不会偷偷退回环境变量。OAuth 凭据快过期(剩余不足 5 分钟,DEFAULT_OAUTH_MINIMUM_VALIDITY_MS = 5 * 60 * 1000)时会在凭据存储的锁内刷新并写回。
环境变量名在 packages/ai/src/env-api-keys.ts 里有一张映射表,findEnvKeys() 可以直接查:
import { findEnvKeys, getEnvApiKey } from "@earendil-works/pi-ai";
findEnvKeys("anthropic"); // ["ANTHROPIC_API_KEY"] 或 ["ANTHROPIC_AUTH_TOKEN", ...]
findEnvKeys("openai"); // ["OPENAI_API_KEY"]Anthropic 有个特例需要留意:
// ANTHROPIC_AUTH_TOKEN participates in env discovery/status, but
// getEnvApiKey() skips it because requests must pass it as Authorization: Bearer.ANTHROPIC_AUTH_TOKEN 会出现在凭据发现列表里,但 getEnvApiKey() 会跳过它,因为这种凭据必须以 Authorization: Bearer 形式发送,而不是当作 API Key。
amazon-bedrock 和 google-vertex 不走固定变量名,而是检查一整组环境来源(AWS profile、ECS 任务角色、web identity token、gcloud ADC 文件)。getEnvApiKey() 对它们返回字符串 "<authenticated>" 而不是真实密钥。
检查认证是否配置好
在发请求之前想知道凭据配没配好,用 checkAuth():
const check = await models.checkAuth("anthropic");
if (!check) {
console.log("未配置凭据");
} else {
console.log(check.type, check.source); // "api_key" "ANTHROPIC_API_KEY"
}Models.checkAuth() 的源码注释只有一句:
Check whether a provider has complete auth configuration without refreshing OAuth.
加粗部分是这个方法的关键设计:它不刷新 OAuth。检查是廉价的、纯本地的;过期令牌在这里不会被续期,只有真正发请求时才会触发刷新流程。返回的 AuthCheck 有 type("api_key" 或 "oauth")和 source(人类可读的来源标签)。
想知道具体解析出了什么(比如是否带 baseUrl 覆盖),用 getAuth():
const auth = await models.getAuth(model);
console.log(auth?.source); // "ANTHROPIC_API_KEY" / "OAuth" / "stored credential"
console.log(auth?.auth.baseUrl); // 可能覆盖模型自带的 baseUrl两者区别:
getAvailable() 特别有用——它返回的是"凭据已配置"的 Provider 下的模型,直接帮你区分"目录里没有"和"有但没配凭据":
const usable = await models.getAvailable("anthropic");将相同入口接回智能体
新建 src/agent.ts:
import { Agent } from "@earendil-works/pi-agent-core"
import { createConnection, responseText } from "./connection.js"
const { models, model } = createConnection()
const agent = new Agent({
initialState: { systemPrompt: "你是中文编程助手。", model },
streamFn: models.streamSimple.bind(models),
})
const timer = setTimeout(() => agent.abort(), 30_000)
try {
await agent.prompt("用一句话解释函数参数。")
const reply = agent.state.messages.findLast((item) => item.role === "assistant")
if (reply?.role !== "assistant") {
throw new Error("没有助手消息")
}
console.log(responseText(reply))
} finally {
clearTimeout(timer)
agent.abort()
await agent.waitForIdle()
}运行 node --env-file=.env --import tsx src/agent.ts。切换模型只需修改 .env 后重新启动进程,工具和任务代码无需跟着修改。正在运行的会话中换模型涉及历史兼容,本例不做热切换。
现在可以分别检查"模型本身能响应"和"运行时能正常使用同一连接"。后续接入其他服务时,优先重复这两项检查。
示例完成了类型和模拟运行检查,未使用真实 Key 联调;账号权限、实际回答和网络情况需要在你的环境验证。