Mastra 内部测试利器 @internal/llm-recorder:LLM 响应录制与回放的完整实践指南
发布时间:2026/9/13 14:40:03 作者:尧图编辑部 阅读量:1,286

Mastra 内部测试利器 internal/llm-recorderLLM 响应录制与回放的完整实践指南【免费下载链接】mastraMastra is the modern TypeScript framework for AI-powered applications and agents.项目地址: https://gitcode.com/GitHub_Trending/ma/mastra在基于大型语言模型LLM的应用测试中最棘手的问题莫过于真实 API 调用既慢又贵还可能因网络抖动或厂商限流导致测试不稳定CI 上跑一次测试成本极高。Mastra 仓库中的internal/llm-recorder包正是为此而生的内部测试基础设施——它像 Vitest 快照snapshot一样工作首次运行时自动录制真实的 LLM API 响应之后的测试则确定性回放这些录制内容让 Agent 与 Workflow 的单元测试在毫秒级完成且完全可复现。读完本文你将掌握四层录制启用方式、五种测试模式、内容哈希匹配原理、SSE 流式回放、二进制音频旁路存储、请求归一化Request Transform以及 API 契约校验等全套实战能力。说明internal/llm-recorder目前是 Mastra 的内部包private: true版本 0.0.68未来会对外发布。本文基于 packages/_llm-recorder/README.md 及同目录源码编写所有用法与行为均以当前仓库实际内容为准。一、核心特性像快照一样录制 LLM 响应internal/llm-recorder的定位是“LLM 响应录制与回放recording and replay”其核心特性如下录制/回放录制真实的 LLM API 响应并在测试中回放无需真实网络基于 MSW使用 Mock Service Worker 实现可靠的 HTTP 拦截见 llm-recorder.ts流式支持捕获并回放 SSE 流式响应且保留 chunk 间的原始时间间隔契约校验在夜间nightly测试中检测 API schema 漂移drift多 Provider支持 OpenAI、Anthropic、GoogleGemini、OpenRouter 四家 API基于内容的匹配请求通过「URL Body 的 MD5 哈希」进行匹配与请求顺序无关。从 llm-recorder.ts 的getLLMTestMode()可以看出它的设计哲学与测试快照完全一致有录制就回放没有就自动录制用户几乎不需要关心底层细节。二、安装作为工作区依赖引入在 Mastra monorepo 中该包通过 pnpm workspace 安装到测试包的devDependencies{ devDependencies: { internal/llm-recorder: workspace:* } }包本身依赖mswHTTP 拦截、diff模糊匹配差异输出、string-similarity字符串相似度计算三个运行时依赖详见 package.json。包同时提供两个导出入口主入口internal/llm-recorder与插件入口internal/llm-recorder/vite-plugin见 package.json 的exports字段。三、四种启用方式从全自动到最手动录制能力有四种启用方式按自动化程度从高到低排列。1. 套件级通过 Vite 插件自动注入推荐在vitest.config.ts中引入llmRecorderPlugin所有匹配的测试文件都会被自动注入录制逻辑无需改动任何测试文件// vitest.config.ts import { defineConfig } from vitest/config; import { llmRecorderPlugin } from internal/llm-recorder/vite-plugin; export default defineConfig({ plugins: [llmRecorderPlugin()], test: { /* ... */ }, });插件的底层实现是 Vite 的transform钩子enforce: pre在构建期把useLLMRecording(...)调用注入到测试文件顶部见 vite-plugin.ts。录制名称会根据测试文件路径自动推导packages/memory/src/index.test.ts→memory-src-indexstores/pg/src/storage.test.ts→pg-src-storage命名逻辑在defaultNameGenerator中实现先相对最近的包根目录识别packages、stores、deployers、voice、server-adapters、client-sdks、auth、observability、pubsub、workflows、e2e-tests等 monorepo 目录模式再去掉.test/.spec后缀与文件扩展名最后把路径分隔符替换为连字符见 vite-plugin.ts。插件支持以下选项llmRecorderPlugin({ include: [src/**/*.test.ts], // 要包含的 glob 模式默认 **/*.test.ts、tsx、js、jsx exclude: [src/**/*.unit.test.ts], // 要排除的 glob 模式默认排除 node_modules、dist nameGenerator: filepath custom, // 自定义录制名称推导函数 recordingsDir: ./__recordings__, // 覆盖录制目录 transformRequest: { // 在匹配哈希前归一化请求详见下文请求变换 importPath: ./test/my-transform, exportName: normalizeRequest, }, });注意插件的默认exclude为[**/node_modules/**, **/dist/**]见 vite-plugin.ts。另外已经手动调用useLLMRecording或enableAutoRecording的文件会被插件自动跳过源码通过code.includes(...)检测见 vite-plugin.ts避免重复注入。2. 单文件级enableAutoRecording()在测试文件顶部导入并调用enableAutoRecording()录制名称同样自动推导import { enableAutoRecording } from internal/llm-recorder; enableAutoRecording(); describe(My Tests, () { it(works, async () { const result await agent.generate(Hello); expect(result.text).toBeDefined(); }); });该函数通过Error.prepareStackTrace检查调用栈来定位测试文件路径见 auto-recording.ts再交给defaultNameGenerator推导名称若无法确定路径则回退到unknown-test。它内部使用 Vitest 的beforeAll/afterAll钩子管理生命周期并支持nameOverride、recordingsDir、forceRecord、replayWithTiming、maxChunkDelay、transformRequest等选项见 auto-recording.ts。3. describe 级useLLMRecording()在describe块内调用所有子测试共享同一份录制import { useLLMRecording } from internal/llm-recorder; describe(My Agent Tests, () { useLLMRecording(my-agent-tests); it(generates text, async () { const response await agent.generate(Hello); expect(response.text).toBeDefined(); }); });useLLMRecording会自动设置beforeAll/afterAll钩子并在每个测试前调用resetFuzzyMatches()重置模糊匹配状态见 llm-recorder.ts。4. 单测级withLLMRecording()将单个测试包进录制作用域回调的返回值会原样透传import { withLLMRecording } from internal/llm-recorder; it(generates a response, () withLLMRecording(my-single-test, async () { const response await agent.generate(Hello); expect(response.text).toBeDefined(); }));实现上它会先暂停已有的父级 MSW 服务器避免端口冲突执行完回调后再恢复父级录制见 llm-recorder.ts。以上所有录制方法都接受transformRequest选项详见第五节。四、五种测试模式环境变量与 CLI 开关模式选择遵循「CLI 标志 环境变量 默认 auto」的优先级# Auto 模式默认- 有录制则回放无录制则录制 pnpm test # 强制重新录制所有录制类似 vitest -u pnpm test -- --update-recordings # 或 UPDATE_RECORDINGStrue pnpm test # 完全跳过录制调试时直连真实 API LLM_TEST_MODElive pnpm test # 严格回放——没有录制则直接失败 LLM_TEST_MODEreplay pnpm test模式选择优先级严格顺序--update-recordings标志或UPDATE_RECORDINGStrue→update强制重录LLM_TEST_MODElive→live不录制LLM_TEST_MODErecord→record旧版别名等价于 updateLLM_TEST_MODEreplay→replay严格模式无录制即失败RECORD_LLMtrue→record旧版兼容默认 →auto有录制回放无录制录制源码中getLLMTestMode()完整实现了这一优先级llm-recorder.ts且支持-U短标志。值得注意的实现细节在setupLLMRecording中update/record模式会删除已存在的录制文件并清空内存中的录制集auto模式按文件是否存在切换到 replay 或 record而replay模式即使找不到录制文件也不会立即报错——因为缺失文件可能意味着该测试本来就没调用 LLM只有当真正发出请求且无匹配录制时才会抛错见 llm-recorder.ts。live模式则完全不启动 MSW 拦截直接放行真实请求见 llm-recorder.ts。此外LLMRecorderOptions还支持mode字段直接在代码中覆盖环境变量以及forceRecord强制录制见 llm-recorder.ts。五、请求匹配原理内容哈希 模糊回退基于内容的 MD5 匹配录制文件采用基于内容的匹配每个请求通过以下两项的 MD5 哈希进行匹配请求 URL请求 Body对象键经过排序以保证一致性哈希取 MD5 前 16 位十六进制字符见hashRequest实现llm-recorder.ts。由于与请求顺序无关因此测试可以乱序执行、并行测试可用、相同的请求可以共享同一份录制。在哈希之前body 会经过normalizeRequestBody对象键被深度排序stableSortKeysISO 日期字符串被规范化canonicalizeISODateString保证序列化内容稳定见 llm-recorder.ts。模糊匹配回退当哈希精确匹配失败时会尝试模糊匹配使用string-similarity计算序列化请求内容的相似度阈值为SIMILARITY_THRESHOLD 0.6见 llm-recorder.ts。匹配策略有三层精确哈希匹配快路径同哈希多条录制如重试产生不同响应按记录顺序依次消费URL 优先候选与当前请求 URL 相同且相似度 ≥ 0.6 时优先命中避免跨 API如/v1/chat/completions与/v1/responses误匹配全局相似度相似度最高的候选且 ≥ 0.6 时命中。命中模糊匹配时会打印 warning 并给出录制内容与当前请求的 JSON diff使用diff库提示你考虑用UPDATE_RECORDINGStrue重录若设置了exactMatch: true模糊匹配会被禁用无精确命中直接抛错见 llm-recorder.ts。二进制请求的特殊匹配对于二进制请求如音频转写body 中带有__binary标记字符串相似度因 multipart boundary 和二进制摘要的随机性而不可靠因此改用body 字节大小相近度匹配容忍度 10%并跟踪已消费的哈希避免多个相似请求都命中同一条录制见 llm-recorder.ts。六、请求变换Request Transform归一化动态字段LLM 请求常含时间戳、UUID、会话 ID 等动态字段这些字段每次运行都会变化但通常不影响回放的响应内容。transformRequest回调在录制决定存储什么和回放决定匹配什么时都会执行确保哈希始终基于归一化后的值计算。在测试代码中使用useLLMRecording(my-tests, { transformRequest: ({ url, body }) ({ url, body: { ...(body as any), timestamp: STABLE, sessionId: STABLE }, }), });支持所有录制方法useLLMRecording、withLLMRecording、setupLLMRecording和enableAutoRecording。通过 Vite 插件使用由于插件在构建期生成代码无法直接接受函数。改为指向一个导出变换函数的模块// test/my-transform.ts export function normalizeRequest({ url, body }: { url: string; body: unknown }) { return { url, body: { ...(body as any), timestamp: STABLE } }; }// vitest.config.ts llmRecorderPlugin({ transformRequest: { importPath: ./test/my-transform, exportName: normalizeRequest, // 省略时默认 transformRequest }, });插件会注入对应的 import 语句相对路径会被解析为相对测试文件的路径并在注入的useLLMRecording调用中接上该变换函数见 vite-plugin.ts。底层实现上transformRequest在回放时还会生成lookupHashes对每条录制额外计算「变换后的哈希」使得新旧两种哈希都能精确匹配实现向后兼容见prepareReplayRecordingsllm-recorder.ts。七、录制存储格式可读 JSON 二进制旁路录制以人类可读的 JSON存储在__recordings__/目录下相对process.cwd()。当请求或响应包含二进制负载例如音频时**字节内容以哈希命名的旁路文件sidecar**直接存放在__recordings__/中JSON 录制仅引用其路径your-package/ ├── __recordings__/ │ ├── my-agent-tests.json │ └── a1b2c3d4-response.wav └── src/ └── tests/二进制元数据content type 与大小保留在 JSON 中原始字节留在产物文件里{ response: { body: { __binary: true, contentType: audio/wav, size: 8192 }, binaryArtifact: { path: a1b2c3d4-response.wav, contentType: audio/wav, size: 8192 } } }这样做既保持录制文件可读又防止大体积二进制数据撑爆 JSON fixture。旁路文件命名规则为hash-request|response-payloadDigest.ext扩展名按 content type 映射为mp3/wav/ogg/webm其他一律为bin见writeBinaryArtifactllm-recorder.ts。读取旁路文件时还会做路径穿越防护拒绝越出录制目录的路径见 llm-recorder.ts。录制文件格式为带元数据的版本化格式{ meta, recordings }旧的纯数组格式会在读取时自动迁移见 llm-recorder.ts。meta包含录制名称、测试文件相对路径、测试名、Provider、模型、创建/更新时间等自描述信息见 llm-recorder.ts。保存时还会按「哈希 响应内容」去重完全相同的请求且响应相同则合并为一条响应不同的重试/变体则全部保留并按记录顺序回放见 llm-recorder.ts。出于安全考虑录制文件会跳过敏感与压缩相关的 header包括authorization、x-api-key、api-key、content-encoding、transfer-encoding、set-cookie以及openai-organization、openai-project等账户元数据见 llm-recorder.ts确保提交到仓库的 fixture 不含密钥信息。八、SSE 流式响应的捕获与回放捕获录制流式响应时captureStreamingResponse逐块读取响应体记录每个 chunk 的内容及相对上一个 chunk 的时间间隔见 llm-recorder.ts。isStreamingResponse依据Content-Type是否为text/event-stream或text/plain判断见 llm-recorder.ts。回放回放时通过ReadableStream按录制顺序逐块输出默认不模拟原始时间间隔以保证测试速度若设置replayWithTiming: true则按记录的间隔上限由maxChunkDelay控制默认 10ms逐个输出 chunk见 llm-recorder.ts。这意味着你的流式 Agent 测试既能验证流式行为又不会因为真实网络延迟而变慢。九、单测级 Live 模式录制套件中的真实 API 验证当整个套件启用录制通过插件或useLLMRecording时可以只让个别测试退出录制、直连真实 API。在describe块内使用useLiveMode()import { useLLMRecording, useLiveMode } from internal/llm-recorder; describe(My Agent Tests, () { useLLMRecording(my-suite); it(replays from recording, async () { // 使用录制响应 const response await agent.generate(Hello); expect(response.text).toBeDefined(); }); describe(real API validation, () { useLiveMode(); it(hits the real API, async () { // 绕过录制调用真实 LLM API const response await agent.generate(Hello); expect(response.text).toBeDefined(); }); }); });useLiveMode()会在作用域内每个测试之前关闭MSW 服务器、之后重启它因此周围的录制对其它测试继续有效见 llm-recorder.ts。实现上它通过模块级activeRecorder单例发现当前活跃的录制器——由于 Vitest 每个测试文件运行在独立 worker 中不存在跨文件污染见 llm-recorder.ts。如果没有活跃录制器例如全局 live 模式它是 no-op。十、支持的 LLM Provider 与拦截范围录制器默认拦截以下 API host定义于LLM_API_HOSTS见 llm-recorder.tsapi.openai.comapi.anthropic.comgenerativelanguage.googleapis.comopenrouter.ai通过LLMRecorderOptions.hosts选项可以收窄拦截范围例如只拦截 OpenAIsetupLLMRecording({ name: openai-only, hosts: [https://api.openai.com], });未拦截的请求走 MSW 的onUnhandledRequest: bypass策略直接放行见 llm-recorder.ts。十一、契约校验Contract Validation检测 API Schema 漂移契约校验用于在夜间测试中提前发现 LLM API 的 schema 漂移——例如 OpenAI 悄悄改变了响应结构。它的原理是比较响应结构类型、字段存在性而非精确值因此对正常响应变化有很强的韧性见 llm-contract.ts。核心 API 如下导出说明validateLLMContract(actual, expected, options?)比较响应 schemavalidateStreamingContract(actual, expected)比较流式 chunk schemaextractSchema(value)从值生成 schemaformatContractResult(result)格式化校验结果用于展示extractSchema递归提取对象/数组/原始类型的结构见 llm-contract.tsvalidateLLMContract比较期望与实际的 schema收集四类差异missing_field缺字段、extra_field多余字段、type_mismatch类型变化、structure_change结构变化见 llm-contract.ts。默认忽略动态字段内置DEFAULT_IGNORE_PATHS包括id、created、created_at、model、system_fingerprint、usage.*、*.index、x-request-id、openai-processing-ms、x-ratelimit-*、cf-*、set-cookie、date、alt-svc等见 llm-contract.ts。校验选项默认值如下ignorePaths: []—— 额外忽略的路径支持*通配如usage.*allowExtraFields: true—— 是否允许实际响应中存在期望 schema 之外的字段allowMissingFields: false—— 是否允许期望字段缺失treatNullAsOptional: true—— 是否将null视为任意类型的合法值。流式契约校验validateStreamingContract会把 chunk 解析为 SSE 事件检查必需事件response.created、response.completed是否存在并对关键事件的 data 做结构校验见 llm-contract.ts。import { validateLLMContract, extractSchema, formatContractResult } from internal/llm-recorder; const result validateLLMContract(actualResponse, expectedResponse); console.log(formatContractResult(result));十二、录制管理辅助 API除录制主流程外包还提供若干管理函数见 llm-recorder.ts导出说明hasLLMRecording(name, dir?)检查录制文件是否存在deleteLLMRecording(name, dir?)删除录制文件listLLMRecordings(dir?)列出所有录制文件getLLMRecordingsDir(filepath?)获取录制目录的绝对路径传入文件路径时返回其所在包根目录下的__recordings__/调试辅助方面LLMRecorderOptions还提供debug选项——开启后会打印请求哈希、模型信息、可用哈希列表与精确/模糊命中结果帮助定位回放未命中的原因见 llm-recorder.ts。十三、性能表现与开发流程各模式的典型耗时与适用场景如下模式典型耗时适用场景Auto100ms回放或 5-30s首次录制默认——开箱即用Update每个测试 5-30s重新录制 fixturesLive每个测试 5-30s用真实 API 调试Replay每个测试 100msCI、严格回放包自身的开发命令见 package.json# 构建 pnpm build # 运行测试无录制则自动录制有录制则回放 pnpm test # 强制重新录制所有 fixtures UPDATE_RECORDINGStrue OPENAI_API_KEYsk-xxx pnpm test其自身的测试配置为 Node 环境、30 秒超时见 vitest.config.ts测试覆盖了 llm-recorder.test.ts、llm-contract.test.ts 与 vite-plugin.test.ts 三个维度可作进一步阅读源码与用例的入口。十四、实践建议与注意事项CI 中使用 strict replay设置LLM_TEST_MODEreplay确保任何未录制的请求都会让测试失败防止 CI 上意外发出真实请求产生费用录制文件提交进仓库__recordings__/下的 JSON 已自动剔除鉴权 header可安全入库它会随代码评审一起被审查异常变更一目了然动态字段务必归一化凡是请求中含时间戳、UUID、会话 ID 的测试都应配置transformRequest否则每次运行哈希都会漂移导致反复重录夜间任务跑契约校验利用validateLLMContract/validateStreamingContract对录制或实时响应做结构比对第一时间发现上游 API 变更音频类测试用二进制旁路TTS/ASR 测试的音频字节存放在 sidecar 文件中JSON 保持轻量匹配则依赖大小相近度与哈希双重机制exactMatch: true适用于对稳定性要求极高的场景禁用模糊匹配后任何请求内容变化都会直接失败适合需要严格回归的场景。需要进一步探索时可以从 llm-recorder.ts、auto-recording.ts、vite-plugin.ts、llm-contract.ts 及其对应测试文件入手Mastra 的packages/_test-utils中也有基于本包封装的测试工具如 llm-helpers.ts 与 llm-mock.ts展示了它在真实 monorepo 测试体系中的落地方式。【免费下载链接】mastraMastra is the modern TypeScript framework for AI-powered applications and agents.项目地址: https://gitcode.com/GitHub_Trending/ma/mastra创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考