长对话会撑爆上下文窗口。学会用 LLM 自动生成"滚动摘要",
让 Chatbot 既记得全程对话,又不消耗大量 Token。
在前几节课里,我们已经学会用 MessagesState 维护对话历史,也尝试过用 trim_messages 和 filter_messages 来控制上下文长度。但这些方法有一个共同的缺陷:它们是丢弃式的——旧消息被直接删掉,那部分对话内容就永久消失了。
真实 LLM 对话面临的挑战是:
上图展示了一场长对话接近上下文上限的危险状态。直接 trim 的结果是让 LLM 忘掉对话开头的关键信息;而本节课的方案——对话摘要(Conversation Summarization)——则是用 LLM 把旧消息压缩成一段摘要文字保留下来,而不是直接删除。
摘要方案的本质是用信息压缩换取 Token 节省。摘要必然有信息损失,但它保留了对话的语义要点,而 trim 则是硬性丢弃原始文字。对于需要长期追踪用户意图的 Chatbot,摘要方案远优于简单截断。
| 方法 | 旧消息的命运 | 信息保留程度 | 实现复杂度 |
|---|---|---|---|
| trim_messages | 直接从 State 中删除 | 低(完全丢失) | 低 |
| filter_messages | 按类型/ID 过滤掉 | 中(保留特定类型) | 低 |
| 摘要(本节课) | LLM 提炼为摘要字符串 | 高(保留语义要点) | 中 |
本节课实现的摘要策略叫做渐进式滚动摘要(Progressive Rolling Summary)。它的工作方式如下:
对话正常进行,消息逐条追加到 State 的 messages 列表中。
每轮对话结束后,检查 messages 列表的长度。一旦超过设定阈值(本例为 6 条),进入摘要流程。
把当前所有消息 + 一个"请生成摘要"的提示词,一起发给 LLM,得到一段摘要文字。如果已有旧摘要,则要求 LLM 把旧摘要和新消息合并成新摘要(滚动更新)。
用 RemoveMessage 删除除最近 2 条以外的所有旧消息,只保留最新的对话片段。
下次调用 LLM 时,把 summary 字段的内容包装成 SystemMessage,追加在消息列表最前面,让 LLM 知道之前的对话要点。
这套机制实现了真正的无损长期记忆:消息列表始终保持短小,而摘要字段随对话进展不断累积语义信息,成为 LLM 的"长期记忆载体"。
实现摘要机制的第一步,是扩展 State 的数据结构。LangGraph 提供了一个内置的 MessagesState,它已经包含了 messages 字段(带 add_messages Reducer,支持追加语义)。我们只需要继承它,并额外添加一个 summary 字符串字段:
from langgraph.graph import MessagesState
class State(MessagesState):
# MessagesState 已内置:
# messages: Annotated[list[AnyMessage], add_messages]
# 我们新增一个 summary 字段,用于存储对话摘要
summary: str
这里有几个关键点值得理解:
MessagesState 是什么:它是 LangGraph 对 TypedDict 的预置封装,内部的 messages 字段用了 Annotated[list, add_messages] 注解,意味着多次返回的 messages 会追加而不是覆盖summary: str 的行为:普通字符串字段没有 Reducer,默认是覆盖语义——每次 summarize_conversation 节点返回新摘要时,直接替换旧摘要summary 字段在图刚启动时不存在,需要用 state.get("summary", "") 安全读取,避免 KeyError将 summary 设计为覆盖语义(而不是追加语义)是故意的:每次摘要节点都会生成一个包含了历史信息的完整新摘要,因此旧摘要可以安全覆盖。messages 使用追加语义,保证每轮 AI 回复都正确追加到列表末尾,而不会覆盖之前的消息。两者配合,构成完整的状态管理方案。
call_model 是负责调用 LLM 的核心节点。它的特别之处在于:在调用 LLM 之前,它会检查 State 中是否存在摘要,如果有,就把摘要包装成 SystemMessage 插入到消息列表最前面,让 LLM 能"看到"之前的对话背景。
from langchain_core.messages import SystemMessage, HumanMessage, RemoveMessage
def call_model(state: State):
# ① 尝试获取现有摘要,不存在则默认为空字符串
summary = state.get("summary", "")
# ② 如果有摘要,把它包装成 SystemMessage 前置到消息列表
if summary:
system_message = f"Summary of conversation earlier: {summary}"
# SystemMessage 放最前面,后面接最近的几条真实消息
messages = [SystemMessage(content=system_message)] + state["messages"]
else:
# 没有摘要时直接使用原始消息列表
messages = state["messages"]
# ③ 调用 LLM,传入(可能已注入摘要的)消息列表
response = model.invoke(messages)
# ④ 返回 AI 的回复(add_messages Reducer 会自动追加)
return {"messages": response}
让我们通过一个对话演示来直观理解 LLM 实际接收到的内容:
call_model 函数在本地构建了包含 SystemMessage 的消息列表,但只把 LLM 的 response 返回给 State。SystemMessage 只是调用 LLM 时的"上下文补丁",不会永久存入 State,因此不会占用额外的 messages 空间。
在 LangChain 的消息体系中,SystemMessage 代表系统级指令,是告诉 LLM"你现在的背景/角色/规则"的最合适方式。摘要作为对话背景,以 SystemMessage 传入,语义上比用 HumanMessage 更准确,也更符合大多数 LLM 的训练惯例。
这是本课最核心的节点,它完成三件事:
RemoveMessage 删除除最近 2 条以外的全部旧消息def summarize_conversation(state: State):
# ① 获取已有摘要(可能为空字符串)
summary = state.get("summary", "")
# ② 根据是否已有摘要,构造不同的提示词
if summary:
# 已有摘要 → 要求"在现有摘要基础上延伸",而不是从头写
# 这就是"滚动摘要"的关键:避免重复计算旧内容
summary_message = (
f"This is summary of the conversation to date: {summary}\n\n"
"Extend the summary by taking into account the new messages above:"
)
else:
# 第一次生成摘要
summary_message = "Create a summary of the conversation above:"
# ③ 构造给 LLM 的完整消息列表(历史消息 + 摘要请求)
messages = state["messages"] + [HumanMessage(content=summary_message)]
response = model.invoke(messages)
# ④ 生成 RemoveMessage 对象列表,标记需要删除的旧消息
# state["messages"][:-2] = 除最后2条外的所有消息
delete_messages = [RemoveMessage(id=m.id) for m in state["messages"][:-2]]
# ⑤ 同时返回:新摘要(覆盖旧摘要) + 删除指令(清理旧消息)
return {"summary": response.content, "messages": delete_messages}
RemoveMessage 是 LangChain Core 提供的一个特殊消息类型。当它被放入 messages 列表返回时,add_messages Reducer 会识别它,并从 State 中删除具有对应 id 的消息,而不是把 RemoveMessage 本身追加进去。这是一种优雅的"消息删除信号"机制。
保留最近 2 条消息(通常是最后一轮的 Human + AI 消息对)有两个目的:
条件边的路由函数 should_continue 决定了图在每轮对话结束后的走向:是直接结束(END),还是先去做摘要再结束。
from langgraph.graph import END
from typing_extensions import Literal
def should_continue(state: State) -> Literal["summarize_conversation", END]:
"""返回下一个要执行的节点名称。"""
messages = state["messages"]
# 关键阈值:超过 6 条消息就触发摘要
if len(messages) > 6:
return "summarize_conversation"
# 消息数在阈值内,直接结束(等下一轮继续积累)
return END
阈值设为 6 是一个示例值。在实际项目中,你可以根据以下因素调整:
| 考量因素 | 建议策略 |
|---|---|
| 模型上下文窗口大小 | 窗口越小,阈值应越低;GPT-4o(128K)可以设较高阈值 |
| 每条消息的平均长度 | 如果消息都很长(比如代码块),应降低阈值 |
| 摘要的调用成本 | 摘要本身也消耗 Token,过于频繁会得不偿失 |
| 对话连贯性要求 | 高连贯性场景(如客服)阈值调高,保留更多原始消息 |
除了按消息条数触发,你也可以改为按估算 Token 数触发(用 model.get_num_tokens()),或者按时间间隔触发(比如对话超过 10 分钟就摘要一次)。本节课的实现是最简单的条数方案,适合入门理解。
现在把所有零件组装起来,构建一个带持久化记忆的完整 Chatbot 图。
from IPython.display import Image, display
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import StateGraph, START
# ① 创建图的蓝图(使用自定义的 State)
workflow = StateGraph(State)
# ② 注册两个节点
workflow.add_node("conversation", call_model) # LLM 对话节点
workflow.add_node(summarize_conversation) # 摘要节点(函数名即节点名)
# ③ 设置入口:START → conversation
workflow.add_edge(START, "conversation")
# ④ 设置条件边:conversation 完成后,由 should_continue 决定走向
workflow.add_conditional_edges("conversation", should_continue)
# ⑤ 摘要节点完成后直接结束
workflow.add_edge("summarize_conversation", END)
# ⑥ 编译时注入 MemorySaver 作为 checkpointer
# MemorySaver 是内存中的 KV 存储,生产环境可换成 SqliteSaver / PostgresSaver
memory = MemorySaver()
graph = workflow.compile(checkpointer=memory)
# 可视化图结构
display(Image(graph.get_graph().draw_mermaid_png()))
给 compile() 传入 checkpointer=memory 后,图就具备了持久化能力:每一步执行完毕,LangGraph 会自动把当前 State 快照保存到 MemorySaver 中。下次调用同一个 thread_id 时,图会从上次保存的状态继续,而不是从头开始。
# 创建一个对话线程(Thread),通过 thread_id 标识
config = {"configurable": {"thread_id": "1"}}
# 第一轮对话
input_message = HumanMessage(content="hi! I'm Lance")
output = graph.invoke({"messages": [input_message]}, config)
# 第二轮对话(同一 thread_id,图从上次状态继续)
input_message = HumanMessage(content="what's my name?")
output = graph.invoke({"messages": [input_message]}, config)
# 读取当前 State 的摘要字段
current_summary = graph.get_state(config).values.get("summary", "")
Thread(线程)的类比:可以把 Thread 理解为 Slack 的不同频道——每个 thread_id 是一个独立的对话频道,互不干扰。你可以同时跟用户 A 和用户 B 各维护一个 Thread,它们的 State(包括摘要)完全独立。
MemorySaver 是进程内存中的存储,程序重启后所有 State 都会消失。它适合开发和测试阶段。生产环境中,应使用 SqliteSaver(文件持久化)或 PostgresSaver(数据库持久化)替代,无需修改图的任何其他代码,只需替换 checkpointer 的类型即可。
以下是 Notebook 中的完整运行演示,展示了从普通对话到触发摘要的完整过程:
第 4 轮对话结束后,should_continue 检测到 len(messages) == 8 > 6,路由到 summarize_conversation 节点。LLM 生成摘要,旧的 6 条消息被删除,只保留最新的 2 条。
# 查看摘要结果
graph.get_state(config).values.get("summary", "")
# 输出:
# 'Lance introduced himself and mentioned that he is a fan of the
# San Francisco 49ers, specifically highlighting his admiration for
# Nick Bosa. The conversation noted that as of September 2023, Nick
# Bosa became the highest-paid defensive player in NFL history with
# a five-year, $170 million contract extension with the 49ers.'
MessagesStatesummary: str 字段SystemMessage 注入摘要RemoveMessage 清理旧消息MemorySaver 作为 checkpointerModule 2 的前几节课讲了 trim_messages(滑动窗口截断)和 filter_messages(按条件过滤)。本节课的摘要方案是这个系列的最终形态:它用 LLM 的语言理解能力把信息"压缩"而非"丢弃",是生产级长期对话 Chatbot 的标准配置。三种方案各有适用场景,按需选择即可。
掌握了摘要记忆之后,你的 Chatbot 已经能够处理无限长的对话而不失忆。Module 3 会在这个基础上引入人工介入机制(Breakpoint):在图执行的特定节点暂停,等待人类确认或修改 State,再继续执行。这是构建安全、可控 Agent 系统的关键技术。