Mastra @internal/llm-recorder 深度解析:LLM 响应录制回放与二进制工件(Binary Artifact)支持
【免费下载链接】mastraMastra is the modern TypeScript framework for AI-powered applications and agents.项目地址: https://gitcode.com/GitHub_Trending/ma/mastra
本文基于 Mastra 仓库中 packages/_llm-recorder/CHANGELOG.md 的核心变更记录展开,深入剖析@internal/llm-recorder这一内部测试基础设施的录制/回放机制,重点讲解其针对音频等非 JSON 载荷新增的二进制工件(binary artifact)支持:哈希命名的 sidecar 文件如何存储、如何通过元数据与 JSON 录制文件关联、回放时如何还原原始字节与 Content-Type 头。读完本文,你将掌握该包的四种接入方式、五种测试模式、请求匹配与请求变换策略,以及二进制载荷从录制到回放的全链路原理。
注意:该包当前是 Mastra 仓库的内部包(
private: true),包名为@internal/llm-recorder,README 中说明未来会对外公开。
一、变更记录背后的核心能力:二进制工件支持
CHANGELOG 中从 0.0.8 到 0.0.63 反复出现同一条 Minor Changes 记录(多版本重复是该仓库变更记录生成方式的产物),其描述的正是这个包最值得关注的能力演进:
Added binary artifact support for non-JSON request/response payloads (for example audio) in the LLM recorder. Binary bytes are now written as hash-based sidecar files in
__recordings__/and referenced from JSON recordings with metadata (contentType,size, and artifactpath). Replay restores the original binary payload and content-type headers from artifacts, while keeping JSON fixtures small and readable.
这条记录概括了三层含义:
- 问题:LLM API 的请求/响应并不总是 JSON。音频输入(语音转写)、音频输出(TTS)、图像等二进制载荷无法直接嵌入 JSON 录制文件,否则会产生巨大且不可读的 fixture。
- 方案:二进制字节被写成基于哈希命名的 sidecar 文件,存放在
__recordings__/目录;JSON 录制文件中只保留轻量元数据(contentType、size、工件path),通过引用关系指向 sidecar 文件。 - 效果:回放时从工件文件还原原始二进制载荷与 Content-Type 头,同时保持 JSON fixture 体积小、可读性高。
二、录制文件存储与二进制工件的实际形态
2.1 目录布局
录制文件默认存放在process.cwd()下的__recordings__/目录(可通过recordingsDir选项覆盖)。当请求或响应包含二进制载荷时,sidecar 文件直接存放在该目录中:
your-package/ ├── __recordings__/ │ ├── my-agent-tests.json │ └── a1b2c3d4-response.wav └── src/ └── tests/从 llm-recorder.ts 的writeBinaryArtifact实现可以看到 sidecar 文件的命名规则:
const payloadDigest = crypto.createHash('md5').update(params.bytes).digest('hex').slice(0, 12); const fileName = `${params.hash}-${params.kind}-${payloadDigest}.${ext}`;即{请求哈希}-{request|response}-{载荷内容MD5前12位}.{扩展名},其中扩展名根据 Content-Type 映射为mp3、wav、ogg、webm,无法识别时回退为bin。这种"请求哈希 + 内容哈希"的双重命名既能保证同一次交互的关联性,又能利用内容哈希天然去重——完全相同的字节只落盘一次。
2.2 JSON 中的二进制引用结构
二进制元数据在 JSON 录制文件中以__binary标记,配合binaryArtifact字段描述 sidecar 文件:
{ "response": { "body": { "__binary": true, "contentType": "audio/wav", "size": 8192 }, "binaryArtifact": { "path": "a1b2c3d4-response.wav", "contentType": "audio/wav", "size": 8192 } } }请求侧同样支持二进制工件:parseRequestBody(llm-recorder.ts)会依据 Content-Type 分流——application/json/+json走 JSON 解析,text/走纯文本,其余内容读取原始字节并生成{ __binary: true, contentType, size, digest }占位值,同时把字节交给writeBinaryArtifact落盘。这样即使请求是音频上传(如语音转写),也能被完整录制。
2.3 录制文件的自描述格式
录制文件采用带版本信息的{ meta, recordings }顶层结构(RecordingFile,见 llm-recorder.ts),meta包含:
name:录制名称(与文件名同名)testFile:生成该录制的测试文件相对路径testName:测试名称(尽力而为)provider:提供商 ID(如openai、anthropic)model:模型 ID(如gpt-4o)createdAt/updatedAt:创建与更新时间戳
model会在录制时从请求体自动推断,provider/model也可通过metaContext显式提供。旧版纯数组格式的录制文件在读取时会被自动迁移(loadRecordingFile),保证向后兼容。
三、二进制回放:字节级还原
回放时,readBinaryArtifact(llm-recorder.ts)会做一次路径穿越防护——校验解析后的绝对路径必须位于 recordingsDir 内,然后读取原始字节,并通过HttpResponse携带录制时保存的 headers(含 Content-Type)原样返回给测试代码:
if (recording.response.binaryArtifact) { return new HttpResponse(readBinaryArtifact(recordingsDir, recording.response.binaryArtifact), { status: recording.response.status, statusText: recording.response.statusText, headers: recording.response.headers, }); }也就是说,测试端拿到的响应与真实 API 返回的字节序列、状态码和响应头完全一致。这一点在 llm-recorder.test.ts 的binary-response-artifact测试中有端到端验证:录制阶段 mock 一个audio/wav的字节响应,断言 JSON 中binaryArtifact存在、sidecar 文件落盘;切换到 replay 模式后,断言content-type仍是audio/wav且replayBytes与原始payload逐字节相等。
四、二进制请求的匹配策略
录制/回放的核心是"请求匹配"。普通 JSON 请求按URL + body 的 MD5 哈希匹配(body 会先做对象键深度排序与 ISO 日期归一化以保证确定性,见stableSortKeys/canonicalizeISODateString)。但二进制请求序列化后的字符串主要是随机的 multipart boundary 和二进制摘要,字符串相似度匹配完全失效。
因此findRecording(llm-recorder.ts)对__binary请求走载荷大小近似匹配:在 URL 相同的前提下,计算候选录制与当前请求的字节数差值,取差值最小者;若相对差值超过 10% 则拒绝(同一 TTS 输出跨运行通常只差几个字节,10% 的容差已足够宽松),并通过usedHashes集合避免多个相近的二进制请求(如多次音频转写)全部命中同一条录制。
五、四种接入方式:从全自动到最手动
CHANGELOG 之外,包的 README.md 给出了从自动化到手动四种启用方式,适用不同粒度:
5.1 Suite 级:Vite 插件(推荐)
在vitest.config.ts中注册,所有匹配的测试文件自动注入录制逻辑,无需改动测试代码:
import { defineConfig } from 'vitest/config'; import { llmRecorderPlugin } from '@internal/llm-recorder/vite-plugin'; export default defineConfig({ plugins: [llmRecorderPlugin()], test: { /* ... */ }, });插件选项:
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', // 导出名(默认 'transformRequest') }, });插件在构建期改写测试文件,注入__autoUseLLMRecording(...)调用;已经手动调用useLLMRecording或enableAutoRecording的文件会被跳过,避免重复注入。由于插件在构建期生成代码,transformRequest不能直接传函数,只能以"模块路径 + 导出名"的方式引用(相对路径会自动换算为从测试文件到变换模块的路径)。
录制名由文件路径自动推导(defaultNameGenerator,见 vite-plugin.ts):
packages/memory/src/index.test.ts→memory-src-indexstores/pg/src/storage.test.ts→pg-src-storage
5.2 文件级:enableAutoRecording()
在测试文件顶部直接调用,通过调用栈自动定位当前测试文件并推导录制名:
import { enableAutoRecording } from '@internal/llm-recorder'; enableAutoRecording(); // 也可传 { nameOverride: 'my-custom-name' } 指定名称 describe('My Tests', () => { it('works', async () => { const result = await agent.generate('Hello'); expect(result.text).toBeDefined(); }); });5.3 describe 级:useLLMRecording()
在describe块内使用,自动挂载beforeAll/beforeEach/afterAll钩子完成 server 启停与保存:
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(); }); });5.4 单测级:withLLMRecording()
把单个测试包进录制作用域,回调的返回值原样透传;若外层已有 suite 级录制,会自动暂停外层 server 避免冲突,结束后恢复:
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(); }));六、五种测试模式:类快照的录制/回放工作流
与 Vitest 快照的体验一致——首次运行自动录制,之后确定性回放。模式由 CLI 标志或环境变量控制:
# Auto 模式(默认)—— 有录制则回放,无录制则录制 pnpm test # 强制全部重录(等价于快照的 vitest -u) pnpm test -- --update-recordings # 或 UPDATE_RECORDINGS=true pnpm test # 完全跳过录制(用真实 API 调试) LLM_TEST_MODE=live pnpm test # 严格回放 —— 无录制则失败 LLM_TEST_MODE=replay pnpm test模式选择优先级(getLLMTestMode,见 llm-recorder.ts):
--update-recordings/-U标志或UPDATE_RECORDINGS=true→ update(强制重录)LLM_TEST_MODE=live→ live(不录制)LLM_TEST_MODE=record→ record(旧别名,等价 update)LLM_TEST_MODE=replay→ replay(严格回放,缺失即失败)RECORD_LLM=true→ record(旧环境变量)- 默认 →auto(有录制回放、无录制录制)
注意 Auto 模式在启动时一次性判定:存在录制文件则整个 suite 走 replay,不存在则整场录制并落盘。严格 replay 模式下即使录制文件缺失也不会立即报错(文件缺失可能只是该测试没发 LLM 请求),真正发起请求且找不到匹配时才会抛错并提示Run with UPDATE_RECORDINGS=true to re-record。update/record 模式还会在录制前删除旧文件并从零开始,损坏的录制文件不会阻塞重录。
七、请求变换:让动态字段不影响匹配
transformRequest回调接收{ url, body }并返回归一化后的{ url, body },在录制和回放两侧都会执行,因此哈希总是基于归一化值计算。适用于请求中带时间戳、UUID、会话 ID 等跨运行变化的动态字段、但不影响要回放响应的场景。
测试代码内写法(四种录制方法都支持):
useLLMRecording('my-tests', { transformRequest: ({ url, body }) => ({ url, body: { ...(body as any), timestamp: 'STABLE', sessionId: 'STABLE' }, }), });回放侧还会把"归一化前的哈希"与"归一化后的哈希"同时作为lookupHashes参与精确匹配(prepareReplayRecordings),进一步提升兼容性。
八、测试粒度内的 Live 模式
suite 已启用录制时,可用useLiveMode()让特定describe内的测试走真实 API——它在作用域内每个测试前关闭 MSW server、测试后重启,互不干扰:
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 () => { const response = await agent.generate('Hello'); // 走真实 API expect(response.text).toBeDefined(); }); }); });若当前没有活动的 recorder(例如全局已是 live 模式),useLiveMode()是空操作。
九、匹配细节:精确匹配、模糊回退与流式重放
9.1 精确匹配与多响应消费
同一哈希可能对应多条录制(同一请求因重试产生不同响应、或归一化后哈希碰撞的不同测试变体)。findRecording会按录制顺序依次消费这些精确匹配,全部消费完后持续返回最后一条,保证"请求-响应链"按真实发生顺序逐条还原(exactReplayCounts在测试文件生命周期内刻意不重置)。
9.2 模糊匹配回退
没有精确命中时,对序列化后的请求内容计算 Dice 相似度(string-similarity库),阈值SIMILARITY_THRESHOLD = 0.6,且优先选择 URL 相同的候选,避免跨 API(如/v1/chat/completions与/v1/responses)错配。模糊命中会打印警告及 JSON diff(基于diff库)供排查,并提示可重录;开启exactMatch选项后模糊匹配会被拒绝并直接抛错。注意:非二进制录制刻意不消耗,可跨测试复用(例如 v1/v2 模型变体共享一条录制);只有二进制模糊匹配受usedHashes约束。
9.3 SSE 流式捕获与重放
流式响应(text/event-stream/text/plain)在录制时逐 chunk 读取并记录每块的时间间隔(captureStreamingResponse);回放时通过ReadableStream按原顺序推送 chunk,replayWithTiming: true时模拟原始间隔(受maxChunkDelay上限钳制,默认 10ms),默认关闭以保证测试速度。
十、契约校验:捕捉上游 API 漂移
包内还提供响应结构契约校验(用于夜间测试发现 API schema 漂移,llm-contract.ts):
extractSchema(value):从值生成 schema 树(对象/数组/基础类型)validateLLMContract(actual, expected, options?):比较"结构"而非具体值——类型、字段有无、数组项结构validateStreamingContract(actualChunks, expectedChunks):解析 SSE 事件序列,校验response.created、response.completed等关键事件是否存在及其数据结构formatContractResult(result):格式化差异输出
默认忽略id、created、model、usage.*、x-request-id、x-ratelimit-*、cf-*等动态/敏感路径(DEFAULT_IGNORE_PATHS),可通过ignorePaths扩展;默认allowExtraFields: true、allowMissingFields: false、treatNullAsOptional: true。
十一、支持的提供商与性能预期
MSW 拦截的默认主机(LLM_API_HOSTS,可用hosts选项收窄):
api.openai.comapi.anthropic.comgenerativelanguage.googleapis.comopenrouter.ai
录制时会跳过authorization、x-api-key、api-key、content-encoding、set-cookie、openai-organization等敏感/压缩头(SKIP_HEADERS),避免凭据与账户元数据进入提交的 fixture。
| 模式 | 典型耗时 | 适用场景 |
|---|---|---|
| Auto | 回放 <100ms / 首录 5-30s | 默认——开箱即用 |
| Update | 每个测试 5-30s | 重录 fixture |
| Live | 每个测试 5-30s | 真实 API 调试 |
| Replay | 每个测试 <100ms | CI、严格回放 |
十二、API 一览
| 导出 | 说明 |
|---|---|
useLLMRecording(name, options?) | Vitest 助手——自动挂载beforeAll/afterAll钩子 |
useLiveMode() | 在已录制的 suite 中让指定测试走真实 API |
withLLMRecording(name, fn, options?) | 单测试录制回调包装 |
setupLLMRecording(options) | 底层手动设置 API |
enableAutoRecording(options?) | 文件级自动录制 |
getActiveRecorder() | 返回当前活动 recorder 实例 |
getLLMTestMode() | 返回当前模式:'auto' \| 'update' \| 'replay' \| 'live' \| 'record' |
hasLLMRecording(name, dir?) | 检查录制文件是否存在 |
deleteLLMRecording(name, dir?) | 删除录制文件 |
listLLMRecordings(dir?) | 列出所有录制 |
getLLMRecordingsDir(dir?) | 获取录制目录绝对路径 |
validateLLMContract(actual, expected, options?) | 比较响应结构 |
validateStreamingContract(actual, expected) | 比较流式 chunk 结构 |
extractSchema(value) | 从值生成 schema |
formatContractResult(result) | 格式化校验结果 |
llmRecorderPlugin(options?) | Vite 自动注入插件 |
defaultNameGenerator(filepath) | 默认录制名推导 |
十三、本地开发与重录实践
# 构建 pnpm build # 跑测试(无录制自动录制,有录制则回放) pnpm test # 强制重录全部 fixture UPDATE_RECORDINGS=true OPENAI_API_KEY=sk-xxx pnpm test包内测试本身即最佳示例:pnpm test会先录制再回放,覆盖了二进制工件落盘/还原、旧格式迁移、哈希匹配等关键路径(见 llm-recorder.test.ts)。依赖方面,该包基于msw(HTTP 拦截)、diff(差异输出)、string-similarity(模糊匹配)实现,入口统一从 index.ts 导出。
结语
@internal/llm-recorder用"类快照"的录制/回放模型解决了 LLM 测试中最棘手的确定性问题:MSW 拦截真实 HTTP 流量、MD5 内容匹配保证并行与乱序安全、transformRequest归一化动态字段、模糊回退容忍轻微请求漂移,而二进制工件支持则把音频等非 JSON 载荷安全地收纳进 sidecar 文件,既保字节级回放 fidelity,又让 JSON fixture 保持小巧可读。结合契约校验能力,它构成了 Mastra 仓库 LLM 相关测试从录制、回放到防漂移的完整闭环。
【免费下载链接】mastraMastra is the modern TypeScript framework for AI-powered applications and agents.项目地址: https://gitcode.com/GitHub_Trending/ma/mastra
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考