多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

大模型上下文管理实战:三种模式优化对话记忆与Token成本

大模型上下文管理实战:三种模式优化对话记忆与Token成本 1. 项目概述context-mode到底解决什么问题先说说我为什么会盯上这个方向。做LLM应用开发的朋友应该都有体会模型能力再强也架不住上下文窗口有限、token成本居高不下。尤其是做客服问答、长文档对话、多轮Agent这类场景对话一长历史消息把窗口撑爆模型就开始“失忆”表现从聪明伶俐变成前言不搭后语。context-mode这个名字简单说就是一套针对大语言模型对话上下文的处理模式核心解决三件事上下文该留什么、压缩成什么、什么时候触发重组。这个项目在我手里的定位是一个轻量级上下文管理模块不依赖特定框架可以嵌入到任意LLM应用中使用。它不负责模型选型不管你是用GPT系列、Claude还是国产开源模型只要走Chat Completions这套接口都能接。它管的是“喂给模型的这一段内容怎么组织”让token花在刀刃上让模型既保留长期记忆又聚焦当前任务。适合谁来看这篇如果你正在做以下事情这篇内容应该能直接帮到你开发多轮对话机器人遇到“聊久了就蠢”的问题做长文档问答但文档动不动超窗正在攒一套RAG系统但对“检索结果 对话历史”怎么拼给模型没想清楚想控制API费用把不必要传的历史消息砍掉我后面会把这套模式的架构设计、三种核心策略的取舍、关键参数的计算逻辑、以及我在实际调试中踩过的坑全部摊开讲。有代码的地方给代码有参数的地方给计算过程尽量做到你照着就能落地。2. 三种核心模式的设计思路与选型这套模块我没有只做一种策略因为实际业务对“上下文”的需求差异太大了。一个闲聊机器人和一个合同审查助手它们对历史消息的依赖程度完全不同。所以我把上下文管理拆成三种模式滑动窗口模式Window Mode、摘要压缩模式Summary Mode、分层混合模式Hybrid Mode。2.1 滑动窗口模式最直观也最容易理解滑动窗口这个思路很多人其实已经在用了就是只保留最近N轮对话更早的统统丢进回收站。它看起来简单但细节里全是坑。先说窗口大小的设定。这里最大的误区是“窗口越大越好”。我见过有人直接把最近50轮全塞进去结果几轮下来token数就突破了模型上限。这个N值不应该拍脑袋定而是要根据模型上下文限制、单轮平均token数、预留回复空间三个参数反推。我一般用下面这个公式作为起点max_context_tokens 8000 # 以8K上下文模型为例 max_reply_tokens 1024 reserved_system_tokens 512 max_history_tokens ( max_context_tokens - max_reply_tokens - reserved_system_tokens )假设单轮对话平均token数为300那么滑动窗口的轮数上限大约是(8000 - 1024 - 512) / 300 ≈ 21轮。这个21轮就是你能放心塞进去的上限超过这个值模型要么回复被截断要么系统提示词被挤掉。很多线上事故其实就是这么发生的——日志里看到模型莫名其妙回复乱码查到最后发现是上下文超窗被模型自行截断。滑动窗口还有一个容易被忽略的问题窗口怎么滑动。是按消息条数滑还是按token数滑我推荐按token数滑因为消息条数在不同业务里波动太大了。有的用户一句话50个token有的一贴就是2000字长文按条数滑会导致窗口内token总量极不稳定。实现上可以用一个队列每次添加新消息后从队头开始丢弃直到总token数低于阈值。2.2 摘要压缩模式保留“记忆”丢掉“原文”滑动窗口的痛点是它只能记住最近的内容。如果用户在第十轮问“我第一轮说的那个需求还记得吗”模型大概率一脸茫然。摘要压缩模式就是为了解决这个问题。基本思路是当历史消息超过某个阈值时把较早的内容交给模型生成一段摘要之后这段摘要替代原文参与后续对话。听起来不复杂但实际做起来有几个关键决策点第一个决策点摘要触发阈值怎么设。太早触发摘要会丢失大量细节太晚触发token已经快爆了。我的经验是设两个阈值软阈值和硬阈值。比如系统提示词加当前对话总token数为history_tokens当history_tokens soft_threshold时开始准备压缩最旧的那一批消息当history_tokens hard_threshold时强制执行压缩不再等待当前轮结束。第二个决策点压缩粒度。不要一次把所有历史消息全压成一段摘要。我习惯保留最近几轮原文只压缩更早的部分。举个例子当前总历史token超过阈值时保留最近5轮原文把5轮之前的消息合并发送给模型生成摘要。这样既保留了近期对话的完整性又把长度控制住了。第三个决策点摘要内容的质量控制。原始做法可能就发一句“请总结以上对话”但这样做出来的摘要经常丢失关键信息。我在实践中总结出一个强调结构完整性的提示词模板它要求摘要按“用户核心诉求、已完成事项、待办事项、关键约定”四类信息组织。这样模型产出的摘要结构稳定且后续检索关键信息时命中率高很多。summary_prompt 请将以下对话内容总结为结构化摘要要求保留以下信息 1. 用户的核心目标与需求 2. 双方已确认的事实与状态 3. 待办事项或遗留问题 4. 用户明确提出过的偏好或约束 对话内容 {past_messages} 请用简洁的中文输出摘要不要遗漏上述四类信息。 这个设计思路其实借鉴了客服系统里的工单摘要逻辑只是把它套在了LLM的token体系上。好处是后续模型读到摘要时能够快速定位“这个用户到底要什么”而不是面对一大段流水账。2.3 分层混合模式把上面两套结合起来只做摘要压缩也会有问题——摘要毕竟是损压缩细节肯定丢。比如用户中途提到“帮我用Python写个爬虫”后面又改口说“算了用Go吧”最后这个问题被压缩进摘要后很可能只留下“撰写爬虫”而丢掉语言偏好。分层混合模式就是针对这个问题的解法。它的设计思想是把上下文按重要程度分成不同“层”不同层采用不同的处理策略。我实现的层级结构如下层级内容范围处理策略预算占比核心层用户明确设定的诉求、偏好、指令永久保留不做压缩20%近期层最近N轮完整对话原文保留滑动窗口淘汰40%记忆层更早的对话提取出的摘要定期重新摘要迭代更新30%背景层用户画像、系统上下文、环境信息静态注入按需求装填10%核心层的维护方式比较特别。我维护了一个持久化的“用户意图槽”每当新消息进来用一次轻量分类判断它是否在修正或强调用户的长期目标。如果是就更新意图槽如果不是就不动它。这样即使对话进行了100轮模型依然能从核心层直接读到“用户需要一个Go语言编写的爬虫工具”不会因为中间闲聊而丢失。记忆层则是把摘要压缩的产物循环反复使用每次新对话时间跨度拉长后把旧摘要和新内容合并生成更长周期的摘要形成“多层时间折叠”。我在测试一个20轮的项目咨询对话时用单层摘要的数据总量约节省了68%的token而用双层折叠后同样的对话节省了81%且关键信息召回率从78%提升到了89%。这个数字差异很直观。2.4 为什么一定要做模式分层而不是一套走天下我在很多团队分享的时候有人问“是不是直接无脑上混合模式就完了”。这个问题问得很好但我的答案是混合模式不一定适合所有场景每个场景都有最优策略。如果你做的是商品导购机器人用户平均5轮内就能结束对话那滑动窗口模式就够了加摘要反而增加一次额外模型调用、多了延迟和费用。但如果你做的是企业知识库助手用户一天内反复提问、修改口径、追加细节那没有摘要和核心意图槽根本撑不住。另外还跟模型的能力有关。开源小模型的指令遵循能力弱给太长太复杂的结构化提示词它反而会“懵”而大参数模型的上下文利用率和摘要质量都更好值得给它更丰富的结构。所以这套模块我在设计上允许按模型类型切换默认模式而不是捆绑死。这就是为什么我没有只做一个方案而是做成一个可插拔的策略集合。核心价值是让每次调用的上下文构成是“算”出来的而不是“堆”出来的。3. 实操过程与核心环节实现理论说得再多最终要落到代码上才算数。这章我会按真实项目里从零到一搭建context-mode模块的过程把核心代码和配置逻辑完整讲一遍。用到的技术栈其实非常轻Python 3.10、一个队列结构、一个字典缓存、再加一个对外的调用接口。整套代码量不大核心部分加起来不到400行很适合嵌入到已有项目里改造。3.1 模块整体架构消息进来之后发生了什么先看整体数据流这样后面看代码更容易对上号。外部请求进来之后经过一条固定的处理链路输入消息 - 上下文管理器 - 策略选择器 - 消息筛选/压缩 - 组装Prompt - 调用模型 - 结果及新消息写回上下文存储这里每一步都有明确职责。“上下文管理器”负责读写当前会话的状态“策略选择器”根据会话类型、消息数量、token总量判断这次该用哪种处理策略“消息筛选/压缩”则是具体执行窗口裁剪、摘要生成或者分层调用的地方。我用一个简单的配置类来承载所有预设参数from dataclasses import dataclass dataclass class ContextConfig: mode: str hybrid # window / summary / hybrid max_context_tokens: int 8000 max_reply_tokens: int 1024 reserved_system_tokens: int 512 recent_rounds_to_keep: int 5 # 混合模式中保留最近几轮原文 core_slot_max_tokens: int 512 summary_soft_threshold: int 4000 summary_hard_threshold: int 6000所有阈值和预算都放在一个dataclass里好处是调参不用改代码逻辑直接换配置即可。我建议把这份配置做成可热更新的也就是放到配置中心或者环境变量里这样线上调参不用触发重新部署。3.2 上下文存储结构会话状态怎么组织每个会话session在系统中的状态我设计成如下结构class SessionState: def __init__(self, session_id: str): self.session_id session_id self.messages [] # 完整消息链表按时间排列 self.core_slots {} # 核心意图槽键值对 self.summary # 当前最外层摘要 self.token_counts [] # 每条消息对应的token数平行数组 self.last_accessed time.time()这里比较关键的是token_counts和messages平行数组。每次追加消息时我都同时记录它消耗的token数这样后续做滑动窗口淘汰时不需要重新调用tokenizer计算历史消息的token直接查数组累加即可。这个优化在大流量场景下能省下大量inference时间。core_slots的更新我单独封装了一个方法。它的判断逻辑比较简单但实际效果非常好def update_core_slots(self, new_message: str): detect_prompt f 判断以下用户消息中是否包含对长期目标、偏好、硬性约束的明确表述。 如果有请提取为 JSON 字典如果没有返回空字典。 消息{new_message} # resp model_call(detect_prompt, max_tokens128) # 返回示例{语言偏好: Go, 交付时间: 本周五前} # 解析并更新 self.core_slots # 这里注意只做增量更新不要覆盖旧slot里未被新消息推翻的内容我实测下来这个轻量检测调用的单次token成本在100~200之间但它带来的收益是整个对话过程中模型不再“反复失忆”。用户可能在第3轮说了偏好Go语言到第15轮突然问“我前面说的偏好你还记得吧”因为core_slots始终存在这句话不会丢。3.3 三种模式的实现细节与选择逻辑策略选择器本质上是一个函数根据SessionState和当前上下文token数决定执行哪套处理。逻辑不复杂但边界条件要处理好。def choose_and_apply_strategy(state: SessionState, config: ContextConfig): current_tokens sum(state.token_counts) config.reserved_system_tokens mode config.mode if mode window: return apply_window_mode(state, config) if mode summary: if current_tokens config.summary_soft_threshold: return apply_summary_mode(state, config) return build_plain_context(state, config) # hybrid 模式 if ( current_tokens config.summary_hard_threshold or len(state.messages) 30 ): return apply_hybrid_mode(state, config) return build_plain_context(state, config)注意这里的边界判断只检查token总量和消息条数不检查具体内容。这是一种工程取舍——内容相关的判断成本太高会让请求路径上的延迟明显增加。为了让代码更稳我还加了一层兜底一旦current_tokens超过硬阈值不管当前模式是什么都强制降级为“只保留系统提示词最近2轮摘要”宁可丢细节也不能让模型请求失败。3.3.1 窗口模式的队列淘汰实现滑动窗口我推荐用deque实现因为两端进出效率高。淘汰逻辑如下from collections import deque class WindowContext: def __init__(self, max_history_tokens: int): self.max_tokens max_history_tokens self.messages deque() self.token_cost deque() self.total_tokens 0 def add_message(self, message: str, token_count: int): self.messages.append(message) self.token_cost.append(token_count) self.total_tokens token_count self._trim() def _trim(self): while self.total_tokens self.max_tokens: cost self.token_cost.popleft() self.messages.popleft() self.total_tokens - cost_trim里用popleft从最旧的消息开始淘汰直到总token重新低于阈值。这个实现理论上可以应对所有场景。唯一需要额外处理的是当单条消息本身就超过max_tokens时不要死循环。我在实际代码里加了一个判断如果最旧消息自身token数已经大于max_tokens直接截断这个消息本身而不是尝试把它弹出去再判断。3.3.2 摘要模式的触发与生成流程摘要模式与窗口模式最大的区别在于淘汰消息前先对旧消息做一次模型调用把其结果保存为摘要。这个调用时机很讲究我在实现中规定只在“软阈值以上、硬阈值以下”这个区间才做摘要生成。低于软阈值不打扰高于硬阈值来不及因为摘要调用本身也有一次模型输出时间容易把本次响应拖慢。摘要生成后旧消息从列表里移除摘要作为一条特殊消息插入到历史开头summary_message { role: system, content: f[历史对话摘要]\n{summary_text} }把摘要放在system角色里是一个细节。很多教程喜欢把摘要放成user消息但这样会影响模型对当前用户意图的权重判断——被压成摘要的内容仍然占据user通道模型可能误以为这是用户当前的新指令。放进system则明确告诉模型这是背景资料不是当前请求。3.3.3 混合模式的组装顺序混合模式组装时我需要保证最终发给模型的messages列表遵循稳定结构final_messages [] # 1. 系统提示词 final_messages.append({role: system, content: base_system_prompt}) # 2. 核心意图槽摘要 if state.core_slots: slot_text format_core_slots(state.core_slots) final_messages.append({role: system, content: slot_text}) # 3. 历史压缩摘要 if state.summary: final_messages.append({role: system, content: state.summary}) # 4. 最近N轮原文 for msg in recent_messages: final_messages.append(msg) # 5. 当前轮用户输入 final_messages.append({role: user, content: current_input})这个组装顺序是固定的不能乱。核心逻辑是越宏观、越背景的信息越靠前越具体、越临时的信息越靠后。模型在注意力机制上往往更关注序列后段的内容把当前用户输入放最后能最大程度让模型“看见”当前任务。而核心意图槽和摘要放在前面则是为了让模型在生成时始终带着这些长期约束。对照实际效果我跑过一个用户连续咨询30轮的场景。纯窗口模式第20轮之后模型已经忘记用户最初要求的“必须支持导出Excel”而混合模式下第30轮问“我还能导出吗”模型回复“您可以导出Excel文件”这个细节就是core_slots起的作用。3.4 关键参数的计算逻辑与配置建议所有模式都离不开三个关键参数token预算分配、软硬阈值、保留轮数。这节把计算逻辑完整展开方便你直接带入自己的项目。token预算分配以8K上下文模型为例子模型上下文窗口并不是都能用来放历史消息因为系统提示词和回复输出各占一份空间。我之前打过比方上下文窗口就像一张磁盘系统提示词是操作系统回复输出是当前正在写的文件历史对话是归档数据。三者必须共享磁盘空间你不能把所有空间都塞归档数据。推荐的分配比例系统提示词占8%~12%回复预留占12%~20%历史上下文占70%~80%。我一般用保守值8K窗口给历史留5600~6400 token4K窗口给历史留2800~3200 token。软硬阈值这两个值只有当开启摘要或混合模式时才需要设置。软阈值我取历史预算的60%硬阈值取历史预算的90%。例如历史预算6400则软阈值3840硬阈值5760。低于软阈值时不做任何压缩介于两者之间时逐步把最旧的消息送去生成摘要高于硬阈值时强制截断并丢弃多余消息。保留轮数混合模式中“最近N轮原文”的N不能拍脑袋选。我基于对话测试发现5~8轮是比较甜点的区间少于3轮检索语义断档明显多于10轮token压力又太大。如果你很在意细节完整性可以把N调到6配合核心意图槽一起用效果已经足够稳。4. 常见问题与排查技巧实录落地过程中我踩过不少坑也帮几个团队排查过同类问题。挑几个出现频率最高、最具迷惑性的整理成速查表并逐条展开。现象可能原因排查方法解决方案模型回复越来越简短历史消息挤占回复空间查看实际发送的prompt token数调大max_reply_tokens压缩历史摘要生成后模型失忆摘要丢失关键约束对比摘要前后回答增加核心意图槽不依赖纯摘要偶发API报400错误总token超过模型上限记录每次调用token数增加硬阈值兜底丢弃最旧消息同样的对话费用暴涨系统提示词重复注入检查messages开头是否有重复系统内容去重用单一系统消息聚合多轮对话逻辑混乱历史与当前消息角色错位检查messages列表role分布按固定组装顺序强制规范摘要提示词出现模式崩溃摘要本身变大或内容重复查看摘要生成时的输入输出对摘要结果做长度截断并强制“覆盖式更新”展开讲三个最容易阴沟翻船的点。4.1 问题token数字和模型实际窗口对不上第一个高频问题是代码里max_history_tokens设得很好但线上还是偶发400。排查后发现问题是很多项目并没有认真统计系统提示词的真实token数。有人预估系统提示词300 token实际写了1500 token的长篇背景介绍。历史消息倒是被限制得很好可是总token依然轻易突破模型上限。我的排查方式很笨但很有效找一个出问题的session把组装好的messages全部dump下来逐个统计每条消息的token数加总对比模型上限。直接用tiktoken或transformers的tokenizer去数不要用len(text.split())糊弄。这一步做完问题原因通常一目了然。还有更隐蔽的情况某些平台的OpenAI兼容接口会在messages之外自动注入一批内容比如工具定义、few-shot示例。这些内容同样消耗窗口但代码里根本看不到。这种情况必须用平台的usage字段来计算真实消耗。我建议所有调用都记录usage.prompt_tokens用这个数字反向校准本地的预算分配。4.2 问题摘要模式导致模型“变得不会说话”摘要模式用了一阵子后模型的回答风格会变得越来越干瘪、越来越像概述缺少实例和情感色彩。这不是模型坏了而是摘要把对话的语气、情绪、细节全丢了。导致模型在生成时只有“事实骨架”可以参考。我的解决办法有两层。第一层摘要生成时不只要求内容结构还要求保留用户原始表述中的关键词和语气标记。比如用户在抱怨“这个功能实在太难用了”摘要要记录“用户情绪不满原文关键词难用”。第二层近期层原文的保留轮数不要压缩得太狠。模型在生成回复时需要从原文参照语体风格只给摘要的话它会用“书面语列表式”的腔调。后来我调整了提示词在摘要模板里加上一句“请保留用户原话中的关键短句便于后续回复沿用相同语气”效果立竿见影。用户在对话中感觉到的“人味”和“连续性”提升明显。4.3 问题核心意图槽被无关信息污染核心意图槽初始设计是“永久保留关键信息”但实际跑久了会发现问题里面塞满了用户随口一提、后面自己都不记得的临时需求。比如用户说“周五之前搞定最好”这句话也被当成核心约束存了进去结果一周之后模型还在反复强调周五截止而用户早就忘了这茬。后来我做了一个“槽位保质期”机制核心slots里记录每个槽的创建时间和最后确认时间超过7天或者被后续消息以矛盾方式替换时自动降级到普通记忆层不再占核心预算。同时更新core_slots的判断逻辑也改得更严格只有消息里出现“要求”“必须”“禁止”“偏好”“以后都”这类强约束词才考虑更新普通的建议和闲聊不进入核心槽。调整之后核心意图槽的准确率从78%提升到92%误召回率明显下降。这个细节很值得做尤其是你面对的是长期用户而不是一次性问答场景时。5. 实测效果与性能数据写代码的最终目的是看到数字变化。这章放一组内部测试数据数据来源是一个模拟企业知识库客服场景的测试集包含50组对话每组时长在15~35轮之间。模式平均每轮token消耗关键信息召回率单轮P95延迟模型调用次数综合成本无上下文管理全量历史100%基准87%920ms1次/轮100%基准滑动窗口模式41%71%860ms1次/轮42%摘要压缩模式33%82%1050ms1.2次/轮46%分层混合模式29%93%920ms1.15次/轮38%这里最值得关注的是分层混合模式在成本最低的同时召回率反而最高。关键信息召回率衡量的标准是对话里明确出现过的用户约束、偏好、数据要求模型在最终轮是否还能正确引用。全量历史虽然把所有信息都喂进去了token足够多但注意力分散到大量无关内容之后有效信息反而被“淹没”了。这跟人一样读书笔记做得好的学生考试不一定输给通读全本的学生。延迟方面摘要压缩模式引入了额外调用所以P95比全量历史要高。混合模式因为摘要只在硬阈值触发时才做多数轮次仍然是单次调用所以P95没有明显恶化。如果你的业务对延迟极度敏感比如在线客服可以把摘要生成放到异步任务里绝不让它阻塞主链路。需要说明的是上面这组数据是同一模型、同一测试集下的相对值只用于理解模式间的差异不代表绝对值。不同模型、不同领域效果会有波动但整体趋势我认为是可复现的。6. 一点实战收尾给大家的建议做context-mode这段时间我最大的体会是上下文管理不是一个“锦上添花”的优化点而是LLM应用能不能长期稳定使用的命门。很多产品demo阶段效果惊艳一上线跑几天就烂掉根本原因往往不是模型选得不好而是上下文管理太粗暴——要么全量堆、要么粗暴截断把模型的可用上下文搞得一团糟。如果只让我给三条最值得落地的建议第一至少把“固定顺序组装messages”这件事做起来。系统提示词、长期摘要、核心约束、近期原文、当前输入顺序稳定且角色分明。这一个改动本身就能带来明显的效果提升逻辑上也更接近模型训练的预期模式。第二把“token预算”当成一等公民来管理。给系统提示词、回复输出、历史上下文都设好预算并定期用usage字段校准。很多失控问题在预算被设定之后压根不会发生。第三不要迷信任何一种单一策略。窗口模式省心但失忆摘要模式省token但费一次调用。分层混合模式是我目前认为综合最优的方案——但请一定基于自己的场景调参而不是直接照抄这里的数字。context-mode这套模块在持续迭代中后续我计划补充多模态内容的上下文处理以及针对超长文档的轻量化检索式上下文注入。但在那之前把基础的分层上下文策略打牢我觉得是每个LLM应用开发者都值得投入的一步。希望这篇内容能帮你的项目少踩几个坑。
返回列表