多租户Chat Memory为什么会串话?会话ID、缓存与数据库隔离完整排查

文章摘要

企业AI应用中最严重的问题之一,是用户A看到用户B的对话内容,或租户A的上下文进入租户B的回答。根因通常不是模型“自己记住了别人”,而是会话ID重复、默认Memory、缓存键缺少租户字段、数据库查询遗漏条件、异步上下文丢失或向量长期记忆过滤不完整。本文提供从入口认证、conversationId、ChatMemoryRepository、Redis缓存、向量库到日志脱敏的完整排查路径。

一、典型表现

  • 新用户第一次提问,模型却引用旧用户信息;
  • 两个浏览器窗口互相影响;
  • 同一账号不同项目对话混在一起;
  • 多实例部署后偶发串话;
  • 测试环境数据进入生产回答。

这类问题必须按安全事件处理,而不是普通回答质量问题。

二、第一风险:默认conversationId

错误:

conversationId = default

或所有请求都没有显式传入会话ID。

结果:

所有用户
→ 同一个Chat Memory

Spring AI 2.0强制显式conversationId,就是为了减少这种风险。

三、第二风险:会话ID可预测但不校验所有权

即使使用不同会话ID,如果接口允许:

POST /conversations/C1002/messages

却没有校验当前用户是否拥有C1002,攻击者可以枚举其他会话。

必须使用:

conversationId
+tenantId
+userId

联合校验。

SQL:

SELECT *
FROM ai_conversation
WHERE conversation_id = :conversationId
  AND tenant_id = :tenantId
  AND user_id = :userId
  AND status = 'ACTIVE';

只按conversationId查询是不够的。

四、第三风险:Redis缓存键缺少租户

错误缓存Key:

chat-memory:{conversationId}

如果不同租户可以生成相同业务会话ID,就会发生碰撞。

推荐:

chat-memory:{environment}:{tenantId}:{userId}:{conversationId}

还要避免测试和生产共用Redis DB或前缀。

五、第四风险:数据库表缺少联合唯一约束

建议:

UNIQUE (
    tenant_id,
    user_id,
    conversation_id
)

消息表索引:

CREATE INDEX idx_chat_message_owner
ON ai_chat_message(
    tenant_id,
    user_id,
    conversation_id,
    created_at
);

不要依赖应用层“理论上不会重复”。

六、第五风险:Repository查询遗漏tenantId

自定义ChatMemoryRepository经常只实现:

findByConversationId(conversationId)

但conversationId不是全局唯一。

更安全的做法是让内部ID全局唯一,同时在业务入口完成所有权验证;或者自定义复合会话键:

tenantId:userId:conversationId

传给ChatMemory。

七、第六风险:ThreadLocal在异步线程丢失

主线程有租户上下文:

TenantContext = T001

异步任务中变成:

TenantContext = null

代码可能退回默认租户或无过滤查询。

不要让Memory隔离依赖隐式ThreadLocal。

显式传递:

public record MemoryContext(
        String tenantId,
        String userId,
        String conversationId
) {
}

八、第七风险:本地缓存与多实例

实例A缓存:

C1001 → 用户A消息

实例B也缓存:

C1001 → 用户B消息

如果会话ID不全局唯一,经过负载均衡后会出现随机串话。

生产环境要么:

  • 使用全局唯一会话ID;
  • 使用共享持久化Memory;
  • 缓存键包含完整归属;
  • 禁止无归属的本地长期缓存。

九、第八风险:向量长期记忆没有过滤

向量检索必须带:

tenant_id
user_id
memory_scope
status

不能先全局检索再在应用层过滤,因为Top K可能全部来自其他租户,过滤后没有正确结果。

应该在检索阶段强制过滤:

{
  "must": [
    {"key": "tenant_id", "match": {"value": "T001"}},
    {"key": "user_id", "match": {"value": "U1008"}},
    {"key": "status", "match": {"value": "ACTIVE"}}
  ]
}

十、共享记忆与私有记忆要区分

企业可能需要:

用户私有记忆
团队共享记忆
租户公共记忆
系统知识

定义作用域:

PRIVATE
TEAM
TENANT
GLOBAL

每种作用域有不同权限。

模型不能因为语义相似,就把团队或全局记忆当作用户明确偏好。

十一、Chat History与Memory双写不一致

可能发生:

Chat History写入用户A
Memory写入默认会话

最终页面看起来正常,但模型上下文串话。

每次写入都记录:

request_id
tenant_id
user_id
conversation_id
history_status
memory_status

出现不一致时应告警,而不是静默继续。

十二、日志本身也可能泄露

排查串话时,开发者常打印完整Prompt和Memory。

日志系统可能让更多人看到敏感对话。

建议记录:

  • 会话ID哈希;
  • 消息数量;
  • Token数;
  • Memory来源;
  • 租户脱敏ID;
  • 内容分类。

只有受控调试环境才允许查看原始内容。

十三、自动化隔离测试

至少包含:

租户A用户1
租户A用户2
租户B用户1

测试:

  1. 三方分别写入独特事实;
  2. 分别继续对话;
  3. 断言只能召回自己的事实;
  4. 重启实例;
  5. 切换负载均衡实例;
  6. 测试异步和流式;
  7. 测试非法conversationId;
  8. 测试向量长期记忆。

示例:

A1事实:项目代号青鸟
A2事实:项目代号白鲸
B1事实:项目代号赤狐

任何交叉出现都应使测试失败。

十四、安全处置流程

发现串话后:

1. 立即停止相关功能
2. 冻结日志和证据
3. 确认受影响租户与时间范围
4. 修复会话和缓存隔离
5. 清理污染Memory
6. 检查向量与摘要派生数据
7. 执行回归测试
8. 按安全制度通知相关方

不能只清空Redis然后恢复服务,因为数据库、向量和摘要可能仍受污染。

十五、快速排查清单

□ 没有默认conversationId
□ 会话ID全局唯一或使用复合键
□ 每次请求校验tenantId和userId
□ Redis Key包含环境和租户
□ 数据库有联合约束
□ 异步任务显式传上下文
□ 多实例使用共享持久化
□ 向量检索在查询阶段过滤
□ 共享和私有记忆有明确Scope
□ Chat History与Memory写入可追踪
□ 日志不打印完整敏感内容

总结

Chat Memory串话的本质通常是隔离键不完整:

conversationId
+tenantId
+userId
+environment
+memoryScope

只有入口认证、数据库、缓存、向量检索、异步线程和日志全部采用同一套隔离模型,才能真正避免多租户数据泄露。

延伸阅读

如果你正在关注企业级 AI 应用、Spring AI、RAG、Agent 与 MCP 工程化落地,欢迎访问 智元界

https://www.zyentor.com/

智元界将持续分享可运行的技术实战、架构设计、问题排查与企业应用案例。