1. 长会话为什么会“断片”,以及摘要中间件到底在解决什么
做过对话类应用的人大概率都遇到过这种场景:用户跟你的 AI 助手聊了四五十轮,前面明确说过“我预算八千、主要拍娃、不要单反”,结果聊到后面推荐相机时,它又开始给你推两万的全画幅,或者干脆反问“请问您的预算是多少”。用户当场就炸了,觉得这 AI 是不是失忆了。
这不是模型笨,这是上下文窗口和Token 预算的物理限制在作祟。大模型的上下文窗口是有限的,早期模型 4K、8K,现在主流也就 128K、200K,看着挺大,但你要知道,Token 消耗是平方级增长的——每一轮对话都要把之前所有历史重新塞进去。聊到第 50 轮,光历史记录可能就吃掉几万 Token,成本飙升不说,一旦超出窗口,最早的内容就被直接截断丢弃,于是“断片”就发生了。
摘要中间件(SummarizationMiddleware)就是干这个的:它不让你无脑地把全部历史塞给模型,而是在对话变长时,自动把早期的对话内容压缩成一段摘要,用摘要替代原始历史,从而在保留关键信息的前提下,把 Token 用量压下来。LangChain 生态里这个组件是长会话场景的标配,配合 LangGraph 做状态管理,能实现“聊一百轮也不失忆、成本还可控”的效果。
这篇文章适合三类人看:一是正在用 LangChain 搭对话应用、被长会话 Token 问题折磨的开发者;二是想搞明白摘要中间件内部机制、准备自己魔改的进阶玩家;三是做工业智能体、客服机器人这类需要长期记忆场景的工程同学。我会从设计思路讲到实操代码,再把我踩过的坑和参数调优经验都摊开说。
2. 摘要中间件的整体设计思路与方案选型
2.1 为什么是“摘要”而不是“截断”或“全量保留”
处理长会话历史,业内常见就三条路,我做个对比你就明白为什么摘要中间件是更优解。
| 方案 | 做法 | 优点 | 致命问题 |
|---|---|---|---|
| 全量保留 | 每轮把完整历史塞进去 | 信息零丢失 | Token 爆炸,超窗即崩,成本极高 |
| 滑动窗口截断 | 只保留最近 N 轮 | 实现简单,成本低 | 早期关键信息永久丢失,必然断片 |
| 摘要压缩 | 早期历史压成摘要,近期保留原文 | 信息保留与成本平衡 | 实现稍复杂,摘要质量依赖模型 |
滑动窗口截断是最容易踩的坑。我见过不少项目图省事,直接messages[-10:],结果用户第一轮说的核心需求全没了。摘要中间件的思路是:近期对话保留原文(保证细节和语气),早期对话压缩成摘要(保证关键事实不丢)。这就像人开会,你记不住每个人说的每句话,但你会记住“上次会议定了 A 方案、预算 50 万、下周三交付”这种要点。
2.2 触发时机:什么时候该做摘要
摘要不是每轮都做,那样反而浪费。核心是设一个Token 阈值。常见做法是:当历史消息的总 Token 数超过模型上下文窗口的某个比例(比如 60%~70%)时,触发摘要。留出的那 30%~40% 是给当前对话、系统提示词和模型输出用的。
这里有个参数计算的实际例子。假设你用某模型,上下文窗口 128K Token,你设置触发阈值 0.6,那就是 76.8K Token 时触发摘要。摘要后目标压到多少?一般压到触发阈值的 30%~50%,也就是 23K~38K Token 左右。这样一次摘要能撑很多轮,不会频繁触发。
注意:阈值别设太高。我试过设 0.9,结果摘要还没做完,加上系统提示词和输出预留,直接超窗报错。0.6 到 0.7 是比较稳的区间。
2.3 摘要保留多少、丢弃多少:keep 与 summarize 的边界
摘要中间件通常有两个关键参数:keep(保留最近多少条消息原文)和触发阈值。keep设太小,近期上下文细节丢失,模型回答会变得笼统;设太大,摘要省下的 Token 又不够。
我的经验值是keep保留最近 6~10 轮对话(约 12~20 条消息)。这个量级能覆盖大多数“指代消解”需求——用户说“就刚才那个”“换成第二个”,模型能从前几轮找到指代对象。再往前的,交给摘要。
2.4 和 LangGraph 的配合:状态怎么管
单用 LangChain 的ConversationSummaryBufferMemory也能做,但生产环境我更推荐LangGraph + SummarizationMiddleware的组合。原因是 LangGraph 把对话状态显式建模成 State,摘要逻辑作为图中的一个节点或中间件钩子,可控性更强,也方便做 human-in-the-loop、持久化、多轮分支。
LangChain 和 LangGraph 的区别这里顺带说一句:LangChain 偏“链式调用”,适合线性流程;LangGraph 偏“状态图”,适合有循环、分支、需要长期维护状态的 Agent 场景。长会话摘要天然是状态管理问题,所以 LangGraph 更合适。面试里也常问这个区别,答“LangGraph 是 LangChain 之上做有状态编排的框架”基本就对了。
3. 核心细节解析与实操要点
3.1 摘要提示词怎么写才不丢关键信息
摘要质量直接决定长会话会不会断片。很多人随便写个“请总结以上对话”,结果摘要出来全是“用户询问了产品信息,助手进行了回答”这种废话,关键的数字、偏好、约束全没了。
好的摘要提示词要结构化,明确要求保留哪几类信息。我常用的模板是这样的:
SUMMARY_PROMPT = """请将以下对话历史压缩成结构化摘要,务必保留: 1. 用户明确提出的硬性约束(预算、时间、数量、禁忌) 2. 用户表达过的偏好和倾向 3. 已经达成的结论或决定 4. 尚未解决的问题 5. 关键实体名称(产品名、人名、地点) 不要保留:寒暄、重复确认、助手的长篇解释。 对话历史: {history} 结构化摘要:"""这个模板的关键在于明确“保留什么”和“丢弃什么”。模型做摘要时如果没有明确指令,倾向于保留“看起来重要”的礼貌性内容,反而丢掉数字。我实测下来,加上“硬性约束”“关键实体”这些词后,摘要里数字和名称的保留率明显提升。
3.2 Token 计数:别用字符数估算
Token 和字符数不是一回事。中文大概 1 个汉字 ≈ 1~2 个 Token,英文 1 个单词 ≈ 1.3 个 Token,代码和特殊符号更飘。用len(text)估算 Token 是新手最容易犯的错,会导致阈值判断严重失准。
正确做法是用对应模型的 tokenizer。LangChain 里可以用tiktoken(OpenAI 系)或模型自带的 tokenizer。如果你用的是国产模型,很多也提供了 tokenizer 接口。实在没有,用transformers的AutoTokenizer加载对应模型的分词器。
import tiktoken def count_tokens(text: str, model: str = "gpt-4") -> int: enc = tiktoken.encoding_for_model(model) return len(enc.encode(text))提示:如果你的应用混用多个模型,Token 计数要按“最贵的那个”或“窗口最小的那个”来算,否则切换模型时会翻车。
3.3 摘要的“递归压缩”问题
聊得特别久的时候,摘要本身也会变长。比如聊了 200 轮,第一次摘要 500 Token,第二次把“旧摘要+新对话”再摘要,可能变成 800 Token,越滚越大。这就是递归压缩膨胀。
解决办法有两个:一是摘要时对旧摘要也做压缩,明确要求“在保留关键信息的前提下尽量精简,目标 X Token 以内”;二是设置摘要的硬上限,超过就强制二次压缩。我在工业智能体项目里就遇到过这个,一个客服机器人聊了三天,摘要滚到 3000 Token,最后加了个“摘要不得超过 800 Token,超出则丢弃最不重要的细节”的约束才压住。
3.4 摘要与原始消息的拼接顺序
摘要生成后,怎么和保留的近期消息拼给模型?顺序很重要。正确顺序是:系统提示词 → 历史摘要 → 近期原文消息 → 当前用户输入。摘要要放在近期消息之前,并明确标注“以下是更早对话的摘要”,让模型知道这是背景而非当前上下文。
我见过有人把摘要放在最后,结果模型把摘要当成了“用户刚说的话”,回答完全跑偏。这个细节不注意,调试半天都找不到原因。
4. 实操过程与核心环节实现
4.1 环境准备与依赖安装
先把环境搭起来。LangChain 生态版本迭代快,建议用较新的稳定版,并且用虚拟环境隔离。
conda create -n summary-mw python=3.11 -y conda activate summary-mw pip install langchain langchain-core langgraph tiktoken如果你要用特定模型,再装对应的 SDK。这里我用一个通用的 ChatModel 接口演示,你替换成自己的模型即可。LangChain 安装时如果遇到依赖冲突,优先保证langchain-core和langgraph版本匹配,这俩是核心。
4.2 定义对话状态与摘要中间件
用 LangGraph 定义状态,把消息列表和摘要都放进 State。
from typing import Annotated, TypedDict from langgraph.graph.message import add_messages from langchain_core.messages import BaseMessage, SystemMessage class ChatState(TypedDict): messages: Annotated[list[BaseMessage], add_messages] summary: str # 累积的历史摘要add_messages是 LangGraph 提供的 reducer,会自动把新消息追加到列表,不用手动管理。summary字段单独存摘要,和消息列表解耦,方便控制。
4.3 摘要触发与压缩的核心逻辑
这是整个中间件的心脏。逻辑是:每次准备调用模型前,先算 Token,超阈值就压缩。
import tiktoken from langchain_core.messages import HumanMessage, AIMessage def count_tokens(text: str, model: str = "gpt-4") -> int: enc = tiktoken.encoding_for_model(model) return len(enc.encode(text)) def maybe_summarize(state: ChatState, llm, threshold: int = 60000, keep_recent: int = 12) -> ChatState: messages = state["messages"] total = sum(count_tokens(m.content) for m in messages) if total < threshold: return state # 没超阈值,不动 # 保留最近 keep_recent 条,其余压缩 to_summarize = messages[:-keep_recent] recent = messages[-keep_recent:] history_text = "\n".join( f"{'用户' if isinstance(m, HumanMessage) else '助手'}: {m.content}" for m in to_summarize ) # 把旧摘要也纳入压缩范围 if state.get("summary"): history_text = f"【已有摘要】{state['summary']}\n\n【新增对话】\n{history_text}" prompt = SUMMARY_PROMPT.format(history=history_text) new_summary = llm.invoke([HumanMessage(content=prompt)]).content return {"messages": recent, "summary": new_summary}这段代码有几个关键点。第一,to_summarize是除最近keep_recent条之外的所有消息,包括之前已经摘要过的部分——通过把旧摘要拼进history_text实现递归压缩。第二,返回时messages只留近期原文,摘要单独存,这样下次触发时不会重复压缩已经压过的内容。第三,阈值 60000 是针对 128K 窗口的保守值,你可以按自己模型调整。
4.4 接入 LangGraph 主流程
把摘要逻辑挂到图里,在调用模型前执行。
from langgraph.graph import StateGraph, END def build_graph(llm): graph = StateGraph(ChatState) def summarize_node(state): return maybe_summarize(state, llm) def chat_node(state): # 拼接:系统提示 + 摘要 + 近期消息 sys = SystemMessage(content="你是一个有长期记忆的助手。") context = [] if state.get("summary"): context.append(SystemMessage( content=f"以下是更早对话的摘要:\n{state['summary']}" )) context.extend(state["messages"]) resp = llm.invoke([sys] + context) return {"messages": [resp]} graph.add_node("summarize", summarize_node) graph.add_node("chat", chat_node) graph.set_entry_point("summarize") graph.add_edge("summarize", "chat") graph.add_edge("chat", END) return graph.compile()流程是:每轮先过summarize节点判断要不要压缩,再进chat节点生成回复。chat_node里拼接顺序严格遵循“系统提示 → 摘要 → 近期消息”,这是前面强调过的关键细节。
4.5 参数调优的实测记录
我在一个客服场景里做过对比测试,模型窗口 128K,用户平均单轮输入 80 Token,助手回复 200 Token。测试结果:
| 配置 | 触发阈值 | keep_recent | 平均每轮 Token | 断片率 |
|---|---|---|---|---|
| 无摘要 | - | - | 随轮数线性增长,50 轮后超窗 | 高 |
| 激进压缩 | 40000 | 6 | 约 8000 | 中(近期细节丢) |
| 平衡配置 | 60000 | 12 | 约 15000 | 低 |
| 保守压缩 | 80000 | 20 | 约 25000 | 极低但成本高 |
最后选了平衡配置。激进压缩虽然省钱,但keep_recent=6时用户说“第二个方案”模型经常找不到指代,断片率反而上升。keep_recent=12是个甜点值。
5. 常见问题与排查技巧实录
5.1 摘要后模型“失忆”了关键数字
这是最高频的问题。原因通常是摘要提示词没强调保留数字,或者摘要模型本身能力弱。排查步骤:先把生成的摘要打印出来看,如果摘要里就没数字,那是提示词问题;如果摘要里有但模型回答时没用,那是拼接顺序或系统提示的问题。
解决办法:提示词里明确“所有数字必须原样保留”,并且摘要用能力较强的模型做,别用最便宜的那个。摘要是一次性成本,值得用好模型。
5.2 Token 计数和实际消耗对不上
常见原因是用了错误的 tokenizer,或者没算系统提示词和工具调用的 Token。排查时把每次请求的完整 payload 打出来,逐段算。另外注意,有些模型的计费 Token 和上下文 Token 口径不同,以官方文档为准。
5.3 摘要递归膨胀导致越压越大
前面提过,解决办法是给摘要设硬上限。在提示词里加“摘要总长度不得超过 800 Token”,并在代码里做二次校验,超了就再压一次。我一般会写个enforce_summary_limit函数兜底。
5.4 多轮工具调用时摘要把工具结果也压没了
Agent 场景里,工具返回的结果(比如查到的订单号、API 返回的 JSON)如果被摘要压掉,后续就没法用了。解决办法是对工具消息单独处理:要么不压缩工具消息,要么在摘要里明确保留“工具调用结论”。我在工业智能体项目里的做法是给工具结果打标记,摘要时优先保留带标记的内容。
5.5 常见问题速查表
| 现象 | 可能原因 | 排查方向 | 解决 |
|---|---|---|---|
| 关键数字丢失 | 摘要提示词未强调 | 打印摘要内容 | 提示词加“数字原样保留” |
| 指代消解失败 | keep_recent 太小 | 检查保留条数 | 调到 10~12 |
| 摘要越滚越大 | 递归膨胀 | 看摘要 Token 趋势 | 设摘要硬上限 |
| Token 超窗报错 | 阈值设太高 | 核对窗口与阈值 | 阈值降到 0.6 |
| 回答跑偏 | 拼接顺序错 | 检查消息顺序 | 摘要放近期消息前 |
| 工具结果丢失 | 工具消息被压 | 检查摘要是否含工具结论 | 工具消息单独保留 |
5.6 几个我踩过的坑
第一个坑是摘要和消息列表双写。早期我把摘要也塞回 messages 列表,结果下次压缩时摘要被当成普通消息又压一遍,信息层层衰减。后来改成摘要单独字段存储,才解决。
第二个坑是异步场景下的竞态。高并发时多个请求同时触发摘要,可能对同一段历史重复压缩。解决办法是加锁或把摘要做成独立的后台任务,不阻塞主对话流。
第三个坑是摘要语言漂移。用户用中文聊,摘要模型有时输出英文摘要,导致后续模型回答语言混乱。提示词里明确“用与对话相同的语言输出摘要”能解决。
6. 进阶玩法:让摘要中间件更聪明
6.1 分层摘要:短期、中期、长期记忆
单一摘要不够用的时候,可以做分层。近期对话保留原文(短期),中期对话压成详细摘要(中期),更早的压成一句话要点(长期)。这类似人脑的记忆分层,实现上就是维护多个摘要字段,按时间窗口滚动压缩。LangGraph 的状态图天然适合做这种分层,每个层级一个节点。
6.2 摘要 + 向量检索:需要时再召回
摘要会丢细节,但有些细节后面可能突然要用。进阶做法是把被压缩的原始对话存进向量库,摘要里保留“索引提示”,当用户提到相关话题时,用检索把原始细节召回。这就是 RAG 思路用在对话记忆上。基于 LangChain 的 RAG 流程做这个很顺,Chroma 或类似向量库都行。
6.3 摘要质量评估:怎么知道压得好不好
生产环境要有监控。我一般记录三个指标:摘要压缩比(压缩后 Token / 压缩前 Token)、关键实体保留率(摘要里是否含原始对话的关键名词和数字)、断片率(用户纠正“我之前说过”的频率)。断片率是最直接的业务指标,一旦上升就说明摘要策略要调。
6.4 和 human-in-the-loop 结合
有些关键决策不该被摘要模糊掉。用 LangGraph 做 human-in-the-loop 时,可以在摘要节点后加个人工确认环节,让运营人员审核摘要是否保留了关键信息。这在工业智能体、医疗咨询这类高要求场景很有必要。
7. 一些参数选择的经验值汇总
最后把我这些年攒下来的参数经验值整理一下,你可以直接拿去当起点,再按自己场景微调。
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 触发阈值比例 | 0.6~0.7 | 占模型窗口比例 |
| keep_recent | 10~12 条 | 约 5~6 轮对话 |
| 摘要目标长度 | 触发阈值的 30%~50% | 压缩后大小 |
| 摘要硬上限 | 800~1500 Token | 防递归膨胀 |
| 摘要模型 | 中等能力以上 | 别用最便宜的 |
| 摘要语言 | 跟随对话语言 | 提示词明确 |
这套配置我在几个项目里跑下来,长会话断片率能压到很低,Token 成本相比全量保留能省 60% 以上。当然具体数字因场景而异,关键是理解每个参数背后的权衡,而不是照抄。
摘要中间件这东西,说穿了就是“用一次性的摘要成本,换长期的上下文稳定”。它不复杂,但细节多,尤其是提示词、拼接顺序、递归压缩这几个点,不注意就会踩坑。我个人的体会是,先把基础版跑通,再根据实际断片情况针对性调参,比一上来就搞分层、搞向量召回要务实得多。等你把单层摘要玩明白了,再往上叠进阶玩法,心里才有底。