Friend 项目 Parity Pack v0 解析:基于本地脱敏 Cassette 重放的 STT/LLM 契约测试体系

原创2026-09-14 12:05:441,380 阅读
文章标签:人工智能AI 应用语音移动开发后端桌面应用智能硬件MCP 服务

Friend 项目 Parity Pack v0 解析:基于本地脱敏 Cassette 重放的 STT/LLM 契约测试体系

本篇文章以仓库中 backend/testing/parity_pack_v0/README.md 为主干,结合 backend/testing/parity_pack_v0/ 下的实现源码、backend/routers/listen/parity_capture.py 实时接线以及 backend/tests/unit/test_parity_pack_v0.py 等测试用例,系统讲解 Parity Pack v0 的完整机制:它如何在仅限开发(dev)且默认拒绝的强门禁下采集匿名的"线缆观测"(cassette)、如何通过 SHA-256 脱敏指纹保证可重放性,以及如何用严格调用拓扑的播放器在无外网、无真实供应商的条件下完成 STT/LLM 回环重放。读完本文,你将掌握这套"采集→脱敏→重放→金标漂移"流水线的每个配置项、目录契约与操作流程,并能在本地复现 npm run test:parity-pack-v0 的整套检查。


1. 定位:本地重放包契约,而非生产采集路径

Parity Pack v0 在 README 开头就给出了明确的边界定义:This is a local replay-pack contract, not a production capture path。它是一套本地重放包契约,用来验证"同一批请求在录制时的行为与当前代码行为保持一致"(parity,即对等一致性),而不是一条可以随时开关的生产录制通道。

由此派生出的两条硬性约束贯穿全文:

  • 载荷受限:Pack 载荷(pack payloads)是受限的本地/开发产物,必须禁止被提交到 Git("must never be committed");
  • 默认拒绝:所有采集入口默认 deny,只有显式满足三项环境变量条件才会放行。

实现上,整个模块由 backend/testing/parity_pack_v0/ 下的若干小文件组成,各司其职:

文件 职责
whitelist.py CaptureWhitelist:从环境变量解析并执行默认拒绝门禁
schema.py CassetteIdentity 与 RequestFingerprint:匿名身份与脱敏指纹
redaction.py 保守脱敏:凭据键、邮箱、电话、URL 参数
capture.py CaptureTap / CaptureInvocation:采集与落盘
players.py STTCassettePlayer / LLMCassettePlayer 回环播放器
manifest.py manifest.json 的构建与写盘
gold.py 金标双跑、漂移报告
runner.py 无外网运行原语与 Fake 命中记账
matrix.py 六个合成 overlay 的名称矩阵
rewrite.py 未来重写二进制的描述符槽位

2. 开发采集门禁:三项环境变量与默认拒绝

采集的唯一入口是 CaptureWhitelist。README 明确:CaptureTap 使用 CaptureWhitelist.from_environ(),在序列化任何 cassette 字节之前调用 allows(principal_id),默认 deny。允许采集必须同时满足三个设置:

OMI_ENV_STAGE=dev
OMI_PARITY_PACK_CAPTURE=1
OMI_PARITY_PACK_ALLOWED_PRINCIPALS='synthetic-user-1,synthetic-device-2'

对照 whitelist.py 的源码,门禁判定逻辑非常直接:

def allows(self, principal_id: str | None) -> bool:
    """Default deny: only explicit dev enablement plus an exact allow-list hit."""
    return bool(self.enabled and self.environment == "dev" and principal_id and principal_id in self.principal_ids)

即允许条件为:OMI_PARITY_PACK_CAPTURE 取值为 1 / true / yes(大小写不敏感)且 OMI_ENV_STAGE == "dev" 且 principal 非空且严格命中 OMI_PARITY_PACK_ALLOWED_PRINCIPALS 逗号分隔列表。任何一个条件不满足,start() 都会拒绝并只保留有界的 lane/reason 元数据。

backend/tests/unit/test_parity_pack_v0.py 用测试锁死了这一行为:prod 阶段即使 capture 置 1 也拒绝;空环境默认拒绝;特别地,OMI_ENV 不是规范运行时阶段变量——只有 OMI_ENV_STAGE 能开启 dev 采集(test_capture_whitelist_ignores_non_canonical_env_var)。

采集命名遵守匿名规则:在 CassetteIdentity 中使用匿名会话/事件标识符;请求指纹是规范化脱敏请求结构的 SHA-256 摘要;auth、cookies、keys、签名 URL 参数以及尽力而为的邮箱/电话字符串都会在生成摘要前被移除或打码。严禁把原始请求数据写进 manifest、报告或 Git。

3. 匿名身份与脱敏指纹:契约的地基

3.1 CassetteIdentity:六元组身份

schema.py 中的 CassetteIdentity 是"单次供应商调用"的身份元组,六个字段:

字段 说明
anon_session 匿名会话标识(必须已是匿名、稳定的标识符)
provider_lane 供应商通道,如 stt、llm、memory
route_or_model 路由或模型名
call_ordinal 调用序号(≥ 0)
retry_attempt 重试次数(≥ 0)
parent_event_anon 父事件的匿名标识

构造器在 __post_init__ 中强制:四个文本字段必须为非空字符串,call_ordinal / retry_attempt 必须非负。key() 方法对身份的规范 JSON 做 SHA-256,生成 64 位、文件系统安全的稳定键名——不包含任何请求内容。测试 test_identity_tuple_is_stable_and_complete 验证了 as_dict() 的字段完整性与 key() 的长度。

3.2 RequestFingerprint:canonical + redacted 的一次性摘要

RequestFingerprint.from_request() 的流程是:先用 redact_value(value, drop_sensitive=True) 递归移除敏感字段(而不是打码),再对规范化 JSON(sort_keys=True, separators=(",",":"))做 SHA-256,算法标识为 sha256-canonical-redacted-v1。摘要本身只用于比对,绝不写入 manifest 或报告。

redaction.py 的脱敏是"保守型"的,包含三层规则:

  • 敏感键:正则 SENSITIVE_KEY 匹配 authorization、auth、cookie、token、secret、api_key、password、signature、signed、private_key、credential、bearer、jwt 等(带词边界)。_is_sensitive_key() 先把 camelCase 归一化为 snake_case,所以 accessToken、clientSecret 与 access_token、access-token 一样能被捕获;
  • URL:剥离 query/fragment,仅保留 scheme/netloc/path,并追加 [REDACTED_URL_PARAMS] 标记;
  • 邮箱/电话:[REDACTED_EMAIL]、[REDACTED_PHONE] 打码。

redact_value 的 drop_sensitive 参数决定敏感值的处理方式:为 True(指纹场景)时直接省略,为 False(报告场景)时替换为稳定的 <a href="https://link.gitcode.com/i/74283e5da38ac93b7c27b621cc00a299" target="_blank">REDACTED] 标记。测试 [test_fingerprint_is_canonical_and_never_contains_sensitive_values 证明:键序不同的两个请求会得到相同指纹,而指纹/脱敏结果中不会残留 topsecret 或电话号码。

3.3 特例:audio_b64 不做过度脱敏

capture.py 的 CaptureInvocation.observe() 有一个容易被忽略的关键细节:base64 音频是不透明二进制而非自由文本,对其跑文本模式脱敏可能把一段 base64 数字误判成电话号码、从而破坏可重放音频事件。因此实现中对 payload.get("audio_b64") 原样保留,仅对其余字段走 redact_value。

4. CaptureTap:三类方向的时序观测

CaptureTap.start() 在通过白名单后创建一次调用(invocation)。CaptureInvocation.observe() 的观测边界记录三类 wire 事件:

  • client:客户端方向的观测(如解码后的客户端音频);
  • outbound:出站到供应商的观测(如发送给 STT 的 socket 数据);
  • inbound:供应商回调方向的观测(如 STT 返回的 transcript)。

每个 CassetteEvent 由 direction、dt_ms、payload 组成;dt_ms 是相对毫秒(相对 invocation 起点的时间差,由可注入的 time.monotonic 时钟计算,取整到毫秒),且必须非负(构造器强制校验)。persist() 把 schema_version: 1、identity、fingerprint 与事件数组写成紧凑 JSON(sort_keys=True, separators=(",",":"))到 cassettes/<identity-key>.json,并可选追加 surface / source 顶层判别字段(见第 6 节)。

白名单未命中时,CaptureTap.start() 不写任何 cassette 字节,只把 {"provider_lane": ..., "reason": "whitelist_miss"} 追加进有界元数据列表 denied_metadata,并裁剪到最近 100 条(del self.denied_metadata[:-100])。

5. 实时路径接线:/v4/listen 与 ListenParityCapture

README 指出,/v4/listen 运行时只在 Firebase WebSocket 认证完成、STT 供应商选定之后才会创建 routers.listen.parity_capture.ListenParityCapture。它:

  1. 用 Firebase UID 仅做 CaptureWhitelist 的精确比对;
  2. 在 cassette 创建前派生匿名会话/事件标识符(_anonymous_id 对 UID+session 做 SHA-256 并截取前 32 位十六进制,见 parity_capture.py);
  3. 记录解码后的客户端音频、成功的 STT socket 发送、以及供应商 transcript 回调;
  4. 在正常 listen 会话 teardown 期间持久化。

parity_capture.py 的 from_environ() 把门禁决策细化为可观测的分类(_allowlist_decision_reason):allowed、capture_disabled、stage_not_dev、principal_missing、allowlist_miss,并同步写入遥测。整条链路设计为采集绝不拖垮 listen 会话:初始化、观察、持久化任何一步异常都只记 warning 日志并降级为无操作(fail-closed,但服务本身继续)。

5.1 第四个变量:OMI_PARITY_PACK_ROOT

README 强调第四个、必需且仅操作者可设的变量,其值必须是仓库之外的绝对路径:

OMI_PARITY_PACK_ROOT=/absolute/restricted/local/parity-pack

没有默认 root。缺失、相对路径或位于仓库内的 root 一律禁用。源码 _capture_root() 落实了这一规则:先要求非空、expanduser 后必须 is_absolute(),再用 root.resolve().relative_to(repository_root) 探测——若解析后的 root 落在仓库目录树内则返回 None(禁用)。因此以下情况全部判定为禁用:

  • 未设置 OMI_PARITY_PACK_ROOT;
  • root 是相对路径;
  • root 位于仓库内;
  • OMI_ENV_STAGE 不是 dev;
  • 缺少 OMI_PARITY_PACK_CAPTURE=1;
  • 白名单未命中。

任何 Helm 或生产默认配置都不会开启这条路径。由于本地 cassette 可能包含受限的音频/transcript 事件载荷,必须把 root 放在 Git 之外,且永远不要把它附到 PR 上。

5.2 有界性约束:采集不是无限录音

parity_capture.py 与 live_capture.py 共享一组采集上限常量:

常量 值 含义
MAX_CAPTURE_EVENTS 1 000 单次 invocation 最多事件数
MAX_CAPTURE_AUDIO_BYTES 8 MiB 音频总字节上限
MAX_CAPTURE_TEXT_CHARS 16 384 单段文本截断长度
MAX_CAPTURE_SEQUENCE_ITEMS 1 000 序列元素截断数量

_can_observe() 在分配 base64 表示之前就检查原始输入(len(audio)),超限即置 _limit_reached 并丢弃后续事件,防止把 listen 会话的内存打爆。

6. 更多采集面:SurfaceParityCapture 与 surface 判别表

SurfaceParityCapture 复用同一套门禁/导出器,覆盖额外的记忆形成(memory-forming)表面。它在 cassette 文档上追加可选的顶层判别字段 surface / source,但保持 v1 的 identity、fingerprint、event 契约不变,因此存量播放器无需改动。README 的映射表如下:

surface source 采集接缝
ptt desktop_ptt_http、desktop_ptt_stream Desktop PCM PTT 与实时 PTT STT(有界音频 + transcript 事件)
screen desktop_screen_activity_sync 纯文本屏幕活动/上下文同步;无视频或 embedding 向量
conversation_finalization conversation_<source> transcript 输入、记忆抽取结果与已接受记忆
memory_write v3_memory_create、v3_memory_batch_create、integration_<app>、twitter_<persona> 手动/API、集成与社交记忆写入者
memory_import v3_memory_import_batch 有界导入产物与摄取结果(非原始媒体)

surface_parity_capture.py 提供了这些表面的通用实现:from_environ() 内部同样走 _capture_root → CaptureTap.start(),任何初始化异常都降级为禁用实例。对记忆载荷还定义了窄化函数 _memory_payload(),只保留 id、content(截断到 16K)、category、visibility、source_type 五个字段——绝不把完整证据链塞进 cassette。capture_memory_write() 则是一次写入一个"memory-write" cassettes 的便捷入口(request 仅含 memory_count 与 source,不暴露 UID)。对应的单元测试在 test_surface_parity_capture.py。

7. 开发部署:emptyDir 挂载与私有 GCS 导出

README 描述了开发 listen 部署的落地方式:挂载 /var/omi-parity-pack 作为 emptyDir,仅供显式白名单的 dogfood 主体验证者使用。Pod 的 fsGroup: 10001 与非 root 后端镜像组一致,使 listener 能创建并持久化 cassettes/ 目录,随后尽力而为地把 cassette JSON 导出到私有开发桶:

gs://based-hardware-dev-omi-parity-pack-v0/parity-pack/v0/cassettes/<identity-key>.json

导出是 fail-open 的:即使 GCS 宕机,listen 会话也照常继续(persist() 内对导出异常只记 warning)。README 给出的离线重放下载命令:

gcloud storage cp -r \
  "gs://based-hardware-dev-omi-parity-pack-v0/parity-pack/v0" \
  ./omi-parity-pack-dogfood/
# Point OMI_PARITY_PACK_ROOT at the local tree (or compose a pack with
# manifest.json as required by this README), then:
npm run test:parity-pack-v0

两条红线:永远不要把 cassettes 提升到生产存储,也不要提交进仓库;emptyDir 是临时存储,pod 重启后、成功导出之前的数据会丢失。

7.1 可观测性:零初始化计数与日志标记

开发 listen 采集暴露零初始化的 Prometheus 计数器 omi_parity_pack_capture_events_total{stage,outcome,reason_class} 及配套的 parity_pack_capture_event 日志标记。其封闭标签(closed labels)用于区分:接受的 listen、白名单决策、采集初始化、cassette 持久化、GCS 导出尝试/成功/失败。这些事件从不包含主体验证者或会话标识、载荷、凭据或 cassette 对象路径;非 dev 运行时既不递增该计数器也不写该日志。相关实现在 parity_telemetry.py。

8. 重放播放器:严格调用拓扑的回环适配器

采集之后是重放。STTCassettePlayer 与 LLMCassettePlayer 是 wire-oracle fakes 的回调式回环适配器(loopback adapters)。两者共享有序的 InvocationTopology:

  • play() 先验证完整身份与规范化脱敏请求指纹,再依次产出录制的 PlayedEvent(direction / dt_ms / payload)并交给 emit 回调;
  • assert_complete() 对未使用的 cassette 判定失败(unused cassettes: N);
  • 错位、乱序或多余调用立即失败(CassetteTopologyError)。

players.py 的具体行为:

情形 错误信息 含义
已消费完但还有新调用 extra invocation: <key> 多出的调用
身份与当前位置 cassette 不一致 out-of-order cassette: expected=... actual=... 乱序
请求指纹不一致 cassette request fingerprint mismatch 请求不匹配
player 通道不匹配 <lane> player cannot play <lane> 通道错配
消费后仍有剩余 unused cassettes: N 未用完的 cassette

STTCassettePlayer.lane = "stt",LLMCassettePlayer.lane = "llm",互不串位。_read() 还会校验 schema_version == 1,不支持的版本直接抛 ValueError。cassettes 始终是受限的本地/dev 输入,禁止提交。

9. 包目录布局与 manifest.json 契约

README 规定的受限本地包目录结构:

<restricted-local-pack>/
  manifest.json                 # hashes + case descriptors only
  inputs/<case>.json            # referenced by inputs_ref
  cassettes/<identity-key>.json # referenced by cassette_refs

manifest.json 记录:schema 版本、pack_id、产物哈希,以及每个 case 的:输入/cassette 引用、预期结果(expected outcomes)、不变量 ID、匿名 cassette 身份、脱敏请求指纹(仅摘要)。由 manifest.py 的 build_manifest() 生成(schema_version: 1、pack_id、cases、artifact_hashes 四段),sha256_file() 按 1 MiB 分块计算产物哈希。测试 test_manifest_has_references_hashes_outcomes_and_invariants 验证了 case 中 invariant_ids、artifact_hashes 的完整性。

基础检查命令是:

npm run test:parity-pack-v0

该脚本在仓库根 package.json 中映射到 backend/testing/parity_pack_v0/run.sh,后者在仓库根执行 PYTHONPATH=backend 的 pytest,覆盖 test_parity_pack_v0.py 与 test_parity_pack_v0_stage3.py 两组测试。

10. 金标、漂移与重写槽

10.1 double_run_gold:先双跑,再冻结

gold.py 的 double_run_gold() 在更新金标(gold)前把每个 case 跑两遍:

  • 两次结果摘要不同 → 直接抛 AssertionError(non-deterministic replay for <case_id>),拒绝非确定性结果;
  • 只有 write_gold=True 才允许改写 expected_outcomes(把第一次结果写回 manifest);普通重放只产出一份仅摘要、仅 warn 的漂移报告 drift_report()(status: warn/ok、drift_count、逐条 Drift(case_id, expected_digest, actual_digest)、enforcement: warn-only)。

设计意图很明确:漂移永不掩盖结果、永不阻塞开发者排查,它只是一个提示信号。金标变更必须显式走 write_gold=True,防止普通重放以副作用方式悄悄改掉本地包的预期结果。

10.2 rewrite_launch_descriptor:重写二进制的显式集成槽

rewrite_launch_descriptor() 是为未来重写二进制预留的显式集成槽位。描述符(RewriteLaunchDescriptor)包含 command、input_manifest、output_manifest、available,其中 available 恒为 False。README 特别强调:该描述符在本仓库中刻意不可用——操作者必须自行安装并调用经批准的二进制(默认名 omi-replay-rewrite),重放流程绝不会下载或执行任意二进制。这是对供应链安全的刻意约束:只有经人工批准、显式提供的二进制才能改写 cassette 数据。

11. 合成 v0 矩阵:六个 overlay

README 给出合成 v0 矩阵的六个 overlay 名称:baseline、duplicate_delivery、provider_timeout、provider_error、out_of_order_events、redacted_capture。它们不包含任何真实采集载荷。对应 matrix.py 的 SYNTHETIC_MATRIX,每个 overlay 用 delivery / provider / expected 三元组描述行为预期:

overlay delivery provider expected
baseline single recorded finalized(正常终结)
duplicate_delivery duplicate recorded idempotent(幂等)
provider_timeout single timeout recoverable(可恢复)
provider_error single error failed-safe(安全失败)
out_of_order_events reordered recorded rejected(拒绝)
redacted_capture single recorded no-sensitive-payload(无敏感载荷)

这组矩阵实际上把重放引擎要验证的六类不变量固化成了命名契约:正常路径、重复投递的幂等性、供应商超时的可恢复性、供应商错误的失败安全、乱序事件的拒绝,以及脱敏捕获确保无敏感载荷。

12. 操作者工作流(仅限 dev)

README 在最后给出三步操作者工作流,也是整套体系的收束:

  1. 本地隔离:把本地包放在仓库之外;绝不提交 cassettes、inputs、载荷或白名单。
  2. 显式门禁:设置 OMI_ENV_STAGE=dev、OMI_PARITY_PACK_CAPTURE=1,以及显式的 OMI_PARITY_PACK_ALLOWED_PRINCIPALS 白名单。任何其他阶段或缺失白名单都是默认拒绝,且不持久化任何 cassette 字节。
  3. Hermetic 重放:用已选择加入的合成/dev principal 运行应用路径,然后执行 npm run test:parity-pack-v0 进行隔离重放。测试拒绝外网 egress 并要求 Fake 命中记账,不使用任何真实供应商或生产服务。

12.1 hermetic 保障如何落地

runner.py 是第 3 步的技术保障:deny_network() 委托给仓库验证过的 block_outbound_network(见 backend/testing/hermetic_network.py),连底层 socket.connect、connect_ex 和 DNS 解析都一并封死;任何被拦截的 egress 都会以 UnexpectedEgress 断言失败。hermetic_run() 在此之上叠加 FakeHitRegistry:每个 Fake 被调用一次记一次 hit(),最后由 require(**expected) 做精确命中计数比对(多了少了都失败)。清理钩子按逆序执行且彼此隔离——某个清理钩子失败不会阻断其他钩子,body 异常永远优先于清理异常上报。测试 test_parity_pack_v0.py 直接导入了 hermetic_run 与 UnexpectedEgress,验证重放确实在完全离线的环境里完成。


小结

Parity Pack v0 是一套把"隐私安全"与"契约测试"焊死在一起的本地重放体系:CaptureWhitelist 的默认拒绝门禁、CassetteIdentity/RequestFingerprint 的匿名与脱敏、CaptureTap 三类方向的事件观测、SurfaceParityCapture 的多种记忆表面、STTCassettePlayer/LLMCassettePlayer 的严格拓扑重放、double_run_gold 的双跑金标与 warn-only 漂移,以及 rewrite_launch_descriptor 的安全重写槽位,共同构成一条从 dev 采集到 hermetic 重放的完整闭环。所有机制都以"载荷不落 Git、身份不落日志、egress 一律封禁"为底线,任何一环失守都会以测试失败或显式禁用收场。对需要为供应商密集型后端(STT/LLM/记忆写入)建立可回归、可离线验证的契约测试体系的工程团队而言,这套 pack 契约与目录布局是一个可直接借鉴的范本。

登录后查看全文
Friend