自洽性:Choice
内容审核流水线里,每一道 Choice 的答案就是一次路由决定:删还是留、进哪个队列、谁来拍板。同一条评论重复问几次,如果标签来回跳,这条评论就会毫无理由地被送到不同的地方。
本篇沿用 自洽性:Noul 的做法,把一条真实的边界评论(来自清华 COLD 中文冒犯语言数据集)连同 8 道审核 Choice 重复问 15 次,看每一道题的答案稳不稳;再在 COLD 测试集上分层抽 60 条评论、每条重复 5 次,看「一致率」和「自动处理比例」之间怎么取舍。
一句话结论(全部来自真实运行):
- 象信一号在 15 次重复里 8 道题的最高概率标签一次都没变(原始一致率 100%),各标签概率在重复之间的平均标准差为 0.0023;5 次完全相同的请求,概率完全一致(标准差 0)。
- 但这条评论本来就在灰色地带:8 道题的最高概率都在 0.34–0.57 之间。套上「最高概率 ≥ 0.60 才自动处置」的规则后,8 道题全部返回「不确定」,自动处理比例为 0%——这是正确的行为,而不是失败。
- 在 COLD 的 60 条评论上,「删除 / 保留」这道题的原始一致率 100%;阈值 0.60 时 77.0% 的答案自动处置、其中 80.1% 与人工标注一致(不设阈值时为 70.0%);阈值提到 0.80 时,自动比例降到 38.7%,准确率升到 91.4%。
- 大模型(DeepSeek)对照的代码已就绪,对照数字待补(见文末「大模型对照」)。
环境准备
pip install xiangxin-sdk pandas numpy
export XIANGXIN_API_KEY="sk-xx-..."本文运行于 2026-09-25,模型 xiangxin-latest(20 次调用全部返回 xiangxin-1.0.0)。
数据
COLD(Chinese Offensive Language Dataset,Deng 等,EMNLP 2022),Hugging Face 上的 thu-coai/cold,许可 Apache-2.0。我们只用测试集(5,323 条知乎 / 微博评论),它有两层人工标注:
| 细粒度标签 | 含义 | 是否冒犯 |
|---|---|---|
| 攻击个人 | 冒犯某个具体的人 | 是 |
| 攻击群体 | 对地域、种族、性别群体的冒犯或偏见 | 是 |
| 反偏见 | 谈及这些话题,但立场是反对偏见 | 否 |
| 其他无冒犯 | 其他不冒犯的评论 | 否 |
第一部分的 state:正文取自 COLD 测试集的一条「攻击个人」样本——一位河南网友反驳地域黑,同时嘲讽对方是「地域汪」。它表面上在反对地域歧视,实际上又在骂人,没有一个显然正确的答案。账号、上下文、举报等元数据是本文构造的,用来模拟真实审核后台看到的信息。
POST = {
"post_id": "C-20931",
"作者": {"用户ID": "u_58120", "注册天数": 41, "历史违规次数": 1, "粉丝数": 186},
"上下文": {
"位置": "公开评论区的楼中楼回复",
"回复对象": "一位在讨论中发表了针对河南人言论的用户",
"社区": "社会新闻讨论区",
},
"内容": {
"正文": "不是好一大半,是完全好了,真的,完全完全。期待你的老乡和你身边的人都能让你百分百满意。"
"对了,忘记我不跟地域汪讲话的。什么?我变成了地域黑?我一河南人?真没法地域黑?"
"我只能黑你这样的地域汪。",
"含外链": False,
},
"举报": {"用户举报数": 3, "举报理由": ["人身攻击", "地域歧视"]},
}第二部分:从 COLD 测试集每个细粒度类别各抽 15 条(random_state=0),共 60 条,人工标注作为参考答案。
问题设计:8 道 Choice
每道题的选项互斥,恰好一个成立,并各带一句描述。
from xiangxin import Choice
QUESTIONS = {
"category": ("这条评论最适用的一个内容政策类别是什么?", {
"无违规": "不违反任何社区规范。",
"人身攻击": "辱骂、贬低某个具体的人,但不针对其所属群体,也没有伤害威胁。",
"群体歧视": "因地域、民族、性别等群体属性攻击某人或某个群体。",
"暴力威胁": "可信地威胁伤害某人,或煽动暴力。",
"引流广告": "未经请求的推广、导流或刷屏,没有人身攻击。",
"色情低俗": "色情或低俗内容。",
}),
"primary_risk": ("处理这条评论时,最应该驱动分流的主要风险是什么?", {
"人身攻击": "对个人的攻击或针对性辱骂是主要风险。",
"地域对立": "引发或加剧地域之间的对立是主要风险。",
"账号历史": "账号的违规历史、重复行为是主要风险。",
"举报集中": "短时间内集中被举报、可能引发围观是主要风险。",
"低风险": "没有值得处理的风险。",
}),
"target": ("这条评论主要针对谁或什么?", {
"无特定对象": "不针对任何具体对象。",
"个人": "针对某一个具体的人。",
"群体": "针对某个地域、民族、性别等群体。",
"平台或社区": "针对社区或平台本身,而不是某个人。",
}),
"stance": ("这条评论对地域歧视持什么立场?", {
"发表歧视": "本身在发表地域歧视或刻板印象。",
"反对歧视": "在反驳、批评地域歧视,没有攻击任何人。",
"借反歧视攻击": "表面上反对地域歧视,但同时在嘲讽或辱骂对方。",
"无关": "与地域歧视话题无关。",
}),
"action": ("应该对这条评论采取什么处置?", {
"放行": "保留评论,不做任何处理。",
"提醒": "保留评论,但向作者发送文明提醒。",
"删除": "删除评论,但不处罚账号。",
"删除并记违规": "删除评论,并给账号记一次违规。",
"转人工": "不做自动处置,等待人工决定。",
}),
"queue": ("这条评论应该进入哪一个审核队列?", {
"自动处理": "自动结案,不需要人工队列。",
"普通队列": "普通审核队列。",
"歧视队列": "地域、民族、性别等群体歧视专项队列。",
"骚扰队列": "人身攻击与骚扰专项队列。",
"高级审核": "安全负责人 / 资深审核队列。",
}),
"review_path": ("这条评论最终应该由谁拍板?", {
"自动": "自动处置,无需人工复核。",
"一线审核员": "由一线人工审核员决定。",
"资深审核员": "需要资深或专项审核员。",
"法务": "转法务或执法对接。",
}),
"severity": ("这条评论的整体严重程度如何?", {
"无": "没有违规。",
"低": "言辞不客气或阴阳怪气,但基本无害。",
"中": "明确的人身攻击或歧视性表达,但没有可信的威胁。",
"高": "攻击或歧视之外,还带有可被视为可信的威胁。",
}),
}
questions = {k: Choice(instructions=ins, criteria=crit) for k, (ins, crit) in QUESTIONS.items()}怎么问
每次重复是一个 system_one 请求,同时回答 8 道题。和 Noul 篇一样,state 里多放一个每次都不同的 uid 字段(一次性随机值),帖子和题目保持不变;另外再发 5 次完全相同(不带 uid)的请求做对照,区分「对无关字段敏感」和「同一请求本身的波动」。
from secrets import token_hex
from xiangxin import XiangxinClient
client = XiangxinClient(model="xiangxin-latest")
runs = []
for i in range(15):
resp = client.system_one(
state={"uid": f"{i}:{token_hex(4)}", "post": POST},
questions=questions,
)
runs.append({k: dict(a.probabilities) for k, a in resp.answers.items()})应用层的判定规则只有一行:最高概率 ≥ 0.60 才采用该标签,否则返回「不确定」转人工。它用的是返回的 probabilities,不需要额外调用。0.60 只是示意,不是校准过的保证;生产中的阈值应当用带标注的样本和误判代价来定(见 置信度 与 置信度门控路由)。
MIN_CHOICE_PROBABILITY = 0.60
def decide(probs: dict[str, float]) -> str:
label, p = max(probs.items(), key=lambda kv: kv[1])
return label if p >= MIN_CHOICE_PROBABILITY else "不确定"结果一:同一条评论问 15 次
成本与速度
| 条件 | 调用次数 | 平均输入 token | 每次成本 | 耗时均值 | 耗时中位数 |
|---|---|---|---|---|---|
| 象信(带 uid) | 15 | 1,066 | ¥0.0000448 | 4,160 ms | 1,292 ms |
| 象信(完全相同) | 5 | 1,041 | — | 1,275 ms | 1,280 ms |
成本按 ¥0.042 / 百万输入 token、输出免费计算。耗时是单线程顺序调用的往返时间;本次运行时推理后端同时承担着其他任务,15 次里有 2 次超过 20 秒,把均值拉高了,中位数更能代表正常情况。
逐题结果
| 题目 | 15 次的最高概率标签 | 最高概率范围 | 阈值 0.60 后 |
|---|---|---|---|
| category | 群体歧视 × 15 | 0.45–0.47 | 不确定 × 15 |
| primary_risk | 地域对立 × 15 | 0.48–0.49 | 不确定 × 15 |
| target | 个人 × 15 | 0.44–0.45 | 不确定 × 15 |
| stance | 借反歧视攻击 × 15 | 0.56–0.57 | 不确定 × 15 |
| action | 删除并记违规 × 15 | 0.36–0.38 | 不确定 × 15 |
| queue | 歧视队列 × 15 | 0.46–0.48 | 不确定 × 15 |
| review_path | 一线审核员 × 15 | 0.34–0.34 | 不确定 × 15 |
| severity | 中 × 15 | 0.50–0.52 | 不确定 × 15 |
15 次平均的完整分布能看出模型「在犹豫什么」:
| 题目 | 平均概率分布 |
|---|---|
| category | 群体歧视 0.461 · 人身攻击 0.291 · 无违规 0.168 · 暴力威胁 0.07 · 色情低俗 0.01 · 引流广告 0.0 |
| target | 个人 0.445 · 群体 0.355 · 无特定对象 0.15 · 平台或社区 0.05 |
| stance | 借反歧视攻击 0.567 · 反对歧视 0.303 · 发表歧视 0.09 · 无关 0.04 |
| action | 删除并记违规 0.367 · 删除 0.189 · 提醒 0.173 · 转人工 0.171 · 放行 0.099 |
| review_path | 一线审核员 0.34 · 资深审核员 0.28 · 自动 0.26 · 法务 0.12 |
这和人读这条评论的感受一致:它针对的是「个人」还是「河南人 / 地域黑」这个群体?是在反对歧视,还是借反歧视骂人?每道题的第二名都不算小。
概率的波动
对每道题,取每个标签的概率在 15 次之间的标准差,再对所有标签、所有题目取平均:
| 条件 | 平均概率标准差 | 最大单标签标准差 | 解析失败 |
|---|---|---|---|
| 象信(带 uid) | 0.0023 | 0.0061 | 0 |
| 象信(完全相同) | 0.0000 | 0.0000 | 0 |
完全相同的请求返回完全相同的概率;带 uid 的请求会有 0.01 量级的抖动(单个标签的最大标准差为 0.0061,例如 stance 的「借反歧视攻击」在 0.56 和 0.57 之间),这是模型对 state 里一个无关字段的轻微敏感,而不是采样随机性——象信一号不做采样。
一致率与自动处理比例
| 条件 | 原始一致率 | 阈值后一致率 | 不确定 | 自动处置 | 出现两个不同具体标签的题 |
|---|---|---|---|---|---|
| 象信(带 uid) | 100.0% | 100.0% | 100.0% | 0.0% | 0 |
「原始一致率」:每道题里出现最多的最高概率标签占 15 次的比例,再对 8 道题取平均。「阈值后一致率」把「不确定」也当成一种决定来算。
和 TypeSafe 原文的那条英文帖子不同,这条评论的每一道题都没有越过 0.60,所以阈值规则把它整条交给了人。这正是我们想要的:模型自己说「拿不准」,而且每次都说同样的「拿不准」,下游就不会一次删、一次留。
结果二:60 条 COLD 评论,一致率 vs 自动比例
单条评论只能说明「稳不稳」。要回答「阈值该设多少」,需要一批带参考答案的数据。我们对 COLD 抽出的 60 条评论各问两道 Choice、每条重复 5 次(300 次调用):
BATCH_QUESTIONS = {
"fine": Choice(
instructions="这条评论属于哪一类?",
criteria={
"攻击个人": "冒犯、辱骂或贬低某个具体的人。",
"攻击群体": "对某个地域、民族、种族、性别群体表达冒犯、偏见或刻板印象。",
"反偏见": "谈及地域、种族或性别话题,但立场是反对偏见、呼吁平等,没有冒犯。",
"其他无冒犯": "不含冒犯内容的其他评论(客观陈述、个人经历、一般讨论)。",
},
),
"decision": Choice(
instructions="按照「不允许冒犯性言论」的社区规范,这条评论应该怎么处理?",
criteria={"删除": "含有冒犯性内容,应当删除。", "保留": "没有冒犯性内容,可以保留。"},
),
}decision(删除 / 保留,二选一),300 个答案:
| 阈值 | 原始一致率 | 阈值后一致率 | 自动处置比例 | 自动部分准确率 |
|---|---|---|---|---|
| 不设 | 100.0% | 100.0% | 100.0% | 70.0% |
| 0.60 | 100.0% | 99.7% | 77.0% | 80.1% |
| 0.70 | 100.0% | 100.0% | 58.3% | 88.6% |
| 0.80 | 100.0% | 99.7% | 38.7% | 91.4% |
| 0.90 | 100.0% | 100.0% | 16.7% | 90.0% |
fine(四分类),300 个答案:
| 阈值 | 原始一致率 | 阈值后一致率 | 自动处置比例 | 自动部分准确率 |
|---|---|---|---|---|
| 不设 | 99.3% | 99.3% | 100.0% | 51.7% |
| 0.60 | 99.3% | 100.0% | 48.3% | 72.4% |
| 0.70 | 99.3% | 100.0% | 40.0% | 70.8% |
| 0.80 | 99.3% | 100.0% | 13.3% | 62.5% |
| 0.90 | 99.3% | 100.0% | 6.7% | 100.0% |
几点观察:
- 一致率几乎饱和。60 条评论里只有 1 条在
fine上出现过两个不同的最高概率标签;decision一条都没有。一致率 99%–100% 的时候,它已经不能帮你区分好坏设置了。 - 真正的旋钮是自动比例。阈值越高,模型越少自己拍板,拍板的部分越可能对。
decision从不设阈值的 70.0% 提到 0.60 的 80.1%、0.80 的 91.4%,代价是自动处置比例从 100% 降到 77.0% 和 38.7%。 - 阈值后一致率不总是 100%。阈值 0.60 和 0.80 时各有 1 条评论的最高概率恰好压在阈值上(5 次分别是 0.59、0.59、0.60、0.59、0.59 和 0.80、0.79、0.79、0.79、0.79),于是 5 次里有 1 次自动处置、4 次「不确定」。阈值规则不会让模型变得确定,只是把「两个具体标签来回跳」变成「具体标签与转人工之间偶尔跳」,后者的代价小得多。
- 四分类更难。
fine不设阈值时准确率只有 51.7%,主要错在把「反偏见」(45/75 次)和相当一部分「攻击群体」(40/75 次)判成「其他无冒犯」。高阈值档(0.80、0.90)剩下的自动样本很少(40 个、20 个答案),准确率的波动主要来自样本量。
按 COLD 真实类别拆开 decision(阈值 0.60):
| 真实类别 | 答案数 | 自动处置 | 自动部分准确率 |
|---|---|---|---|
| 其他无冒犯 | 75 | 86.7% | 100.0% |
| 反偏见 | 75 | 93.3% | 78.6% |
| 攻击个人 | 75 | 61.3% | 87.0% |
| 攻击群体 | 75 | 66.7% | 50.0% |
「攻击群体」是短板:模型在自动处置的一半答案里选择了「保留」,这 25 个答案中有 20 个的「保留」概率只在 0.62–0.71 之间,刚刚越过阈值。这些多是隐性的地域刻板印象(如「他们那谁也听不懂的鸟语」、比较各地经济的调侃),没有脏字,9B 的象信一号 1.0 容易低估它们,见 象信一号 1.0 的能力边界。对这类题,实践中应当按类别设不同阈值,或把「删除」概率进入某个区间的评论一律送审。
300 次调用平均输入 257 token,合计 ¥0.00323。
大模型对照
TypeSafe 原文把同一套题交给多种大模型,比较 temperature=0 / 默认温度 / 单选输出三种设置的一致率与概率波动。我们用 DeepSeek(OpenAI 兼容接口)做同样的对照,脚本 llm_baseline.py 已写好:
- 第一部分:同一帖子 × 3 种设置(分布输出 t=0、分布输出默认温度、单选 t=0)× 15 次 = 45 次调用;
- 第二部分:60 条 COLD 评论 × 分布输出默认温度 × 3 次 = 180 次调用。
提示词里放 json.dumps(POST)、全部 8 道题及其标签描述,并要求只输出 JSON;分布输出模式按标签逐个读取概率,缺失或非数字一律记为解析失败,不做任何修补。
对照数字待补:本文运行时大模型密钥尚未就绪,这一节的表格会在真实运行后补上,不做任何估计。
讨论
为什么稳。 象信一号不做采样:一次前向直接给出每个选项的概率。完全相同的请求返回完全相同的概率,只有 state 本身变化(哪怕是一个无关的 uid)才会让概率移动 0.01 量级。所以「标签翻转」只可能发生在两个选项概率本来就很接近的时候。
稳定不等于正确。 本文第一部分只测可重复性,不测对错;第二部分的 fine 题一致率 99.3%,准确率却只有 51.7%。一个每次都给出同一个错误答案的系统也是 100% 一致的。
什么时候该加「不确定」。 当错判的代价高于人工复核的代价时。阈值应当按动作的风险来定:给评论打个提醒标签可以低一些,删帖并记违规应当高得多。第二部分那张「阈值 × 自动比例 × 准确率」的表,就是在你自己的带标注数据上应该画的那张表。
局限。
- 第一部分只有一条评论,结论不能推广到所有边界内容;第二部分 60 条、每类 15 条,按类别拆开后每格只有 75 个答案、15 条独立评论。
- 带 uid 的设计无法区分「对无关字段的敏感」与其他来源的波动,这一点和 Noul 篇相同。
- COLD 的标注基于其论文的定义(例如「反偏见」算不冒犯),和你所在平台的规范未必一致。
完整代码
https://github.com/xiangxinai/xiangxin-cookbooks/tree/main/consistency_choice_cookbook
common.py:帖子、题目、COLD 抽样、判定与统计函数main.py:象信部分(第一部分 20 次调用,第二部分 300 次调用)llm_baseline.py:DeepSeek 对照(225 次调用;未设置LLM_API_KEY时直接退出)analyze.py:从results/生成本文所有表格data/cold_test.csv:COLD 测试集(Apache-2.0)

