十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Mastra 内部测试工具 @internal/test-utils:LLM 集成测试的版本无关封装、API Key 管理与 Provider 级 Mock 实战指南

Mastra 内部测试工具 @internal/test-utils:LLM 集成测试的版本无关封装、API Key 管理与 Provider 级 Mock 实战指南 Mastra 内部测试工具 internal/test-utilsLLM 集成测试的版本无关封装、API Key 管理与 Provider 级 Mock 实战指南【免费下载链接】mastraMastra is the modern TypeScript framework for AI-powered applications and agents.项目地址: https://gitcode.com/GitHub_Trending/ma/mastrainternal/test-utils是 Mastra 仓库内部的测试辅助包位于 packages/_test-utils/README.md为所有依赖真实 LLM 调用的集成测试提供三件核心武器同时兼容 AI SDK v4 与 v5 的版本无关 Agent 封装、replay 模式下的占位 API Key 管理以及基于internal/llm-recorder的 Provider 级 LLM Mock。读完本文你将掌握如何在 Mastra 各包中编写既能本地 replay 离线跑、又能一键record真实采集、还能按 Provider 单独打桩的稳定集成测试。注意这是一个内部包private: true不面向外部消费。若需要录制/回放 LLM 请求官方推荐的是internal/llm-recorder——本包中的 Mock 正是构建在它之上的高层封装。安装与接入作为 workspace 内部依赖只需要在目标包的devDependencies中声明{ devDependencies: { internal/test-utils: workspace:* } }包入口由 src/index.ts 统一导出./llm-helpersAgent 封装与 Key 管理和./llm-mockLLM Mock 工厂。除此之外包还通过独立子路径internal/test-utils/setup提供一个Vitest setup 文件见 src/setup.ts建议在 vitest 配置中启用// vitest.config.ts export default defineConfig({ test: { setupFiles: [internal/test-utils/setup], }, });这个 setup 文件做了两件对确定性测试至关重要的事确定性 UUID通过AsyncLocalStorage为每个测试用例维护独立计数器把crypto.randomUUID()以及node:crypto/crypto模块导入的randomUUID统一替换为00000000-0000-4000-8000-xxxxxxxxxxxx形式的递增 UUID避免时间戳/随机值污染录制请求的 hash。静默 Mastra 日志通过vi.mock(mastra/core)与vi.mock(mastra/core/mastra)包装Mastra类默认注入logger: false让测试输出保持干净。个别需要日志的用例仍可显式传入new ConsoleLogger({ name: test })。版本无关的 Agent 调用封装Mastra 同时需要支持 AI SDK v4走generateLegacy/streamLegacy与 v5走generate/stream且记忆参数从threadId/resourceId演进为memory: { thread, resource }。为避免每个测试文件都写一堆版本分支internal/test-utils提供了四个 helper实现见 src/llm-helpers.tsimport { agentGenerate, agentStream, isV5PlusModel, getModelRecordingName } from internal/test-utils;agentGenerate / agentStream一个调用双版本兼容agentGenerate会根据模型版本自动调用agent.generate()v5或agent.generateLegacy()v4并完成两类参数迁移将threadId/resourceId转换为 v5 的memory: { thread, resource }格式将 v4 的output结构化输出 schema映射为 v5 的structuredOutput: { schema }。const result await agentGenerate(agent, Hello, { threadId, resourceId }, model); // 结构化输出v5 走 structuredOutputv4 走 output const result await agentGenerate(agent, Extract data, { threadId, output: mySchema }, model);agentStream与agentGenerate使用完全相同的转换逻辑只是调用stream()/streamLegacy()const stream await agentStream(agent, Count to 5, { threadId }, model);从源码看src/llm-helpers.ts转换发生在isV5PlusModel(model)分支内先解构剥离threadId、resourceId、output再在 v5 路径上重建memory与structuredOutput。值得注意的一个实现细节如果调用方已经显式传了structuredOutput转换不会覆盖它if (output !transformedOptions.structuredOutput)这保证了显式配置的优先级。isV5PlusModel模型版本判定isV5PlusModel(openai/gpt-4o); // true字符串模型一律按 v5 处理 isV5PlusModel({ specificationVersion: v2 }); // true isV5PlusModel({ specificationVersion: v1 }); // false isV5PlusModel({ modelId: gpt-4o }); // false无 specificationVersion 视为 v4判定规则与 src/llm-helpers.test.ts 中的用例一一对应字符串模型恒为 true带specificationVersion的对象只有v1视为 v4v2/v3/v4均视为 v5既非字符串又无specificationVersion的对象返回 false。getModelRecordingName模型 → 录制文件名把模型配置转换成可安全用作录制文件名的字符串getModelRecordingName(openai/gpt-4o-mini); // openai-gpt-4o-mini getModelRecordingName({ modelId: gpt-4o }); // gpt-4o getModelRecordingName({ specificationVersion: v2 }); // sdk-v2 getModelRecordingName({ foo: bar }); // unknown-model源码中的归一化规则是字符串模型先把/换成-再剔除所有非[a-zA-Z0-9-]字符带modelId的 SDK 模型只做字符清洗带specificationVersion的生成sdk-版本前缀都无法识别时回退为unknown-model。API Key 管理replay 模式下用占位 Key 通过校验集成测试在 replay 模式下不需要真实 Key——HTTP 请求已被 Mock 拦截。但 Mastra 的Agent类在发起请求前会校验 Key 是否存在因此需要setupDummyApiKeys塞入占位 Key 来通过校验。setupDummyApiKeys(mode, providers?)import { setupDummyApiKeys } from internal/test-utils; import { getLLMTestMode } from internal/llm-recorder; setupDummyApiKeys(getLLMTestMode()); // 默认覆盖全部 Provider setupDummyApiKeys(getLLMTestMode(), [openai]); // 只覆盖 OpenAI setupDummyApiKeys(live); // live/record/update 模式下是 no-op关键行为源码见 src/llm-helpers.ts模式过滤mode为live、record、update时直接返回这些模式必须用真实 Keyreplay与auto模式下才注入占位 Key。不覆盖真实 Key只有环境变量未设置时才写入占位值if (!process.env[envVar])。Google 双变量GOOGLE_GENERATIVE_AI_API_KEY与GOOGLE_API_KEY任一未设置都会被填充。占位值与环境变量映射如下Provider环境变量占位值openaiOPENAI_API_KEYsk-dummy-for-replay-modeanthropicANTHROPIC_API_KEYsk-ant-dummy-for-replay-modegoogleGOOGLE_GENERATIVE_AI_API_KEY/GOOGLE_API_KEYdummy-google-key-for-replay-modeopenrouterOPENROUTER_API_KEYsk-or-dummy-for-replay-modehasApiKey(provider) 与配套的跳过逻辑hasApiKey(openai); // 检查 OPENAI_API_KEY hasApiKey(anthropic); // 检查 ANTHROPIC_API_KEY hasApiKey(google); // 检查 GOOGLE_API_KEY两个变量任一存在即 true hasApiKey(openrouter); // 检查 OPENROUTER_API_KEY与它配套、但在 README 之外同样值得了解的两个函数是hasRealApiKey和shouldSkipLLMTesthasRealApiKey(provider)会排除占位 Key包含-dummy-或dummy-子串的值不算真实 Key避免把 dummy Key 误判为可用凭证shouldSkipLLMTest(mode, provider, recordingName?)封装了完整的跳过策略有真实 Key 永不跳过replay模式强制不跳过缺录制就让它失败auto模式仅在存在该 Provider 的非空录制时放行live/record/update无真实 Key 则跳过。配合describe.skipIf(skipLLM)使用即可实现无 Key 时的优雅跳过。Provider 级 LLM MockcreateLLMMock 与 createGatewayMockMock 层构建在internal/llm-recorder之上录制/回放用 MSW 拦截 LLM API 流量并支持只 Mock 指定 Provider、放行其他 Provider。返回的是自包含实例无全局状态生命周期完全由你掌控实现见 src/llm-mock.ts。createLLMMock(model, options?)包装真实模型实例传入一个真实 AI SDK 模型实例Mock 会读取它的provider与modelId用于命名录制文件然后通过 MSW 拦截该模型的所有 API 请求import { openai } from ai-sdk/openai; import { createLLMMock } from internal/test-utils; describe(OpenAI agent, () { const mock createLLMMock(openai(gpt-4o)); beforeAll(() mock.start()); afterAll(() mock.saveAndStop()); it(generates a response, async () { const result await agent.generate(Hello); expect(result.text).toBeDefined(); }); });对任何 AI SDK 模型实例都适用Provider 与模型会自动进入录制命名与元数据createLLMMock(openai(gpt-4o)); // 录制标记为 openai.chat gpt-4o createLLMMock(anthropic(claude-3)); // 录制标记为 anthropic claude-3录制命名规则源码推导默认名称由测试文件路径 Provider 模型三段拼接形如core-src-agent-my-test.e2e--openai-chat--gpt-4oprovider/modelId中的.与/会被归一化为-src/llm-mock.test.ts 验证了anthropic.messages→anthropic-messages。测试文件路径取自 Vitest worker 状态__vitest_worker__.filepath录制的目标目录会优先落到所属包的__recordings__/目录通过 monorepo 目录模式识别packages、stores、deployers等根目录而不是简单使用 cwd。注意对于 gateway/字符串模型如openai/gpt-4o没有模型实例可传请改用createGatewayMock()。返回的LLMMock实例提供以下成员属性 / 方法说明providerId提取出的 Provider如openaimodelId提取出的模型如gpt-4orecordingName录制文件使用的名称mode当前测试模式record/replay/auto/live另有updatestart()开始拦截请求saveAndStop()保存录制若在录制模式并停止拦截recorder底层LLMRecorderInstance供高级用法createGatewayMock(options?)整包流量打桩针对字符串 gateway 模型如new Agent({ model: openai/gpt-4o })没有模型实例可供读取createGatewayMock只负责录制/回放全部 LLM API 流量import { createGatewayMock } from internal/test-utils; describe(my agent, () { const mock createGatewayMock(); beforeAll(() mock.start()); afterAll(() mock.saveAndStop()); it(works, async () { const agent new Agent({ model: openai/gpt-4o, ... }); const result await agent.generate(Hello); expect(result.text).toBeDefined(); }); });它的录制名直接由测试文件路径推导不带模型后缀其余生命周期 API 与createLLMMock一致。Mock 选项总览选项说明name显式指定录制名缺省时由测试文件路径自动推导recordingsDir录制文件目录默认是包根目录下的__recordings__mode覆盖测试模式默认读取LLM_TEST_MODE环境变量forceRecord即使已有录制也强制重新录制replayWithTiming回放时保留原始分块chunk时间节奏maxChunkDelay回放分块之间的最大延迟单位 ms默认 10transformRequest在 hash 匹配前转换请求用于归一化时间戳等动态字段extraHosts额外需要拦截的 API host与自动探测结果合并debug开启详细的调试日志exactMatch为 true 时只接受精确 hash 匹配关闭模糊/相似度匹配注意mode选项的存在意味着 Mock 实例无需依赖环境变量即可精确控制行为这对在多模式矩阵里跑同一套测试很有用。底层原理internal/llm-recorder 是如何工作的理解 Mock 的上限需要看懂它脚下的录制引擎见 packages/_llm-recorder/README.mdMSW 拦截录制/回放完全基于 Mock Service Worker 对 HTTP 层的可靠拦截目前覆盖api.openai.com、api.anthropic.com、generativelanguage.googleapis.com、openrouter.ai四个 host。内容匹配而非顺序匹配每个请求按MD5(url 排序后的 body)哈希匹配因此测试执行顺序无关、可并行相同请求共享同一份录制。流式支持完整捕获并回放 SSE 流式响应及其分块时间。二进制旁路音频等二进制响应体以 hash 命名的 sidecar 文件存放在__recordings__/下JSON 里只保留{ __binary: true, contentType, size }元数据引用避免 JSON fixture 被撑爆。测试模式优先级--update-recordings/UPDATE_RECORDINGStrue→updateLLM_TEST_MODElive→liveLLM_TEST_MODErecord→recordLLM_TEST_MODEreplay→replay严格模式缺录制直接失败RECORD_LLMtrue旧版→record默认 →auto有录制就回放没有就录制。模式判定的边界行为都有 src/llm-helpers.test.ts 中的用例覆盖包括大小写不敏感与优先级覆盖。createLLMMock在内部通过setupLLMRecording({ name, recordingsDir, debug, metaContext: { testFile, provider, model } })创建录制器见 src/llm-mock.tsstart()即recorder.start()saveAndStop()是await recorder.save()与recorder.stop()的组合——所以 Provider 与模型信息会写进录制文件的 meta 上下文方便追溯。高级工具函数与完整测试流程README 中还记录了三个面向高级场景的导出// 从 model router ID 提取 Provider extractProviderId(openai/gpt-4o); // openai extractProviderId(netlify/anthropic/claude-3); // anthropic多级路径取到真正模型所属 Provider extractProviderId(azure-openai/my-deployment); // azure-openai // 从 model router ID 提取模型 extractModelId(openai/gpt-4o); // gpt-4o extractModelId(netlify/anthropic/claude-3); // claude-3 extractModelId(openai); // undefined没有模型段 // 已知 Provider ID → API host 的映射表供高级用途 PROVIDER_HOSTS;其中PROVIDER_HOSTS是已知 Provider 到其 API host 的映射如 OpenAI →api.openai.com在自定义拦截范围时可直接复用。把这些能力串起来一个标准的 LLM 集成测试文件大致长这样import { getLLMTestMode } from internal/llm-recorder; import { setupDummyApiKeys, createLLMMock, shouldSkipLLMTest, } from internal/test-utils; import { openai } from ai-sdk/openai; const MODE getLLMTestMode(); setupDummyApiKeys(MODE); // replay/auto 下注入占位 Key 通过 Agent 校验 const skipLLM shouldSkipLLMTest(MODE, openai, my-recording-name); describe.skipIf(skipLLM)(OpenAI 集成测试, () { const mock createLLMMock(openai(gpt-4o)); beforeAll(() mock.start()); afterAll(() mock.saveAndStop()); it(生成文本, async () { const result await agent.generate(Hello); expect(result.text).toBeDefined(); }); });日常执行时三种模式切换极其顺手# 默认 auto有录制就离线回放没有录制就用真实 Key 录制 pnpm test # 强制重录全部 fixture类似 vitest -u 更新快照 UPDATE_RECORDINGStrue OPENAI_API_KEYsk-xxx pnpm test # 严格回放适合 CI缺录制直接报错 LLM_TEST_MODEreplay pnpm test # 全走真实 API适合本地调试 LLM_TEST_MODElive pnpm test写在最后internal/test-utils的价值在于把三类高频痛点收敛成稳定 API版本差异由agentGenerate/agentStream/isV5PlusModel屏蔽Key 校验由setupDummyApiKeys/hasApiKey/shouldSkipLLMTest兜底请求打桩由createLLMMock/createGatewayMock提供自包含、可精确控制生命周期的 Provider 级 Mock。配合internal/llm-recorder的 MSW 拦截与内容 hash 匹配Mastra 内部各包的集成测试得以在本地零成本离线回放与CI 严格校验之间无缝切换。仓库内 packages/_test-utils/recordings/ 下保留的llm-recorder-tests.json等录制文件就是这套机制日常运转的直接证据。【免费下载链接】mastraMastra is the modern TypeScript framework for AI-powered applications and agents.项目地址: https://gitcode.com/GitHub_Trending/ma/mastra创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表