跳到主内容
创建时间:最后更新:作者:白川

给智能体接入模型

智能体运行时需要一个能接收消息、返回结果的模型入口。模型名称只是其中一项:还要确定通过哪个服务连接、用什么协议,以及凭据由谁提供。

这一篇把模型调用从完整智能体里单独拿出来。完成后你能判断问题发生在连接层还是后续的工具执行层:连接层的问题只有固定的几种表现,不会和"模型不听话""工具调不动"混在一起。

前置条件只有一个 TypeScript 项目:Node.js 22.19 以上,装好 @earendil-works/pi-ai,.env 里准备好 DEEPSEEK_API_KEY 与 MODEL_ID。示例用 DeepSeek 的 deepseek-flash,同系列的 deepseek-v4-pro 只需要改一个环境变量。

文中的 API 名称对应 Pi 模型层(@earendil-works/pi-ai)的公开接口。如果你的项目依赖的是其它版本,先对照本地 node_modules/@earendil-works/pi-ai 的类型定义确认签名再照抄代码。相关实现集中在 packages/ai/src/models.ts 与 packages/agent/src/agent.ts,遇到行为对不上时以这两处为准。

四个配置项,各管一件事

配置作用在 Pi 里对应什么
Provider
提供连接实现与模型目录
deepseekProvider() 返回的 Provider 对象
模型 ID
告诉服务使用哪个模型
目录条目里的 id,例如 deepseek-flash
服务地址
决定请求发到哪里
Provider.baseUrl,DeepSeek 的服务地址
凭据
证明调用者具备访问权限
环境变量、凭据存储或 OAuth 三选一

这四项要按顺序落地:注册 Provider → 查目录确认模型 → 确认凭据 → 发起请求。后面几节就按这个顺序展开,每一步都有各自明确的失败表现,"连不上"因此可以逐段拆开定位。

同一个模型品牌可能通过不同服务提供。服务地址、协议和凭据必须配套,不能只把模型 ID 换掉就假定连接完成。

Provider 上的模型目录是一份静态清单,这一点决定了后面很多行为。Models.getModel(provider, id) 的实现只有一行:

typescript
// packages/ai/src/models.ts
getModel(provider: string, id: string): Model<Api> | undefined {
  return this.getModels(provider).find((model) => model.id === id);
}

getModel 返回 undefined 到底意味着什么

它意味着这个 id 不在目录里。就这么一件事。

它不意味着"账号没有权限访问这个模型",也不意味着"凭据无效"。凭据问题会在更后面的环节暴露出来——要么是本地解析阶段抛错,要么是服务端返回 4xx。

这条区分很重要,因为两种问题的排查方向完全相反:

getModel 结果说明下一步查什么
返回模型对象
id 拼写正确,目录里有这条
凭据与账号权限
返回 undefined
id 拼错,或该 provider 目录里没这条
目录里到底有哪些 id

想看目录里有哪些 id,直接列出来:

typescript
// 打印当前 provider 的全部模型 id
for (const m of models.getModels("deepseek")) {
  console.log(`${m.id}  ${m.name}  context=${m.contextWindow}  maxTokens=${m.maxTokens}`);
}

getModels(provider) 的源码注释写明它是 "Best-effort: a provider whose getModels() throws yields no models"。也就是说,provider 自己抛异常时你拿到的是空数组,getModel 同样返回 undefined——这时 undefined 反映的是"拿不到目录",而不是"目录里没有这个 id"。要区分这两种情况,用 getModels(provider).length === 0 再确认一次。

目录里没有你要的模型时

内置目录是编译进包里的,可能和你账号可用的模型不一致。deepseek-flash 与 deepseek-v4-pro 都不在目录里时,有两条路:换成目录里真实存在的 id,或者自己拼一个 Provider——后者不做目录校验,模型 ID 由服务决定。

typescript
import { createModels, createProvider, envApiKeyAuth, type Model } from "@earendil-works/pi-ai"
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy"

const baseUrl = process.env.DEEPSEEK_BASE_URL || "https://api.deepseek.com/v1"
const model: Model<"openai-completions"> = {
  id: process.env.MODEL_ID || "deepseek-flash",
  name: "DeepSeek",
  provider: "deepseek",
  api: "openai-completions",
  baseUrl,
  reasoning: false,
  input: ["text"],
  // 下面两个数值按 DeepSeek 官方文档填写,这里只是占位。
  contextWindow: 65536,
  maxTokens: 4096,
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
}

const models = createModels()
models.setProvider(
  createProvider({
    id: "deepseek",
    auth: { apiKey: envApiKeyAuth("DeepSeek", ["DEEPSEEK_API_KEY"]) },
    models: [model],
    api: openAICompletionsApi(),
  }),
)

自建 Provider 要自己交代清四件事:请求地址、模型 ID、协议和凭据变量名。cost 填零只是占位,不代表服务免费。

独立跑一次请求

创建 src/connect-model.ts:

typescript
import { createModels, type Context } from "@earendil-works/pi-ai"
import { deepseekProvider } from "@earendil-works/pi-ai/providers/deepseek"

const models = createModels()
models.setProvider(deepseekProvider())

const modelId = process.env.MODEL_ID || "deepseek-flash"
const model = models.getModel("deepseek", modelId)
if (!model) {
  // 先把目录里真实存在的 id 打印出来,便于对照拼写
  console.error("目录中的 id:", models.getModels("deepseek").map((m) => m.id).join(", "))
  throw new Error(`模型目录中不存在:${modelId}`)
}

// Context 的形状:systemPrompt 可选,messages 必填,tools 可选。
// 直接调用模型层时,systemPrompt 与 tools 是 context 上的独立字段;
// 交给 Agent 之后,它们会被折叠进 transcript 里的 system 消息。
const context: Context = {
  messages: [{ role: "user", content: "只回复:连接成功", timestamp: Date.now() }],
}

const result = await models.completeSimple(model, context, {
  apiKey: process.env.DEEPSEEK_API_KEY,
  signal: AbortSignal.timeout(30_000),
})

if (result.stopReason === "error" || result.stopReason === "aborted") {
  throw new Error(result.errorMessage || result.stopReason)
}
for (const block of result.content) {
  if (block.type === "text") {
    console.log(block.text)
  }
}
console.log("用量:", result.usage)

执行:

bash
node --env-file=.env --import tsx src/connect-model.ts

模型 ID 从 .env 的 MODEL_ID 读取,换成 deepseek-v4-pro 只改这一行,脚本本身不用动。

completeSimple 的实现只有一行,但它解释了为什么"建立连接前的失败"通常不会以异常的形式出现:

typescript
// packages/ai/src/models.ts
async completeSimple(
  model: Model<Api>,
  context: Context,
  options?: ModelsSimpleStreamOptions,
): Promise<AssistantMessage> {
  return this.streamSimple(model, context, options).result();
}

也就是说 completeSimple = streamSimple + 取出最终那条 AssistantMessage。而 streamSimple 内部走 lazyStream,它把"建立连接前的准备工作失败"也编码成一条 stopReason: "error" 的消息,而不是抛出异常:

typescript
// packages/ai/src/api/lazy.ts
setup()
  .then((inner) => forwardStream(outer, inner, observer, observeResult))
  .catch((error) => {
    const reason = signal?.aborted ? "aborted" : "error";
    const message = createSetupErrorMessage(model, error, startedAt, reason);
    const event: AssistantMessageEvent = { type: "error", reason, error: message };
    outer.push(event);
    observer?.onEvent?.(event);
    outer.end(message);
    observeResult(message);
  });

所以失败要靠检查 stopReason 来发现,而不是靠 try/catch 兜住 await。上面示例里 stopReason === "error" 的分支就是为此存在的。

这只针对"建立连接前就失败"这一步,不代表请求全程都不会抛异常:凭据解析一类的调用会以 ModelsError 拒绝。稳妥的写法是两层都检查,stopReason 判断服务返回的结果,try/catch 兜住解析阶段的异常。

提示词要求"连接成功"只用于让输出容易识别;模型是否遵守这四个字不应作为网络连通性的判断依据。

分清认证失败和模型不存在

这是排查时最容易混起来的两件事:它们都表现为"请求没成功",但根因完全不同。

Models 上有一个专门做这件事的方法:

typescript
// packages/ai/src/models.ts
/** Check whether a provider has complete auth configuration without refreshing OAuth. */
checkAuth(providerId: string, options?: AuthOperationOptions): Promise<AuthCheck | undefined>;

它返回 AuthCheck | undefined,AuthCheck 的形状是 { source?: string; type: "api_key" | "oauth" }。返回 undefined 表示这个 provider 的凭据配置不完整。

它和 getModel 是两条独立的轴,组合起来正好覆盖四种情况:

getModelcheckAuth结论
有对象
有结果
配置看起来正常,问题在服务端(key 无效、账号无权限、模型被下线)
有对象
undefined
模型 id 没问题,但凭据没配好,请求发不出去
undefined
有结果
凭据没问题,模型 id 写错了或在目录外
undefined
undefined
两处都要查

两个 undefined 还有一个共同前提:provider 必须先注册。checkAuth 在 provider 找不到时直接返回 undefined:

typescript
// packages/ai/src/models.ts
checkAuth(providerId: string, options?: AuthOperationOptions): Promise<AuthCheck | undefined> {
  const signal = operationSignal(options?.signal);
  const check = (async () => {
    signal.throwIfAborted();
    const provider = this.providers.get(providerId);
    if (!provider) return undefined;
    return this.checkProviderAuth(provider, await this.readCredential(providerId, signal), signal);
  })();
  return raceWithAbortSignal(check, signal);
}

getModel 同理——getModels(provider) 遇到未注册的 provider 返回空数组。所以"getModel 和 checkAuth 同时返回 undefined"时,先确认 models.setProvider(...) 调过了。

写一个把四种情况区分开的检查脚本:

typescript
// src/check-connection.ts
import { createModels } from "@earendil-works/pi-ai"
import { deepseekProvider } from "@earendil-works/pi-ai/providers/deepseek"

const models = createModels()
models.setProvider(deepseekProvider())

const modelId = process.env.MODEL_ID || "deepseek-flash"
const model = models.getModel("deepseek", modelId)
const auth = await models.checkAuth("deepseek")

console.log("模型 id 存在:", model !== undefined)
console.log("凭据配置完整:", auth !== undefined)
if (auth) {
  // source 是人类可读的来源标签,例如 "DEEPSEEK_API_KEY" 或 "stored credential"
  console.log("凭据类型:", auth.type, "来源:", auth.source)
}

if (!model) {
  console.error("→ 模型 id 不在目录里。目录中的 id:")
  console.error(models.getModels("deepseek").map((m) => m.id).join("\n"))
}
if (!auth) {
  console.error("→ 凭据未配置完整:检查环境变量、凭据存储或 OAuth 登录。")
}
if (model && auth) {
  console.log("→ 两项本地检查都通过;若请求仍失败,看服务返回的错误正文。")
}

运行:

bash
node --env-file=.env --import tsx src/check-connection.ts

checkAuth 的注释里 "without refreshing OAuth" 这半句值得留意:它只检查配置是否齐备,不会顺手刷新过期的 OAuth token。真正发请求时,令牌刷新发生在另一条路径上(getAuth 与请求路径),那里才有锁保护,避免并发刷新同一个轮换令牌。

想看凭据究竟是从哪来的,用 getAuth:

typescript
const providerAuth = await models.getAuth("deepseek")
if (providerAuth) {
  console.log(`配置来源:${providerAuth.source}`)
} else {
  console.log("未配置")
}

getAuth 会对过期的 OAuth 令牌执行刷新,并且永不静默回退到环境变量——源码注释写的是 "a failed refresh never silently falls back to an env key"。它与 checkAuth 的差别在于:getAuth 会拒绝(reject ModelsError),checkAuth 返回 undefined。

凭据的三种来源

Pi 把凭据来源分成三类,各自有独立的入口。

来源怎么配谁来解析适用场景
环境变量
DEEPSEEK_API_KEY=...
provider 的 auth.apiKey.resolve
本地开发、CI
凭据存储
createModels({ credentials: myStore })
CredentialStore 的 read / modify
需要持久化、多进程共享
OAuth
models.login("anthropic", "oauth", interaction)
provider 的 auth.oauth
订阅制账号,没有 API Key

环境变量

这是本篇示例用的方式:把 Key 放进 .env,用 node --env-file=.env 加载。defaultProviderAuthContext() 的 env 实现就是读 process.env:

typescript
// packages/ai/src/auth/context.ts
export function defaultProviderAuthContext(): AuthContext {
  return {
    async env(name: string): Promise<string | undefined> {
      const value = getProcessEnv()?.[name];
      return typeof value === "string" && value.trim().length > 0 ? value : undefined;
    },
    // ...
  };
}

变量名不是自己起的。模型层在 packages/ai/src/env-api-keys.ts 的映射表里把 provider 指向对应的凭据变量:

typescript
// packages/ai/src/env-api-keys.ts
const envMap: Record<string, string> = {
  deepseek: "DEEPSEEK_API_KEY",
  // ... 其它 provider ...
};

不是所有 provider 都只认一个变量:Anthropic 的 resolve 会依次尝试 ANTHROPIC_AUTH_TOKEN、ANTHROPIC_OAUTH_TOKEN、ANTHROPIC_API_KEY,第一个非空的就赢。source 字段会告诉你实际命中的是哪个。

凭据存储

createModels() 不传 credentials 时用的是内存实现:

typescript
// packages/ai/src/models.ts
constructor(options?: CreateModelsOptions) {
  this.credentials = options?.credentials ?? new InMemoryCredentialStore();
  this.modelsStore = options?.modelsStore ?? new InMemoryModelsStore();
  this.authContext = options?.authContext ?? defaultAuthContext();
  this.identity = options?.identity;
}

InMemoryCredentialStore 的注释说得很直接:"Apps inject persistent stores." 内存实现随进程退出而消失,多进程之间也不共享。

持久化存储需要自己实现 CredentialStore 契约:read(providerId)、list()、modify(providerId, fn)、delete(providerId)。modify 是唯一的写入路径,它把读-改-写串行化到同一个 provider 上,所以 OAuth 令牌刷新发生在 modify 内部,多个进程不会重复刷新同一个轮换令牌。

源码注释里还有一条容易忽略的规则:"A stored credential owns its provider: environment variables are only consulted when nothing is stored." 存储的凭据一旦存在,环境变量就不再被读取。

OAuth

DeepSeek 只提供 API Key,不走 OAuth。模型层里支持 OAuth 的 provider 包括 Anthropic、OpenAI Codex、GitHub Copilot、OpenRouter 等,入口是 Models.login:

typescript
// packages/ai/src/models.ts
login(providerId: string, type: AuthType, interaction: AuthInteraction, options?: LoginOptions): Promise<Credential>;

interaction 需要实现两个回调:

typescript
await models.login("anthropic", "oauth", {
  prompt: async (p) => {
    // p.type: 'text' | 'secret' | 'select' | 'manual_code'
    return await askUser(p.message)
  },
  notify: (event) => {
    // event.type: 'info' | 'auth_url' | 'device_code' | 'progress'
    if (event.type === "auth_url") console.log("打开:", event.url)
    if (event.type === "device_code") console.log("验证码:", event.userCode, "at", event.verificationUri)
  },
})

这里的 "anthropic" 只是 provider ID,换成你使用的那个即可。登录成功后凭据由 Models 写入凭据存储,之后的请求会自动解析并刷新令牌,不需要再手工传 key。

补充一句包结构上的事实:@earendil-works/pi-ai 的 exports 里确实有 "./oauth" 这个子路径,但它只导出类型(OAuthAuthInfo、OAuthPrompt 等兼容声明),不含运行时逻辑。真正的 OAuth 流程在 provider 的 auth.oauth 上,由 Models.login 驱动。

用量字段怎么读

Usage 的结构:

字段含义
input
输入 token
output
输出 token
cacheRead
从提示缓存读取的 token
cacheWrite
写入提示缓存的 token
cacheWrite1h?
1 小时保留的那部分缓存写入,只有 Anthropic 上报这个拆分
reasoning?
推理 token
totalTokens
总 token
cost
成本,含 total 与 totalFromProvider?

字段是否出现取决于服务上报了什么,不要假设每个 provider 都会填满这张表。

reasoning 是 output 的子集。源码注释写得很清楚:"This is a subset of output: output already includes these tokens." 把两者相加会重复计数。

cost.total 也有个陷阱,源码注释是 "Best available total: provider-reported billed cost when supported, otherwise the catalog estimate",而 totalFromProvider 用来区分二者:"True when total is the billed cost reported by the provider. False when it is a catalog estimate; absent also means false."

也就是说:cost.total 不等于账单。只有 totalFromProvider === true 时它才是服务端实报金额;false 或字段缺失都表示这是按目录费率估算的数值。

排查对照表

现象优先检查
本地提示缺少 Key
.env 是否存在,命令是否带 --env-file
getModel 返回 undefined
MODEL_ID 拼写;用 getModels(provider) 打印真实 id 列表
checkAuth 返回 undefined
凭据三种来源是否至少有一种配全;注意存储凭据会盖住环境变量
服务返回认证失败
Key 是否属于该服务、是否有效;此时 checkAuth 通常仍返回结果
服务拒绝模型访问
账号权限、服务实际支持的模型;模型可能已从目录下线
请求超时或连接失败
请求地址、网络、代理与服务响应
文本成功但工具调用失败
模型和协议是否支持当前工具声明

错误日志可以保留状态、模型 ID 和经过处理的请求信息,不要把完整认证头或 Key 输出到终端。

交给智能体时多了一层

把同一条连接交给智能体运行时,模型入口从 completeSimple 换成了 streamFn:

typescript
const models = createModels()
models.setProvider(deepseekProvider())
const model = models.getModel("deepseek", modelId)
if (!model) {
  throw new Error(`模型目录中不存在:${modelId}`)
}
const agent = new Agent({
  initialState: { systemPrompt, model, tools },
  streamFn: models.streamSimple.bind(models),
  getApiKey: () => apiKey,
})

两处差别值得留意:

第一,streamFn 就是 models.streamSimple。它接收的 context 类型是 TranscriptContext,而不是直接调用时用的 Context。TranscriptContext 的注释解释了原因:"The prompt and tool declarations are carried by the transcript's system messages." 也就是说,交给 Agent 之后,系统提示与工具声明不再待在 context 的独立字段上,而是被折叠进 transcript 开头的 system 消息里。

第二,getApiKey 是按 provider 名返回凭据的:

typescript
// packages/agent/src/agent.ts
getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;

调用点在循环里,每次请求都会重新解析一次:

typescript
// packages/agent/src/agent-loop.ts
const resolvedApiKey =
  (config.getApiKey ? await config.getApiKey(config.model.provider) : undefined) || config.apiKey;

config.model.provider 就是当前模型的 provider 字段。所以只接了 DeepSeek 一个 provider 时忽略参数直接返回同一个 Key 是可行的;接多个 provider 时要按传入的 provider 名分别给凭据。这个钩子存在的意义是应对会过期的凭据——源码注释写着 "Useful for short-lived OAuth tokens (e.g., GitHub Copilot) that may expire during long-running tool execution phases."

由此,排查顺序可以固定下来:先跑直连脚本,通过后再跑智能体脚本。直连失败时不要改工具代码;直连成功但智能体失败,再查消息、工具和循环状态。

你现在能做什么

  • 有一条能单独跑的直连脚本:模型 ID 拼错、凭据缺失、服务报错各有各的表现,不用靠一句"连接失败"去猜。
  • 能用 getModel 与 checkAuth 两条轴,把问题定位到凭据、模型 ID、服务端三段中的一段,再决定查哪边。
  • 知道用量字段里哪些能当账单看:cost.total 只有在 totalFromProvider 为真时才是服务端实报金额。
  • 知道交给智能体后多出的那一层是什么:streamFn 负责模型调用,getApiKey 负责按 provider 名供凭据。

排查时按这个顺序走,每一步只排除一类原因:

  1. getModel 返回 undefined → 先修模型 ID,或者改用自建 Provider。这一步和凭据无关。
  2. checkAuth 返回 undefined → 再补凭据。环境变量、凭据存储、OAuth 任意一种配全即可。
  3. 两项都通过但请求仍然失败 → 问题在服务端:Key 无效、账号没有该模型权限、模型已下线。

接下来要做的不是继续调连接,而是把任务范围划清楚:系统指令、用户任务与待分析材料各归其位(用指令和消息定义智能体的行为)。示例中不含任何 Key,真实响应需要用自己的凭据跑一遍。

参考资料