模型负责两端的语义翻译,确定性执行层负责中间的状态、许可与提交。
这一章往里走一层。因为“交给模型”这件事,如果真的塞进一次调用里一把做完,会立刻出问题——一次模型调用不是一个黑盒,它内部同样有职责边界。
一、一次调用里,其实混着三件事
看一个最常见的写法。系统给模型一份提示词,让它在一次调用里完成整个回合:
这是对话历史和当前状态。请你:
理解用户这句话的意思,
判断现在该推进到哪一步、该不该生成摘要,
然后用得体的语气回复用户。
这段提示词看起来很自然,很多系统一开始就是这么写的。但它其实要求模型在一次生成里,同时干三件性质完全不同的事:
理解 这句话是什么意思、该更新哪个字段 —— 语义
决策 现在允许做什么、这一轮该做哪件 —— 逻辑
表达 把要说的话组织成得体的回复 —— 语言
按第 16 章给出的目标分工,中间那件“决策”只要已经能由明确规则判定,就不该继续让模型自由决定,而应由确定性执行层承担。真实项目当时仍主要用提示词内部的代码式结构表达这些规则;这里讨论的是具备应用层条件后的实现。另一个问题是:即便抽取、决策和表达暂时都由模型参与,把三件事挤在一次生成里,也会互相污染。
二、挤在一起会怎样
三件事同处一次生成,最典型的三种翻车。它们描述的都是可观察的输出倾向,不是对模型“内心怎么想”的断言——在联合生成里,判断结果和表达倾向常常是耦合着一起产出的。
表达带偏了决策。 为了让回复连贯,模型倾向于产出一个能让对话顺畅收尾的动作。用户语气热情,输出就更容易滑向“意向很强、可以推进”;用户说了句客气话,摘要就一起被生成出来了——推进动作和当前语气被一起写了出来,而不是先独立判断、再表达。
决策被表达偷偷改写。 系统状态里明明是“联系方式已拒绝”,但模型在组织回复时,“结尾邀请对方留个联系方式”读起来更自然,于是又把已经拒绝的字段问了一遍。不是它故意违反状态,而是表达的连贯性盖过了状态的约束。
理解被结论反向带动。 当“这一轮应该推进”的倾向先出现,模型再去组织对用户话语的解释时,就更容易把一句中性的话呈现成“有购买意向”。结论和证据在同一次生成里互相迁就——属于第 4 章讨论静默推理时警惕的那种事后合理化。
这三种翻车有一个共同点:判断和表达放在同一次生成里,语言的连贯性就会和判断结果耦合,去影响本该独立得出的判断。
三、拆成三段:抽取、决策、渲染
解法是把一个回合拆成三段,每一段有明确的输入、输出和职责。
① 抽取 输入:用户消息 + 当前状态
输出:结构化的证据候选和事件候选
职责:只理解,不决策、不表达 —— 模型
② 决策 输入:归并后的候选状态
输出:这一轮的主动作 + 原因码
职责:只按规则判断 —— 确定性控制流水线
③ 渲染 输入:已经批准的动作 + 允许陈述的事实
输出:给用户的自然语言
职责:只表达,不改动任何决定 —— 模型
在本书的目标架构里,中间那段“决策”已经按第 16 章的分工交给确定性控制层。这里还要说清一件事,免得把它误解成另一个大函数:
“决策段”是一个运行时阶段的名字,不是一个新的单体决策器。
它内部不是一个 decision(state) 大函数,而是前面几章拆好的那一整套确定性组件:归并、闸门、策略、状态机、不变量。第 13 到 15 章费力把决策拆成这些独立可测的部分,不能到这里又被塞回一个黑盒里。准确的图景是:
抽取契约(模型)
↓
确定性控制层
〔归并、闸门、策略、状态机、不变量等,
按各自职责和检查点运行〕
↓
计划动作契约
↓
渲染契约(模型)
这里特意不把中间那层画成一条固定的串行流水线。原因在第 15 章已经讲过:不变量分状态、动作、提交前几种,检查点各不相同;状态机也不是简单地“排在策略后面跑一次”。这一层的工作,是由这些确定性组件按各自的职责和检查点共同完成的,不是一条谁跟着谁的固定管线。第 17 章不承担描述完整运行时顺序的任务——那些细节分散在第 12 到 15 章,也不该在这里再压出第二张略有出入的母图。
所以真正由模型做的,是一头一尾:抽取和渲染。这正好落回上一章那句“模型管两头”。
关键在于:这两头之间,要有一道确定性的控制边界。 抽取的输出先经过证据核验、归并、闸门和不变量,才轮到渲染。渲染拿到的不是“用户说了什么”,而是“系统已经批准做什么”。按这套目标分工,表达层没有权限回头改写已经形成的判断。
每一段之间的接口,就是一份契约。
抽取契约和渲染契约后面两节会展开,中间那份也要正式说清,否则“三份契约”就少了一份:确定性控制层最终不能只返回一句“该问微信了”,而应该形成一个结构化的计划动作(Planned Action)——动作类型、作用域、状态版本、原因码,以及渲染所需的事实引用。它既是控制层的输出,也是渲染层的输入。 后面第六节看到的 action / scope_id / base_version / reason_code / facts,就是这份契约在本书参考架构中的一种实现。
于是三份契约正式闭环,各管一段翻译:
抽取契约 自然语言 → 证据候选 / 事件候选
计划动作契约 候选状态 → 已批准的动作
渲染契约 已批准的动作 → 给用户的消息
三段三份契约。
生产观察 这个拆分在同一项目体系的另一条业务流里,被写成了提示词全文第 0 节,标题叫“最高优先级:计算与输出分离”,正文把一轮拆成六步:重建上一轮状态、提取本轮证据、合并为终状态、计算当前状态与唯一动作、按动作生成回复、发送前校验——这里的“终状态”是旧系统用语,对应本书的本轮候选状态,不是流程终止态,也不是已提交状态。然后是一句边界声明:
以上计算过程永不向用户展示。
后面跟着一份禁止清单,禁止在回复里出现内部状态、动作名、字段名、计数、自检结果,以及“按……处理”“记录为……”“归类为……”这类字段映射说明。
同一项目体系的两条业务流都出现了相近结构。 这说明“计算与输出分离”的问题在该体系内部重复出现;它能否跨项目复现、是不是同类问题的普遍收敛解,仍需要独立项目验证。
四、抽取契约:只做语义解释,不做业务决策
抽取这一段,模型的任务被严格限制在把自然语言转成证据候选和事件候选:它可以解释用户表达的语义,但不能据此决定系统下一步该做什么。这里的输出要按第 11 章那条边界分成两层——证据候选(观察到了什么原话)和事件候选(这次观察该怎么解释),不能混成一团。
要说清这条边界的两面:
"电话就不留了" → decline(phone) 合法:这是对语义的解释
decline(phone) → "那就改问微信吧" 越界:这是业务决策
前者是抽取该做的——把一句自然语言解释成一个结构化事件;后者是控制层的事——已拒绝手机号之后该转向哪个渠道,由业务策略决定(第 16 章)。抽取可以说“用户在拒绝”,但不能说“所以接下来该做什么”。 这条线和第 11、16 章完全一致。
它的输出契约大致是这样:
{
"evidence_candidates": [
{"temp_id": "E1", "quote": "电话就不留了",
"locator": {"start": 10, "end": 17}},
{"temp_id": "E2", "quote": "我们单位在上海",
"locator": {"start": 0, "end": 7}}
],
"event_candidates": [
{"type": "decline", "slot": "phone", "evidence_refs": ["E1"]},
{"type": "set", "slot": "organization_region",
"value": "上海", "evidence_refs": ["E2"],
"confidence": "high"}
],
"unresolved": ["用户提到'尽快',但未说明具体时间"]
}
(注意这里用的是“我们单位在上海”这种无歧义的表述。第 11 章讲过,“发到上海分部”这类话到底是收货地、公司所在地还是一个新需求,本身就需要判断——那种歧义案例正是抽取要在 unresolved 里标出来的,而不是直接绑成一个字段。)
有四条约束是这份契约的重点。
第一,只输出观察,不输出结论。 抽取可以说“用户在拒绝 phone 这个字段”,但不能说“所以这一轮应该改问微信”。后者是决策,不归它管。一旦抽取开始输出动作建议,决策的边界就被它侵蚀了。
第二,证据和事件分开,事件只引用证据。 事件候选不自带原文,只用 evidence_refs 指向证据候选。为什么要分开:接下来由证据层(代码)机械核验每一条 quote 是否逐字来自输入,通过了才锁定成正式证据;事件则做枚举与结构校验。这是第 11、12 章的分工——
原文引用由证据层在事件进入归并之前机械核验;归并器只消费已经通过验证的事件及其证据引用,它自己不重新核验原文、也不重新理解语言。
上一版写成“带原文让归并可以核验”,那是错的:核验发生在归并之前,归并只管折叠。
第三,confidence 只是诊断信息,不是放行依据。 事件里可以带模型自报的 confidence,但要明确它的地位:
模型自报的 confidence 只是一条诊断元数据,不等于证据强度,更不能直接作为闸门放行的依据。
一个字段能不能被归并进候选状态,取决于证据核验、事件语义、业务规则和归并的结果;能不能最终成为已提交状态,还要经过验证与提交——都不取决于模型说自己有多确定。否则就会出现“模型说 high,系统就当真”这种危险的短路。
第四,拿不准要显式说出来。 unresolved 遇到含糊的表述(“尽快”到底是多久),正确的做法不是替用户猜一个,而是如实标记“这里不确定”。抽取阶段的猜测,会变成后面所有环节都无法察觉的错误——一旦它写成了一个确定的事件,下游就把它当事实处理了。这对应第 8 章那条判断:不能表达不确定的系统,会把每次拿不准变成硬猜。
五、渲染契约:只读已批准的决定
渲染是三段里最容易出问题的一段,因为它离用户最近,最有“自由发挥”的诱惑。
渲染契约的核心只有一句话:
渲染层只能读取已经批准的业务决定,不能自行重新做业务决策。
要说准的是,渲染层当然还在“决定”一些东西——用疑问句还是陈述句、先确认还是先提问、怎么组织语言。但这些都是措辞层的选择,不是业务动作。渲染不能碰的,是动作本身、事实本身和行动权:它不能把“询问联系方式”改成“提交服务请求”,不能添一个没被批准的事实,也不能翻掉控制层的结论。
具体来说,渲染拿到的输入是一个已经定好的动作,连同它被允许陈述的事实:
{
"action": "ASK_FIELD",
"scope_id": "demand_1",
"base_version": 41,
"field": "wechat",
"reason_code": "PHONE_VALUE_DECLINED_TRY_ALTERNATIVE",
"facts": [
{"key": "phone_status", "value": "用户已拒绝提供手机号",
"evidence_ids": ["E14"]}
]
}
它要做的,只是把这个动作说成一句得体的话:“方便留个微信吗?加您沟通更方便。”
有几件事它明确不许做:
- 不许改动作。 决定是“问微信”,它不能因为“觉得这时候发个摘要更好”就去发摘要;
- 不许添事实。
facts里没有的东西,它不能说。不能为了让话显得专业,编一个“我们的工作人员已经开始处理”; - 不许翻案。 上游判定“已拒绝提供手机号”,它不能在措辞里旁敲侧击地再问一次手机号;
- 不许把原因码直译给用户。
reason_code是给日志和话术选择用的,不是让它对用户说“因为 PHONE_VALUE_DECLINED_TRY_ALTERNATIVE”。
这里顺带纠正一个措辞。上面的 reason_code 用的是 PHONE_VALUE_DECLINED——拒绝提供手机号,而不是笼统的 PHONE_DECLINED。第 15 章区分过:拒绝提供手机号(字段维度)和拒绝被电话联系(许可维度)是两件事,可以同时是“号码已给、但不许打电话”。渲染层的 facts 和原因码都要保持这个区分,不能又混回去。
这些约束合起来是一件事:渲染把“决定什么”和“怎么说”彻底分开了。 决定是上游的、确定性的、可测试的;渲染只负责语言。
生产观察 渲染层最隐蔽的越界,不是公然改动作,而是用措辞悄悄扩大或改变动作的含义。决定是“确认收到手机号”,它渲染成“好的,我已经帮您登记,工作人员稍后联系您”——顺手加了一个“已登记”的承诺,而系统可能根本还没提交。这就回到了第 9 章那条:完成语是事实主张,没有回执支撑就不能说。渲染层是完成语最容易溜出来的地方。
生产观察 这条边界在真实系统里甚至写进了小节标题。下面保留原结构、将业务词汇匿名化:“联系信息问句渲染【只读
APPROVED_FIELD】”,正文第一句就是“本节不参与字段选择”,然后规定渲染层的自由度只剩下一件事:锁定后,渲染层只能决定是否添加“好的”或安全短称呼,不得反推、替换或调整问句所索取的字段。
更狠的一条在提交那一节:已提交状态“话术渲染、检索内容、称呼和自然语言优化只能读取,不能回写”。
值得注意的是这些约束的形式——它们不是“请注意不要改动作”这样的提醒,而是把渲染层能碰的东西枚举了出来:读
APPROVED_FIELD、选基础问句、决定加不加一句自然前缀,然后锁定。枚举允许做的,比列举不许做的更闭合;这是第 7 章白名单原则在表达层的应用。
六、facts 白名单:渲染只能说被允许说的
上面那个 facts 字段值得单独讲,因为它是控制“渲染不乱说”的主要手段。
思路和第 7 章的输出白名单一样:与其列一长串“不许说什么”,不如明确给出“允许说什么”。
但要说准 facts 从哪来。它不是简单地“来自已提交状态”——如果这样,本轮用户刚说的手机号还没提交,渲染就没法说“好的,手机号收到了”。准确的说法是:
在目标运行时中,facts 是一份“获准用于表达的事实快照”:由确定性控制层从当前可用的信息里挑选、批准,并与本轮的动作和状态版本绑定。配套参考实现由代码承担这一职责。
它的来源可以是多处:上一版已提交状态、本轮刚算出的候选状态、工具返回的观测结果、已核验的证据——但共同点是它们都经过了上游批准,而不是模型自己认为“可以说”。渲染只能在这份快照的范围内组织语言:
允许说 facts 快照里列出的事实
可以做 把它们组织成通顺、得体的话
不能做 陈述快照之外的任何"事实"
还有一层边界要说清:不同来源的事实,能支撑的表达强度不一样。 本轮候选状态和已核验证据,足以支撑“看到您刚提供了手机号”这类观察性表达——它陈述的是“用户说了什么”。但“手机号已经登记成功”“服务请求已经提交”“已经进入人工处理”这类完成语,陈述的是“一件动作已经发生”,它们不能只凭候选状态就说出口——内部状态类主张(如“信息已保存”)由成功的应用内提交支撑,外部动作类主张(如“服务请求已提交”)必须由与之匹配的成功回执支撑:
观察性表达(用户刚提供了什么) ← 候选状态 / 已核验证据即可
内部状态类完成语(信息已保存) ← 成功的内部 Commit 支撑
外部动作类完成语(已提交 / 已通知) ← 匹配的 Observation = SUCCESS 支撑
这与第 9 章“模型可以建议行动,但不能创造结果”的原则一致,也和第 18 章的提交语义直接相接——快照里没有“已提交成功”这条事实,渲染就不能说“已经提交”。
给 facts 绑上 base_version,还有一个下游的好处:第 18 章做验证和提交时,能反查“这句话是基于哪一版状态渲染出来的”,从而判断渲染依据的状态有没有在中途被改过。
这样一来,“模型会编造”这个老问题,在渲染层就被大幅收窄了——它能自由发挥的只有措辞,不包括内容。在目标形态里,内容边界由这份快照划定,快照来自确定性控制层,而不是由渲染模型自行决定;配套参考实现中,这一快照由代码生成。
这也解释了为什么价格、库存、优惠这类敏感信息要特别小心:它们只有被明确放进 facts 快照时,才允许出现在回复里。 模型记忆里那个“这款一般八千多”,如果没进快照,就不该说出口。
七、三段之间:可以拒绝,但不能偷偷改写
把三段串起来,有一条贯穿的纪律。先说一个不准确的版本:“后一段不能推翻前一段的结论”——这句话太绝对了。下游其实必须有权拒绝:代码发现 evidence_quote 根本不是输入里的原文、发现动作违反了不变量、发现状态版本已经变了,当然要拦下来,不能因为“不许推翻前一段”就硬着往下走。
准确的纪律是:
后一段可以拒绝前一段,但不能悄悄改写前一段。
区别在于“显式”还是“静默”:
允许(显式) 拒绝、校验失败、退回、重试、标记为冲突——都会留下痕迹
禁止(静默) 不吭声地把前一段的结论换成另一个,然后继续执行
- 决策只能基于抽取产出、并经过核验的状态来判断;如果状态不合法,它显式拒绝或降级,而不是自己重新理解一遍用户的话,凑一个能往下走的解释;
- 渲染只能基于决策批准的动作来表达;如果动作和 facts 对不上,它应当报错退回,而不是自己改一个动作说出去。
这条纪律的意义,是让错误可以被定位。当输出出了问题,你可以顺着三段倒查:
话说错了? → 看渲染:facts 对不对,措辞有没有越界
动作选错了? → 看决策:状态对的话,是不是决策表或闸门错了
状态就是错的? → 看抽取和归并:是抽取抽错了,还是归并折叠错了
三段各管一段,错误就落在某一段里,而不是糊成一团。第 20 章的失败定位就建立在这种边界上——分段不只是为了正确,也是为了出错时查得清。
如果三件事挤在一次调用里,这种倒查根本做不了:一段读起来通顺、但事实错误的回复,你分不清是它理解错了、判断错了,还是单纯说错了。
八、拆开之后,代价与收益
把一个回合拆成三段,不是没有成本。
成本。 至少多一次模型调用(抽取和渲染分开),延迟和费用都会上升。中间的决策层还要额外实现和维护。对一个简单的单轮任务,这是过度设计——它可能一次调用就够了,第 5 章的停靠点 A 就是这种情况。
收益。 一旦系统需要跨轮状态、需要保证“说过的话算数”、需要在出错时查清是哪一环,拆分的收益就压过了成本:
- 判断不再被表达带偏;
- 状态和许可由确定性的中间层守着,渲染碰不到;
- 每一段可以单独测试——抽取喂一句话验证它的事件输出,渲染喂一个动作验证它的措辞,互不干扰;
- 出错能定位到段。
什么时候值得拆,判据还是第 5 章那条:有没有一个具体的、由跨轮正确性带来的痛点。有,就拆;只是单轮问答,就别拆。
但和第 16 章一样,这条判据要补一个高风险例外:
单轮不代表就不用拆。 一个任务哪怕只有一轮——“取消我的订单”“修改账户资料”“把这封邮件发出去”——只要它涉及权限、外部副作用、合规或强事实约束,就值得走“抽取 → 校验/决定 → 执行/表达”的分段,而不是一次模型调用全包。理由和第 9、15、16 章一致:不可逆的动作,做之前必须有一道独立于生成的确定性检查。
换句话说,拆分的触发条件有两个,满足其一即可:跨轮正确性的痛点,或者单次动作的高风险。
拆分解决了“一次调用内部的职责混乱”。最后留下三条边界:
抽取负责解释用户说了什么,控制层负责决定系统允许做什么,渲染只负责把已批准的决定说出来。
后一阶段可以拒绝前一阶段的输出,但不能静默重解释或改写它。
渲染的自由度应该存在于措辞里,而不是事实和行动权里。
但拆分也引出了一个新问题:三段串起来之后,最后那一步——把决定真正提交出去、把消息真正发出去——本身也可能出错。 渲染生成的那句话,在发出去之前,还要不要再检查一遍?如果这一轮同时要改状态、又要发消息、还要调外部接口,它们怎么保证要么都成、要么都不成?