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

发布时间:2026/11/6 8:50:14
Agent 短期记忆与会话隔离:多轮任务如何不串线 目录一、三个串线现场一别人的答案出现在我的回答里二同一个用户的两件事互相污染三用户改口了但旧口径还在起作用四三个现场指向同一组缺失二、三层短期记忆一三层的定义二一条判断归属的规则三为什么不再多分一层三、什么放哪一层一八个例子二最常见的一次放错三一个可执行的检查四、作用域键串线的唯一防线一键的六段构成二缺任意一维会发生什么三为什么作用域要拼在前缀五、作用域键必须出现在六个地方一六个位置二把它变成一条能被机器检查的约定六、并发同一会话的两个请求一会话级串行化二版本 CAS三请求去重四为什么不用「后写覆盖」就算了七、会话的生命周期一四个状态与三条时限二「每次请求都续期」为什么是个陷阱三轮数上限八、改口不是覆盖一取代而不是覆盖二实际输出三为什么这件事值得多花一点存储九、隔离验证怎么证明它真的不串一四个方向二金丝雀可以自动检查的断言三金丝雀能证明什么、不能证明什么四一条必须写的接入测试十、多租户存储布局一三档布局二按租户删除是一块试金石三容易被忘掉的四个副本十一、九条路径的实际输出十二、从 Demo 版到生产版一十项差异二哪三项必须在第一版就到位十三、失败模式与工程权衡一九类失败模式二第二类与第九类最值得单独盯三三个常见反例1、「我们是单租户不需要这些」2、「加了锁就不用 CAS 了」3、「把历史全部带上最安全」四核心权衡十四、四类测试一作用域与键二四个方向的隔离三并发与幂等四生命周期与改口十五、安全、性能与成本影响一安全二性能三成本四九个上线指标十六、生产检查清单一分层与归属二作用域与键三并发与生命周期四验证与运维五清单之外的一句总结十七、结论与延伸一本文结论二与专栏其他文章的衔接参考资料干货分享感谢您的阅读串线问题有一个共同特征它们都不报错。缓存键少一维另一个租户拿到你的检索结果会话和任务混成一层同一句「对」被当成对另一件事的确认可变事实被覆盖事后没人说得清那份报告当时用的是哪个口径。这次我们把短期记忆拆成会话、任务、用户三层给出作用域键必须出现的六个位置、并发的三件事、生命周期的三条时限、改口的时间线表达以及可以自动检查的隔离验证方法。本节作为读者你将获得什么三个串线现场的根因以及它们共同缺失的三样东西键、层、时间。会话层、任务层、用户层的划分以及一条能在三个问题内判断归属的规则。作用域键的六段构成缺任意一维分别会发生什么、怎么被发现。作用域键必须出现的六个位置——漏掉任何一处前面五处的隔离都白做。并发控制的三件事会话级串行化、版本 CAS、请求去重以及它们各挡什么。会话生命周期的四个状态与三条时限以及「每次请求都续期」为什么是个陷阱。可变事实的时间线表达改口是取代而不是覆盖它带来什么、代价是多少。隔离验证的四个方向与金丝雀方法以及它能证明什么、不能证明什么。多租户存储的三档布局以及「按租户删除」为什么是一块试金石。一份仅依赖标准库、可直接运行的实现含九条演示路径与二十九个测试。十项 Demo 与生产差异、九类失败模式、四类测试、九个上线指标与二十项检查清单。关于内容立场本文中的业务场景、阈值与时限均为示例用于说明结构与取舍不是任何系统的实测数据。还有设计的代码入如果写的有问题的请您在评论区留言提醒感谢一、三个串线现场一别人的答案出现在我的回答里检索结果做了缓存键是问题文本的哈希值。两个不同租户问了同一句话第二个人直接拿到了第一个人的检索结果——而那份结果里带着对方的订单号。缓存命中率的曲线很好看没有任何报警。根因是缓存键里没有租户维度。二同一个用户的两件事互相污染用户在同一个会话里同时推进两件事查退款和查工单。上下文按「最近 8 轮」取于是那句「对」——本来是确认退款口径——被当成了对工单排序的确认。两件事的结论都对了一半。根因是会话与任务被当成了同一层轮次没有任务归属。三用户改口了但旧口径还在起作用用户先说「按下单地址」十分钟后改成「按收货地址」。记忆是覆盖式的新值写进去旧值消失。当天的报告用的是新口径而前一天已经出过一份旧口径的报告——两份报告的差异没有任何记录能解释。根因是可变事实被覆盖而不是被取代时间线丢了。四三个现场指向同一组缺失第一个缺的是键所有与记忆有关的键都必须带完整作用域——租户、用户、会话一个都不能少。第二个缺的是层会话、任务、用户是三个不同的作用域生命周期与读写规则都不同合并它们必然串线。第三个缺的是时间可变事实要记录生效区间改口是追加一条新的并让旧的失效而不是原地覆盖。这三样东西加起来就是本文说的「短期记忆」。它的核心不是存什么而是谁能读到、什么时候有效。二、三层短期记忆一三层的定义会话层任务层用户层范围一次连续交互一次可独立完成的工作跨会话的稳定偏好内容轮次记录、本次澄清的口径、临时偏好目标、计划、进度、产物引用语言、时区、报告模板、授权范围生命周期小时级分钟到天级月级作用域键租户 用户 会话会话键 任务租户 用户典型错误当成永久存储与会话层混用把一次性澄清写成长期偏好二一条判断归属的规则给任何一条信息按顺序问三个问题第一个否定答案就是它的归属1.换一个任务它还成立吗不成立 → 任务层。2.换一次会话它还成立吗不成立 → 会话层。3.用户会希望它长期生效、并且能自己改掉吗会 → 用户层不会 → 别往上放。第三个问题里的「能自己改掉」是一个硬约束。写进用户层意味着它会长期影响这个用户的所有任务如果用户看不见、改不掉那就是在替用户做他不知道的决定。三为什么不再多分一层常见的提议是再加一个「组织层」或「租户层」用来放团队共享的口径与模板。它确实存在但它不是短期记忆——它是配置应该走配置中心与版本管理有评审、有灰度、有回滚。把配置塞进记忆层的后果是一次会话里的临时口径会因为写错了层而影响整个团队。三、什么放哪一层一八个例子信息放哪层理由放错的后果「这次用表格给我」会话换一次会话就不该生效放用户层 → 以后永远是表格「区域按下单地址」任务是本任务的口径不是习惯放会话层 → 污染同会话另一个任务报告模板偏好用户跨会话稳定用户可改放会话层 → 每次都要重说一遍当前步骤与已用预算任务与任务状态机同生共死放会话层 → 两个任务互相覆盖上一轮的工具返回任务只对当前任务有意义放用户层 → 永久保留无用数据用户的时区与语言用户长期稳定放会话层 → 每次重新问澄清出来的临时口径任务它是本次的约定放用户层 → 变成永久规则会话金丝雀会话用于隔离验证跨层复用 → 验证失去意义二最常见的一次放错把「澄清出来的临时口径」写进用户层。它看起来很合理用户已经说过一次了下次就不用再问体验更好。但它的实际效果是一次特殊场景下的选择变成了以后所有任务的默认值而用户完全不知道这件事发生过也不知道该去哪里改掉它。本专栏的长期记忆一篇会展开这条边界。三一个可执行的检查给每一个写入记忆的调用点加一个必填参数层。然后做两件事在代码评审清单里加一条「这个写入的层对吗」把「按层统计的写入量」做成指标。用户层的写入量应当远小于会话层与任务层。如果三者量级接近基本可以确定有信息放错了层——而且几乎总是往上放错。四、作用域键串线的唯一防线一键的六段构成dataclass(frozenTrue, slotsTrue) 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_asciiFalse, sort_keysTrue, defaultstr) 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 的键ttenant-a|uu-1001|ss-1|pretrieve|h3320eaccf89ed9e3 B 的键ttenant-b|uu-2002|ss-9|pretrieve|h3320eaccf89ed9e3注意后两者的哈希部分仍然相同——这是对的输入确实一样。真正起隔离作用的是前面那几段。三为什么作用域要拼在前缀把作用域拼在键的前缀而不是后缀有一个附带好处按前缀扫描就能列出或删除某个租户的全部条目。这一点在缓存层尤其重要因为大多数缓存系统只支持前缀扫描不支持任意字段查询。五、作用域键必须出现在六个地方漏掉任何一处前面五处的隔离都白做。一六个位置1、缓存键。检索结果、重排结果、模型响应、工具返回。最常见的漏点是「同样的问题何必再算一遍」。2、锁键。会话级串行化的锁。漏点是用任务 ID 做锁键于是同会话的两个任务可以并发改同一份会话记录。3、限流键。每租户、每用户的配额。漏点是只按 IP 或只按接口限流一个大租户可以打满全局配额。4、检索过滤。向量库与全文索引的过滤条件。漏点是靠「索引里只有本租户数据」这一假设——多租户共享索引时这个假设随时会被打破。5、日志字段。每条日志都带脱敏后的作用域。漏点是只记request_id出问题时无法回答「这条日志属于谁」。def redacted(self) - str: 日志用保留可定位性去掉可识别性。 h hashlib.sha256(self.user.encode()).hexdigest()[:8] return ft{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 过期后写入被拒会话已过期ttenant-a|u#1bee97ac|ss-6001|kT-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(slotsTrue) 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 fCANARY-{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_asciiFalse)金丝雀该挂在三个位置覆盖面依次扩大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: 对第二种里那句「对」不会被误解成对工单排序的确认。按租户删除删除前的全部键 ttenant-a|uu-0|ss-a0 ttenant-a|uu-1|ss-a1 ttenant-a|uu-2|ss-a2 ttenant-b|uu-0|ss-b0 ttenant-b|uu-1|ss-b1 删除 tenant-a3 个会话金丝雀验证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(targetw, 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、流式、取消见第八部分《服务化 AgentAPI 契约、任务 ID、流式响应与取消》。参考资料多租户与隔离Multi-tenant SaaS 架构模式AWS 白皮书页 —— 租户隔离的三档布局与各自的取舍本文第十章与之对应。Azure 架构中心多租户数据存储与分区 —— 共享表、分区与独立库三种方案的具体权衡。PostgreSQL 文档表分区 —— 按租户分区的实现方式与查询裁剪机制。PostgreSQL 文档行级安全策略 —— 在数据库层强制租户过滤可作为应用层之外的第二道防线。并发与一致性PostgreSQL 文档并发控制 —— 乐观并发与隔离级别版本 CAS 的底层依据。Redis 文档分布式锁Redlock —— 分布式锁的实现与其失效条件本文「必须假设锁会失效」的出处。How to do distributed lockingMartin Kleppmann —— 对分布式锁安全性的批评性分析说明为什么需要 CAS 兜底。Idempotency-Key HTTP 头草案IETF —— 请求去重键的标准化表达。时序数据与可变事实Bitemporal HistoryMartin Fowler —— 有效时间与记录时间的双时间线建模本文事实取代机制的完整形态。Event SourcingMartin Fowler —— 「追加而非覆盖」的一般化模式。会话管理与安全OWASP Session Management Cheat Sheet —— 会话时限、绝对过期与续期策略的安全实践。OWASP Logging Cheat Sheet —— 日志中不应出现的内容与脱敏要求。GDPR 官方文本 —— 存储限制与删除权本文「删除是一条跨系统编排」的合规依据。Agent 记忆设计Effective context engineering for AI agentsAnthropic 工程博客 —— 上下文与记忆的边界讨论。A practical guide to building agentsOpenAI —— 会话与任务状态的组织建议。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询