怎么不调用真实 API 测试 Effect AI 的调用?

测 LLM 调用最容易走偏的两条路:真的去调 API,慢、贵、结果还不确定;或者把整个 LanguageModel 换成一个直接返回固定值的假对象,那样解码、错误映射、span 这些真正会出错的部分全被绕过去了。

用 LanguageModel.make 只替换 provider 那一层:给它 generateText 和 streamText,它自己负责 prompt 处理、响应解码、结构化输出校验和 span,所以替身只需要回放一段脚本。

下面是替身的完整写法,然后是三个用它加 TestClock 写的测试。这里的 LanguageModel、AiError 来自 effect/ai(effect 4.0.5)。

替身要写成什么样?

// stub.ts
import { Duration, Effect, Layer, Ref, Stream } from "effect"
import { AiError, LanguageModel, type Response } from "effect/ai"

export type Step = Effect.Effect<Array<Response.PartEncoded>, AiError.AiError>

export const done = (text: string): Step =>
  Effect.succeed<Array<Response.PartEncoded>>([
    { type: "text", text },
    {
      type: "finish",
      reason: "stop",
      usage: {
        inputTokens: { uncached: 10, total: 10, cacheRead: 0, cacheWrite: 0 },
        outputTokens: { total: 5, text: 5, reasoning: 0 }
      }
    }
  ])

export const fail = (reason: AiError.AiErrorReason) =>
  Effect.fail(AiError.make({ module: "Stub", method: "generateText", reason }))

export const rateLimited = (retryAfter: Duration.Input) =>
  fail(new AiError.RateLimitError({ retryAfter: Duration.fromInputUnsafe(retryAfter) }))

export const scripted = (steps: ReadonlyArray<Step>, calls: Ref.Ref<number>) =>
  Layer.effect(
    LanguageModel.LanguageModel,
    LanguageModel.make({
      generateText: () =>
        Effect.gen(function* () {
          const i = yield* Ref.getAndUpdate(calls, (n) => n + 1)
          return yield* steps[Math.min(i, steps.length - 1)]!
        }),
      streamText: () => Stream.empty
    })
  )

calls 这个 Ref 用来断言重试了几次。脚本走到最后一步后会一直重复最后一步,所以 [done("not json")] 就表示「永远返回坏输出」。

替身返回的是 PartEncoded,也就是 provider 的原始形态,所以 generateObject 里的 JSON 解析与 schema 校验是真实走过的,不是被绕开的。

怎么让重试测试不真等?

用 TestClock 把时间变成确定的。下面三个测试分别验证:retryAfter 被当成延迟下限、不可重试的错误只调用一次、持续返回坏输出时用尽四次尝试后变成 AiBadOutput。被测的 summarize 是一个带重试、超时和错误映射的函数,写法见 LLM 调用的重试和超时,顺序该怎么排? 与 Effect AI 的 AiError 怎么映射成领域错误?。

// summarize.test.ts
import { it, expect } from "@effect/vitest"
import { Effect, Fiber, Ref } from "effect"
import { TestClock } from "effect/testing"
import { AiError } from "effect/ai"
import { summarize } from "./summarize.ts"
import { done, fail, rateLimited, scripted } from "./stub.ts"

const good = done(JSON.stringify({ title: "t", bullets: ["a"], sentiment: "neutral" }))

it.effect("retryAfter 是延迟下限", () =>
  Effect.gen(function* () {
    const calls = yield* Ref.make(0)
    const fiber = yield* summarize("hello").pipe(
      Effect.provide(scripted([rateLimited("5 seconds"), good], calls)),
      Effect.forkChild
    )
    yield* TestClock.adjust("4900 millis")
    expect(yield* Ref.get(calls)).toBe(1)
    yield* TestClock.adjust("100 millis")
    expect((yield* Fiber.join(fiber)).title).toBe("t")
    expect(yield* Ref.get(calls)).toBe(2)
  })
)

it.effect("鉴权失败不重试", () =>
  Effect.gen(function* () {
    const calls = yield* Ref.make(0)
    const error = yield* summarize("hello").pipe(
      Effect.provide(scripted([fail(new AiError.AuthenticationError({ kind: "InvalidKey" }))], calls)),
      Effect.flip
    )
    expect(error._tag).toBe("AiMisconfigured")
    expect(yield* Ref.get(calls)).toBe(1)
  })
)

it.effect("坏输出用尽重试后变成 AiBadOutput", () =>
  Effect.gen(function* () {
    const calls = yield* Ref.make(0)
    const fiber = yield* summarize("hello").pipe(
      Effect.provide(scripted([done("not json")], calls)),
      Effect.flip,
      Effect.forkChild
    )
    yield* TestClock.adjust("1 minute")
    expect((yield* Fiber.join(fiber))._tag).toBe("AiBadOutput")
    expect(yield* Ref.get(calls)).toBe(4)
  })
)

第一个测试的关键是先拨 4900 毫秒,断言只调用过一次,再拨 100 毫秒,才看到第二次调用:这说明 5 秒的 retryAfter 确实成了延迟下限。

还能用同样的方式测什么?

同样的替身还验证了另外几件事:持续挂起的调用会在每次 20 秒后超时,四次尝试之后变成 AiUnavailable;InvalidUserInputError 这类调用方 bug 会变成 defect 而不是失败。TestClock 的一般用法见 Effect 的超时和重试怎么测,才不用真等?,运行时边界与测试的整体做法见 Effect 运行时边界与测试:只在入口 run,用 TestClock 测时间。

版本是 effect 4.0.5。替身和上面三个测试都在 strict 与 exactOptionalPropertyTypes 下编译通过,并用 @effect/vitest 实际跑过;挂起超时与 defect 那两条也是用同一个替身跑过的,但没有在这里贴出代码。summarize 的实现不在本文里,示例依赖它的行为,照抄前请先按链接里的写法实现。全程没有调用真实的 LLM API。