[Feature] MemorySaver 在缺失 threadId 时静默回退到 "$default",导致跨会话状态互相可见
Feature Request
在多租户 / 多用户服务里,只要调用方忘记传 threadId,MemorySaver 既不报错也不告警,而是把所有这类调用写进同一个常量槽位 "$default"。后果是:一个用户能读到另一个用户的会话状态。
建议(二选一或都做):
- 缺失
threadId时输出一条warn日志; - 在文档中明确说明该默认行为。
说明:我理解这可能是刻意设计(MemorySaver 只是内存版存储),因此不要求改成抛异常(那会破坏兼容性),只希望消除「静默」这一属性。
Is your feature request related to a problem? Please describe
完全离线、不需要 API Key、不依赖大模型的最小复现(JUnit 5):
MemorySaver saver = new MemorySaver();
// 两个"不同用户"的调用上下文:都没有传 threadId
RunnableConfig userA = RunnableConfig.builder().build();
RunnableConfig userB = RunnableConfig.builder().build();
// 用户 A 写入一份只属于他的存档
saver.put(userA, Checkpoint.builder()
.id("checkpoint-of-USER-A")
.state(Map.of("owner", "USER_A", "secret", "A 的私有对话内容"))
.nodeId("node_a")
.nextNodeId("__END__")
.build());
// 用户 B 读取自己的存档(他从未写过任何东西)
System.out.println(saver.get(userB).map(Checkpoint::getState).orElse(null));实际输出(spring-ai-alibaba 1.1.2.0 / graph-core):
userA.threadId = Optional.empty
userB.threadId = Optional.empty
saver.get(userB).isPresent() = true
saver.get(userB).getState() = {secret=A 的私有对话内容, owner=USER_A}即:用户 B 读到了用户 A 的私有状态。
Describe the solution you'd like
方案 A(最小改动、向后兼容,推荐):在 MemorySaver.loadOrInitCheckpoints 中,当 config.threadId() 为空时输出 warn,并把 "$default" 抽成具名常量:
String threadId = config.threadId().orElseGet(() -> {
logger.warn("No threadId provided in RunnableConfig; all such calls share the same "
+ "checkpoint bucket '{}'. Set threadId explicitly to isolate conversations.",
DEFAULT_THREAD_ID);
return DEFAULT_THREAD_ID;
});方案 B(文档):在 Memory / Checkpointer 相关文档中明确写:
未提供
threadId时,所有调用共享同一个"$default"槽位;生产环境必须显式传入。
建议 A + B 一起做:A 解决"静默误用",B 解决"认知盲区"。
Describe alternatives you've considered
| 备选方案 | 评价 |
|---|---|
抛异常(像 ReactAgent.updateAgentState() 那样) |
语义最严格,但是 breaking change:现有依赖"不传也能用"的代码会直接失败 → 不建议 |
| 每次调用生成随机 threadId | 能彻底消除共享,但会让"不传 threadId 也想延续记忆"的单用户 demo 场景失效 → 不合适 |
| 完全保持现状 | 成本最低,但无法阻止误用,且问题无任何日志线索 → 不足以解决 |
Additional context
源码依据(1.1.2.0)
① 存储字段名本身就表明按 thread 分档
public class MemorySaver implements BaseCheckpointSaver {
final Map<String, LinkedList<Checkpoint>> _checkpointsByThread;
}② 默认槽位来自反编译 MemorySaver.loadOrInitCheckpoints
8: invokevirtual RunnableConfig.threadId()Ljava/util/Optional;
11: ldc String "$default"
13: invokevirtual Optional.orElse
24: getfield MemorySaver._checkpointsByThread
33: invokeinterface Map.computeIfAbsent // ← 用 threadId 作为 key③ 同一框架内行为不一致:ReactAgent 的以下代码明确要求 threadId:
new IllegalArgumentException("threadId must be provided in RunnableConfig for interruption.")也就是说:HITL 路径强制要求 threadId,存档路径却静默兜底。
影响
- 多租户 / 多用户服务下造成状态越权可见(上下文泄漏);
- 排查困难:既无异常也无日志,问题表现为"AI 莫名其妙知道别人说过的话"。
已搜索,未发现重复
在 repo 内搜索 threadId / MemorySaver / "$default" 等相关 issue(35 条),未发现相同问题。
最接近的是 #4608(同一个 _checkpointsByThread Map 缺少清理机制)。
环境
- spring-ai-alibaba
1.1.2.0(spring-ai-alibaba-graph-core/spring-ai-alibaba-agent-framework) - JDK 21.0.10
补充
如果维护者认可方案 A,我可以提交对应的 PR。
Source: alibaba/spring-ai-alibaba