怎么不调用真实 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。