LangChain长会话断片终结者:摘要中间件实战指南
1. 长会话为什么会“断片”从Token机制说起做过对话类应用的人基本都踩过这个坑聊到二三十轮之后模型突然开始“失忆”前面明确说过的需求、约束、人设全部失效甚至开始胡言乱语。很多人第一反应是模型不行换模型、调温度、加提示词折腾一圈发现治标不治本。问题的根子不在模型而在上下文窗口和Token预算这两个硬约束上。先把概念捋清楚。Token是模型处理文本的最小单位中文里大致一个汉字对应1到2个Token英文一个单词通常1到1.5个Token。每次调用模型你发过去的所有内容——系统提示词、历史对话、当前问题——都会被拼成一个完整的上下文送进去模型按Token计费也按Token限制长度。主流模型的上下文窗口从早期的4K、8K到现在常见的32K、128K看起来很大但实际用起来消耗极快。算一笔账你就明白了。假设系统提示词500 Token每轮用户输入平均100 Token模型回复平均300 Token那么一轮对话就是400 Token的增量。20轮下来就是8000 Token加上系统提示词接近9000。如果中间有长文档、代码片段、工具调用返回结果单轮就能吃掉几千Token。128K的窗口听起来能撑很久但实际业务里往往十几轮就逼近上限这时候要么报错要么被迫截断历史一截断就“断片”。提示很多人以为上下文窗口是“记忆容量”其实它更像“工作台面积”。工作台就这么大东西堆满了就得往下扔扔什么、怎么扔才是摘要中间件要解决的核心问题。那为什么不能简单粗暴地只保留最近N轮因为长会话里真正有价值的信息分布是不均匀的。用户第一轮说的核心需求、第五轮确认的关键约束、第十二轮纠正的一个错误认知这些可能比最近三轮的寒暄重要得多。只保留最近内容等于把前面的关键信息全丢了模型自然“断片”。而全量保留又装不下这就是矛盾所在。SummarizationMiddleware摘要中间件就是在这个背景下出现的。它的思路很直接当上下文快满的时候不粗暴截断而是把较早的历史对话压缩成一段摘要用摘要替代原始对话从而在有限的Token预算里保留尽可能多的有效信息。LangChain生态里这类中间件已经比较成熟配合LangGraph做状态管理能实现比较优雅的长会话处理。这篇文章适合谁看如果你正在用LangChain或LangGraph做对话应用遇到过长会话失忆、Token超限、成本失控的问题那这篇内容基本就是为你写的。我会从设计思路、核心机制、实操落地、问题排查几个层面拆开讲尽量把每个“为什么”都说透让你看完能直接上手改自己的项目。2. 摘要中间件的整体设计思路拆解2.1 核心矛盾信息完整性与Token预算的博弈摘要中间件要解决的本质问题是在固定Token预算下最大化有效信息保留量。这是个典型的资源分配问题。你可以把它想象成整理行李箱箱子就那么大衣服、鞋子、洗漱用品都要带但装不下全部怎么办把大件衣物卷起来压缩把不重要的东西舍弃把最关键的放最上层方便取用。对应到对话系统里“压缩”就是摘要“舍弃”就是丢弃低价值内容“放最上层”就是保证最近对话的完整性。一个好的摘要策略必须同时满足三个条件摘要后的总Token数在预算内、摘要内容覆盖了关键信息、最近几轮对话保持原样不被压缩。这三个条件缺一个效果就会打折扣。为什么最近几轮不能压缩因为当前对话的语境连贯性最重要用户刚说的话、模型刚回复的内容如果被摘要成一句话细节全丢了下一轮回复就会答非所问。所以标准做法是设置一个保留窗口比如最近4到6轮对话原样保留只对更早的历史做摘要。2.2 为什么选摘要而不是其他方案处理长会话有好几种思路各有取舍。我把常见的几种列出来对比一下你就明白为什么摘要中间件是当前比较优的解法。方案原理优点缺点直接截断超过限制就丢弃最早的内容实现简单零额外成本关键信息丢失严重容易断片滑动窗口只保留最近N轮实现简单上下文连贯早期关键信息全丢向量检索把历史存入向量库按相关度召回理论上能保留全部信息召回不准延迟高实现复杂摘要压缩把早期历史压缩成摘要保留关键信息Token可控摘要质量依赖模型有信息损耗分层记忆短期长期记忆分离结构清晰扩展性好实现复杂需要额外存储直接截断和滑动窗口太粗暴向量检索在对话场景里召回效果不稳定——用户问“刚才说的那个参数”向量检索很难精准命中。摘要压缩是在简单和效果之间找到了一个平衡点用一次额外的模型调用换取Token的大幅压缩同时保留语义层面的关键信息。这个额外调用的成本相比省下来的Token和避免的断片问题性价比很高。LangChain的SummarizationMiddleware基本就是这个思路的工程化实现。它会在对话历史达到某个阈值时触发摘要把早期对话交给模型压缩然后用摘要替换原始历史。配合LangGraph的状态管理摘要可以作为状态的一部分持久化下次对话直接复用不用重复摘要。2.3 触发时机什么时候该做摘要触发时机是个关键设计点太早触发浪费算力太晚触发可能已经超限。常见的有三种触发策略按Token数触发当历史Token数超过预算的某个比例比如70%时触发。这是最直接的方式但需要准确计算Token数不同模型的分词方式不一样得用对应的tokenizer。按轮数触发每N轮对话触发一次摘要。实现简单但不够精确短对话浪费长对话可能来不及。混合触发同时监控Token数和轮数任一条件满足就触发。实际项目里推荐这种兼顾精确性和兜底。我个人的经验是Token阈值设在预算的65%到75%之间比较合适。留出25%到35%的空间给当前轮的用户输入、模型回复和可能的工具调用结果。如果设得太高比如90%很可能摘要还没做完下一轮就超限了。注意触发阈值不是拍脑袋定的要结合你的实际业务算。如果你的应用经常有长文档输入阈值要调低如果都是短对话可以适当调高。2.4 摘要的粒度与保留策略摘要不是把所有历史揉成一段就完事粒度设计直接影响效果。常见的有两种一种是滚动摘要每次触发时把“上一次的摘要新增的早期对话”一起重新摘要形成一个不断更新的摘要。好处是摘要始终是最新的坏处是每次都要重新处理成本略高而且多次摘要会有信息累积损耗。另一种是分段摘要把历史按时间段或轮次分成若干段每段独立摘要保留多段摘要。好处是信息保留更完整坏处是摘要本身也会占Token段数多了照样超限。实际项目里滚动摘要用得更多因为Token控制更简单。但要注意滚动摘要做多次之后早期细节会逐渐模糊。我的做法是给摘要加一个“关键信息锚点”机制在摘要里强制保留用户明确提出的需求、约束、偏好这类信息用固定格式标注避免被后续摘要稀释掉。3. 核心机制与实操要点详解3.1 Token计算别用字符数糊弄自己很多人图省事用字符数除以2来估算Token这在中文场景下误差很大。中文一个汉字通常是1到2个Token但标点、数字、英文混排的情况很复杂。准确的做法是用模型对应的tokenizer。以OpenAI系列为例可以用tiktoken库import tiktoken def count_tokens(text, modelgpt-4): encoding tiktoken.encoding_for_model(model) return len(encoding.encode(text))如果是其他模型LangChain提供了统一的接口from langchain_community.callbacks import get_openai_callback # 或者用模型自带的token计数方法 from langchain_openai import ChatOpenAI llm ChatOpenAI(modelgpt-4) # 部分模型支持直接获取token数为什么要这么较真因为Token计算误差会直接导致触发时机不准。你以为是70%实际可能已经85%了摘要还没触发就超限了。我见过有项目用字符数估算结果中文场景下实际Token是估算值的1.8倍阈值完全失效。3.2 摘要提示词的设计决定摘要质量的关键摘要质量直接决定长会话会不会断片而摘要质量又取决于提示词。很多人随便写一句“请总结以下对话”结果摘要出来的东西全是废话关键信息一个没留。好的摘要提示词要明确告诉模型保留什么、舍弃什么、用什么格式。我常用的摘要提示词模板是这样的SUMMARY_PROMPT 请对以下对话历史进行摘要要求 1. 保留用户明确提出的所有需求、约束、偏好和纠正 2. 保留已经确认的关键事实、参数、决策 3. 保留未完成的任务和待办事项 4. 舍弃寒暄、重复确认、无关闲聊 5. 用简洁的条目式输出每条不超过30字 对话历史 {history} 摘要这个提示词的核心是分类保留需求类、事实类、任务类必须留闲聊类可以丢。这样摘要出来的内容信息密度高Token利用率好。还有一个技巧是结构化输出。让摘要按固定格式输出比如【用户需求】... 【关键约束】... 【已确认事实】... 【待办事项】...结构化摘要的好处是后续可以按需提取比如只把“用户需求”和“关键约束”放进上下文“待办事项”单独存起来。这样Token控制更精细。3.3 保留窗口的设置最近几轮不能动保留窗口的大小需要权衡。太小了当前对话的连贯性不够太大了留给摘要的空间就少。我的经验值是保留最近4到6轮对话具体看你的单轮Token量。如果单轮对话平均500 Token保留6轮就是3000 Token。假设总预算是16K系统提示词占1K那留给摘要和当前输入的空间是12K很充裕。如果单轮平均2000 Token比如有长文档那保留4轮就是8000 Token留给摘要的空间就紧张了这时候要么降低保留轮数要么提高总预算。保留窗口还有一个细节工具调用结果要不要保留。如果对话里有工具调用返回结果往往很长。我的做法是工具调用结果如果已经体现在摘要里了原始结果就可以丢弃如果还没摘要就保留最近一次的工具结果更早的压缩掉。3.4 摘要的存储与复用摘要做完之后存哪里这决定了下次对话能不能复用。最简单的做法是存在内存里但服务重启就没了。生产环境一般用Redis或数据库持久化。LangGraph的状态管理天然支持这个。你可以把摘要作为状态的一个字段配合checkpointer持久化from langgraph.checkpoint.sqlite import SqliteSaver memory SqliteSaver.from_conn_string(:memory:) # 摘要作为状态的一部分 class ConversationState(TypedDict): messages: list summary: str summary_up_to: int # 摘要覆盖到第几条消息summary_up_to这个字段很关键它标记了摘要覆盖的范围。下次对话时从summary_up_to之后的消息开始拼接前面的用摘要替代。这样避免了重复摘要也保证了消息顺序不乱。提示摘要和原始消息要分开存储。原始消息保留在数据库里用于审计和回溯摘要只用于构建上下文。别为了省空间把原始消息删了出了问题没法排查。3.5 摘要中间件的代码骨架把上面的点串起来一个摘要中间件的核心逻辑大概是这样class SummarizationMiddleware: def __init__(self, llm, max_tokens8000, keep_recent4, trigger_ratio0.7): self.llm llm self.max_tokens max_tokens self.keep_recent keep_recent self.trigger_ratio trigger_ratio self.summary self.summary_up_to 0 def should_summarize(self, messages): total self._count_all_tokens(messages) return total self.max_tokens * self.trigger_ratio def summarize(self, messages): # 保留最近keep_recent轮其余做摘要 to_summarize messages[self.summary_up_to:-self.keep_recent*2] if not to_summarize: return history_text self._format_messages(to_summarize) prompt SUMMARY_PROMPT.format(historyhistory_text) new_summary self.llm.invoke(prompt).content # 滚动摘要合并旧摘要 if self.summary: merge_prompt f合并以下两段摘要去重并保留关键信息\n旧{self.summary}\n新{new_summary} self.summary self.llm.invoke(merge_prompt).content else: self.summary new_summary self.summary_up_to len(messages) - self.keep_recent * 2 def build_context(self, messages): recent messages[self.summary_up_to:] if self.summary: return [{role: system, content: f历史摘要{self.summary}}] recent return recent这段代码是骨架实际用的时候要处理异常、并发、持久化等问题。但核心逻辑就是判断是否触发、压缩早期历史、保留最近对话、拼接上下文。4. 完整实操流程与关键环节实现4.1 环境准备与依赖安装先把环境搭起来。LangChain生态更新很快版本兼容性是个坑建议用虚拟环境隔离。conda create -n summary-mw python3.11 conda activate summary-mw pip install langchain langchain-openai langgraph tiktoken版本上LangChain 0.2.x和LangGraph 0.2.x搭配比较稳。如果你用的是LangChain 0.1.x部分API不兼容建议升级。安装完之后先跑个最小示例验证环境from langchain_openai import ChatOpenAI llm ChatOpenAI(modelgpt-4o-mini, temperature0) print(llm.invoke(你好).content)能正常返回就说明环境没问题。这里用gpt-4o-mini做摘要成本低、速度快摘要任务不需要太强的模型。4.2 构建带摘要的对话链接下来把摘要中间件接进对话流程。我用LangGraph来组织因为它的状态管理更适合这种有状态的场景。from typing import TypedDict, Annotated from langgraph.graph import StateGraph, END from langgraph.graph.message import add_messages from langchain_core.messages import HumanMessage, AIMessage, SystemMessage class State(TypedDict): messages: Annotated[list, add_messages] summary: str summary_up_to: int def chat_node(state: State): # 构建上下文 context [] if state.get(summary): context.append(SystemMessage(contentf历史摘要{state[summary]})) context.extend(state[messages][state.get(summary_up_to, 0):]) response llm.invoke(context) return {messages: [response]} def summarize_node(state: State): # 摘要逻辑 messages state[messages] up_to state.get(summary_up_to, 0) keep 4 to_sum messages[up_to:-keep*2] if len(messages) keep*2 else [] if not to_sum: return {} # ... 调用摘要 return {summary: new_summary, summary_up_to: len(messages) - keep*2}图的结构是chat节点处理对话然后判断是否需要摘要需要就走summarize节点不需要就结束。这个流程跑通之后长会话就不会因为Token超限而报错了。4.3 参数调优阈值、保留轮数、摘要长度参数调优是个反复试的过程没有万能值。我给出一个调优的思路和参考值。触发阈值从0.7开始试。如果发现经常在摘要完成前就超限降到0.6如果发现摘要太频繁、成本高升到0.8。监控指标是“摘要触发时的实际Token占比”和“摘要后剩余Token占比”。保留轮数从4轮开始。如果你的对话单轮很短比如客服问答可以降到2到3轮如果是复杂任务比如代码调试升到6到8轮。判断标准是摘要后模型回复是否还能接上当前话题。摘要长度控制在原始历史的15%到25%。太短了信息丢太多太长了省不下Token。可以在摘要提示词里加一句“摘要长度控制在XXX字以内”。参数起始值调整方向判断依据触发阈值0.7超限频繁则降成本高则升摘要触发时Token占比保留轮数4话题接不上则升空间不够则降回复连贯性摘要长度20%信息丢失则升Token紧张则降摘要后关键信息覆盖率4.4 实测记录一个20轮对话的完整过程我拿一个实际场景测了一遍用户让助手帮忙规划一个技术方案中间涉及需求确认、技术选型、参数调整、方案修改。总共20轮对话单轮平均400 Token。前8轮Token数在6400左右没触发摘要。第9轮触发此时历史Token约7200超过8000的70%即5600。摘要把前5轮压缩成一段200 Token的摘要保留最近4轮。摘要后总Token降到约4200。第10到14轮继续对话Token缓慢增长。第15轮再次触发此时摘要覆盖前9轮摘要长度约350 Token滚动合并后保留最近4轮。摘要后总Token约4800。第20轮结束时总Token稳定在6000左右没有超限。整个过程中模型在第12轮还能准确引用第3轮用户说的“预算控制在5万以内”这个约束说明摘要保留了关键信息。对比不做摘要的情况第12轮左右就会超过8000 Token要么报错要么被迫截断截断后模型就忘了前面的约束开始给出超预算的方案。4.5 成本核算摘要到底划不划算摘要本身要调用模型有成本。算一下每次摘要调用输入约2000 Token输出约300 Token用gpt-4o-mini的话成本极低。20轮对话触发2次摘要额外成本可以忽略。省下来的成本呢如果不做摘要要么用更大的上下文窗口更贵的模型要么截断导致效果差需要重试。截断重试的成本往往比摘要高得多。而且摘要让对话能持续更久用户体验好留存高这个价值不是Token成本能衡量的。提示摘要模型和对话模型可以分开。对话用强模型保证质量摘要用便宜的小模型控制成本。实测gpt-4o-mini做摘要完全够用。5. 常见问题与排查技巧实录5.1 摘要后模型还是“失忆”这是最常见的问题。原因通常有三个摘要提示词没强调保留关键信息、摘要长度太短导致信息丢失、保留窗口设置不当导致摘要和最近对话之间有断层。排查方法把摘要内容打印出来看对照原始对话检查用户的核心需求、约束、已确认事实是否都在。如果不在改提示词如果在但模型还是忘检查摘要和最近对话的拼接顺序确保摘要作为system消息放在最前面。我踩过的一个坑是摘要里保留了信息但格式是流水账模型在长上下文里注意力分散没注意到。后来改成结构化摘要用【】标注类别模型对关键信息的召回明显提升。5.2 摘要触发太频繁导致响应变慢每次摘要都要调模型如果触发太频繁用户会感觉回复变慢。解决办法提高触发阈值、增大保留窗口、或者用异步摘要——对话正常进行摘要在后台做做完再更新状态。异步摘要是生产环境推荐的做法。用户感知不到摘要的延迟对话流畅度好。实现上用消息队列或者后台任务摘要完成后更新summary和summary_up_to字段。5.3 滚动摘要多次后信息失真滚动摘要做多了早期信息会被反复压缩逐渐模糊。比如用户第一轮说的“要支持中文”经过5次摘要后可能变成“支持多语言”再经过几次可能就没了。对策是关键信息锚点在摘要提示词里要求把用户明确的需求和约束用固定格式标注每次合并摘要时强制保留这些锚点。另一个做法是限制滚动次数超过一定次数后把摘要固化不再合并新的摘要单独存一段。5.4 Token计算不准导致阈值失效前面提过用字符数估算Token在中文场景下误差大。除了用tokenizer还要注意不同模型的tokenizer不一样。gpt-4和gpt-4o的tokenizer就不同混用会算错。排查方法写个测试拿一段已知Token数的文本用你的计算方法算一遍对比误差。误差超过10%就得换方法。5.5 常见问题速查表问题现象可能原因排查方法解决方案摘要后失忆提示词没保留关键信息打印摘要对照原文改提示词加结构化格式响应变慢摘要触发太频繁看触发日志提高阈值或异步摘要信息失真滚动摘要次数过多对比早期摘要和原文加锚点限制滚动次数阈值失效Token计算不准用已知文本测试换对应tokenizer摘要和对话断层保留窗口设置不当检查拼接顺序调整保留轮数确保衔接成本超预期摘要模型太贵看调用记录换小模型做摘要5.6 几个独家避坑技巧第一个技巧摘要也要做去重。滚动摘要合并时新旧摘要可能有重复内容让模型去重能省不少Token。提示词里加一句“合并时去除重复信息”。第二个技巧给摘要加时间戳或轮次标记。比如“【第1-5轮】用户需求...”这样模型知道信息的时效性不会把很早的临时决定当成当前约束。第三个技巧保留一条“原始消息索引”。摘要里标注关键信息来自第几轮需要时可以回溯原始消息。这在排查问题时特别有用。第四个技巧摘要失败要有降级方案。如果摘要调用超时或报错不能卡住整个对话。降级方案可以是跳过本次摘要用滑动窗口临时顶一下下次再试。6. 进阶扩展从摘要中间件到分层记忆摘要中间件解决了单次会话内的长上下文问题但跨会话的记忆还需要额外设计。一个自然的扩展是分层记忆架构短期记忆用摘要中间件管理当前会话长期记忆用向量库存储跨会话的关键信息。具体做法是会话结束时把摘要存入向量库标注用户ID和时间。下次该用户开始新会话时检索相关历史摘要作为初始上下文注入。这样既控制了Token又保留了跨会话的连续性。LangGraph的checkpointer机制天然支持这种扩展。你可以把摘要作为状态持久化配合向量库做检索。工业智能体场景里这种分层记忆是标配——设备的历史故障记录、用户的偏好设置、之前的诊断结论都需要跨会话保留。另一个扩展方向是多级摘要。把摘要分成粗粒度和细粒度两级粗粒度摘要保留核心决策和结论细粒度摘要保留具体参数和细节。构建上下文时粗粒度全量注入细粒度按相关度检索注入。这样在Token有限的情况下信息利用率更高。我在实际项目里用这套架构跑过工业设备的诊断助手单次会话能稳定支撑50轮以上不“断片”跨会话也能记住设备的历史问题。Token成本相比全量上下文降低了70%以上效果反而更好因为摘要强制模型聚焦关键信息减少了噪声干扰。最后分享一个我在调优过程中总结的经验摘要中间件的效果七分靠提示词三分靠参数。别在参数上死磕先把摘要提示词打磨好让模型知道该保留什么、舍弃什么效果立竿见影。参数调优是在提示词到位之后的锦上添花顺序反了会事倍功半。