ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

上下文管理模式设计:解决LLM长对话失忆问题的工程实践

上下文管理模式设计:解决LLM长对话失忆问题的工程实践

做AI对话类应用的人应该都有过这种体验:新上线的智能助手前几轮表现得聪明利落,一旦用户多聊几句,模型就开始“失忆”,前面确认过的信息转头就忘,甚至连用户名字都能搞混。我一度以为是模型能力不行,后来把prompt日志拉出来逐条排查,发现问题出在“上下文”的组织方式上。为了系统性解决这件事,我写了一个内部项目,代号就叫context-mode,核心思路是给不同阶段的对话配上不同的上下文管理模式。这篇文章会从需求拆解、模式设计、工程实现到踩坑复盘,把整套方案完整写出来,适合正在做LLM应用、Agent编排或智能对话产品的开发者参考。

1. 上下文负担:为什么对话一长模型就“失忆”

1.1 一段“听话”历史引发的错觉

大多数方案的第一版都是“把历史消息全部拼进prompt”,我的第一版也不例外。做法很简单:写一个收集历史消息的函数,把用户和助手的消息按时间顺序拼接成一个长文本,连同最新用户输入一起发给模型。

刚开始效果确实不错,使用者甚至会觉得这个助手“很懂我”。但随着对话轮数增长,问题开始密集出现。我拿一次超过40轮的在线客服对话做了统计,结果发现:

  • 模型重复提问的次数占整体回复的27%,用户刚回答过的内容它又问了一遍
  • 用户在对话早期明确提出的诉求,在处理时被直接忽略
  • 有一次模型把上一个会话里用户确认的地址,和当前会话中另一个用户提供的地址搞混了

这让我意识到一个重要结论:上下文窗口不是随便塞东西的仓库,塞得越多,模型真正能利用到的反而越少。问题不在对话内容本身,而在“如何组织、裁剪、呈现”这些内容。

1.2 长上下文的物理限制

这里涉及一个在业内被反复验证的现象:模型在超长上下文中的注意力分布并不均匀,对位于中间位置的文本尤其容易忽略,业界通常叫它“Lost in the Middle”。就算上下文窗口有足够的token额度,把额度用完也未必是好事。我实测发现,当上下文占用超过窗口的三分之二后,模型对中段信息的引用准确率会出现明显滑坡。

除了准确率,成本也是实打实的。每多一个token就多一份推理开销,长上下文不仅增加单次请求的货币成本,也让首token延迟显著变长。对于客服、智能助手这类需要实时互动的场景,延迟上升直接伤害用户体验。用户不会关心你是因为“上下文太长”才变慢的,他只知道“这个助手变笨了”。

1.3 context-mode要解决的需求拆解

在设计方案之前,我把需要解决的问题拆成四条硬性需求:

  • R1:对话历史不能无上限增长,必须显式管理,而不是让prompt被动膨胀
  • R2:不同任务阶段对上下文的需求不同,闲聊场景不需要精确保留每一条历史,但下单、支付时必须能精确引用早期信息
  • R3:上下文组织方式的切换必须可观测、可回滚,不能变成一个黑盒
  • R4:无论用哪种模式,最终发给模型的prompt结构要保持稳定,避免模型因为格式漂移产生混乱

这四条需求决定了后面所有设计的方向。也让我确定 context-mode 不是一个单一技巧,不是“加摘要”或者“截断”就完事,而是一套模式管理框架,需要把会话的生命周期切成不同的模式进行治理。

2. 先给上下文体检:一份会话信息的分类清单

2.1 会话里到底有哪些信息在占用token

动手写代码前,我先做了一件事:把一次真实会话中所有可能被写进prompt的信息,拉成一份清单。这一步非常关键,因为大多数人只盯着对话历史,却忽略了很多“隐性信息”同样在占用上下文,也在影响模型表现。

信息类别典型内容丢失后的影响
对话历史用户消息、助手回复、中间追问高,模型会出现重复提问
用户画像偏好、身份、已确认的约束极高,可能直接违反用户要求
工具返回值API响应、数据库查询结果中高,Agent经常因此中断
会话元信息时间、来源页面、设备类型中,对部分场景影响明显
系统指令角色设定、安全规则、输出格式极高,丢了会崩
瞬时状态当前执行步骤、待确认项目极高,丢失即任务中断

做完清单我才发现,真正需要“动态管理”的是对话历史、工具返回值这一类随着会话不断增长的信息,而系统指令、用户画像属于必须保留的骨架,连压缩的念头都不该有。

2.2 静态层、动态层、瞬时层:三层划分法

我把上表里的信息按照“可变性”分成三层:

  • 静态层:系统指令、角色设定,几乎不变,占固定开销,永远保留
  • 动态层:会话历史、工具返回,随时间增长,是模式管理的核心对象
  • 瞬时层:当前用户请求、当前执行步骤,必须保持最新,不做压缩

这个分类引出了一个关键设计决策:用户画像这类重要信息,可以定期从动态层里提炼出来,单独缓存为静态层的一部分。这样即使后续历史被摘要、被压缩,用户偏好也不会丢失。我在真实场景里验证过,这一步对长会话的效果提升非常明显,很多所谓的“失忆”其实是用户画像跟着历史一起被压缩掉了。

2.3 动态层的预算怎么算

在研究模式策略时,我给上下文预算定了一个简单但有效的公式。假设目标模型的上下文窗口是 W,静态层固定开销是 S,当前请求连同瞬时状态的最低必要预算是 C,那么动态层可用预算 D 就是:

D = W - S - C

所有模式本质上都是在 D 这个预算内,决定“给模型看什么”。这个公式的价值不在精确性,而在于给决策划定边界。我见过不少团队花大量精力优化prompt里的措辞,却放任动态层占了窗口的90%,那优化措辞的意义非常有限。提前把预算图画出来,后续所有模式切换的阈值都有了一个统一的参考基准。

2.4 判断“哪条信息可以删”的方法

怎么知道一条信息到底重不重要?我采用了一个看起来笨但很有效的方法:删掉这条信息,跑一组回归问题集,对比模型输出的准确率变化。如果删掉后准确率几乎不变,说明这条信息是可以摘掉或压缩的候选;如果准确率明显下滑,就说明它必须留在上下文里。我把它称作“精度差分评估法”。

实操上,回归问题集不需要很大,按业务场景挑20到30个典型问题就够。关键是覆盖三类:需要引用早期信息的问题、需要遵守用户约束的问题、必须依赖工具结果才能回答的问题。每次调整上下文策略,就把这组问题跑一遍,用数据说话,而不是凭感觉调参。

3. 三种核心上下文模式的设计与取舍

3.1 Full Mode:完整直通不是“无脑全塞”

Full Mode 的目标很明确:在关键任务阶段,尽可能保留完整历史信息,让模型能够精确引用。典型场景包括下单支付、医疗咨询、多步工具调用等。

但Full Mode也不是把全部历史原封不动丢进去。我在实现时给Full Mode加了三个前置操作,效果非常明显:

  • 去掉历史中上一轮assistant回复里复述的用户请求片段,只保留最终输出。很多模型在流式回复时会复述一遍用户问题,这些复述很占token,但没有任何增量信息
  • 对工具返回值做字段级裁剪。比如数据库查询只保留命中行和必需列,不把整个数据表丢进去
  • 对对话历史做事件去重。如果用户连续三次调整同一个参数,保留最后一次完整表述和第一次的原话,中间的过渡轮次降级为一行状态标记

这些操作不改变语义,只是去掉冗余。实测下来,能让Full Mode的token占用减少20%到30%,模型能力的发挥反而更稳定,因为重要信息不再被无关细节稀释。

3.2 Compact Mode:分层摘要比一次性总结可靠得多

Compact Mode 用于中长会话和非关键阶段,核心手段是摘要压缩。但直接对整段历史做一次性总结,往往会丢失时间线,导致后续对话“张冠李戴”。我采用的是分层摘要策略,也叫rolling summary。

具体做法是:每经过N轮对话,或每累计K个token,就触发一次摘要。新摘要生成时,必须基于“上一次摘要 + 期间新增的消息”,而不是对全量历史重新总结。这样摘要始终是最新的一层,保留了事件发生的先后顺序。

实现时有两个细节很关键。第一是给摘要设预算,我按目标模型窗口的30%作为摘要上限,一旦超过就继续缩编,或者把更早的内容异步写入长期存储。第二是摘要必须带元信息,包括时间锚点、置信度、对应的原始段落引用。生成摘要时我会要求模型只写确定信息,不确定的内容宁可省略,也不要编造。

还有一个容易忽视的点:即时状态块必须原样保留在摘要前面。比如购物车内容、当前正在上传的文件列表、待用户确认的条款,这些信息一旦被摘要吸收,用户会明显感觉到“助手记性变差”。这类信息不能被压缩,必须原样置顶。

3.3 Retrieve Mode:从“全量保留”走向“按需召回”

Retrieve Mode 解决的是另一个问题:当会话特别长,或者需要引用很久以前的信息时,压缩可能会把关键细节丢光。与其把全部历史一次塞给模型,不如把历史切片,按需召回。

我没有直接用常规向量检索完事,因为对话历史有一个特点:很多细节只在特定时刻有意义,过了时间窗口就不再需要。所以我在召回前加了一层时间衰减过滤:只有与当前问题相关性足够高、且时间上不算过于久远的消息,才会被召回。如果一条旧消息相关性特别强,仍然可以突破阈值被召回。

实际操作时,我还会给每条召回结果附加一个置信度字段。置信度低于阈值的片段,宁可不注入prompt,也不让它诱导模型基于残缺信息做出自信却错误的推断。这一步让检索模式在真实场景中的可靠度提升非常明显。

3.4 三种模式的取舍与选择建议

模式token成本信息完整度延迟典型场景
Full Mode高高高下单、支付、多步工具调用
Compact Mode中中低高频消息、闲聊、信息收集
Retrieve Mode动态中高中知识库问答、跨会话引用

选择建议很直接:如果任务要求高精确性,优先Full Mode;如果是长会话但不需要精确引用早期细节,优先Compact Mode;如果用户明确提到“我之前说过”,或问题涉及历史事实,优先Retrieve Mode。三套模式不是互相排斥的,一个会话可以随阶段切换,关键在于切换规则要清晰。

4. 切换不是调个if:模式状态机与工程落地的关键细节

4.1 切换判定:不要只盯着token数

一个很容易犯的错误是只按当前token数决定是否切换模式。实际上,切换节奏应该结合多个信号综合判断:

  • 会话轮数:超过一定轮数后,Full Mode的优势开始下降,继续硬撑只会增加成本和延迟
  • 关键事件:用户提供了新约束、要求重新协商、话题发生明显跳转,这些时刻需要回到完整上下文
  • 错误重试率:如果模型连续三次解析同一个操作失败,大概率不是模型笨,而是上下文丢了关键信息。此时正确的做法是切换或重建上下文,而不是继续追加历史

我最终把规则收敛成一组简单的策略描述,例如:“会话轮数小于12轮且没有关键事件,用Full Mode;每5轮评估一次是否需要进入Compact Mode;一旦识别到下单、更改地址、支付这类关键词,立刻切回Full Mode”。这些规则写成了可热加载的配置文件,调整时不需要改代码。

4.2 状态机与滞回区:避免模式抖动

切换逻辑必须防抖。如果模式切换过于频繁,用户会明显感觉到“记忆时好时坏”,上一句模型还记得,下一句又忘了。我引入了一个滞回区概念:一旦进入Compact Mode,至少保持N轮不再切走,除非遇到优先级足够高的关键事件。这里的N可以是3轮或5轮,按业务对记忆连续性的敏感度调整。

状态机我收敛成四个状态:Full、Compact、Retrieve、Preparing。其中Preparing状态专门用于上下文重建期间。重建过程中如果用户来了新请求,直接排队或用旧上下文先响应,避免让用户感知到“断片”。

4.3 标准化上下文结构:meta、segments、current

为了让模式切换不至于重写整条请求管线,我把上下文定义成统一的数据结构:

  • meta:会话ID、创建时间、当前模式标识、版本号
  • segments:历史、摘要、工具结果分段的列表,每个段带来源和优先级
  • current:当前用户请求以及瞬时状态块

不同模式只是segments的组装方式不同,最终发给模型的messages永远是同一套模板。这样做的好处是显而易见的:切换模式时只需要重新构造segments,不需要改动请求发送逻辑,回退时只需要换回之前的segments,成本极低。

4.4 异步压缩与版本号:避免并发竞态

Compact Mode的摘要压缩如果放在同步链路里,首token延迟会很夸张,体验很差。我的做法是把压缩操作挪到后台异步执行:利用模型响应结束后的空闲时间,或者用户正在输入的时间段,后台重新生成摘要,并把最新上下文写回存储。

但异步引入了一个新的问题:后台压缩读取旧消息的同时,用户可能又发了新消息,压缩结果会把新消息丢掉。为了解决竞态问题,我给会话快照加了一个版本号。每次新用户消息到达,版本号自增;后台压缩任务在真正执行前检查版本号,如果发现期间有变化,就放弃本次压缩,等下一次空闲时机。

这个细节看起来不起眼,实际使用中它避免了很多“上下文不一致”导致的诡异问题。没有版本控制之前,我遇到过模型突然丢失了用户最新几条消息的情况,排查了很久才发现是异步压缩的竞态。

4.5 回退机制:切换失败不能阻塞对话

模式切换不是百分百成功。摘要生成可能超时、检索服务可能不可用、新上下文构造后模型可能报格式错误。在设计上我加了一条简单但重要的回退规则:每次构造新上下文时,保留旧上下文快照。如果带新上下文的请求失败,自动回退到旧快照重新发送。

快照我只保留最近三份,超过就清理,存储开销几乎可以忽略。这个回退机制在早期联调阶段救了很多次场,尤其是新增摘要格式偶尔不符合模型预期的时候,有旧快照兜底,至少能保证用户请求不会被卡死。

5. 实测数据与踩坑复盘

5.1 一组可复现的对比结果

我用一套模拟客服数据做了对比。数据里包含了用户反复修改地址、中途插入闲聊、最后要求结算这类真实场景。每组策略各跑100轮模拟对话,记录到第40轮时的表现:

策略错误率用户约束遵从率平均单轮token占用
全量塞历史约18%81%满负荷
Full Mode + 裁剪约11%89%偏高
Compact Mode(一次性摘要)约15%86%中等
Compact Mode(分层摘要 + TTR)约7%93%中等
Retrieve Mode约8%92%动态

分层摘要加TTR预算的Compact Mode,以及Retrieve Mode,在长会话稳定性和约束遵从率上优势明显。Full Mode在24轮以内依然是最稳的,这也反过来支持了“关键阶段切回Full Mode”的切换策略。

5.2 第一个坑:摘要写进去的“幻觉实体”

有一次模型在摘要里写了一句“用户表示愿意接受替代品”,实际上用户只是随口开了个玩笑,并没有真正同意。这句摘要被当成确定信息带进了后续对话,系统开始给用户推荐一堆他根本不想要的产品,用户体验直接崩了。

教训很明确:摘要生成必须开保守模式,只写被明确确认过的信息,并且在摘要里给每条关键记录加上时间锚点。宁可摘要少写一点,也不能把不确定的内容写进去。

5.3 第二个坑:token计数口径不统一

早期我用一个通用估算库来计算token,但实际请求用的是目标模型自己的tokenizer,两者的差异在长消息上能达到10%到15%的偏差。这直接导致切换阈值不准,有时候该压缩的没压缩,不该切的先切了。

排查了半天才发现,问题不在策略,而在计量基准不一致。后来把上下文预算相关的所有统计统一改用目标模型自己的tokenizer,阈值计算才稳定下来。这个坑属于典型的“看起来小,影响全盘”,建议一开始就锁定计量口径。

5.4 第三个坑:切换太勤快,用户觉得记忆时好时坏

最早的实现是“一超过窗口比例就切Compact,一检测到关键词就切Full”。结果用户聊三句话,模式切了两次,模型的表现忽好忽坏。加入滞回区之后,模式切换频率明显降低,用户体验的稳定性也上来了。

所以我的经验是:切换逻辑宁可保守,不要激进。模式切换的本质是牺牲一部分上下文信息来换取成本与延迟的可控,如果频繁来回折腾,用户感知到的就是“记忆断断续续”,反而两头不讨好。

5.5 第四个坑:召回片段诱导模型“自信犯错”

Retrieve Mode里最容易踩的坑是:召回来的历史片段本身是不完整的,模型基于残缺片段给出了看起来自信、实际错误的推断。比如只召回了一句话的一部分,模型就把它当成完整事实去回答用户。

我在召回结果中加了置信度过滤后,这个问题基本被杜绝。低置信度的片段宁可不注入,也不要给模型制造“输出幻觉”的机会。实际使用中,过滤掉的片段里确实有一些本该有用的信息,但整体稳定性的提升远远覆盖了这部分损失。

5.6 一些经验总结与扩展方向

做完整套 context-mode 之后,我最大的感受有三条。

第一,策略一定要文件化。模式和切换阈值不要写死在代码里,而是放在可热加载的配置文件中。项目在不同业务线复用时,只需要调参数,不需要改逻辑,省了很多重复联调的时间。

第二,可观测性要放在第一位。每次模式切换、每次摘要生成、每次召回,都要留下结构化日志。这套日志在排查“模型为什么突然变笨”时,比任何prompt调试工具都管用。数据不会说谎,日志能告诉你问题出在上下文组织还是模型本身。

第三,建议从会话信息清单开始做。不要直接照抄别人的摘要模板或者RAG方案,先把上下文的组成、现状和预算摸清楚,再决定用哪种模式。每个业务对“重要信息”的定义不同,照搬方案的结果往往是水土不服。

后续我想把压缩后的摘要数据沉淀成微调语料,再把切换日志接入现有的可观测平台做模式分析仪表盘。如果你也在做Agent或者对话产品,可以先从最小的一版Full Mode加裁剪跑起来,再慢慢叠加Compact和Retrieve,这样风险可控,也好验证每个模式带来的真实收益。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进