槽位能表达“当前值是什么”和“处于什么处境”,却还回答不了“凭什么”。
这一章补上这块:系统据以做判断的依据,本身也需要被管理。
一、他先说 A 地,后来说 B 地
先看一段对话。
第 3 轮 用户:我们公司登记地在 A 地
第 7 轮 用户:哦对了,这批产品是要交付到 B 地分部的
现在系统要填“地区”这个字段。填 A 地还是 B 地?
这个问题槽位回答不了,因为它只有一个格子。 而真正麻烦的地方在于,这两句话可以有至少三种解释:
- 用户在纠正——第 3 轮说错了,实际应当是 B 地;
- 这是两个不同的字段——公司登记地在 A 地,交付地址在 B 地;
- 这是一个新的范围——公司登记地在 A 地,但这批产品属于 B 地分部的新需求。
三种解释对应三种完全不同的后续行为。而不管选哪一种,系统都需要一个东西:依据。
依据不能是“模型觉得”。它必须是一条可以被指出来、被核对、被比较的记录——谁说的、原话是什么、什么时候说的、说的是哪个范围。
这就是证据。
二、证据是一条记录,不是一个值
先把证据和值分开。第 10 章说过:value 是系统当前允许使用的值,而用户实际说过什么由证据保存。
一条证据记录大致长这样:
{
"id": "E07",
"source": "user",
"timestamp": "<event_time>",
"turn": 3,
"scope": "demand_1",
"quote": "我们公司登记地在 A 地",
"locator": { "message_id": "m_31", "start": 0, "end": 7 }
}
三件事值得单独说。
第一,quote 必须逐字来自输入。 不能是“用户提到公司在北方地区”这样的转述。第 4 章讲过更强的做法是由程序验证这段文字确实出现在原文里;第 7 章把它落成了引用核验。这里是同一条原则在状态层的应用——转述不能当证据。
理由很实际:改写之后就失去了裁决依据。“不方便接电话”被总结成“用户对联系方式有顾虑”,之后你就无法准确判断他拒绝的是电话联系,还是所有联系方式。
第二,证据账本只追加,不修改。 用户改口不是去改那条旧证据,而是新增一条。
这里要分清一件很容易混的事:证据记录的真实性,不等于证据内容的真实性。 账本要保证的是“这句话确实在这个时间、由这个来源出现过”,而不是“这句话描述的事实一定成立”——用户完全可能说错、记错,或者过一会儿改口。
所以旧证据依然有效地证明着“用户当时确实这样说过”;至于它现在还该不该支撑当前状态,由后面的归并规则决定。第 8 章讲历史时用的是同一条原则,这里把它落到了字段级别。
第三,证据要标明来源。 常见的有这几类:
表 11-1 证据来源与使用注意事项
| 来源 | 例子 | 注意 |
|---|---|---|
| 用户消息 | “我们公司在北京” | 最常见,也最需要保留原文 |
| 工具回执 | 接口返回的订单地址 | 第 9 章:它是观测结果,进证据链 |
| 注入字段 | 页面上的当前产品 | 第 7 章:先判有效性,再作为证据 |
| 人工录入 | 客服在后台补的信息 | 要记录操作人;是否更权威由业务规则决定,并不天然更可信 |
这里要划一条线:模型的推断不属于原始证据。
“用户看起来是想改地址”——这不是一条观察记录,而是对若干条观察做出的解释,它属于下一节要讲的事件层。如果业务上确实需要把这类判断长期保存,也必须标明它是派生的,并记下它依据了哪几条原始证据:
{ "type": "derived", "claim": "...", "derived_from": ["E07", "E19"] }
不能让推断和用户原话在账本里长得一模一样。 一旦混进同一层,后面的裁决就失去了最重要的一个维度——这句话究竟是谁说的。
前面那个 JSON 是文本型证据的样子。不同来源可以保存不同的原始载荷:用户消息保留原话和定位,工具回执保留原始返回体和请求标识,注入字段保留原始字段值与来源标记。共同要求只有一条:必须能回到那份未经语义改写的原始观察。
适用边界 证据账本会保存用户原话,因此它同时也是一份含有个人信息的数据。存多久、谁能看、怎么脱敏、导出时怎么处理,属于数据治理的范畴——第 23 章会讨论。“要留证据”和“要留所有原话很久”不是一回事。
三、证据锁:机械的事先做,而且不许改写
这一节讲一个具体的机制,它解决的是一类很隐蔽的故障。
故障形态是这样的:用户在一句话里给了手机号,机械上完全能抽出来,但系统最后还是没拿到。
原因通常出在顺序上。如果整个抽取过程交给一次语义判断——“请从这段话里识别用户提供的信息”——那么模型在识别的同时还在做别的判断:这是不是有效?他是不是在开玩笑?这个号码看起来像测试数据吧?于是一个本来已经抓到的值,在语义层被弄丢了。
解法是把顺序倒过来:先做纯机械的抽取和校验,把结果锁定;语义判断在这之后进行,且不得改写已锁定的部分。
以手机号为例,机械层做的事非常朴素:
为避免在公开书稿中写入可能对应真实用户的完整号码,下面统一用 X 表示任意一位数字;这些字符串只演示格式,不对应可拨打号码。
1. 扫描候选片段 → 原文:"138 XXXX-XXXX"
2. 只删空格、短横线、括号 → 规范化值:"138XXXXXXXX"
3. 纯结构校验:长度、首位、号段
4. 通过 → 记为一条证据;不通过 → 记为无效候选,同样留证
这里有个细节不能含糊:规范化的结果不能覆盖原文。 证据保存的是用户实际打出来的那串字符,规范化值是由它派生出来的候选:
{
"id": "E14",
"quote": "138 XXXX-XXXX",
"locator": { "message_id": "m_44", "start": 6, "end": 19 },
"normalized_value": "138XXXXXXXX"
}
原文用于追溯,规范化值用于校验和后续处理。 两者一旦混成一个字段,出问题时你就再也说不清用户当时到底打了什么。
这里有一条明确的禁止:不得因为“看起来像测试号”就判无效。 连续的零、顺子、重复数字——这些都可能是真实号码。真实性需要独立的验证机制来确认——短信验证码、回拨、账户绑定信息、外部系统核验——而不是靠模型对号码“像不像真的”的主观判断。模型一旦获得“我觉得这个不像真的”这种否决权,就会开始丢弃合法数据,而且丢得毫无规律。
但“锁什么”必须说清楚,否则很容易实现成一个危险的版本。看这句话:
“我们客服号码是 138xxxxxxxx,不过别打那个,联系我微信。”
数字确实在那里,机械层完全能抽出来。但它不是用户的联系电话,而且用户已经明确否定了这个渠道。 如果证据锁把“这是 phone 字段的有效值”也一并锁死,系统就会直接得出一个完全错误的结论。
所以边界是:
机械层锁定的是“这段内容确实出现过”,不是“它属于哪个字段、是不是当前有效值”。
原始候选片段 → 锁定(不许语义层悄悄删掉)
↓
属于哪个字段 → 语义判断
↓
产生什么事件 → 语义判断 + 业务规则
↓
成为当前值吗 → 归并规则决定(第 12 章)
被锁住的只有第一格。后面每一格都仍然要经过解释——证据锁保证的是“抓到的东西不会凭空消失”,而不是“抓到就算数”。
同样的思路适用于其他字段:
- 非电话渠道:要得出“微信账号是什么”这个结论,需要渠道名加实际账号值。“加个微信吧”当然是一条真实证据——它可能支持“用户偏好微信”,配合上下文甚至能支持“电话被拒绝”——但它不足以产生
set(wechat, ...)。“微信 wx_wang01”才具备这个条件。证据存在,不等于它能支撑你想要的那个结论。 - 组合表述:如果某类实体有稳定的词典或确定性规则——机构名词典、标准账号格式、固定编号规则——那么规则能稳定命中的片段可以先锁定为候选。但把“A 公司刘先生”拆成单位和联系人两项,本身就需要词典或实体识别;规则稳定判不了的绑定,仍然交给语义层。
- 流程标记:判断“摘要是否已经发过”,只认系统历史里真实出现过的固定标记,而不是从对话语义里推测。
生产观察 证据锁的优先级高于后续的语义抽取——但要按上面那条边界来理解:语义层可以判断这段内容属于哪个字段、构不构成有效值,却不能让它凭空消失。 如果语义层的输出里干脆没有出现这段已锁定的内容,那就不是判断结果,而是丢失,应当重算。这条规则挡住的是一类最难排查的故障:字段明明给过,系统却当作没有。
这条机制其实是第 3 章那句话在状态层的又一次应用:能被规则判定的,不要交给语义。
生产观察 第 1 章那个系统后来把这件事做成了提示词的第一执行步,标题写着“原始证据预检【先于一切语义判断】”,第一句是:
先直接读取原始消息角色和原文,生成不可被后文改写的证据锁。
里面有三条特别值得看。
第一条是关于有效性判断的边界:
手机号只按结构判断。规范化后恰为 11 位数字、首位为 1、第二位为 3—9 时为有效。不得因连续零、重复数字、尾号循环、顺子或号码“看起来像测试号”判无效;真实性由后续人工联系确认,不由 AI 猜测。
这里要守住本章的那条区分:证据校验的是“这个值合不合格式”,不是“这个人是不是真的用这个号”。 把“看起来像假号”写进判定,就是让证据层去承担它无法承担的责任。
第二条是关于原文与规范化值的分离:
对同一个手机号候选只删除空格、短横线、括号和字段标签,再拼接数字。公开稿中的格式示意写作
138 XXXX XXXX、138-XXXX-XXXX→138XXXXXXXX,其中X代表任意一位数字。原文照原样留着,规范化值另存一份——这样“用户到底怎么写的”和“系统实际使用的值”都不会丢。
第三条是关于什么才算证据:
只有 AI 回复里真实出现过固定标记「需求摘要」……才算摘要已完成。用户原文里的“需求:”、诊断输出、槽位值、尚未发送的摘要草稿,均不得作为摘要完成的证据。
最后那一项最见功力:草稿不是证据。 系统自己正准备说的话,不能反过来当成“这件事已经发生过”的依据——这条纪律在第 18 章会以另一种形式再出现。
这一节的末尾还给了证据锁一个明确的优先级:“若后续状态与证据锁冲突,保留原始证据锁并重算后续状态。” 冲突时牺牲的是推导结果,不是原始观察。
四、什么交给模型,什么交给代码
顺着上一节,可以把分工写清楚。
先区分历史实现和目标分工:上面的“证据锁”真实存在于提示词内部,仍由模型按结构化规则执行;下面这张表描述的是具备应用层条件后的目标形态,其中可确定判定的部分才交给代码。
表 11-2 模型判断与代码判定的分工
| 交给模型判断 | 交给代码判定 |
|---|---|
| 这句话在说哪个字段 | 格式对不对(长度、字符集、结构) |
| 这是提供、纠正,还是拒绝 | 取值在不在允许范围内 |
| 说的是哪个需求、哪个对象 | 是不是和已有证据完全重复 |
| 表述是否含糊、需不需要澄清 | 时间是否已过有效期 |
分界线是:需要理解语言的,交给模型;能用规则判定的,交给代码。
这两边的失败方式也不一样。模型判错,通常表现为把 A 字段的内容填进了 B 字段,或者没认出用户在纠正;代码判错,通常表现为规则本身写得不对,而且每次都会以同样的方式出错——这反而是好事,因为它可复现、可测试、可修。
五、事件:这条证据对系统意味着什么
证据说的是“发生了什么”,但它还没有说“这对系统意味着什么”。中间需要一层转换。
图 11-1 给出这条单向链:证据保存观察,事件保存解释,归并器再据此计算候选状态。后一层不得覆盖前一层。
图 11-1 证据、事件与候选状态的单向链
比如“不方便接电话,微信 wx_wang01”这一句话,它同时意味着两件事:电话联系被拒绝了,微信有了值。所以一条证据可以产生多个事件,也可能一个都不产生(纯闲聊)。
需要先说明范围:这里讲的是“用户表达如何改变槽位”这一类语义事件,不是系统里全部的事件类型——工具回执、动作成败、询问次数的变化,同样需要有可追踪的写入路径。
这类事件分两层。
字段事件——改变某一个槽位:
表 11-3 字段事件的操作语义
| 操作 | 含义 | 典型表述 | 对状态的影响 |
|---|---|---|---|
set |
提供一个值 | “我们在北京” | 未知 → 有效 |
correct |
纠正之前的值 | “刚才说错了,是上海” | 有效 → 有效(换值) |
decline |
明确拒绝提供 | “手机号先不留了” | → 已拒绝 |
clear |
撤回之前给过的值 | “那个先不用了” | → 已撤回 |
作用域事件——改变的是“有几组字段”:
表 11-4 作用域事件的操作语义
| 操作 | 含义 | 典型表述 | 影响 |
|---|---|---|---|
new_scope |
开启一个新的范围 | “另外还要一台别的” | 新建一组字段 |
分层的理由在第 12 章会更明显:归并时要先处理作用域,再把字段事件折叠到对应的那一组里。顺序反了,新需求的值就会落进旧需求。
每个事件必须引用产生它的证据:
{
"type": "decline",
"slot": "phone",
"evidence_ids": ["E14"],
"turn": 4
}
用数组而不是单个字段,是因为一次解释常常需要多条证据共同支撑。开场那个例子里,单看 E19 那句“送到上海分部”,根本判断不出它是不是纠正——必须和 E07 放在一起才有结论:
{ "type": "correct", "slot": "region", "evidence_ids": ["E07", "E19"] }
这条引用是整个链路可追溯的关键。出问题时你要能反过来问:这个字段为什么变成了“已拒绝”?——顺着事件引用的 evidence_ids 回到 E14,看到原话“手机号先不留了”,这条判断就说得清楚了。 如果事件不带引用,状态出错时你只能重新猜一遍。
correct 和 new_scope 的区分尤其重要,因为它们对应的正是开场那个问题——而这两者往往只差一句话的语气。第 12 章会专门处理它。
六、证据也有适用范围
第 7 章讲过一条边界:“这条证据说了什么”和“这条证据在说谁”是两个问题。 在状态层,这一条要落成证据记录上的字段。
范围至少有三个维度:
说的是谁 哪个需求、哪个订单、哪个产品、哪个联系人
什么时候被观察到 这句话是什么时候说的、这条数据是什么时候查的
什么时候有效 从哪天开始生效、什么时候失效、哪个版本
适用于什么 适用机型、适用地区、适用政策
中间两行必须分开,它们经常不是一回事。今天查到一条政策,写着“自下月一日起执行”——被观察到的时间是今天,有效时间是下个月。 把两者混成一个字段,第 10 章那个“已过期”状态就没法正确判定。
为什么范围要记在证据上,而不只是记在槽位上?因为同一个槽位的不同证据,范围可能不同。用户为第一批产品说过地址,为第二批产品又说了另一个——两条证据都真实,各自属于不同的需求。如果范围只记在槽位上,这两条就会互相覆盖。
范围也是第 10 章那个“已过期”状态的来源:一条证据的有效期过了,或者它所属的场景已经变了,由它支撑的状态就应该转为已过期,而不是继续当作当前值使用。
七、回到开场
现在用这一章的工具重新看那两句话。
账本里有两条证据:
E07 turn 3 user scope=demand_1 "我们公司登记地在 A 地"
E19 turn 7 user scope=? "这批产品是要交付到 B 地分部的"
问题一下子清楚了:真正要判断的不是“信哪一个”,而是“E19 是什么类型的事件”。
- 如果是
correct,当前地区更新为 B 地;E07 仍然留在证据账本里,只是不再支撑当前的地区值; - 如果它说的是另一个字段(收货地址 ≠ 公司所在地),那么两条证据各归各的字段,谁也不影响谁;
- 如果是
new_scope,那么这是一个新需求,它有自己的一组字段。
E19 的 scope 之所以标成问号,是因为它需要判断,不能直接从原文读出来。
判断有两条路。能判定就判定:如果字段模型里本来就区分“公司登记地”和“交付地址”,“交付到 B 地分部”就提供了很强的交付地址线索;规则能稳定判定,就直接绑定。判不了就问,但要问得具体:
不好:您说的地区是哪个?
好: 这批产品是交付到 B 地分部吗?公司登记地仍按 A 地记录可以吗?
第二种问法之所以更好,是因为它把系统的两种解释摆了出来,用户一句话就能裁决。当系统无法在两个解释之间选择时,正确的做法是把选择权交出去,而不是自己挑一个然后继续往下走。
八、该信哪一个
证据层现在具备了完整的最小链路:每条依据都有来源、时间、范围和原文,机械可判的部分被锁定,语义判断有了明确的分工,证据到状态之间也有了事件这层转换。
但最初那个问题还没有真正被回答。
假设 E19 确实是对同一个字段的 correct。那新值就该覆盖旧值吗?如果两条证据来自不同来源呢——用户说北京,工具查到的注册地是上海,信谁?如果时间上更早的那条来自人工客服的确认,而更晚的那条只是用户随口一提呢?
再进一步:一条 decline 之后,用户又主动给了值,算不算撤销了拒绝?一个 new_scope 开出来的新需求,要不要继承上一个需求里的联系方式?
这些问题都不是单条证据能回答的,它们需要一套规则:多条证据摆在一起时,怎么合成一个当前值。
这套把多条证据与事件合成当前状态的规则,就是归并。