跳到主要内容

对话树:你的服务器作为日志存储的对话结构

· 阅读需 12 分钟
Tian Pan
Software Engineer

用户输入“其实,我的意思是五十,而不是十五”,点击最后一条消息上的铅笔图标并进行编辑。UI 表现得非常出色:它向用户展示修改后的消息,淡出旧消息,将助手过时的回复变为带删除线的“幽灵”状态,并呈现一段流畅的对话,读起来就像最初的错误从未发生过一样。用户心满意足地发送了下一轮对话。而智能体却用“十五”进行了回答。

Bug 不在模型身上。模型准确地接收了服务器发送的内容,而服务器发送的是:原始消息、原始助手回复、撤回动作、修改后的消息以及新的请求——所有内容按顺序拼接在一起,实时发送。用户在进行一场已经编辑过的对话。而智能体在进行一场从未被编辑过的对话。两份对话记录在第三轮开始分叉,此后再未统一,之后的每一轮对话都在为这一差距支付“利息”。

UI 是树,存储是日志,而没人告诉模型

当团队构建聊天产品时,有两个设计决策是在不同的房间里完成的。前端团队将对话设计为用户导航的对象:一串气泡,每个都可编辑、可重试,每个都有一个小小的“重新生成”按钮,静默地分支出一个同级回复。这就是一棵树。每一次编辑都会创建一个分支。每一次重新生成都会创建一个同级节点。UI 将树中的一条路径投影为可见对话,并将剩余部分隐藏在交互入口之后,如果用户愿意,可以重新进入这些路径。

后端团队则基于不同的直觉,将对话建模为事件日志。每条用户消息都是一次追加(append)。每条助手回复也是一次追加。每一次编辑同样也是一次追加——被记录为 message.v2 并排在 message.v1 之后,而不是对 message.v1 的修改。对于审计、流式处理、数据分析和回放来说,这是正确的选择。但对于 Prompt 构建来说,这是错误的选择,而且直到模型开始回答用户根本不记得问过的问题时,才有人察觉到这一点。

这种差距是无声的。前端保持了用户心理模型的一致性。后端保证了数据仓库的真实性。中间的 Prompt 组装器——即那个将对话转换为 messages 数组的函数——按照时间顺序拼接日志,因为 Schema 就是这样提供数据的,而模型则尽职尽责地根据修改前后的时间线合成一个连贯的回复。大模型的流畅性使得这种失败难以察觉:不太流畅的模型会因为矛盾的上下文产生胡言乱语,Bug 在第一周就会暴露。而尖端模型生成的回复读起来很正确,只是回答了错误的问题。

从业者不断重新发现的失败模式

这种 Bug 有多种形态。第一种形态是未生效的编辑。用户将“十五”修改为“五十”,UI 接受了,但助手的下一轮回复仍引用“十五”,就像用户坚持要用这个词一样。用户再次编辑,语气愈发强硬。智能体再次引用,表现得愈发自信。双方都在根据各自看到的输入进行正确的推理。只是输入内容并不一致。

第二种形态是幽灵助手消息。用户在第三轮点击了“重新生成”,因为之前的回复不对。UI 替换了该回复。日志追加了一个同级节点。在第四轮中,模型的上下文包含原始助手回复、重新生成的回复以及用户第四轮的后续。由于后续内容与重新生成的回复一致,却与原始回复矛盾,模型会选择一个锚点进行回答,通常是较早的那个,因为它更接近用户的原始表述。对话现在引用了一个用户从未见过的回复。

第三种形态是未回滚的回滚。用户使用了 UI 的“返回到第二轮并从此继续”功能,重写了第三轮,并基于新分支又进行了四轮对话。服务器将其建模为追加,日志中仍然保留着旧的第三至第七轮。如果 Prompt 组装器遍历的是日志而非树,模型就会看到全部十二轮对话:用户在平行宇宙中的分身继续了那个被放弃的线程,而智能体将两个时间线视为一体进行推理。

第四种形态是隐形 Token 税。即便模型的回复保持连贯——可能是因为最新的用户消息足够清晰,足以覆盖之前冲突的轮次——但每一条保留下来的编辑前消息都在为后续的每一次调用支付 Token 租金,而且是永久性的,尽管用户认为他们已经删除了这些内容。在大规模运行时,这是一项可衡量的支出项,但由于没人意识到它的存在,也就没人将其归因于此。

为什么“只需遍历活跃分支”是一个知易行难的答案

一旦诊断明确,修复方案听起来就很显而易见:遍历树,而不是日志。将用户在屏幕上能看到的消息发送给模型,而不是数据库恰好拥有的消息。难点在于将“用户能看到的消息”转变为一个真实的、可查询的服务器端概念,而非前端的一种隐式计算。

这意味着服务器需要将对话树视为一等公民(first-class object)。每条消息都有一个父指针。一次编辑会产生原始用户消息的一个同级节点,并被标记为活跃同级节点。一次重新生成会产生原始助手回复的一个同级节点,并被标记为活跃。一次回滚将对话的“头节点”(head)重新指向树中较早的节点,将放弃的分支作为数据保留,但不作为实时上下文。然后,Prompt 组装器从根节点遍历到头节点,在每个分叉点遵循活跃同级节点指针,并准确地将该序列输出为 messages 数组。

加载中…
References:Let's stay in touch and Follow me for more thoughts and updates