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 examples/streaming_demo --target js # SSE 流式 → 逐帧递给应用 → 离线逐帧回放
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)) // 同步
}
}| 指标 | 值 | 复算方式 |
|---|---|---|
| 库包 | 12 个(含门面包) | moon info,或各目录下的 pkg.generated.mbti |
| 生产代码 | 6,214 行 | 排除 *_test.mbt / *_wbtest.mbt(含示例与 CLI) |
| 测试代码 | 4,781 行 | 两类测试文件之和 |
| 测试 | 296 个,wasm-gc 与 js 各跑一遍 | moon test --target wasm-gc / --target js |
| 对抗性用例 | 篡改 20 条 + 脱敏 300 组随机结构 + 解析器 600 组随机输入 + 形状 12 条 | codec/tamper_test.mbt、sanitize/leak_test.mbt、stream/fuzz_test.mbt、codec/shape_test.mbt |
| 演示模块 | 5 个视图;12 个白盒测试 + 端到端冒烟 | demo/ |
| 操作 | js | wasm-gc |
|---|---|---|
| 指纹(1 KB 请求) | 19 µs | 4 µs |
| 规范文本(1 KB JSON) | 8 µs | 1 µs |
| 编码(100 条记录) | 7.8 ms | 2.0 ms |
| 解码 + 完整性校验(100 条记录) | 5.8 ms | 1.9 ms |
| 匹配未命中(1000 条,带索引) | 22 µs | 9 µs |
| 会话回放(100 条记录) | 70 µs | 31 µs |
| SSE 解析(200 帧) | 570 µs | 213 µs |
moon run cmd/main --target js -- tokens <你的 cassette.json>tests/
cassettes/
chat.cassette.json
streaming.cassette.json- name: Set up MoonBit
run: |
curl -fsSL https://cli.moonbitlang.com/install/unix.sh | bash
echo "$HOME/.moon/bin" >> $GITHUB_PATH
- name: Replay recorded LLM interactions (no network, no API key)
run: moon test --target js///|
test "chat completes without a network" {
let text = @fs.read_file_to_string("tests/cassettes/chat.json")
let session = @mooncassette.replay_session(text)
let response = session.send(my_request()) catch {
error => fail("replay failed: " + error.to_string())
}
assert_eq(response.status, 200)
}///|
test "no behavioural drift since the recording was accepted" {
let old = @codec.decode(@fs.read_file_to_string("tests/cassettes/chat.json"))
let fresh = @codec.decode(
@fs.read_file_to_string("tests/cassettes/chat.new.json"),
)
let report = @mooncassette.compare_recordings(old, fresh)
if !report.is_clean() {
fail("drift detected: " + report.summary())
}
}{
"format": "mooncassette",
"version": 2,
"meta": {
"generator": "mooncassette/0.6.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 | 只比指纹,跳过规范文本校验 | 省下的是校验而非计算(实测与 Exact 相当),除非确有理由,否则用 Exact |
| Subset(keys) | 只比请求体顶层指定字段 | 「prompt 一致就算同一次调用」 |
| Sequential | 不比内容,按录制顺序消费 | 调用顺序本身即语义 |
| 场景 | 无索引 | 有索引 |
|---|---|---|
| 未命中(扫完全表) | 18.8 ms | 22 µs |
| 会话回放(100 条记录) | 1.9 ms | 70 µs |
| 建立索引 | — | 18.3 ms(每份 cassette 一次) |
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=1线上 data: {...} ──▶ SSE 文本
│
┌──────────┴──────────┐
▼ ▼
Response.stream Response.body
(原始帧,逐个保留) (聚合后的最终结果,与非流式同形)mooncassette verify <cassette.json> # 解码 + 完整性校验,非零退出码表示失败
mooncassette show <cassette.json> # 打印概要(版本、记录数、token、模型)
mooncassette diff <old.json> <new.json> # 报告两次录制之间的漂移,有漂移则退出码 1
mooncassette cost <cassette.json> <prices.json> # 按价目表汇总 token 成本
mooncassette tokens <cassette.json> # 上报用量与「4 字符 1 token」估算的对照
mooncassette explain <cassette.json> <request.json> # 判断请求能否回放,不能则说明差在哪(退出码 1)
mooncassette helpmoon run cmd/main --target js -- cost examples/demo.cassette.json examples/prices.example.json
# cost=$0.000210 priced=2 unpriced=0 no_usage=0match: no
policy=Exact cursor=0 interactions=1
#0 provider=openai model=gpt-4o differs: $.body.api_key (only in the request), $.body.model (only in the recording)| 取舍 | 原因 | 影响 |
|---|---|---|
| 规范文本不区分 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 执行器抽象 | 否 |
| stream | SSE 帧解析与渲染 | 否 |
| cost | 按价目表做成本核算 | 否 |
| 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 与模块版本脱钩 |
| 0.4.0 | 流式响应:SSE 帧解析、OpenAI / Anthropic 增量聚合、以及回放侧的逐帧重放(Session::replay_stream);cost 成本核算;CLI 新增 cost 与 explain。新增 stream 与 cost 两个包;cassette 格式版本升到 2,读取端兼容 1–2 |
| 0.5.0 | 「别人能照着用」:CI 接入指南(中英 README 各一节 + 可直接复制的 GitHub Actions 示例)、限流重试示例与脚本化应答序列(ScriptedReplies)、基准套件 examples/benchmarks(并据此修掉扫描中重复计算指纹的问题:1000 条记录未命中 18.8 ms → 22 µs,会话回放 1.9 ms → 70 µs)、可视化 Demo(5 个视图 + 截图)、异步适配层(独立模块)、CLI 新增 tokens;另修正若干「文档与实现不符」之处(FingerprintOnly 被误称为性能逃生通道、replay_session 的参数名、SSE 起始行判据) |
| 0.6.0 | 二进制与多模态载荷:providers 按响应体内容决定承载方式(JSON / 原文 / 载荷信封),非文本字节不再被有损解码成替换字符;base64 归一化后进指纹,同一份字节的等价写法共用一个指纹;超过阈值的大载荷直接进信封;脱敏视图新增折叠与截断(fold / fold_json);新增 payload 包 |
let text = @fs.read_file_to_string("tests/cassettes/chat.json")
let session = @mooncassette.replay_session(text)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