做过Agent应用的人大概都会在第几十轮对话时经历一次内心崩溃用户明明十分钟前才提过的需求Agent转头就忘而某个无关紧要的闲聊细节却因为塞在上下文里把关键指令的注意力挤没了。这个系列上一篇文章聊了Agent的工具调用与规划能力这次我把目光移到Agent的记性上聊聊LangChain里做记忆管理的实用手段——Summarization中间件也就是用摘要给Agent的记忆做加减法。先说明白一件事Agent根本不存在记不住的问题它的问题要么是脑子里装了太多没用的东西要么是重要的东西被挤出了窗口。上下文窗口本质是一张工作台不是硬盘。窗口有限装了垃圾真正要用的工具就摆不下了。所以记忆管理的核心不是让Agent记住更多而是让它记住更少但更关键的内容。Summarization中间件做的就是这件事把废话压进摘要把关键信息留在台面上。这篇文章适合正在用LangChain搭建对话式Agent、却为多轮对话记忆发愁的开发者。我会从原理讲到实操再分享几组我踩过坑之后的调参记录尽量让看完的人能直接动手改自己的项目。1. 先聊痛点Agent不是健忘是记性差得太离谱1.1 工作台与硬盘重新理解上下文窗口很多人第一次接触上下文窗口这个概念会误以为它是模型能存多少东西。其实这个类比不准确。上下文窗口更像你面前的物理工作台——模型每处理一个新请求所有需要用到的信息都必须摆在这张台子上。台子就这么大摆满了新的就放不下信息太多模型还会在细节里迷路抓不住重点。真正能称之为记忆的是模型之外的持久化存储向量数据库、外部文件、数据库记录、以及我们今天要讲的摘要文本。Agent与用户对话时只有把工作台上堆不下的历史对话不断归置到摘要里才能给新的输入腾出位置。这个类比能解释很多实际问题。比如你问Agent一个需要精确数字的问题但之前的对话里有几十句无关的寒暄模型在计算时可能会被寒暄中模糊的信息干扰输出结果的确定性明显下降。工作台太乱手边的工具再厉害也使不上劲。1.2 记忆过剩token烧钱响应变慢我用过一个很典型的反面案例某个客服类Agent对话轮次一多上下文里堆满了用户的客套话、重复提问、甚至是系统自身的工具调用日志。每轮请求都要把这些原始记录全部发给模型账单肉眼可见地涨。更要命的是模型在长上下文里的推理精度是递减的无关信息越多回答质量越差有时候明明之前已经确认过的答案因为上下文干扰反而给了一个错误的说法。这里面有一个很少被新人在意的问题工具调用日志。Agent每调用一次外部工具结果会原样写在消息记录里。如果某个工具返回的是超长JSON而这次的返回结果后续根本用不上这个文本就会一直被携带到每一轮请求中token开销非常夸张。这就是典型的记忆过剩上下文本身没有清洗和裁剪机制用得越久越臃肿。1.3 记忆缺失用户说我刚刚才告诉过你另一端的问题同样让人抓狂。用最简单的滑动窗口方案时上下文只保留最近N轮对话用户三天前提过的结算周期是每月5号早就被滑出去了今天Agent问你们的结算周期是哪天——用户当场就炸了。最尴尬的是用户修改过需求的情况。今天早上说改成红色主题下午聊了二十分钟别的之后Agent又开始推荐蓝色方案。这其实不是模型变笨了而是滑动窗口把上午那条修改指令丢弃了。缺失记忆和冗余记忆看起来是相反的问题本质却是同一个没有对信息做优先级管理。1.4 为什么Summarization是当前最务实的解药解决记忆问题有三条经典路线滑动窗口、向量检索、摘要压缩。滑动窗口实现简单但丢记忆向量检索擅长找事实但很难表达用户的偏好变化、需求演进这类线性过程摘要压缩则恰好补上了前两者的短板——它用LLM自己把冗长的历史提炼成一份精华笔记既保留逻辑连贯性又大幅压缩token占用。这就是Summarization中间件存在的理由。它不追求记录一切而是追求在有限的上下文里装下最重要的那部分历史。2. Summarization中间件在做什么给记忆做可视化账单2.1 三条技术路线滑动窗口、向量库、摘要压缩四年前我刚开始搭聊天机器人时用的方案非常简单把最近十轮对话全部拼进prompt。后来换了向量库方案把每轮对话切块存进去按相关度召回。再到后来接触了摘要压缩才算是找到了一个比较均衡的形态。这三条路线的取舍如下表方案类型时效性成本信息保真度实现复杂度滑动窗口高低只保留最近长期信息易丢低向量检索高中高未命中时关键信息易漏中摘要压缩中中依赖摘要质量可控性高中向量检索有一个很容易被忽略的问题召回是按相似度计算的但用户的需求变更往往并不与旧对话相似。比如用户明确说过预算提高到五万后续对话里又出现预算这个词时检索器也许能召回这句但如果用户换了措辞、说的是资金上限调整一下召回效果就会变得非常不稳定。摘要压缩则没有这个问题因为它是把完整的故事压缩成一条逻辑线不管后面怎么措辞摘要里的关键信息始终在场。2.2 摘要缓冲的核心机制最近完整保留历史滚雪球式压缩LangChain里最有代表性的实现是ConversationSummaryBufferMemory。它的工作方式可以理解成四个步骤新对话直接写进一个缓冲区未做任何压缩处理。当缓冲区的token总量超过设定的max_token_limit时触发一次摘要操作。摘要操作把较旧的对话部分交给LLM与已有的摘要合并生成一份更新后的新摘要。清空已经处理过的旧消息缓冲区里重新只保留最新对话摘要文本则会携带所有被压缩掉的历史。这个过程很像记账流水明细只保留最近一小段更早的账目全部汇入总结栏。你随时可以看到最近发生了什么而过去的每一笔钱最终只以汇总形式存在。2.3 加减法的两把尺子max_token_limit与摘要提示词这里我理解的最重要的经验是Summarization不是无脑压缩它有明确的加减法逻辑。加法是把应该长期生效的信息写进摘要减法是把只对当下对话有效的信息丢掉。具体到操作层面决定加法和减法的就是两个参数max_token_limit决定你愿意在当前对话上花多少预算预算越小历史被压缩得越狠摘要提示词决定LLM在压缩时重点保什么。前者是数量的尺子后者是质量的尺子。两条都要调只调一个会出现上下文还是爆炸或者摘要写了等于没写的情况。我在实践中的默认策略是寒暄语、请求帮助的客套话、超长的工具返回日志全部减掉用户的身份信息、已经确认过的决定、变更过的需求、尚未完成的任务加进去。3. 手把手实现给Agent装上Summarization记忆中间件3.1 依赖准备与基础链路先说环境。我本地的测试环境是Python 3.11安装了langchain、langchain-openai和langchain-community。这里提个醒LangChain版本迭代非常快如果你用的是老版本某些类路径可能已经变了建议按当前官网文档为准。安装命令很简单pip install langchain langchain-openai langchain-community核心链路是ChatOpenAI负责生成摘要和回答ConversationSummaryBufferMemory负责记忆管理ConversationChain把两者串起来。如果你自己的项目不用ConversationChain完全可以把memory对象单独拿出来在自定义的Agent流程里手动读写。3.2 用ConversationSummaryBufferMemory搭出第一版下面这段代码是我在最简单场景里跑通的第一版from langchain_openai import ChatOpenAI from langchain.chains import ConversationChain from langchain.memory import ConversationSummaryBufferMemory llm ChatOpenAI( modelgpt-4o-mini, temperature0 ) memory ConversationSummaryBufferMemory( llmllm, max_token_limit500, memory_keyhistory, return_messagesTrue ) chain ConversationChain( llmllm, memorymemory, verboseTrue ) # 模拟多轮对话 chain.invoke({input: 你好我是小林做数据开发五年了最近想转行做AI应用。我们公司的业务是跨境电商。}) chain.invoke({input: 我想设计一个智能客服系统能从订单表数据里回答用户关于物流的问题。}) chain.invoke({input: 预算比较紧张希望用开源模型来部署暂时不考虑调用云端大模型API。})跑完第二轮之后memory内部就开始维护一份摘要。你可以随时打印出来看print(memory.load_memory_variables({}))结果大致是这样摘要把用户的身份、目标、预算约束都保留了而把你好我是谁这类寒暄压缩掉了。这就是加法与减法同时发生的过程。3.3 给摘要加自定义提示词控制保留的优先级用默认摘要提示词时模型经常会漏掉一些你觉得不重要但我很在乎的信息。我遇到过最典型的情况是用户之前说过价格别报给客户只报给自己人这个信息在摘要里被模型漏掉了。后来我意识到要让摘要稳定保留业务关键信息必须自定义摘要提示词。自定义提示词的本质是给LLM一个明确的取舍清单。下面是我项目里用过的一个简化版本from langchain_core.prompts import PromptTemplate custom_summary_prompt PromptTemplate.from_template( 请对以下对话历史进行总结生成一段连贯的中文摘要。\n 总结时必须遵守以下优先级\n 1. 用户的身份、角色与背景信息必须保留\n 2. 用户明确提出的需求、约束与变更决定必须保留尽量原文表述\n 3. 尚未完成的任务与后续计划必须保留\n 4. 寒暄、客套、过程性细节不要写入摘要。\n 如果历史中已经存在摘要请在原有摘要基础上合并更新不要重复写入相同内容。\n 已有摘要\n{history}\n 新的对话内容\n{input}\n 摘要 ) memory ConversationSummaryBufferMemory( llmllm, max_token_limit500, memory_keyhistory, return_messagesTrue, promptcustom_summary_prompt )这里有两个变量名必须注意{history}是旧摘要{input}是新的对话内容名字不能改否则内部格式化时会报错。用这个模板之后摘要对关键约束的保留率明显提升用户对你怎么还记得我说过这个的反馈次数直线上升。3.4 与向量检索组合长短时记忆双通道只靠摘要也有它的局限比如用户问你帮我查一下上个月邮件里提到的报价单这类问题依赖的是具体事实摘要里能给的空间非常有限。所以工程上更成熟的方案是让摘要记忆和向量检索记忆同时工作摘要负责承载对话的连续逻辑作为长期记忆向量库负责承载具体事实片段作为可检索记忆缓冲区则保留最近的原始对话作为工作记忆。我把这个结构叫双通道记忆from langchain_community.vectorstores import FAISS from langchain_openai import OpenAIEmbeddings from langchain.text_splitter import RecursiveCharacterTextSplitter # 假设历史对话已经被持久化这里以文本片段示例 history_texts [ 用户提到结算周期是每月5号, 用户说过希望对话回复风格简洁直接, 用户对物流异常特别在意多次询问超时赔付机制 ] splitter RecursiveCharacterTextSplitter(chunk_size200, chunk_overlap20) docs splitter.create_documents(history_texts) vector_store FAISS.from_documents(docs, OpenAIEmbeddings()) # 需要时检索最相关的记忆片段 retrieved vector_store.similarity_search(结算周期, k2)双通道组合的核心逻辑是每次用户提问时先向量检索出与当前问题最相关的事实片段再把它与压缩后的历史摘要拼接在一起一起交给Agent。这样Agent既保留了对话的连贯逻辑又能在需要时精确调取事实。4. 高频翻车现场与参数调优记录4.1 摘要里丢了关键约束Agent擅自改了方案这个坑我踩过不止一次场景是这样的用户说不要用第三方支付接口Agent在对话前半段处理得很好到了第20轮因为摘要里没把这条约束写进去Agent给出了一套接入第三方支付的方案。排查后发现默认摘要模板在信息密度高的时候倾向于保留用户目标而遗漏限制条件。我的解决办法是在自定义摘要模板里把用户明确的约束与禁止项单独列出来并且使用不要做X这种原文说法而不是转述成用户对支付方案有偏好。实践下来让LLM在摘要里保留原文措辞比让它自由概括可靠得多。4.2 中文语境的摘要质量模型总是把细节丢进垃圾桶英文语料里摘要模型表现通常不错但中文里有一个特有的问题中文的会话有大量语气词、重复表达、客套话默认摘要模板会把所有看起来不重要的内容都删掉包含一些业务上其实很关键的细节。我的经验是给中文摘要模板增加一条明确指令即使看起来啰嗦只要涉及业务指令、时间节点、金额、人名、品牌名、政策或规则的表述都必须在摘要的合适位置出现。 这条指令的效果立竿见影。本质上是在提示模型中文对话里数字和时间往往藏在普通句子里不能按英文习惯丢弃。4.3 token统计口径不一致max_token_limit成了摆设刚开始用max_token_limit时我以为它就是缓冲区里原始消息的token上限后来发现它实际上更接近触发摘要的阈值而且不同模型对token的统计口径并不完全一致。GPT系列模型的tokenizer和开源模型的统计方式有差异同一个max_token_limit500在不同模型下实际触发的时机可能差很远。排查这个问题最直接的办法是打印memory内部的缓冲区消息长度。我一般会在测试环境里每轮对话后输出当前buffer的总token数观察它和阈值的关系。如果发现摘要触发得太频繁或太稀疏就调整阈值。经验上max_token_limit设置为核心模型上下文窗口的5%到10%是一个比较稳妥的起点再根据业务对话的实际长度微调。4.4 摘要更新时机不当导致的前后矛盾摘要更新时机是另一个容易出问题的点。如果用ConversationSummaryBufferMemory当用户最新输入触发了摘要操作时旧的摘要会被更新但更新后的摘要可能在下一轮才生效。也就是说用户刚说上一句话不算改成方案B摘要里还写着方案AAgent在下一轮回复里可能继续沿用方案A。我的做法是在关键决策发生的那一轮主动调用一次memory.prune()或者重新读取记忆变量强制让摘要同步最新状态。同时在自定义摘要提示词里增加一句如果新对话内容与已有摘要存在冲突以新对话内容为准并在摘要中标注这是最新决定。这一招在处理需求变更场景时非常管用。4.5 工具返回内容污染摘要最后一类高频问题是工具调用的长文本输出。如果Agent在大模型调用之间查询了数据库、调用了外部API返回的长JSON会被当作对话的一部分计入记忆。我的经验是在把工具结果写入记忆之前先做一层预处理只提取用户视角需要的信息比如状态、关键数字、错误码完整的长文本如果不影响后续对话就直接丢弃不进入记忆模块。这一步很多教程不会提但它往往是最省token的地方。一次复杂查询的原始返回可能几千字预处理之后只剩一两句话摘要负担瞬间减半。5. 从够用到好用记忆中间件的工程化扩展5.1 摘要持久化与多会话复用当你把对话窗口一关内存里的摘要就没了。真正的产品化应用里用户明天回来继续聊Agent必须记得昨天的进度。解决方案是给摘要加上持久化层。我常用的轻量做法是把摘要存进数据库import sqlite3 def save_summary(conversation_id: str, summary: str): conn sqlite3.connect(memory.db) conn.execute( INSERT OR REPLACE INTO summaries (conversation_id, summary, updated_at) VALUES (?, ?, datetime(now)), (conversation_id, summary) ) conn.commit() conn.close() def load_summary(conversation_id: str) - str | None: conn sqlite3.connect(memory.db) cursor conn.execute( SELECT summary FROM summaries WHERE conversation_id ?, (conversation_id,) ) row cursor.fetchone() conn.close() return row[0] if row else None恢复会话时把库里读出的摘要作为ConversationSummaryBufferMemory的初始摘要塞回去用户就会感觉Agent从未断片。如果你用的是LangGraph这类框架可以在checkpoint里直接带上摘要效果类似。5.2 多级摘要层级压缩应对超长对话当对话跨越几十轮甚至上百轮时单层摘要也会变得非常长。这时候需要做多级摘要先把最近对话压缩成分段摘要再把分段摘要汇总成总摘要。这两层结构各司其职总摘要覆盖宏观目标、重大决策、身份信息分段摘要保留每个阶段的细节。多级摘要的好处是取用灵活。当Agent需要回答第一阶段我们当时怎么约定的可以直接读取对应分段摘要而不是从一份大而全的总摘要里找。这实际上是把记忆从一条线变成了一棵树信息组织方式更符合人类的记忆习惯。5.3 实测效果对比有/无Summarization的完整对话案例最后放一组我在测试环境里跑的实际效果对比。同样是30轮对话不启用Summarization时最后两轮请求的上下文约6000多token模型输出偶尔会遗漏早前确认的约束启用ConversationSummaryBufferMemory并把max_token_limit设为800之后上下文稳定在1200 token左右关键决策信息完整保留单轮响应速度也明显提升。这个数字说明了一个直观结论对于中长对话场景摘要压缩带来的收益不是略微优化而是决定可用性的差异。没有摘要机制Agent的使用时长一拉长基本必翻车加上摘要机制之后至少能把稳定对话的轮次从十轮以内拉到几十轮。回到开头那个工作台的比喻。我做这一套记忆方案之后最大的体会是Agent的智能不取决于它记得多少而取决于它在正确的时间忘掉多少。Summarization中间件本质上是给Agent安装了一个善于总结的私人助理它不会替你决策但它帮你把最该记住的事放在眼前。如果你正在为自己的Agent频繁遗忘而苦恼先别急着上更大的模型试试给记忆做做加减法往往效果来得更快。
阅读完成 · 觉得有帮助?