[Feature] MemorySaver 在缺失 threadId 时静默回退到 "$default",导致跨会话状态互相可见

Author: z1258Created Sep 16, 2026Updated Sep 16, 2026
Labelsneeds-triage

Feature Request

在多租户 / 多用户服务里,只要调用方忘记传 threadIdMemorySaver 既不报错也不告警,而是把所有这类调用写进同一个常量槽位 "$default"。后果是:一个用户能读到另一个用户的会话状态

建议(二选一或都做):

  1. 缺失 threadId 时输出一条 warn 日志;
  2. 在文档中明确说明该默认行为。

说明:我理解这可能是刻意设计(MemorySaver 只是内存版存储),因此不要求改成抛异常(那会破坏兼容性),只希望消除「静默」这一属性。

Is your feature request related to a problem? Please describe

完全离线、不需要 API Key、不依赖大模型的最小复现(JUnit 5):

java
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" 抽成具名常量:

java
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 分档

java
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:

java
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.0spring-ai-alibaba-graph-core / spring-ai-alibaba-agent-framework
  • JDK 21.0.10

补充

如果维护者认可方案 A,我可以提交对应的 PR

Source: alibaba/spring-ai-alibaba