即使固定模型配置、时钟、随机种子并将温度设为零,细微的输出差异仍会写入记忆并改变后续对话。文章指出,评测有状态智能体时应重新定义“相同”,不能照搬无状态系统的逐字复现标准。
当你测量的系统拥有记忆时,“可复现”究竟意味着什么。
这是一次脚本化对话的第 3 轮。问题是:“我哥哥叫什么名字?”
运行 A 回答:“他的名字叫 Nicolás。”运行 B 回答:“Nicolás。”
同一个事实、同一套配置、同一个固定时钟、同一个 seed、temperature 为零。两个本应完全相同的进程,从这一轮开始却不再相同,因为我的系统会把每次响应都存为记忆,而这段记忆又会进入下一轮。从第 3 轮开始,两段对话走上了不同的人生轨迹。
我原本是来验证一件自己认为理所当然的事:我的测量工具具有可复现性。但我验证出的却是另一件事:我所追求的那种可复现性在物理上不可能实现,而真正的工作将是定义什么叫“相同”。
我为一个对话式 Agent 构建记忆系统。它会记住你告诉它的内容,对记忆进行整合,并在每一轮检索相关上下文。系统运行在 Node 上,调用托管模型。
开发了几个月后,我想开始进行测量。当系统不再只是玩具,有些问题就无法回避。随着对话增长,每一轮的成本会增加多少?复杂的记忆排序机制,相比普通的余弦相似度,是否值得存在?这些都是实验问题,而实验需要一件可靠的测量工具。
于是,我为整个过程定下了第一条规则:先校准工具,再用它进行测量。这里的工具并不是抽象意义上的系统,而是测试框架——运行脚本化对话的模拟器、让两次运行具备可比性的固定时钟,以及记录系统在每一轮中具体行为的 trace。这些东西此前从未接受过测量。
在运行任何实验之前,我先审计了测试框架。
最核心的发现并非来自某种怀疑,而是来自对生产环境 trace 数据库的一次查询:208 个对话轮次中有 49 个,也就是 23.6%,携带着代码 fallback 的精确特征。
负责对用户意图进行分类的组件有一条 fallback 路径。如果模型调用失败,它就返回“这是闲聊,什么都不用做”。作为一种防御机制,我觉得这还算合理。不合理的是,它悄无声息:几乎每四轮对话中就有一轮,系统没有检索任何记忆、没有存储任何内容,也没有执行任何操作。原因并不是用户真的在闲聊,而是一次调用失败了。
“悄无声息”这个词非常准确。trace 记录的 confidence 是 0,而这恰好也是分类器正常运行但无法确定结果时会出现的值。没有任何信息能区分“分类器犹豫不决”和“分类器根本没有运行”。而我已经盯着这些 trace 看了几个月。
我只能通过统计规律发现问题:无论消息只有 4 个字符还是长达 22,000 个字符,无论是问候还是长篇反思,同样的三个字段组合都在不断重复。面对如此异质的输入,不可能有一个真实的分类器在四分之一的情况下都落在完全相同的位置。而在这 49 个轮次中,还有 22 个甚至连响应都没能完成:整个请求直接崩溃了,但在 trace 里看起来依然一切正常。
我对问题原因提出的第一个假设非常漂亮,但它错了。我拿真实 API 进行验证,结果这个假设自己推翻了自己。真正的机制完全不同,而且愚蠢得多:分类器接收了最近的上下文,却没有对其进行预算。系统确实有一道 guardrail,查询最多只会取最近五十条消息,但消息数量上限并不等于 token 上限。而且,这不是一个只有在对话变得足够长时才会跨过的阈值,而是一道始终存在的天花板:只要某段对话在 72 小时窗口内超过五十条消息,分类器就会收到数量恰好达到上限、却完全没有经过预算的上下文。遇到长消息时,这些内容就会撑爆模型的上下文窗口,API 返回 400,随后一个 catch block 把它变成了“用户只是在闲聊”。
这 49 个轮次中,究竟有多少是因为这个原因失败的,我不知道,而且永远也不会知道,因为 trace 没有记录触发 fallback 的原因。这个字段是修复的一部分,而不是诊断的一部分。
第二个发现出现在测试框架本身。模拟器中有八条与记忆整合有关的断言,它们是否执行取决于 trace 中的某个字段,但从来没有任何代码写入过这个字段。条件永远不会成立,因此八条断言全部被悄无声息地跳过,而模拟器仍以退出码 0 报告成功。我原本打算用来做测量的工具里,有八项检查从未真正检查过任何东西。
这次审计在二十七项发现中识别出了七个阻塞性缺陷。而我最不愿意接受的教训是:会撒谎的可观测性比没有可观测性更糟糕,因为除了造成原本的所有问题,它还会额外向你收取一份信心作为服务费。
一共七项修复,全都是外科手术式的调整,没有一项涉及重新设计架构。这份清单本身已经充分说明了问题的性质:统一时钟,让一个时间戳控制整个轮次;为每次 fallback 标注原因,使其不再与真实结果无法区分;按每次调用分别统计成本和延迟,而不是只记录最后一次调用;支持固定 sampling 参数;返回诚实的退出码。
接下来是那场赌局,也就是最终的退出关卡。用完全固定的条件运行同一段脚本化对话两次——temperature 为零、固定 seed、固定时钟、每次运行使用干净的数据库,同时调用真实模型和真实 embedding——然后验证两次生成的 trace 是否等价。
我原以为它们会完全相同。明知道这句话现在听起来有多荒唐,我还是把它写了下来。
第 3 轮,“他的名字叫 Nicolás。”对“Nicolás。”
模型提供商给出的 seed,按合约只能保证 best-effort。即使 temperature 为零,也不保证逐字节完全一致。我知道这一点,事实上,在我运行任何实验之前,这已经作为脚注写了下来。但我没有真正理解的是接下来会发生什么。
差异会以复利的方式不断累积:每一次差异都会成为下一次差异的本金。它的机制包含三个步骤——响应被持久化为记忆,这段记忆进入下一轮的上下文,随后两个进程不再接收相同的输入。从第 3 轮开始,运行 A 和运行 B 看到的就不再是同一个世界。这里的差异并不只体现在措辞上,而是体现在对话状态中。我所说的对话状态是一个非常具体的东西:每次运行正在积累的记忆集合。A 存储的是“他的名字叫 Nicolás。”,B 存储的是“Nicolás。”。这个差异未经任何中介,直接进入了下一轮,因为 recent-context 区块会原封不动地携带最近几次交互的存储内容。之后,它还会从另一扇门重新出现:一旦这段记忆离开最近上下文窗口,它们就会变成两段拥有不同 embedding 的文本,以不同的竞争力争取重新进入 prompt。从那一刻开始,两次运行不仅说出了不同的话,它们用来进行决策的材料也不再相同。
让我觉得有意思的是,系统竟然维持了那么久。意图分类器吸收了大约十轮的文本漂移,却没有改变行为,因为输入虽然已经不同,但差异还不足以越过任何决策边界。第一个硬差异直到第 13 轮才出现,而且只是分类器 confidence 的一次轻微震动:0.9 对 0.95,两次运行得出的 intent 仍然相同。真正的行为分歧直到第 22 轮才发生,当时一次运行决定检索记忆,另一次则没有。
从这里开始,差异产生了级联:不同的 intent 会分派不同的 action,不同的 action 会写入不同的记忆,而不同的记忆又会改变下一轮的上下文。运行结束时,我总共得到 34 个硬差异——所谓硬差异,是指那些本不应发生变化的字段出现了不同,例如被分类出的 intent 或实际执行的 action。这些差异分布在 12 个轮次中,其中有几个其实是同一个事件在不同字段中的表现:一次 routing 翻转,会同时表现为 intent、confidence、action 数量和已执行 action 的差异。
比较器给出的正式结论是 NOT EQUIVALENT,退出码为 1。
接下来这个数字对我最重要,尽管它并不是我原计划使用的指标。两次运行中输入完全相同、真正干净的轮次,在三十三轮中只有两轮:漂移开始得太早,几乎没有留下任何余量。“干净轮次中的硬差异为零”听起来很有力量,但样本只有两轮,证明不了多少东西。
真正能证明一些事情的是另一项测量。有 18 个轮次,两次运行组装出的上下文可以明确证明是不同的——prompt 区块长度和 router 的字符预算均存在差异,而且这些差异经过了实际测量——但所有绝不能变化的字段依然完全一致:intent、confidence、temporal scope、policy、action dispatch、model-call sequence。十八次真实的失败机会,十八次全部一致。正是这一结果让我们有资格声称:只要获得相同输入,确定性层就始终执行相同的操作;而在它不再保持一致的地方,是因为输入早已不同。真正无法支撑这一结论的,反倒是那两个没有任何失败机会的轮次中没有出现失败。

接下来是这套方法中对我最重要的部分,因为你很容易在这里作弊,而且不会被任何人发现。最简单的脱身办法,是凭感觉降低标准,然后说:“确实有 34 个差异,但它们全都源于第 3 轮的一次措辞差异,所以都是级联,因此只是噪声。”问题在于,如果没有提前写好的规则,这句话就无法区分真正的级联与一个被我出于方便称作级联的缺陷。我不认为这称得上判断标准,它只是一个披着标准外衣的直觉。
在查看任何一个数据点之前,我把所有 trace 字段预先分成了三个层级。
必须精确相等,用于确定性主干:分类出了哪个 intent、应用了哪种 temporal policy、分派了哪些 action、发生了怎样的 model call 序列。
允许存在差异,但必须将其归因于提前明确的两种噪声机制之一:模型的 sampling variance,以及 embedding jitter(同一个字符串进行两次 embedding,并不一定总会返回完全相同的向量,而这足以让两个几乎打成平手的候选项互换顺序)。
按设计排除,即那些永远不可能一致的内容:标识符、延迟、持续时间。
支配整个判断的规则是:未归因差异必须为零。使用全局容差——例如“只要 95% 的字段一致就算通过”——会让真正的缺陷藏在噪声预算背后。归因机制不会允许这种情况发生,因为它要求每个差异都必须归属于一种我已经理解的机制。
我还增加了一条级联规则。从文本漂移开始的轮次,也就是本例中的第 3 轮起,之后出现的任何硬差异都要被单独标记,因为此时已经存在一种合理解释:输入不再相等。这些差异依然会被计入,但会与输入确实完全相同的轮次中出现的差异区分开来。只有在输入真正相同的轮次中,出现的分歧才无法通过系统之外的因素进行解释。
接下来是最难写下来的部分。
这套分类规则经过了预注册,而且完全按设计运行。34 个硬差异全部出现在第 3 轮之后,因此级联规则适用于其中每一个;所有被容忍的差异,也都被归因于两种已命名的噪声机制之一;而且在看到数据后,没有任何结果迫使我发明新的类别——这才是检验预注册分类体系是否有效的真正标准。
我在看到结果后修改的是通过标准。原标准规定,只要出现任何硬差异,测试就算失败。按照这个标准,任何调用托管模型的多轮对话都不可能通过这道关卡。我写了一份带日期的补充说明,用一句话重新定义了标准:如果输入相同的轮次中没有出现硬差异,并且每个剩余差异都能归因于一种已命名的噪声机制,那么测试就算通过。
我很清楚,在看到数据后修改门槛,恰恰是会让一场实验失效的做法。因此,这份补充说明带有日期,并明确声明它是在运行结束后写下的,因为“哪些内容是在实验前注册的,哪些决定是在实验后做出的”,本身就是数据的一部分。而且我认为,这项修改的理由本身是成立的:如果一条标准没有任何系统能够满足,那么它就无法告诉你关于这个系统的任何信息。
有两件事是我从中得到的、而且认为可以迁移到其他场景的经验。
先校准工具,再进行实验:在相信 trace 之前,先用生产数据审计它们。
还有最让我意外的一点:差异会通过持久化状态以复利方式累积。在一个拥有记忆的系统中,两次“相同”的运行不只会在措辞上产生差异,还会在对话状态上产生差异,并由此进一步在行为上产生分歧。一个系统在暴露问题前维持了好几轮,并不意味着它具有鲁棒性,只意味着问题延迟显现。
我还要明确指出,以上内容没有声称什么。它并没有声称可以实现 text-to-text 的可复现性;事实上,它证明了这是不可能的。它也还没有验证任何实验,只是验证了未来用于测量这些实验的工具不会谎报自身行为。
工具校准完毕后,实验才真正开始:我们要理解哪些记忆功能值得保留,以及每一项功能的成本是多少。
如果你也在构建 Agent,并测量过类似的东西,或者完全不同的东西,甚至得出了相反的结论,我很想和你交流经验。尤其是,如果你找到了一种我可能遗漏的方法,可以约束这种不断累积的效应。
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。