Demo 证据强度升级(四层阶梯)
适用对象:单模块或小项目的"作品型交付物"(投递 demo、比赛原型、面试作品)。 目标不是"做得更大",而是让证据扛得住追问。⛔ 对手是"套壳感"和"自证循环",不是工程量。
⛔ 铁律 0:先数能力半径,别靠通读印象
通读会觉得"写得挺扎实",一数才知道是不是空壳。先跑这几个计数(用脚本,不靠眼看):
| 数什么 | 怎么数 | 危险信号 |
|---|---|---|
| LLM 调用数 | 全文 grep openai/anthropic/LLM | = 0 却是 AI 方向的作品 |
| 检索/向量组件数 | grep embedding/vector/numpy | = 0 却在讲"智能检索" |
| 真实数据条数 | 用例字段里有几个字段是人写的 | 全部人写 = 只有规格样本 |
| 核心判据从哪来 | 顺着主函数看输入 | 判据是外部喂进来的标量 ⇒ 结构性问题 |
| 验证强度 | 已有几层 | 只有"用例全绿" = 自证循环 |
🔴 最常见、也最致命的一条:把最难的一步当成输入(例:准入闸门吃 confidence,而 confidence 是别人喂的数字 ⇒ "这条记忆可不可信"被推出了闸门之外)。评审一句话就能问倒。
四层验证阶梯(每层都要能一条命令复跑)
| 层 | 干什么 | 证明什么 | 不证明什么 | |---|---|---|---| | 1 规格用例 | 作者编写、逐条期望 | 实现符合本人写的规格 | 规格本身对不对 | | 2 变异测试 | 故意植入 N 类漏洞变体,要求逐个被抓 | 用例有鉴别力 | 未覆盖的失效模式 | | 3 性质测试 | 不写期望值,只断言性质 | 实现在整个输入空间满足不变量 | 公式/口径是否正确 | | 4 长程 + 回放 | 多步任务 + 轨迹落盘 + 重放校验 | 因果隔离 + 可审计 | 真实场景的收益 |
第 2 层的三个坑(都踩过)
- 锚点会随源码改而失效:变异靠字符串替换定位 ⇒ 升级源码后必须同步更新锚点,并新增针对新模块的变异体(否则新内核根本没被变异覆盖)。
- 跨文件变异必须注入模块:若主模块是
from evidence import ...,只改evidence.py源码无效 —— 要exec变异的 evidence 源码成模块对象,塞进sys.modules["evidence"],再exec主模块源码。不这么做,跨模块变异全部假绿。 - "未被抓"是发现,不是失败:某个变体 0 命中 ⇒ 说明用例在该轴上有盲区。此时不要怀疑变体,去补用例。 补法:找一对卡在阈值两侧的用例(同一参数、只动一个变量、一个刚好过、一个刚好不过),把该轴钉住。
第 3 层必须自带元检查
"一把全绿恰恰可疑":先证明这套性质能失败。做法:拿第 2 层的变异体回头打这套性质,要求每个变体至少被 1 条性质抓住。若某变体对性质完全不报警 ⇒ 性质在该维度上是摆设。
第 4 层:三臂单变量消融(拿数字的那一层)
同一批步骤跑三臂,唯一差别是判据:无闸门 / 只按标量阈值 / 完整判据。
- 差异才能归因给判据本身(不做跨臂聚合)。
- 🔴 代价那一半必须同页报出来:完整判据往往把"复用率"砍半(例:16/16 → 8/16)。只报收益不报代价 = 拿一半数字说事。
- 若用模拟器代替真 LLM 出数字:必须在同一页标注"合成/因果隔离,不是真实收益",并把"接真模型"列为下一步。
阈值标定:把"拍脑袋的常数"变成"有出处的数"
若系统里有硬编码阈值 θ:在带标签样本上扫 θ,找「误放(该拒却放行)= 0」的区间,取区间内最小的 θ(零误放下最大化复用)。再用第二条独立证据(用例期望当标签)算 P/R 交叉验证。两条路径都指向同一个值附近,才敢说这个默认值有出处。
宣称门禁:把"文档里写的字"变成"可执行断言"
交付文档里每个数字都是一条对外承诺。别靠通读确认——写成脚本去对拍,让它红。
两条门禁(各一个脚本,进 run_all.py,缺依赖标 SKIP、SKIP 不算通过):
| 门禁 | 干什么 | 关键设计 |
|---|---|---|
| 数字门禁 | 从文档抽取每个数字,与实测值比 | 🔴 抽取不到 = FAIL(否则"悄悄删掉不达标那句"就能蒙混过关) |
| 引文门禁 | 文档引用的原文片段,在原始文档里查 count(frag)==1 | 🔴 必须带负对照:捏造/改写的片段要 0 命中 ⇒ 证明匹配器不是"什么都命中" |
- 数字门禁首跑多半会红,先怀疑自己的抽取正则,不是先改文档。曾 6 红里 5 条是我正则写错。
- 断言定义也会过宽:把"非标准库依赖"写成"第三方依赖"会把 stdlib 算进去 ⇒ 用
sys.stdlib_module_names判。 - 🔴 引用前必须逐字回原文确认:你归给某句的结论,原文是不是真这么写。顺手常能挖到比原引文更硬的锚点(本轮就挖到一节更贴切的官方小节)。
接口契约探针:同一语义的多种写法都要试
"没有值"至少有三种写法:字段省略 / null / {}(空容器)(还有 "" / 0 / False)。它们语义相同,但实现常常只处理其中一种。
⇒ 逐写法跑一遍,把结果并排成表。本轮实测:省略 ✅ / None ✅ 走 fail-closed;{} 直接抛 TypeError —— 一个号称"fail-closed"的中间层,在最需要它的脏输入场景里崩了。
- 报告时必须同时给可达性:这个写法在当前夹具里出现几次?(实测 0 处 ⇒ 只能叫"潜在缺陷",不许说成"线上会挂"。)
- 这类探针通常只值一行修法,但它是 claim-bearing 改动 ⇒ 必须重跑全套回归证无退化,不能顺手改。
- ✅ 修完要固化成"契约测试"(进
run_all.py),且让脚本自己复现旧行为:第 [0] 段直接打印Evidence(**{})会抛什么 —— 这样"为什么要改"本身可执行、可回归,而不是一句写在注释里的主张。 断言要写成"三种写法给同一个(decision, reason)且都不抛异常",并配一组正/负对照(合法证据必须放行、未知来源必须拒)。 - ⚠️ 先确认返回形状,再写断言:本轮我以为
admit_dict返回 3 元组、实际返回 dict ⇒ 首跑 5 条 FAIL 全是我的解包错。同一模块里两个函数返回形状不同是常态,别按一个推另一个。
否定结论也是产出(别当没结果)
消融/多臂对照跑完,"这条路走不通"往往比"我的方案 +3%"更值钱,因为它省掉后面所有人的重复投入。合格写法要带三条:
- 否定谁:具体到部件("换个嵌入模型救不了 X")。
- 反例读数:能一眼看懂的反直觉数字(例:异义对余弦 0.861 > 所有真同义对的 0.505–0.697)。
- 归因到上限:性能天花板由什么决定(例:由权威表覆盖度决定——半表时 FRR 从 0.000 弹回 0.930),以及卡点在哪(例:79.7% 的真同义对至少一方是商标/缩写 ⇒ 信息不在字符串里,调模型无解,只能靠映射表)。
⇒ 把否定结论写进 EVIDENCE.md 的"本轮真问题",而不是删掉。它和正面读数同级。
把真 LLM 接进来当消费者("里面没有 AI" 的正解与三个陷阱)
当作品被质疑"里面根本没有 AI"时,正解是真的接一个 LLM 进回路,而不是加一句"预留了 LLM 接口"。本地跑(Ollama 等)⇒ 零成本,因此"改一版→重测→再改"可以循环,而不是赌一次。
接之前必须先跑只读探针(对全部样本让模型自己判、与真值对账),回答一件事:这个对照是不是退化的? 若模型不看你的中间层就能全判对,那"加了中间层"零增益 ⇒ 补了也没信息量,早点知道比做完再知道便宜。
三个陷阱(都实测踩过)
- 🔴 信息等价性陷阱(最隐蔽):把中间层能看到的东西原样渲染给对照臂(例:把结构化证据字段拼进提示词),等于把你的判据送给了对手 ⇒ 对照退化(实测:两个模型都 0 污染)。 ⇒ 对照臂只能看到该角色在真实系统里本来能拿到的东西(例:检索器返回的"结论+来源+状态"),结构化证据留在中间层里。渲染口径必须写进文档,跨渲染模式的数字不可混算。
- 🔴 "安全选项"会让某类指标天然测不出:若"拒绝/重算"是安全选项,真模型在信息不足时默认保守 ⇒ "污染/误放"类指标永远为 0,测不出差异。 ⇒ 这时可测的增量是:误拒(把该放的拒了) 与 成本(0 次调用 vs N 次调用),以及对不合规输出的处置。换指标,不是换结论。
- 🔴 别假设输出合规:要求"只回一个词",思考型模型实测 3/16 条给不出可解析输出(内容全落在 reasoning 里)。 ⇒ 这本身就是中间层的价值证据(对不可解析输入 fail-closed);也是你的评测器必须先做规范化 + 无法解析单列一类的原因(不能算作对,也不能算作错)。
诊断失败模式:同一现象往往有两个真因,手段完全不同
本轮实例:模型输出"不合规"(解析不出结果)。它至少有两个可能真因:
| 假设的真因 | 该用什么手段 | 实测 |
|---|---|---|
| 格式不听话 | 约束解码(JSON Schema / grammar) | ⚠️ 有效,但没能解决 |
| token 预算被"思考"吃光 | 提高 num_predict | ✅ 3/3 恢复可解析 |
怎么分开:看失败样本的共同特征。本轮所有失败条目的生成 token 恰好等于上限(512), 而所有成功条目落在 130–401 ⇒ 一眼看出瓶颈在"上限"而非"格式"。
⇒ 两条纪律:
- 中间量(token 数、延迟、原始文本)必须落表。只记"成功/失败"会永远找不到真因 —— 真因往往就藏在"失败样本为什么失败"的那个数字里。
- 别急着买药:先把失败样本捞出来排一排,看它们共同有什么。少了这一步, "加个约束库"这类直觉修法很容易花力气治错地方(本轮我差点就这么干了)。
证明"约束真的生效":必须用冲突臂 + 双向对照
"输出合法"证明不了约束生效 —— 模型可能只是听 prompt 的话。两个解释同解 ⇒ 判据无效。
做法:让 prompt 要求非法值,看输出会不会被强制成合法值;并做双向对照(同一 prompt 加/不加约束)。
本轮实测(Ollama format):
prompt(两次完全相同): ...decision field is exactly "YES" or "NO".
带 format=<enum["REUSE","RECOMPUTE"]> → '{"decision": "RECOMPUTE"}' ← 被强制
不带 format(负对照) → '{"decision": "YES"}' ← 听话
⇒ 这才排除了"模型配合"这个同解解释。
评估一个外部项目:读它怎么实现,别只读它怎么宣传
本轮实例:某项目 README 写 Works with any model — Same code runs across OpenAI, Ollama, vLLM。
读源码才发现它对 Ollama 就是 format=self.type_adapter.format_output_type(...)
⇒ 它只是转发你本来就能直接调的那个参数,对本地栈没有增量保证(增量是开发便利性)。
三条硬证据(按可信度排序):
- 源码行号(它究竟把请求变成了什么)
- 依赖清单(
pyproject.toml的dependencies,不是 README 的功能列表) - 后端要求(是否需要 tokenizer / logits 级控制 ⇒ 决定它能不能作用于 HTTP 接口)
⚠️ 打分表要区分"能力"与"是不是答案":满分只说明"这个方案本身值", 不说明"它能解决你手上这个具体问题"。两张结论必须分开写,否则会拿选型表冒充问题闭环。
报告纪律
- 🔴 "LLM 完全跟随中间层"不等于"中间层正确":把中间层的判定+理由喂给 LLM 复核,实测 11/11 完全跟随、0 次覆盖。这是从属,不是独立验证 —— 中间层的错误会随它一起传播,在这条臂上不可见。⇒ 这条臂只能说"两者不打架、中间层可作前置判据",不能说"LLM 验证了中间层"。
- 🔴 价值主张必须分臂拆开报:本例中"省成本"只在中间层单独判定时成立(0 次调用);"中间层 + LLM 复核"这条臂仍要付 N 次调用,它的收益只是把误拒压掉。混成一句"既准又省"就是超范围宣称。
- 重复 ≥3 次并报一致性:
temperature=0不等于逐位可复现。 - 至少两个不同规模的模型都跑一遍;只有一个模型的结论是"该模型的结论",不是"LLM 的结论"。
- 把模型名 + 量化版本 + 端点 + 提示词全文记进留痕,否则读数不可复现。
- 探针产出否定结论时("这条宣称测不出来"),照样写进台账 —— 它省掉了后面所有人的重复投入。
诚实边界模板(每次交付都要写)
· 本文件证明:<实现满足规格 / 性质 / 在合成任务上的因果隔离>
· 本文件不证明:<公式口径正确 / 真实数据上的收益 / 阈值真实最优>
· 合成数据 / 规格样本 / 真实数据:已分别标注
· 两处实现共用同一套公式 ⇒ 对拍只覆盖<顺序/方向/归属>,不覆盖公式本身
交付纪律
- 不覆盖旧版本:新版本另起文件(
v3),旧版保留作留痕对照;交付物里明确写"传新不传旧",并同步改掉所有引用旧路径的文档(否则会真的传错文件)。 - 确定性打包:固定
ZipInfo.date_time+ 排序文件名 ⇒ 两次打包 md5 必须一致。 - 留档必须是真实输出:把脚本 stdout 原样落盘,不手工润色数字。
- 🔴 禁止用 shell heredoc 写文件(
cat <<EOF > x.py):\b会被吞成退格符0x08、\n会变成真换行 ⇒ 脚本语法错、正则永不匹配,且肉眼看不出来。改文件一律用编辑器工具,或写一个补丁脚本文件再执行;补丁脚本里每处替换断言count==1,任一不中则整体不写入。 - 🔴 第三方许可必须读 LICENSE 原文,不能只看 API 字段:GitHub API 报
NOASSERTION、LICENSE404,扒开LICENSE.md才发现是 CC BY 4.0 / ODC-By 1.0 / Apache-2.0 混搭 ⇒ "API 字段 ≠ 实际条款"。只借鉴语义/思路时也要在文档里写明"未复制其代码"。 - 🔴 汇总留痕不会自己更新:一键复跑脚本通常只写"各步自己的日志",不写它自己的汇总日志。所以你扩充了步骤/入口之后,旧汇总文件(例:"6/6 通过")会静静地过期,而文档里已经写着"11/11"。⇒ 改步数后必须重跑并把汇总重定向落盘;核验时要连留痕的生成方式一起核,别只核正文里的数字。本轮就是被自己的门禁抓到这一条。
- 🔴 重构后必须用 diff 比对新旧输出(把上一轮的 stdout 存一份,重跑后
diff,数字必须逐字一致)。只跑"没报错"是抓不到静默退化的;本轮就是靠 diff 抓到自己把with块缩进截断导致的I/O operation on closed file。 - 🔴 空转开关本身就是 bug:加了
--flag却没实现 ⇒ 它静默什么都不做。同一条:过滤逻辑加了参数却没接到消费点(例:--only没过滤表 ⇒ 缺臂时直接 KeyError)。改完参数表,要顺着每个新参数走一遍它到底改了什么。 - 🔴 留痕脚本会覆盖 —— 跑"开关测试"前先确认它写不写盘:用
--only gate验证一个新参数时,脚本把正式留痕覆盖了(trace 64 行 → 16 行)。修法:部分运行另起文件名(..._partial_<臂>.*),正式留痕只由完整运行写。要试参数,先看清副作用面。 - 升级完回头给旧诊断逐条对账(哪条已关、哪条只关一半、为什么某条刻意不关),比只写"已完成"可信得多。
- 🔴 工作区与定版要分离 —— 否则"目录 vs 包"会变成两个真相:迭代久了,工作目录必然领先于已发布的 zip (本轮工作目录 79 文件、已发布包 44 文件)。做法:工作区 mv 升级为 vN+1,再从旧 zip 解出一个干净的 vN 目录,解完立刻核对"目录文件清单 md5 == zip 清单 md5",这样"目录 = 工作区、zip = 定版"各归其位。 ⚠️ 别用"复制目录"制造两份几乎相同的树 —— 会让人分不清哪份是该传的。
- 🔴 改代码必须给出"无退化"证据,而不是"我改对了":重跑全量入口 → 汇总重定向落盘 →
把上一版的各层读数逐条对照(本轮:58/58、20/20、8/8、48/48、θ 区间、引文 11/0 全部逐字未变,
且
verify_claims仍 22 PASS / 0 FAIL)。claim-bearing 的一行修也算改动,没有这份对照就不算完成。
一句话记法
<!-- OB-S:oyqG6EIQ-GKDzsx1SwgS5Q -->先数能力半径,再造四层验证,再用消融拿数字,最后把边界写在纸上。 评审问不倒的不是工程量,是"你知不知道自己的东西能证明什么、不能证明什么"。
微信扫一扫