mooncassette

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

    llm
    testing
    record-replay
    cassette
    vcr
    deterministic
    mock
    regression-testing
    Download zip
    Author
    Version
    0.3.0
    License
    Apache-2.0
    Last updated
    16 hours ago
    Downloads
    3

    Dependencies

    #mooncassette

    中文 · English

    mooncakes.io license

    给 LLM 应用做确定性录制与回放 —— 让「调用大模型」这件事变得可以离线、可以复现、可以回归。

    mooncassette 记录一次真实的 LLM 交互,把它落成一个可读、可 diff 的 cassette 文件;之后无论在 CI、在同事的电脑上、还是在没有网络的飞机上, 测试都会回放出逐字节一致的结果。


    #为什么需要它

    用 LLM 写应用时,测试是三难的:

    做法问题
    直接调用真实 API慢、贵、不稳定;CI 需要密钥;同一输入两次结果不同
    手写 mock每次改 prompt 都要同步改 mock;mock 与真实响应逐渐漂移
    不测改一个 prompt 就得靠手动试,回归无从谈起

    mooncassette 提供第四条路:录制真实流量,回放确定性结果

    开发阶段 真实调用 ──▶ 录制 ──▶ demo.cassette.json ──▶ 提交进版本控制 测试 / CI 无网络 ──▶ 回放 ──▶ 逐字节一致的结果,零密钥


    #安装

    moon add weopqrst/mooncassette

    moon.pkg 中按需引用:

    import { "weopqrst/mooncassette", "weopqrst/mooncassette/core", "weopqrst/mooncassette/providers", }

    已发布在 mooncakes.io


    #30 秒上手

    不需要网络,也不需要 API Key:

    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


    #核心保证(不变量)

    这些是本项目对外承诺、且有测试守护的性质:

    1. 回放模式绝不发起真实调用。 Mode::Playback 的会话在构造上就不持有 Transport,因此「不小心打到线上」在类型层面被排除。
    2. 落盘内容一定经过脱敏,而真实调用一定保留密钥。 同一次业务请求被派生为 两个形态:发给 provider 的保留密钥(否则鉴权会失败),写入 cassette 的已脱敏。 录制与回放两侧走同一条派生链,否则会出现「录得进去、回放不出来」。
    3. 规范文本是确定的。 对象键按 UTF-8 字节序排序,与 Map 迭代顺序无关, 因此同一份 cassette 在任何后端、任何运行下都产出相同字节。
    4. 指纹跨目标稳定。 不使用标准库 Hasher(其实现与后端相关), 而是自实现 FNV-1a 64 位,并用官方测试向量校验。
    5. 指纹相同 ≠ 请求相同。 指纹只用于索引;最终判据是规范文本全等。 一旦检出真实碰撞会显式报错,而不是静默返回错误结果。
    6. 文件被手工改动一定会被发现 —— 包括响应。 cassette 里保存了两个不同 覆盖范围的摘要,解码时都会重算比对(详见下文「两个摘要」)。
    7. Interaction 完全确定。 时间戳等非确定字段只允许出现在 CassetteMeta 从类型结构上杜绝「测试随机挂掉」。


    #快速上手

    #1. 定义你的 Transport

    Transport 是唯一的扩展点,把真实 HTTP 客户端接进来即可。它刻意不含 录制、匹配、脱敏逻辑 —— 那些都由会话引擎负责。

    ///|
    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)
    }

    #2. 开发时:自动录制

    Auto 模式 = 「命中就回放、未命中就录制」。首次运行会真实调用并写盘, 之后运行完全离线。

    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))

    #3. 测试时:只读回放

    ///|
    test "chat returns the recorded answer" {
    let session = @mooncassette.replay_session(recorded_text)
    let response = session.send(request)
    assert_eq(response.body, Json::string("录下来的答案"))
    }

    这个测试不需要网络、不需要密钥,且永远稳定。


    #接入真实 provider

    回放与漂移检测都建立在「统一请求/响应」之上,而真实世界是各家自己的报文格式。 providers 包负责这层翻译,且不依赖任何 HTTP 客户端(因此仍在全部后端可编译, 并且可以用真实报文做离线测试)。

    协议路径鉴权头usage 字段名
    OpenAiChat/chat/completionsAuthorization: Bearer …prompt_tokens / completion_tokens
    AnthropicMessages/messagesx-api-key + anthropic-versioninput_tokens / output_tokens

    OpenAiChatbase_url 可以指向 DeepSeek、Moonshot、vLLM、Ollama 等兼容端点, 因此一个适配器就能覆盖相当广的范围。

    它替你处理掉这些容易出错的地方:

    • 各家的路径与鉴权头差异;
    • usage 字段命名差异(OpenAI 是 prompt_tokens,Anthropic 是 input_tokens);
    • 错误响应也要能录下来——否则「限流时怎么办」没法写回归测试;
    • 网关返回 HTML 错误页时不会把响应体丢掉,而是包成 {"raw_body": "…"}
    • 报文体中若显式写了 model,必须与 Request.model 一致,否则直接报错 (这类静默不一致会同时污染指纹与计费口径,很难排查)。

    #你需要实现的只有「发出去」这一步

    ///|
    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)

    api_key 只会进入 HTTP 请求头,永远不会写进 cassette —— cassette 记录的是 统一请求/响应,而不是抓包结果。

    #关于异步 HTTP 客户端(重要)

    mizchi/x/http 这类客户端是 async 的(async fn post),而 Transport::send HttpSender::send 都是同步的。MoonBit 没有「从同步上下文启动异步任务」的入口 moonbitlang/async 只提供 spawn,没有 block_on 式的桥接),因此异步客户端 无法实现这两个 trait

    但接入并不麻烦:整条链路里只有「把报文发出去」这一步是异步的,其余全部复用同步 API。

    ///|
    // 命中就回放,未命中就发一次并录下来。
    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)) // 同步
    }
    }

    Session::record 会按与自动录制完全相同的派生链处理请求与响应(规范化 + 脱敏), 因此手工录下的记录与自动录下的记录在 cassette 里形态一致,可以互相回放。 换句话说:这条路径绕开了 Transport,但没有绕开任何不变量。

    协议编解码本身与同步/异步无关,所以 encode / decode 两步可以在任何上下文中复用。 若客户端是同步的,则用上面的 FunctionSender 更省事。


    #概念

    #cassette

    一个 cassette 就是「按录制顺序排列的交互列表」,一个 JSON 文件。它是人类可读的, 可以直接在 code review 里看 diff —— 这正是「键序固定」这一设计的意义。

    { "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整条记录(含响应)防篡改。响应是最容易被手工改动的地方

    只写 fingerprint 是不够的:如果只校验请求,那么「为了让失败的测试通过、 直接改掉 cassette 里的期望输出」这种做法会悄无声息地溜过去,测试也就失去了意义。 integrity 的存在就是为了堵住这个洞。

    #请求规范化

    生成指纹前会剔除易变字段(request_idtimestampnonce 等), 否则每次调用指纹都不同,回放永远不会命中。默认列表见 @core.default_drop_keys,可自定义。

    #匹配策略

    策略语义适用场景
    Exact指纹相同 规范文本全等默认,推荐
    FingerprintOnly只比指纹,跳过文本比对确有性能瓶颈时
    Subset(keys)只比请求体顶层指定字段「prompt 一致就算同一次调用」
    Sequential不比内容,按录制顺序消费调用顺序本身即语义

    查找采用环形扫描:从当前游标向后找,找不到再从头回卷。这样既能正确处理 「同一请求被录多次、按次序依次回放」,又能在实际调用次数多于录制次数时复用 最早的一条,而不是直接失败。

    #未命中时能看到什么

    只报一个指纹几乎没有可操作性——用户无法判断该去补录制、改请求,还是换匹配策略。 因此 NoMatch 的消息里直接带上诊断结论:

    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)

    三点刻意的设计:

    • 差异只报路径,不报字段值:诊断信息常被写进测试输出或日志,而传进去的请求 未必经过脱敏;
    • 路径做排序,候选按「差异最少」稳定排序,因此报告可以被断言、可以逐字比较;
    • 「录制条数不够」与「请求对不上」分开报。 顺序模式下游标越界抛 Exhausted 而不是 NoMatch——前者的修法是补录制,后者是改请求或换策略,混在一起会把排查 引向错误的方向。

    需要自行格式化时可以调用不抛错的 Session::diagnose

    ///|
    let diagnosis = session.diagnose(request)
    for line in diagnosis.lines() {
    println(line)
    }

    #脱敏

    cassette 会被提交进 git、贴进 issue、用于演示,所以脱敏是默认行为

    • 精确键名匹配(authorizationx-api-keycookie …);
    • 后缀匹配(_key_token_secret …)——刻意用后缀而非子串, 以免误伤 max_tokens / total_tokens 这类计数字段;
    • 字符串值中的密钥形状子串扫描(sk- 后接 8 位以上)。

    #漂移检测

    回放解决的是「不该变的东西别变」;漂移检测解决的是「该变的东西变了没有」。

    你更新了 prompt、换了模型版本,重新录制了一份 cassette。直接看 JSON diff 是读不懂的 (指纹变了、键序也会变)。mooncassette diff 回答你真正关心的三件事:

    类型含义
    Removed这个调用在旧录制里有、新的没有 —— 调用被删了,或请求被改动
    Added只出现在新录制里 —— 新增调用,或请求被改动
    Changed请求完全相同,但响应变了 —— 这才是真正的模型行为漂移

    第三类最关键:你的代码一行没改,模型的输出却变了。

    两点刻意的设计:

    • 请求改动会报成「一条 Removed + 一条 Added」,而不是「一条 Changed」。 因为我们无法判断改后的请求「对应」原来哪一条;与其猜错,不如如实报告。
    • 比较前会重新规范化请求。 正常录制出来的请求本就是规范化形态(再规范化是幂等的), 但对手工构造或被外部工具改过的 cassette,这一步能避免把「易变字段残留」误判为漂移。 容错方向是「宁可少报」。

    diff 在有漂移时以退出码 1 结束,因此可以直接当作 CI 的一步。

    $ 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


    #CLI

    mooncassette verify <cassette.json> # 解码 + 完整性校验,非零退出码表示失败 mooncassette show <cassette.json> # 打印概要(版本、记录数、token、模型) mooncassette diff <old.json> <new.json> # 报告两次录制之间的漂移,有漂移则退出码 1 mooncassette help

    verify 会同时校验两个摘要,因此它可以直接当作 CI 里的一步: 任何被手工改动的 cassette 都会在那里被拦住,并精确定位到 $.interactions[i].integrity

    参数解析刻意不依赖位置(不同后端 @env.args() 语义不一致), 而是扫描已知子命令关键字,因此 native 与 js 上行为一致。


    #设计取舍(已知边界)

    诚实列出,避免误用:

    取舍原因影响
    规范文本不区分 11.0JSON 只有一种数字类型;解析器会归一为 1若需区分整数/浮点,请自行在业务层编码
    非有限数(NaN/Inf)写成 nullJSON 无法表示它们不会产出非法 JSON,但会丢失该值
    指纹用非密码学哈希只做索引,一致性由文本全等兜底无安全影响
    integrity 用非密码学哈希目的是检出误改,不是防恶意伪造需要防伪造请配合签名/权限控制
    脱敏只按键名判断值里混进密钥无法可靠识别已用 sk- 形状扫描部分缓解;敏感场景请人工复核
    不记录耗时耗时是非确定字段,进入 Interaction 会破坏可复现性需要延迟断言请在业务层另行测量
    库本体不含 fs/http 依赖保持纯计算、全后端可编译文件读写与网络由使用方接入


    #测试与验证

    moon test --target wasm-gc # 默认目标 moon test --target js # 交叉验证 moon check --target js moon fmt && moon info

    当前 161 个测试全部通过,覆盖十个包,且在 wasm-gcjs 两个目标上各跑一遍。 测试的重点不是行数,而是每条不变量都有对应断言,例如:

    • FNV-1a 用官方测试向量校验(空串 / "a" / "foobar");
    • 规范文本的键序、转义、幂等性;
    • 环形查找的推进与回卷边界(白盒直测私有算术);
    • 篡改请求体响应体状态码token 用量都必须被检出;
    • 脱敏不能误伤 max_tokens / total_tokens
    • 真实调用的请求必须保留密钥(用记录型 Transport 断言);
    • 带密钥的请求「录制后立刻回放」必须命中(回归测试);
    • 回放模式下 Transport 调用次数必须为 0;
    • 漂移检测:键序/易变字段变化不算漂移,而响应变化必须算;
    • 漂移检测:重复的同一请求按出现顺序两两配对,报告顺序固定为 Removed → Changed → Added;
    • 协议解码用真实 API 形状的报文(含 429 错误体、HTML 网关错误页、非整数 token 数)验证;
    • 诊断:差异路径按字典序排序(Map 迭代顺序不保证稳定,不排序就无法断言);
    • 诊断:差异数量相同时保持录制顺序(稳定排序);
    • 诊断:$.model$.body.x 能分别定位,「只在记录里」「只在请求里」与数组长度差都会被报出;
    • 顺序模式耗尽抛 Exhausted,空 cassette 报 NoMatch——两者不可混;
    • 手动录入(Session::record)与自动录制产出同一形态,且「录完立刻回放」必须命中;
    • 手动录入绝不触碰 Transport(这是异步接入的前提);
    • 手动录入返回的是脱敏后的响应,与回放返回的内容一致(否则首次运行与后续运行会看到不同的值)。


    #包结构

    职责是否依赖 IO
    canon规范 JSON 文本 + 稳定哈希
    core数据模型、规范 JSON 视图、错误、请求规范化
    fingerprint请求指纹、完整性摘要、等价判据
    matcher四种匹配策略与环形查找
    sanitize脱敏策略与递归脱敏
    codeccassette 编解码与完整性校验
    drift两次录制之间的漂移检测
    providersOpenAI / Anthropic 协议编解码、HTTP 执行器抽象
    recorderTransport 抽象、会话引擎、MockTransport
    mooncassette门面:最短上手路径

    全部子包都是纯计算包,可在 wasm / wasm-gc / js / native 后端编译。 真实网络与文件系统由使用方通过 Transport 与自有 IO 接入 (仓库内的示例与 CLI 使用 moonbitlang/x,属于外层壳)。


    #已完成

    版本内容
    0.1.0数据模型与规范 JSON 文本、跨目标稳定指纹、四种匹配策略、脱敏、cassette 编解码与双摘要完整性校验、会话引擎、verify/show CLI、离线示例
    0.2.0漂移检测(drift 包 + mooncassette diff);示例演示「模型换版本后行为漂移」的完整闭环
    0.3.0协议适配与未命中诊断:providers(OpenAI / Anthropic)、Session::record 手动录入路径(异步客户端的接入方式)、Session::diagnose 与可读的未命中消息;修复顺序模式耗尽被误报为 NoMatchgenerator_id 与模块版本脱钩


    #许可

    Apache-2.0

    auto_session

    创建自动录制会话。

    语义为「命中就回放、未命中就录制」:首次运行会调用真实 Transport 并把结果并入 cassette,之后运行则完全离线。这是推荐的日常开发流程。

    compare_recordings

    比较两份录制,返回漂移报告。

    典型用法:把通过验收的 cassette 提交进版本控制;日后换了模型版本 或在改了 prompt 之后重新录制,跑一次对比就能回答「模型行为变了没有」。 报告区分三类:调用被删(Removed)、调用新增(Added)、 以及请求没变但响应变了Changed)——最后一类才是真正的行为漂移。

    replay_session

    创建只读回放会话。

    这是最安全的入口:不会发起任何真实调用,未命中即报错。 适合在 CI 与单元测试中使用。

    save

    fn save(session :
    Session
    , indent? : Int) -> String

    把会话当前的 cassette 编码为文本。

    indent 省略时输出 2 空格缩进(适合提交进版本控制); 0 可得到紧凑形式(适合做字节级比对)。

    Source Files