1. 为什么“记忆”是Agent从玩具走向工具的分水岭
做Agent开发的人迟早会撞上一堵墙:对话轮次一多,模型就开始“胡言乱语”,要么把前面聊过的关键信息忘得一干二净,要么把上下文塞爆导致接口直接报错。这个问题的本质不是模型不够聪明,而是它的“工作记忆”容量有限——就像一个人同时只能记住手头七八件事,超出部分要么被挤掉,要么让整个认知系统崩溃。
我最初做Agent时也踩过这个坑。一个客服场景的对话助手,用户聊到第十五轮时突然问“你刚才说的那个退款政策适用于哪些订单”,模型一脸茫然地回复“抱歉,我没有提到过退款政策”。实际上它在第八轮明确说过。问题出在哪?上下文窗口被塞满了,早期的对话内容被系统自动截断,而截断的位置恰好把那段关键信息切掉了。
这就是“Agent记忆”要解决的核心问题:在有限的上下文预算内,让Agent记住该记住的,忘掉该忘掉的,并且在需要时能准确召回。目前业界主流方案有三条路线——截断、总结、向量检索。它们不是互斥的,但选错了主方案,你的Agent要么贵得离谱,要么蠢得感人。
这篇文章适合正在做Agent应用、被上下文管理折磨过的开发者。我会把三种方案的底层逻辑、适用边界、实操细节和踩坑经验全部拆开讲,最后给出一个我实际项目中验证过的混合策略。全文基于真实项目经验,涉及的具体参数和代码可以直接抄作业。
2. 截断方案:最粗暴也最容易被低估的基线策略
2.1 截断不是“砍掉旧消息”这么简单
很多人对截断的理解停留在“超过N条就删最早的”,这其实是最粗糙的做法。截断的核心决策有三个维度:截断位置、截断粒度、截断后的补偿。
截断位置决定了你从哪一刀切下去。常见的有按消息条数截断、按Token数截断、按对话轮次截断。按条数最简单但最不靠谱——一条长消息可能占2000 Token,十条短消息可能才500 Token,按条数切会导致上下文预算利用率极不稳定。
按Token数截断是更工程化的做法。你需要先算清楚系统提示词占多少、当前用户输入占多少、预留多少给模型输出,剩下的才是历史消息的预算。我通常的做法是:
def truncate_by_tokens(messages, max_context_tokens, reserve_for_output=1024): # 系统消息永远保留 system_msgs = [m for m in messages if m["role"] == "system"] system_tokens = count_tokens(system_msgs) # 可用预算 = 总窗口 - 系统提示 - 输出预留 available = max_context_tokens - system_tokens - reserve_for_output # 从最新消息往前累加,直到超出预算 kept = [] used = 0 for msg in reversed(messages): if msg["role"] == "system": continue msg_tokens = count_tokens([msg]) if used + msg_tokens > available: break kept.insert(0, msg) used += msg_tokens return system_msgs + kept这段代码的关键在于从最新往回遍历,保证最近的消息优先保留。因为在实际对话中,最近几轮的信息相关性最高,早期内容被截断的代价相对可控。
2.2 截断粒度:整条丢弃还是部分保留
按整条消息截断有个致命问题:如果一条关键消息刚好卡在边界上,你会把整条都丢掉。比如用户在前一轮说“我的订单号是12345,收货地址是北京市朝阳区某街道”,这条消息有80 Token,但预算只剩50 Token,整条丢弃意味着订单号和地址全没了。
更精细的做法是按句子或段落粒度截断。把长消息拆成语义单元,从旧到新逐单元丢弃,直到预算够用。这样至少能保住消息的后半部分。代价是需要额外的句子分割逻辑,而且可能把一条完整信息切得支离破碎。
我的经验是:对于结构化信息(订单号、日期、金额),截断粒度要细;对于闲聊内容,整条丢弃无所谓。可以在消息元数据里打个标签,标记哪些消息包含关键实体,截断时优先保留这些消息的完整内容。
2.3 截断方案的隐性成本
截断最大的优势是零额外计算成本——不需要调模型做总结,不需要维护向量库,纯字符串操作。但它的隐性成本在于信息丢失不可逆。一旦早期对话被截掉,Agent就永久失去了那段记忆,除非用户重新提及。
我在一个多轮任务规划场景中吃过这个亏。用户先说了“帮我规划一个三天的行程”,Agent问了几个问题后开始生成方案。用户中途说“把第二天的博物馆换成美术馆”,Agent照做了。但到第三天用户问“你之前推荐的博物馆叫什么来着”,Agent完全想不起来——因为第一轮对话早被截断了。
所以截断方案适合什么场景?短对话、信息密度低、不需要跨轮次回溯的任务。比如简单的问答机器人、单轮指令执行、FAQ查询。一旦涉及多轮状态维护、长程依赖、用户偏好记忆,截断就不够用了。
实操心得:截断方案一定要配合“关键信息提取”使用。在截断之前,先用规则或小模型把消息中的实体(人名、日期、金额、订单号)抽出来,存到一个独立的“事实表”里。这样即使原始消息被截断,Agent仍能通过查表回答相关问题。
3. 总结方案:用模型压缩历史,但别指望它“无损”
3.1 总结的触发时机比总结本身更重要
总结方案的核心思路是:当上下文快满时,调一次模型把历史对话压缩成一段摘要,然后用摘要替换原始消息。听起来很优雅,但第一个坑就是什么时候触发总结。
触发太早,频繁调模型做总结,成本和延迟都上去了;触发太晚,上下文已经爆了,总结请求本身可能都发不出去。我的做法是设置一个水位线:当上下文使用率达到70%时触发总结,留30%的缓冲给总结请求和后续对话。
def should_summarize(current_tokens, max_tokens, threshold=0.7): return current_tokens / max_tokens >= threshold这个阈值不是拍脑袋定的。70%意味着你还有30%的空间,假设总窗口是8K Token,那就有2.4K Token的余量。一次总结请求的输入是当前全部历史(约5.6K Token),输出摘要控制在500 Token以内,完全够用。
3.2 总结提示词的设计决定信息保留率
总结方案的效果90%取决于提示词。我见过太多人直接用“请总结以下对话”这种万能提示词,结果模型把关键的数字、日期、用户偏好全丢了,只留下一堆“用户询问了X,助手回答了Y”的废话。
好的总结提示词要明确告诉模型保留什么、丢弃什么。我的模板是这样的:
你是一个对话摘要生成器。请将以下对话历史压缩成一段摘要,要求: 必须保留: - 用户明确提出的所有数字、日期、金额、订单号 - 用户的偏好、禁忌、特殊要求 - 已经确认的决策和结论 - 未完成的待办事项 可以丢弃: - 寒暄和礼貌用语 - 重复确认的内容 - 已经被推翻的中间方案 输出格式: 【已确认事实】... 【用户偏好】... 【待办事项】... 【对话脉络】...这个模板的关键在于结构化输出。把摘要分成几个固定板块,后续拼接回上下文时,模型更容易定位到需要的信息。实测下来,结构化摘要比自由文本摘要的信息召回率高出一大截。
3.3 总结的“有损压缩”问题与补偿手段
总结本质上是有损压缩,就像把一张高清图压成JPEG,细节必然丢失。我做过一个对比测试:同一段20轮的对话,用总结方案压缩后,模型对早期细节问题的回答准确率从92%降到了67%。丢失的主要是具体数字和边缘信息。
补偿手段有两个。一是分层总结:不是把所有历史压成一段,而是保留最近N轮的原始消息,只对更早的历史做总结。这样最近的信息无损,早期信息有损但可接受。二是摘要+索引:总结时同时生成一个“关键实体列表”,把对话中出现的重要实体单独存起来,需要时通过实体检索回原始消息。
def hierarchical_summarize(messages, keep_recent=6): if len(messages) <= keep_recent: return messages old_messages = messages[:-keep_recent] recent_messages = messages[-keep_recent:] summary = call_llm_for_summary(old_messages) summary_msg = {"role": "system", "content": f"[历史摘要] {summary}"} return [summary_msg] + recent_messageskeep_recent=6这个参数是我在多个项目中调出来的经验值。6轮对话大约覆盖最近3-4个来回,足够维持当前话题的连贯性。如果任务对近期上下文更敏感(比如代码调试),可以调到8-10;如果是闲聊类,4-5也够。
注意:总结方案有一个隐藏的“摘要漂移”问题。如果每次总结都基于上一次的摘要再总结,信息会逐层衰减,几轮之后摘要就变得空洞无物。解决办法是始终基于原始消息做总结,而不是基于上一次的摘要。维护一个原始消息缓冲区,每次总结都从这里取全量数据。
4. 向量检索方案:让Agent拥有“长期记忆”的正确姿势
4.1 向量检索解决的是“回忆”而不是“记住”
截断和总结都是在“记住”层面做文章——决定哪些内容留在上下文里。向量检索的思路完全不同:把所有历史消息存进向量库,需要的时候再检索出来。Agent不需要“记住”所有东西,只需要在回答问题时能“回忆”起相关片段。
这个思路的转变很关键。就像人不需要记住昨天午饭吃了什么,但被问到“你上次推荐的那家餐厅”时能想起来。向量检索让Agent具备了这种按需回忆的能力。
实现上分三步:入库、检索、注入。入库是把每条消息转成向量存起来,检索是根据当前问题找最相关的历史片段,注入是把检索结果拼接到当前上下文中。
import numpy as np from sentence_transformers import SentenceTransformer class VectorMemory: def __init__(self, model_name="all-MiniLM-L6-v2"): self.encoder = SentenceTransformer(model_name) self.vectors = [] self.messages = [] def add(self, message): vec = self.encoder.encode(message["content"]) self.vectors.append(vec) self.messages.append(message) def search(self, query, top_k=3): query_vec = self.encoder.encode(query) scores = [np.dot(query_vec, v) / (np.linalg.norm(query_vec) * np.linalg.norm(v)) for v in self.vectors] top_indices = np.argsort(scores)[-top_k:][::-1] return [self.messages[i] for i in top_indices]4.2 检索质量取决于“怎么切”和“怎么查”
向量检索的效果取决于两个环节:切块策略和查询构造。
切块策略决定了每条记忆的粒度。按单条消息切块最简单,但一条消息可能包含多个主题,检索时容易引入噪声。按对话轮次切块(一问一答为一个块)语义更完整。按主题切块最理想但需要额外的主题分割逻辑。
我的经验是:对话类场景按轮次切块,文档类场景按段落切块,代码类场景按函数切块。切块大小控制在200-500 Token之间,太小则语义不完整,太大则检索精度下降。
查询构造是另一个容易被忽视的点。直接用用户当前输入去检索,往往效果不好,因为用户输入可能很短、很模糊。更好的做法是用最近几轮对话的拼接作为查询,或者让模型先对用户输入做一次“查询改写”,补全指代和省略信息。
def build_query(recent_messages, current_input, window=3): context = " ".join([m["content"] for m in recent_messages[-window:]]) return f"{context} {current_input}"比如用户问“那个方案怎么样了”,单独看这句话检索不到任何有用信息。但结合前几轮关于“数据库迁移方案”的讨论,查询变成“数据库迁移方案 那个方案怎么样了”,就能准确召回相关历史。
4.3 向量检索的延迟与成本账
向量检索不是免费的。每次检索都要做一次编码(约10-50ms)和一次相似度计算(取决于库大小,几千条以内可忽略)。如果每轮对话都检索,延迟会累积。我的优化策略是按需检索:只有当用户输入包含指代词(“那个”“之前”“上次”)或疑问词(“什么”“为什么”“怎么”)时才触发检索,否则直接用最近上下文。
成本方面,如果用本地小模型做编码,基本零成本;如果用API做编码,按Token计费,但编码用的Token远少于生成。存储成本可以忽略——几万条消息的向量也就几十MB。
真正的大头是检索结果的注入成本。检索出3条历史消息,每条200 Token,就是600 Token注入到上下文。如果检索质量不高,这600 Token就是纯浪费。所以检索的top_k不要设太大,3-5条足够,并且要加一个相似度阈值,低于阈值的直接丢弃。
实操心得:向量检索最适合“事实回忆”类问题,比如“我的订单号是多少”“上次说的预算是多少”。对于“推理”类问题(“根据之前的讨论,你觉得应该选哪个方案”),向量检索帮助有限,因为推理需要的是完整上下文而不是零散片段。这类问题还是要靠总结或截断来维持上下文连贯性。
5. 三种方案的取舍矩阵:什么场景选什么
5.1 从四个维度做决策
选方案不能凭感觉,我通常从四个维度评估:对话长度、信息密度、召回精度要求、成本预算。
| 维度 | 截断 | 总结 | 向量检索 |
|---|---|---|---|
| 额外计算成本 | 无 | 中(每次总结调模型) | 低(编码+检索) |
| 信息丢失风险 | 高(不可逆) | 中(有损压缩) | 低(原始消息保留) |
| 长程召回能力 | 无 | 弱(依赖摘要质量) | 强(按需检索) |
| 实现复杂度 | 低 | 中 | 中高 |
| 适用对话长度 | 短(<10轮) | 中(10-50轮) | 长(50轮以上) |
| 适用信息密度 | 低 | 中 | 高 |
短对话(10轮以内)直接用截断,简单可靠。中等长度(10-50轮)用总结,配合最近N轮原始消息。长对话(50轮以上)必须上向量检索,否则要么上下文爆掉,要么摘要空洞化。
5.2 混合策略:我实际项目中的三层记忆架构
纯用某一种方案都有短板,我最终落地的是一个三层架构:
第一层:工作记忆(最近6轮原始消息)。永远保留,不压缩不截断。这层保证当前话题的连贯性。
第二层:短期记忆(总结摘要)。对6轮之前、50轮以内的历史做结构化总结,保留关键事实和决策。
第三层:长期记忆(向量库)。所有历史消息全量入库,按需检索。当用户提到早期信息时,从向量库召回原始片段注入上下文。
class AgentMemory: def __init__(self, max_recent=6, summarize_threshold=20): self.recent = [] self.summary = "" self.vector_store = VectorMemory() self.max_recent = max_recent self.summarize_threshold = summarize_threshold def add_message(self, message): self.recent.append(message) self.vector_store.add(message) if len(self.recent) > self.max_recent: old = self.recent.pop(0) # 累积到一定量后再触发总结 if len(self.recent) % self.summarize_threshold == 0: self.summary = self._update_summary(old) def get_context(self, query): context = [] if self.summary: context.append({"role": "system", "content": f"[历史摘要] {self.summary}"}) # 按需检索长期记忆 if self._needs_retrieval(query): retrieved = self.vector_store.search(query, top_k=3) for msg in retrieved: context.append({"role": "system", "content": f"[历史片段] {msg['content']}"}) context.extend(self.recent) return context def _needs_retrieval(self, query): triggers = ["那个", "之前", "上次", "刚才", "早先", "前面"] return any(t in query for t in triggers)这个架构的关键在于分层触发:最近消息永远保留,中期消息定期总结,早期消息按需检索。三层各司其职,既控制了上下文长度,又保证了召回能力。
5.3 参数调优的实战经验
三层架构有几个关键参数需要根据场景调:
max_recent(最近消息保留数):任务型对话设4-6,闲聊型设6-8,代码调试型设8-10。原则是保证当前任务的完整上下文。
summarize_threshold(总结触发间隔):不要每丢一条消息就总结一次,那样太频繁。我设的是每累积20条消息触发一次总结,把这段时间的旧消息一起压缩。
top_k(检索返回条数):3-5条。太多会引入噪声,太少可能漏掉关键信息。配合相似度阈值使用,低于0.6的直接丢弃。
相似度阈值:0.6是我在通用场景下的经验值。如果发现检索结果经常不相关,可以调到0.7;如果发现该召回的内容召不回来,降到0.5。
注意:这些参数没有万能值,必须根据你的具体数据做A/B测试。我建议先跑100轮模拟对话,统计不同参数下的召回准确率和上下文Token消耗,再定最终值。
6. 落地时最容易踩的三个坑
6.1 坑一:总结时把“系统提示词”也总结了
这是新手最容易犯的错误。总结逻辑写成了“把messages里所有内容压缩”,结果系统提示词(定义Agent角色和能力的部分)也被压缩进去了。下次对话时Agent的角色定义变得模糊,行为开始漂移。
正确做法是在总结前把系统消息分离出来,只总结用户和助手的对话消息。系统消息永远原样保留,不参与总结。
def safe_summarize(messages): system_msgs = [m for m in messages if m["role"] == "system"] dialog_msgs = [m for m in messages if m["role"] != "system"] summary = call_llm_for_summary(dialog_msgs) return system_msgs + [{"role": "system", "content": f"[历史摘要] {summary}"}]6.2 坑二:向量检索召回了“助手自己说的话”
向量库里存了用户消息和助手回复。检索时如果不做角色过滤,可能召回助手之前生成的错误信息,然后Agent把它当成事实继续使用,形成“自我强化”的错误循环。
解决办法是在检索时优先召回用户消息,或者给助手消息加一个可信度权重。用户说的事实可信度高,助手说的话只是“曾经说过”,不一定正确。
def search_with_role_priority(self, query, top_k=3): results = self.search(query, top_k=top_k*2) # 用户消息权重1.0,助手消息权重0.7 weighted = [(r, 1.0 if r["role"] == "user" else 0.7) for r in results] weighted.sort(key=lambda x: x[1], reverse=True) return [r for r, _ in weighted[:top_k]]6.3 坑三:上下文拼接顺序搞错导致模型困惑
检索到的历史片段、摘要、最近消息,拼接顺序很重要。我试过把检索片段放在最前面,结果模型把历史片段当成了当前对话的一部分,回答时张冠李戴。
正确的顺序是:系统提示词 → 历史摘要 → 检索片段 → 最近消息 → 当前用户输入。历史摘要和检索片段都要用明确的标记包裹,比如[历史摘要]和[历史片段],让模型知道这些是“背景信息”而不是“当前对话”。
def build_final_context(system, summary, retrieved, recent, current): messages = [{"role": "system", "content": system}] if summary: messages.append({"role": "system", "content": f"[历史摘要]\n{summary}"}) for r in retrieved: messages.append({"role": "system", "content": f"[历史片段]\n{r['content']}"}) messages.extend(recent) messages.append({"role": "user", "content": current}) return messages这个顺序经过多次实测,模型对“什么是背景、什么是当前任务”的区分最清晰。
7. 一个可复用的决策流程图(文字版)
最后给一个我在团队内部用的决策流程,帮你快速判断该用哪种方案:
第一步:估算对话轮次。如果预期不超过10轮,直接用截断,别折腾。超过10轮进入第二步。
第二步:判断信息密度。如果对话中大量出现数字、日期、订单号、用户偏好等结构化信息,向量检索是必须的。如果主要是闲聊和定性讨论,总结方案够用。
第三步:评估召回精度要求。如果业务要求“用户问什么都能准确回答”,必须上向量检索。如果允许一定程度的遗忘(比如闲聊机器人),总结方案可以接受。
第四步:算成本账。总结方案每次触发要调一次模型,如果对话频繁且预算紧张,考虑用更便宜的模型做总结,或者降低总结频率。向量检索的编码成本低,但检索注入会增加上下文Token消耗。
第五步:上混合方案。除非场景极其简单,否则我建议直接用三层架构:最近消息保留 + 中期总结 + 长期向量检索。前期多花两天搭框架,后期省下无数调试时间。
这套方案我在三个不同项目中落地过,从客服对话到任务规划到知识问答,效果稳定。唯一需要根据场景调的就是那几个参数,调参方法在5.3节已经给了。如果你正在被Agent的记忆问题困扰,建议先从截断方案跑通基线,再逐步叠加总结和向量检索,不要一上来就追求完美架构。