狻猊善坐: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,后端用它当 thread,LangGraph 的 checkpointer 自动把每一轮的 messages 存下来,下一轮再取出来。开发体验顺滑到让人产生幻觉:记忆这件事好像不用设计了,框架替我做了。变化是从两个方向同时来的。一个方向是「跨会话」。客户说:我上周就跟你们说过我是华东区的,怎么每次开新窗口都要重新报一遍工号?这不是 bug,这是产品需求——用户认为「我是谁」这件事不该随着窗口关闭而消失。可是它存在哪?它显然不该存在某个 thread 的 messages 里,因为 thread 一关,它就没了;它也不该无限复制进每个 thread,因为那就成了冗余事实源。另一个方向是「多租户」。当三十家客户共用一套服务时,session_id由前端生成这个设计瞬间变成了安全漏洞:只要客户端愿意,它可以传一个别人的session_id上来,而我们的服务端会因为「它就是一个字符串」而欣然接受。于是同一个字段,同时被要求承担三件事:找到正确的对话状态、找到正确的用户事实、判断这个人有没有权限动这份数据。三件事的生命周期完全不同,却被绑死在一个字符串上。狻猊坐得再稳,脚下的椅子已经开始晃了。一坐定其位一举见其形一言定其界一事见其真02、问题旧方案失效的方式很具体:我们把「记忆」当成「日志」在处理。日志的默认行为是追加、全量、永不删除;而记忆的默认行为应该是有界、分层、可删除。业务影响有三层。最轻的是体验层:新窗口丢失用户身份,用户每开一次窗口就要重新自我介绍,坐席骂得最凶。中间是成本层:messages 全量回灌,第 40 轮的请求里带着前 39 轮的全部原文,窗口很快撞上限,延迟和费用一起往上走。最重的是合规层:客户提出删除请求时,我们的数据散落在 thread 状态、事实缓存、日志三处,没人能给出一个确定的删除清单。技术表现同样有三层。第一,状态边界不清:短期状态(这一轮聊了哪些消息、当前进行到哪一步)和长期事实(这个用户属于哪个区域、偏好什么语言)存在同一个容器里,导致「关闭会话」这个动作无法定义——关掉它,事实也没了;不关它,状态就永远在长。第二,身份边界不清:user_id只在业务表里出现,从来没进入过记忆层,所以记忆层根本不知道「这份数据属于谁」。第三,预算边界不清:没有任何组件负责回答「这一轮到底能带多少 token 进去」。把这些问题收敛成可验证的完成标准,我给自己定了四条:其一,同一个user_id+session_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 [数据/状态] Checkpointer(thread 级 messages 与摘要)/ Store(user 级偏好与业务事实) | 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.11;langgraph与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:text=state['messages'][-1].contentif'我叫什么'intext:forminreversed(state['messages'][:-1]):hit=re.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='好的,记住了')]}builder=StateGraph(S)builder.add_node('reply',reply)builder.add_edge(START,'reply')builder.add_edge('reply',END)checkpointer=InMemorySaver()graph=builder.compile(checkpointer=checkpointer)defchat(user_id:str,session_id:str,text:str)->str:thread_id=f'{user_id}:{session_id}'cfg={'configurable':{'thread_id':thread_id}}out=graph.invoke({'messages':[HumanMessage(content=text)]},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_id=u_1, session_id=s_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 管 thread,Store 管 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 记住多少事并不重要,重要的是它知道哪些事属于哪一段、属于哪个人、什么时候该忘掉。一界分短长一策定删留一图见取舍一役知深浅
狻猊善坐: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,后端用它当 thread,LangGraph 的 checkpointer 自动把每一轮的 messages 存下来,下一轮再取出来。开发体验顺滑到让人产生幻觉:记忆这件事好像不用设计了,框架替我做了。变化是从两个方向同时来的。一个方向是「跨会话」。客户说:我上周就跟你们说过我是华东区的,怎么每次开新窗口都要重新报一遍工号?这不是 bug,这是产品需求——用户认为「我是谁」这件事不该随着窗口关闭而消失。可是它存在哪?它显然不该存在某个 thread 的 messages 里,因为 thread 一关,它就没了;它也不该无限复制进每个 thread,因为那就成了冗余事实源。另一个方向是「多租户」。当三十家客户共用一套服务时,session_id由前端生成这个设计瞬间变成了安全漏洞:只要客户端愿意,它可以传一个别人的session_id上来,而我们的服务端会因为「它就是一个字符串」而欣然接受。于是同一个字段,同时被要求承担三件事:找到正确的对话状态、找到正确的用户事实、判断这个人有没有权限动这份数据。三件事的生命周期完全不同,却被绑死在一个字符串上。狻猊坐得再稳,脚下的椅子已经开始晃了。一坐定其位一举见其形一言定其界一事见其真02、问题旧方案失效的方式很具体:我们把「记忆」当成「日志」在处理。日志的默认行为是追加、全量、永不删除;而记忆的默认行为应该是有界、分层、可删除。业务影响有三层。最轻的是体验层:新窗口丢失用户身份,用户每开一次窗口就要重新自我介绍,坐席骂得最凶。中间是成本层:messages 全量回灌,第 40 轮的请求里带着前 39 轮的全部原文,窗口很快撞上限,延迟和费用一起往上走。最重的是合规层:客户提出删除请求时,我们的数据散落在 thread 状态、事实缓存、日志三处,没人能给出一个确定的删除清单。技术表现同样有三层。第一,状态边界不清:短期状态(这一轮聊了哪些消息、当前进行到哪一步)和长期事实(这个用户属于哪个区域、偏好什么语言)存在同一个容器里,导致「关闭会话」这个动作无法定义——关掉它,事实也没了;不关它,状态就永远在长。第二,身份边界不清:user_id只在业务表里出现,从来没进入过记忆层,所以记忆层根本不知道「这份数据属于谁」。第三,预算边界不清:没有任何组件负责回答「这一轮到底能带多少 token 进去」。把这些问题收敛成可验证的完成标准,我给自己定了四条:其一,同一个user_id+session_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 [数据/状态] Checkpointer(thread 级 messages 与摘要)/ Store(user 级偏好与业务事实) | 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.11;langgraph与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:text=state['messages'][-1].contentif'我叫什么'intext:forminreversed(state['messages'][:-1]):hit=re.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='好的,记住了')]}builder=StateGraph(S)builder.add_node('reply',reply)builder.add_edge(START,'reply')builder.add_edge('reply',END)checkpointer=InMemorySaver()graph=builder.compile(checkpointer=checkpointer)defchat(user_id:str,session_id:str,text:str)->str:thread_id=f'{user_id}:{session_id}'cfg={'configurable':{'thread_id':thread_id}}out=graph.invoke({'messages':[HumanMessage(content=text)]},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_id=u_1, session_id=s_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 管 thread,Store 管 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 记住多少事并不重要,重要的是它知道哪些事属于哪一段、属于哪个人、什么时候该忘掉。一界分短长一策定删留一图见取舍一役知深浅