凌晨两点,我把那一轮对话的日志从头翻到尾。没什么好翻的,十二步,每一步干的事都一样:发一个参数完全相同的检索请求,拿到一模一样的结果。结果里甚至带着一句提示,大意是——“这个问题你刚才已经问过了”。它看见了。然后接着查。全程没有人盯着它。最后统计出来了,170k input tokens,全部这么烧掉的。
这周期实习生的表现,画外音:它是真的在很努力地确认自己没搞错,努力到像考前把同一页书翻了两个小时的考生。你甚至没法骂它不认真。
我们最开始以为是上下文窗口的事。窗口是不是太挤,模型忘了自己干过什么?不是。日志一格一格翻过去,上下文里明明有之前的全部结果,包括那句提醒。它全都读了,然后继续查。
先交代一下这个agent的处境吧。引擎是私有协议,网上搜不到任何资料。也就是说,模型没有预训练记忆可以用——layout是什么,Table有什么成员,它一概不知道。每一个API名、每一个参数、每一个返回值,都必须实时查询。而它真正能拿到的知识来源只有两个:一个私有文档索引,一套检索工具。这儿没有“常识”可言,全部是现场取证。
这就像一个刚毕业的实习生,被扔进一个没有任何注释的遗留系统。区别是,这个实习生连“猜”的依据都没有。它不会因为见过类似的接口就猜个大概;它必须先查,查到为止。
所以它查。查到什么程度?有一次,五步里三步花在重新确认已经写进context的API名上。你可能觉得它傻。不对。频繁重查对它有明确的好处——检索结果可能更新,可能不同,重查一次,答案就有机会被纠正。反过来,“停下来”没有任何奖励信号。继续查,最坏结果只是多烧几个token;不查,它可能带着一个错误的API名交卷,然后被打回去。模型只是诚实地沿着梯度走,走成了这副德行。
问题在于,这条梯度是我们给的。
我的第一反应是上防御,疯狂上压制。以下是那段时间我们加的防线,按代码里出现的先后排的:
第一条,identical-call计数器。同一个参数组合,查一次以上就报警。第二条,per-turn查询预算,每轮查询次数超了就砍。第三条,一个不带embedding的字符串归一化,防止它换个措辞绕开前两条。第四条,leader/follower去重,处理同一轮并发线程里的重复查询。第五条,provided_symbols豁免——已经出现在上下文里的符号,不用再走检索grounding。
五层,够不够?
不够。而且第一刀就砍在自己人身上。预算这个限制,第一版把一个正常干活的模型给坑了。那是它头一回正经核对十一个符号的成员是否存在,动作全部合理,结果因为“每轮最多查十次”的死线,第十一个没查到,它开始瞎猜。我把日志翻出来看的时候都想笑——我们修的是“过度检索”,结果把“正确检索”一起修掉了。
后来预算改成按工具类型计费:便宜且确定性的查找免费,贵的语义检索才计入预算。这一刀切下去,节奏感立刻上来了。同样一个模型,同样一套检索工具,查询次数肉眼可见地降了。
然后是nudge的事。当时为了引导模型做深入验证,我们在工具结果里加了一句话:
look up the interface's REAL members
这句是写给模型看的,意思是“你去确认一下这个接口真正的成员”,不是让它把这句话拿去搜。模型看完,把这句话当成一个搜索query,原样执行了十二次。十二次。我不知道它是觉得这里面藏了什么玄机,还是单纯觉得“系统让我查我就查”。总之在那之后,所有nudge全部改成只描述动作和精确名称查找,再也不敢写任何像query的文字。
到这里,我们一直在做一件事:拦。不让它多查。但拦是个死胡同——你永远不知道它下一条新花样是什么。你堵住一个洞,它从另一个洞冒头。天天当消防员,好几天没干正事。回头翻这些记录,最让我不舒服的不是它的重复检索,而是我一开始竟然以为加几个限制就能治住它。
后来换了个思路:不拦它少查,让它第一次就查对。
两个工具。第一个叫query inflation,把一个用户问题改写成多个不同风格的检索query——“怎么做”“关键词”“举个例子”,避免单一问法成为漏召回的唯一原因。第二个叫resolve_topic,一个不含embedding的确定性短语表,用正则扫示例代码,把人类“相对目标”的表述,映射到引擎的“实际词汇表”上。
这套东西跑起来之后,平均检索轮次降下来一截。我们松了口气。当时感觉,终于把过度检索这件事摁住了。
然后检索管线自己给了我们一记耳光。
它开始产出幻觉API。layout.tableCellEnd、Table.cellSpacing,这俩成员根本不存在。但检索管线在生成答案的时候把它们编出来了。agent照单全收,写进了代码里。我们的grounding体系假设“来源于检索即可靠”,结果检索本身成了不可靠的来源。防幻觉的系统,自己就是个幻觉源。
这比我攒过任何一次翻车都憋屈。不是某个环节坏了,是“信任模型”整个塌了。我一直靠“至少检索的结果是真实的”在撑,现在连这个都没了。
那段日子我一直在想一件事:为什么每一次都这样?我们修一个洞,它从另一个洞溜进来。后来想明白了一个道理,其实很简单:这个模型会发明API,不是因为知识不够,是因为它没有“停止”与“拒绝”的通道。它不会说“我不确定这个成员存在”,它只会继续往下编,然后真心相信编出来的东西。
这里我交了好几次学费,才真正接受一件事:写后审查是没用的。让一个发明了某个API的模型审自己写的代码,等于让被告当自己的律师。它一定会维护自己的发明,而且振振有词。“Table.cellSpacing?我查过了啊,你看result。”它不撒谎,它是真的相信。所以唯一的办法,是在代码落地成文件之前拦下来。
这才是后面所有设计的出发点:写前拦截,不是写后审查。
我们做了个symbol checker,规则朴素到有点丢人。模型声称某个接口有某个成员,就去查一个由文档结构自动生成的成员索引。先解析接收者类型——比如layout对应Layout——再检查成员是否存在。这个索引不是手维护的,是从文档chunk标题自动生成的。手写列表一定会漂移,这个我在别处已经交过税了,不重复交。
symbol checker还有个兄弟,叫fake engine。拿文档索引造一个假引擎,每一轮对话结束的时候,真的把agent的代码执行一遍。注意,是执行。它的主要职责,是抓从动态类型参数和返回值里溜进来的虚构成员。静态检查管不到的,它管。你有一个函数接收一个unknown类型的参数然后调它.render(),渲染引擎到底有没有这个方法?跑一下就知道。
然后新的问题来了:我们拿这些发现去卡它,它不在乎。运行时报错不阻断最终输出,agent看见一个warning,回一句“哦,这个我知道,问题不大”,然后就交卷了。发现问题却没法掐住结果,发现等于没发现。
于是上了finish gate。工具循环在最后一轮可以拒绝agent交出的final answer,把未解决的finding以字符串形式喂回它的context,让它重新处理。拒稿信息没有任何情绪,就是一段反叛文本,但模型没法忽略它——因为它出现在context里,出现在和用户消息同一个位置上。
gate的上限是共享预算里预留下来的一段,专门用来防“无限打回”的死循环。
这条链路跑通之后,我们又陆续加了些边角料。每一个都不是预防性设计,全都是踩完坑才补的。
claim audit:抓agent报告里的过去时动词,跟实际文件改动逐一对照。它有一次在总结里写“更新了第四个文件”,实际上那个文件从头到尾没被碰过。不是故意的,是它觉得自己应该改过了。检测器只做一件事:断言和事实不一致,拒绝。
envelope marker:gate返回的消息用一个显眼的包裹标记前缀,标明“这是自动预发送检查的结果,不是用户”。否则模型会把被拦截的草稿当成用户反馈,试图“顺着用户的期望”继续改。
silence trailer:告诉模型“不要道歉、不要提你被检查过、永远不要删掉已经通过的代码,往前进,不要往后退”。这条我调了无数遍才算写稳——在此之前,模型被拒稿之后的第一反应永远是“那我删了重写”,哪怕原本的代码是对的。
最后一件事,值得单独说。
有一次我们交给这个agent一个删除任务,删掉一个文件里的废弃函数。任务跑完,好家伙,文件不但没变小,反而变大了。我查了一下日志,差点没坐稳——它用的是“生成式块重写”来做删除:把整个文件读进来,重新生成一版,“顺便”重构了几个相邻函数,“顺便”补了几个注释。它觉得这是额外加分项。
我们的修复方案是把它逼到一个完全没有自由发挥空间的角落:delete-only pass。模型只负责输出要删除的行号标记,由确定性代码执行实际删除。模型说“删哪几行”,代码负责真的删。分权制衡。
说实话,做完这套系统,我最想说的不是“我们成功了”或者“终于跑通了”这种话。最诚实的总结是:模型的能力全程没有发生任何变化,它从头到尾都能写出正确的代码——在正确的约束下。真正变化的是它能做什么而不被拒绝。
同样一个模型,没有上限、没有门槛、没有审查,它可以一遍一遍重复检索直到把170k tokens烧掉,可以把nudge当成prompt执行,可以凭空发明API然后坚定地为你辩护,