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

排查模型连接与调用失败

按 ModelsError 的六个错误码定位问题,用 checkAuth 区分"没配凭据"与"没有这个模型",为调用增加超时与清晰的失败出口。

排查模型连接与调用失败

模型调用失败时,先确认错误发生在哪一层。模型目录里找不到 ID、服务拒绝 Key、接口路径错误,以及流开始后中断,需要不同的排查动作。

本篇沿用模型配置的 connection.ts。目标是让程序清晰报告未完成状态,避免用盲目重试掩盖配置问题。

错误码是排障的第一把钥匙

模型层抛出的错误统一是 ModelsError。它的定义在 packages/ai/src/utils/models-error.ts:

typescript
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;
	}
}

六个码各自指向一层问题:

code层次典型消息先查什么
provider
Provider 本身
Unknown provider: x、Provider p does not support deferred responses
Provider 是否注册、能力是否实现
auth
凭据解析与存储
Provider is not configured: anthropic、Credential store read failed for p
Key 是否配置、凭据存储是否可读
oauth
OAuth 令牌刷新
OAuth refresh failed for p、OAuth refresh returned a token that expires too soon for p
重新登录
stream
流建立阶段
Provider p has no API implementation for "xxx"
协议选错、映射漏配
model_source
模型目录来源
Model refresh failed for p
远端目录可达性
model_validation
模型数据合法性
目录条目字段不合法
目录生成流程

ModelsError 的一个设计细节:底层原因会被拼进 message。

typescript
/** 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() 能帮助检查前者,但不能代替一次真实请求。

三种"模型不可用"要分清

"找不到模型"其实有三种完全不同的原因,修复动作也完全不同:

现象判定方法原因修复
目录里没这个模型
models.getModel(p, id) 返回 undefined
模型 ID 拼错,或当前包版本的目录不含它
核对 ID 与目录
Provider 没注册
models.getProvider(p) 返回 undefined
setProvider() 没调用,或 id 拼错
先注册 Provider
有但没权限
getModel() 有值,checkAuth() 返回 undefined
凭据没配置或已失效
配置凭据

这段代码把三者分开报告:

typescript
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:

typescript
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
}

运行:

bash
node --env-file=.env --import tsx src/handle-errors.ts

模型集合中的认证或网络失败可能作为错误消息返回,因此代码同时处理返回状态和抛出的异常。不能只写 try/catch,然后把所有正常返回都打印成"完成"。

15 秒是本例设置的整体请求上限。maxRetryDelayMs 约束可接受的重试等待,不等于"只重试两秒",也不是统一的重试次数设置。底层适配器和 SDK 可能已有重试行为,先检查实际实现再叠加应用重试。

按错误码分流处理

要写一个能同时覆盖配置错误和请求错误的入口,就需要在 catch 里按 code 分流:

typescript
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 直接导出,不用深路径导入:

typescript
export { ModelsError, type ModelsErrorCode } from "./auth/resolve.ts";

OAuth 失败要单独处理

oauth 码有独立语义。packages/ai/src/auth/resolve.ts 的注释说明了两种失败的差别:

typescript
/**
 * 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 码,直接提示用户重新登录,而不是清空凭据重试。

刷新过程本身有一层保护:已经开始的刷新不会因为取消而中断。源码注释:

typescript
// 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():

typescript
const credential = await models.login("anthropic", "oauth", {
  prompt: async (p) => {
    // 返回用户输入的字符串
    return askUser(p);
  },
  notify: (event) => {
    console.error(event.type, event.message ?? "");
  },
});

登出用 models.logout(providerId)。

用现象缩小范围

现象检查顺序
本地模型不存在
Provider 注册、模型 ID、依赖目录版本
未解析到凭据
环境文件、变量名称、凭据来源
服务拒绝认证
Key 所属服务、有效性和账号权限
404 或接口路径不存在
基础地址、协议、是否重复追加路径
请求字段不被接受
协议选错或兼容字段不受支持
429 等限流提示
服务配额、并发量、返回的等待建议
超时或网络错误
DNS、代理、连接建立、服务响应阶段
有部分文字后失败
流式错误与最终状态,不能只检查屏幕文字

服务可能把多种问题合并成同一状态码。表格是排查顺序,不是一条报错唯一对应一种原因的分类器。

传输层失败有诊断信息

WebSocket 传输失败会留下结构化诊断,挂在 AssistantMessage.diagnostics 上:

typescript
const diagnostic = {
  configuredTransport: "auto",
  fallbackTransport: "sse",       // 仅流开始前失败时出现
  eventsEmitted: false,
  phase: "before_message_stream_start",
  requestBytes: 1234,
};

phase 有两个取值,决定了能不能自动恢复:

phase含义后续行为
before_message_stream_start
消息流开始前失败
自动落回 SSE 重试
after_message_stream_start
消息流开始后失败
不降级,直接抛错

流开始后不降级的原因:部分内容已经发出去了,换一条线重放会重复。诊断信息是脱敏后的,可以安全记录。

什么时候重试

短暂网络问题、部分限流或服务故障可以在预算内重试;无效凭据、错误模型名和不兼容字段应先修配置。用户主动取消后不能再自动发起同一任务。

需要应用级重试时,先明确已经显示的片段如何处理、是否发生工具副作用、总时间如何限制,以及底层已经重试了多少次。本篇不添加一个外层无限循环,也不在失败后擅自把内容发送给另一家服务。

工具失败属于另一层:模型请求成功并要求调用工具,但执行器无法完成动作。应回填明确工具错误,不必重新开始所有模型请求。

保留足够的证据

记录 Provider、模型 ID、协议、结束状态、耗时与脱敏后的错误原因。请求 ID 如果服务提供可以一并保留;不要默认所有 Provider 都返回同一字段。

调试时检查载荷有助于定位消息格式问题,但原始消息可能含代码和用户材料,日志范围应该服务于当前问题。密钥和完整认证头不需要进入教学输出。

AssistantMessage 上的 responseId、responseModel 和 providerThinkingLevel 是 Provider 报告的诊断字段,只在服务提供时存在。

最终验收正常请求、配置错误、中止与服务失败四类路径。示例已完成类型检查与模拟状态验证,本轮未使用真实 Key 联调;真实账号权限、服务限流和网络恢复需要在你的环境验证。

参考资料