别让模型自报状态:两次事故和一条设计原则
让模型在输出里顺带汇报自己进行到哪一步,是把流程的正确性押在它的自觉上。两次真实事故——一次是标记漏掉,一次是报错被吞成一个编造的分数写进了学生档案。
有一类设计,写出来的时候特别顺手:让模型在回答的末尾顺带汇报一下自己的状态。
比如让它讲完一段之后,在末尾附一个标记,说明「这一步过了」还是「学生没懂,我再讲一遍」。系统读这个标记,推进流程。一个模型调用同时干了两件事,省了一次调用,代码也短。
我们这么做过。两次,栽在不同的地方。
第一次:标记会漏,而且是在最需要它的地方漏
场景是从零开始讲一个知识点:讲一段、问一句,学生答上来就往下走,答不上就换个角度再讲一遍。推进与否,由模型在回复末尾自报。
实测下来,快的模型和强的模型都会在开放式的那几拍上漏掉或者放错这个标记。
注意「开放式的那几拍」——这不是随机分布的失败。标记漏得最多的地方,恰恰是这段对话最自由、最需要判断的地方。结构化的、模板性强的环节它记得很牢;一旦这一拍要求它展开讲、要求它照顾学生的情绪,它的注意力就被内容占满,末尾那个格式要求就掉了。
**换句话说:它在最不需要判断的时候最可靠,在最需要判断的时候最不可靠。**这跟你想要的正好相反。
改法是把这两件事拆开:讲课的模型只负责把话说好,推进不推进由一个独立的判定单独做——一次单标签分类,温度调到零,输出空间只有几个值。
单标签分类比「在一长段讲解的末尾记得加一个标记」稳得多。原因不难理解:前者是一个约束极强的小任务,后者是在一个自由生成任务上外挂一个格式要求,而生成越自由,外挂的东西越容易掉。
原来那套读标记的代码留着,但降级成兜底——万一模型自己带出来一个残留的标记,把它剥掉,别让它显示给学生。
第二次:报错被吞掉,变成一个编造的分数写进了学生档案
这次更贵,因为它安静。
有一处代码把模型名写死了。后来接入的服务换了家,这个写死的名字发过去,每一次调用都返回错误。
问题不在于报错——报错是好事。问题在于这个错误被兜底逻辑接住,转成了一个默认的低分,然后这个分数被当成学生的真实表现,写进了他的掌握度模型。
于是系统里出现了一批「这个学生在这里表现不好」的记录。它们看起来完全正常:有分数、有时间、有对应的知识点。它们全是假的,来源是一个网络错误。
而这些记录会影响后面所有的教学决策——给他安排什么、什么时候复习、判不判他会了。
修法有三层:
**第一层,去掉写死的模型名。**只在明确配置了的时候才覆盖,跨服务硬编码一个名字是这类事故的根源。
**第二层,也是更重要的一层:给结果加一个「这次到底算不算数」的标记。**建服务失败、模型调用失败,都标成弃权——分数可以显示给学生看(免得界面空着),但绝不推进掌握度模型。
**第三层:让下游认这个标记。**上游标了弃权,下游这一帧就不动学生模型。
这三层里,第二层是关键。它把一个隐式的假设变成了显式的字段:「这个结果是真的测出来的,还是兜底兜出来的」——这件事必须能被下游问到。
不加这个字段的话,兜底值和真实值在类型上完全一样,谁也分不出来。而兜底值恰恰是最不该被当真的那种数。
一条原则
把这两次拼起来,得到的是同一条:
模型可以生成内容,不能维护状态。
状态——进行到第几步、这次算不算数、要不要往下走——必须由确定性的代码持有和推进。让模型自报状态,等于把流程的正确性押在它的自觉上。而它的自觉在压力最大的那一拍最先失效。
这条原则有几个具体的推论,都是我们花代价换来的:
**推论一:一次调用只让它做一件事。**生成内容的调用不要顺带要一个格式化的判断。要判断就单独调一次,把输出空间压到最小。多花的那次调用,比排查「为什么偶尔漏一个标记」便宜得多。
**推论二:兜底值必须带标记。**任何一个「出错时用的默认值」,都要能被下游识别出它是默认值。否则它会以真实数据的身份,安静地流进你最不希望它去的地方。
**推论三:错误不能被吞成一个合法的值。**这是上一条的更强版本。报错转成默认值这个动作本身没问题,问题是转完之后信息就没了。转,但要留下痕迹。
为什么这类问题不会被测试抓到
值得单独说一句:这两次事故,单元测试都是绿的。
第一次是因为测试用的是固定的模型回复,那份回复里当然有标记——真实模型在开放式对话里才会漏。第二次是因为测试环境里配置是对的,写死的那个名字恰好能用。
它们的共同点是:失败发生在系统的边界上,而测试通常把边界给桩掉了。
所以这类问题只能靠两件事发现:一是真的跑一遍完整链路(第一次事故是三轮真人全流程跑出来的),二是在设计上让这类失败不可能安静——弃权标记就是干这个的,它把一次静默的污染变成了一个下游必须处理的显式状态。
