Skip to main content

relaycheck

CI PyPI License: MIT

检测 LLM API 中转站(relay / 代理商 / 聚合站)是否掉包模型、是否虚报计费。

你付的是 claude-3-5-sonnet 的钱,拿到的是不是 deepseek-chat?你说 max_tokens=16, 账单上为什么是 268 个 output token?这个工具用可复现的证据回答这两个问题。

relaycheck 是只读的。它只发 chat completions、只读面板自己公开或与你账号相关的端点。 它不注册账号、不兑换任何东西、不修改远端状态。

English documentation: README.en.md


为什么需要它

中转站可以在服务端做这些事,而你从响应里看不出来:

它做了什么 你看到的
把你请求的 model 字段改写成另一个便宜模型 响应里 model 字段被改回你请求的名字
把提示词改写、丢给一个通用后端 一个格式正确、内容通顺的回答
隐藏思维链,但照 output 价格计费 你只要一个词,却计费了 350 个 token
接受 max_tokens / stop / tools 然后忽略 HTTP 200,参数静默消失
流式返回一个短答案,非流式返回另一个 两条路径各自看起来都正常
长输入只转发最后几千个字符,却按全文计费 回答照样通顺,history 却莫名其妙地「记不住」

这些行为都不会留下你能直接读到的痕迹。 但它们会留下行为指纹——同一个后端伪装成 两个模型时,它的 tokenizer 和输出会露出马脚。


一条硬规则:CLEAN 必须意味着「已经验证过」

这是整个工具最重要的一条不变量,比任何单个探针都重要。

报告里的 CLEAN 不是「没发现问题」,而是「这一项查过了,结果是好的」。 这两者的区别在真实场景里是致命的:

  • 中转站在抖动,8 次请求全部超时;
  • 如果「拿不到回答」被当成「回答看起来没问题」,报告会给出一个全绿的结果——
  • 而一个全绿的报告比没有报告更糟:它把一次真实的掉包洗成了清白证明。

所以每个探针都必须区分六种结局,并且只有第一种能写 CLEAN:

结局 报告里长什么样
检查跑了,结果正常 CLEAN(如 canary-clean / params-clean)
检查根本没跑成 INFO(budget-000,以及 tok-000 / echo-000 / twins-000 / id-000 / canary-000 / bill-000 / bill-202 / params-102 / stream-000 / ctx-000 / ctx-102)
检查跑了,但没有可判定的内容 INFO(params-103:样本为空或过短;stream-103:两条路径的回复都是空的)
检查跑了,但该站不可复现,比对本身无效 INFO(params-104:temperature=0 两次结果不同;stream-104:两次相同请求返回不同,流式比对无意义)
检查跑了,看到值得记录的异常,但它不构成指控 INFO(id-100:模型自述的厂商与售卖名称不符;echo-101:响应自称的是同厂商的另一个名字;id-101:模型自述不稳定;id-102:自述未能复核;bill-201:面板公开了上游成本但没加价)
检查跑了,发现异常 LOW / MEDIUM / HIGH / CRITICAL

*-000 是保留编号,专用来表示「这一项没测成」,永远不是一个指控; budget-000 表示探针被墙钟预算提前掐断,bill-202 表示面板端点全不可达。

第三行是曾经缺掉的那一种。前两种都在说「我没测出来」, 第三种却在说「我测了,但手里那个空字符串什么也证明不了」——把空字符串 当成「不一致」或「参数被忽略」,就是拿自己的预算问题去指控别人的中转站。 这一行是踩过坑之后补上的,reasoning 场景和它的回归测试专门守着它。 看到它们请当成空白,不是合格。

第四行是同一个错误的另一种穿法。这次手里不是空字符串,而是两段真的不一样 的文字——但该站在 temperature=0 下自己都不能复现自己(MoE 路由、批处理都会 这样),那么「流式与完整回答不同」和「两次调用结果不同」都是噪声的预期表现, 不是证据。所以规则是:先量底噪,再下结论。量不出底噪,就如实写「无法判定」, 而不是把噪声写成 HIGH。noisy 场景和它的回归测试守着这一条。

第五行守的是相反方向的错误:确实看到了点什么,但它撑不起一条指控。 一个模型自称是另一个厂商(id-100),这听起来很像「抓到了」,但自述本身就是 训练语料喂出来的,非 OpenAI 的模型自称 OpenAI 是常态 —— 它只能当线索记一笔, 撑不起一条指控,所以是 INFO 而不是 LOW。它自己同一句问题问两遍答得都不一样 (id-101)就更不算数了。响应体自称的是同厂商的另一个名字(echo-101), 或者面板自己公布的上游成本里根本没有加价(bill-201 降成 INFO),也都是同一类: 值得写进报告,不值得写进指控栏。 把这些塞进 MEDIUM,和把这些直接删掉, 是同一个错误的两种穿法——前者冤枉好人,后者让读者以为「没写就是没问题」。

这条规则是测试守着的,不是靠自觉:dead 场景(面板活着、但每一次 completion 都失败)断言报告里不允许出现任何「没查就判清白」的 finding id。 slow 场景(行为正确但极慢)断言被截断的探针必须自报「未跑完」,而不是报通过。

对使用者的一句话:看到 INFO 的 *-000 / *-102,请把它当成空白而不是合格。 你要的是「稳定期重跑一次」,而不是「看起来没问题」。


检测原理(为什么这些证据站得住)

1. Tokenizer 指纹 —— 最便宜、最硬的证据

把 8 个固定测试串(英文 / 中文 / 代码 / emoji / 混合 / 空白 / 数字 / JSON)分别发给每个模型, 每次 max_tokens=1,读 usage.prompt_tokens。

两个声称来自不同厂商的模型,如果 8 个串的 token 数全部一致,它们用的是同一个 tokenizer。 同一个 tokenizer 意味着同一个模型家族——不同厂商不可能共享。

成本:每个模型 9 次请求,每次只输出 1 个 token。

诚实边界:相同 tokenizer 只证明同家族,不证明同权重(gpt-4o 和 gpt-4o-mini 共享 tokenizer)。所以探针会先看这些名字自称是哪个厂商:

  • 名字互相矛盾(claude-3-5-sonnet 和 deepseek-chat 却共享 tokenizer)→ tok-100 MEDIUM;
  • 名字本来就同厂商(gpt-4o 和 gpt-4o-mini)→ tok-101 INFO,只作为事实记录, 明确写着「这不是掉包证据」。

一条规则:只有互相矛盾的名称才构成证据。名称解析不出厂商时按「未知」处理, 绝不把「未知」读成「冲突」。

2. 响应体自报的模型名 —— 一条几乎白送的硬证据

每个兼容 OpenAI 协议的网关都必须在回复里填 model 字段。一个把请求真的转发到 另一个后端的站,通常会把后端的名字原样带出来 —— 改写它属于额外的工作,而这个字段 又不是客户端会检查的东西。

于是「你付钱买的模型名」和「上游自称的模型名」可以摆进同一条证据,代价是每个模型 两次请求(完整返回一次、流式返回一次,因为那是站方要分别填写的两个位置)。

这条检查不把那个字段当口供:

情况 判定
与请求名完全一致 echo-clean
名字不同但同厂商(gpt-4o → gpt-4o-2024-11-20) echo-101 INFO,明写「这不是指控」
名字不同且跨厂商(请求 deepseek-chat,响应自称 claude-3-5-sonnet) echo-100 MEDIUM / 疑似
字段为空,或名字看不出属于哪家厂商 echo-102 INFO:无法对照

诚实边界:字段由上游填写,所以它可能是站方伪造的、被统一改写成售卖名的,也可能是空的。 跨厂商冲突通常意味着请求真的落在了另一个后端上,但也可能是站方套了一层自己的 上游命名。所以 echo-100 的信度是「疑似」而不是「已确认」,措辞里也请你把它和 twins、tokenizer 一起读。

反方向同样重要:只有每个模型在两条路径上都拿到了能对照的响应,才会输出 echo-clean。有请求失败、或预算提前用完,一律是 echo-000 INFO —— 「没测成」永远不许写成「没问题」。

3. 双胞胎比对 —— 掉包的直接证据

把同一组开放式提示词发给每个模型(temperature=0),两两比对输出。

关键设计:提示词必须是开放式的。 早期版本用了「写出 1 到 20」「计算 17×23」这类唯一正确答案的提示词——这是个陷阱: 两个真的不同的模型都会输出 1 2 3 ... 20,于是每个诚实的中转站都会被判成骗子。 只有当答案空间足够大时,「逐字节一致」才是有意义的证据。

所以用的是:虚构的协议名、关于丢包的俳句、不存在的颜色、想象中的城市。

每个提示词对同一个模型跑两次。只有两次逐字一致的提示词才进入比对——如果后端本身不可复现, 跨模型的差异就什么都证明不了,跨模型的一致也只是运气。不可复现的提示词会被丢弃并如实 记录为局限,而不是悄悄产生一个假阴性。

严重程度阶梯:

情况 判定
tokenizer 相同 + 输出一致 + 声称不同厂商 CRITICAL(已确认)
声称不同厂商 + 输出一致 HIGH
tokenizer 相同 + 输出一致 + 声称同一厂商 LOW(twins-101,不下结论)
输出一致(厂商未知) MEDIUM(疑似)

最后那行 LOW 是刻意压低的。同一厂商的两个名字输出逐字一致,可能是合法的别名 (同一套权重挂在两个名字下,例如 gpt-4o 与 chatgpt-4o-latest),也可能是把一个后端 当成两个档位卖给你。单独的输出比对区分不了这两种情况,所以它报 LOW 并且不下结论—— LOW 不触发默认的 --fail-on high,不会把一个诚实的别名对判成骗子。

4. Canary 注入 —— 提示词有没有原样送达

在提示词里塞一个唯一标记 RC-XXXXXXXXXXXX,要求模型复述。正常转发的中转站会照做。 不回显说明提示词被中间层改写,或者返回的是预置/缓存答案。

5. 隐藏思维链计费

要一个词(max_tokens 很小),看 completion_tokens 与可见字符数是否严重不成比例。 如果响应里既没有 reasoning_content 字段、也没有上报 reasoning_tokens,但 completion 仍然很大, 那就是思维链被隐藏了、却照 output 价格收费。

6. 参数透传

每一项都是行为测试,不信 HTTP 200:

  • max_tokens:要 16,账单却 > 24 → 忽略
  • stop:差分测试——先拿基线(含 stop token),再带 stop 请求。生效则被截断,被忽略则返回完全一样
  • temperature:两阶段——temperature=0 必须可复现,temperature=1.5 两次必须不同(相同说明采样被丢弃)
  • n=2 / response_format / logprobs / tools:看返回里到底有没有

temperature 这一项有个反直觉的地方:temperature=0 不可复现不等于参数被丢弃。 MoE 路由、批处理的后端本身就不确定,诚实站也会这样。所以「两次结果不同」 单列为 params-104(INFO,明确写着不指控参数被忽略), params-100(MEDIUM)只留给「有可判定样本、且样本之间矛盾」的情况。 分不清就不指控——这是这个项目最基本的一条纪律。

7. 流式完整性

同一提示词,流式与非流式用完全相同的采样参数各跑一次,并且把非流式那一次再跑一遍作对照。 三件事按顺序判定:

  1. 两次非流式请求如果自己就不一样 → 该站没有可复现输出,开放式比对作废(不能拿噪声当掉包证据); 此时改用确定性问答回退(37 * 41,只有一个正确答案,采样噪声解释不了差异), 仍是一致 → stream-104(INFO):没测成,但如实报告;真有分歧 → stream-100(HIGH)。
  2. 两条路径内容不一致 → stream-100(HIGH),两条路径不是同一个后端或计费口径不同。
  3. 流式不返回 usage → stream-101(MEDIUM),你无法对账。

第一版这里犯过错:流式路径没传 temperature,非流式传了 temperature=0, 然后把「两次不同参数的输出不同」当成掉包,发了 HIGH。改了参数对齐还不够, 于是又补了上面第 1 步的对照与回退。

8. 面板自报口径

读 /v1/sub2api/billing、/v1/usage、/api/v1/settings/public 等端点。 如果 /v1/usage 同时给出 cost(向你收的)和 account_cost(它自己记的上游成本), 加价倍率就不再是猜测,而是平台自己的账。

商家口头说「按请求数计费」时:面板 /v1/sub2api/billing 的 billing_scope 是权威值。 billing_scope=token 就说明按 token 计费,口头说法是假的。

9. 可用性 —— 先确认对方到底能不能干活

这条不是掉包检测,但没有它,上面所有结论都可能站不住。

探针发 8 次最简请求(max_tokens=8),关闭重试统计原始成功率与延迟分位数。 关闭重试是刻意的:开了重试就看不到真实的失败率,而那正是要测的东西。

  • 失败率 > 20% → HIGH。一个连一句话都服务不好的中转站, 既不能稳定干活,也让其他探针的结论随时可能不完整。
  • 中位延迟 > 15 秒 → MEDIUM。通常意味着共享池排队或被限速,而不是直连官方。

结果会同时写进报告的备注里,所以读报告的人第一眼就知道: 后面那些「没发现问题」到底是真的没问题,还是压根没跑完。

10. 上下文完整性 —— 长输入有没有被悄悄砍掉

这条针对一种很不显眼、但很花钱的行为:中转站宣称支持 128k 上下文, 实际只把最后几千个字符转发给后端,前面的全丢了,然后照你发送的完整长度计费。

它比掉包难发现得多,因为没有任何异常信号:不报错、不警告、HTTP 200、 回答通顺、usage 里的 prompt_tokens 看着也合理。你只会觉得「这模型记性不好」。

做法是「两标记针法」:

  1. 在一个长输入的文首埋一个标记 ALPHA-<12 位随机码>,文末埋一个 BETA-<12 位随机码>, 中间填满无意义文本,再要求模型按顺序回显这两个标记。
  2. 从浅到深逐级加大输入(默认 2000 → 8000 → 32000 tokens),一旦发现截断立即停止,不再往上烧钱。

判读方式是差分,不依赖模型的记忆力:

观察到 结论
两个标记都取回 该深度以内没有被截断
只有文末标记 头部被丢弃(最常见的截断方式)
只有文首标记 尾部被丢弃
两个都没有 当作不可判读,不算证据

两个标记都是随机生成的十二位字符串,模型不可能凭空猜出其中一个却漏掉另一个, 所以「只回显文末」这个结果只有一种解释。

  • 有更浅一档完整通过作为对照 → HIGH / 很可能
  • 最深一档就丢标记、没有对照 → MEDIUM / 疑似
  • 明确被拒绝(413 / 414 / 422,或 400 且正文提到 token 上限)→ INFO。 这是如实告知,不是欺诈;很多服务就是这么做的。

诚实的中转站也会在某个深度之后开始丢内容——那是它自己后端的上限。 所以 ctx-clean 的措辞是「在测试范围内完整到达」,并明确写出 「这只是下限,不代表该站承诺的上限一定成立」。探针不会把它读成「支持 128k」。

这个探针每档深度都要发一次几千到几万 token 的请求,很贵, 因此默认不启用(属于 --probes all),且默认只测 1 个模型。


安装

# 从 PyPI 装
pip install relaycheck

# 想跟 main 上还没发版的最新提交
pip install "git+https://github.com/jayson-yxj/relaycheck.git"

# 或者从源码目录装,会建出 relaycheck 这个命令
pip install .

# 开发模式(带 pytest)
pip install -e ".[dev]"

# 或者干脆不装,直接跑
python -m relaycheck.cli --help

依赖只有 requests。Python ≥ 3.9。

关于版本:语法用 ast.parse(..., feature_version=(3, 9)) 逐文件核对过(20 个文件,0 个不兼容)。 端到端由 CI 在 Linux 的 3.9 / 3.11 / 3.13 与 Windows、macOS 的 3.11 上跑同一套验收, 本地开发机是 3.11 —— 所以更老的版本是靠 CI 保下来的,不是靠手感。

不装 Python:Windows 桌面版

给完全不想碰命令行的人一个能双击的东西。从 Releases 页下载 relaycheck-<版本>-windows-x64.zip,解压,双击 relaycheck-desktop\relaycheck-gui.exe —— 目标机器不用装 Python,也不用碰命令行。

想自己构建的话,仓库里带一套 PyInstaller 打包脚本:

.\gui\build.ps1        # 自建 .venv-build,产出 dist\relaycheck-desktop\

界面只做三件事:填地址和 Key、点「开始检测」、看结论卡片和日志。它不重写审计逻辑 —— 界面把表单拼成和命令行完全一样的 argv,交给子进程跑 relaycheck.cli,再读 report.json 渲染结论。所以界面永远不可能给出和命令行不一样的结论。

两个刻意的设计:API Key 走环境变量,绝不进命令行(Windows 上任何进程都能通过 WMI 读到别的进程的命令行),审计跑在子进程而不是线程里(卡在 60 秒 socket 超时里的线程 中断不掉,「停止」按钮会是假的)。

三个必须提前知道的事,都没有免费解法:

问题 现状
杀毒软件误报 PyInstaller 的打包方式本身就是恶意打包器的常见特征,360 / 火绒 / Defender 都可能拦或直接删。已用 onedir(不是 onefile)并关掉 UPX 来压低误报率,但要彻底解决得做代码签名
SmartScreen 拦截 没有签名证书,用户第一次双击会看到「Windows 已保护你的电脑」,得点「更多信息」→「仍要运行」。证书一年约 $100–400,这一个弹窗就足以劝退大部分小白,是目前最大的落地障碍
体积 整个目录约 30 MB,分发时必须整个拷走,只拷 exe 起不来

细节见 gui/README.md。


用法

# 最简:自动发现模型,跑默认探针
relaycheck --base-url https://api.example.com --api-key sk-xxxx

# 指定要对比的模型(双胞胎检测最有价值:尽量选声称来自不同厂商的)
relaycheck -u https://api.example.com -k sk-xxxx \
    --models "gpt-4o,claude-3-5-sonnet,deepseek-chat,gemini-1.5-pro"

# 全量探针(含参数透传、流式完整性与长输入截断)
relaycheck -u https://api.example.com -k sk-xxxx --probes all

# 只跑最便宜、最硬的三个探针
relaycheck -u https://api.example.com -k sk-xxxx --probes echo,tokenizer,twins

# 只查长输入有没有被悄悄砍掉(每次请求都很贵,按需使用)
relaycheck -u https://api.example.com -k sk-xxxx --probes context \
    --context-sizes 8000,32000,128000

# 对方特别慢?先只测可用性,确认它到底能不能服务
relaycheck -u https://api.example.com -k sk-xxxx --probes reliability

Key 也可以走环境变量:RELAYCHECK_API_KEY / OPENAI_API_KEY。

常用参数

参数 说明
-m, --models 受测模型,逗号分隔。缺省时从 /v1/models 里按厂商多样性挑选
--max-models 最多测几个(默认 6,控制请求数与花费)
--probes 探针子集或 all
--delay 请求间隔秒数(默认 0.4,避免触发限流)
--timeout 单次请求超时秒数(默认 60)
--budget 每个探针的墙钟预算秒数(默认 240)。见下节
--reliability-samples 可用性探针采样次数(默认 8)
--context-sizes 上下文探针的测试深度(token,默认 2000,8000,32000),逐级加深,发现截断即停
--context-max-models 上下文探针最多测几个模型(默认 1;该探针很贵)
--fail-on 达到该级别返回退出码 1(默认 high)
--list-probes / --list-models 只看清单

关于 --budget:慢站点不会被挂死

真实世界里大量中转站是又慢又不稳的。作者实测过一家:最简请求(只要求回一个词、 输出上限 8 token)连发 9 次,5 次在 45 秒内没有返回;同一模型一会儿 1.9 秒、一会儿 45 秒超时。没有预算的话,tokenizer 探针那 27 个请求会跑几十分钟,看起来就像卡死。

所以每个探针都有独立的墙钟预算(默认 240 秒)。预算用尽时:

  • 探针立即停止,客户端在每次请求前和请求超时上都受同一个截止时间约束;
  • 报告里明确写出「该探针因超时预算提前结束,未完成的部分没有结论」, 并列出完成了多少;

重点:被截断的探针绝不会显示为「通过」。宁可报告不完整,也不给一个假清白。 想跑得更完整就调大 --budget(--budget 0 表示不限),但请先确认对方值不值得等。

慢站点跑的时候你会看到心跳行,用来区分「慢」和「卡死」:

  → reliability …
      · DeepSeek v4 Flash 第 1/8 次探测中…
      · DeepSeek v4 Flash 第 2/8 次探测中…
    ✓ reliability: high (8 请求 / 241.3s) [预算用尽,未跑完]
      成功率 3/8(失败率 62%),中位延迟 2.9s

输出

  • report.md —— 可读报告,可以直接作为投诉附件
  • report.json —— 全部原始数据,任何结论都能自行复核

退出码:0 无问题 / 1 达到 --fail-on / 2 运行失败。

这三个码必须互不重合,所以代码里有一条硬约束:任何异常都不许以退出码 1 收场。 一次崩溃和一条真实指控如果都返回 1,从 shell 里看就没法区分了 —— 脚本会把它当成 「查出来了」。同理,进度行里的字符编不出来也不许中断审计:Windows 控制台默认是 GBK, ✓ 这类字符编不出,旧版本会在这里抛 UnicodeEncodeError,审计在探针循环中间死掉、 报告一个字都没写、退出码还是 1。现在只放宽 errors(不放宽 encoding),编不出的字符 退化成 ?,中文在 cmd.exe 里照旧正常显示。

report.md / report.json 一律是 UTF-8,与控制台代码页无关。想让 stdout 也变 UTF-8 (比如重定向进日志再拿 UTF-8 工具读),设环境变量 PYTHONUTF8=1 即可。

换行一律是 \n,Windows 上也是。否则同一份审计在两台机器上产出的报告会有一个整文件的 差异(每行都「变了」),真正变了的那个字段反而看不出来。


报告怎么用

每条 finding 都带严重程度和可信度两个独立字段。这两件事必须分开: 「这很糟」和「我们能确定这很糟」是不同的主张。

  • CRITICAL + 已确认 = 可以直接拿去对质的证据
  • HIGH + 很可能 = 强推断,需要商家提供日志才能彻底定论
  • MEDIUM + 疑似 = 值得人看一眼的信号,不要单独拿它去指控

对质时的说法(以 twins-100 为例):

你们的 claude-3-5-sonnet 和 deepseek-chat 在 8 个固定测试串上返回完全一致的 prompt_tokens,并且在 4 个开放性提示词上、每个提示词各采样 2 次、输出全部逐字节一致。 请出示这两个模型各自的上游调用凭据与账单。


诚实的局限

这个工具的结论有明确边界。用之前务必读这一段,否则会做出自己无法支撑的指控。

  1. 相同 tokenizer ≠ 相同权重。 只证明同家族。必须配合行为比对。
  2. 行为一致可能是缓存,不一定是掉包。 canary 探针就是用来区分这两种情况的。
  3. 模型自述不可靠。 模型会幻觉自己的身份,非 OpenAI 的模型自称 OpenAI 是常见现象 (训练语料所致)——诚实站上照样会出现:实测中一个正常转售 deepseek-v4-flash 的站,该模型两轮都稳定地自称 OpenAI。所以 id-100 只是 INFO 的 疑似,标题里写着 「线索,非结论」:它只负责记下「这个模型说自己是谁」,结论由 tokenizer 和 twins 出。 它一路从 MEDIUM 降到 LOW、再降到 INFO,理由都是同一条:撑不起指控的东西不该出现在 指控栏里,否则每一个转售 DeepSeek 的诚实站都会挨一条 LOW。 另外还有一道门槛:自述只有被重复一遍才算线索。 问到某个外国厂商时,探针会把同一个 问题再问一次;两次都点名同一个外国厂商才输出 id-100,答得不一致就降级成 id-101(INFO,措辞里明确写着不指控掉包)。
  4. 参数被忽略可能只是兼容层缺陷,不必然是恶意。报告中已按此区分(params-100 MEDIUM / params-101 INFO)。
  5. 部分模型可能不支持某些能力(如 logprobs、tools)。探针会把「明确报错」与 「静默忽略」分开记录——前者是兼容性缺口,后者才是欺骗。
  6. 请求会消耗你的额度。 全量探针在 6 个模型上约 210 次请求。先用 --probes echo,tokenizer,twins 做初筛——echo 每个模型只花两次请求, 是最便宜的硬检查。
  7. 限流可能使部分探针无法完成。 探针会如实报告「未能执行」,不会把崩溃当成通过。
  8. 推理模型会先烧隐藏推理,再写正文。 max_tokens 给得太小的时候,返回的可见正文是 空的、finish_reason 是 length、reasoning_content 里却写了一大段。 空回复不是证据:探针会先按放大后的预算重试一次,再去比较内容;确实拿不到内容的 检查一律记成「未检验」(params-103 / stream-103 / *-000),绝不写成「参数被忽略」 或「两条路径不一致」。这一条是踩过坑之后补上的——在一个真实且诚实的中转站上, 这个形状曾经让本工具误报出一条 HIGH 和一条 MEDIUM。
  9. 不可复现的站不能用「比对」来指控。 有些后端(MoE 路由、批处理)在 temperature=0 下本身就不确定,同一个请求两次返回不同的文字。此时 「流式与完整回答不同」和「两次调用结果不同」都是噪声的预期表现。 探针会先量底噪:量不出来就退回确定性问答,仍判定不了就写 stream-104 / params-104(都是 INFO,措辞里明确写着不指控)。 把这种噪声写成 HIGH 或 MEDIUM,是同一个错误的两种穿法。 这一条同样来自那个真实诚实站——修完推理模型的空回复之后,紧接着踩到的就是它。
  10. ctx-clean 只是一个下限。 它只证明「在本次测试到的最深一档以内,输入完整到达」, 不证明该站宣称的上下文长度成立。想看更深的深度就调大 --context-sizes—— 代价是每次请求都更长、更贵。
  11. 响应体里的 model 字段不是口供。 它是站方填的:可以伪造,可以被统一改写成售卖名, 也可以是空的。所以跨厂商冲突只能算 疑似(echo-100),而且它和 twins、 tokenizer 是三种互相独立的观察,请一起读。这个探针真正的价值在反方向: 扣掉它之后,只有两条路径都拿到可对照响应的模型才会输出 echo-clean, 拿不到就是 echo-000 INFO。

开发

# 自测:本地模拟中转站,八个场景
python tests/test_mock_relay.py

# 模型选择的纯函数单元测试(秒级,不联网)
python tests/test_selection.py

# 桌面壳:key 不进 argv、窗口通道、结论卡片与 reporter 同步
python tests/test_gui.py

tests/mock_relay.py 起八个本地服务:

  • fraudulent —— 4 个模型名由 2 个后端提供,跨厂商别名、参数全部忽略、流式返回不一致、 隐藏思维链计费、长输入被砍到只剩最后 2 万个字符(约 5k token,正好落在上下文探针 默认前两档之间,所以能看到「浅的一档通过、深的一档丢标记」这个明确的形状)
  • clean —— 3 个真实不同的 tokenizer、诚实自述、精确计费、参数全部生效、长输入完整转发
  • same-vendor —— 同一厂商的两个名字挂在一个后端上(合法别名的情形),其余一切诚实
  • slow —— 行为完全正确,但每次请求慢 2~3 秒(用来验证探针时间预算)
  • dead —— 面板和模型列表正常,但每一次 completion 都失败(用来验证上一条硬规则)
  • reasoning —— 推理模型后端:小 max_tokens 时可见正文为空、reasoning_content 有内容、finish_reason 是 length,计费如实包含推理 token。其余一切诚实。 这个场景守的是第三种错误,也正是真实站点上踩到的那一种:两个空字符串不能被读成 「两条路径不一致」,空样本也不能被读成「参数被忽略」
  • noisy —— 诚实但输出不可复现:temperature=0 下同一个请求连发两次会得到两段不同的 文字,其余一切诚实。守的是第四种错误:噪声不是证据;而且探针不许在这里沉默—— 该站既然自己都不可复现,报告就必须把这件事说出来(stream-104 / params-104,都是 INFO)
  • unstable-self —— 模型每次自述都换一个厂商(同一句问题问两遍得到不同答案), 其余一切诚实。守的是第五种错误:一条连自己都重复不了的自述不构成线索, 必须降级为 id-101(INFO),而不是发一条 id-100

验收标准是两半:fraudulent 必须被抓出来(≥1 条 CRITICAL,且是具体的 finding id), clean、same-vendor、slow、dead、reasoning、noisy、unstable-self 必须零误报 (same-vendor 允许一条 LOW 别名提示)。 一个对诚实中转站乱叫的工具比没有工具更糟——它会把一次真实的指控洗成噪音。 「慢」不等于「有问题」,自己的超时更不能变成对别人的指控,我们的 token 预算也不等于 对方的参数有问题。

「零误报」不是口号,是回归测试:

  • test_same_vendor_aliases_are_not_accused 断言 same-vendor 场景下没有 MEDIUM 及以上的 finding、tok-100 和 twins-100 都不出现,而 tok-101 和 twins-101 出现。 任何把「同厂商共享 tokenizer」重新升级成指控的改动都会当场挂掉。
  • test_dead_relay_is_never_reported_as_clean 断言 dead 场景下不允许出现 id-clean / canary-clean / stream-clean / params-clean / twins-clean / ctx-clean 中的任何一个,并且必须出现 id-000 / canary-000 / stream-000 / ctx-000 / rel-100。 任何把「没查成」重新写成「查过了没问题」的改动都会当场挂掉。
  • test_context_probe_catches_silent_truncation 断言 fraudulent 场景下 ctx-100 报到 HIGH(因为有更浅的对照深度)、missing == "head"、且证据里带着服务端 回报的 prompt_tokens;同时断言 clean 场景下真的出现 ctx-clean—— 否则这个探针可能腐烂成永远只发 ctx-000,而所有「干净」的报告都会在这里变得毫无意义。
  • test_reasoning_relay_is_not_falsely_accused 断言 reasoning 场景下没有任何 CLEAN 以上的 finding,特别是 stream-100 和 params-100 都不出现;同时断言 twins-clean / id-clean / canary-clean 真的出现——光「不误报」不够, 放大预算后探针必须真的得出结论,而不是集体退化成一堆 *-000。 这个场景来自一次真实误报:诚实站的两个推理模型,让小预算的探针把空回复当成了证据。
  • test_noisy_relay_is_not_falsely_accused 断言 noisy 场景下没有任何 CLEAN 以上的 finding,特别是 stream-100 和 params-100 都不出现;同时断言 stream-104 和 params-104 真的出现(这里沉默也是一种撒谎:该站自己都不可复现,报告必须说出来), 并且 stream-clean 不出现——开放式比对已经被弃用,宣称「流式与完整返回一致」等于 把一次没有真正执行的比对写成了通过。
  • test_echo_probe_compares_the_reported_model_name 断言 fraudulent 场景只跑 echo 也会得到 echo-100(MEDIUM/疑似),且证据里点名的正是那两个跨厂商别名、完整与流式 两条路径都被抓到;clean 只得到 echo-clean;same-vendor 得到 echo-101 而没有 echo-100。任何把「同一个后端挂了两个名字」重新升级成跨厂商指控的改动都会当场挂掉。
  • test_unstable_self_report_is_not_an_accusation 断言 unstable-self 场景下没有任何 CLEAN 以上的 finding、id-100 不出现、id-101 出现、id-clean 也不出现—— 最后这一条同样是防沉默:自述既然不稳定,报告不能一边说「未发现矛盾」。
  • test_cli_survives_a_legacy_console_encoding 在子进程里把 stdout 钉到 cp936(中国区 Windows 的默认代码页)跑一遍真实 CLI,断言它正常收尾、写出了报告、退出码为 0。 这条守的是另一类混淆:进度行里有个 cp936 编不出来的字符(✓),它会在探针循环中间抛 UnicodeEncodeError,于是一次崩溃以退出码 1 收场,和「发现了高于阈值的问题」撞在一起。 从 shell 里看,一个排版 bug 和一条真实指控长得一模一样。
  • test_model_autodiscovery_runs_without_models_flag 断言不带 --models 时(第一次用的人 走的就是这条路)模型真的是从 /v1/models 按厂商多样性挑出来的:mock 摆出 3 个不同厂商的 模型,--max-models 2 必须各取一个,而不是照目录顺序取前两个。 这条守的是一种沉默的失败:挑错了模型不会报错,探针会拿同一个后端跟自己比,然后报告 说这家站很干净。选择错误是唯一一类「没有任何可见症状」的错误。
python tests/mock_relay.py --port 8123 --scenario fraudulent --verbose

项目结构

relaycheck/
  client.py        HTTP 客户端(重试、退避、截止时间、SSE 解析、base_url 归一)
  models.py        Finding / Severity / Confidence / Usage / Completion
  families.py      厂商关键词 → 家族判定(selection / echo / tokenizer / identity 共用一份)
  selection.py     从 /v1/models 按厂商多样性挑模型
  reporter.py      文本 / Markdown / JSON 报告
  cli.py           命令行入口
  probes/
    base.py        探针框架(崩溃隔离 + 墙钟预算 + 进度心跳)
    reliability.py 可用性:成功率 / 延迟分位数(关重试测原始值)
    echo.py        响应体自报的模型名(完整返回 + 流式返回各一次)
    tokenizer.py   tokenizer 指纹
    twins.py       双胞胎行为比对
    identity.py    身份自述 + canary 注入
    billing.py     隐藏思维链计费 + 面板口径
    params.py      参数透传(7 项行为测试)
    stream.py      流式完整性
    context.py     长输入是否被静默截断(文首/文末双标记 + 升序阶梯)
tests/
  mock_relay.py        模拟中转站(八个场景)
  test_mock_relay.py   端到端验收(15 项)
  test_selection.py    模型选择单元测试(15 项,不联网、不起服务)
  test_gui.py          桌面壳单元测试(12 项,无 tkinter 时跳过)
gui/
  relaycheck_gui.py        桌面壳:拼 argv、跑子进程、读 report.json 渲染结论
  relaycheck_cli_entry.py  控制台引擎入口(GUI 的子进程用,不进 pip 包)
  relaycheck_gui.spec      PyInstaller:一个 COLLECT,两个 exe
  build.ps1                一键构建(自建 .venv-build)
  e2e_bundle.py            源码 / 引擎 exe / 窗口 exe 跑同一份审计,逐字段比对
  README.md                桌面版说明与分发注意事项
examples/
  report-*.md          四份真实工具输出(掉包 / 诚实 / 死站 / 不可复现)
.github/workflows/
  ci.yml               3.9 / 3.11 / 3.13 × Linux,外加 Windows 与 macOS 各一条腿
  release.yml          打 tag 时经 Trusted Publishing 发到 PyPI(仓库里不存任何凭据)
  desktop.yml          打 tag 时在 Windows runner 上构建桌面版 zip 并挂到 Release
SECURITY.md            安全边界、报告里有什么、怎么报漏洞
CHANGELOG.md           行为变更,尤其是 finding id 与严重程度的语义变化
RELEASING.md           给维护者看:一次性配置与发布步骤

families.py 单独成文件是有原因的:selection.py(挑跨厂商模型)、tokenizer.py (判断共享 tokenizer 是否可疑)、identity.py(比对自述)都必须对「这个名字属于哪个厂商」 给出同一个答案。三份各自维护的关键词表迟早会漂移,然后同一个模型会在报告里被两个探针 判成不同厂商——那是最难查的一类假指控。

新增探针:继承 probes/base.py 的 Probe,实现 run(ctx),在 probes/__init__.py 的 ALL_PROBES 里注册。如果你的探针会循环请求,请务必在循环里检查 ctx.out_of_budget()、捕获 RelayBudgetExceeded 并调用 self._note_budget(...) —— 否则一个慢中转站就能把这个探针变成永不停机的黑盒。 RelayBudgetExceeded 必须单独捕获,不能掉进泛化的 except Exception:那会把 「我们主动停了」变成「中转站报错了」,也就是一次由我们自己的超时制造的假指控。


安全

用它之前请读一遍 SECURITY.md,三条要点:

  • 只用你自己的 Key,打你自己在用的站。 这个工具只发只读的对话请求,不发消息、 不改配置、不动计费接口的写操作。唯一的副作用是这些请求会真实计费到你的账户上。
  • 报告里不会出现你的 Key。 relaycheck/reporter.py 全文不引用 api_key, 只写目标 URL。但报告里有你账户的余额、消费与用量——那是敏感信息, 公开分享前先删掉「面板」「计费」段落。
  • 输出的是证据,不是判决。 severity(多严重)和 confidence(多确定)刻意分开; LOW / SUSPECTED 的意思是「有线索,撑不起指控」。 拿报告去理论之前,先读「诚实的局限」和每条发现的「建议」。

许可

MIT

Release files for relaycheck 0.1.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for relaycheck 0.1.1
File Size Uploaded
relaycheck-0.1.1.tar.gz 218.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for relaycheck 0.1.1
File Interpreter ABI Platform
relaycheck-0.1.1-py3-none-any.whl Python 3 none any Details

Total release size: 326.4 kB

Release files / relaycheck-0.1.1.tar.gz

Download URL relaycheck-0.1.1.tar.gz
Size 218.5 kB
Tags Source
SHA-256 checksum
How to use checksums
e938963c250a1babd101a8011ef0c2bef314f73efd1fd0c649b9ffc50dfa9ec4
BLAKE2b-256 checksum
How to use checksums
f9784176c41e7ad1f89c79b4396842b13942338b2b9e1457088b400ef9c7aaa4
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 26, 2026.

Transparency log

Release files / relaycheck-0.1.1-py3-none-any.whl

Download URL relaycheck-0.1.1-py3-none-any.whl
Size 107.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
a83537cb9c863e85c65232d3f107404508cba21d844aacc3ece4fe410bcd17a8
BLAKE2b-256 checksum
How to use checksums
19eebe1e7ab9d29446fe9b5be3c0412fe49d11391c526aaeb5e1818a16e67800
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 26, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.1 This release

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page