文章摘要
企业AI应用中最严重的问题之一,是用户A看到用户B的对话内容,或租户A的上下文进入租户B的回答。根因通常不是模型“自己记住了别人”,而是会话ID重复、默认Memory、缓存键缺少租户字段、数据库查询遗漏条件、异步上下文丢失或向量长期记忆过滤不完整。本文提供从入口认证、conversationId、ChatMemoryRepository、Redis缓存、向量库到日志脱敏的完整排查路径。
一、典型表现
- 新用户第一次提问,模型却引用旧用户信息;
- 两个浏览器窗口互相影响;
- 同一账号不同项目对话混在一起;
- 多实例部署后偶发串话;
- 测试环境数据进入生产回答。
这类问题必须按安全事件处理,而不是普通回答质量问题。
二、第一风险:默认conversationId
错误:
conversationId = default或所有请求都没有显式传入会话ID。
结果:
所有用户 → 同一个Chat MemorySpring AI 2.0强制显式conversationId,就是为了减少这种风险。
三、第二风险:会话ID可预测但不校验所有权
即使使用不同会话ID,如果接口允许:
POST /conversations/C1002/messages却没有校验当前用户是否拥有C1002,攻击者可以枚举其他会话。
必须使用:
conversationId +tenantId +userId联合校验。
SQL:
SELECT*FROMai_conversationWHEREconversation_id=:conversationIdANDtenant_id=:tenantIdANDuser_id=:userIdANDstatus='ACTIVE';只按conversationId查询是不够的。
四、第三风险:Redis缓存键缺少租户
错误缓存Key:
chat-memory:{conversationId}如果不同租户可以生成相同业务会话ID,就会发生碰撞。
推荐:
chat-memory:{environment}:{tenantId}:{userId}:{conversationId}还要避免测试和生产共用Redis DB或前缀。
五、第四风险:数据库表缺少联合唯一约束
建议:
UNIQUE(tenant_id,user_id,conversation_id)消息表索引:
CREATEINDEXidx_chat_message_ownerONai_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。
显式传递:
publicrecordMemoryContext(StringtenantId,StringuserId,StringconversationId){}八、第七风险:本地缓存与多实例
实例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测试:
- 三方分别写入独特事实;
- 分别继续对话;
- 断言只能召回自己的事实;
- 重启实例;
- 切换负载均衡实例;
- 测试异步和流式;
- 测试非法conversationId;
- 测试向量长期记忆。
示例:
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只有入口认证、数据库、缓存、向量检索、异步线程和日志全部采用同一套隔离模型,才能真正避免多租户数据泄露。