☰
LLM Agent上下文管理:遗忘压缩与Harness架构实践
2026/10/22 13:24:48 网站建设 项目流程

做 LLM Agent 落地的朋友应该都有过这种体验:对话进行到第三十轮,模型开始"失忆"——用户十分钟前刚确认过的收货地址,它转头就答非所问;上一步工具调用返回的订单编号,它说"抱歉,我没有找到这个信息"。这通常不是模型变笨了,而是上下文窗口被撑爆之后,队尾的旧消息被系统静默丢弃。我在某智能客服项目里就吃过这个亏,后来整套解决方案的核心,就是围绕"记忆的遗忘与压缩"做文章——给大模型外围套一层负责上下文维护的调度层,我们内部管它叫 Harness。

这篇文章适合两类人:一类是把 LLM 接入真实业务、被长对话和超长文档反复折磨的开发者;另一类是刚接触 Agent 架构、想搞明白"记忆管理"到底解决什么问题的同学。下面我会从遗忘的成因讲起,把主流压缩策略、Harness 的实现细节、调参经验和踩坑记录一次性讲透,最后给出可以直接抄作业的代码骨架。

1. 遗忘从哪来:上下文窗口的物理极限与注意力稀释

1.1 Token 是硬通货,窗口是硬边界

先说一个很多人容易忽略的事实:大模型的"记忆"本质上是输入序列的一次性重放。模型每生成一个 token,都要把整段上下文重新过一遍注意力计算,而每一层的 Key-Value 缓存(KV Cache)会随序列长度线性增长。所谓上下文窗口,就是这个 KV Cache 在显存和计算开销上能承受的物理上限。

我最早犯的错,就是把这个上限当成"容量足够用"。某客服项目的场景是用户咨询售后流程,平均一个完整会话要二十到四十轮,每轮还夹杂工具调用结果、商品详情、历史工单数据。4096 token 的窗口,大约十六轮之后就开始报警。更麻烦的是,不同模型对超窗口的处理不一样:有的直接抛异常中断生成,有的默默掐掉最早的消息。后者最阴险——程序没报错,但模型把最初的用户诉求、已确认的条款全忘光了,后续回答开始自说自话。

1.2 超出窗口后,到底丢了什么

我整理过一份"丢失清单",观察下来,超出窗口后被截断的信息通常集中在三类:

  • 目标与约束:用户最初提出的任务目标、必须遵守的规则(比如"不要推荐超过预算的商品")。这类信息一旦丢失,整个会话方向就歪了。
  • 已确认的实体:订单号、地址、型号、日期等结构化事实。它们往往只在一两轮内出现过,被截断后就再难找回。
  • 推理中间态:多轮决策的推导链条。比如先排除了方案 A 再选了 B,后来用户问"为什么不选 A",模型已经想不起来了。

最要命的是第三类。信息可以从数据库重新查,但"为什么当初做了那个决定"这种推理脉络,纯靠后续对话很难恢复。我第一次意识到这个问题,是用户问"你们之前不是说可以免费退换吗",而模型因为早几轮的上下文被挤掉,坚称"从未承诺过此类政策"——场面一度非常尴尬。

1.3 遗忘在窗口内的"隐性磨损"

后来我还发现,即使上下文没超出窗口,遗忘也在发生。业界有个说法叫"Lost in the Middle"——模型对长序列中间部分的注意力显著弱于开头和结尾。开头是系统提示词和用户最早诉求,天然被重点关注;结尾是最近几轮对话,参与生成时权重高;唯独中间的十几轮历史,模型虽然"看得到",但实际利用效率很低。

这也是我把问题拆成"遗忘与压缩"两个部分来思考的原因:超窗口是被动遗忘,窗口内的注意力稀释是被动压缩。Harness 要做的事情,是把这两种被动过程变成主动的、可控的维护动作。既然上下文必然有损耗,那不如由我们决定损耗什么、保留什么、用什么形式保留。

2. 压缩策略拆解:摘要、检索与结构化剪枝三条路线

2.1 摘要式压缩:把历史翻译成"备忘笔记"

最直观的思路,是让模型自己把旧对话压缩成一段摘要,然后只把摘要留在上下文里。我在 Harness 里的第一个版本就是这么做的:每累计十轮对话,就调用一次摘要模型,把前面十轮压缩成 200 字左右的"会议纪要",后续轮次只带纪要。

这个方案的优点是好实现、效果直观,对闲聊型和问答型场景特别友好。但它的缺点同样明显:摘要是一次有损编码,具体数字、名称、条件分支很容易在压缩中丢失。我实测过一组数据,把一段包含"周五下午三点""订单 20240615""发票抬头为公司全称"的对话交给摘要模型,十个版本里有三个漏掉了日期,一个写错了订单号。摘要可以概括事实,但"概括"这个动作本身就在制造遗忘。

2.2 检索式记忆:给上下文装一个外挂索引

于是第二版我引入了检索式记忆。思路是把对话中出现的实体和关键事实抽出来,写成结构化记录存进独立的记忆库(早期用一个 JSON 文件,后来换成了向量库),每次模型生成前,按当前问题去记忆库里召回最相关的条目,拼进上下文。

这套方案对"事实型遗忘"的修复效果立竿见影。客服场景里,订单号、地址、退换货政策这些内容一旦进了记忆库,就不再依赖原始对话是否还在窗口内。但它的代价是架构复杂度上了一个台阶:需要额外的抽取流程、存储组件和召回逻辑。而且检索式记忆有个天然缺陷——它擅长存"点",不擅长存"线"。一个多步骤决策的完整脉络,被拆成零散条目后,模型很难把它们重新串成一条连贯的推理链。

2.3 结构化剪枝:从 Token 层面做减法

第三条路线更底层,直接操作 token 序列。思路是识别上下文里"低价值"的部分——比如重复的闲聊寒暄、工具调用的超长原始返回、被覆盖的错误消息——把它们按规则删掉,或者替换成占位符。这一层不依赖模型能力,纯靠规则和启发式判断,我用来处理一类特别典型的问题:某些查询接口一次返回几千 token 的 JSON,其中九成是无关字段,模型根本用不上。

结构化剪枝和摘要、检索并不冲突。我最终的 Harness 是三者的组合:规则剪枝打底,过滤明显的无用 token;摘要负责压缩大段对话脉络;检索负责兜底关键事实。各有分工,各管一段。

2.4 三种策略对比

策略保真能力实现成本最适合的场景主要风险
摘要式压缩中,语义保留但细节易丢低,一次额外调用长对话脉络、决策过程数字和条件信息失真
检索式记忆高,结构化准确高,需存储与召回链路实体事实、跨会话记忆推理链断裂、碎片化
结构化剪枝中高,无损但有选择中,需规则设计工具返回、冗余日志误删有效信息

我见过很多团队只做其中一种就上线,最后都在某个场景翻车。摘要派在客服场景丢了订单号,检索派在复杂决策场景答非所问,剪枝派误删了关键条件导致模型逻辑混乱。正确的认知是:上下文维护是一个组合问题,不是单选题。

3. Harness 的上下文维护:三层记忆架构与压缩工作流

3.1 分层记忆:工作区、短期记忆、长期记忆

Harness 的核心设计是把上下文按生命周期分成三层。第一层是"工作区"(Working Memory),就是当前直接塞给模型的 prompt,包含系统提示词、最近的几轮对话、当前工具调用的返回。这一层负责"正在思考的内容"。第二层是"短期记忆",保存最近一两个小时内完整的历史消息,尚未压缩的原始状态。第三层是"长期记忆",保存经过摘要和抽取后的压缩版本,可以跨会话持久化。

这三层的边界就是遗忘的调度边界。工作区溢出,从短期记忆里取旧消息做压缩,压缩结果放进长期记忆;工作区清理出空间后,再从长期记忆里召回相关内容补回去。整个流程的核心矛盾只有一个:工作区空间永远不够,怎么分配最合理。

3.2 Harness 拦截点:在消息流上做手术

Harness 的工作方式不是被动等待模型处理,而是在每一条消息进入模型之前和之后各设一个拦截点。前置拦截负责三件事:检查工作区 token 水位、执行遗忘触发决策、组装最终 prompt。后置拦截负责两件事:把新的消息追加进短期记忆、抽取可入长期记忆的结构化事实。

这样做的好处是所有维护逻辑对业务代码透明。上层应用只需要调用 Harness 的统一接口,完全不用关心上下文怎么压缩、记忆怎么存取。这也是它和直接在业务逻辑里拼接 prompt 的最大区别——维护逻辑被集中了,而不是散落在各个调用点。

3.3 遗忘触发机制:水位线、增量压缩与全量重写

遗忘触发不能拍脑袋定时,我最终用的是双条件水位线。主条件是工作区 token 占用率,设定一个高水位(默认 70%)和一个低水位(默认 40%)。超过高水位就触发压缩任务,压缩到低水位以下才停止。辅助条件是轮次计数,每满 N 轮强制做一次轻量检查,防止单条超长消息瞬间撑爆窗口。

压缩任务内部还分两种:增量压缩和全量重写。增量压缩只处理最早的一段历史,用摘要模型生成新摘要,插入原有摘要的末尾,成本低但不解决摘要本身过长的问题。全量重写则把当前所有压缩片段合并,重新生成一份完整摘要,能消除冗余,但开销大。我的做法是:每次增量,每四次增量之后做一次全量重写。这样既控制成本,又避免摘要像滚雪球一样越来越臃肿。

3.4 压缩结果的回流与二次消费

压缩不是终点,被压缩的内容在后续对话中还需要能"回来"。Harness 做了一个召回接口:每次组装 prompt 时,根据当前用户问题做一轮检索,把长期记忆中最相关的若干条摘要片段拼接进工作区。召回的数量由当前剩余空间动态决定——空间紧张就只回填最相关的一条,空间宽裕就多回填几条。

这套"压缩-存储-召回"的回流机制,是我认为 Harness 和简单截断之间最本质的区别。简单截断是删除,Harness 是归档;删除的信息永远消失,归档的信息在需要时还能被唤醒。代价是每次生成前多一次检索延迟,但相比丢失关键信息导致的错误回答,这点延迟完全值得。

4. 实操落地:在 Harness 里搭一套可用的上下文维护

4.1 配置项与核心参数

先给出一份可以修改的配置模板。我建议所有阈值都做成配置而不是写死在代码里,因为调参会是后期最频繁的操作。

harness: # 工作区水位线,浮点比例 high_watermark: 0.7 low_watermark: 0.4 # 轮次触发阈值 turn_trigger: 8 # 摘要策略:incremental / full summary_mode: incremental full_summary_interval: 4 # 长期记忆召回数量 recall_limit: 3 # 工具返回的最大保留 token tool_result_max_tokens: 800 # 摘要模型 summary_model: "summarizer-v1"

这里有三个参数值得反复强调。high_watermark决定多早开始压缩,设得太高容易在某条大消息进来时措手不及,设得太低则压缩过于频繁,浪费模型调用。我用 0.7 是因为实测中单条工具返回经常占掉 10% 左右的窗口,留出余量才能安全处理突发。tool_result_max_tokens是剪枝策略的开关,超过阈值的返回内容先截断再交给模型,多数场景下模型只需要返回里的头部信息。recall_limit控制回填量,召回太多会把刚压缩省下的空间又填回去,失去压缩意义。

4.2 主循环的伪代码实现

下面这个 Python 伪代码是我在项目里实际跑通的主循环,去掉了具体的模型调用细节,保留了完整的维护逻辑。

class Harness: def __init__(self, config): self.cfg = config self.working = [] # 工作区消息 self.short_term = [] # 短期记忆 self.long_term = LongTermMemory() # 长期记忆 self.turn_count = 0 self.summary_fragments = [] def estimate_tokens(self, messages): # 实际项目中用 tokenizer 精确统计 return sum(len(m["content"]) for m in messages) def maybe_compress(self): ratio = self.estimate_tokens(self.working) / self.cfg["max_tokens"] if ratio < self.cfg["high_watermark"]: return target = int(self.cfg["max_tokens"] * self.cfg["low_watermark"]) while self.estimate_tokens(self.working) > target and self.short_term: # 从短期记忆头部取一段最旧的对话 oldest = self.short_term.pop(0) self.summary_fragments.append(oldest) # 从工作区中移除对应的原始消息 self.working = [m for m in self.working if m["id"] != oldest["id"]] if len(self.summary_fragments) >= self.cfg["full_summary_interval"]: self.summary_fragments = [self.full_summarize(self.summary_fragments)] compact = self.summarize(self.summary_fragments) self.long_term.store(compact) self.working.insert(0, { "role": "system", "content": f"[历史摘要] {compact['text']}" }) def recall(self, query): hits = self.long_term.search(query, k=self.cfg["recall_limit"]) if hits: self.working.insert(0, { "role": "system", "content": "[相关记忆]\n" + "\n".join(h.text for h in hits) }) def run_loop(self, user_message): self.turn_count += 1 self.maybe_compress() self.recall(user_message) # 组装最终 prompt 并调用模型 response = call_model(self.working + [user_message]) # 后置处理:存短期记忆、抽取结构化事实 self.short_term.append({ "id": f"m{self.turn_count}", "role": "user", "content": user_message }) self.short_term.append({ "id": f"r{self.turn_count}", "role": "assistant", "content": response }) self.working.extend(self.short_term[-2:]) return response

这段代码刻意保留了可读性,真实项目里至少还要加三样东西:token 统计用的分词器、消息去重的消息 ID、以及摘要模型失败时的降级策略。降级策略我建议做成"宁可保留原始消息也不中断服务",毕竟压缩失败只是上下文变长,服务挂了就是事故。

4.3 与模型交互的接入点

Harness 的接入位置需要仔细想。我的做法是封装了一个ask()函数,替换掉所有直接调用模型的位置。任何业务模块想和模型对话,只调用harness.ask(user_message),内部自动完成水位检查、记忆召回、prompt 组装和响应归档。这样改造存量代码时,每个调用点只改一行。

需要特别注意的是,不要让业务代码绕过 Harness 直接拼 prompt。我在项目中遇到过多次:某个同事图方便,在处理特殊流程时手动拼接了上下文片段,结果这些内容既没进入短期记忆,也没被压缩管理,反而占用了工作区空间,干扰了整体的水位判断。上下文维护的前提是所有消息都纳入统一管理,任何旁路都会破坏水位模型的准确性。

4.4 可观测性:维护动作也要被看见

上下文维护是隐形的,不出问题的时候很难察觉,出了问题又极难排查。所以我在 Harness 里加了完整的事件日志:每次触发压缩、生成摘要、召回记忆,都写一条结构化日志,包含触发原因、压缩前后 token 数、摘要内容 snippet。上线初期我靠着这些日志定位了至少三个问题——一次是摘要模型连续失败导致低水位从未达到,一次是召回逻辑把同一片段重复插入工作区,一次是剪枝规则误伤了关键字段。

日志还能用来做成本分析。压缩是额外模型调用,如果不记录,月底账单会让人困惑。我统计过,加了摘要和检索之后,单会话的模型调用成本大约上升 18%,但因为上下文更紧凑,生成的 token 总量反而下降,最终每完成一个会话的成本基本持平。这笔账一定要算清楚。

5. 踩坑记录:调参路上的六个真实教训

5.1 摘要频率太高,模型反而更笨

第一次上线时我把压缩触发阈值调得过于激进,每三到五轮就压缩一次。结果模型经常"忘记"最近几轮聊的具体内容,因为那些内容还没来得及被充分使用就被压成了摘要。后来我查日志发现,高频压缩导致一个恶性循环:压缩占用了模型调用次数,摘要质量不稳定,模型回答错误,用户补充说明,补充说明又触发新压缩。

教训是:遗忘调度要考虑信息的"使用价值"。刚发生的信息还有很高的即时利用价值,不应该过早压缩。把高水位调高、触发轮次调大之后,这个问题基本消失。

5.2 摘要丢了关键数字,酿成一次订单事故

这是让我印象最深的一次事故。用户在下单流程中确认了"优惠券抵扣 120 元",六轮之后问"优惠券是怎么算的",模型答"根据您使用的优惠券抵扣了约一百元"。原因就是中间那六轮被压缩时,摘要模型把"120"写成了"约百元"。金额这种关键信息,丢了就是事故级别的错误。

解决方案是给摘要模型加了一条结构化抽取规则:凡涉及金额、日期、单号、地址、姓名等实体,必须原样保留在摘要的"关键字段"区块,禁止改写。同时用正则做了校验,发现摘要里出现"约""大概"等模糊修饰词时直接触发重新摘要。钱这个东西,永远不允许被"压缩"。

5.3 摘要的摘要:递归失真

增量压缩模式下,摘要会不断追加新内容,旧的摘要片段会再次进入全量重写。我一开始没意识到,全量重写其实是对"摘要的摘要"再做压缩,每一次压缩都是一轮信息磨损。实测下来,三次重写之后,原始对话里的细节基本只剩骨架,语气、条件分支、例外情况全部丢失。

对策是给全量重写设置保真度检查:重写后的摘要必须能通过一轮"关键信息问答"验证——随机抽取原始片段中的五个关键问题,让模型用压缩后的摘要回答,正确率低于 80% 就保留旧摘要不合并,或者放宽摘要长度上限。这个检查虽然增加了一次模型调用,但能有效阻止信息在多次压缩中衰减到不可用。

5.4 检索召回命中率低,问题不在向量库

接入检索式记忆后,有一段时间召回效果很差,用户明明之前提过某个信息,模型却答不上来。我一开始怀疑向量库的相似度计算有问题,调了好几天 embedding 模型,效果依旧。后来排查才发现,问题出在抽取环节——不是检索不到,是压根没抽出来存进去。当时的事实抽取规则只覆盖了"用户直接陈述"的信息,而很多关键信息出现在模型自己的回复里,比如"我已经为您查到订单 20240615 的状态是已发货",这种由助手产出的事实完全没被抽取。

修正抽取规则,把助手回复中的结构化实体也纳入抽取范围之后,召回率直接翻倍。这个坑的通用教训是:上下文维护的整条链路里,入库环节往往比检索环节更容易出问题。先确认东西确实被存了,再优化怎么找。

5.5 剪枝误伤:规则太贪,把推理前提给剪了

结构化剪枝的规则一度写得过于激进,把"看起来没用"的工具返回字段全删了。结果有一次模型在分析用户退款资格时,因为缺少某个字段的原始值,给出了错误判断。那个字段在日志里显示的频率极低,但恰好是那个场景的判定依据。

从此我给剪枝规则加了一个限制:工具调用的返回内容默认只截断、不删除,且截断方向是保留尾部——很多 API 把汇总信息放在返回末尾。只在明确知道某个字段无意义的情况下才做字段级删除。宁可多保留一些 token,也不能为了省空间而丢掉模型做决策的依据。

5.6 恢复代价被低估:让模型"想起来"反而更贵

最后这个教训是关于成本模型的。很多人觉得压缩能省 token,实际上压缩是拿一次额外模型调用换取后续每次调用的空间节省。如果会话很快结束,压缩反而亏钱。短会话场景不需要压缩,直接全量带上下文反而是最优解。

我在 Harness 里加了一个会话长度预估:如果当前轮次小于 10 轮且水位没有逼近上限,就完全不做压缩动作。只有当会话确实走向"长尾"时才启动维护流程。这个开关让短会话的平均延迟和成本都降了一截,体验提升很明显。

6. 效果评估与下一步:从固定策略到自适应遗忘

6.1 评估指标怎么定

上下文维护做得好不好,不能只看对话流畅度。我在项目里用了四个客观指标:关键信息召回率,在会话结束后逐条核对用户提过的事实是否在后续回答中被正确引用;任务完成率,多轮任务里用户是否最终获得了正确结果;平均回答延迟,压缩与召回带来的额外耗时是否在可接受范围;单会话总成本,把摘要调用和 token 消耗都算进去。这四个指标各有侧重,单独看都会骗人,必须放在一起看。

实测数据上,加了 Harness 之后关键信息召回率从 62% 提升到 91%,任务完成率提升约 15 个百分点,平均回答延迟增加约 120 毫秒,单会话总成本基本持平。这个结果说明,把遗忘变成可控的维护动作,确实能换来实打实的质量提升——前提是压缩策略选得对、参数调得准。

6.2 下一步:让遗忘更接近人脑的记忆规律

现有策略还是太"平面"了,所有消息的压缩优先级基本一样。人脑的遗忘其实是有曲线的:刚发生的事记得最清,几天后快速衰减,但被反复提及的、情感强烈的、和既有知识关联紧密的信息会长期保留。我计划下一步在 Harness 里引入类似机制:给每条记忆维护一个"重要性分",随时间和引用次数动态衰减或增强;高分记忆走检索式存储,低分记忆允许被彻底遗忘,连摘要都不保留。

另外还有一个方向是递归自查——让模型定期评估自己的回答是否遗漏了关键上下文,一旦发现遗漏,主动触发记忆召回。这相当于给模型装了一个"自我怀疑"模块,成本更高,但能兜住一些调度层顾及不到的遗忘场景。

做上下文维护这一年多,我最大的体会是:不要幻想用一个完美的算法保住所有信息,那是和物理规律对着干。真正成熟的做法是接受遗忘必然发生,然后设计一套机制,让系统知道什么该忘、什么不该忘、忘掉之后还能不能找回来。Harness 这个名字起得也算贴切——它就像一个马具,把模型这匹烈马套在可控的轨道上,该收缰收缰,该放缰放缰。对正在做长对话场景的朋友,我的建议是别急着上复杂架构,先把水位线、摘要和召回这套最小闭环跑通,再根据你的业务数据逐步加码。上下文维护没有银弹,但把基础机制做好,遗忘就不再是事故,而是系统策略的一部分。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询