从聊天记录到真正的记忆:为 AI Agent 设计一套分层记忆系统
随着大模型能力的提升,越来越多的应用不再满足于做一个一问一答的 ChatBot,而是想做成能长期陪伴用户、记住用户偏好、跨会话理解用户意图的 Agent。但大模型本身并没有真正意义上的记忆,它每一次回答都只是基于当前输入的 Prompt 做一次推理,所谓的“记住了什么”,其实都是开发者把历史信息重新组织好之后又喂给了模型。于是一个 Agent 做得好不好,很大程度上并不取决于底层模型,而取决于它背后那套记忆管理系统设计得是否合理。
在做这类系统的过程中,我越来越确信一件事,一套成熟的记忆系统绝不是简单地把聊天记录存进数据库再原样发回去,而应该像人脑一样对信息做分类、筛选、归纳和关联。本文记录的是一套面向 LangChain、LangGraph 或类似 Agent 框架的分层记忆设计思路,包括记忆该分几层、多主题对话怎么处理、跨主题的背景信息怎么共享、超长输入怎么切分,以及最后怎么在有限的 Token 预算里拼出一个真正有效的 Prompt。
系统全貌:记忆是怎么被用起来的
在拆开每个模块之前,先把整套流程过一遍,后面每一节其实都是在填这张图里的某一格。用户发来一条消息,系统先判断这条消息的长度,如果在模型能直接处理的范围内,就走 Topic Routing,判断它属于哪个正在进行的话题,或者要不要开一个新话题,如果消息长到明显超过模型上下文,比如用户直接甩过来一份合同或者一段日志,就先进入切分和摘要流程,不会直接怼给模型。
图中每一个节点后面都会在对应小节里展开,切分摘要对应第四节,Topic 相关的检索对应第三节,Context Builder 的打分和拼装对应第五节,记忆提取和写回则对应第五节最后一部分。
确定话题之后,系统会检索与这个话题相关的记忆,包括话题自己的历史摘要、跨话题的全局背景,以及可能影响当前话题的约束条件。这些检索出来的信息连同用户当前的问题,一起交给 Context Builder 拼装成最终发给模型的 Prompt。模型给出回答之后,流程并不会就此结束,系统会从这轮对话里再提取一遍,看看有没有产生新的偏好、约束或事实,把这些更新写回记忆库,同时刷新话题摘要。
整个过程形成一个闭环,用户的每一次输入既是这一轮的问题,也是下一轮记忆的原材料。
二、记忆该存什么,而不是聊天记录本身
这一部分先解决一个最基础也最容易被忽视的问题,也就是记忆系统里到底应该存什么东西。很多团队一开始都会走一条最直接的路,把所有聊天记录原样存库,下次聊天再整段发给模型,这条路在 Demo 阶段没有问题,但走得越远问题越大,因此需要先说清楚它错在哪,再引出应该怎么分层存放。
2.1 为什么不能直接保存聊天记录
把历史聊天原样保存并全部回灌给模型,会随着对话轮数增加暴露出几个问题。Token 会越来越多,大量与当前问题无关的历史仍然占据着上下文,模型容易被这些旧信息干扰甚至给出混乱的回答,同时成本和响应时间也会持续上升。这个问题在实际项目里往往不是线性恶化,而是突然爆发,比如某个用户和 Agent 已经聊了两三个月,某一天历史消息拼接起来直接超过了模型的上下文上限,请求直接报错,这时候如果没有提前设计好的降级方案,只能临时在代码里粗暴截断最早的消息,而这些被截断的内容里很可能藏着用户之前明确交代过的重要偏好,截断之后模型就会“失忆”,用户明显能感觉到助手前后不一致。
更本质的问题是,人类其实也不是这样记事情的,昨天有人和你一起吃饭聊天,一个月后你大概率想不起完整对话内容,但你会记得这个人喜欢吃川菜,或者他正在装修房子。人类的大脑天生就会对信息做压缩和筛选,只保留对未来有用的那部分,这不是记忆力不好,而是一种进化出来的效率机制。如果把这套机制类比到 Agent 上,聊天记录相当于人的“短时感知”,而真正沉淀下来的用户画像才相当于长期记忆,两者不应该用同一种方式存储,也不应该用同一种方式检索。人类记住的是事实,不是对话记录,一个好的 Agent 也应该按这个方式设计记忆,而不是简单地把数据库当成聊天记录的备份盘。
2.2 记忆保存的是事实,不是对话
具体来说,如果用户说“今天突然想吃辣”,这句话如果原样写入长期记忆,模型以后可能一直认为用户口味偏辣,但它其实只是一次临时表达,不该进入长期记忆。真正应该保存的是经过提取后的结构化信息,比如用户连续多次表达喜欢现代简约风格之后,可以沉淀成一条
{
"type": "user_preference",
"content": "用户偏好现代简约装修风格"
}
而如果用户明确说过家里养了一只猫,这类信息属于稳定事实,会影响后续很多话题,值得单独记录成
{
"type": "constraint",
"content": "用户家里有猫",
"applies_to": ["装修", "家具", "清洁"]
}
这两条记录的区别在于,前者是偏好,后者是会跨话题生效的约束,applies_to 字段决定了这条记忆在哪些话题下应该被自动召回,这个设计在后面讲 Constraint Memory 时会再展开。判断一条信息值不值得沉淀,本质上是问模型给出回答之后该不该做“记忆提取”,如果只是一句“今天想吃火锅”这种一次性表达,一般不需要进入长期记忆,这一步会在后面的记忆更新策略里再具体说明。
实践中判断“值不值得记”比想象中麻烦,单次表达和稳定偏好之间没有一条清晰的界线。比较稳妥的做法是给同一类信息设置一个出现次数或者时间窗口的门槛,比如同一个偏好在最近三次相关对话里都被提到,或者用户用了“一直”、“平时”、“通常”这类强调持续性的词,才把它从候选提升为正式记忆,而像“今天”、“突然”、“心情不好想吃”这类带明显时间限定或情绪色彩的表达,即使被记录下来,也应该打上较低的 confidence,并设置较短的 expires_at,让它随时间自然过期,而不是长期占用上下文预算。另外要注意,用户明确说出的信息和模型推测出的信息置信度不应该相同,例如用户直接说“我家有猫”属于高置信度事实,而模型从“猫抓板”“猫粮”这类词推测出用户养猫,就应该标记成较低置信度,后续在 Context Builder 打分时也要体现这种差异。
2.3 分层记忆:短期、中期、长期
在“存事实而不是存对话”这个前提下,记忆本身也不该是扁平的一堆事实,而应该按生命周期分层,这样才能既保证当前上下文的连续性,又避免历史数据无限增长。
短期记忆负责当前会话,只保存最近几轮聊天、当前任务状态和一些临时变量,生命周期很短,可以直接放在内存、Redis 或者 LangGraph 的 Checkpointer 里,不需要做长期持久化。这一层的设计目标是低延迟读写,因为每一轮对话都要访问它,一般不需要向量检索,直接按时间顺序取最近 N 轮或者按 Token 数截断即可,LangGraph 里可以直接依赖 checkpointer 保存的 state,不用另外建表。
中期记忆保存近期项目和最近几天的重要聊天摘要,比如用户最近一直在讨论装修,装修相关的信息就适合放在这一层,可以设置 TTL,比如七天、三十天,或者随项目结束而失效,避免无限增长。中期记忆本质上是按话题分组维护的摘要,等下一节引入话题这个维度之后会看到,它其实就对应到每个话题各自的历史摘要。这一层比短期记忆活得久,但不是永久保留,如果一个话题超过设定的 TTL 都没有被重新触发,比较合理的处理方式不是直接删除,而是先归档或者进一步压缩成一条更短的摘要,这样即使用户几个月后突然又提起装修,系统还能从归档里找回大致背景,而不是完全从零开始。
长期记忆保存的是稳定事实,比如用户的职业、装修预算、语言偏好、家庭情况,这些信息不会因为一次聊天轻易改变,更适合作为用户画像长期保留。长期记忆的写入门槛应该明显高于中期记忆,因为一旦写错,纠正成本很高,用户很可能不会主动提起“我并没有说过我是程序员”,这类错误只能靠后续对话里的矛盾信息去修正,因此长期记忆的每条记录都应该保留来源,比如是哪一轮对话、哪一句话触发的提取,方便出问题时回溯。三层记忆各自负责不同时间尺度的信息,短期保证连续性,中期承载正在进行的任务,长期沉淀稳定画像,三者的读写频率和一致性要求也不一样,短期允许频繁覆盖,中期允许周期性刷新,长期则应该谨慎更新、频繁读取。
三、主题管理:让 Agent 在多个话题间自由切换
分好层之后还有一个现实问题,现实中的聊天不会一直围绕同一个主题,用户可能上午聊装修,下午聊宠物,晚上又聊工作,第二天继续回到装修。如果系统只按时间顺序保存聊天,模型很容易在装修话题里引用宠物的内容,或者在讨论工作时又扯出装修预算,回答会越来越离谱,这一节要解决的就是怎么给聊天引入“主题”这个维度,以及主题之间怎么互相借用信息。
3.1 多主题聊天带来的上下文混乱
解决办法是给每条消息标记一个 Topic,比如装修、宠物、工作、旅游、健身,每个 Topic 各自维护自己的摘要、关键词、Embedding 和历史记忆。当用户再次讨论装修时,不需要恢复整段聊天记录,只需要恢复“装修”这一组历史即可,这样既能减少 Token 消耗,也能显著提升上下文的准确性。
这里可以举一个更具体的反例来说明不分 Topic 会出什么问题。假设用户前一天连续问了十几条关于换工作的问题,聊到薪资谈判、offer 对比、离职交接,第二天突然问“阳台那边放个书架合适吗”,如果系统仍然把最近几十条消息原样塞进上下文,模型很可能把“书架”和前一天的工作话题强行关联,给出诸如“考虑到你正在权衡两个 offer,建议先不要急着添置家具”这种驴唇不对马嘴的回答。引入 Topic 之后,这条关于书架的消息会被路由到“装修”或者新建的“家居”话题下,模型看到的上下文就只有装修相关的历史,不会被前一天的工作话题干扰。
3.2 如何判断当前消息属于哪个主题
这里有个绕不开的问题,系统怎么知道用户这句话该归到哪个 Topic,而不是简单按时间顺序堆在最后一个话题下面。比较实用的做法是先对当前这句话做 Embedding,去和已有 Topic 的摘要做相似度检索,找出最相关的几个候选。如果候选里有明显更接近的一个,再交给模型做最终判断,因为语义相似不代表真的是同一件事,比如聊装修预算和聊旅游预算,向量距离可能很接近,但其实是两个话题,这一步交给模型判断会比单纯依赖向量距离更可靠。如果所有候选的相似度都很低,说明用户开了一个新话题,系统就新建一个 Topic,而不是勉强塞进某个旧话题里。
这套两阶段的路由可以简化成下面这样一段伪代码,先做向量召回缩小候选范围,再交给模型做语义确认。
def route_topic(message, existing_topics, embed, llm_classify, threshold=0.75):
query_vec = embed(message)
# 先用向量相似度粗筛候选话题,避免每条消息都要过一遍 LLM
candidates = top_k_by_cosine_similarity(query_vec, existing_topics, k=3)
if not candidates or candidates[0].score < threshold:
return create_new_topic(message)
# 相似度接近的候选交给模型做最终判断,防止“预算”这类词把不同话题误判成同一个
return llm_classify(message, candidates)
这里的 threshold 需要根据实际数据调,设得太低会导致新话题很难被建立,用户明明换了话题,系统却把它塞进旧话题里,历史摘要被无关内容污染,设得太高又会导致同一个话题被反复拆成多个新 Topic,检索时反而找不全历史。比较稳妥的做法是先用一批真实对话跑离线评估,观察不同阈值下误合并和误拆分的比例,再结合线上反馈慢慢调整,而不是一开始就拍脑袋定一个数字。
这套流程跑起来之后,用户其实感觉不到“话题”这个概念的存在,他们只是自然地在几个话题之间来回切换,系统在背后默默把每句话归位。
3.3 Topic 不能完全隔离
但仅仅按 Topic 分类还不够,甚至可以说如果 Topic 划分得越干净,隔离带来的副作用反而越明显。假设用户连续聊了一个月养猫,然后突然开始讨论装修,虽然整个过程中用户从没说过“装修的时候请考虑猫”,但一个合格的助手应该意识到,家里有猫的话,装修建议应该优先考虑地板耐抓、家具耐磨、猫砂盆位置这些细节。如果装修 Topic 只加载自己的历史摘要,完全看不到宠物 Topic 里的信息,那么无论装修话题聊得多深入,模型也不会主动提到猫,因为它压根不知道这个背景存在。也就是说宠物这个 Topic 实际上会影响装修 Topic,Topic 之间不能完全隔离,这就需要引入独立于 Topic 之外的两层记忆,专门负责把这类跨话题才有意义的信息挑出来单独管理。
3.4 Global Memory 承载跨话题背景
第一层是 Global Memory,用来保存那些会影响很多不同话题的背景信息,比如用户职业、家庭情况、预算、城市、语言偏好、健康状况、是否有宠物、是否有老人小孩。这些信息不属于某一个具体 Topic,而是整个用户画像的一部分,无论后面讨论装修、家具还是旅行,只要相关,这些背景都可以自动参与上下文构建。
Global Memory 和长期记忆经常被混为一谈,但两者的检索方式其实不太一样。长期记忆更多是按 Topic 或者按需检索出来的具体事实,而 Global Memory 更像是每次对话都默认加载的一小段用户画像摘要,类似于系统提示词的一部分,不需要额外做相似度检索。实践中可以把 Global Memory 维护成一段结构化的简短文本,例如“用户是一名后端工程师,居住在上海,家中有一只猫,预算相对宽裕”,每次构建 Prompt 时都直接拼进去,而不是每次都重新检索一遍,这样既省了一次检索开销,也保证了不同 Topic 看到的背景是一致的。当然,Global Memory 也不能无限增长,字段太多之后同样需要做筛选和压缩,避免它自己也变成一份臃肿的档案。
3.5 Constraint Memory 管理约束条件
第二层是 Constraint Memory,专门管理会限制回答方向的约束条件,比如预算只有二十万、租房、有猫、家里有老人、对花粉过敏、厨房面积很小。这些约束通常不对应某个具体 Topic,却会影响很多话题的回答质量,因此建议单独维护一张 Constraint 表,每条记录至少包含以下字段。
content,约束的具体内容,比如“用户家里有猫”confidence,这条约束的置信度,来自用户明确表达还是模型推测importance,重要程度,决定它在打分排序时的权重applies_to,会影响哪些 Topic,比如装修、家具、清洁updated_at,最近一次更新时间,用于判断是否过期
以后进入装修 Topic 时,系统只需要按 applies_to 字段查一遍 Constraint Memory,就能自动把“用户家里有猫”带进上下文,模型也就会自然推荐耐抓地板,而不是普通木地板。
applies_to 这个字段值得多说一句,它既可以人工预设,也可以让模型在提取约束时顺带给出。比如从“我家有猫”这句话提取约束时,可以在提取的 Prompt 里直接要求模型输出这条约束可能影响的话题列表,模型基于常识大概率会给出装修、家具、清洁这几个选项,遗漏或者过度覆盖都是正常现象,因此上线初期建议保留人工审核或者允许用户在设置里查看和修正这些约束,避免一条判断错误的约束长期影响不相关话题的回答质量。另外,约束和偏好不同,约束一旦确立通常具有较强的排他性,比如“预算只有二十万”这类约束如果被误判,后续推荐很容易超出用户实际承受范围,因此 Constraint Memory 的更新应该比普通偏好更保守,倾向于覆盖而不是简单追加。
3.6 Topic 关联图
Global Memory 和 Constraint Memory 解决的是背景信息怎么跨话题生效,但 Topic 与 Topic 之间其实还有更直接的关联,值得单独拎出来说。装修这个话题天然会牵扯到家具、预算、收纳,宠物这个话题又会牵扯到家具、清洁、旅行,如果把每个 Topic 都当成一个孤立的箱子,讨论装修时系统不会主动想到去看看宠物话题里有没有相关信息,即便 Constraint Memory 里已经记着用户家里有猫。更好的做法是把 Topic 之间的关联也显式记录下来,形成一张图而不是一条单向链条,装修连着家具、预算、收纳、宠物,宠物又连着家具、清洁、旅行、装修。
这张图既可以人工预设一批常见领域的关联规则,比如装修和家具、宠物和清洁这类常识性关联可以直接写死,也可以随着系统运行不断积累,比如发现某个用户的装修话题和旅游话题反复出现相似的检索命中,就把这两个 Topic 之间的边权重加高。有了这张图,检索记忆时就不再只看当前话题自己的历史,还能顺着关联边去看相邻话题里有没有值得带进来的信息,具体做法通常是在检索当前 Topic 的历史之外,再按边的权重取一到两个关联最紧密的 Topic,各自召回少量相关内容一并纳入候选池,而不是把所有关联 Topic 的全部历史都拉进来,否则又会回到“信息太多挤占预算”的老问题。这和 Constraint Memory 按影响范围广播是两回事,Constraint Memory 解决的是某条具体事实该不该被别的话题看到,Topic 关联图解决的是话题本身的检索路径该怎么走,两者配合起来,才能让系统在装修话题里自然想起猫、想起预算,而不需要用户重复说一遍。
四、超长内容的处理:切分、检索与递归摘要
主题和记忆解决的是“对话该怎么组织”,但还有另一类问题跟对话轮数无关,而是单次输入本身就超出了模型能处理的长度,比如聊天轮数堆积到几百轮,或者用户一次性甩过来几十万 Token 的日志、合同、代码仓库。这一节要解决的是这类超长内容怎么在不丢信息的前提下塞进有限的上下文里。
4.1 超长聊天历史怎么办
聊天轮数越积越多,模型上下文终究有限,正确做法不是删除历史而是总结历史。比如最早的三百轮聊天可以压缩成一句“用户一直在讨论装修,重点关注预算、客厅、灯光和收纳”,然后只保留最近几十轮原始聊天,真正发给模型的是历史摘要加最近聊天,这样既保留了连续性,又不会浪费大量 Token。
这个总结过程不建议做成一次性任务,而应该做成滚动更新。比较常见的做法是设一个滑动窗口,比如始终保留最近二十轮原始消息,一旦超出这个窗口,就把被挤出去的那部分消息追加进已有摘要重新生成一版新摘要,而不是等聊天堆到几百轮才一次性总结,一次性总结成本高,而且模型面对几百轮原始文本时容易遗漏中间部分的信息。滚动摘要的另一个好处是可以让摘要本身携带一些结构,比如按“当前进展”“已确定事项”“待解决问题”分段总结,这样即使原始聊天已经被丢弃,模型也能从摘要里判断出当前任务推进到了哪一步,而不只是一段模糊的背景描述。
4.2 用户一次性发送超长内容怎么办
更极端的情况是用户直接发来几十万 Token 的日志、一份合同或者一个代码仓库,这些内容显然不能直接塞给模型。正确的处理链路是先完整保存原文,前端展示时使用完整内容,然后按规则切分成 Chunk,对每个 Chunk 建立向量索引,必要时再做摘要,形成 Chunk 到 Chunk Summary 再到 Group Summary 最后到 Final Summary 的层级结构。真正进入模型的是摘要和与当前问题相关的原文片段,而不是全部原始内容,这样既保证信息完整性,又能实现精确检索。
切分规则本身也有讲究,不能简单按固定字符数硬切。日志类内容适合按时间戳或者按单条日志记录切分,保证每个 Chunk 是一条完整的日志而不是被从中间截断,合同类内容适合按条款编号切分,保证第八条不会被拆到两个 Chunk 里,检索命中时至少能拿到完整的一条,代码仓库则适合按文件甚至按函数切分,并在每个 Chunk 里附带文件路径和所属类名之类的元信息,方便后续检索结果能定位到具体位置。切分粒度太细会导致检索命中的片段缺乏上下文,太粗又会让无关内容混进本该精确命中的 Chunk 里,一般建议给每种内容类型单独调切分参数,而不是全局用同一套规则。
4.3 摘要本身也超长怎么办
如果 Chunk Summary 数量本身也多到超过上下文限制,答案很简单,继续摘要,也就是让摘要本身也可以递归。这是典型的 Map-Reduce 思路,先分别总结各个分组,再总结这些总结,直到最终结果能放入模型上下文为止。这种递归摘要不仅适用于超长文档,也同样适用于前面提到的超长聊天历史。
递归摘要的层数不是越多越好,每多压缩一层,细节丢失就会累积一次,因此实践中一般会限制递归深度,比如最多做两到三层,如果三层之后总结果仍然超出预算,说明单纯靠摘要已经无法解决问题,这时候应该转而依赖下一节讲的 Chunk 检索来补细节,而不是继续无限制地压缩摘要。另外,Map 阶段各个分组的总结应该尽量保持独立,避免让某个分组的总结依赖另一个分组的上下文,否则并行处理时容易出现信息错位,Reduce 阶段汇总时也更难判断哪些细节是可以舍弃的。
4.4 Chunk 不只是用来摘要的,还要用于精确检索
递归摘要能解决信息太多装不下的问题,但摘要有个副作用,越往上层压缩细节丢得越多,如果用户问的恰好是一个具体细节,摘要就帮不上忙了。比如用户上传的是一份合同,几轮摘要之后系统记得的是“这是一份租赁合同,涉及押金、违约责任、维修义务”,但如果用户问第八条第三款的违约金具体是多少,摘要里根本不会保留这么细的数字。这时候真正该做的不是让摘要写得更细,而是回到 Chunk 这一层去做检索,原始文本切出来的每个小块本身都建了 Embedding 索引,针对用户这句具体的问题去找最相关的几个 Chunk,把命中的原文片段连同摘要一起交给模型。摘要负责让模型知道文档整体是什么、背景是什么,Chunk 负责把命中的原文细节递过去,两者分工不同,摘要保连贯,Chunk 保精确,缺一个都不行。
这套摘要加检索的组合,本质上和检索增强生成(RAG)的思路是一致的,区别只在于这里的“知识库”不是固定的外部文档,而是用户当次对话临时上传的内容,生命周期通常和这次任务绑定,任务结束后可以归档或者清理索引。实践中还有一个细节容易被忽略,用户追问细节时,除了命中的 Chunk 本身,最好再带上它前后相邻的一到两个 Chunk,因为很多细节的完整含义依赖上下文,比如违约金条款前面可能有一句“除非另有约定”,如果只召回命中的那一个 Chunk,模型给出的答案可能忽略掉这类前提条件,反而造成误导。
五、Context Builder:在有限 Token 内组装最优上下文
前面几节分别解决了记忆该存什么、话题怎么组织、超长内容怎么切分,但这些信息最终都要汇总到同一个地方,也就是真正发给模型的那个 Prompt。这一节讲的是这个汇总环节该怎么做,包括候选信息怎么打分、Token 预算怎么算、模型回答之后记忆又该怎么更新,以及支撑这一整套系统的数据结构大致长什么样。
5.1 候选信息打分与 Token 预算
能进入 Context Builder 候选池的信息已经不少了,短期上下文、当前 Topic 的摘要、关联 Topic 带来的信息、Global Memory、Constraint Memory、检索出来的相关 Chunk,如果全部塞进去很可能又超过模型的上下文长度,所以在真正拼 Prompt 之前还需要做两件事,一是给候选打分排序,二是算清楚还有多少预算可用。
打分可以综合当前问题的相似度、记忆本身的重要程度、更新时间的新旧、以及提取时的置信度,一个常见的组合方式是
score=similarity+importance+recency+confidence \text{score} = \text{similarity} + \text{importance} + \text{recency} + \text{confidence} score=similarity+importance+recency+confidence
按分数排序后只取排名靠前的部分,而不是一股脑全部塞进去。预算这边则要先算清楚模型的上下文总长度,减去系统提示词,减去要给模型输出留的空间,剩下的再分给记忆和最近几轮聊天
可用预算=模型上下文长度−系统提示词−输出预留−记忆预算−最近聊天 \text{可用预算} = \text{模型上下文长度} - \text{系统提示词} - \text{输出预留} - \text{记忆预算} - \text{最近聊天} 可用预算=模型上下文长度−系统提示词−输出预留−记忆预算−最近聊天
如果候选记忆的总长度已经超出这部分预算,就得继续做裁剪,而不是等发给模型时才发现超长报错。这两步做完,Context Builder 才真正知道自己手上有哪些牌可以打,也知道这些牌加起来占多少格子。
打分公式里每一项的权重也不是一成不变的,不同类型的问题应该调整权重的侧重点。用户问一个具体细节问题时,相似度应该占更高权重,因为这时候找到最相关的那条记忆或者 Chunk 比覆盖面更重要,用户开启一个全新话题时,重要程度和跨话题的约束反而更值得优先展示,因为这时候还没有明确的相似度信号可以依赖。裁剪预算时也要注意留出余量,实际调用模型时 System Prompt 和工具调用描述往往也会占用不少 Token,如果预算计算时只考虑了记忆和聊天历史,忽略了这部分固定开销,线上还是会偶尔遇到超长报错,比较稳妥的做法是把这部分固定开销也当成一项预留额度提前扣除,而不是等真正拼接时才发现算漏了。
5.2 Context Builder 的职责
打完分、算完预算之后,Context Builder 的职责就是在这个预算之内,根据当前问题动态组合系统提示词、当前短期上下文、当前 Topic 摘要与历史、Global Memory、Constraint Memory、与当前问题最相关的 Chunk,以及用户当前的输入,最终拼出一个信息密度足够高的 Prompt。它不是简单拼接聊天记录,而是每一轮都要重新判断哪些信息该进、哪些该舍,这也是现代 Agent 和传统聊天机器人最大的区别之一。
实现上,Context Builder 通常会被拆成几个明确的阶段,先并行发起各类检索,Topic 历史、Global Memory、Constraint Memory、相关 Chunk 各自互不依赖,可以同时查询以降低延迟,再统一汇总候选并按前面提到的打分公式排序,最后按预算截断,同时保证被截断的部分优先舍弃分数最低的候选,而不是简单按加入顺序砍掉末尾。这个阶段结束后拼出来的 Prompt 结构应该是稳定的,比如固定按系统提示词、全局背景、约束条件、话题摘要、相关片段、最近聊天、当前问题这样的顺序排列,这样即使每次填充的内容不同,模型也更容易适应固定的 Prompt 结构,减少因为格式变化带来的效果波动。
5.3 回答之后的记忆更新
模型给出回答之后,流程并没有结束,系统还要做一次记忆提取,判断这轮对话里有没有出现新的偏好、约束、项目状态或者长期事实。如果只是“今天想吃火锅”这类临时表达,一般不需要写入长期记忆,只有反复出现或者用户明确表达的信息,比如“我家养了一只猫”,才值得沉淀成结构化记忆并挂上对应的 applies_to 和 confidence。这一步做完之后,Topic 的摘要也需要同步更新,让下一次进入这个话题时能拿到最新的背景。
记忆提取本身一般也是靠一次单独的模型调用完成,输入是这一轮的用户消息和模型回答,输出是结构化的候选记忆列表,同时要求模型给出 memory_type、confidence 和可能的 applies_to。这一步容易踩的坑是让提取模型和对话模型共用同一次调用,直接在回答末尾附带一段记忆提取结果,这样做省了一次调用,但会导致输出格式不稳定,有时候模型会把提取结果混进正常回答里泄露给用户。更稳妥的做法是拆成两次独立调用,对话模型只管生成回答,提取模型专门负责结构化输出,两者互不干扰,即使提取失败也不影响当前这轮的正常回答。写回记忆库时还要处理好去重和更新,如果某条约束已经存在,新提取出的内容只是重复确认,应该更新 updated_at 和 confidence,而不是重复插入一条内容相同的记录,否则同一个事实会在库里越堆越多,反而增加后续检索和打分的负担。
5.4 推荐的数据结构
支撑上面这一整套流程,底层至少需要 Conversation、Topic、Message、Chunk、Summary、Embedding、Memory、Constraint、GlobalProfile 这几张表。其中 Memory 表建议统一采用下面这套字段,不管是偏好、约束还是全局背景都走同一张表,只靠 memory_type 和 scope 区分类型。
id、user_id、topic_id,标识这条记忆归属谁、属于哪个话题memory_type、scope,区分偏好、约束、事实等类型,以及生效范围content、embedding,记忆的具体内容和用于检索的向量confidence、importance,置信度和重要程度,供打分排序使用created_at、updated_at、expires_at,生命周期相关的时间戳applies_to,会影响哪些 Topic,跨话题生效的关键字段
统一用一张表管理各种类型的记忆,好处是打分、检索、过期这些逻辑可以复用同一套代码,不需要针对偏好、约束、全局背景各写一遍。除了 Memory 表本身,Topic 表一般还需要维护 summary、keywords、embedding、last_active_time 和指向关联 Topic 的边表,Chunk 表则需要保留 source_id 指回原始文档、sequence 记录在原文中的顺序,方便命中之后回溯到前后文。这些表之间的关系并不复杂,核心思路是让 Message 归属 Topic,Memory 和 Constraint 可以关联到具体 Topic 也可以是全局的,Chunk 归属某一次超长输入,Summary 则可能同时对应 Topic 或者 Chunk 分组,只要把这几条关系理清楚,具体用关系型数据库还是文档数据库实现都是次要问题,向量部分单独接入一个向量数据库或者带向量索引扩展的关系库即可。
六、结语
这套设计做下来,核心其实可以归纳成几件事。记忆不再是原样保存的聊天记录,而是经过提取的结构化事实,按短期、中期、长期分层存放,多主题对话通过 Topic Routing 和 Topic Memory 各自维护历史,同时靠 Global Memory、Constraint Memory 和 Topic 关联图让不同话题之间的背景信息能够互相借用,超长输入通过切分、向量检索和递归摘要处理,既不丢细节也不超预算,最后由 Context Builder 在有限 Token 内对所有候选信息打分排序,动态拼出真正相关的 Prompt,而不是把历史一股脑塞进去。
即使未来模型的上下文窗口继续变大,这套记忆架构也不会因此失去意义,因为更大的上下文并不等于更好的回答,真正决定 Agent 表现的是它能否从大量信息里挑出当下最相关、最可靠的那一部分。这才是 Agent 从一个能连续对话的接口,走向一个真正拥有长期记忆和持续学习能力的助手,需要迈出的一步。
更多推荐

所有评论(0)