排查模型连接与调用失败
按 ModelsError 的六个错误码定位问题,用 checkAuth 区分"没配凭据"与"没有这个模型",为调用增加超时与清晰的失败出口。
排查模型连接与调用失败
模型调用失败时,先确认错误发生在哪一层。模型目录里找不到 ID、服务拒绝 Key、接口路径错误,以及流开始后中断,需要不同的排查动作。
本篇沿用模型配置的 connection.ts。目标是让程序清晰报告未完成状态,避免用盲目重试掩盖配置问题。
错误码是排障的第一把钥匙
模型层抛出的错误统一是 ModelsError。它的定义在 packages/ai/src/utils/models-error.ts:
export type ModelsErrorCode = "model_source" | "model_validation" | "provider" | "stream" | "auth" | "oauth";
export class ModelsError extends Error {
readonly code: ModelsErrorCode;
constructor(code: ModelsErrorCode, message: string, options?: { cause?: unknown }) {
super(withCauseDetail(message, options?.cause), options);
this.name = "ModelsError";
this.code = code;
}
}六个码各自指向一层问题:
ModelsError 的一个设计细节:底层原因会被拼进 message。
/** Callers surface `error.message` only, so keep the underlying reason in it. */
function withCauseDetail(message: string, cause: unknown): string {
if (cause === undefined || cause === null) return message;
const detail = formatThrownValue(cause).trim();
if (!detail || message.includes(detail)) return message;
return `${message}: ${detail}`;
}调用方通常只打印 error.message,所以真正的底层错误不会丢失。实际效果是消息长这样:Unknown provider: x: boom。
注意:ModelsError 只在集合层的显式调用中抛出。 请求路径会把同样的失败编码进流里,变成 stopReason: "error" 的助手消息。两条路径要分开处理。
让配置错误先于网络请求暴露
配置入口已检查 Provider 与模型 ID。把 PROVIDER 写成未注册名称,或写入目录不存在的 ID,程序应立即失败。此时修改网络重试次数没有意义,因为请求尚未进入模型服务。
凭据则有两步:本地是否能解析到,以及远程服务是否接受。getAuth() 能帮助检查前者,但不能代替一次真实请求。
三种"模型不可用"要分清
"找不到模型"其实有三种完全不同的原因,修复动作也完全不同:
这段代码把三者分开报告:
import { createModels } from "@earendil-works/pi-ai";
import { anthropicProvider } from "@earendil-works/pi-ai/providers/anthropic";
const models = createModels();
models.setProvider(anthropicProvider());
const providerId = "anthropic";
const modelId = "claude-sonnet-4-6";
if (!models.getProvider(providerId)) {
console.error("Provider 未注册:", providerId);
process.exit(1);
}
const model = models.getModel(providerId, modelId);
if (!model) {
console.error(`目录中不存在:${providerId}/${modelId}`);
console.error("该 Provider 当前目录中的模型:");
for (const item of models.getModels(providerId)) {
console.error(" -", item.id);
}
process.exit(1);
}
const check = await models.checkAuth(providerId);
if (!check) {
console.error(`凭据未配置:${providerId}`);
console.error("设置对应的环境变量,或调用 models.login() 走交互式登录。");
process.exit(1);
}
console.log("凭据来源:", check.source, "| 类型:", check.type);checkAuth() 是最省事的判定手段,它的源码注释点明了成本:
Check whether a provider has complete auth configuration without refreshing OAuth.
不刷新 OAuth——所以它是纯本地检查,不会发起网络请求,也不会消耗刷新令牌。返回 undefined 表示"没配置",返回 { type, source } 表示配置完整。
增加明确的失败分支
新建 src/handle-errors.ts:
import { createConnection } from "./connection.js"
try {
const { models, model } = createConnection()
const result = await models.completeSimple(
model,
{
messages: [{ role: "user", content: "用一句话解释变量。", timestamp: Date.now() }],
},
{
signal: AbortSignal.timeout(15_000),
maxTokens: 256,
maxRetryDelayMs: 2_000,
},
)
if (result.stopReason === "error" || result.stopReason === "aborted") {
console.error("请求未完成:", result.stopReason)
console.error("原因:", result.errorMessage || "服务未提供详细原因")
process.exitCode = 1
} else if (result.stopReason === "length") {
console.error("输出达到上限,请调整任务或预算后重试")
process.exitCode = 1
} else if (result.stopReason === "toolUse") {
console.error("模型请求了工具,本程序没有执行工具")
process.exitCode = 1
} else {
for (const block of result.content) {
if (block.type === "text") {
console.log(block.text)
}
}
}
} catch (error) {
console.error("配置或请求初始化失败:", error instanceof Error ? error.message : String(error))
process.exitCode = 1
}运行:
node --env-file=.env --import tsx src/handle-errors.ts模型集合中的认证或网络失败可能作为错误消息返回,因此代码同时处理返回状态和抛出的异常。不能只写 try/catch,然后把所有正常返回都打印成"完成"。
15 秒是本例设置的整体请求上限。maxRetryDelayMs 约束可接受的重试等待,不等于"只重试两秒",也不是统一的重试次数设置。底层适配器和 SDK 可能已有重试行为,先检查实际实现再叠加应用重试。
按错误码分流处理
要写一个能同时覆盖配置错误和请求错误的入口,就需要在 catch 里按 code 分流:
import { ModelsError } from "@earendil-works/pi-ai";
function describeFailure(error: unknown): { code: string; hint: string } {
if (!(error instanceof ModelsError)) {
return { code: "unknown", hint: "非模型层错误,检查应用代码与网络栈。" };
}
switch (error.code) {
case "provider":
return { code: error.code, hint: "Provider 未注册或缺少该能力的实现。" };
case "auth":
return { code: error.code, hint: "凭据缺失或凭据存储不可用。" };
case "oauth":
return { code: error.code, hint: "OAuth 刷新失败,需要重新登录。" };
case "stream":
return { code: error.code, hint: "协议不匹配或传输层建立失败。" };
case "model_source":
return { code: error.code, hint: "模型目录来源不可达。" };
case "model_validation":
return { code: error.code, hint: "模型目录数据不合法。" };
}
}ModelsError 从 @earendil-works/pi-ai 直接导出,不用深路径导入:
export { ModelsError, type ModelsErrorCode } from "./auth/resolve.ts";OAuth 失败要单独处理
oauth 码有独立语义。packages/ai/src/auth/resolve.ts 的注释说明了两种失败的差别:
/**
* Resolve provider-scoped auth by provider id, or provider auth plus static
* model headers when passed a model. Includes a source label for status UI.
* Resolves `undefined` when the provider is unknown or unconfigured.
* Rejects with `ModelsError`: code "oauth" when a token refresh fails (the
* stored credential is preserved for retry; re-login fixes it), code "auth"
* when api-key resolution or the credential store fails. Request paths
* surface rejections as stream errors.
*/关键区别:
oauth码——令牌刷新失败。已存储的凭据被保留,可以重试;重新登录能修好。auth码——Key 解析失败或凭据存储失败。
所以遇到 oauth 码,直接提示用户重新登录,而不是清空凭据重试。
刷新过程本身有一层保护:已经开始的刷新不会因为取消而中断。源码注释:
// A refresh that has started survives cancellation or a superseding model refresh, so a
// rotated refresh token is always persisted. A newer refresh then sees the fresh credential.原因是 provider 可能已经轮换了 refresh token,中途取消会导致唯一有效的 token 丢失。刷新超时上限是 15 秒(DEFAULT_OAUTH_REFRESH_TIMEOUT_MS = 15_000)。
另外,"剩余有效期不足 5 分钟"就触发刷新(DEFAULT_OAUTH_MINIMUM_VALIDITY_MS = 5 * 60 * 1000)。
重新登录用 models.login():
const credential = await models.login("anthropic", "oauth", {
prompt: async (p) => {
// 返回用户输入的字符串
return askUser(p);
},
notify: (event) => {
console.error(event.type, event.message ?? "");
},
});登出用 models.logout(providerId)。
用现象缩小范围
服务可能把多种问题合并成同一状态码。表格是排查顺序,不是一条报错唯一对应一种原因的分类器。
传输层失败有诊断信息
WebSocket 传输失败会留下结构化诊断,挂在 AssistantMessage.diagnostics 上:
const diagnostic = {
configuredTransport: "auto",
fallbackTransport: "sse", // 仅流开始前失败时出现
eventsEmitted: false,
phase: "before_message_stream_start",
requestBytes: 1234,
};phase 有两个取值,决定了能不能自动恢复:
流开始后不降级的原因:部分内容已经发出去了,换一条线重放会重复。诊断信息是脱敏后的,可以安全记录。
什么时候重试
短暂网络问题、部分限流或服务故障可以在预算内重试;无效凭据、错误模型名和不兼容字段应先修配置。用户主动取消后不能再自动发起同一任务。
需要应用级重试时,先明确已经显示的片段如何处理、是否发生工具副作用、总时间如何限制,以及底层已经重试了多少次。本篇不添加一个外层无限循环,也不在失败后擅自把内容发送给另一家服务。
工具失败属于另一层:模型请求成功并要求调用工具,但执行器无法完成动作。应回填明确工具错误,不必重新开始所有模型请求。
保留足够的证据
记录 Provider、模型 ID、协议、结束状态、耗时与脱敏后的错误原因。请求 ID 如果服务提供可以一并保留;不要默认所有 Provider 都返回同一字段。
调试时检查载荷有助于定位消息格式问题,但原始消息可能含代码和用户材料,日志范围应该服务于当前问题。密钥和完整认证头不需要进入教学输出。
AssistantMessage 上的 responseId、responseModel 和 providerThinkingLevel 是 Provider 报告的诊断字段,只在服务提供时存在。
最终验收正常请求、配置错误、中止与服务失败四类路径。示例已完成类型检查与模拟状态验证,本轮未使用真实 Key 联调;真实账号权限、服务限流和网络恢复需要在你的环境验证。