NLI 推理链路怎么支撑一个 if 语句?把 SemIf 的『这句符合条件吗』拆开看
【免费下载链接】SemIf-OpenJevSemantic ifs from open models, on a 3090 at home. Independent; not affiliated with Jev or TypeSafe.项目地址: https://gitcode.com/gh_mirrors/op/SemIf-OpenJev
程序员的if是全世界最确定的东西:条件为真就走真分支,为假就走假分支,符号之间没有模糊地带。但当条件变成自然语言——"这封邮件是否属于投诉"、"这条工单是否应该转给计费组"、"证据是否支持部署成功"——同一句话在不同上下文里会有完全不同的真假值。传统做法是把语义条件硬编码成关键词规则,写一次管一次,改一次崩一次。
SemIf(原 OpenJev)把这个问题换了个问法:与其让程序读懂规则,不如让模型直接回答"这句符合条件吗",并且以概率分布的形式回答,让if的布尔判断直接建立在一次前向传播的输出上。本文以仓库源码为据,把这条"NLI → 条件判断 → 布尔结果"的链路逐段拆开:前提与假设如何映射成选项、判别模型的置信度在边界上表现如何、以及它和生成式大模型说"是/否"到底差在哪。
一个 if 的自然语言化:从support-1看输入契约
仓库自带的示例 examples/decisions.jsonl 第一行就是最典型的"这句符合条件吗":
{ "id": "support-1", "state": "The deployment completed at 14:02 UTC. Health checks passed in all three zones. No rollback was initiated.", "question": "Is there evidence that the deployment succeeded?", "options": [ {"id": "yes", "description": "The deployment succeeded."}, {"id": "no", "description": "The deployment did not succeed."}, {"id": "insufficient", "description": "The evidence is insufficient to decide."} ] }注意这个契约的三个字段:state(非结构化证据)、question(运行时定义的条件)、options(2–16 个带描述的可选结论)。它没有固定任务头,也没有预训练时的分类标签——条件本身是运行时随请求一起到达的。这正是 src/semif_phase1/core.py 中validate_row约束的输入形态:证据可以是字符串、JSON 对象或数组,但必须是有限 JSON 数据;选项必须唯一且数量受限。if的布尔空间在这里被显式展开成了"支持 / 不支持 / 证据不足"三值,甚至更多——route-1那一行就是三选一路由。
这个形态的意义在于:程序不再需要理解语义,它只需要把三个字段填好,然后读回一个概率分布,把 argmax 当布尔值用。语义理解被外包给模型,符号控制流留在程序里。
NLI 如何把前提与假设映射成布尔结果
SemIf 对"这句话是否符合条件"的建模,本质上是把自然语言推理(NLI)的三分类骨架搬进了选项空间。证据最硬的是 WANLI 基准的构造方式,见 benchmarks/build_wanli.py:
- 上游 NLI 样本的
premise(前提)直接变成state; hypothesis(假设)被包装进question:"Assess the claim using only the supplied evidence: ...";- 三分类标签
entailment / neutral / contradiction被原样映射为supported / insufficient / contradicted,选项描述写死为"The evidence establishes the claim / does not establish either / establishes the opposite"。
也就是说,一句"证据是否支持这个结论"的布尔询问,被还原成了 NLI 的蕴含/中立/矛盾三值判断——布尔结果不是模型"拍脑袋"给出的,而是蕴含关系映射出来的。DIRECT_SYSTEM 提示词则把这条链路压实,见 src/semif_phase1/core.py:
Apply the supplied criterion to the supplied evidence. Choose exactly one listed option. Respond with only its uppercase letter, with no explanation or reasoning.推理链路的另一条实现是 reranker 路线,见 src/semif_phase1/reranker.py:它借用 Qwen3-Reranker-4B 原生的 yes/no 契约,把每个候选答案单独变成一个"文档是否满足要求"的 NLI 命题,前缀提示词是"Judge whether the Document meets the requirements based on the Query and the Instruct. Note that the answer can only be 'yes' or 'no'.";每条选项得到logit(yes) - logit(no)的 log-odds,再做一次跨选项归一化。注意这一步归一化只是项目定义的比较规则,不是上游契约的一部分——但正是这种"先逐选项做蕴含判断、再跨选项比较"的做法,让 reranker 对选项顺序天然不敏感。
两条路线在 WANLI 256 行外部基准上的差距很有信息量:判别式直接读数平衡准确率 0.637,reranker 只有 0.522(docs/RESULTS.md)。它说明"NLI 任务语义上等价"不等于"决策系统效果上等价"——把条件判断拆成多个独立 yes/no 命题再合并,在这个冻结的评测集上是吃亏的。NLI 的三值骨架可以直接喂给判别式读数,效果优于逐选项二分类的合成。
判别模型的置信度:条件化、未校准、边界会翻车
if关心的是真假,但工程上真正危险的是置信度。判别式读数返回的 softmax 概率有一个必须写进文档的性质:它只对声明选项做归一化,是"给定这些备选答案时的条件得分",不是校准过的操作置信度。这句话在 src/semif_phase1/direct.py 里被逐行实现,并在每一条输出里以probability_status: conditional option score; uncalibrated as decision confidence落盘。
校准实验把这个边界行为量化得很彻底(docs/CALIBRATION.md、benchmarks/calibrate.py):
| 工作负载 | 模型准确率 | 拟合温度 T | 原始 ECE | 校准后 ECE(留出外) |
|---|---|---|---|---|
| authored(144 行) | 0.806 | 1.23 | 0.068 | 0.038 |
| WANLI(256 行,NLI) | 0.637 | 2.50 | 0.208 | 0.069 |
关键发现在 WANLI 上:模型对 NLI 任务过度自信——声称约 90% 置信时实际只对约 64%(所以需要 T=2.5 把 logits 拉开)。温度缩放把 ECE 从 0.208 压到 0.069,bootstrap 区间不重叠,是统计上真实的提升。而温度缩放是单调变换,argmax 不会移动:校准只改置信度,不改决策本身。这给"if 之外的降级闸门"提供了一个真实操作点——原始分数上设 0.8 阈值毫无意义,校准之后阈值才对应真实准确率。
边界行为不止于置信度,还有扰动稳定性(docs/RESULTS.md 的 36 组扰动实验):
| 变体 | Direct 准确率 | Direct 翻转 | Reranker 准确率 | Reranker 翻转 |
|---|---|---|---|---|
| 选项反转 | 0.813 | 10 | 0.498 | 2 |
| 准则改写 | 0.706 | 9 | 0.647 | 9 |
| 追加无关上下文 | 0.821 | 4 | 0.563 | 13 |
判别式读数在选项反转下翻 10 次——位置措辞和概率移动没有被解决;reranker 顺序不变,但"稳定地错"(准确率掉到 0.498)。两套系统在 36 行缺失证据集上各做出了一次 score 超过 0.8 的insufficient之外的选择。结论很直白:判别模型的条件判断在分布内可用,在边界上必须带着校准与扰动报告一起用,任何把原始概率当操作置信度的做法都是危险的。这也解释了社区里判别式决策模型路线为何同时在"小而快"的方向收敛——如 340M 参数的 GLiNER2.5-Decide 以无 token 生成的轻量判别分类胜出——核心逻辑都是同一件事:把语言理解直接接线到决策输出。
判别读数与生成式"是/否"的本质差异
生成式大模型回答"这句符合条件吗",会先说一段推理、再吐一个 yes/no,程序再去解析。SemIf 的判别路线把这段全部砍掉:一次前向传播,取最后一个位置的完整词表 logits,只对声明的答案槽(大写字母 token)做 softmax,不采样任何 token。src/semif_phase1/direct.py 里_forward甚至只用logits_to_keep=1只保留末位 logits,_slot_ids还会校验每个答案字母必须是单个往返一致的 token,防止 A/B 被分词拆碎。
仓库里有一组同模型、同状态、同 21 条二元条件的受控对比(docs/RESULTS.md):
| 输出路径 | 耗时(中位数) | 输出 token 数 |
|---|---|---|
| 判别式直接读数 | 1.023 s | 0 |
| 紧凑 JSON 数组(仅 yes/no) | 5.332 s | 111 |
生成式基线已经是最省的模式——只要有序的"yes"/"no"数组、不要键、不要置信度、不要解释,首 token 中位数 0.489 s 后开始流式输出,但完成整个数组仍耗时 5.332 s,是直接读数的 5.21 倍。更苛刻的 minified 数组要求下,模型在三次运行里都重复值并撞上 128-token 上限,被如实记录为失败而不是用来夸大倍率。两个 readout 的 argmax 在 18/21 上一致——这不是语义等价声明,而是纯系统级成本对比。
仓库的交互回放 demo/index.html 把这条差异做成了可视化(demo/assets/semif-phase1-replay.gif):同一冻结的 4B 模型、同一状态、同一 21 个问题,生成路径在 t≈0 后逐个 token 流式吐出答案,判别路径在完成点整批出现 21 组概率对——"typed decisions appear together while JSON streams token by token"。
本质差异不在"哪个更聪明",而在计算图的结构:生成式把决策藏在自回归采样里,等待、流式、解析、修复错误格式都是不可避免的税;判别式把决策编码成末位 logits 的分布,布尔结果直接从分布里取。这个结构优势还能叠加状态复用:在 37 状态 × 21 条件的自有基准(benchmarks/data/shape777.jsonl)上,一次 prefill 公共状态后并行分叉打分,把吞吐从新鲜打分的 2.33 decisions/s 拉到 20.03 decisions/s(777 个决策 38.8 s 完成,见 src/semif_phase1/shared.py),而 reranker 因为每个二元决策要做两遍完整的"状态+问题+选项"评估,只有 1.86 decisions/s。判别读数的代价——比如并行后缀相对新鲜打分在 777 个 argmax 里漂移 5–6 个——也在文档里如实提交,边界再一次以数字而不是口号的形式出现。
结论:把 NLI 读成概率,把概率接进 if
拆开看,"这句符合条件吗"在 SemIf 里走的是这样一条链:非结构化证据 + 运行时条件 + 描述选项 → 冻结提示词 → 一次前向 → 声明选项的 logits → softmax 条件概率 → argmax 驱动 if。NLI 在其中提供的不是"聊天能力",而是蕴含关系的三值骨架(支持/不足/矛盾);判别式读数提供的不是"生成答案",而是零采样的概率读出;校准层提供的不是"更高准确率",而是让置信度配得上阈值。三者缺一,语义 if 要么不可解释、要么慢一个数量级、要么在边界上盲目自信。
对想在自家系统里做语义条件判断的工程团队,这个仓库给出的可复用结论是:把条件显式建模成 2–16 个带描述的选项,用判别式读数拿到条件概率,在目标工作负载上做温度校准再设阈值,最后把扰动测试(选项反转、准则改写、无关上下文、缺失证据)写进发布门禁。布尔结果永远是符号层的事,但让布尔结果可靠的语义层,需要像对待测试用例一样对待边界。
【免费下载链接】SemIf-OpenJevSemantic ifs from open models, on a 3090 at home. Independent; not affiliated with Jev or TypeSafe.项目地址: https://gitcode.com/gh_mirrors/op/SemIf-OpenJev
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考