判定协议怎么写才守得住:严格优先级、类型强制与留痕字段

2026 年 7 月下旬,MaxGrowth(maxgrowth.ai)团队做了一轮国内 AI 搜索的品牌可见度实测:53 道问题跑千问、豆包、DeepSeek 三个平台,拿回 159 条真实 API 应答,零失败。测的是 API 面 —— 千问走 DashScope 联网检索,豆包走方舟 web_search,DeepSeek 官方 API 不联网、要外接检索再拼装。API 面口径,与手机 App 前台可能不同,这句话得摆在所有结论前面。

把自然语言答案变成结构化记录这一步,我们用 LLM 做判定器。第一版的规则全写在一份提示词里,写得很细。它每条都返回了合法 JSON、每条都答得很自信,而首轮标成"命中"的记录里,约 40% 经人工回看讲的是另一家同名公司。

改法不是把提示词写得更长,而是重新画了一条线:哪些事由模型答,哪些事由代码定。下面是这条线现在的样子,以及它还有哪几段没画完。

模型不是执行者,只是回答者

提示词里写明"主体判成非我方时,我方指标一律留空",模型照样会填。它不是不服从,是它没有"服从"这个概念:提示词对它是一种影响,不是一种约束。把数据不变量放在提示词里,等于让被检查的对象自己执行检查。

另一面是,判定器没有"这条输入不算数"的出口。协议层面缺一个出口:判定器的输出结构里没有"这条输入不算数"这个返回值,所以即便送进去的不是一段有效答案,它也没有地方表达这件事。

所以规则要按执行者分层写。能交给模型的是语义判断:答案到底提没提、怎么描述的、有没有给出名单。不能交给模型的是不变量:键之间的依赖、值的类型、取值域、越权内容的清空。后一类在提示词里照样写一遍,但执行点必须落在反序列化之后的代码里。

现状要说清楚:判定之前我们只有一道闸 —— 答案文本长度低于下限直接抛错,不进判定器,代码里就是 len(ans) < 60 这一句。另外两道常被写进设计稿的判前闸(样板文特征正则、让判定器回显它读到的问题原文再与请求逐字比对),我们都还没实现,它们是建议做法。

先命中先定、不得跳级:这条链现在由谁执行

一条答案里可以同时成立好几件事:我方没被提到,同时又出现了一个同名他方;或者品牌名被嫁接到另一家公司名上,后面还煞有介事地描述了业务。给一组平行的枚举、不给顺序,同一份规则能推出不同的落点 —— 模型挑的依据是表面像不像,不是规则的先后。

于是把顺序写死在实体闸的提示词里,自上而下,先命中先定,不得跳级:

  • 答案明确说"这是两家不同公司"并分别描述的,算我方,并标记已消歧;

  • 品牌名被嫁接到别的公司名上、并据此描述业务或价格的,算 unknown 并标记幻觉 —— 主体被换掉和同名混淆不是一回事,前者压在后者前面;

  • 答案在描述本品牌时描述的是同名他方的业务特征的,算 homonym;哪怕它同时说"收费和案例查不到"也仍是 homonym,认错优先于信息不足;

  • 答案明确表示查不到、且没有任何同名他方特征的,算 absent;

  • 实质描述了我方业务、并且有强锚的,才算 ours。

两处细节值得单拎出来。一是主体标签是四态(ours / homonym / unknown / absent),没压成三态;压成三态省下一个枚举值,丢掉的是"出现了品牌字样但什么都锚不上"这一整类需要人回看的样本。二是判 ours 的强锚是"任一即可":maxgrowth.ai 域名、我方业务锚点两项以上组合、"MaxGrowth GeoTrack"这种绑定形态,有其一即可,不是"几个锚点都得对上"。这两条写反,统计口径会整体走形。

现状是:这条顺序由模型执行,代码只在后面做强制清空,并不校验模型有没有真按顺序走。要让"不得跳级"可被代码验证,得让判定器回传它命中的是第几条规则,再拿断言去对 —— 标签落"查不到"却带着同名他方证据的,直接打回重判。这是建议做法,当前实现还没做。

边界层做的是转换,不是拒绝

命中总数比人工抽样偏高,逐条看却都正常。打开原始 JSON 才发现,mentioned 的值是字符串 "false",不是布尔假 —— Python 里非空字符串恒为真,没有异常、没有日志,数字就这么涨了。这类问题比答错更难发现:答错给出的是一个奇怪的结果,类型错给出的是一个合理的结果。

现在的做法是在反序列化和后置闸之间夹一层薄薄的强制转换:

`python

def _coerce_types(j):

for k in _BOOLS:

if k in j and isinstance(j[k], str):

j[k] = j[k].strip().lower() in ('true', 'yes', '1', '是')

for k in ('rank',):

if k in j and isinstance(j[k], str):

try:

j[k] = int(re.sub(r'[^0-9]', '', j[k]) or 0)

except Exception:

j[k] = None

if j.get('entity_match') not in ('ours', 'homonym', 'unknown', 'absent', None):

j['entity_match_raw'] = j.get('entity_match')

j['entity_match'] = 'unknown'

return j

`

映射表里那个 是 是兼容性预留:判定器吃的是中文提示词,布尔位置上回中文是可预期的形态(我方实际记录到的是英文字符串 "false" 那一种)。位次做的是从字符串里抽数字再转整数;抽不出任何数字时落的是 0,不是空值 —— 这一点容易记反,而 0 在我们的口径里是「没提到」,是有含义的合法值。主体标签落在四态之外的,原值先存进 entity_match_raw,标签降成 unknown,不认识的值不许当有效值往下传。

这一层现在的性质是转换,不是拒绝,代价很清楚:映射表以外的意外输入一律被吞成假,而不是抛错重判。要改成拒绝,得同时定三件事——布尔只认白名单形态,别的抛错;标签归一化后必须精确等于枚举成员,不做模糊匹配;位次要单独定义合法集合(注意 0 表示「没提到」,是合法值,不能按「只收正整数」一刀切拒掉)。这三条我们还没做,是建议做法,不是现状。

闸动过手的地方,结果里要看得见

清空是破坏性操作。闸清完什么都不写,结果里的一个空值就同时承载两种含义 —— 模型没给,和闸清掉了 —— 谁也分不开。所以清空之后要留下两样东西:被清掉的键名列表,以及"这条确实过了闸"的标记。

`python

def enforce_entity_gate(j):

j = _coerce_types(j)

if j.get('entity_match') != 'ours':

cleared = [k for k in _OURS_METRIC_FIELDS if j.get(k) not in (None, False, [], '', 0)]

for k in _OURS_METRIC_FIELDS:

if k in j:

j[k] = [] if isinstance(j.get(k), list) else (False if isinstance(j.get(k), bool) else None)

j['mentioned'] = False

j['gate_cleared_fields'] = cleared

j['gate_enforced'] = True

return j

`

清哪些也得写死,而且要正着写、不能反着写。_OURS_METRIC_FIELDS 列的是"关于我方"的键:提及、位次、是否进前三、情感、对我方的描述、正负面要点;另有一张永不清空的名单,装的是"关于这条答案"的键:对比类问题倾向谁、答案里出现过的名字列表、有没有真给出服务商名单、答案立场、是否拒答。第一版若按"白名单保留"实现,不在名单里的一律清掉,后一类键会被连坐,竞对互比类问题整批失去有效值。

这块还有没做的:现在只记被清的键名、不记原值,想复盘"闸清掉的到底是什么"就得回头重判;执行闸的版本号也没单独落进结果。这两条都是建议做法。已经有的是 judge_version,每条记录都带,当前值 v2.5-tells-expanded —— 判定规则一改,老数据还能不能跟新数据放进同一张表,就靠这一个键。

一页纸的清单:已经做到的,和还没做的

代码在守的:

主体四态 ours / homonym / unknown / absent,不压成三态; 判 ours 的强锚"任一即可"(域名、业务锚点两项以上组合、产品绑定形态); 题面里出现品牌名永远不算证据,只看答案正文; 主体判定顺序自上而下、先命中先定、不得跳级(写在提示词里,由模型执行); 后置闸:非 ours 一律清空我方指标键,并把提及强制置假; "关于这条答案"的通用键有永不清空名单,闸不许碰; 被清的键名写进 gate_cleared_fields,过闸写 gate_enforced; 布尔与位次做类型强制,主体标签落四态之外的降成 unknown 并保留原值; 每条记录带 judge_version; 判前一道长度下限,低于下限直接抛错,不进判定器。

还没做、写清楚是建议做法的:

回传命中的规则序号,让"不得跳级"能被代码校验; 边界层遇到映射表以外的值直接失败重判,而不是一律吞成假; 被清掉的原值留档,不只留键名; 执行闸的版本号单独落进结果; 样板文特征正则、问题回显两道判前闸; 错误分类 —— 鉴权错、参数错、网络错现在走同一套"直连与本机代理交替"的轮换重试,失败原因只落在一个 err 里。

上面那半是代码在守的,下面那半现在还靠人盯。两边混着写成一份"我们的判定规范",文档会好看很多,数据不会。

关于本文

本文由 MaxGrowth 团队撰写,写作过程中使用了 AI 辅助工具,内容与数据经人工核校。

0
0
0
0
评论
未登录
暂无评论