第 5 章:Detection 与 Scoring——二元判断与有序评分
第 5 章:Detection 与 Scoring——二元判断与有序评分
Detection 问"有没有",Scoring 问"多少分"。前者是 Noul 的主场,难点全在阈值;后者是 Score 的主场,难点全在"别让模型直接给数字"。本章说清阈值背后的召回/精确取舍、
score = Σ i·p_i的含义、以及为什么概率校准比那个分数本身更重要。
Noul 的本质:一个带概率的"是/否"
Detection 的底层是 Noul。它不返回干巴巴的 true/false,而是返回一个 P(true):
from typesafe import TypesafeClient
client = TypesafeClient()
d = client.systemone(
state=source_file,
questions={
"is_malicious": {"type": "noul", "question": "这段代码是否在试图执行未声明的网络外连或破坏性操作?"}
},
instructions="你是供应链安全审查员,只看代码行为,不猜意图。"
)
print(d["is_malicious"].value) # True / False
print(d["is_malicious"].probability) # 例如 0.91初看之下这比"让模型回 yes/no"好不到哪去。真正拉开差距的是那个概率:它把"是不是"变成了一个连续量,而连续量可以设阈值、可以排序、可以分级动作。Detection 的一切设计,本质上都围绕"怎么用这个概率"。
Detection:阈值才是主角
Detection 的成败几乎不在模型,而在阈值。同一个 Noul,阈值定在 0.3 还是 0.8,得到的是两套完全不同的系统。
p = d["is_malicious"].probability
if p >= 0.3:
block_and_review(source_file) # 阈值低:宁可误报阈值不是拍脑袋定的,它由两类错误的代价决定:
- 漏判(false negative):明明是坏东西却放过了。代价可能是安全事故、资金损失。
- 误判(false positive):明明是好的却拦下了。代价可能是正常业务流程被打断、用户被误伤。
阈值往低走,漏判减少但误判增多;往高走反之。所以正确的顺序是:先想清楚这两类错误各自要花多少代价,再倒推阈值——而不是先选一个"看起来合理"的 0.5。
召回 vs 精确:两类场景相反的直觉
这条取舍在不同场景里的最优解是相反的。
flowchart TD
P["Noul 输出的 P(true)"] --> T{"阈值定在哪"}
T -->|"调低(如 0.3)"| LOW["多拦:召回高、误报多
交人工 / 二次确认消化"]
T -->|"调高(如 0.85)"| HIGH["少拦:精确高、漏报多
放行或只记录"]
LOW --> S1["安全类场景
宁可误报不可漏报"]
HIGH --> S2["体验类场景
宁可漏报不可误报"]安全类场景,宁可误报不可漏报。 恶意链接、凭证泄漏、注入攻击——漏掉一个可能代价极高,而误报一个只是多一次人工复核。所以这类场景把阈值压低,让召回优先,再靠人工或二次确认消化误报。
体验类场景,宁可漏报不可误报。 自动屏蔽一条评论、自动折叠一封邮件、自动拦截一次操作——误报直接伤害用户体验,而漏报往往只是少了一次自动处理。所以这类场景把阈值抬高,让精确优先。
同一句"这是垃圾吗"的 Noul,放在反垃圾邮件里该设 0.4,放在"要不要给用户弹提示"里该设 0.85。判断的场景决定阈值的方向,而不是判断的文本。
二次确认:召回优先时的补偿手段
如果为了召回把阈值压得很低,误报会变多。一个干净的补偿手段是二次确认:第一遍宽进,第二遍严出。
这正是 is-malicious(luantak/is-malicious)的做法。它先对源码与构建文件问一批 Jev Noul 检查,把可疑的片段升级进入第二次 pass,最终在代码执行前给出"牵涉哪些文件、哪些行"的结果。第一遍负责不漏(宽阈值捞进来),第二遍负责不冤枉(对被捞进来的再精判)。这种"召回靠第一遍、精确靠第二遍"的两级结构,是安全类 Detection 的常见形态。
# 第一遍:宽进
p1 = client.systemone(
state=chunk,
questions={"sus": {"type": "noul", "question": "这段代码有没有可疑行为?"}}
)["sus"].probability
if p1 >= 0.3: # 低阈值,先捞进来
# 第二遍:严出,连带上下文一起判
p2 = client.systemone(
state=f"上下文:{context}\n\n片段:{chunk}",
questions={"mal": {"type": "noul", "question": "结合上下文,这是真实的恶意行为吗?"}}
)["mal"].probability
if p2 >= 0.8:
report(chunk)两级结构还有个隐性收益:第二遍能看到更多上下文。第一遍常常只有孤立的片段,第二遍可以带上调用点、文件路径、导入表——精度提升往往来自这里,而不只是来自更严的阈值。
Scoring:为什么不要直接要一个数字
Scoring 的底层是 Score。它最反直觉的一点是:不要让模型直接给你一个 1–10 的数字。
原因是数字尺度没有共识边界。同一段文本,这次模型给 7,下次给 8,你没法知道这是"真的更好"还是"抖动"。而下游一旦拿这个数去卡阈值("≥8 才推荐"),抖动就直接变成了不稳定行为。
Jev 的做法是让你给几个有序档位,模型返回一个分布:
d = client.systemone(
state=headline,
questions={
"severity": {
"type": "score",
"question": "这条新闻所描述事件的威胁严重程度?",
"labels": ["信息", "关注", "警惕", "严重", "危急"]
}
}
)
print(d["severity"].probabilities) # {"信息":0.05,"关注":0.15,"警惕":0.30,"严重":0.40,"危急":0.10}
print(d["severity"].score) # 1*0.05+2*0.15+3*0.30+4*0.40+5*0.10 = 3.35档位少(通常 3–5 档)、语义清晰,每个档位都有共识——"危急"和"严重"的区别是明确的,而"7 分和 8 分"不是。稳定性由此而来。
score = Σ i·p_i 的含义
上面例子里的 3.35 是这么来的:把第 i 档记为数值 i(信息=1、危急=5),用每档的概率加权求和。
这个数有三层含义:
第一,它是分布的"重心"。 3.35 落在 3(警惕)和 4(严重)之间偏后,说明整体判断偏严重但没到"危急"。比"取概率最高的那一档"信息量更大——只看最大档你会得到"严重",但丢掉了"还有 30% 概率是警惕"。
第二,它的取值范围是连续的。 落点介于档位之间,天然适合做细粒度阈值。score >= 3.5 比"档位等于严重及以上"更平滑。
第三,它的方差/分布形状反映不确定性。 分布集中在某一档,说明笃定;分布摊平,说明这条本身就没法定级。同样的 score 可能对应完全不同的确定性——{警惕:1.0} 和 {信息:0.3, 关注:0.4, 警惕:0.3} 的 score 接近,但后者其实判不出来。所以和 Choice 一样:看分布,不只看 score。
校准与置信度:分数可信吗
阈值策略有一个隐含前提——概率是诚实的。如果模型说 0.9,实际只有 0.6 的把握,那所有阈值都建在沙子上。衡量这件事的指标是 ECE(Expected Calibration Error,期望校准误差),越低越好。
poorjev(rupeshpoojary9/poorjev)是一个很好的例子。它在通用零样本 NLI 模型上实现 Jev 的 Choice/Score/Noul 接口,然后用温度缩放和 conformal abstention 把置信度做"诚实",附带一份可复现的校准评测:ECE 从 0.170 降到 0.071(交叉验证),完全离线、不需要 API key。
0.170 意味着什么?意味着按 0.9 阈值拦下的东西里,有相当一部分其实没那么确定。降到 0.071 之后,阈值才真正可信。如果你要在 Detection/Scoring 上做严肃的阈值策略,校准必须进入你的验证清单——第 23 章会把校准评测做成通用能力。
用概率做分级动作
Detection 与 Scoring 最实用的地方,是它们支持分级动作而不是二值开关。看一个 Scoring 的分级:
sev = d["severity"].score
if sev >= 4.0:
page_oncall() # 危急:直接寻呼
elif sev >= 2.5:
notify_channel() # 警惕→严重:通知频道
else:
log_only() # 信息/关注:只记录再看一个 Detection 的分级:
p = d["is_bad"].probability
if p >= 0.9:
auto_block()
elif p >= 0.5:
queue_for_human() # 灰色地带交人
else:
allow()分级的价值在于:它把"模型有不确定性"这件事显式地写进了控制流。 笃定的自动处理,模糊的转人工,安全的放行——每条分支都对应一个明确的动作,评审时一眼能看清系统在各种置信度下会做什么。
三个生产项目
Dub(dubinc/dub)——把恶意链接检测放进创建流程。 Dub 是一个开源短链服务。它在 malicious-link-check.ts 里调用 typesafe-ai/jev,在短链被创建之前就对 URL 做类型化判定。这里的形态选择很关键:它没有用一张黑名单,而是让它被一个 Noul 判定"这条 URL 该不该被拦"。黑名单的问题是永远滞后——新出现的钓鱼域名不在名单上;而语义判定能在链接生成的当下给出一个带概率的结论。这是 Detection 相对传统规则最典型的增量价值:从"匹配已知坏东西"升级到"判断是不是坏东西"。
Sniff Test(DanRWilloughby/snifftest)——十问一体的文字护栏。 Sniff Test 是一个文字 linter,它对每个段落问 Jev 十个 Boolean 问题,检测堆叠含糊(stacked hedges)、复述式结尾、not-X-but-Y 转折、光秃秃的成本数字这类 AI 写作痕迹,阈值设 0.7。它同时以 CLI、pre-commit hook、GitHub Action 和 Claude Code skill 四种形态存在。实测数据很能说明问题:中位延迟 182 ms,54 个干净段落里只误报 1 个,而 Haiku 4.5 误报了 37 个。
这个数字值得停一下。同样的阈值下,Jev 的误报是 1/54,生成模型是 37/54——差了三十多倍。原因不在"谁更聪明",而在检测任务本就不该交给生成模型:生成模型在"这段有没有某特征"上给的判断,是它顺带编出来的,不是它专门产出的。把它交给一个专门做判定、且概率经过校准的决策层,误报率会低一个数量级。这就是本书核心论点的又一次落地。
WorldMonitor(koala73/worldmonitor)——把严重度做成分级。 WorldMonitor 是一个实时全球情报面板,它用 Jev 把新闻标题的威胁严重度打分成 5 个威胁等级,并把事件归类到14 个冲突、网络、基础设施领域。这正是 Scoring 的教科书用法:一个有序的 severity 维度(5 级),加一个互斥的领域维度(14 类 Choice)。面板的每个色块、每次排序、每次告警降噪,都建立在这个 severity 分数上。它说明 Scoring 的产出往往不是"给用户看的分数",而是驱动下游决策的中间量——排序、过滤、触发。
其余项目一张表带过
| 项目 | 形态 | 关键设计 |
|---|---|---|
| Perch(lakeday-org/perch) | Noul + Choice + Score | 语义代码 linter:对每个方法(连同调用者/被调用者)问"有没有 bug"(Noul)、"是哪一类、在哪一行"(Choice)、"多严重"(Score),外加按语言过滤的 CWE Noul 检查 |
| JevGate(Tech-Byte-Frontier/jevgate) | Noul + Choice + Score | CI 与编码 Agent 的质量门:答案达 0.80 记为 review / consider,review 直接让构建失败,拿不准的文件保持 uncertain 而不是放过 |
| JevPDF(kylemclaren/jevpdf) | Noul | 浏览器内 PDF 搜索:每行问一个"是否回答了查询"(每请求 16 行,共享页面文本作 state),0.55 以上高亮并按概率排序 |
| JevSlop(TKY-27/JevSlop) | Score | 对文章在八个 Score 轴上打分,聚合成 0–100 的 Slop Score |
| jeff(saembit/jeff-cli) | Score | Go CLI:按 YAML 规格里每个加权维度的 Score 求和排序;score 命令能把阈值翻译成退出码 10,供 shell 与 CI 使用 |
| Clean Code Judge(frostney/clean-code-review) | Noul | 对 PR 每个文件跑 31 个 Clean Code 布尔检测外加函数长度与嵌套深度,再交给写作模型生成评述 |
| jev-risk-check-provider(caiovicentino) | Noul + Choice + Score | 把对交易对手的判断聚合成代码控制的 0–100 分;540 次运行中阈值 65–75 段准确率 99.76%、0 误报,每决策约 $0.00005、p50 约 400 ms |
| hippo-memory(kitfunso/hippo-memory) | Noul(重排) | 可选的 Jev 重排器把检索 R@1 从 0.41 提到 0.62(私有 300 查询开发者库) |
注意 Sniff Test、JevPDF、JevSlop 都在做同一件事的不同切面:把一个模糊的质量判断拆成一组离散问题,用阈值切断,再交给代码执行动作。Detection 与 Scoring 不是为了得到"一个准确率数字",而是为了得到一个能被控制流接住的、带概率的判定。
小结
- Detection 底层是 Noul,返回带概率的布尔;Scoring 底层是 Score,返回有序档位分布——两者真正的设计工作都在"怎么用概率"上。
- 阈值的定法由两类错误的代价决定:安全类场景压低阈值保召回,体验类场景抬高阈值保精确;召回优先时可加"二次确认"两级结构补偿误报。
- 不要直接要 1–10 的数字:给有序档位拿分布,
score = Σ i·p_i是分布的重心,取值范围连续、可做细粒度阈值;但相同的 score 可能有完全不同的确定性,要看分布。 - 阈值策略的前提是概率诚实,ECE 校准必须进验证清单(poorjev 把 ECE 从 0.170 压到 0.071)。
- 少用二值开关,多用分级动作——把"模型不确定"显式写进控制流。
- 实测佐证:Sniff Test 在 0.7 阈值下干净段落误报 1/54,而生成模型 37/54;Dub 用语义判定替代黑名单;WorldMonitor 用 5 级 severity 驱动整个情报面板。
下一章进入 Search 与 Retrieval,看 Jev 如何"对每个候选问一个问题",在候选集里挑出真正相关的东西——这是 RAG 精排的核心动作。