news 2026/11/6 8:48:20

Agent 短期记忆与会话隔离:多轮任务如何不串线

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Agent 短期记忆与会话隔离:多轮任务如何不串线

目录

一、三个串线现场

(一)别人的答案出现在我的回答里

(二)同一个用户的两件事互相污染

(三)用户改口了,但旧口径还在起作用

(四)三个现场指向同一组缺失

二、三层短期记忆

(一)三层的定义

(二)一条判断归属的规则

(三)为什么不再多分一层

三、什么放哪一层

(一)八个例子

(二)最常见的一次放错

(三)一个可执行的检查

四、作用域键:串线的唯一防线

(一)键的六段构成

(二)缺任意一维会发生什么

(三)为什么作用域要拼在前缀

五、作用域键必须出现在六个地方

(一)六个位置

(二)把它变成一条能被机器检查的约定

六、并发:同一会话的两个请求

(一)会话级串行化

(二)版本 CAS

(三)请求去重

(四)为什么不用「后写覆盖」就算了

七、会话的生命周期

(一)四个状态与三条时限

(二)「每次请求都续期」为什么是个陷阱

(三)轮数上限

八、改口不是覆盖

(一)取代而不是覆盖

(二)实际输出

(三)为什么这件事值得多花一点存储

九、隔离验证:怎么证明它真的不串

(一)四个方向

(二)金丝雀:可以自动检查的断言

(三)金丝雀能证明什么、不能证明什么

(四)一条必须写的接入测试

十、多租户存储布局

(一)三档布局

(二)按租户删除是一块试金石

(三)容易被忘掉的四个副本

十一、九条路径的实际输出

十二、从 Demo 版到生产版

(一)十项差异

(二)哪三项必须在第一版就到位

十三、失败模式与工程权衡

(一)九类失败模式

(二)第二类与第九类最值得单独盯

(三)三个常见反例

1、「我们是单租户,不需要这些」

2、「加了锁就不用 CAS 了」

3、「把历史全部带上最安全」

(四)核心权衡

十四、四类测试

(一)作用域与键

(二)四个方向的隔离

(三)并发与幂等

(四)生命周期与改口

十五、安全、性能与成本影响

(一)安全

(二)性能

(三)成本

(四)九个上线指标

十六、生产检查清单

(一)分层与归属

(二)作用域与键

(三)并发与生命周期

(四)验证与运维

(五)清单之外的一句总结

十七、结论与延伸

(一)本文结论

(二)与专栏其他文章的衔接

参考资料


干货分享,感谢您的阅读!

串线问题有一个共同特征:它们都不报错。缓存键少一维,另一个租户拿到你的检索结果;会话和任务混成一层,同一句「对」被当成对另一件事的确认;可变事实被覆盖,事后没人说得清那份报告当时用的是哪个口径。

这次我们把短期记忆拆成会话、任务、用户三层,给出作用域键必须出现的六个位置、并发的三件事、生命周期的三条时限、改口的时间线表达,以及可以自动检查的隔离验证方法。

本节,作为读者你将获得什么

  • 三个串线现场的根因,以及它们共同缺失的三样东西:键、层、时间。
  • 会话层、任务层、用户层的划分,以及一条能在三个问题内判断归属的规则。
  • 作用域键的六段构成,缺任意一维分别会发生什么、怎么被发现。
  • 作用域键必须出现的六个位置——漏掉任何一处,前面五处的隔离都白做。
  • 并发控制的三件事:会话级串行化、版本 CAS、请求去重,以及它们各挡什么。
  • 会话生命周期的四个状态与三条时限,以及「每次请求都续期」为什么是个陷阱。
  • 可变事实的时间线表达:改口是取代而不是覆盖,它带来什么、代价是多少。
  • 隔离验证的四个方向与金丝雀方法,以及它能证明什么、不能证明什么。
  • 多租户存储的三档布局,以及「按租户删除」为什么是一块试金石。
  • 一份仅依赖标准库、可直接运行的实现,含九条演示路径与二十九个测试。
  • 十项 Demo 与生产差异、九类失败模式、四类测试、九个上线指标与二十项检查清单。

关于内容立场:本文中的业务场景、阈值与时限均为示例,用于说明结构与取舍,不是任何系统的实测数据。还有设计的代码入如果写的有问题的,请您在评论区留言提醒,感谢!

一、三个串线现场

(一)别人的答案出现在我的回答里

检索结果做了缓存,键是问题文本的哈希值。

两个不同租户问了同一句话,第二个人直接拿到了第一个人的检索结果——而那份结果里带着对方的订单号。

缓存命中率的曲线很好看,没有任何报警。根因是缓存键里没有租户维度。

(二)同一个用户的两件事互相污染

用户在同一个会话里同时推进两件事:查退款,和查工单。

上下文按「最近 8 轮」取,于是那句「对」——本来是确认退款口径——被当成了对工单排序的确认。两件事的结论都对了一半。

根因是会话与任务被当成了同一层,轮次没有任务归属。

(三)用户改口了,但旧口径还在起作用

用户先说「按下单地址」,十分钟后改成「按收货地址」。记忆是覆盖式的:新值写进去,旧值消失。

当天的报告用的是新口径,而前一天已经出过一份旧口径的报告——两份报告的差异没有任何记录能解释。

根因是可变事实被覆盖而不是被取代,时间线丢了。

(四)三个现场指向同一组缺失

第一个缺的是键:所有与记忆有关的键都必须带完整作用域——租户、用户、会话,一个都不能少。

第二个缺的是层:会话、任务、用户是三个不同的作用域,生命周期与读写规则都不同,合并它们必然串线。

第三个缺的是时间:可变事实要记录生效区间,改口是追加一条新的并让旧的失效,而不是原地覆盖。

这三样东西加起来,就是本文说的「短期记忆」。它的核心不是存什么,而是谁能读到、什么时候有效。

二、三层短期记忆

(一)三层的定义

会话层任务层用户层
范围一次连续交互一次可独立完成的工作跨会话的稳定偏好
内容轮次记录、本次澄清的口径、临时偏好目标、计划、进度、产物引用语言、时区、报告模板、授权范围
生命周期小时级分钟到天级月级
作用域键租户 + 用户 + 会话会话键 + 任务租户 + 用户
典型错误当成永久存储与会话层混用把一次性澄清写成长期偏好

(二)一条判断归属的规则

给任何一条信息按顺序问三个问题,第一个否定答案就是它的归属:

1.换一个任务,它还成立吗?不成立 → 任务层。

2.换一次会话,它还成立吗?不成立 → 会话层。

3.用户会希望它长期生效、并且能自己改掉吗?会 → 用户层;不会 → 别往上放。

第三个问题里的「能自己改掉」是一个硬约束。写进用户层意味着它会长期影响这个用户的所有任务,如果用户看不见、改不掉,那就是在替用户做他不知道的决定。

(三)为什么不再多分一层

常见的提议是再加一个「组织层」或「租户层」,用来放团队共享的口径与模板。

它确实存在,但它不是短期记忆——它是配置,应该走配置中心与版本管理,有评审、有灰度、有回滚。把配置塞进记忆层的后果是:一次会话里的临时口径,会因为写错了层而影响整个团队。

三、什么放哪一层

(一)八个例子

信息放哪层理由放错的后果
「这次用表格给我」会话换一次会话就不该生效放用户层 → 以后永远是表格
「区域按下单地址」任务是本任务的口径,不是习惯放会话层 → 污染同会话另一个任务
报告模板偏好用户跨会话稳定,用户可改放会话层 → 每次都要重说一遍
当前步骤与已用预算任务与任务状态机同生共死放会话层 → 两个任务互相覆盖
上一轮的工具返回任务只对当前任务有意义放用户层 → 永久保留无用数据
用户的时区与语言用户长期稳定放会话层 → 每次重新问
澄清出来的临时口径任务它是本次的约定放用户层 → 变成永久规则
会话金丝雀会话用于隔离验证跨层复用 → 验证失去意义

(二)最常见的一次放错

把「澄清出来的临时口径」写进用户层。

它看起来很合理:用户已经说过一次了,下次就不用再问,体验更好。

但它的实际效果是:一次特殊场景下的选择,变成了以后所有任务的默认值,而用户完全不知道这件事发生过,也不知道该去哪里改掉它。本专栏的长期记忆一篇会展开这条边界。

(三)一个可执行的检查

给每一个写入记忆的调用点加一个必填参数:层。然后做两件事:

  • 在代码评审清单里加一条「这个写入的层对吗」;
  • 把「按层统计的写入量」做成指标。

用户层的写入量应当远小于会话层与任务层。如果三者量级接近,基本可以确定有信息放错了层——而且几乎总是往上放错。

四、作用域键:串线的唯一防线

(一)键的六段构成

@dataclass(frozen=True, slots=True) class Scope: tenant: str user: str session: str = "" task: str = "" def __post_init__(self) -> None: if not self.tenant or not self.user: raise ScopeError("tenant 与 user 不能为空") def cache_key(self, purpose: str, payload: object) -> str: raw = json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str) digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16] return f"{self.session_key}|p={purpose}|h={digest}"

构造函数直接拒绝空租户与空用户,这是这条约定的最小实现:宁可当场拒绝服务,也不要用一个缺维的键去读写记忆。

(二)缺任意一维会发生什么

缺哪一维会发生什么发现方式
租户A 租户的缓存被 B 租户命中;按租户删除做不到只能靠用户投诉或合规审计
用户同租户内两个用户共享上下文与缓存同上;同租户内更难被察觉
会话同一用户的两次独立对话互相干扰表现为「它记得我上次说的,但我没说过」
任务同会话的两个任务互相污染表现为「两件事的结论都对了一半」
用途检索缓存被重排缓存命中表现为返回了结构不对的数据
输入指纹不同问题共用一个缓存条目表现为答非所问

配套代码的实际输出:

只用输入指纹做键: A 的键:3320eaccf89ed9e3 B 的键:3320eaccf89ed9e3 ← 完全相同,B 会直接拿到 A 的缓存结果 带完整作用域的键: A 的键:t=tenant-a|u=u-1001|s=s-1|p=retrieve|h=3320eaccf89ed9e3 B 的键:t=tenant-b|u=u-2002|s=s-9|p=retrieve|h=3320eaccf89ed9e3

注意后两者的哈希部分仍然相同——这是对的,输入确实一样。真正起隔离作用的是前面那几段。

(三)为什么作用域要拼在前缀

把作用域拼在键的前缀而不是后缀,有一个附带好处:按前缀扫描就能列出或删除某个租户的全部条目。这一点在缓存层尤其重要,因为大多数缓存系统只支持前缀扫描,不支持任意字段查询。

五、作用域键必须出现在六个地方

漏掉任何一处,前面五处的隔离都白做。

(一)六个位置

1、缓存键。检索结果、重排结果、模型响应、工具返回。最常见的漏点是「同样的问题何必再算一遍」。

2、锁键。会话级串行化的锁。漏点是用任务 ID 做锁键,于是同会话的两个任务可以并发改同一份会话记录。

3、限流键。每租户、每用户的配额。漏点是只按 IP 或只按接口限流,一个大租户可以打满全局配额。

4、检索过滤。向量库与全文索引的过滤条件。漏点是靠「索引里只有本租户数据」这一假设——多租户共享索引时,这个假设随时会被打破。

5、日志字段。每条日志都带脱敏后的作用域。漏点是只记request_id,出问题时无法回答「这条日志属于谁」。

def redacted(self) -> str: """日志用:保留可定位性,去掉可识别性。""" h = hashlib.sha256(self.user.encode()).hexdigest()[:8] return f"t={self.tenant}|u#{h}|s={self.session or '-'}|k={self.task or '-'}"

6、产物路径。对象存储里的前缀。漏点是用产物 ID 做扁平路径,于是无法按租户设置生命周期与访问策略。

(二)把它变成一条能被机器检查的约定

做法是:不允许直接拼接键,所有键都必须由作用域对象产生。

scope.cache_key(purpose, payload)、store.lock_for(scope)、scope.redacted()—— 只要键的产生入口是收敛的,漏维这件事就从「靠人记得」变成了「类型系统与代码评审能看见」。

六、并发:同一会话的两个请求

三件事各挡一类问题,缺一不可。

(一)会话级串行化

同一会话的写入排队。键必须是会话键,不是任务键——否则同会话的两个任务仍然可以并发改同一份记录。

锁要有超时与持有者标识,并且必须假设它会失效:网络分区、进程被杀、GC 停顿都会让锁形同虚设。

(二)版本 CAS

锁失效时的兜底。每次写入都带上读到的版本号,版本不一致就拒绝并重试。

def commit(self, prev: SessionRecord, nxt: SessionRecord) -> None: with self._guard: cur = self._data.get(prev.scope_key) if cur is None or cur.version != prev.version: raise ConcurrencyError( f"版本冲突:期望 {prev.version},实际 {cur.version if cur else '不存在'}") self._data[prev.scope_key] = nxt

重试时必须重新读取、重新计算,而不是把旧结论再写一次——后者会把并发冲突变成静默的数据错误。CAS 失败率是一个必须观测的指标。

(三)请求去重

用户双击、网关重试、客户端超时重发,都会产生内容相同的请求。

没有request_id去重,会话里会出现两条一样的用户输入,模型会把它当成「用户强调了两遍」——而「强调」在很多提示词里是一个有效信号,会真的改变输出。

配套代码的实际输出:

八个并发请求后:轮数 8,版本 v8,异常 0 个 轮号序列: [1, 2, 3, 4, 5, 6, 7, 8] 不走会话锁、直接用过期版本提交时: 版本冲突:期望 8,实际 9 同一 request_id 重投两次: 第一次返回轮号 1,第二次返回轮号 1,实际轮数 1

(四)为什么不用「后写覆盖」就算了

会话历史是只追加的,覆盖等于丢一轮。而丢的那一轮往往是用户刚说的话——用户会看到「我明明说了,它没反应」。

更麻烦的是轮号:覆盖会让轮号出现空洞或重复,而很多下游逻辑(引用某一轮、从某一轮恢复、按轮号做增量同步)都依赖轮号连续。配套测试里有一条专门断言轮号序列连续。

七、会话的生命周期

(一)四个状态与三条时限

IDLE_AFTER = 15 * 60 # 无请求多久转 idle(示例值) EXPIRE_AFTER = 8 * 3600 # 绝对过期时限(示例值) ARCHIVE_AFTER = 30 * 86400 # 过期后多久归档(示例值) MAX_TURNS = 200 # 单会话最大轮数(示例值)

空闲阈值决定什么时候可以把会话的热数据从内存或缓存里换出去。它只影响成本,不影响正确性。

绝对过期时限决定会话最长能活多久。它是安全与成本的共同边界。

归档时限决定正文什么时候转冷存储。归档之后会话仍可查询,但只剩元信息与摘要。

实际输出:

刚写入 状态 active 轮数 1 超过空闲阈值 状态 idle 轮数 1 超过绝对过期时限 状态 expired 轮数 1 超过归档时限 状态 archived 轮数 0 过期后写入被拒:会话已过期:t=tenant-a|u#1bee97ac|s=s-6001|k=T-1

(二)「每次请求都续期」为什么是个陷阱

它看起来只是提升体验:用户还在用,就别过期。实际后果有三个:

  • 一个被长期占用的会话会无限期持有全部历史,上下文越来越长、成本越来越高;
  • 数据保留期失去上界,合规上无法回答「这些数据最多存多久」;
  • 会话数只增不减,容量规划失去依据。

正确做法:活跃续的是空闲判定,不是绝对时限。配套测试里有一条专门断言这一点:

def test_activity_does_not_extend_absolute_deadline(self): s.append_turn("user", "第一轮") deadline = store.get(sc).expires_at for _ in range(5): clock.advance(600) s.append_turn("user", "继续") self.assertEqual(store.get(sc).expires_at, deadline)

需要继续的用户,开一个新会话并带上必要的结论——而「必要的结论」正是任务层的事实,它们本来就是可以跨会话携带的。

(三)轮数上限

时限管的是时间,轮数上限管的是密度。一个高频交互的会话可能在几十分钟内累积上百轮,而它离过期还很远。

nxt.turns.append(turn) if len(nxt.turns) > MAX_TURNS: # 轮数上限:防止单会话无限增长 nxt.turns = nxt.turns[-MAX_TURNS:]

单纯丢最旧的是一个兜底策略,它会丢掉最早的约定。更好的做法是在接近上限时触发一次阶段性总结,把早期轮次压缩成任务层的几条事实——这属于上下文工程的范畴,本专栏有专篇。

八、改口不是覆盖

(一)取代而不是覆盖

@dataclass(slots=True) class Fact: key: str value: str valid_from: float valid_to: float | None = None # None 表示当前有效 source_turn: int = 0 superseded_by: str = "" def active_at(self, t: float) -> bool: return self.valid_from <= t and (self.valid_to is None or t < self.valid_to)

写入时,如果同名事实已存在且值不同,就把旧的标记为失效,再追加一条新的:

new = Fact(key, value, now, None, source_turn) for f in nxt.facts: if f.key == key and f.active_at(now): if f.value == value: return f # 重复断言:不产生新版本 f.valid_to = now f.superseded_by = new.fact_id nxt.facts.append(new)

注意「重复断言不产生新版本」这一条:用户在多轮里反复确认同一件事是很常见的,每次都写一条新记录会让事实表迅速膨胀,也会让历史变得难读。

(二)实际输出

当前有效事实:{'region_rule': '按收货地址'} 十分钟前有效的事实:{'region_rule': '按下单地址'} 完整历史: F-3a0a35f8 按下单地址 从 1780000000 到 1780000600 → 被 F-016b8612 取代 F-016b8612 按收货地址 从 1780000600 至今

(三)为什么这件事值得多花一点存储

事后复盘时,最关键的问题不是「现在的口径是什么」,而是「那份报告当时用的是哪个口径」。覆盖式写入回答不了第二个问题,于是两份不同的报告之间的差异永远说不清。

代价很小:一条事实多一个时间字段和一个指针。收益是两件事:

  • 可解释:任何一次产出都能追到当时生效的事实集;
  • 可回放:用某个历史时刻的事实集重跑一次,应当得到当时那份产物。

第二点是「持久化执行」一篇的基础,本专栏后面会展开。

九、隔离验证:怎么证明它真的不串

「我们做了隔离」不是一个可以被验证的说法。下面是可以被验证的版本。

(一)四个方向

方向要求表现
跨租户A 租户的内容不得出现在 B 租户的任何片段里最严重,通常直接构成合规事故
跨用户同租户内两个用户之间同样不得互相可见比跨租户更难察觉,两边都「像是自己的数据」
跨会话同一用户的两次独立对话不得互相干扰「它记得我上次说的,但我这次没说过」
跨任务同一会话里的两个任务不得互相污染「两件事的结论都对了一半」

(二)金丝雀:可以自动检查的断言

# 每个会话创建时分配一个唯一标记 canary = f"CANARY-{uuid.uuid4().hex[:10]}" # 任何片段里都不应出现别的会话的标记 def leaked(snippet: dict, foreign_canary: str) -> bool: if not foreign_canary: return False return foreign_canary in json.dumps(snippet, ensure_ascii=False)

金丝雀该挂在三个位置,覆盖面依次扩大:

1.单元测试:四个方向各一条断言,成本接近零;

2.压测:并发跑多租户多会话,在每次装配后检查所有外来金丝雀;

3.线上采样:按一定比例(示例:千分之一)对真实请求做同样的检查,命中即告警。

第三条最有价值,因为串线往往发生在测试覆盖不到的路径上:一个新加的缓存、一个临时的排查接口、一次数据导出。

(三)金丝雀能证明什么、不能证明什么

它只能证伪,不能证明「一定没串」。它抓的是「内容被整段带到了别处」这一类,抓不到「被改写后带过去」——比如模型读到了别人的内容然后用自己的话复述。

所以它是底线检查,不是充分检查。真正的隔离仍然要靠作用域键这一层结构性约束来保证。

(四)一条必须写的接入测试

用 A 租户的凭据去检索一个只有 B 租户才有的关键词,必须返回空。

这条测试的价值在于:检索层的隔离在单租户环境下永远不会暴露问题,而多租户环境下的第一次暴露往往就是事故。它不能靠看代码来保证,因为「索引里只有本租户数据」这个假设是隐式的,没有任何代码会声明它。

十、多租户存储布局

隔离不只是键的问题,也是物理布局的问题。

(一)三档布局

布局优点缺点适用
共享表 + 租户列实现最简单,资源利用率最高一次漏写过滤就是全量泄露;大租户影响小租户早期、租户少、敏感度低
共享表 + 分区查询天然分区裁剪;导出与删除直接;影响被限制分区数受限,超大租户仍需单独处理多数中等规模场景
独立库 / 独立实例隔离彻底,可做差异化的备份与加密策略运维成本随租户数线性增长;跨租户统计与升级变难强合规、少量大客户

常见做法是默认共享分区,对特定客户单独拉出来。

(二)按租户删除是一块试金石

合规要求「删除某租户的全部数据」时,三种布局的难度差别巨大:

  • 独立库:删库即可;
  • 分区:删分区,秒级完成;
  • 共享表无分区:全表扫描加逐表删除,而且永远无法确定「是不是删干净了」。

配套代码里的purge_tenant只有几行,前提是键里第一段就是租户:

def purge_tenant(self, tenant: str) -> int: with self._guard: keys = [k for k, v in self._data.items() if v.tenant == tenant] for k in keys: del self._data[k] return len(keys)

(三)容易被忘掉的四个副本

记忆不只存在主库里。同一份内容通常还散落在:

  • 缓存:键里必须带租户,否则按租户失效做不到;
  • 向量库 / 全文索引:过滤条件必须带租户,不能依赖「索引里只有本租户」这个假设;
  • 日志与追踪:字段里要有脱敏后的作用域;
  • 对象存储:路径前缀要带租户,这样生命周期策略才能按租户配置。

「删除某租户全部数据」因此不是一次删除,而是一条跨四个系统的编排:删除请求本身应当是一个有状态的任务,逐个系统确认,全部完成才算结束。

十一、九条路径的实际输出

配套代码可以直接跑出九条路径。下面是其中三条的要点。

同会话跨任务的过滤效果:

不按任务过滤(取最近 5 轮): [T-refund] user: 统计上月华东退款 [T-ticket] user: 另外帮我查一下待处理工单 [T-refund] assistant: 退款口径按下单地址,对吗? [T-ticket] assistant: 工单按创建时间倒序,对吗? [T-refund] user: 对 按任务过滤(T-refund): [T-refund] user: 统计上月华东退款 [T-refund] assistant: 退款口径按下单地址,对吗? [T-refund] user: 对

第二种里那句「对」不会被误解成对工单排序的确认。

按租户删除:

删除前的全部键: t=tenant-a|u=u-0|s=s-a0 t=tenant-a|u=u-1|s=s-a1 t=tenant-a|u=u-2|s=s-a2 t=tenant-b|u=u-0|s=s-b0 t=tenant-b|u=u-1|s=s-b1 删除 tenant-a:3 个会话

金丝雀验证:

A 的金丝雀:CANARY-9f2a10cd3e B 的金丝雀:CANARY-4e77b0aa21 B 的片段里出现 A 的金丝雀:False A 的片段里出现 B 的金丝雀:False

十二、从 Demo 版到生产版

(一)十项差异

维度Demo 版常见做法生产版做法不改会怎样
记忆分层一个会话字典装所有会话 / 任务 / 用户三层分开同会话两个任务互相污染
作用域隐式(靠调用方传对)作用域对象强制四维少一维就串线且不报错
缓存键输入哈希作用域 + 用途 + 输入指纹跨租户命中别人的结果
锁无,或用任务 ID会话键加锁 + 版本 CAS并发写互相覆盖
请求去重无request_id去重且与写入同事务重复轮次被当成强调
历史选取最近 N 轮按任务过滤后再取 N 轮跨任务串线
改口覆盖追加新事实 + 旧事实失效无法回答「当时用的哪个口径」
过期无,或每次请求续期空闲阈值 + 绝对时限 + 归档会话无限增长,保留期无上界
日志记request_id记脱敏作用域无法回答「这条日志属于谁」
隔离验证靠人工看金丝雀 + 四方向断言 + 线上采样靠用户投诉才发现

(二)哪三项必须在第一版就到位

「作用域」「缓存键」「记忆分层」这三项都是结构性的,后补意味着改掉所有读写记忆的调用点:

  • 作用域:把隐式参数改成强制对象,要动每一个入口,而且历史数据里没有租户维度,无法回填;
  • 缓存键:改键等于全量失效一次,这本身可以接受;真正的麻烦是「哪些地方做了缓存」往往没人说得全;
  • 记忆分层:从一个字典拆成三层,等于重写读取逻辑,而且要判断每一条历史数据原本属于哪一层——通常判断不了。

其余七项可以后补:锁、去重、过期、日志字段、隔离验证都是在既有结构上加机制,不需要改数据形态。

十三、失败模式与工程权衡

(一)九类失败模式

#失败模式成因对策
1缓存键漏维只用输入指纹做键所有键由作用域对象产生,禁止手工拼接
2索引过滤缺失依赖「索引里只有本租户」的假设检索请求强制带租户过滤并写接入测试
3跨任务串线按「最近 N 轮」取历史轮次带任务归属,取历史时按任务过滤
4并发覆盖两个请求读到同一版本会话键加锁 + 版本 CAS,冲突后重新读取再算
5重复轮次用户双击或网关重试request_id去重且与写入同事务
6轮号空洞覆盖或并发导致不连续把「轮号连续」写成断言
7无限续期每次请求都延长过期时间活跃只影响空闲判定,绝对时限不可续
8会话无上限轮次一直追加轮数上限 + 阶段性总结
9副本没清干净删除只覆盖主库四处副本键里都带租户,删除走统一编排

(二)第二类与第九类最值得单独盯

第二类(索引过滤缺失)在单租户环境下永远不会暴露,而多租户环境下的第一次暴露往往就是事故。检出方式不是看代码,而是写一条接入测试——用 A 租户的凭据去检索一个只有 B 租户才有的关键词,必须返回空。

第九类(副本没清干净)的特点是主流程做对了,但删除这件事跨了四个系统。它需要的不是某一处的修复,而是一条编排:删除请求作为一个有状态的任务,逐个系统确认,全部完成才算结束。

(三)三个常见反例

1、「我们是单租户,不需要这些」

单租户只是暂时的。更重要的是:单租户里仍然有「跨用户」和「跨会话」两个方向,而这两个方向的串线同样是事故。把作用域建起来的成本在第一天最低。

2、「加了锁就不用 CAS 了」

分布式锁在超时、网络分区、进程被杀、长时间 GC 停顿的情况下都会失效,而失效时它不会告诉你。CAS 的作用不是替代锁,而是在锁失效时把「静默的数据错误」变成「一个可以重试的异常」。

3、「把历史全部带上最安全」

带上全部历史既不安全也不便宜:跨任务串线的根源就是「全部带上」。安全的做法是按任务过滤,然后按上下文工程的规则做选择。

(四)核心权衡

权衡项一端另一端建议
分层粒度少:实现简单多:边界清晰但维护面大三层,不再多分;配置走配置中心
锁粒度会话级:冲突少更细:并发高但边界复杂会话级 + CAS,够用且易解释
会话时限短:安全、省钱长:体验连续空闲短、绝对时限中等、归档长
事实历史覆盖:省存储取代:可解释可回放取代;代价只是两个字段
隔离验证只做测试加线上采样两者都做,采样比例可以很低

十四、四类测试

隔离是少数几件「可以被完全证伪」的事,值得把断言写满。

(一)作用域与键

def test_missing_tenant_or_user_raises(self): with self.assertRaises(ScopeError): Scope("", "u-1", "s-1") def test_same_payload_different_tenant_gives_different_key(self): a, b = Scope("t-a", "u-1", "s-1"), Scope("t-b", "u-1", "s-1") self.assertNotEqual(a.cache_key("retrieve", {"q": "x"}), b.cache_key("retrieve", {"q": "x"}))

还要断言「同内容同作用域的键是稳定的」,且与字段顺序无关——否则缓存永远不会命中。

(二)四个方向的隔离

除了四个方向各一条断言,建议再写一条反证用例:

def test_unfiltered_recent_turns_do_mix(self): """反证:不按任务过滤时确实会混——这正是需要过滤的理由。""" ... got = [t.text for t in Session(self.store, t1).recent_turns(10)] self.assertIn("工单相关", got)

它保证过滤逻辑真的在起作用,而不是因为测试数据本来就不会混。

(三)并发与幂等

串行地调用两次不算并发测试。有效的写法有两种,两种都要有:

  • 真的起多个线程同时写,断言最终轮数与轮号连续(覆盖正常路径);
  • 手工构造「两个请求都读到同一版本」的场景,断言第二次提交必然失败(覆盖锁失效时的兜底路径)。
def test_concurrent_appends_do_not_lose_turns(self): ts = [threading.Thread(target=w, args=(i,)) for i in range(16)] ... self.assertEqual(len(rec.turns), 16) self.assertEqual([t.turn_no for t in rec.turns], list(range(1, 17)))

(四)生命周期与改口

四个状态按时限依次推进、归档后正文被清空、过期后写入被拒、活跃不延长绝对时限、改口后旧事实带失效时间且指向新事实、可以按任意历史时刻还原事实集。

本轮交付时,二十九个测试全部通过,耗时约 0.003 秒(本机 Python 3.12,示例环境,仅供说明量级)。

十五、安全、性能与成本影响

(一)安全

作用域键是这一层的主要安全边界。它不是加密,也不是权限系统,但它决定了「一次错误的代码改动会不会造成跨租户泄露」。

日志同样需要隔离。日志里的原始用户标识是一个常被忽略的泄露面:日志的访问面通常比主库宽得多,保留期也更长。脱敏后的作用域保留了可定位性,去掉了可识别性。

删除是一个跨系统的编排,不是一次操作。合规上的「删除权」要求覆盖全部副本,这一点在设计之初就要体现在键的结构里,否则后期只能靠全量扫描。

(二)性能

会话级锁是一个潜在的串行化瓶颈。单会话内的请求本来就是串行的(用户在等),所以影响有限;但要注意锁的持有时间——不要在锁内做模型调用或外部请求。

分区裁剪比索引更有效。会话表的查询几乎总是带租户与会话两个条件,按租户分区可以让每次查询只扫一个分区。

归档降低的是热数据体积。归档把正文移走之后,会话表只剩元信息,扫描成本大幅下降;而归档后的会话查询频率极低,冷存储的延迟可以接受。

(三)成本

三条路径:

  • 存储成本:会话时限与归档策略直接决定热数据规模;
  • 推理成本:按任务过滤会减少上下文长度,这一项通常比存储成本大一个量级;
  • 事故成本:串线事故的处理成本(排查、通知、合规报告)远高于前两者,而它可以靠这一层的结构性约束大幅降低。

(四)九个上线指标

指标看什么备注
会话状态分布活跃 / 空闲 / 过期 / 归档活跃数持续上升说明过期策略没生效
会话轮数分布P50 / P95 / P99P99 触到上限说明缺少阶段性总结
CAS 失败率并发冲突的唯一可见信号持续升高说明热点会话或锁没生效
请求去重命中率按来源分组客户端偏高正常,服务端偏高是 Bug
金丝雀命中次数串线的直接证据建议硬告警,必须长期为零
缺维键拒绝次数作用域构造失败次数建议硬告警
按层写入量会话 / 任务 / 用户三层用户层应远小于另两层
事实改口次数每会话平均改口次数偏高说明澄清设计有问题
按租户的会话与存储量容量规划与成本分摊也是发现异常租户的最直接信号

两个硬告警的理由:金丝雀命中次数是串线的直接证据,没有任何「可以接受的非零值」;缺维键拒绝次数意味着有调用点没有带全作用域——这些调用当场被拒绝了,所以没有造成泄露,但它说明约定没有被完整执行。

十六、生产检查清单

(一)分层与归属

1. 会话、任务、用户三层分开,各有明确的生命周期。

2. 每个写入调用都显式指定层。

3. 有一条「换任务 / 换会话还成立吗」的归属规则。

4. 按层统计的写入量有看板,用户层应远小于另两层。

5. 临时澄清结论不写进用户层。

(二)作用域与键

6. 所有键由统一的作用域对象产生,禁止手工拼接。

7. 缺租户或缺用户时当场拒绝,而不是用默认值。

8. 缓存键 = 作用域 + 用途 + 输入指纹。

9. 锁键、限流键、索引过滤、日志字段、产物路径都带作用域。

10. 作用域拼在键的前缀,支持按前缀列举与删除。

(三)并发与生命周期

11. 同一会话的写入走会话级串行化。

12. 版本 CAS 作为锁失效时的兜底,冲突后重新读取再算。

13.request_id去重,且与会话写入同事务。

14. 空闲阈值、绝对过期时限、归档时限三者分开。

15. 活跃只延长空闲判定,不延长绝对过期时限。

(四)验证与运维

16. 每会话一个金丝雀,四个方向各有断言。

17. 线上按比例做金丝雀采样检查,命中即告警。

18. 有一条「用 A 的凭据检索 B 的关键词必须为空」的接入测试。

19. 按租户删除覆盖主库、缓存、索引、日志与对象存储。

20. 可变事实记录生效区间,可按任意历史时刻还原。

(五)清单之外的一句总结

短期记忆的难点从来不是「存什么」,而是「谁能读到、什么时候有效」。

前者是数据结构问题,写错了会报错;后者是作用域问题,写错了不会报错——它只会在某一天,让一个用户看到另一个用户的订单号。这就是为什么这一层的工作重心是键、锁与断言,而不是存储格式。

十七、结论与延伸

(一)本文结论

第一,三个串线现场缺的是同样三样东西:键(作用域不完整)、层(会话与任务混用)、时间(事实被覆盖)。

第二,短期记忆分三层:会话、任务、用户。判断归属的规则是三个问题,第一个否定答案就是它的归属。

第三,作用域键有六段,缺任意一维都会产生一条不报错的串线路径。所有键必须由统一的作用域对象产生。

第四,作用域键必须出现在六个位置:缓存、锁、限流、检索过滤、日志、产物路径。漏一处,其余五处白做。

第五,并发需要三件事:会话级串行化、版本 CAS、请求去重。CAS 失败后必须重新读取重新计算。

第六,会话有三条时限。活跃只延长空闲判定,不延长绝对过期时限——否则保留期失去上界。

第七,改口是取代而不是覆盖。代价是两个字段,收益是可解释与可回放。

第八,隔离验证有四个方向,金丝雀是可以自动检查的底线;但它只能证伪,不能证明。

第九,物理布局同样是隔离的一部分。「按租户删除」的难易程度是检验布局的试金石。

第十,删除是一条跨系统的编排:主库、缓存、索引、日志、对象存储都要覆盖。

(二)与专栏其他文章的衔接

《Agent 工作流实战》专栏导读地图与阅读路径说明-CSDN博客

本文是专栏第三部分「状态、上下文与记忆」的第四篇,处理的是「多个会话、多个任务、多个租户同时存在时怎么不互相干扰」:

  • 任务状态本身的结构与迁移,见《把 Agent 建模为状态机:生命周期、事件与状态迁移》与《状态 Schema 设计:输入、过程、产物与错误如何落盘》——本文的任务层就是那两篇里的状态记录;
  • 本文按任务过滤出来的轮次,接下来如何被选择、排序与压缩,见《上下文工程:选择、排序、压缩,而不是塞满窗口》;
  • 哪些结论值得从任务层升到用户层、升上去之后怎么管理,见《长期记忆不是向量库:写入、召回、遗忘与纠错》——本文刻意把这条边界留给了那篇;
  • 会话中断后如何恢复而不重复执行副作用,见《持久化执行:Checkpoint、断点续跑与幂等恢复》。

向后延伸:

  • 租户隔离、最小权限与临时凭证的完整设计,见第六部分《最小权限落地:身份、授权、租户隔离与临时凭证》;
  • 日志与上下文中的敏感数据治理,见第六部分《敏感数据治理:密钥、PII、脱敏、保留与删除》;
  • 会话级限流与背压,见第六部分《限流、队列与背压:高并发下保护模型和下游系统》;
  • 会话作为服务接口的表达(任务 ID、流式、取消),见第八部分《服务化 Agent:API 契约、任务 ID、流式响应与取消》。

参考资料

多租户与隔离

  • Multi-tenant SaaS 架构模式(AWS 白皮书页) —— 租户隔离的三档布局与各自的取舍,本文第十章与之对应。
  • Azure 架构中心:多租户数据存储与分区 —— 共享表、分区与独立库三种方案的具体权衡。
  • PostgreSQL 文档:表分区 —— 按租户分区的实现方式与查询裁剪机制。
  • PostgreSQL 文档:行级安全策略 —— 在数据库层强制租户过滤,可作为应用层之外的第二道防线。

并发与一致性

  • PostgreSQL 文档:并发控制 —— 乐观并发与隔离级别,版本 CAS 的底层依据。
  • Redis 文档:分布式锁(Redlock) —— 分布式锁的实现与其失效条件,本文「必须假设锁会失效」的出处。
  • How to do distributed locking(Martin Kleppmann) —— 对分布式锁安全性的批评性分析,说明为什么需要 CAS 兜底。
  • Idempotency-Key HTTP 头草案(IETF) —— 请求去重键的标准化表达。

时序数据与可变事实

  • Bitemporal History(Martin Fowler) —— 有效时间与记录时间的双时间线建模,本文事实取代机制的完整形态。
  • Event Sourcing(Martin Fowler) —— 「追加而非覆盖」的一般化模式。

会话管理与安全

  • OWASP Session Management Cheat Sheet —— 会话时限、绝对过期与续期策略的安全实践。
  • OWASP Logging Cheat Sheet —— 日志中不应出现的内容与脱敏要求。
  • GDPR 官方文本 —— 存储限制与删除权,本文「删除是一条跨系统编排」的合规依据。

Agent 记忆设计

  • Effective context engineering for AI agents(Anthropic 工程博客) —— 上下文与记忆的边界讨论。
  • A practical guide to building agents(OpenAI) —— 会话与任务状态的组织建议。
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/6 8:24:42

Windows域控设置IE主页 默认打开百度 【全域策略生效】

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/11/6 8:22:31

嵌入式LLM实战:从资源约束到硬件闭环的完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/11/6 8:17:26

车联网数据处理系统架构与实现关键技术

1. 项目背景与核心价值车联网数据正在成为智慧交通领域的重要基础设施。每天产生的车辆轨迹、速度、位置等数据量级可达TB级别&#xff0c;如何高效采集、存储和分析这些数据是行业痛点。这个项目通过构建完整的车联网数据处理流水线&#xff0c;实现了从数据采集到可视化展示的…

作者头像 李华
网站建设 2026/11/6 8:16:31

前驱图与AOE网:操作系统与数据结构中的时间关系模型对比

1. 项目概述 作为一名计算机专业的学习者&#xff0c;我最近在复习操作系统和数据结构时&#xff0c;发现了一个有趣的现象&#xff1a;操作系统中的"前驱图"和数据结构中的"AOE网"虽然来自不同领域&#xff0c;但它们都在描述某种"时间的形状"。…

作者头像 李华
网站建设 2026/11/6 8:14:51

金融级消息中间件选型与Pulsar实战优化

1. 从RabbitMQ到Pulsar&#xff1a;中原银行消息中间件升级实战 消息中间件作为分布式系统的"血管"&#xff0c;承载着业务数据的流转重任。在金融行业数字化转型浪潮中&#xff0c;中原银行面临第一代RabbitMQ架构的诸多痛点&#xff1a;脑裂风险高导致服务不可用、…

作者头像 李华