我把带对话记忆的 Agent 做成了单例,上线后用户 A 问的问题,Agent 拿着用户 B 的对话历史在回答,记忆全串了:一次 Agent 会话状态没隔离的深度复盘

我做了个对话式 AI Agent,它有记忆能记住对话历史 conversationHistory,为了方便省资源把它做成了单例。上线后多个用户并发使用时,用户 A 问的问题 Agent 却拿着用户 B 的对话历史在回答、答非所问,更严重的是 A 的对话内容出现在了 B 的回复里(信息泄漏)。查清才明白:所有用户共用了同一个 Agent 实例的记忆——我把会话级的对话历史放成了单例的共享可变实例字段,所有用户的对话都往这同一份里读写、互相覆盖串扰。这篇复盘从故障现场讲到会话级状态 vs 应用级状态、为什么单例不能持有会话状态,再到 Agent 设计成无状态+会话记忆按 sessionId 隔离存外部、每会话独立上下文对象、显式传递会话标识的完整正解,以及状态要分清作用范围让存放位置匹配其逻辑归属、会话不隔离不只是功能 bug 更是隐私事故、多用户隔离是不可妥协的安全红线绝不为共享便利混同私有数据的认知。

我把带对话记忆的 Agent 做成了单例,上线后用户 A 问的问题,Agent 拿着用户 B 的对话历史在回答,记忆全串了:一次 Agent 会话状态没隔离的深度复盘

那个串台是用户投诉"Agent 答非所问、还说出了别人的信息"才暴露的:我做了个对话式 AI Agent,它有"记忆"——能记住对话历史(conversationHistory),这样多轮对话才连贯。为了"方便、省资源",我把这个 Agent 做成了单例(全局一个实例)。上线后,在多个用户并发使用时,诡异的事发生了:用户 A 问了个问题,Agent 却拿着用户 B 刚才的对话历史在回答,答非所问;更严重的是,A 的对话内容,出现在了 B 的回复里(信息泄漏)。我查清这个 Agent 的状态管理,才看明白,后背发凉:问题出在所有用户共用了同一个 Agent 实例的"记忆"我把 Agent 做成了单例,而这个单例实例里有一个共享的、可变的 conversationHistory 字段存对话历史;于是所有用户的对话,都往这同一个 conversationHistory 里塞、又从它里面读;用户 A 的请求把 A 的对话写进去,紧接着用户 B 的请求又把 B 的写进去、还读到了 A 的;多个用户的会话状态,在这一个共享的记忆里互相覆盖、互相串扰;结果就是答非所问、上下文错乱、甚至一个用户的信息泄漏给另一个。根本原因是:把"本应每个会话独立"的状态(对话记忆),放进了一个"被所有会话共享"的单例里;会话状态没有隔离,导致多用户的状态互相串扰。问题的根,是 Agent 单例里有共享可变的对话记忆,多个用户/会话共用一份、互相覆盖串扰,导致答非所问和信息泄漏。这篇就把这次"Agent 会话状态没隔离"的坑,从头到尾复盘一遍。

故障现场:单例 Agent 的记忆被所有用户共享

问题在于 Agent 单例里有共享可变的会话状态,被所有用户共用:

// ✗ 出问题的代码: Agent单例, 里面有共享可变的对话记忆
@Component  // 单例! 全局一个实例
public class ChatAgent {
    // ✗ 共享可变状态: 所有用户的对话都往这一个list里塞
    private List conversationHistory = new ArrayList<>();

    public String chat(String userInput) {
        conversationHistory.add(new Message("user", userInput));   // ✗ 写进共享的history
        String reply = llm.complete(conversationHistory);           // ✗ 读的是共享的(混了别人的!)
        conversationHistory.add(new Message("assistant", reply));
        return reply;
    }
}

// 现象(多用户并发):
// - 用户A调chat → 把A的对话写进conversationHistory;
// - 用户B调chat → 又把B的写进【同一个】conversationHistory, 且读到了里面A的内容;
// - → A、B的对话在同一份记忆里互相覆盖、串扰;
// - → Agent拿着混杂/别人的历史回答, 答非所问; A的内容泄漏给B。

// 为什么? Agent单例 + 实例字段存会话状态 = 所有会话共享同一份状态:
// 1. 单例: 全局只有一个ChatAgent实例, 所有请求都用它;
// 2. conversationHistory是实例字段 → 整个应用就这一份, 被所有用户/线程共享;
// 3. 多用户并发: 大家都读写这同一份history → 状态串扰 + 数据竞争;
// 4. → "对话记忆"本该【每个会话/用户各一份】, 却被做成了【全局共享一份】。

// 根本: 把"会话级"的状态(每个对话独立的记忆)放在了"应用级"的共享单例里。

// 关键: Agent的对话记忆是"会话级状态"(每个用户/会话独立), 若放进被所有请求共享的单例实例字段,
//       多用户的会话状态会互相覆盖串扰、信息泄漏 —— 会话状态必须隔离, 每个会话各一份。

第一次想明白"原来所有用户都在往同一个 conversationHistory 里读写"时,我又懊恼又后怕:"我把 Agent 做成单例图省事,把对话历史当成了它的一个字段;完全没想到这等于让全世界的用户共用一份记忆、互相串台,还泄漏了别人的信息。"这个坑最危险的地方在于:单用户测试时完全正常(就一个会话、就一份记忆,没人和它抢),只有多用户并发时才暴露;而且它不只是功能错乱,更涉及信息泄漏(一个用户看到另一个用户的对话)——这是严重的隐私/安全事故;并发下还伴随数据竞争(共享可变 list)下面就来拆解,Agent 的会话状态该怎么隔离。

第一件事:搞懂会话级状态 vs 应用级状态

我顺着这次事故,把 Agent(及一切服务)的状态管理彻底理清了。

Agent/服务的状态: 会话级 vs 应用级, 该放哪?

【核心: 对话记忆是"会话级状态"(每会话独立), 不能放进被所有请求共享的单例实例字段; 要按会话隔离(per-session)或Agent无状态+外部存会话】

1. 区分两种状态:
   - 应用级状态(全局共享): 配置、连接池、模型客户端、缓存等 → 所有请求共用一份, 适合放单例;
   - 会话级状态(每会话独立): 某个用户某次对话的记忆/上下文/中间结果 → 每个会话各一份, 绝不能共享!

2. 本文的错: 把会话级状态放进了应用级共享的单例
   - Agent单例(应用级) + conversationHistory实例字段(会话级状态) → 所有会话共用一份记忆;
   - → 多会话串扰、覆盖、信息泄漏、数据竞争。

3. 为什么单例不能持有会话状态:
   - 单例全局唯一、被所有请求/线程共享; 它的实例字段就是"全局共享的"一份;
   - 而对话记忆是"属于某个会话的", 把它放成单例字段 = 让所有会话共享 = 串扰。

4. 正确做法: 会话状态按会话隔离
   - 方案A: Agent设计成【无状态(stateless)】, 会话记忆放外部(按sessionId存Redis/DB), 每次请求带上sessionId取自己的;
   - 方案B: 每个会话一个独立的Agent实例/上下文对象(per-session/per-request), 状态随会话, 用完销毁;
   - 方案C: 把会话状态作为参数/上下文显式传递, 而非存在共享实例字段里;
   - 核心: 让每个会话只能访问"自己的"状态, 互相隔离。

5. 还要注意并发安全:
   - 即使隔离了, 同一会话若有并发(同一用户多请求)也要注意; 共享的应用级状态(如缓存)要线程安全;
   - 单例里【绝不放可变的、请求相关的】状态(那是并发bug和串扰的根源)。

6. 这其实是Web开发的老问题, 在Agent里重现:
   - "别在单例/静态里存请求/会话相关的可变状态"是Web服务的基本纪律;
   - Agent有了"记忆", 更容易不小心把会话记忆做成共享状态 → 要格外警惕。

一句话: 对话记忆是会话级状态(每会话独立), 放进被所有请求共享的单例实例字段会导致多用户串扰、信息泄漏;
   要让会话状态按会话隔离(无状态Agent+外部按sessionId存, 或每会话独立实例/显式传递); 单例别放请求相关的可变状态。

这套认知,是整个坑的根。区分两种状态:应用级(全局共享:配置/连接池/模型客户端,适合单例)、会话级(每会话独立:某用户某次对话的记忆/上下文,绝不能共享)本文的错:把会话级状态(conversationHistory)放进了应用级共享的单例,所有会话共用一份、串扰泄漏。为什么单例不能持有会话状态:单例全局唯一被所有请求共享、它的实例字段就是全局共享的一份,而对话记忆属于某个会话。正确做法:A 无状态 Agent+会话记忆按 sessionId 存外部(Redis/DB)、B 每会话独立实例/上下文、C 会话状态作为参数显式传递;核心是每个会话只能访问自己的状态、互相隔离并发安全:单例里绝不放可变的、请求相关的状态。这是 Web 开发老问题在 Agent 里重现(Agent 有了记忆更易把会话状态做成共享)。一句话:对话记忆是会话级状态(每会话独立),放进被所有请求共享的单例实例字段会导致多用户串扰、信息泄漏;要让会话状态按会话隔离(无状态 Agent+外部按 sessionId 存,或每会话独立实例/显式传递);单例别放请求相关的可变状态。

第二件事:正解——无状态 Agent + 按 sessionId 存会话记忆

搞懂了原理,正解就清晰了:把 Agent 设计成无状态的,会话记忆按 sessionId 存在外部(Redis/DB),每次请求带上 sessionId 取出自己的记忆;或每个会话用独立的上下文对象,让会话状态互相隔离

// ====== 正解一: 无状态Agent + 按sessionId存会话记忆(推荐) ======
@Component  // Agent单例没问题, 只要它【无状态】(不持有任何会话相关的可变字段)
public class ChatAgent {
    private final MemoryStore memoryStore;   // 外部存储(Redis等), 按sessionId隔离

    public String chat(String sessionId, String userInput) {
        // ★ 从外部取【这个会话自己的】历史(按sessionId隔离, 各是各的)
        List history = memoryStore.getHistory(sessionId);
        history.add(new Message("user", userInput));
        String reply = llm.complete(history);
        history.add(new Message("assistant", reply));
        memoryStore.saveHistory(sessionId, history);   // 存回这个会话的
        return reply;
    }
}
// → Agent本身无状态, 对话记忆按sessionId隔离存在外部; 用户A、B各取各的history, 互不串扰; 也天然支持多实例扩展。
// ====== 正解二: 每个会话一个独立的上下文对象(把状态显式传递) ======
public class Conversation {        // 每个会话一个实例, 持有自己的记忆
    private final List history = new ArrayList<>();
    // ... 这个会话独立的状态 ...
}
// Agent的方法接收会话上下文作为参数, 而非用共享实例字段:
public String chat(Conversation conv, String userInput) { /* 操作conv.history */ }
// → 每个会话的状态在自己的Conversation对象里, 不共享。

// ====== 要点 ======
// 1. Agent单例本身要【无状态】: 只放应用级共享、且线程安全的东西(模型客户端、配置、外部存储引用);
//    绝不放"会话相关的可变状态"(对话历史、用户上下文、中间结果)作为实例字段;
// 2. 会话记忆按sessionId隔离: 存在外部(Redis/DB)或每会话独立对象, 每个会话只访问自己的;
// 3. 显式传递会话标识/上下文: chat(sessionId/conv, input), 让"这是哪个会话"明确;
// 4. 注意会话记忆的清理/过期/上限(同517篇): 别让它无限增长;
// 5. 多实例部署: 无状态Agent+外部存会话, 天然支持水平扩展(任意实例都能处理任意会话);
// 6. 隐私/安全: 会话隔离也是隐私底线——绝不能让一个用户看到另一个的对话(信息泄漏是严重事故)。

// 核心: Agent单例必须无状态(不持有会话相关可变字段), 会话记忆按sessionId隔离存外部或每会话独立对象、
//   显式传递会话标识; 让每个会话只访问自己的状态, 杜绝多用户串扰和信息泄漏。

修复的核心,是"Agent 无状态、会话记忆按 sessionId 隔离"正解一:无状态 Agent+按 sessionId 存会话记忆(推荐)——Agent 单例本身无状态,对话历史按 sessionId 存外部(Redis),每个请求取自己会话的 history、互不串扰,还天然支持多实例扩展正解二:每个会话独立的上下文对象(状态在各自的 Conversation 对象里,作为参数显式传递)。要点:Agent 单例无状态(只放应用级共享且线程安全的)、会话记忆按 sessionId 隔离、显式传递会话标识、注意记忆清理/上限、无状态支持水平扩展、会话隔离也是隐私底线归根结底:Agent 单例必须无状态(不持有会话相关可变字段),会话记忆按 sessionId 隔离存外部或每会话独立对象、显式传递会话标识;让每个会话只访问自己的状态,杜绝多用户串扰和信息泄漏。

第三件事:Agent/服务状态管理的其他常见坑

排查后我把 Agent、服务状态管理相关的其他坑也系统梳理了一遍。

Agent/服务状态管理的其他常见坑

# 1. 会话状态放共享单例(本文): 多用户串扰、信息泄漏。→ 无状态+按sessionId隔离。

# 2. 单例里放可变请求状态: 并发数据竞争+串扰。→ 单例只放不可变/线程安全的应用级状态。

# 3. 用实例字段在多次请求间传数据: Web/Agent里实例可能被复用, 状态串。→ 用参数/会话存储传。

# 4. 会话记忆无限增长(同517篇): 不清理, 越来越长超context/占内存。→ 裁剪/摘要/设上限。

# 5. 会话状态只存内存不持久化: 实例重启/多实例下会话丢失/找不到。→ 存外部(Redis/DB)。

# 6. 静态可变变量存请求数据: 全局共享, 同问题。→ 别用static存请求相关可变状态。

# 7. 多Agent协作的状态混乱: 多个子Agent共享/传递状态不清晰。→ 明确每个Agent的状态边界和传递。

# 8. 没有会话隔离的隐私风险: 用户看到别人的数据。→ 严格按用户/会话隔离, 当隐私底线。

# 共同根源: 服务(尤其单例/无状态扩展的服务)处理"多个并发的、各自独立的请求/会话"时, 必须严格区分
#   "哪些状态是全局共享的、哪些是每个请求/会话独立的"; 把"会话独立的状态"错放进"全局共享的地方",
#   就会导致并发串扰、数据竞争、信息泄漏——这是并发服务最经典也最严重的一类错误。

# 核心: 严格区分应用级(全局共享)和会话/请求级(各自独立)的状态; 单例/静态只放共享且线程安全的,
#   会话状态按会话隔离(外部存储/独立对象/参数传递); 永远别让一个会话的状态泄漏/串扰到另一个。

排查让我把 Agent/服务状态管理的其他坑也梳理清了。一、会话状态放共享单例(本文)。二、单例里放可变请求状态三、用实例字段在多次请求间传数据四、会话记忆无限增长五、会话状态只存内存不持久化六、静态可变变量存请求数据七、多 Agent 协作状态混乱八、没有会话隔离的隐私风险它们的共同根源是:服务(尤其单例/无状态扩展的服务)处理"多个并发的、各自独立的请求/会话"时,必须严格区分"哪些状态是全局共享的、哪些是每个请求/会话独立的";把"会话独立的状态"错放进"全局共享的地方",就会导致并发串扰、数据竞争、信息泄漏——这是并发服务最经典也最严重的一类错误核心是:严格区分应用级(全局共享)和会话/请求级(各自独立)的状态;单例/静态只放共享且线程安全的,会话状态按会话隔离(外部存储/独立对象/参数传递);永远别让一个会话的状态泄漏/串扰到另一个下面这张图,是这次会话状态没隔离坑的成因与解法:

第四件事:应用级状态 vs 会话级状态对比表

这次踩坑后,我把"应用级状态"和"会话级状态"对比成一张表。

维度 应用级状态(全局共享) 会话级状态(每会话独立)
例子 配置、连接池、模型客户端、缓存 对话记忆、用户上下文、中间结果
共享范围 所有请求共用一份 每个会话各一份
放哪 单例/静态(要线程安全) 外部按 sessionId 存/独立对象/参数
放错后果 (放对即可) 放进共享单例 → 串扰/泄漏
判断标准 "大家共用的" "属于某个会话/用户的"

这张表把两种状态钉清了。核心是:区分状态最关键的一问是"它是'属于所有人的(共享)'还是'属于某一个会话/用户的(独立)'"——属于所有人的(配置/连接池)放全局共享没问题;属于某一个的(对话记忆)就绝不能放进全局共享的地方;我的错, 就是把一个"属于某个会话"的东西(对话记忆), 放进了一个"属于所有人"的容器(单例)里它给我的最大启发是:处理状态时,"这个状态的'归属/作用范围(scope)'是什么"是首要问题——是全局的、会话的、请求的、还是线程的?;而 bug 的一大来源, 就是"把一个窄作用范围的状态, 错放进了一个更宽的作用范围里"(会话状态放成全局),导致它被本不该访问它的人访问、修改、看到;"状态的作用范围, 必须和它的'归属'匹配"这给了我一种管理状态的清醒:定义/存放任何状态时,要先想清"它逻辑上属于谁、应该被谁访问"(它的作用范围),再把它放在"恰好匹配这个作用范围"的地方——全局的放全局、会话的按会话隔离、请求的随请求、线程的用 ThreadLocal;"让状态的存放位置/作用范围, 严格匹配它的逻辑归属",是避免状态被错误共享、串扰、泄漏的根本原则认清状态要分清作用范围、让存放位置匹配其逻辑归属——是这个坑带给我的认知。

第五件事:这次事故暴露的"共享带来的隐私与安全风险"

这次让我反思更深一层:这个 bug 不只是功能错乱,它还导致了"一个用户看到另一个用户的对话"——这是隐私泄漏。我把"功能 bug"和"隐私/安全事故"的严重性对比成表。

维度 纯功能 bug(答非所问) 隐私/安全事故(信息泄漏)
影响 体验差、结果错 用户隐私泄漏、信任崩塌
严重性 较高 极高(可能违法/重大事故)
可挽回 修了就好 泄漏出去难收回
本文同时 答非所问 A 的对话泄漏给 B
根源 同一个:会话状态没隔离

这张表道出了这个 bug 的另一重严重性。核心是:同一个"会话状态没隔离"的技术缺陷,不仅造成了功能错乱(答非所问),还造成了更严重的隐私泄漏(一个用户看到了另一个用户的私密对话);"数据/状态的隔离", 不只是个功能正确性问题, 更是个隐私和安全的底线问题——尤其在多租户/多用户的系统里它给我的深刻启发是:在"多个用户/租户共用一套系统"的场景里,"隔离(isolation)"是一条不可逾越的底线——一个用户绝不能访问、看到、影响到另一个用户的数据和状态;而"共享(为了省资源/方便)"和"隔离(为了安全/隐私)"常常是矛盾的,在涉及用户数据时, 隔离必须优先于共享的便利;"图省事的共享", 一旦越过了"该隔离的用户数据"这条线, 就从'性能优化'变成了'安全事故'这给了我一种构建多用户系统的根本红线意识:构建任何"服务多个用户/租户"的系统时,要把"用户间的数据/状态隔离"当成不可妥协的安全红线——在做"共享以省资源"的优化时, 必须严格确认"共享的是'不涉及用户隐私的公共资源', 而绝非'用户各自的私有数据/状态'";"守住多用户隔离的安全红线、绝不为共享的便利牺牲用户数据的隔离",是构建可信的多用户系统的底线原则认清会话不隔离不只是功能 bug 更是隐私事故、多用户隔离是不可妥协的安全红线——是这个坑带给我的工程态度。

第六件事:给 Agent/服务设计状态时,我现在的自检习惯

现在每当我要给 Agent 或服务设计一个状态(尤其有记忆的),我都会先按这张图问自己:

这张图的精髓,是"先问状态属于谁:全局的放单例、会话的按 sessionId 隔离"全局共享放单例(线程安全)、会话级绝不放共享单例、隔离无状态+外部存/独立对象/参数传、多用户隔离当安全红线这套习惯,让我从"图省事把记忆做成单例字段"变成了"先分清状态归属、会话状态严格隔离"——核心始终是:Agent 单例必须无状态,会话记忆按 sessionId 隔离存外部或每会话独立对象、显式传递,让每个会话只访问自己的状态,杜绝多用户串扰和信息泄漏。

我立下的几条规矩

这场"单例 Agent 记忆串台、信息泄漏"的事故,换来了我设计 Agent/服务状态时,刻进骨子里的几条铁律:

  1. 对话记忆是会话级状态(每会话独立),不是应用级共享状态。
  2. 单例/静态只放应用级共享、且线程安全/不可变的状态(配置/连接池/客户端)。
  3. 绝不把会话相关的可变状态(对话历史/用户上下文)放进共享单例的实例字段。
  4. Agent 设计成无状态,会话记忆按 sessionId 隔离存外部(Redis/DB)。
  5. 或每个会话用独立的上下文对象、会话标识作为参数显式传递。
  6. 先问状态"属于谁",把它放在作用范围匹配其归属的地方。
  7. 多用户隔离是不可妥协的安全红线——绝不让一个用户看到/影响另一个的数据。

写在最后

回头看,这场由"会话状态没隔离"引发的、记忆串台加信息泄漏的事故,真正教给我的,远不止"无状态 Agent + 按 sessionId 存"这一个技巧。它让我对"'共享'是把双刃剑——它能省资源、带来便利, 但当被共享的是'本该属于某个个体的私有之物'时, 共享就成了'不该有的混同', 边界一破, 私密就泄漏、彼此就串扰",有了一次刻骨的体会。我栽跟头,是因为我为了"省资源、方便",把 Agent 做成了单例、让大家"共享"它——这个"共享"的念头本身没错(很多东西确实该共享);可我没分清"什么该共享、什么绝不能共享":模型客户端、配置这些"公共设施"该共享;而每个用户的对话记忆, 是'私人物品'——它属于那一个用户、那一次会话, 绝不该和别人混在一起;我把"私人物品(对话记忆)"扔进了"公共仓库(单例)"里, 于是大家的东西堆在一起、互相拿错、还看到了彼此的隐私这让我领悟到一个关于"共享的边界"的深刻认知:"共享"必须有清晰的边界——"公共的、无归属的东西"可以共享(且共享有益),"有明确归属、属于某个个体的私有之物"则必须隔离、绝不能共享;"该共享的不共享"是浪费, 而"不该共享的乱共享"是灾难(串扰、冲突、隐私泄漏)——后者的代价远大于前者;"分清公私边界、公共的共享、私有的隔离", 是一切涉及多方共用资源的系统(乃至社会)的基本秩序这给了我一种处理"共享与隔离"的根本清醒:每当我想"共享"某个东西以图便利时,要先严格地问"这个东西, 是'公共的、可共享的', 还是'属于某个个体、必须隔离的私有之物'?"——对前者放心共享, 对后者(尤其是用户的数据/状态/隐私)严格隔离, 绝不为便利而混同;"想共享前先分清公私、守住私有数据必须隔离的边界",是构建一个既高效(该共享的共享)又安全可信(该隔离的隔离)的多方系统的根本原则认清共享必须有边界、公共的可共享私有的必须隔离、绝不为便利混同私有数据——这,是我用一次会话串台的事故,换来的、关于 AI Agent、也关于如何把握共享与隔离边界的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次给有记忆的 Agent 设计状态时,先分清"这是公共的还是某个会话私有的"、把会话记忆严格按 sessionId 隔离,那我对着那串了台、泄了密的对话复盘的这段时间,就值了。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

我按索引取数组元素、直接用它的属性,TypeScript 一声没吭,可那个索引越界了、取到的是 undefined,运行时直接炸:一次 TS 索引访问类型漏洞的深度复盘

2026-6-2 23:03:30

技术教程

我在批处理循环里写了 try except pass,觉得出错就跳过别让程序崩很稳健,结果一批数据悄悄少处理了一半还谁都不知道,连 Ctrl+C 都按不停:一次裸 except 吞掉异常的深度复盘

2026-6-2 23:15:35

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索