长文本生成与记忆:Agent 的长期记忆与摘要压缩
当一个 Agent 需要读完一份几百页的合同、整理一整天的客服对话,或者陪用户聊上几百轮,它很快就会撞上两道墙:模型一次能「看」进去的内容有限,一次能「写」出来的内容也有限。本文拆解长文本与长对话场景下的核心难题,给出一套「短期记忆、长期记忆、工作记忆」的分层架构,并讲解滚动摘要、检索式记忆等压缩与召回策略,以及它们各自的代价。
一、长文档与长对话的两类难题
第一类难题是上下文窗口有限。无论模型的上下文长度标称多大,它一次能稳定处理的 token 数始终有上限。把超长内容直接塞进提示词会带来三个问题:token 成本线性增长、推理延迟变高、模型在过长上下文中容易「中间遗忘」(lost in the middle),即对位于提示词中部的信息关注度下降。
第二类难题是生成长度有限。即便上下文放得下,单次生成的输出长度也受模型与接口限制(单次回复通常有最大 token 上限)。要产出一份很长的报告、代码或翻译稿,不能奢望一次吐完,必须分段续写,并且保证前后连贯。
这两类难题共同指向同一个结论:Agent 不能把全部历史原样保留,而要把信息分层管理,该压缩的压缩,该外置的外置,只在当下真正需要时才把相关内容取回来。
二、记忆架构:短期、长期与工作记忆
可以把 Agent 的记忆分成三层,分别对应不同的存储介质与生命周期。
短期记忆(上下文窗口)。也就是当前这次推理真正「看得到」的提示词内容,包括系统提示、近期对话、刚取回的检索片段。它容量有限、速度最快,是模型当下推理的直接依据。
长期记忆(向量库、数据库、文件)。跨会话、跨时间保存的事实、文档与历史。它容量近乎无限,但不在上下文里,必须被主动写入与取回。常见载体包括向量数据库(按语义检索)、关系型或文档数据库(按结构化字段查询)、以及本地文件或对象存储(按路径读取)。
工作记忆(压缩后的当前状态)。这是把短期与长期记忆「加工」后得到的、适合放进当前上下文的一段精简状态。它可以是滚动摘要、当前任务清单、已抽取的关键实体,或者一份「已知事实与未决事项」的速记。工作记忆是衔接另外两层的枢纽:每次推理前,Agent 把长期记忆中相关的部分取回,与近期对话一起压缩成工作记忆,再交给模型。
一个朴素的比喻:短期记忆是办公桌上正在看的那几页纸,长期记忆是公司档案室里所有的卷宗,工作记忆是你便签上写的「本周待办与关键结论」。
三、摘要压缩:滚动摘要与 Map-Reduce
压缩的核心思想是「用更小的信息体积保留更关键的内容」。两条常见路线如下。
滚动摘要(rolling summary)。不保存逐字对话,而是始终维护一份「持续更新的要点摘要」。每发生新一轮对话,就把「旧摘要 + 新内容」交给 LLM,生成一份新的、更短的摘要。下一轮推理时,模型只拿到摘要与最近一两条原文,而非全部历史。这样既控制了 token 消耗,又能支撑近乎无限轮次的对话。
以 LangChain 的 ConversationSummaryMemory 为例:它会在后台调用大模型,把对话逐步凝练成摘要,适合「逐字保存历史会非常费 token」的长对话;与之配套的 ConversationSummaryBufferMemory 则是混合方案——保留最近几轮的原文,把更早的内容编译成摘要,在「不丢近期细节」与「不爆上下文」之间取平衡(上述两类记忆的行为描述已核验,见文末参考)。
Map-Reduce 式长文处理。当要处理的是「一篇长文档」而非「一段长对话」时,可以先用切分把原文拆成若干段落,对每段各自做局部摘要(Map),再把局部摘要两两合并,循环直到得到一份符合长度目标的全局摘要(Reduce)。这种思路天然适配并行,也常作为后续检索或问答的输入。
下面给出两个可运行的示例骨架。
"""滚动摘要示例:用 LLM 把对话历史压缩成要点。
说明:以下为概念性代码。LLM 客户端的真实调用方式与参数名以各厂商文档为准(待核实)。
"""
from dataclasses import dataclass
@dataclass
class RollingSummaryMemory:
"""维护一份持续更新的对话摘要,而不是保存完整原文。"""
system_prompt: str = "你是擅长归纳要点的助手,请把对话历史压缩成简洁、可复原关键事实的要点。"
summary: str = "" # 当前滚动摘要
llm = None # 实际的 LLM 客户端(待核实:具体初始化方式依厂商 SDK 而定)
def _call_llm(self, prompt: str) -> str:
"""调用大模型生成摘要。真实接口参数(model、temperature 等)待核实。"""
# 伪代码:response = self.llm.chat(messages=[...]); return response.text
raise NotImplementedError("请替换为真实 LLM 调用(待核实)")
def update(self, new_user: str, new_ai: str) -> str:
"""把新一轮对话并入滚动摘要。"""
base = "这是对话开始。" if not self.summary else self.summary
compress_prompt = (
f"{self.system_prompt}\n"
f"现有摘要:\n{base}\n\n"
f"新增内容:\n用户:{new_user}\n助手:{new_ai}\n\n"
f"请输出更新后的要点摘要(保留人物、偏好、关键事实与未决事项):"
)
self.summary = self._call_llm(compress_prompt)
return self.summary
def load(self) -> str:
"""返回当前压缩状态,供主模型作为工作记忆使用。"""
return self.summary
# 使用示例
memory = RollingSummaryMemory()
memory.update("我叫小明,来自台北。", "好的,我记住了。")
memory.update("我喜欢打篮球和看书。", "收到,已记录你的爱好。")
print(memory.load())
"""Map-Reduce 式长文处理:先把长文切段各自摘要,再合并成全局摘要。"""
def map_reduce_summarize(chunks, llm_summarize, llm_merge):
# Map:对每一段独立生成局部摘要
partials = [llm_summarize(chunk) for chunk in chunks]
# Reduce:把若干局部摘要合并成更小的摘要,循环直到满足长度
while len(partials) > 1:
merged = []
for i in range(0, len(partials), 2):
pair = partials[i:i + 2]
merged.append(llm_merge(pair))
partials = merged
return partials[0] if partials else ""
四、检索式记忆:把历史存向量库,按需取回
滚动摘要擅长「保住主线」,但会持续丢失细节;检索式记忆走另一条路:把历史对话或文档切片后编码成向量,存入向量库,当用户提出新问题时,用语义相似度找出最相关的片段,再注入当前上下文。它不依赖时间顺序,适合「主题跳跃、跨度很长」的对话——比如用户一个月前聊过某份财报,今天又问起其中某个数字。
检索式记忆常与 RAG(检索增强生成)结合:知识库文档和用户历史都进入同一套向量检索管线,模型在回答时既能引用外部资料,也能引用「自己之前说过的话」。关键点在于切分策略(按段落、按语义边界还是按固定窗口)、元数据过滤(按时间、来源、会话ID),以及取回数量与上下文长度的预算分配。
下面是一段检索式记忆的伪代码骨架。
"""检索式记忆骨架:历史入库,按需取回相关片段(与 RAG 共用向量库)。"""
def remember(vector_store, turn_text, embedding_fn, metadata):
"""把一轮对话切片编码后写入向量库。"""
vector_store.upsert(
vector=embedding_fn(turn_text),
payload={"text": turn_text, **metadata}, # 元数据:会话ID、时间、角色
)
def recall(vector_store, query_text, embedding_fn, top_k=4):
"""取回与当前问题最相关的历史片段。"""
hits = vector_store.search(embedding_fn(query_text), top_k=top_k)
return [hit["payload"]["text"] for hit in hits]
五、实操示例:用 LLM 对对话历史做滚动摘要
把第三节的 RollingSummaryMemory 接到一个最小对话循环里,可以看到它如何在不保留全文的前提下,仍然「记得」早期事实。
"""最小对话循环:用滚动摘要支撑多轮记忆。"""
def chat_loop(memory, user_says):
# 1) 取回当前工作记忆(压缩后的状态)
working_memory = memory.load()
# 2) 组装提示词:系统角色 + 工作记忆 + 本轮用户输入
prompt = (
f"你是客服助手。已知背景摘要:\n{working_memory}\n\n"
f"用户:{user_says}\n助手:"
)
# 3) 调用主模型得到回复(真实接口待核实)
reply = main_model_chat(prompt) # 伪代码
# 4) 把本轮并入滚动摘要
memory.update(user_says, reply)
return reply
# 多轮演示(实际 reply 由模型生成,此处略去)
memory = RollingSummaryMemory()
chat_loop(memory, "我的订单号是 A1024,昨天下的。")
chat_loop(memory, "到现在还没发货,帮我查一下。")
chat_loop(memory, "如果今天发不了,我想申请退款。")
# 即便原文已被压缩,摘要中应保留:订单号 A1024、未发货、有退款意向
实践要点:摘要提示词要明确「保留哪些维度」(人物、偏好、订单号、未决事项等),否则 LLM 容易在压缩时漏掉后续要用到的精确信息;同时建议保留最近一两轮原文,避免摘要把关键数字也一并抹掉。
六、权衡:压缩、检索、成本与延迟
滚动摘要会让信息持续「降级」,精确数字、代码片段、专有名词可能被概括掉,导致后续无法精确还原。缓解办法是保留近期原文、在提示词中要求「原样保留关键标识」,或对重要字段做结构化抽取后再摘要。
检索式记忆可能取错片段:语义相似不等于事实相关,检索召回的内容未必是回答所需,甚至引入干扰。缓解办法是加入元数据过滤、重排序(rerank),以及对取回结果做相关性校验。
成本与延迟方面:每次摘要更新都要额外调用一次 LLM,检索要额外做编码与查询,整体会增加 token 开销与响应时间。需要按场景平衡——短会话用简单截断即可,长会话才上摘要或检索;并给取回内容设预算上限,防止工作记忆本身又撑爆上下文。
最后,记忆方案不是单选。真实系统往往组合使用:近期对话留在短期记忆,早期对话用滚动摘要压成工作记忆,跨主题或跨会话的事实放进向量库按需检索。分层的意义,正是让「该在眼前的」恰好在眼前。
小结
长文本与长对话的瓶颈来自上下文窗口有限与生成长度有限。解决思路是把记忆分层:短期记忆是当下可见的上下文,长期记忆外置于向量库或数据库,工作记忆是压缩后的当前状态。滚动摘要适合保住主线、支撑长对话,Map-Reduce 适合处理长文档,检索式记忆与 RAG 结合则擅长跨时间、跨主题的精确取回。它们各自有代价——摘要会丢细节、检索可能取错、二者都增加成本与延迟——因此生产系统通常以组合方式取长补短。
参考与延伸阅读
- LangChain
ConversationSummaryMemory文档与示例:说明该记忆会随时间把对话凝练成摘要,适合「逐字保存历史会非常费 token」的长对话;可返回摘要字符串或系统消息列表。(已核验) - LangChain
ConversationSummaryBufferMemory文档:混合记忆,保留近期原文并把更早内容编译成摘要。(已核验) - LangChain 现行文档以 LangGraph 的持久化与「压缩长对话」中间件作为长期记忆的主推方案,独立记忆类的使用方式请以官方迁移指南为准。(已核验,迁移细节建议进一步对照官方文档)
- 各厂商 LLM 客户端的初始化、对话调用与参数(model、temperature 等)不在本文核验范围,接入时以对应 SDK 文档为准。(待核实)
- 向量库的具体接口(upsert、search 的方法名与返回结构)因选型而异,示例中为通用伪代码。(待核实)