Deterministic record-and-replay (cassette) for LLM interactions: write offline, reproducible, drift-detecting regression tests for LLM applications without API keys or network.

| 做法 | 问题 |
|---|---|
| 直接调用真实 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)) // 同步
}
}| 你的客户端 | 该实现什么 | 代价 |
|---|---|---|
| 同步,报文兼容 OpenAI / Anthropic | providers.HttpSender | 最小:只写「发出去」 |
| 同步,报文是自己的格式 | 再加一个 providers.Protocol | 多写 encode / decode |
| 异步(async fn,如 mizchi/x/http) | 不用 trait,见上文「关于异步 HTTP 客户端」 | 自己写录制分支 |
| 只想把已有流量落成 cassette | recorder.Transport 的最小实现 | 需自行遵守规范化 / 脱敏边界 |
///|
pub impl @providers.Protocol for MyProtocol with fn name(_self) {
"my-vendor"
}
///|
pub impl @providers.Protocol for MyProtocol with fn encode(self, request) {
@providers.HttpRequest::new(
"POST",
self.base_url + "/v1/complete",
[("content-type", "application/json")],
@canon.to_canonical_string(request.body),
)
}
///|
pub impl @providers.Protocol for MyProtocol with fn decode(_self, response) {
// 自己解析报文。`usage` 尽量填 provider 上报的真实值:
// 回放、成本与漂移检测都读它;不填仍能回放,但「精确」那一半就没了。
@core.Response::new(response.status, @json.parse(response.body))
}///|
let transport = @providers.ProviderTransport::new(
MyProtocol::new("https://api.my-vendor.com"),
@providers.FunctionSender::new(fn(request) { MyClient::call(request) }),
)///|
test "my protocol sends what I think it sends" {
let sender = @providers.ScriptedHttpSender::new()
sender.on(
@providers.HttpRequest::new(
"POST",
"https://api.my-vendor.com/v1/complete",
[("content-type", "application/json")],
expected_body,
),
@providers.HttpResponse::new(200, "{\"text\":\"hi\"}"),
)
let transport = @providers.ProviderTransport::new(
MyProtocol::new("https://api.my-vendor.com"),
sender,
)
let response = transport.send(request)
assert_eq(response.status, 200)
assert_eq(sender.call_count(), 1)
}| 指标 | 值 | 复算方式 |
|---|---|---|
| 库包 | 14 个(含门面包) | moon info,或各目录下的 pkg.generated.mbti |
| 生产代码 | 6,506 行 | 排除 *_test.mbt / *_wbtest.mbt(含示例与 CLI;不含 demo/、llm/、async/ 三个独立模块) |
| 测试代码 | 5,658 行 | 两类测试文件之和(同一口径) |
| 测试 | 352 个,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.9.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 migrate <old.json> <new.json> # 升到当前格式并打印变更摘要(绝不原地覆盖)
mooncassette trim <in.json> <out.json> --keep <spec> # 按策略裁剪记录
mooncassette merge <out.json> <in.json> [<in.json> ...] [--policy <p>] # 合并多份录制,丢弃完全相同的重复
mooncassette stats <in.json> [<in.json> ...] # 汇总多份录制(版本 / 来源 / 状态 / 模型)
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)moon run cmd/main --target js -- trim examples/demo.cassette.json /tmp/one.json --keep first:1
# trimmed : examples/demo.cassette.json -> /tmp/one.json
# keep : first:1
# records : 2 -> 1| 版本 | 引入 | 关键差异 |
|---|---|---|
| 1 | 0.3.0 及更早 | 仅非流式记录(Response 无 stream) |
| 2 | 0.4.0 | 引入 Response.stream(流式记录);追加流式记录后写端自动升到 2 |
# 只写新文件,绝不原地覆盖源文件
moon run cmd/main -- migrate old.cassette.json new.cassette.json
# 迁移后自检:通过即表示语义未变(两个摘要都对齐)
moon run cmd/main -- verify new.cassette.json| 取舍 | 原因 | 影响 |
|---|---|---|
| 规范文本不区分 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自 0.9.0 起,下表三项被锁定为格式契约:读端向后兼容全部已发布版本;写端恒定输出当前值;升大版本(1.0)前不会变更。
| 维度 | 当前值 | 读端保证 | 写端保证 | 变更条件 |
|---|---|---|---|---|
| cassette_format_version | 2 | 接受 1..2(所有历史版本可直接解码,无需迁移) | 恒定写 2 | 仅 1.0 大版本 |
| stream_format_version | 2 | 含流式记录的旧文件可解码 | 追加流式记录时自动升级到 2 | 仅 1.0 大版本 |
| content_encoding | "base64" 信封 | 接受 {"content_encoding":"base64","payload":"<规范 base64>"} 与普通 JSON 对象(无信封) | 仅产出 base64 信封 | 新增编码方式时 |
| 包 | 职责 | 是否依赖 IO |
|---|---|---|
| canon | 规范 JSON 文本 + 稳定哈希 | 否 |
| core | 数据模型、规范 JSON 视图、错误、请求规范化 | 否 |
| fingerprint | 请求指纹、完整性摘要、等价判据 | 否 |
| matcher | 四种匹配策略与环形查找 | 否 |
| sanitize | 脱敏策略与递归脱敏 | 否 |
| codec | cassette 编解码与完整性校验 | 否 |
| payload | 大载荷信封:编码、base64 归一化、阈值决策 | 否 |
| drift | 两次录制之间的漂移检测 | 否 |
| providers | OpenAI / Anthropic 协议编解码(含流式聚合)、HTTP 执行器抽象 | 否 |
| stream | SSE 帧解析与渲染 | 否 |
| cost | 按价目表做成本核算 | 否 |
| recorder | Transport 抽象、会话引擎、MockTransport | 否 |
| maintain | 录制维护:格式迁移、裁剪、合并、统计 | 否 |
| 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 包 |
| 0.7.0 | 生态接入与录制维护:mizchi/llm 适配层(独立模块 mooncassette-llm,把 llm 的估算 token 换成 provider 上报的精确用量);maintain 包与 CLI 四个维护子命令(migrate / trim / merge / stats);README 新增「接入你自己的 HTTP 客户端」一节。异步适配层因工具链未升级,仍顺延 |
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.