第5篇·狻猊·静坐善思:数字人为什么总是记不住? 狻猊善坐Agent 连续对话的记忆分层与会话隔离会话不是记忆缓存不是存储一个 ID 越界三层数据全错。[读者] 正把 Demo 级 Agent 推进到多用户生产的后端架构师 [痛点] 上下文越塞越乱session/thread/user 三个 ID 混用一个值 [现在读] 多租户上线前记忆边界必须先于功能划定 [读完] 能落地一套可隔离、可压缩、可删除的记忆分层[旧方案] 一个 session_id 串起全部对话messages 全量回灌模型 | v [新需求] 多用户、多会话、跨会话偏好、上下文窗口有硬上限 | v [冲突] 短期状态与长期事实混在同一份数据里隔离与删除无从下手 | v [后果] 串话、超窗、删不干净、成本随轮数线性上涨我是老李做 Agent 平台后端。去年我们给一个工业设备售后团队做智能客服助手取名「狻猊」取的是龙生九子好坐而稳的意思——我们希望它坐在一个用户身边稳稳地把一段长对话接住。第一版演示很成功销售当场就签了试用单我也天真地以为这事儿的难点在模型选型上。真正的难题在三个月后浮现。试用期从三个坐席扩到三十个坐席再从三十个坐席扩到三十家客户每一家客户又有多个自己的坐席。同一份代码、同一套数据库、同一个模型唯一变的是「谁在说话」这一维度的基数。这时我们才发现代码里那个被反复传递的session_id其实同时承担了三个含义它是路由键是状态键还是权限键。狻猊善坐善的是长时间保持一个姿势。而一个会记住事情的 Agent要善的却是另一件事知道什么该留在座位上什么该换个座位什么该被请出去。短期会话状态和长期用户事实凭什么要塞进同一个session_id是继续在那个字段上加user_id、加tenant_id一路补丁下去还是干脆按生命周期把记忆切成两层如果这层边界画错了等到某个客户发来「请删除我的全部数据」时你要删的到底是哪几张表、哪一段 JSON、哪一份向量索引会话初见长上下文渐重一人变千人旧约定失真01、故事先把现场说清楚。狻猊的整体结构是一个 LangGraph 图一个入口节点做意图识别一个检索节点查设备手册一个模型节点出回复。图本身没问题问题出在图的编译参数上——我们给它挂了一个内存版 checkpointer然后每次请求都从 HTTP 头里取session_id直接塞进configurable.thread_id。任务是让同一个坐席在同一段对话里被连续记住换一段对话不串味换一个坐席不串人。第一周这个方案跑得非常好。前端每开一个聊天窗口生成一个session_id后端用它当 threadLangGraph 的 checkpointer 自动把每一轮的 messages 存下来下一轮再取出来。开发体验顺滑到让人产生幻觉记忆这件事好像不用设计了框架替我做了。变化是从两个方向同时来的。一个方向是「跨会话」。客户说我上周就跟你们说过我是华东区的怎么每次开新窗口都要重新报一遍工号这不是 bug这是产品需求——用户认为「我是谁」这件事不该随着窗口关闭而消失。可是它存在哪它显然不该存在某个 thread 的 messages 里因为 thread 一关它就没了它也不该无限复制进每个 thread因为那就成了冗余事实源。另一个方向是「多租户」。当三十家客户共用一套服务时session_id由前端生成这个设计瞬间变成了安全漏洞只要客户端愿意它可以传一个别人的session_id上来而我们的服务端会因为「它就是一个字符串」而欣然接受。于是同一个字段同时被要求承担三件事找到正确的对话状态、找到正确的用户事实、判断这个人有没有权限动这份数据。三件事的生命周期完全不同却被绑死在一个字符串上。狻猊坐得再稳脚下的椅子已经开始晃了。一坐定其位一举见其形一言定其界一事见其真02、问题旧方案失效的方式很具体我们把「记忆」当成「日志」在处理。日志的默认行为是追加、全量、永不删除而记忆的默认行为应该是有界、分层、可删除。业务影响有三层。最轻的是体验层新窗口丢失用户身份用户每开一次窗口就要重新自我介绍坐席骂得最凶。中间是成本层messages 全量回灌第 40 轮的请求里带着前 39 轮的全部原文窗口很快撞上限延迟和费用一起往上走。最重的是合规层客户提出删除请求时我们的数据散落在 thread 状态、事实缓存、日志三处没人能给出一个确定的删除清单。技术表现同样有三层。第一状态边界不清短期状态这一轮聊了哪些消息、当前进行到哪一步和长期事实这个用户属于哪个区域、偏好什么语言存在同一个容器里导致「关闭会话」这个动作无法定义——关掉它事实也没了不关它状态就永远在长。第二身份边界不清user_id只在业务表里出现从来没进入过记忆层所以记忆层根本不知道「这份数据属于谁」。第三预算边界不清没有任何组件负责回答「这一轮到底能带多少 token 进去」。把这些问题收敛成可验证的完成标准我给自己定了四条其一同一个user_idsession_id连续两轮第二轮必须能复述第一轮提供的身份信息其二同一个user_id换一个session_id短期原文不可见但已沉淀的长期事实可见其三不同user_id之间在任何路径下都不得读到对方的原文或事实其四对一个user_id执行删除后按该user_id前缀检索记忆层返回必须为空。这四条里前两条是功能后两条是边界。经验告诉我功能可以后补边界补不了——因为边界一旦破了历史数据里就已经混进了不该混的东西。旧法已失灵影响可触摸标准须可验边界先立住03、原理这一篇只需要三个原理但它们都不太符合直觉。第一个原理上下文窗口是「视图」不是「存储」。模型的输入是每一轮临时渲染出来的东西它不持久、不共享、不可查询。真正的持久层有两个一个是按 thread 存档的会话状态一个是按用户存档的事实库。把它们混为一谈的直接后果是你会不自觉地用「塞更多历史」来解决「记不住」的问题而这条路线的终点必然是撞墙。反直觉判断把全量历史回灌给模型效果往往不如给它一份带优先级的摘要。因为流水账没有重要性排序模型需要在噪音里自己找线索而摘要本身就是一次信息筛选它替模型完成了「什么值得记住」的判断。第二个原理摘要压缩掉的不是信息量是可追溯性。这是一个很少被写进文档的代价。原始 messages 能回答「用户在第 7 轮说过他的工号是 A123」摘要只能回答「用户自报过工号」。当下游需要精确值时摘要救不了你。所以摘要不能替代事实抽取——事实要单独落成结构化条目摘要只负责保住对话的语义连贯。反直觉判断摘要不是「压缩存储」的手段而是「放弃溯源」的决策。你必须先接受这一点才敢把它放进链路。第三个原理多租户隔离的难点不在写入而在删除。写入时加个user_id字段谁都会做删除时你要面对的是checkpoint 表里按 thread 存的 JSON blob、事实库里按 namespace 存的键值、以及两者之间的引用关系。LangGraph 的 checkpointer 以 thread 为单位存档长期 Store 以 namespace 元组为单位存档这两套东西的删除语义天然不同——一个按 thread 删一个按 namespace 前缀删。如果你的 namespace 里没有user_id那你就永远删不干净。这三个原理合起来指向同一个设计动作先把身份键拆开再把数据按生命周期分层最后把预算和删除做成显式组件。顺序不能反。视图非常量存储非缓存多存一分险少存一分真04、架构[输入] HTTP 请求 {token, session_id, message} | v [模块] 鉴权与路由层 LangGraph 图摘要节点 / 事实检索节点 / 模型节点 | v [数据/状态] Checkpointerthread 级 messages 与摘要/ Storeuser 级偏好与业务事实 | v [处理] 预算内组装摘要 最近 K 轮原文 命中事实 | v [输出] 回复 thread 状态更新 事实写入这张图里最关键的一条线是鉴权与路由层。它负责把三个身份键从不同的来源取出来user_id来自令牌不可由客户端指定session_id来自请求代表一段对话thread_id由服务端派生通常是user_id与session_id的组合。派生这一步看似多余实际上是整个隔离体系的锚点——只要thread_id里含user_id跨用户读状态在结构上就不可能发生。第二层是双存储。Checkpointer 保存 thread 级的短期状态它解决的问题是「这一段对话进行到哪了」生命周期与对话同长。Store 保存 user 级的长期事实它解决的问题是「这个人是谁」生命周期与账号同长。两者之间不共享主键只通过user_id关联。这种「不共享」是刻意的它让两层的删除可以独立进行也让两层的演进可以独立进行。第三层是组装器。它必须在调用模型之前算出预算摘要占多少、事实占多少、最近原文占多少。LangChain 的短期记忆文档里提到的裁剪与摘要两种策略落到工程上就是这一层的一个显式函数而不是散落在各个节点里的临时判断。收益是清晰的隔离有锚点、删除有清单、成本有上限。代价同样清晰多了一次摘要调用多了一次事实检索多了一套 namespace 约定要维护而且引入了一个新的失败模式——摘要与事实可能不一致比如摘要说用户换了区域而事实库里还是旧的。适用条件也要说清楚如果产品就是单人单会话的内部工具这套分层是过度设计只有当「多用户 跨会话 有删除要求」三者同时成立时它才值得。入路由定源状态分层存事实归一处组装见边界05、实战一次先做一个最小可跑通的 V1只解决「同一段对话要连续」故意留下跨会话和隔离两个缺口。环境与依赖Python 3.11langgraph与langchain-core的具体版本随官方文档演进安装前需按当前官方文档核验。安装方式为pip install -U langgraph langchain-core。LangGraph 的 checkpointer 模块路径在不同版本间有过调整若导入失败请以官方文档中的最新路径为准。核心实现如下。为了让验证不依赖任何模型 API这里用一个规则函数代替模型节点——它只负责证明状态被持久化了不涉及模型能力。# 版本提示模块路径与参数以当前官方文档为准importrefromtypingimportAnnotated,TypedDictfromlanggraph.graphimportStateGraph,START,ENDfromlanggraph.graph.messageimportadd_messagesfromlanggraph.checkpoint.memoryimportInMemorySaverfromlangchain_core.messagesimportHumanMessage,AIMessageclassS(TypedDict):messages:Annotated[list,add_messages]defreply(state:S)-dict:textstate[messages][-1].contentif我叫什么intext:forminreversed(state[messages][:-1]):hitre.search(r我叫([\u4e00-\u9fa5A-Za-z0-9]),m.content)ifhit:return{messages:[AIMessage(content你叫hit.group(1))]}return{messages:[AIMessage(content我不知道)]}return{messages:[AIMessage(content好的记住了)]}builderStateGraph(S)builder.add_node(reply,reply)builder.add_edge(START,reply)builder.add_edge(reply,END)checkpointerInMemorySaver()graphbuilder.compile(checkpointercheckpointer)defchat(user_id:str,session_id:str,text:str)-str:thread_idf{user_id}:{session_id}cfg{configurable:{thread_id:thread_id}}outgraph.invoke({messages:[HumanMessage(contenttext)]},cfg)returnout[messages][-1].content启动与请求这段代码不需要服务进程直接在脚本里调用即可。验证分三步未在当前环境实测以下为预期结果。print(chat(u_1,s_1,我叫阿强))# 同一 thread 第一轮print(chat(u_1,s_1,我叫什么))# 同一 thread 第二轮print(chat(u_1,s_2,我叫什么))# 换 session换 thread示例输出好的记住了 你叫阿强 我不知道前两行证明 thread 级记忆成立第二轮从 checkpointer 里取回了第一轮的消息。第三行证明短期记忆的作用域是 thread 而非 user——换一个 session 就什么都读不到。这个结果不是失败它精确地暴露了 V1 的边界Chat函数只实现了「会话内连续」完全没实现「跨会话记住用户」也没有任何身份校验user_id是谁完全由调用方说了算。另外还有一个隐性缺口state[messages]会随轮数单调增长代码里没有任何裁剪或摘要跑到几十轮就是一颗定时炸弹。V1 的价值不在于它能用而在于它把这几个缺口变成了可复现的实验现象。先跑通一遍再留个缺口小步见真章大改有依据06、排查第一条诊断链关于串话。现象测试同学反馈A 坐席的会话里出现了 B 客户的公司名。怀疑checkpointer 用错了 key或者缓存串了。检查把每次请求的thread_id打出来和请求里的user_id、session_id做三元对比同时用graph.get_state(cfg)打印该 thread 当前的消息条数。证据日志里出现了thread_id u_2:s_1而发起请求的坐席是u_1。原因很直白——V1 的user_id是前端传的前端在切换客户时只重置了user_id变量没重置session_id于是服务端派生出了一个属于别人的 thread。根因身份来自客户端而不是来自令牌。thread_id里含user_id这个设计是对的但user_id的来源错了等于给了一把假钥匙。修复user_id强制从鉴权上下文解析请求体里的同名字段一律忽略同时在会话表里记录 session 的归属路由层在派生thread_id之前先校验session_id是否属于当前user_id。第二条诊断链关于超窗。现象一段对话进行到几十轮后模型调用开始报上下文长度超限同时账单上涨明显。怀疑模型变了或者单条消息特别长。检查用get_state取出该 thread 的全部 messages统计条数与总字符数并检查是否存在工具返回的超长文本被完整塞进了 state。证据state 里确实是全量历史且其中夹着若干条检索节点返回的长文档原文。根因没有预算组件。上下文长度不是一个「模型参数」而是一个「渲染结果」而 V1 里没有任何代码负责渲染。错误尝试第一反应是在节点里直接写state[messages] state[messages][-20:]。这个做法错了两次一次是它和add_messages这个 reducer 打架覆盖式赋值会在下一轮产生非预期的合并结果另一次是它砍掉的是「最老的原文」而最老的那几条里恰好包含用户的身份说明和系统约束砍完之后行为立刻漂移。正确的方向是把「裁剪」和「摘要」拆成两个显式动作裁剪只作用于原文窗口摘要负责把被裁掉的语义留住。错误尝试第二个错误尝试是把用户偏好当成一条 system 消息每轮追加进 messages。这样确实「记住」了但偏好被复制进了每一个 thread 的历史里用户改一次偏好所有历史 thread 里都留着旧值删除时更是灾难。事实必须存在 thread 之外那是 Store 的职责。现象先固定证据再说话错试要记住根因才落刀07、优化V2 只改三处每一处都对应上一条诊断链。第一处身份派生。user_id从令牌解析session_id保留在请求里但必须通过归属校验thread_id由服务端拼接为user_id:session_id。改动的原因是把权限键从不可信输入里摘出来新行为是跨用户读状态在结构上不可能发生。验证方式很直接构造一个携带他人session_id的请求期望返回 403 而不是任何对话内容。第二处长期事实入 Store。把用户的稳定属性区域、语言偏好、工号与半稳定业务事实在保设备、上次报修时间分开存放namespace 的第一段固定为user_id第二段区分事实类别。写入时机放在对话结束或事实被明确确认时而不是每轮都写。读取时按当前意图只取相关类别不整包注入。这里的具体 API——put、search、按前缀检索、语义检索所需的索引配置——需按当前官方文档核验不同版本的方法签名有差异。第三处上下文预算。在模型节点之前加一个组装函数先算预算再按「事实摘要 → 对话摘要 → 最近 K 轮原文」的优先级填充超出预算时先压缩对话摘要绝不动事实。这一步的代价是链路里多了一次摘要调用所以摘要不是每轮都做而是当原文窗口超过阈值时才触发触发后把被裁掉的部分并入已有摘要。LangChain 的长期记忆文档把这类记忆分成语义记忆、情景记忆和程序性记忆三类我们的落法是语义记忆用户是谁进 Store情景记忆这次对话发生了什么由摘要承接程序性记忆该按什么流程回答放在系统提示词里不进任何动态存储。这个分法比「重要的存起来」有用得多因为它给出了每条数据的归属答案。V2 之后前面那四条完成标准逐条可验连续两轮能复述身份换 session 后原文不可见但事实可见跨用户可以构造请求验证 403删除时按user_id前缀检索 Store 并删除对应 thread结果为空。注意删除能力在 checkpointer 侧的具体方法名与语义同样需按当前官方文档核验必要时直接按 thread 维度做后台清理。需要说明的是以上验证都是「可执行的验证方案」不是线上跑出来的效果数据。任何关于延迟、成本、命中率的结论都得在你自己的环境里测出来才算数。改在根因上不在症状边新行为可测旧毛病不返08、演进[同一输入] user_idu_1, session_ids_9, 第 41 轮消息 | --[V1] 全量 messages 回灌 / 代价成本随轮数线性涨、串话风险、删除面模糊 | --[V2] 摘要 最近 K 轮 Store 命中事实 / 代价多一次摘要调用、需处理摘要与事实不一致 | [Trade-off] 得到窗口可控、隔离有锚点、删除有清单 失去早期原文的逐字可追溯性、一次额外的链路延迟 适用边界多用户 跨会话 有删除要求三者同时成立才值得正确性上V2 严格优于 V1。因为 V1 的失败是结构性的它没有身份维度无论怎么调参都无法回答「这份数据属于谁」。V2 把user_id变成 namespace 的第一段正确性从「靠约定」变成「靠结构」。稳定性上情况要复杂一些。V1 只有一种状态链路短出错点少V2 引入了摘要节点和事实检索节点多了一个可能失败的下游也引入了摘要与事实不一致这个新问题。稳定性的账要这么算V1 的故障是「必然发生但延迟发生」跑到几十轮一定炸V2 的故障是「可能发生且随机发生」摘要漂移。前者可预测后者不可预测。工程上我用两件事来对冲后者摘要节点失败时降级为纯裁剪不做摘要事实写入前做一次显式确认避免把模型的猜测当事实。复杂度上V2 明显更高。多了一层 namespace 约定、一套删除流程、一个预算函数。这部分复杂度是买来的买的是隔离和可删除。如果产品没有多租户这笔钱不该花。成本上V1 的成本随轮数单调上升且没有上限V2 把输入侧成本压到了一个预算内的常数区间代价是每触发一次摘要多一次调用。是否划算取决于对话的平均长度——短对话里 V2 反而更贵长对话里 V2 才回本。适用范围上V1 适用于单用户内部工具、演示和验证状态机本身V2 适用于任何会被外部用户使用的产品。遗留问题也有两个一是摘要的可追溯性损失无法完全消除只能靠事实抽取把关键值捞出来二是 checkpointer 与 Store 之间没有事务极端情况下会出现「事实写了但 thread 没更新」的不一致需要用可重放的写入顺序来兜底。同输入两解各付各代价得此失彼处边界即答案09、洞见9.1 三个 ID 是三种语义不是一个字段的三种写法session_id是路由键回答「这段对话是哪一段」thread_id是状态键回答「去哪里取这份状态」user_id是权限键回答「谁有资格动这份数据」。这三者一旦合并成一个字符串由客户端传入就等于把权限交给了客户端。反直觉判断thread_id里包含user_id看似冗余实际上是整套隔离体系里最便宜的一道防线。它让「读到别人的状态」这件事在结构上不可能发生而不是依赖每个开发者记得加where条件。冗余换安全这笔买卖在任何多租户系统里都划算。9.2 摘要压缩的不是信息量是可追溯性很多人把摘要当成存储优化觉得它省了 token。它确实省了 token但真正的代价不在 token 上而在溯源能力上。原文能回答「第几轮说了什么」摘要只能回答「大概说过什么」。反直觉判断如果下游有任何一个环节需要对账、审计或精确回溯摘要就必须与事实抽取配对使用而不能单独存在。因为事实是结构化的、可校验的摘要是自由的、不可校验的。把不可校验的东西放进需要校验的链路等于埋雷。9.3 长期记忆的分层维度不是重要性是生命周期与删除边界「重要的存长期、不重要的存短期」这条经验法则在实践中几乎没有可操作性因为没人能定义什么叫重要。可操作的维度是生命周期随会话结束就该消失的进 thread随账号存在而存在的进 Store随产品版本迭代而变化的进提示词。这个维度还有一个附带好处它直接给出了删除清单。删除一个用户时你只需要问三个问题——他的 thread 有哪些、他的 namespace 有哪些、他在提示词里有没有特殊配置。前两个问题都有确定答案第三个问题本就不该存在。9.4 上下文窗口是渲染结果不是数据模型把上下文当成一个「容器」会让人不断往里塞东西把它当成一个「视图」就会自然地去问这一屏该显示什么、按什么优先级显示、超了怎么办。这个视角转换会顺手解决三件事预算、裁剪、以及「为什么模型记不住」这个伪问题。一看层级分二看删除面三看成本线判断自成章10、系统落地原来我们有什么一张会话表、一个session_id、一个内存版 checkpointer以及一个「记忆 messages」的朴素假设。这套东西能撑起演示撑不起多租户。本篇新增了什么一个身份派生层把权限键从客户端手里拿回来一个双存储结构Checkpointer 管 thread 级状态、Store 管 user 级事实一个预算组件在模型调用之前显式决定带什么进去一份删除清单让「删除某个用户的数据」从一句口号变成三个确定的操作。这一章不涉及任何新的框架能力全部是 LangGraph 与 LangChain 已有能力的重新编排。现在能做什么同一个用户在同一段对话里被连续记住换一段对话不丢身份不同用户之间读不到彼此的数据单个用户可以按user_id前缀清理干净长对话不会无限膨胀。这五件事恰好对应开篇那四条完成标准加上上下文预算。还缺什么缺一致性保障checkpointer 与 Store 之间没有事务缺摘要质量的评估手段目前只能靠抽样人工看缺事实的冲突消解策略当用户前后说了两个不同区域时谁覆盖谁没有定论缺多租户下的配额管理虽然隔离做到了但「一家客户能用多少存储」还没有约束。下一步如何演进先把删除做成一个可重放的任务而不是一次性的脚本因为删除天然需要重试与审计再把事实写入加上版本与来源标记解决冲突消解最后才考虑语义检索因为语义检索会引入向量索引这一层新的状态它同样要纳入删除清单——在没想清楚怎么删它之前不要引入它。原来有骨架今添一层皮能做的事清缺的事明白11、小结Q1 → 不能。短期状态与长期事实的生命周期完全不同共用一个键必然两头不讨好 Q2 → 分层而不是加字段Checkpointer 管 threadStore 管 user预算器管视图 Q3 → 删不干净就意味着隔离失败删除面必须在写入面之前设计 状态 → 一套可隔离、可压缩、可删除的 V2 记忆骨架附四条可验证标准回头看狻猊这个名字起得挺准。它要「善坐」就得先知道自己坐在哪把椅子上——thread_id决定椅子user_id决定谁有资格坐session_id只决定这是第几次坐下。三者分清它才坐得稳三者混淆它换个人来就要摔一跤。这套骨架的价值不在于它多先进而在于它把「记忆」从一个模糊的产品词拆成了几个可以写进代码、写进测试、写进删除脚本的工程对象。做完这一步后面无论换模型、加工具、上语义检索你都有一个可以往里挂东西的稳定结构。三问皆有答一态可交付不留虚数语只留可复现12、作业12.1 理解题为什么thread_id里要冗余包含user_id参考答案因为它把权限校验从「每个查询都要记得加条件」变成了「结构上不可能越界」。只要thread_id由服务端派生且包含user_id即使某个下游忘了做鉴权也无法构造出一个属于他人的 thread。这是一种用少量存储冗余换取安全确定性的做法特别适合多人协作、代码路径多的服务端。12.2 实战题给 V1 加上「同一用户跨 session 记住称呼」的能力且不污染短期状态。参考答案不要把称呼写进 messages也不要复制进每个 thread。正确做法是在 Store 里以user_id为 namespace 的第一段写入一条事实键可以是profile在组装上下文时按当前user_id读取这条事实插入到提示词中。要点有三写入发生在事实被明确确认时而不是每轮读取只取相关类别不整包注入删除时按user_id前缀清理即可覆盖。具体 API 签名需按当前官方文档核验。12.3 排障题某用户会话在第 30 轮后开始重复自己说过的话且响应时间明显变长如何定位参考答案先固定现象取get_state看该 thread 的 messages 条数与总字符数确认是否全量累积再检查是否存在摘要节点未被触发或阈值配置得过高然后检查被裁掉的部分是否曾被写回 state 导致重复注入。根因通常是缺少预算组件或裁剪与 reducer 冲突导致内容重复合并。修复方向是引入显式的组装函数把裁剪与摘要拆成两个独立动作并对触发阈值做可观测的埋点。注意不要用覆盖式赋值直接改 messages那会和add_messages的语义打架。12.4 架构判断题团队提出「把用户偏好直接写进系统提示词每次拼进去最省事」你是否同意参考答案分情况。如果偏好是产品级的全局配置写进提示词没问题。如果偏好是用户级的不同意。原因有三用户级偏好写进提示词意味着它在每个 thread 的历史里都被复制了一份用户改一次偏好历史数据里全是旧值删除某个用户的数据时你无法从提示词里精确摘除他的部分提示词版本管理会与用户数据耦合导致回滚困难。正确做法是用户级事实进 Store提示词只保留「如何读取事实」的指令不保留事实本身。理解见根骨实战见手感排障见耐心判断见格局13、思考这篇的核心冲突只有一句话一个字符串承担不了三种身份。它看起来是个命名问题实际上是边界问题。可复用的工程判断有三条。第一任何「记忆」系统在设计时先问删除不问写入——因为写入的代价你当场就能看见删除的代价你要等到第一次合规审查才会看见。第二把身份键从不可信输入里摘出来是成本最低的一道安全防线没有之一。第三上下文永远按视图对待视图就该有预算、有优先级、有降级路径否则它迟早会变成一个谁都不敢动的黑洞。狻猊善坐坐的是位置不是时长。一个 Agent 记住多少事并不重要重要的是它知道哪些事属于哪一段、属于哪个人、什么时候该忘掉。一界分短长一策定删留一图见取舍一役知深浅