ARTICLE DETAIL

资讯详情

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

大模型上下文管理优化实战:context-mode如何降Token提准确率

大模型上下文管理优化实战:context-mode如何降Token提准确率 做智能助手项目的时候我被上下文管理折磨得不轻。多轮对话一长Token 消耗翻倍响应变慢模型还经常“失忆”用户明确说过好几遍的需求转头就忘。后来我花了两周重写了一套上下文管理模块内部代号就叫context-mode效果立竿见影单会话 Token 消耗平均下降 60%用户意图理解的准确率反而提升。这篇文章把我踩过的坑、重写后的设计思路和核心代码全部分享出来项目背景是知识库问答类应用但思路通用于所有基于大语言模型的多轮交互场景搞 Agent 开发和聊天机器人的人都用得上。1. 先搞清楚 context-mode 到底在解决什么问题大模型应用的上下文管理本质上是在做一件很反人性的事在“尽量多给信息”和“尽量少花钱”之间找平衡。早期我把所有历史消息、检索回来的文档片段、工具调用结果一股脑全塞进 API结果就是上下文窗口像滚雪球一样越来越大。具体来说不管理上下文会出现三个连锁反应。第一是费用失控对话超过十轮后光历史消息就要占掉上千 Token每一次新请求都在为以前的废话买单。第二是响应质量下降模型接收的噪音太多注意力被无关内容稀释经常答非所问甚至把早先的错误信息当成依据。第三是延迟变长大量冗余 Token 意味着更大的计算用户明显感觉聊天变“钝”了。我意识到上下文不能当作一个无限大的口袋而应该是一块有限的资源分区。context-mode 的核心思路就是给上下文设定预算、划分区域、按策略压缩让每一段进入模型的内容都“有用”。这个模式主要解决三类场景。一是长会话用户连续问二十个问题历史信息无法全部保留二是知识库问答检索结果和用户问题需要精确匹配不能让历史话题喧宾夺主三是 Agent 工具调度模型需要记录工具返回值但返回值可能非常长。这三类场景有一个共同痛点上下文里真正重要的往往只有几个关键信息大部分都是可有可无的垃圾。在设计 context-mode 前我先定义了两个核心指标。指标一是“有效信息密度”指每个 Token 携带的、对当前决策有用的信息量指标二是“上下文命中率”指模型回答时正确引用了被保留信息的比例。这两个指标在后续的压缩策略和评测中都是抓手。2. 核心机制上下文的分区、预算与优先级2.1 为什么不能用一个整体列表装所有消息OpenAI、Claude 这类模型的 API 虽然接受一个 messages 数组看起来每个消息地位平等但实际效果差异巨大。模型通常对最新的用户输入和系统提示最敏感中间堆积的若干轮历史容易被弱化。如果把这些历史统统塞进去还不如不塞。真实场景里上下文内容其实是分层的。我测试过大量样例后发现一条理想的 Prompt 上下文应该具备四个层次身份与目标系统角色、关键记忆用户偏好、明确的需求目标、短期历史最近一两轮的问答、即时数据检索片段、工具返回、当前提问。相应地我也把 context-mode 设计成这些分层结构的组合。2.2 预算分配策略先定上限再谈压缩预算分配是整套模式的骨架。我给每次请求设计了一个“上下文总预算”默认按模型的上下文窗口动态取 70% 作为安全余量。比如模型支持 8K 上下文我实际使用的预算就是 5.6K留下了充足的生成空间。预算需要细分到每个分区。我使用一张配置表来管理不同业务场景可以在同一套框架下调整比例分区名称默认比例说明系统指令与角色设定10%固定 Prompt通常常驻不压缩关键记忆区20%用户偏好、需求目标、重要约定短期历史窗口30%最近少数几轮对话即时数据区40%检索文档、工具返回、用户当前输入这个比例不是我拍脑袋定的而是经过对比测试得出的经验值。我发现即时数据占比过低时知识库问答容易“幻觉”会凭空编造不存在的文档内容即时数据占比过高时模型又会迷失在细节里忘记用户最初关心的是什么。2.3 数据结构的组织方式带标签的上下文单元在 context-mode 里我决定不再用纯文本字符串拼接上下文而是引入一个轻量级封装层——上下文单元。每个单元是一个字典包含内容、类型、来源、时间戳例如{ content: 用户偏好使用简洁回复不超过100字, type: memory, source: history, ts: 1710000000, priority: 2 }类型分为system、memory、history、retrieval、tool_result。这个设计让后面的压缩、排序、清洗都有了依据我可以按类型筛选哪些先删哪些必须保留也可以按时间戳判断哪条记忆已经过期。没有这套结构之前我只能对整段文本做暴力截断效果非常差。3. 实操实现从零搭建一个可用的 ContextManager3.1 基础骨架每次对话先过 Token 计算器内部实现的起点是一个ContextManager类。它维护一个上下文单元列表每次新消息进来先做 Token 估算如果估算结果超过预算就触发压缩。估算我直接使用tiktoken来做因为 OpenAI 系的模型 Token 计算用同一个库最准示例代码如下import tiktoken class ContextManager: def __init__(self, budget_chars: int 8000, model: str gpt-3.5-turbo): self.budget budget_chars self.enc tiktoken.encoding_for_model(model) self.units [] def _token_count(self, text: str) - int: return len(self.enc.encode(text)) def _total_tokens(self) - int: return sum(self._token_count(u[content]) for u in self.units) def add_unit(self, content: str, unit_type: str, source: str , priority: int 1): self.units.append({ content: content, type: unit_type, source: source, ts: int(time.time()), priority: priority })这段代码里有一个容易忽略的坑预算单位必须和你实际调用的模型一致。不同模型的 Tokenizer 对中文、代码、Markdown 的分词方式差异很大。我最初用字符数做预算单位结果中文文本被严重低估信息量和 Token 消耗完全不成正比。3.2 组装上下文的固定顺序组装环节看似简单其实大有讲究。同一个内容放在不同位置模型表现完全不同。我的固定组装顺序是系统指令常驻关键记忆经过压缩和去重短期历史最近几轮对话即时数据检索片段和工具返回当前轮次的用户消息这个顺序是符合注意力的自然分布的。模型对开头和结尾的内容更敏感开头放系统角色和长期目标可以稳定整体行为结尾放当前问题让模型优先处理当前任务中间的历史是辅助材料放在中间位置产生的干扰最小。组装函数的实现比较直接但有个细节建议留意给每个内容块加分隔标签。比如即时数据前加一句“以下是从知识库检索到的资料仅供参考”而不是让模型靠猜来推断内容来源。这一步看似多余但对减少幻觉很有效。def build_context(self, current_query: str, retrieval_texts: list[str]) - str: sections [] for u in self.units: if u[type] system: sections.append(f【系统指令】\n{u[content]}) elif u[type] memory: sections.append(f【关键记忆】\n{u[content]}) elif u[type] history: sections.append(f【历史对话】\n{u[content]}) if retrieval_texts: joined \n---\n.join(retrieval_texts) sections.append(f【知识库检索结果】\n{joined}) sections.append(f【当前用户提问】\n{current_query}) return \n\n.join(sections)3.3 触发压缩的判断逻辑压缩最怕频繁触发每次压缩都要重新调用模型做摘要本身也是成本。我做的策略是设置一个压缩水位线和恢复水位线。Token 总量超过预算的 90% 才触发压缩压缩完成后目标降到预算的 60% 以下。这个缓冲空间保证了不会出现“刚压缩完又超限”的抖动。def maybe_compress(self): total self._total_tokens() if total self.budget * 0.9: target int(self.budget * 0.6) self._compress_to_target(target)触发条件和压缩目标说明预算上限是 8000 Token那么 7200 Token 时开始动手压缩压缩到 4800 Token 以下才停止。这样每次压缩后都能预留充足的余量给新对话和检索内容。4. 压缩策略与关键记忆提取的完整方案4.1 冗余淘汰按照优先级和时间双重排序压缩的第一层策略是淘汰不是摘要。很多上下文内容其实是可以直接丢弃的。比如工具调用返回的完整 JSON、某个中间环节的错误信息、已经完全过时的检索片段这些内容删掉丝毫不影响对话质量。淘汰规则上我实现了一个排序函数。综合优先级从高到低系统指令、关键记忆、近期历史、检索结果和时间戳越旧越优先删除来排序然后从尾部开始逐个移除单元直到达到目标 Token 数。这一段逻辑是整个 context-mode 中最简单的也是短期内收效最大的。4.2 摘要压缩保住谈话的主线淘汰解决不了所有问题。用户明确讲过一段很长的需求说明这属于重要记忆不能删但原文可能有 2000 Token长期占着预算也不现实。这种情况下我用一次独立的摘要请求把它压缩成 200 Token 的浓缩版本。摘要请求本身也保存在ContextManager里作为一条类型为memory的新单元并标记为compressed。这样设计的好处是如果后续对话发现记忆细节不足还可以通过原始 ID 去检索完整历史但正常情况不需要把全文留在上下文中。我的摘要 Prompt 会明确要求“保留具体数字、名称和明确约束删除情绪化和寒暄内容”。不做这个约束时模型容易给出“用户说了一些要求”这种废话式摘要完全丢失关键信息。4.3 关键记忆提取一份要长期保存的结构化档案这个功能是 context-mode 的精髓。我做了一个独立模块MemoryExtractor在每轮对话结束后异步运行把用户的重要陈述、需求变更、明确偏好提取出来。示例代码def extract_memories(new_message: str, existing_memories: list[str]) - list[str]: prompt f 根据用户新发言和已有记忆提取需要长期保留的更新信息。 已有记忆{existing_memories} 用户新发言{new_message} 要求只输出需要新增或更新的记忆点每条一行。聚焦偏好、目标、约束。若没有新记忆输出“无”。 response call_llm(prompt) return parse_lines(response)实测下来这个模块会记住一些非常有价值的信息比如“用户每次只想要 3 个方案”、“她明确说过不推荐收费工具”、“上轮说过数据部门下周要交付提到过截止日期是周五”。这些信息如果不在上下文里后续回答很容易跟用户的人设脱节体验下降非常严重。关键记忆也孵化了去重逻辑。当新提取的记忆和已有记忆相似度过高时采用“新替旧”的策略。比如用户先说“我偏好简洁回复”后又说“还是详细一点吧”这是需求变更必须以最新表态为准。这个场景很容易在实战中踩坑机械地保留所有历史偏好会让模型无所适从。5. 踩坑实录我掉进去的四类上下文陷阱5.1 压缩后关键数字丢失第一次测试压缩功能时我遇到了一个典型问题对话中用户提到“预算是 5000 元”压缩后的摘要却变成了“用户对预算有限制”。数字彻底消失了。这直接导致后续模型给出的落地方案全部超预算。修复方式有两个层面。第一摘要 Prompt 强制要求保留数字第二更重要对包含金额、日期、数量等结构化信息的内容在压缩前额外调用一次规则提取把它们存成独立的memory单元。规则提取用正则就能搞定不依赖模型稳定可靠。5.2 模型被旧话题带偏还有一个让人头疼的情况用户先聊了半个月度报告的事突然问了一句“这个数据能不能用”模型的表现却是继续沿着月度报告的话题展开分析。原因是历史上下文的篇幅太大压制了当前提问的权重。调整顺序后问题消失。我把当前用户提问放在上下文的最后并且明确用“当前用户提问”标签隔离让模型意识到这是一个新任务。同时短期历史窗口只保留最近两轮更早的内容交给关键记忆区处理。5.3 检索结果太长挤爆上下文知识库问答里召回阶段经常一次返回 5 到 10 个文档片段每个片段可能有 500 到 1000 Token。合在一起直接突破预算。刚开始我天真地截断每个片段结果把最关键的结论部分切掉了模型回答质量雪崩。优化后我在召回阶段增加一个“重排”环节。先用一个轻量模型或规则打分只保留与当前问题最相关的前 3 段。然后对入上下文的内容做“定位提取”只截取包含问题关键词的句子及其前后两句话。长文本变成了“引言关键句尾注”的局部内容Token 占用大幅下降。5.4 Token 统计与实际请求不一致另一个隐蔽问题context-mode 内部统计的 Token 数和实际调用 LLM 接口时返回的prompt_tokens有不少出入。排查后发现是编码不一致。统计时我用的gpt-3.5-turbo编码器但实际接口调用走的是gpt-4两者的 Tokenizer 在部分特殊字符上处理不同。解决方案是让 Token 统计和实际调用使用同一个编码配置在代码里写一个常量来控制。如果接的是第三方模型或自部署模型就用模型提供的 Tokenizer或者按字符数做粗略换算并留出 1.2 倍安全系数。5.5 常见问题速查表症状原因解决方案回答遗漏用户明确说过的偏好偏好被压缩或淘汰关键记忆独立存储摘要前规则提取模型延续旧话题历史上下文过长且优先级过高限制短期历史调整上下文顺序检索内容挤爆预算召回片段过多过长增加重排只保留关键词附近内容Token 统计偏差大编码器不一致统计与调用统一编码增加安全系数每次压缩后回答波动摘要丢失细节摘要 Prompt 增加“保留数字和名称”要求同一轮对话反复触发压缩缺少恢复水位线压缩目标设为预算的 60%留出缓冲区6. 多 Agent 场景下的上下文传递经验6.1 独立上下文与共享总线的分离最近我把 context-mode 从单轮问答扩展到了多 Agent 协作。在这个场景下我发现了一个新问题Agent 的历史上下文中大部分内容其他 Agent 根本不关心。如果把每个 Agent 的完整上下文广播给所有人预算会被瞬间打爆。我的做法是独立的上下文缓冲区加一个精简的共享总线。每个 Agent 维护自己的ContextManager只保留与自己任务直接相关的记忆。当 Agent 之间需要协作时只向总线发布“目标卡片”和“结果摘要”而不是完整对话记录。比如数据分析 Agent 只需要知道“前端 Agent 需要本季度销售数据按地区分组”并不需要知道前端 Agent 是怎么和用户寒暄的。6.2 上下文交接时的一致性校验多 Agent 场景还有一个隐性问题A Agent 记忆里记录的理解和 B Agent 不一致。比如用户在 A Agent 里说过“数据口径按不含税算”B Agent 不知道这条规则产出的结果就错了。应对方式是每次 Agent 交接时把自己的关键记忆区发送给下一个 Agent作为“前置条件”。同时保留一个全局配置区放置所有 Agent 都认可的公共约束。实践下来把公共约束当作系统指令放在每个 Agent 上下文开头是最稳妥的。6.3 压缩策略的异步化多 Agent 场景中压缩调用的频次更高每次压缩都同步等待模型返回会造成严重的链路阻塞。我把压缩和记忆提取全部异步化新消息进入后立即返回后台任务完成后再更新上下文。用户无感知系统的并发能力也上来了。异步化需要注意一个细节不能让后台任务和当前的请求竞态写同一个列表。我用了 Python 的threading.Lock做简单互斥或者更稳健的方式是用 Redis 的分布式锁。并发场景下不做这个处理会出现记忆丢失或重复提取的问题。7. 一点完善经验context-mode 的评测方法写到这里想聊聊怎么验证这套模式有没有用。很多人上线上下文管理后只看“卡不死”就认为成功了但实际上合理的评测能让下一次优化有的放矢。我总结了一套轻量评测法准备一组覆盖典型场景的测试问题集大概二十到三十个问题包含长会话延续、偏好记忆、知识库引用、会话切换等题型。跑同一个任务三遍分别评估“全量上下文”“滑动窗口”“context-mode 完整模式”三种方案记录每种方案的回答正确率、Token 消耗、延迟时间。评测后我拿到了一个反直觉的结论全量上下文的正确率并不是最高的因为噪音干扰严重时模型经常引用到无关的历史内容。反而是压缩后的 context-mode 得分最高。这套评测方法虽然简单但是为后续迭代提供了方向。每次改动压缩策略或调整预算比例时我都会重新跑一遍测试集对比效果。没有评测数据支撑的上下文优化就是瞎调参数上线后出问题连回归在哪都不知道。最后分享一个小技巧在压缩日志里记录被删内容和原因。我每轮压缩都会把被淘汰的单元原样写进日志文件标注删除原因。这个习惯帮我发现了很多意料之外的删除逻辑缺陷比如某些高优历史被误删、某个关键记忆因为时间戳判断出错被清理。这两条日志比任何测试集都更真实也更值得长期维护。
返回列表