Effect AI 的 AiError 怎么映射成领域错误?

LanguageModel.generateText 失败时,错误通道里是 AiError,你拿到它的第一反应往往是 error.message 打日志。但调用方真正想知道的是:这个错值不值得重试,是我的配置错了,还是模型拒绝了这段内容。

AiError 本身只带 module、method 和一个 reason,所有信息都在 reason._tag 这个联合里;把它映射成自己的领域错误,最稳的写法是一个没有 default 分支的 switch。

原因有两个:十八种 reason 的分类是你的业务判断,不是库能替你做的;而没有 default,provider 以后新增一种 reason,这里在编译期就会报错,不会在凌晨三点落进兜底分支。下面先看 reason 里库已经替你回答了什么,再看 switch 怎么写。

AiError 里到底有什么?

reason 是一个带 _tag 的联合:RateLimitError、QuotaExhaustedError、AuthenticationError、ContentPolicyError、NetworkError、StructuredOutputError 等共十八种。

每个 reason 都有一个 isRetryable,限流和 5xx 是 true,鉴权失败和额度耗尽是 false;AiError 自己也转发这个属性,还提供 retryAfter。这是库给出的事实,我不会在自己的代码里再维护一张「哪些错误可重试」的表。

映射到领域错误则是我自己的事实。我先定了四种领域错误,名字回答的是「调用方该怎么办」:

import { Data, Duration } from "effect"

export class AiUnavailable extends Data.TaggedError("AiUnavailable")<{
  readonly retryAfter: Duration.Duration | undefined
}> {}
export class AiRefused extends Data.TaggedError("AiRefused")<{ readonly detail: string }> {}
export class AiBadOutput extends Data.TaggedError("AiBadOutput")<{ readonly detail: string }> {}
export class AiMisconfigured extends Data.TaggedError("AiMisconfigured")<{ readonly detail: string }> {}

暂时不可用就稍后再来,被拒绝就换内容,输出不合格就降级,配置错了就该有人被叫醒。这几个类的写法依赖 Data.TaggedError,背景见 Effect 类型化错误:TaggedError、catchTag 与 failure/defect 之分。

怎么写这个没有 default 的 switch?

import { Effect } from "effect"
import { AiError } from "effect/ai"

const toDomain = (
  error: AiError.AiError
): Effect.Effect<never, AiUnavailable | AiRefused | AiBadOutput | AiMisconfigured> => {
  const reason = error.reason
  switch (reason._tag) {
    case "RateLimitError":
      return Effect.fail(new AiUnavailable({ retryAfter: reason.retryAfter }))
    case "InternalProviderError":
    case "NetworkError":
      return Effect.fail(new AiUnavailable({ retryAfter: undefined }))
    case "ContentPolicyError":
      return Effect.fail(new AiRefused({ detail: reason.description }))
    case "InvalidOutputError":
    case "StructuredOutputError":
      return Effect.fail(new AiBadOutput({ detail: reason.description }))
    case "QuotaExhaustedError":
    case "AuthenticationError":
    case "InvalidRequestError":
    case "UnsupportedSchemaError":
      return Effect.fail(new AiMisconfigured({ detail: reason.message }))
    case "UnknownError":
    case "ToolNotFoundError":
    case "ToolParameterValidationError":
    case "InvalidToolResultError":
    case "ToolResultEncodingError":
    case "ToolConfigurationError":
    case "ToolkitRequiredError":
    case "InvalidUserInputError":
      return Effect.die(error)
  }
}

用的时候接在调用后面:Effect.catchTag("AiError", toDomain)。

为什么故意不写 default?

我试过删掉其中一个 case,编译器立刻报 TS2366(函数缺少结尾 return)。这就是想要的效果:以后 provider 新增一种 reason,这里在编译期就会炸,而不是静默落进兜底分支。

哪些 reason 该变成 defect,而不是失败?

最后一组我选择 Effect.die:调用方式写错、toolkit 配置错,是程序 bug,不是可以恢复的失败。InvalidUserInputError 这类调用方 bug,我用本地替身验证过,确实会变成 defect 而不是失败。failure 与 defect 的划分,见 Effect 类型化错误:TaggedError、catchTag 与 failure/defect 之分;底层错误该在哪一层翻译,见 Effect 里底层错误该在哪里翻译成领域错误?。

只想处理其中一种 reason,有更短的写法吗?

有。如果你只想单独处理某一种 reason,而保留其余错误,4.0.5 提供了 Effect.catchReason("AiError", "RateLimitError", handler),handler 拿到的是已经解开的 reason。这种「父错误加嵌套 reason」的两级写法,见 Effect catchReason:处理「父错误 + 嵌套 reason」的两级错误。

映射之后,哪些领域错误值得重试、延迟怎么算,是下一个问题:LLM 调用的重试和超时,顺序该怎么排?

版本是 effect 4.0.5。映射函数在 strict 与 exactOptionalPropertyTypes 下编译通过,删掉一个 case 报 TS2366 是我实际试过的;InvalidUserInputError 变成 defect 是用本地替身跑过的。Effect.catchReason 在 4.0.5 里存在,但我没有在这里单独运行它,用之前请自己确认签名。全程没有调用真实的 LLM API。