Agent 记忆与状态管理Agent 记忆与状态管理
区分运行状态、工作记忆、会话记忆与长期记忆,系统设计幂等写入、TTL、PII 和租户隔离。区分运行状态、工作记忆、会话记忆与长期记忆,系统设计幂等写入、TTL、PII 和租户隔离。
专题导读
“Agent 记忆”不是一个单一数据库,也不等于把聊天记录全部向量化。Java 后端面试首先要区分两类责任:运行状态用于可靠推进和恢复当前任务;记忆用于给当前或未来决策提供上下文。前者要求明确状态机、版本和精确恢复,后者允许相关性召回,却必须接受信息可能过期、冲突或不完整。
记忆还要按生命周期分层:working memory 服务当前一次推理/任务;session memory 维持同一会话连续性;long-term memory 跨会话保存经治理的信息。episodic(事件经历)、semantic(稳定事实)和 procedural(流程/技能)是长期记忆的内容类型,不是另外三种存储产品。任何分类都必须落到数据模型、来源、权限、TTL、纠错与删除。
知识地图
输入 → 可信身份/租户 → 读取运行 checkpoint → 装配 working memory → 召回 session/long-term 候选 → 权限与时效过滤 → 排序/冲突处理 → 模型使用 → 产生记忆候选 → 写入策略/同意/去重 → 持久化与索引 → TTL/纠错/删除
四个概念必须分清:
- 运行状态(runtime state):run、step、计划版本、预算、审批、工具操作状态;是恢复控制面的事实来源。
- 工作记忆(working memory):当前一步真正需要的目标、证据和中间结果;容量有限、可裁剪,通常是派生上下文。
- 会话记忆(session memory):同一 session 的近期消息、摘要和临时偏好;一般有短 TTL,不应默认跨会话。
- 长期记忆(long-term memory):经确认且对未来有价值的事实、偏好或经验;需要 provenance、版本、访问范围和删除能力。
编号面试题
Q1:运行状态、working memory、session memory、long-term memory 有什么区别?
核心回答
运行状态回答“任务执行到哪里、能否安全继续”;working memory 回答“当前一步需要看到什么”;session memory 回答“本会话前面发生了什么”;long-term memory 回答“跨会话后仍值得保留什么”。它们的一致性、生命周期和失败后果不同,不能共用一个模糊的 memory 表。
深入解释
运行状态必须精确,例如取消订单步骤是 PENDING、COMMITTED 还是 UNKNOWN;相似度召回不能承担这种判断。working memory 可以由 checkpoint、用户输入和检索结果重新装配,超出上下文预算时可裁剪。session memory 通常绑定 sessionId 并设置短期过期。long-term memory 应经过写入门槛,绑定主体、来源和用途。
episodic/semantic/procedural 是内容语义:一次“用户上次选择中文”可视为 episodic;经确认的“用户偏好中文”是 semantic;“诊断 Java 线程池耗尽的步骤”接近 procedural。程序性内容可能影响工具行为,风险更高,不能从一次模型输出自动升级为可执行规则。
面试追问:聊天历史属于哪一层?
原始近期消息通常属于 session memory;本轮选中的少量消息进入 working memory。若未经确认,不应自动成为长期事实。
常见错误回答:“短期记忆放 Redis,长期记忆放向量库。”生命周期是语义要求,不由产品名定义;关系库也能做 TTL,向量库也不等于长期事实源。
Q2:为什么运行状态不能放进向量库按相似度恢复?
核心回答
任务恢复需要按 runId 精确读取最新版本、执行条件更新和判断状态不变量;向量相似检索返回的是近似候选,无法证明“这就是最新 checkpoint”,也通常不提供领域事务与唯一约束。
深入解释
运行状态至少包含 runId、version、status、planVersion、当前 step、已知副作用结果、预算和待审批项。恢复时必须拒绝旧版本覆盖新版本,确保终态不可重新执行。关系数据库、事件历史或持久化工作流更适合作为 source of truth;Redis 可以做协调或状态存储,但需明确持久化、复制、淘汰和恢复目标。
“副作用前后各写一次 checkpoint”仍不能消除远端提交成功、完成 checkpoint 未落库的崩溃窗口。必须配合下游幂等键、操作状态查询、outbox/saga 或人工对账,不能宣称 exactly-once。
面试追问:Redis 能否保存运行状态?
可以,但要基于 RPO/RTO、持久化、复制、故障切换、淘汰策略和审计要求评估;不能把缓存默认当唯一恢复源。
常见错误回答:“向量库也是数据库,所以能保存所有状态。”存得下不代表满足精确读取、一致性和恢复语义。
Q3:运行状态如何处理并发更新和重复事件?
核心回答
使用单写者、数据库行锁或 version 乐观锁控制状态迁移;每个外部事件和副作用使用稳定幂等键。更新条件必须包含当前状态与版本,受影响行数为零时重新读取,而不是覆盖写。
深入解释
例如只允许 RUNNING(version=7) 更新为 WAITING_APPROVAL(version=8)。两个 worker 同时提交时只有一个成功,另一个读取新状态后决定停止或重新计算。事件消费记录 eventId,副作用记录 operationId,两者不是同一个概念:重复事件可以不产生新状态,重复副作用必须由执行端返回首次结果。
检查点数据量应受控。大工具结果放对象存储并保存不可变引用和摘要,状态表只保留控制字段。状态历史用于审计,但当前快照用于高效推进。
面试追问:乐观锁失败是否直接重试原写入?
不应盲重试;先读取最新状态并重新判断迁移是否仍合法,否则可能覆盖取消或审批结果。
常见错误回答:“Kafka 分区有序,所以不会并发。”重平衡、重试、人工补偿和其他入口仍会带来重复与并发。
Q4:working memory 怎样受上下文窗口和成本约束?
核心回答
working memory 是一次决策的受限工作集,应按安全指令、当前目标、已确认状态、必要证据、相关记忆的优先级装配,并为输出预留 Token。不能把所有历史和检索候选无界追加。
深入解释
每一项应带来源、时间、类型、估算 Token、可信度和是否可裁剪。优先保留业务不变量和当前步骤证据,删除重复、过期和低相关内容。摘要可以降低成本,但属于派生数据:摘要可能丢失否定词、数字和权限条件,需要关联原文版本并可失效。
working memory 通常不需要单独长期持久化;为了复现可以保存装配清单、来源引用和 prompt 版本,而不是把模型内部隐藏推理当状态。上下文越长不一定越好,还会增加延迟、成本和干扰。
面试追问:如何选择裁剪顺序?
先保护系统策略、当前目标和权威证据,再按相关性、时效和冗余裁剪历史;具体顺序应由任务级评估验证。
常见错误回答:“模型上下文很大,所以全部塞进去最准确。”长上下文不能消除注意力稀释、冲突和成本。
Q5:session memory 的 TTL 应如何设计?
核心回答
同时考虑空闲 TTL 和绝对生命周期:空闲 TTL 可在活跃会话续期,绝对过期时间限制最长保留。TTL 是数据保留策略的一部分,不是完整删除、合规和权限方案。
深入解释
会话键应至少作用域化为 tenantId + subjectId + sessionId,防止 ID 碰撞和跨租户读取。续期需明确哪些事件算“活跃”,避免后台轮询无限延长。Redis EXPIRE 设置 key 超时的命令复杂度为 O(1),但应用仍需考虑复制、持久化、淘汰和过期时机;不能把 TTL 当严格到毫秒的删除 SLA。
会话摘要和原消息可能有不同保留期。用户结束会话、撤回同意或注销时,可触发显式删除,不必等待 TTL。涉及审计法定义务的数据应进入独立、受控的审计域,而不是借 session memory 长期保留。
面试追问:滑动 TTL 会有什么风险?
活跃会话可能永久存在;应加绝对上限,并防止非用户活动错误续期。
常见错误回答:“设置 30 天 TTL 就满足删除要求。”缓存、索引、备份和日志仍可能保留副本,也缺少删除完成证据。
Q6:哪些信息值得写入 long-term memory?
核心回答
只有对未来任务稳定有用、有合法用途和保存权限、来源可追溯、用户可查看/纠正/删除的信息才应写入。模型可以提出候选,但不能以“我觉得重要”为唯一写入依据。
深入解释
写入策略可以分级:用户显式声明的偏好经确认后保存;从多次行为推断的偏好先标为低置信候选;身份、财务、医疗、密钥等敏感内容默认拒绝或进入更严格流程。每条记录至少包含 memoryId、tenant、subject、kind、content、source、sourceVersion、confidence、createdAt、expiresAt、sensitivity、consent、version 和删除状态。
长期记忆不是事实真相。用户偏好会变化,订单状态会失效,模型摘要会出错。重要事实应回链权威系统;记忆适合提示“可能相关”,不应替代订单库、配置中心或身份系统。
面试追问:用户说“记住我的 API Key”怎么办?
普通 Agent memory 不应保存密钥;引导使用专门的 Secret Manager,并避免密钥进入 prompt、日志和向量索引。
常见错误回答:“只要用户说记住,就可以永久保存。”仍需用途限制、敏感分类、保留期和产品政策。
Q7:长期记忆如何做到幂等写入、去重和版本化?
核心回答
每次写入请求带作用域化 requestId,存储层用唯一约束返回首次结果;对同一语义主体使用稳定 key 或去重指纹,更新采用版本检查,并保留来源与变更历史。向量相似只能辅助找候选,不能单独决定合并。
深入解释
网络重试可能重复提交“偏好语言=中文”。tenant + subject + requestId 防止同一请求重复插入;tenant + subject + memoryType + canonicalKey 可定位当前事实。新旧内容冲突时,不应静默覆盖:可以提高版本、标记 superseded、保留冲突,或要求用户确认。
幂等保留期不能短于消息最大重放窗口。相同请求 id 携带不同内容应拒绝,避免参数漂移。写数据库与更新向量索引无法天然原子时,可用 outbox 发送索引事件;召回只返回已发布版本,删除也要传播 tombstone。
面试追问:内容 hash 相同就算重复吗?
不一定。不同主体、来源、用途或有效期即使文本相同也不是同一记录;hash 必须与业务作用域结合。
常见错误回答:“用 embedding 相似度大于 0.9 就覆盖旧记忆。”阈值不能证明语义等价,也可能合并否定或数值差异。
Q8:记忆召回如何处理相关性、时效、来源和冲突?
核心回答
先做 tenant/subject/权限/删除/过期的硬过滤,再按任务相关性、来源可信度、时间衰减和确认状态排序。召回结果必须带 provenance,并把冲突显式交给规则、权威系统或用户处理。
深入解释
安全过滤必须在候选生成阶段生效,不能先跨租户向量检索后只靠模型过滤。混合召回可使用结构化 key、关键词和向量相似生成候选,再 rerank;top-K 控制上下文成本。相似度不是事实置信度,近期也不一定覆盖权威来源。
冲突例子:“偏好中文”与“本次请用英文”并不一定矛盾,后者是当前任务约束,优先级更高;“常住城市 A/B”则需要版本和来源判断。最终 prompt 应标明“记忆可能过期,请勿当授权依据”。
面试追问:召回分数能跨模型版本比较吗?
通常不能直接比较;embedding/reranker 升级需版本化索引、离线评估和迁移策略。
常见错误回答:“最相似的记忆就是最真实的。”相似度只衡量表示空间接近程度。
Q9:如何保护记忆中的 PII 和敏感信息?
核心回答
从数据最小化开始:不需要就不收集,不应进入记忆就入口拦截。确需保存时进行分类、用途限制、字段级访问控制、传输/静态加密、密钥隔离、脱敏日志、保留期和访问审计。
深入解释
PII 不只包括身份证号,还可能通过姓名、地址、设备标识和行为组合识别个人。原文、摘要、embedding、缓存、日志和离线评估集都可能是派生副本。embedding 不是匿名化;它仍来自个人数据,也可能泄漏属性或被关联。
检索服务只返回完成当前任务所需字段;模型提供商、区域和数据保留设置也要纳入数据流评审。测试使用合成数据。访问日志本身也可能含查询词和 memory id,需要最小化和权限隔离。
面试追问:加密后是否可以无限期保存 PII?
不可以。加密降低泄露风险,不替代用途、同意、保留期和删除义务。
常见错误回答:“做了 embedding 就看不到原文,所以不是敏感数据。”表示转换不等于去标识化或不可逆匿名化。
Q10:多租户记忆系统怎样实现真正的租户隔离?
核心回答
租户标识必须来自可信身份上下文,并贯穿主键、查询条件、索引 namespace、缓存键、幂等键、对象存储路径、队列消息和审计。对高隔离要求场景,进一步采用独立 schema、数据库、索引或加密密钥。
深入解释
仅在应用末尾过滤结果不够:候选检索、计数、错误信息和缓存都可能泄露存在性。数据访问层应强制 tenant 条件,数据库可配合行级安全或独立账户;向量索引必须在检索前限定 namespace/metadata filter,并验证底层实现的过滤语义。
后台任务、离线重建和删除任务同样需要 tenant context。运维账号应走受审计的 break-glass 流程。测试要包含交叉租户 ID 碰撞、缓存污染、批量导出和索引重建场景。
面试追问:UUID 足够随机,是否可以不加 tenant 条件?
不可以。不可猜测性不是授权,泄漏或日志暴露 ID 后仍会越权。
常见错误回答:“每条记录有 tenantId 字段就完成隔离。”还必须确保所有读写、缓存、索引和异步路径都强制使用它。
Q11:TTL、用户删除、索引删除和备份保留如何协调?
核心回答
TTL 负责到期不可再用于在线召回;用户删除需要显式 tombstone、立即阻断读取、异步清理主存储/缓存/索引并记录完成状态;备份通常按既定保留期隔离保存,恢复后必须重新应用删除日志。三者不是同一个动作。
深入解释
删除流程可建模为 REQUESTED → BLOCKED → PURGING → COMPLETED/FAILED。先写 tombstone,保证旧索引消息到达也不能重新发布;再清理派生副本。每个处理器幂等,以 deletionId 记录结果。失败进入重试与告警,用户侧展示真实状态而不是提前宣称物理清除完成。
不可变备份往往不能即时逐条删除,应限制访问和保留期,并确保恢复演练会重放 tombstone。具体法律义务取决于业务地区和政策,系统设计应与隐私/法务要求对齐,而不是自行给出统一期限。
面试追问:为什么不直接物理删除主表?
可先物理删除,但 tombstone/删除日志有助于阻止缓存、索引和重放事件“复活”数据,并提供完成证据。
常见错误回答:“Redis key 过期后所有副本都会同步消失。”索引、日志、备份和下游副本有独立生命周期。
Q12:如何用 Java 21 实现一个带租户隔离、TTL、幂等和删除状态的最小记忆库?
核心回答
下面示例只使用 Java 21 标准库,展示长期/会话记忆的治理边界。它故意不保存运行 checkpoint,以强调两者应使用不同接口和一致性模型。代码中的 100 条召回上限、4,000 个 UTF-16 code unit 和 365 天 TTL 都只是演示策略,不是行业标准;生产值必须由上下文预算、容量评测、敏感级别和组织保留政策配置。生产环境应替换为事务数据库、受控缓存和索引服务,并对敏感内容加密或令牌化。
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
public final class GovernedMemoryStore {
enum Kind { SESSION, EPISODIC, SEMANTIC, PROCEDURAL }
enum Sensitivity { PUBLIC, INTERNAL, PII, SECRET }
enum Status { ACTIVE, SUPERSEDED, DELETED }
record Scope(String tenantId, String subjectId) {
Scope {
Objects.requireNonNull(tenantId);
Objects.requireNonNull(subjectId);
if (tenantId.isBlank() || subjectId.isBlank()) {
throw new IllegalArgumentException("scope parts must not be blank");
}
}
}
// 由认证/授权层创建;业务调用方不能自行提升 allowedSensitivities。
record AccessContext(Scope scope, Set<Sensitivity> allowedSensitivities) {
AccessContext {
Objects.requireNonNull(scope);
allowedSensitivities = Set.copyOf(allowedSensitivities);
}
}
record WriteCommand(
String requestId,
Kind kind,
String canonicalKey,
String content,
Set<String> tags,
String source,
Sensitivity sensitivity,
boolean consentGranted,
Duration ttl) {
WriteCommand {
Objects.requireNonNull(requestId);
Objects.requireNonNull(kind);
Objects.requireNonNull(canonicalKey);
Objects.requireNonNull(content);
tags = Set.copyOf(tags);
Objects.requireNonNull(source);
Objects.requireNonNull(sensitivity);
Objects.requireNonNull(ttl);
}
}
record Memory(
UUID memoryId,
Scope scope,
Kind kind,
String canonicalKey,
String content,
Set<String> tags,
String source,
Sensitivity sensitivity,
Instant createdAt,
Instant expiresAt,
long version,
Status status) {
Memory {
tags = Set.copyOf(tags);
}
boolean visibleAt(Instant now) {
return status == Status.ACTIVE && now.isBefore(expiresAt);
}
}
record ScoredMemory(Memory memory, double score) {}
private record RequestKey(Scope scope, String requestId) {}
private record RequestEntry(WriteCommand command, UUID memoryId) {}
private record CanonicalKey(Scope scope, Kind kind, String canonicalKey) {}
private final Clock clock;
private final Map<UUID, Memory> byId = new HashMap<>();
private final Map<RequestKey, RequestEntry> requestIndex = new HashMap<>();
private final Map<CanonicalKey, UUID> canonicalIndex = new HashMap<>();
public GovernedMemoryStore(Clock clock) {
this.clock = Objects.requireNonNull(clock);
}
public synchronized Memory write(Scope scope, WriteCommand command) {
validateWrite(command);
RequestKey requestKey = new RequestKey(scope, command.requestId());
RequestEntry previous = requestIndex.get(requestKey);
if (previous != null) {
if (!previous.command().equals(command)) {
throw new IllegalArgumentException("requestId reused with different content");
}
return requireMemory(previous.memoryId());
}
Instant now = clock.instant();
CanonicalKey canonical = new CanonicalKey(scope, command.kind(), command.canonicalKey());
UUID oldId = canonicalIndex.get(canonical);
long nextVersion = 1;
if (oldId != null) {
Memory old = requireMemory(oldId);
nextVersion = old.version() + 1;
if (old.status() == Status.ACTIVE) {
byId.put(oldId, withStatus(old, Status.SUPERSEDED, old.version()));
}
}
Memory memory = new Memory(
UUID.randomUUID(), scope, command.kind(), command.canonicalKey(),
command.content(), command.tags(), command.source(), command.sensitivity(),
now, now.plus(command.ttl()), nextVersion, Status.ACTIVE);
byId.put(memory.memoryId(), memory);
requestIndex.put(requestKey, new RequestEntry(command, memory.memoryId()));
canonicalIndex.put(canonical, memory.memoryId());
return memory;
}
public synchronized List<ScoredMemory> recall(
AccessContext access, Set<String> queryTags, int limit) {
if (limit <= 0 || limit > 100) throw new IllegalArgumentException("invalid limit");
Instant now = clock.instant();
List<ScoredMemory> candidates = new ArrayList<>();
for (Memory memory : byId.values()) {
if (!memory.scope().equals(access.scope()) || !memory.visibleAt(now)) continue;
if (!access.allowedSensitivities().contains(memory.sensitivity())) continue;
if (memory.sensitivity() == Sensitivity.SECRET) continue;
Set<String> overlap = new HashSet<>(memory.tags());
overlap.retainAll(queryTags);
if (queryTags.isEmpty() || !overlap.isEmpty()) {
long ageDays = Math.max(0, Duration.between(memory.createdAt(), now).toDays());
double recency = 1.0 / (1.0 + ageDays);
double score = overlap.size() * 10.0 + recency;
candidates.add(new ScoredMemory(memory, score));
}
}
return candidates.stream()
.sorted(Comparator.comparingDouble(ScoredMemory::score).reversed()
.thenComparing(item -> item.memory().memoryId()))
.limit(limit)
.toList();
}
public synchronized Optional<Memory> delete(Scope scope, UUID memoryId) {
Memory current = byId.get(memoryId);
if (current == null || !current.scope().equals(scope)) return Optional.empty();
if (current.status() == Status.DELETED) return Optional.of(current);
Memory deleted = withStatus(current, Status.DELETED, current.version() + 1);
byId.put(memoryId, deleted); // tombstone 立即阻断 recall。
return Optional.of(deleted);
}
private static Memory withStatus(Memory current, Status status, long version) {
return new Memory(
current.memoryId(), current.scope(), current.kind(), current.canonicalKey(),
current.content(), current.tags(), current.source(), current.sensitivity(),
current.createdAt(), current.expiresAt(), version, status);
}
private static void validateWrite(WriteCommand command) {
if (command.requestId().isBlank() || command.requestId().length() > 100) {
throw new IllegalArgumentException("invalid requestId");
}
if (command.canonicalKey().isBlank() || command.canonicalKey().length() > 100) {
throw new IllegalArgumentException("invalid canonicalKey");
}
if (command.content().isBlank() || command.content().length() > 4_000) {
throw new IllegalArgumentException("invalid content length");
}
if (command.ttl().isZero() || command.ttl().isNegative()
|| command.ttl().compareTo(Duration.ofDays(365)) > 0) {
throw new IllegalArgumentException("ttl must be in (0, 365 days]");
}
if ((command.sensitivity() == Sensitivity.PII
|| command.kind() != Kind.SESSION) && !command.consentGranted()) {
throw new IllegalArgumentException("explicit consent is required");
}
if (command.sensitivity() == Sensitivity.SECRET) {
throw new IllegalArgumentException("secrets must use a dedicated secret manager");
}
}
private Memory requireMemory(UUID memoryId) {
Memory memory = byId.get(memoryId);
if (memory == null) throw new IllegalStateException("corrupt memory index");
return memory;
}
public static void main(String[] args) {
var store = new GovernedMemoryStore(Clock.systemUTC());
var scope = new Scope("tenant-a", "user-42");
var access = new AccessContext(scope, Set.of(Sensitivity.PUBLIC, Sensitivity.INTERNAL));
var command = new WriteCommand(
"request-1001", Kind.SEMANTIC, "preferred-language", "中文",
Set.of("preference", "language"), "user-confirmed", Sensitivity.INTERNAL,
true, Duration.ofDays(90));
Memory first = store.write(scope, command);
Memory duplicate = store.write(scope, command);
System.out.println(first.memoryId().equals(duplicate.memoryId())); // true
System.out.println(store.recall(access, Set.of("language"), 5));
store.delete(scope, first.memoryId());
System.out.println(store.recall(access, Set.of("language"), 5)); // []
}
}示例写入和按 id 删除的 Map 操作平均为 O(1);设记录数为 n、候选数为 m、扫描中处理的标签总数为 G,召回与标签求交约为 O(n + G),候选排序为 O(m log m),额外空间为 O(m)。结构化复合键避免租户/主体字符串拼接碰撞;同 requestId 的命令不一致会被拒绝,canonical 更新会把旧版本标为 SUPERSEDED。生产系统可只保存规范化请求摘要而非重复敏感原文,并使用结构化索引或 ANN 缩小候选,但 tenant/权限/TTL/tombstone 硬过滤仍必须正确。后台还需幂等清理内容、缓存、向量索引和备份恢复链路。
面试追问:为什么
canonicalIndex更新还不够?真实系统要用数据库唯一约束和事务解决多实例竞争,并用 outbox 同步搜索/向量索引;单 JVM
synchronized只用于说明语义。常见错误回答:“把这个 Map 换成 ConcurrentHashMap 就能上生产。”它仍缺少持久化、跨实例事务、加密、审计和删除传播。
Q13:关系数据库、Redis、向量库应如何组合?
核心回答
按责任组合,而不是选一个“Agent 数据库”:关系数据库适合权威元数据、版本、同意、删除和幂等;Redis 适合短 TTL 会话、缓存和协调;向量库适合语义候选检索。向量索引通常是可重建派生物,不应成为权限和删除状态的唯一事实源。
深入解释
常见写路径是:事务库写 memory 与 outbox → 异步索引 → 标记 published。读路径先带 tenant/subject 条件检索候选,再回源数据库验证 active/version/permission。删除先写 tombstone,再发布删除事件清缓存和索引。Redis key 需要 namespace、TTL 和淘汰策略;缓存 miss 只是回源,不代表记录不存在。
选型要回答 RPO/RTO、并发写、查询模式、索引延迟、数据规模、租户隔离、加密和删除 SLA。所谓“长期”描述保留语义,不代表必须放慢存储;所谓“working”也不代表只能在 JVM 堆内。
面试追问:索引延迟期间怎样避免读到旧记忆?
候选回源校验版本和 tombstone;高一致性需求可暂时绕过索引或使用读己之写策略。
常见错误回答:“Redis 只用于缓存,所以不需要备份和权限。”是否需要取决于它承载的数据与恢复目标。
场景设计题:设计一个多租户 Java 面试教练的记忆系统
题目:系统需要在同一会话记住当前复习进度,跨会话保存用户确认的薄弱知识点,但不得保存用户粘贴的公司代码和密钥;企业租户之间必须隔离。请给出设计。
设计要点
- 运行状态:练习 run、题号、评分版本和提交状态进入关系库 checkpoint,以
version乐观锁推进。 - working memory:本题、评分 rubric、近期错误和必要知识片段按 Token 预算装配,不持久化隐藏思维链。
- session memory:Redis key 使用
tenant:user:session,配置空闲 TTL 与绝对上限;结束会话显式删除。 - 长期记忆:只保存用户确认的“并发基础薄弱”等抽象结论,记录来源练习、置信度、版本、TTL 和 consent。
- 入口分类:检测并阻止 secret;公司代码默认不进入长期 memory、日志和评估集,临时处理也设置短保留。
- 租户隔离:Principal 注入 tenant;数据库查询、缓存、向量 namespace、对象路径、队列和幂等键全链路带 tenant。
- 写入一致性:
tenant + user + requestId去重;数据库事务写 memory/outbox,异步建索引,回源验证当前版本。 - 召回:先 subject/权限/TTL 硬过滤,再按相关性、来源和时间排序;当前用户指令覆盖旧偏好。
- 纠错删除:用户可查看、修正和删除;tombstone 立即阻断召回,后台清缓存/索引并跟踪完成。
- 指标:召回命中、用户纠正、过期引用、跨租户拒绝、PII/secret 拦截、删除完成时间和索引延迟。
故障恢复与排障清单
- worker 重启后重复副作用:不要从聊天重放;读取 checkpoint,按 operationId 查询下游并依赖执行端幂等。
- 会话突然丢失:检查 Redis 淘汰、TTL 续期、主从切换和持久化;产品需明确 session 丢失时的降级。
- 旧记忆“复活”:检查 tombstone 是否先于索引删除、旧 outbox 是否能覆盖新版本、恢复备份后是否重放删除日志。
- 跨租户召回:立即停用相关索引路径;检查 namespace、metadata filter、缓存键、批处理和回源 SQL。
- 重复写入大量偏好:检查 requestId 保留期、canonical key、消息重放与同键不同参数拒绝。
- 摘要与原文矛盾:使摘要绑定 sourceVersion;原文更新/删除时失效并重建,关键字段回源验证。
- TTL 到期仍可搜索:召回时以权威
expiresAt硬过滤,不能只等待后台物理删除索引。 - 删除长时间未完成:查看各副本处理器状态、重试与死信;用户侧区分“已阻断使用”和“物理清理完成”。
- PII 出现在 trace:停止不必要采集、限制访问与保留、评估通知义务,并修复日志字段白名单。
- 召回质量下降:按 embedding/index/reranker/prompt 版本分解,检查过期、冲突与索引延迟,不要只调 top-K。
速记总结
- runtime state 是控制事实,memory 是决策上下文;两者不能混为一谈。
- working、session、long-term 按生命周期分层;episodic、semantic、procedural 是内容类型。
- checkpoint 要精确读取、版本控制和可恢复,不能由向量相似检索承担。
- session 同时考虑空闲 TTL 与绝对上限;TTL 不等于完整删除。
- long-term 写入需要稳定价值、来源、权限、同意、版本和可删除性。
- 模型只提出记忆候选,不能自行决定永久保存敏感信息。
- 幂等键必须作用域化;同键不同内容拒绝,索引用 outbox 同步。
- 召回先做租户、主体、权限、TTL 和 tombstone 硬过滤,再做相关性排序。
- embedding 不是匿名化,也不是事实置信度。
- 租户隔离要贯穿数据库、缓存、索引、对象存储、队列和审计。
- 删除采用 tombstone + 派生副本清理 + 完成记录,备份恢复后重放删除。
- 关系库、Redis、向量库各司其职,不存在万能的“Agent memory database”。