多租户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
测试:
- 三方分别写入独特事实;
- 分别继续对话;
- 断言只能召回自己的事实;
- 重启实例;
- 切换负载均衡实例;
- 测试异步和流式;
- 测试非法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
只有入口认证、数据库、缓存、向量检索、异步线程和日志全部采用同一套隔离模型,才能真正避免多租户数据泄露。
延伸阅读
如果你正在关注企业级 AI 应用、Spring AI、RAG、Agent 与 MCP 工程化落地,欢迎访问 智元界:
https://www.zyentor.com/
智元界将持续分享可运行的技术实战、架构设计、问题排查与企业应用案例。