ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

LLM上下文管理:context-mode模式设计,解决长对话Token成本与记忆困境

LLM上下文管理:context-mode模式设计,解决长对话Token成本与记忆困境 先说一个我踩过的坑。去年做客服机器人上线没两周就被用户吐槽聊到第 20 句机器人就像失忆了一样前面说好的退款方案后面又重复问一遍。我第一反应是模型不行换更强的模型之后问题变成了 token 账单暴涨。后来我才意识到问题不在模型而在上下文——我一直把整段对话全量塞进去从没考虑过上下文到底该以什么模式进 prompt。这个思考最后变成了一个内部工具我管它叫 context-mode。这篇文章就把这套上下文管理模式的设计思路、模式拆解、实现细节和踩坑记录完整分享出来适合正在做 LLM 应用、聊天机器人、Agent 或 RAG 系统的开发者。1. 原生上下文窗口带来的两难选择为什么需要一个 context-mode1.1 全量塞入和高额 token 之间的死循环早期开发时大家往往遵循一个最直觉的做法把每轮对话都追加到 messages 数组里然后一股脑发给模型。这样做看起来“保留了一切信息”但实际操作几次就会发现两个致命问题。第一个是成本问题。每次请求的 token 消耗和上下文长度近似线性增长。客服场景平均 30 轮对话单轮 300 token累计就是近 9k token 起步。如果并发量上去了这部分成本是实打实的利润黑洞。第二个是质量问题。上下文越塞越多模型注意力会被稀释尤其是中间部分的早期对话经常处于“看到了但等于没看到”的状态。我自己在测试集上的数据是总上下文超过 8k token 之后客服意图分类的准确率开始明显下滑超过 12k 后关键实体订单号、金额、地址的抽取准确率掉了近 15 个百分点。这时候有个很自然的想法那就把超出的部分截断吧。但截断也是一门学问。直接丢弃旧消息会导致用户重复投诉他已经说过的诉求系统却在后续回答里完全忽略。按重要性排序保留又很难定义“重要消息”的边界。我试过按时间衰减、按对话轮次加权、按实体命中率打分都没有一个让人满意的通用解。1.2 从 kubectl context 里得到的灵感后来有次在配 Kubernetes 多集群环境时我突然意识到kubectl context解决的其实是同一个问题。多个集群的连接信息都在 kubeconfig 里但你不必每次把所有集群的信息全加载进来只需要切换到一个 context让当前命令只面对一个目标环境。切换成本极低而且每个 context 的配置可以被单独组织。对话系统也一样。整个会话的历史记录池是唯一的但每次模型请求真正需要的上下文可能是不同的子集和不同的形态。与其设计一个万能的“全保留或全截断”策略不如定义一个 context-mode让系统根据当前任务类型、历史长度、token 预算切换到不同的上下文处理模式。这个思路一出来整个架构就清晰了。1.3 上下文模式的抽象层次我给 context-mode 下的定义很简单它是一组与当前请求绑定的上下文构造规则决定了哪些历史消息被保留、以什么形式保留、按什么顺序注入 prompt。之所以用“模式”而不是“参数”是因为单个布尔开关无法覆盖真实场景。你需要的是几个有明确边界的预设Full Mode、Compact Mode、Focus Mode。每个模式有自己的构造函数、压缩策略和触发条件。后续加新模式时只要实现了统一的上下文包接口就能无缝接入。这套设计不是为了炫技而是为了在“信息密度”和“回复可靠性”之间找到平衡点。2. context-mode 的具体定义三种核心模式拆解2.1 Full Mode完整上下文适合复杂推理Full Mode 是最直白的模式把当前 session 中所有消息按原始顺序全部注入 prompt。它不保证绝对完整因为也有消息数上限和 token 上限但策略是“能塞多少塞多少、塞不下再从最早的消息开始丢弃”。适用场景很明确需要全局理解才能给出结论的复杂任务比如代码审查、合同分析、方案设计。用户明确依赖早期信息的任务例如“把我上周说的需求一起考虑进来”。对话轮次较少一般少于 10 轮且总 token 在预算内的情况。Full Mode 的优点是信息无损缺点是成本和注意力稀释风险高。所以我会给它加一个硬性上限超过这个上限就不再是“Full”而是强制走降级流程。这个上限不一定是模型自带的最大上下文长度而要结合你自己的质量评测来定。我曾经在 32k 上下文窗口的模型上测试过当 prompt 超过 20k 时回复质量和稳定性都呈下降趋势。因此我把 Full Mode 的硬上限设为模型最大窗口的 50%~60%这是一个相对安全的经验值。2.2 Compact Mode压缩摘要适合长会话Compact Mode 解决的是长时间会话问题。核心思路是把早期消息压缩成结构化摘要保留最近几轮原始消息再把两者合并作为模型输入。我的设计是这样的一条初始摘要记录用户的长期偏好、已经确认的关键事实、尚未解决的问题列表。一个最近消息窗口保留最近 N 轮完整对话N 根据 token 预算动态调整。一个实体表记录用户、订单、时间、金额等关键实体及其状态防止摘要时丢信息。比如一个客服对话进行了 40 轮Compact Mode 会把前 30 轮压成一段 500 字的摘要剩余 10 轮按原文携带。这样模型既能理解前因后果又不会被大量重复闲聊淹没。实现上需要注意压缩的粒度。我踩过的坑是初期用模型直接压缩历史结果每次压缩都丢一点细节多轮压缩后摘要越来越“面”甚至出现事实错误。后来改成“摘要 关键消息列表”的双通道结构摘要负责叙述逻辑关键消息列表负责保存不可丢失的硬事实订单号、承诺、金额等。这个结构在后续评测中效果远好于单纯摘要。2.3 Focus Mode裁剪检索适合 RAG 问答Focus Mode 放弃大部分历史消息只保留“当前意图 必要背景”的精简上下文。它适合 RAG 场景或者单轮工具调用场景。在这种模式下我会把用户当前问题、从上文抽取出的潜在指代关系、以及检索到的相关知识片段拼装成 prompt。历史对话只保留一个极简的“短期事实快照”比如“用户正在处理订单 #1024 的退款问题”。如果当前问题不依赖多轮历史Focus Mode 能把 token 消耗降到最低同时减少注意力分散提升检索答案的引用准确率。Focus Mode 最大的风险是过度裁剪导致失去指代信息。用户说“第二个方案我接受”如果不带上下文模型根本不知道“第二个方案”是什么。所以我在实现里加了一个轻量级的指代解析模块从最近几轮消息中抽取候选实体和候选方案随当前问题一起注入。这一块后面有专门小节展开。2.4 模式选择策略基于任务类型和 token 预算三种模式不是拍脑袋选的而要由一个路由函数决定。我总结了一个简单但有效的选择算法先看任务类型如果是多跳推理、代码生成、长文档分析默认 Full Mode。再看对话长度如果历史超过阈值Full Mode 降级为 Compact Mode。最后看任务目标如果当前是检索问答、查天气、算算术这类独立任务优先 Focus Mode。如果用户主动要求“重新考虑整个对话背景”强制切回 Full Mode。我用一句话概括这三者的关系Full 是完整记忆Compact 是概括记忆Focus 是工作记忆。一个健康的对话系统应该像一个有经验的客服一样在需要回忆时记得清在需要执行时抓重点。3. 实现 context-mode 的关键细节状态机、触发器和上下文包3.1 上下文状态机active / compacting / focused / closed在代码层面我会为每个 session 维护一个上下文状态机。状态包括active处于 Full Mode完整历史可用。compacting正在进行摘要压缩或已经切换到 Compact Mode。focused处于 Focus Mode只有精简上下文。closed会话结束上下文资源释放。状态转换必须显式触发。比如从 active 到 compacting需要在历史长度超过阈值时自动发生从 compacting 到 focused则需要当前任务被判定为“独立任务”时发生。状态机的好处是可以拦截非法切换。比如会话还没结束你不能直接跳到 closed同一时刻只允许一个 mode 生效。我最初没有状态机直接用 if-else 判断模式结果在并发场景下出现了可怕的竞态一个请求刚切到 Compact另一个请求又基于 Full 的历史构造了 prompt导致同一 session 内回答风格前后矛盾。引入显式状态机后这类问题就不再出现。3.2 触发切换的三种时机模式切换不是任意时刻都能做我设置了三个检查点第一个是请求入口。每次新消息进来时路由函数根据任务类型和历史长度判断当前 mode 是否仍然适用。这个检查最频繁也最关键。第二个是响应生成后。模型生成回复后如果回复里出现了“刚才说”“之前提到”这类回溯词说明模型需要更完整的上下文系统会把下一个请求自动升级到 Full Mode。这个信号是通过一个简单的正则规则检测的。第三个是定时压缩。对于超长会话即使没有新消息进来后台也会定期把早期历史压缩一次保证随时可以切换到 Compact Mode。这三种触发方式覆盖了大部分场景。它们都是副作用很小的动作不会打断用户与系统的交互。3.3 上下文包的数据结构与序列化为了让模式切换无感我设计了一个统一的 ContextBundle 数据结构。核心字段如下dataclass class ContextBundle: session_id: str mode: str # full | compact | focus status: str # active | compacting | focused | closed messages: list # 原始消息Full 模式下为全部Compact 下为最近窗口 summary: str # Compact 模式下的历史摘要 entity_table: dict # 关键实体表如订单号、用户名 focus_snapshot: str # Focus 模式下的短期事实快照 target_task: str # 当前任务类型用于路由 token_estimate: int def to_prompt(self, tokenizer): if self.mode full: return self._build_full_prompt(tokenizer) elif self.mode compact: return self._build_compact_prompt(tokenizer) elif self.mode focus: return self._build_focus_prompt(tokenizer)这里最核心的是to_prompt方法。不同模式生成 prompt 的逻辑不同但对外部调用方来说只需要拿到一个 bundle 然后调用to_prompt。这样即使后续新增模式上层代码也完全不用改。序列化方面我使用 JSON 协议缓冲区双层结构。轻量场景下直接用 JSON 落盘方便调试生产环境用 protobuf 减少存储开销。实体表单独使用一个哈希表存储并且支持快速查询单个实体避免每次构造 prompt 时都全量扫描。3.4 压缩策略摘要、向量召回和消息丢弃的正确组合很多人问压缩历史到底该用摘要还是向量召回我的答案是分阶段使用。当历史消息数量还在可控范围内比如 30 轮以内用 LLM 做摘要是最简单有效的。让模型提炼出三个关键信息用户目标、已确认事实、待解决问题。这三类信息对后续对话最重要。当会话已经很长比如超过 100 轮再对全部历史做摘要会非常贵。这时候可以引入向量召回把每条历史消息向量化在构造 prompt 时根据当前问题去召回最相关的历史片段。这种方案的特点是“用检索代替摘要”能处理更长会话但可能会漏掉某些不直接相关却对全局理解重要的信息。所以我现在的混合策略是先用摘要生成全局骨架再用向量召回补充与当前问题强相关的局部细节。两者合并之后再经过一次信息去重避免重复内容污染 prompt。消息丢弃则是最下策要配合用户反馈机制使用。比如连续两轮模型回复都被用户点踩就把最近的对话消息重新加回上下文并切到 Full Mode。这种反馈回退机制很有效能让系统从长会话退化中自我恢复。4. 从原型到生产我在真实项目中改过的几个坑4.1 模式切换导致的回答风格断层第一个坑来自一个很意外的地方切换模式后模型回复风格会突变。Full Mode 下模型能看到很多早期细节回复往往很具体Compact Mode 下如果摘要写得太干巴模型就会开始“套话”或反问更多信息。同一个用户上午还在享受详细解答下午就发现系统回复变得简短而敷衍体验非常分裂。解决思路是给摘要增加“语气与承诺”字段。压缩历史时要求模型额外记录用户偏好和服务承诺的风格例如“用户希望回复尽量给出具体金额”“之前承诺了 24 小时内反馈”。这些摘要字段会和实体表一起注入 prompt让模型即使看不到原文也能延续之前的表达风格。4.2 Compact Mode 的摘要漂移问题摘要漂移是长会话方案的头号公敌。最开始我用一次压缩后后续就不断在这个摘要上继续追加结果过了 20 轮后早期摘要被层层“二手信息”覆盖很多细节已经面目全非。后来我改成了“分级压缩”第一级每 10 轮消息生成一个局部块摘要原始消息仍然留存备份。第二级只允许基于原始消息块重新生成全局摘要而不是在局部摘要上再抽象。这样做的好处是每次压缩都以原始消息为基准不会出现错误逐级放大的问题。代价是存储成本上升但换来的是关键事实的准确性这在客服场景里是值得的。4.3 Focus Mode 中检索结果与历史记忆的冲突Focus Mode 投入使用时出现了一个诡异现象用户明明在上一轮提过订单编号当前问题里只写了“那这个订单呢”但系统因为切换到了 Focus根本看不到“这个订单”指什么。我把指代解析模块加进去之后情况好了很多但新的问题又来了。有时候检索到的外部知识片段和系统提示词里的历史事实不一致。比如知识库里说“退款周期是 7 个工作日”但用户在上一轮刚说过“客服承诺 3 天内到账”。Focus Mode 把历史事实丢掉了模型就只会照着知识库的通用规则回答违背了对用户的承诺。解决方式是在 focus_snapshot 里额外保存“与当前任务冲突度最高的历史事实”。选择哪些事实进入快照不是靠规则枚举而是用一个轻量分类器判断如果当前问题涉及金额、时间、承诺、责任这些高风险实体就把这些实体的最新状态强制注入 prompt。这样既保持 Focus 的精简又不会丢掉最关键的履约信息。4.4 token 计数误差与预算控制最后分享一个容易被忽略的坑token 计数。很多人直接用len(text)估算这在英文上勉强能用在中文和多字节字符场景下误差非常大。我见过线上系统因为低估了 token 数量导致 prompt 超过模型窗口直接报错。后来我把所有 token 估算都统一走模型对应的 tokenizer 函数而不是字符长度。同时给每个 mode 设置了不同的预算上限比如 Full Mode 不超过总窗口的 60%Compact Mode 不超过 40%Focus Mode 不超过 25%。这个比例是我根据多次测试调出来的。预算上限之外还要留安全余量因为模型回复本身也需要 token 空间。这里还有一个细节不同模型的 tokenizer 版本可能不同同一个字符串在不同模型下的 token 数会差不少。尤其是在一个支持多模型切换的平台上预算管理函数必须能感知当前模型否则就会出现切换模型后 prompt 超长或预算浪费的问题。5. context-mode 的适用范围和不适用场景5.1 什么时候值得上 context-mode如果你的应用满足下面任一条件那就值得引入 context-mode会话长度经常超过 10 轮且历史请求全量注入风险高。任务类型混杂既有需要完整背景的多跳推理又有只需局部信息的检索问答。对 token 成本敏感希望用更小的 prompt 达到相近的回复质量。你已经在尝试给 prompt 加各种规则但规则一多就互相打架。我自己是在客服机器人上验证的。上线 context-mode 后平均每请求 token 消耗下降了约 45%同时人工评测的“关键信息保留率”提升了 20% 以上。成本和质量同时改善这种优化并不多见。5.2 什么时候不要用context-mode 不是银弹。如果你的应用满足下面任一条件建议先别上会话平均轮次很少1~3 轮历史信息完全可以一次带过。模型能力极强且上下文窗口极大而你并不在意成本。你还没有一套可量化的评测集单纯靠感觉调上下文任何模式都只会让你更混乱。还有一个不太显眼的限制如果你的应用不允许异步压缩任务那么 Compact Mode 的压缩过程可能在请求中造成额外延迟。我一般会把压缩放到后台队列而不是卡在用户请求的链路上。5.3 开源框架对照LangChain 的 memory 不就是另一种 context-mode 吗后来我研究了 LangChain 的 memory 机制发现它本质上也是在做 context-mode 的活。ConversationBufferMemory 是 Full ModeConversationSummaryMemory 是 Compact Mode而 VectorStoreRetrieverMemory 是 Focus Mode。只是框架把这些模式做成了可插拔 memory 类而我在做的是一套统一的状态机和上下文包。对比两者的取舍LangChain 的方式好处是开箱即用适合快速原型。自己做 context-mode 的好处是能统一控制状态转换和压缩策略能针对业务数据做更细的优化。如果你是技术选型阶段我的建议是先用 LangChain memory 跑通业务逻辑确认质量和成本瓶颈后再决定要不要自研。不要一上来就为了纯技术追求自研上下文系统。5.4 给团队的建议先在指标上验证再谈模式最后说一点管理层面的经验。推动 context-mode 这类内部设计时最容易犯的错是“想当然”。不要嘴上说“这样效果会更好”要用数据说话。我在内部推行时设计了一套简单评测集包含 100 组真实客服对话每组对话都标注了“必须被回传的关键事实”和“期望回复”。然后分别跑 Full、Compact、Focus 三种模式统计“关键事实召回率”和“回复准确率”。这个评测集成为后续所有模式调整的基准。一个优秀工程团队和业余团队的区别往往就在于有没有一套反馈闭环。context-mode 不是写完就结束了每次压缩策略调整、每次模式切换条件变更都应该回到评测集上验证。我后来几乎把一半的精力放在维护评测集和制定量化目标上收到的回报远比写更多代码要大。最后分享一个小技巧如果你也想快速落地一个简版 context-mode不需要一上来就写完整框架。先从最痛的点开始给当前 prompt 加一段“关键事实快照”把用户最早确认过的目标、最核心的几个实体、以及你给过的承诺写进去。这个动作只需 100 行代码就能明显改善长会话后半段的回复质量。等跑通之后再慢慢扩展出 Compact 摘要、Focus 检索和状态机你会发现这套模式设计远比想象中顺手。
返回列表