跳到主要内容

在网关层交换了两个用户上下文的 conversation_id 冲突

· 阅读需 10 分钟
Tian Pan
Software Engineer

收到一张读起来像幻觉一样的客户支持工单。用户附上了一张截图:一个他们从未问过的问题,顶部显示着他们的账户名,接着是引用了他们从未上传过的文件的模型回复。追踪记录看起来很干净。模型完全按照要求执行了任务。问题在于,这个提问完全来自另一个租户,而你的网关由于 conversation_id 值发生了碰撞,将两个对话路由到了同一个后端状态。

你在餐巾纸上算了算。UUID v4 有 122 位的熵。在 5000 万个对话的语料库中,发生任何碰撞的生日边界概率远低于五千万分之一。一年前设计系统时你跑过这个计算。数学是正确的。数学现在依然正确。改变的是你的两个后端层不再以相同的方式生成 ID,而数学所描述的概率从来就不是你实际运行中的概率。

这是 ID 方案隐藏得最深的一种失效模式:每个层级单独来看都是正确的,但全局系统是错误的,而且这种差距在某个用户看到另一个用户的数据之前是不可见的。修复方法不是换一个更好的生成器,而是换一种思维方式来思考 ID 契约应该存在于何处。

你计算的概率是针对你以前拥有的系统

生日边界假设有一个从单一分布中提取的单一生成器。一旦你有了两个后端层——每一层都有自己的生成器、自己的随机源、自己版本的 UUID 库——你就有了两个分布,而联合碰撞域不再由你针对单一层运行的数学所描述。

生产环境中出现的偏移模式几乎总是枯燥乏味的。一个层将其 UUID 库从调用 getrandom(2) 的版本升级到了在负载下系统调用返回 EAGAIN 时延迟回退到用户空间 PRNG 的版本。另一个层运行在容器中,其基础镜像在启动时为 CSPRNG 植入了一次种子,并由于熵池比应用程序启动更晚才变热,导致每一个 fork 的 worker 都继承了这个种子。第三个层通过缓存一个请求范围的 UUID 生成器来“优化”性能,结果发现该生成器在应该从 122 位空间提取时,却从 32 位空间提取。

这些更改中的每一个都在本地经过了评审,通过了各自的测试,并在所属的层上平稳上线。但没有一个针对联合分布进行了评审。当你发现五千万分之一变成了五万分之一的那天,就是网关将两个活跃对话路由到同一个后端记录的那天。

教训不是“使用更好的 RNG”。而是 ID 方案的碰撞特性是所有在该命名空间中生成 ID 的生成器组合而成的涌现特性,而这种组合并不是任何单一层可以审计的属性。

网关是观察这种组合的地方

你的网关是路径中唯一一个在 ID 通往后端状态的途中能看到来自每一层的组件。它通常也是对这些 ID 进行验证最少的组件。默认模式是对 conversation_id 进行哈希以选择后端 Pod,路由请求,并让后端根据其存储解析 ID。如果两个层在不同的后端记录中生成了相同的 ID,网关会将请求路由到哈希选中的那个 Pod 上的记录。另一个记录就变得不可见了。对话被服务给了一个不该看到的其他用户。

将网关视为命名空间契约的执行点。解析为两个不同后端记录的 conversation_id 是一个路由错误,而不是后端错误,因为网关才拥有全局视图。捕捉到这一点的验证并不复杂。在每个请求中,检查该 ID 是否存在于多个后端存储中;如果是,则拒绝路由并触发告警。当碰撞罕见时(这是你预期的运行状态),这种检查成本很低;当碰撞变得频繁时(这是你需要捕捉的状态),警报会非常响亮。

这种做法会给每个请求增加一跳的反对意见是客观存在的,答案是你可以进行采样。在稳定的流量形态下,1% 的采样可以在一分钟内发现五万分之一的碰撞率,这正是你将“用户正在读取其他用户的数据”从耗时数小时的支持升级转化为 P 级事故所需的响应速度。

按租户划分的前缀可以改变爆炸半径

即使你的生成器是完美的,碰撞的爆炸半径也是由命名空间结构决定的,而不是由任何单个 ID 的熵决定的。扁平的 conversation_id 命名空间意味着任何碰撞都有可能是跨租户的;而 tenant_id:conversation_id 命名空间则意味着碰撞只能发生在租户内部,这会将安全事件转化为(虽然仍然糟糕,但可控的)一致性错误。

架构上的转变是将租户隔离视为 ID 结构的属性,而不是消费 ID 的应用程序逻辑的属性。如果你的 ID 在前缀中带有租户信息,那么“这个 ID 是否可能属于另一个租户”的问题在命名空间级别就有了唯一答案,处理它们的应用程序代码在编写时就无需时刻担心租户检查是一种自律而非保证。每一个处理 ID 的地方都可能遗漏租户检查;将租户作为 ID 的结构属性可以消除大部分此类风险。

这种做法会将租户标识符泄露到 URL 和日志中的反对意见也是客观存在的,答案是对于 Agent 产品来说,租户边界已经是每项操作中最重要的事实。如果你无法从请求中辨别它属于哪个租户,你就无法审计、无法限流、无法计费,也无法调查跨租户事件。无论如何,租户信息都会出现在你的日志中。将其放入 ID 是确保其在 ID 中保持一致性的成本最低的方法。

单一分配机构使组合变得合理

深层次修复多层漂移问题的方法是停止让每个层级在共享命名空间中生成 ID。运行一个拥有该命名空间并暴露生成 API 的单一 ID 分配服务。各层级调用它。各层级不再拥有 UUID 库。它们拥有一个 HTTP 客户端,向分配器请求给定租户的下一个 ID,获取字符串并使用它。分配器的 RNG(随机数生成器)是系统中唯一的 RNG。分配器的库版本是系统中唯一的库版本。分配器的审计是你唯一需要的审计。

这种做法的成本是在创建对话时增加了一次网络跳转,而这几乎从不在热路径上。其收益在于,你的冲突特性现在是一个易于理解的单一组件的属性,而不是每个层级局部选择的涌现属性。当你将分配器的生成器从 UUID v4 升级到 UUID v7 时,你只需通过一次评审,就能在所有地方同时完成升级。当你发现某个生成器表现不佳时,你只有一个地方需要调查,也只有一个地方需要修复。

这与你的支付系统已经使用的模式相同。支付团队中没有人会在结账服务内部生成交易 ID。存在一个交易分配机构,它有自己的冗余和审计,每个需要交易 ID 的服务都会向它请求。支付系统之所以这样工作,是因为两个各自独立的正确生成器所产生的跨交易故障模式是不可接受的,而且在罕见的创建路径上增加一次网络跳转的成本,与审计漏洞的成本相比微不足道。智能体产品(Agentic products)具有相同的跨对话故障模式,但尚未采用同样的纪律。

对话 ID 除了名称之外,本质上就是支付 ID

将这一切联系起来的论据很简短。在智能体产品中,conversation_id 是用户与模型关系中起承重作用的主键。它索引用户的记忆。它索引用户的计费。它索引审计追踪。它是跨租户隔离所依赖的关键。系统中每一个有意义的操作要么将 conversation_id 作为输入,要么将其作为输出。围绕它的纪律至少应该等同于围绕交易 ID 的纪律。

大多数团队存在的错位在于,对话 ID 的标识符方案是在早期,由一名工程师在第一个冲刺阶段决定的,当时只有一个后端,ID 如何跨层级组合的问题还不是一个问题。三年后,有了四个层级,最初的工程师已经去了不同的团队,而当初设计的 ID 方案对于现在的产品来说已经是承重结构了。由于没有人安排补课,严谨性从未跟上。

理想状态的具体表现:

  • 单一分配器拥有对话命名空间,并且是生产环境中唯一能向其中生成 ID 的组件。
  • 每个 ID 都带有租户前缀,即使随机后缀发生冲突,也能限制跨租户的波及范围。
  • 网关对传入的 ID 进行采样或完整验证,确保其仅对应一条后端记录,并在出现重复时发出报警。
  • ID 方案的安全性评审频率与支付方案一致,明确询问“如果任何单一组件发生漂移,跨租户的故障模式是什么”。
  • 拥有分配器的团队有一套针对“生成器似乎退化”的运行手册,并且该手册已经过演练。

这些都不罕见。其中三条是完全从支付工程中借鉴过来的。它们没有在智能体技术栈中传播的原因是,“conversation_id”听起来像是聊天记录的一个细节,而不是一个主键,而那些将其视为细节的团队,正是那些最终会编写事故报告的团队。

纪律转移就是核心工作

conversation_id 视为它实际所在的主键。停止让每个层级在共享命名空间中生成。让网关验证组合情况。将租户放入 ID 中,使隔离成为一种结构性属性,而不是一种依赖开发者纪律的属性。执行支付团队已经执行了三十年的安全评审。

关于 UUID 冲突的数学计算是正确的。但它对于一个你并未运行的系统是正确的。你所运行的系统是多个生成器的组合,其冲突特性是该组合的属性,而这个组合才是你应该审计的对象。当你像支付团队对待交易那样严谨地对待对话命名空间之日,就是这类事故从你的周报“调查中”栏目消失之时。

References:Let's stay in touch and Follow me for more thoughts and updates