Deterministic record-and-replay (cassette) for LLM interactions: write offline, reproducible, drift-detecting regression tests for LLM applications without API keys or network.
Dependencies
| 做法 | 问题 |
|---|---|
| 直接调用真实 API | 慢、贵、不稳定;CI 需要密钥;同一输入两次结果不同 |
| 手写 mock | 每次改 prompt 都要同步改 mock;mock 与真实响应逐渐漂移 |
| 不测 | 改一个 prompt 就得靠手动试,回归无从谈起 |
开发阶段 真实调用 ──▶ 录制 ──▶ demo.cassette.json ──▶ 提交进版本控制
测试 / CI 无网络 ──▶ 回放 ──▶ 逐字节一致的结果,零密钥moon add weopqrst/mooncassetteimport {
"weopqrst/mooncassette",
"weopqrst/mooncassette/core",
"weopqrst/mooncassette/providers",
}moon run examples/offline_demo --target js # 录制 → 落盘 → 回放 → 漂移检测
moon run examples/openai_protocol --target js # 真实 provider 报文 → 录制 → 离线回放
moon run cmd/main --target js -- verify examples/demo.cassette.json
moon run cmd/main --target js -- show examples/demo.cassette.json
moon run cmd/main --target js -- diff examples/demo.cassette.json examples/demo.drifted.cassette.json
moon test --target wasm-gc///|
struct MyHttpTransport {
client : MyHttpClient
}
///|
fn MyHttpTransport::send(
self : MyHttpTransport,
request : @core.Request,
) -> @core.Response raise @core.CassetteError {
let response = self.client.post(request.model, request.body) catch {
error => raise @core.CassetteError::TransportFailure(error.to_string())
}
@core.Response::ok(response.body, usage=response.usage)
}let session = @mooncassette.auto_session(
@core.Cassette::new("chat-demo"),
MyHttpTransport::new(client),
)
let _ = session.send(request)
// 把结果写入 tests/cassettes/chat-demo.cassette.json 并提交进版本控制
@fs.write_string_to_file(path, @mooncassette.save(session))///|
test "chat returns the recorded answer" {
let session = @mooncassette.replay_session(recorded_text)
let response = session.send(request)
assert_eq(response.body, Json::string("录下来的答案"))
}| 协议 | 路径 | 鉴权头 | usage 字段名 |
|---|---|---|---|
| OpenAiChat | /chat/completions | Authorization: Bearer … | prompt_tokens / completion_tokens |
| AnthropicMessages | /messages | x-api-key + anthropic-version | input_tokens / output_tokens |
///|
let sender = @providers.FunctionSender::new(fn(request) {
MyClient::call(request) // (HttpRequest) -> HttpResponse raise CassetteError
})
///|
let transport = @providers.ProviderTransport::new(
@providers.OpenAiChat::new(api_key, base_url="https://api.deepseek.com/v1"),
sender,
)
///|
let session = @mooncassette.auto_session(@core.Cassette::new("chat"), transport)///|
// 命中就回放,未命中就发一次并录下来。
let response = match session.try_replay(request) {
Some(recorded) => recorded
None => {
let http_request = protocol.encode(request) // 同步
let http_response = my_client.send(http_request) // ← 只有这一步需要 await
session.record(request, protocol.decode(http_response)) // 同步
}
}{
"format": "mooncassette",
"version": 1,
"meta": {
"generator": "mooncassette/0.3.0",
"name": "chat-demo",
"recorded_at": "2026-09-12T08:00:00Z"
},
"interactions": [
{
"fingerprint": "fnv1a64:9f2c1ab34de5f607",
"integrity": "fnv1a64:5ae605b113f91e60",
"request": {
"body": { "messages": [{ "content": "你好", "role": "user" }] },
"model": "gpt-4o",
"provider": "openai"
},
"response": {
"body": { "choices": [] },
"status": 200,
"usage": { "input_tokens": 10, "output_tokens": 2 }
}
}
]
}| 字段 | 覆盖范围 | 用途 |
|---|---|---|
| fingerprint | 仅请求 | 回放匹配(回放时响应尚不存在,不能纳入);也便于人工排查「为什么没命中」 |
| integrity | 整条记录(含响应) | 防篡改。响应是最容易被手工改动的地方 |
| 策略 | 语义 | 适用场景 |
|---|---|---|
| Exact | 指纹相同 且 规范文本全等 | 默认,推荐 |
| FingerprintOnly | 只比指纹,跳过文本比对 | 确有性能瓶颈时 |
| Subset(keys) | 只比请求体顶层指定字段 | 「prompt 一致就算同一次调用」 |
| Sequential | 不比内容,按录制顺序消费 | 调用顺序本身即语义 |
mooncassette: no recorded interaction matches request fnv1a64:2b1e... (the cassette has 2
interaction(s); closest is #0 (provider=openai model=gpt-4o), differing at $.body.messages[0].content)///|
let diagnosis = session.diagnose(request)
for line in diagnosis.lines() {
println(line)
}| 类型 | 含义 |
|---|---|
| Removed | 这个调用在旧录制里有、新的没有 —— 调用被删了,或请求被改动 |
| Added | 只出现在新录制里 —— 新增调用,或请求被改动 |
| Changed | 请求完全相同,但响应变了 —— 这才是真正的模型行为漂移 |
$ mooncassette diff examples/demo.cassette.json examples/demo.drifted.cassette.json
[changed] gpt-4o #0 -> #0 b862439f
drift detected: removed=0 changed=1 added=0 unchanged=1mooncassette verify <cassette.json> # 解码 + 完整性校验,非零退出码表示失败
mooncassette show <cassette.json> # 打印概要(版本、记录数、token、模型)
mooncassette diff <old.json> <new.json> # 报告两次录制之间的漂移,有漂移则退出码 1
mooncassette help| 取舍 | 原因 | 影响 |
|---|---|---|
| 规范文本不区分 1 与 1.0 | JSON 只有一种数字类型;解析器会归一为 1 | 若需区分整数/浮点,请自行在业务层编码 |
| 非有限数(NaN/Inf)写成 null | JSON 无法表示它们 | 不会产出非法 JSON,但会丢失该值 |
| 指纹用非密码学哈希 | 只做索引,一致性由文本全等兜底 | 无安全影响 |
| integrity 用非密码学哈希 | 目的是检出误改,不是防恶意伪造 | 需要防伪造请配合签名/权限控制 |
| 脱敏只按键名判断 | 值里混进密钥无法可靠识别 | 已用 sk- 形状扫描部分缓解;敏感场景请人工复核 |
| 不记录耗时 | 耗时是非确定字段,进入 Interaction 会破坏可复现性 | 需要延迟断言请在业务层另行测量 |
| 库本体不含 fs/http 依赖 | 保持纯计算、全后端可编译 | 文件读写与网络由使用方接入 |
moon test --target wasm-gc # 默认目标
moon test --target js # 交叉验证
moon check --target js
moon fmt && moon info| 包 | 职责 | 是否依赖 IO |
|---|---|---|
| canon | 规范 JSON 文本 + 稳定哈希 | 否 |
| core | 数据模型、规范 JSON 视图、错误、请求规范化 | 否 |
| fingerprint | 请求指纹、完整性摘要、等价判据 | 否 |
| matcher | 四种匹配策略与环形查找 | 否 |
| sanitize | 脱敏策略与递归脱敏 | 否 |
| codec | cassette 编解码与完整性校验 | 否 |
| drift | 两次录制之间的漂移检测 | 否 |
| providers | OpenAI / Anthropic 协议编解码、HTTP 执行器抽象 | 否 |
| recorder | Transport 抽象、会话引擎、MockTransport | 否 |
| mooncassette | 门面:最短上手路径 | 否 |
| 版本 | 内容 |
|---|---|
| 0.1.0 | 数据模型与规范 JSON 文本、跨目标稳定指纹、四种匹配策略、脱敏、cassette 编解码与双摘要完整性校验、会话引擎、verify/show CLI、离线示例 |
| 0.2.0 | 漂移检测(drift 包 + mooncassette diff);示例演示「模型换版本后行为漂移」的完整闭环 |
| 0.3.0 | 协议适配与未命中诊断:providers(OpenAI / Anthropic)、Session::record 手动录入路径(异步客户端的接入方式)、Session::diagnose 与可读的未命中消息;修复顺序模式耗尽被误报为 NoMatch、generator_id 与模块版本脱钩 |
Install
Download zipDeterministic record-and-replay (cassette) for LLM interactions: write offline, reproducible, drift-detecting regression tests for LLM applications without API keys or network.
Dependencies