资讯动态

大模型上下文管理实战:从token成本到context-mode策略设计

发布时间:2026/10/6 13:34:33 来源:尧图企业网站定制
第一次意识到“上下文”这个东西必须单独治理是在做某个客服机器人的时候。上线第三天用户连续问了五个问题机器人把第三轮提到的订单号当成了最新订单参数直接张冠李戴。我翻出日志一看整个聊天历史被一字不改地塞进 prompttoken 逼近 3800结论是上下文越长模型反而越笨。当时团队里有人漫不经心说了句“要是有个 context-mode 就好了不同场景用不同的上下文策略。”这句话后来就成了这个项目的起点。context-mode 并不是一个现成的开源库而是我们在实际项目里沉淀出来的一套上下文管理方案。简单说它把“怎么选历史、怎么压缩历史、怎么把历史组合进 prompt”这个流程独立成一个可切换的模式层。同一个会话同一份历史在不同模式下会生成不同结构的上下文从而适配客服、代码助手、RAG 问答等不同任务。这篇文章把设计思路、核心代码、接入方式和踩过的坑都摊开讲一遍适合正在做 LLM 应用、尤其是被长对话和 token 成本折腾过的开发者参考。1. 项目初衷为什么需要 context-mode1.1 大模型应用里“上下文”到底难在哪先做一个比较直白的类比。你和一个记性很差的人聊天他手里有一本越来越厚的小本子里面记着你们从第一句话到现在的所有内容。你问“那个东西怎么样了”他需要先翻完整本笔记才能回答。如果笔记里有大量无关内容他的判断速度会变慢甚至把早期的信息当成最新的。大模型的聊天接口本质上就是一个这样的小本子你每次调用 API都要把历史消息作为参数传进去模型自己是不会“记住”任何东西的。真正的难点有三个。第一个是 token 成本历史越长每次请求的输入 token 越多费用近似线性上涨第二个是注意力衰减大量研究表明模型对上下文窗口中间部分的内容关注度明显低于开头和结尾历史一长早期关键信息会被“冲淡”第三个是信息冲突当聊天历史里出现互相矛盾的细节比如用户先说要黑色后来换成白色如果不加处理地全量传入模型很可能选中错误的那条。这些问题的根源不是模型能力而是我们把“上下文”当成了一个普通列表没有给它设计生命周期。context-mode 要解决的就是给这个列表加上“取哪些、丢哪些、怎么压缩、按什么顺序放”的规则。1.2 context-mode 是什么不是什么context-mode 的定义可以收敛成一句话它是夹在业务逻辑和大模型 API 之间的一个上下文策略层。每个模式定义了三件事从原始历史中挑选哪些消息对被选中的消息做多大程度的压缩变换以及最终在 prompt 中的排列顺序。它不是对话记忆框架不负责把历史持久化到 Redis 或数据库它不是向量数据库不做语义检索它也不是 prompt 模板不关心业务话术怎么组织。它只做一件事输入一堆原始消息输出一组结构化的 messages让模型在有限上下文窗口里看到最该看到的内容。这个定位非常重要。很多团队一开始把上下文管理和 prompt 工程混在一起最后代码越写越乱。prompt 模板是“模型该怎么说话”context-mode 是“模型该看到哪些话”。两者分开以后业务同学改话术不会影响上下文逻辑算法同学调模式也不会把客服话术改坏。1.3 它适用的场景和不建议用的场景适用场景有一个共性同一个用户或同一个任务会有一长串连续交互且不同阶段需要的历史信息粒度不同。我实际实践下来最匹配的是客服机器人它天然有订单、售后、售前等状态不同状态需要不同深度的历史其次是 AI 编程助手当前对话可能涉及多个文件但真正和本次问题相关的只有一两个然后是 RAG 问答系统用户多轮追问时前几轮问过什么会直接影响检索策略。不建议用的场景也有三类第一是纯单轮问答用户每次都把问题说完整没有历史依赖加这个模式纯粹浪费一层调用第二是低延迟实时流式场景为了省 token 做压缩反而增加延迟得不偿失第三是模型上下文窗口极大而预算极其充足的场景比如内部工具可以接受 128k token 输入那就不需要刻意折腾。2. 核心设计上下文模式的四层结构2.1 模式配置层把上下文变成可声明配置上下文策略最大的问题是很容易写死在业务代码里。客服代码里保留最近 20 条代码助手代码里保留当前文件内容RAG 代码里保留检索片段。一旦策略需要调整要翻到对应业务代码里去改风险极高。所以 context-mode 的第一层设计是配置化。每个模式被抽象成一个 dataclass字段包括模式名称、最大 token 预算、最大历史消息数、历史选择函数和压缩函数。业务代码只依赖模式名不关心内部实现。from dataclasses import dataclass from typing import Callable, List, Dict, Optional dataclass class ContextMode: name: str max_tokens: int 2000 max_history_messages: int 20 picker: Optional[Callable[[List[Dict]], List[Dict]]] None compressor: Optional[Callable[[List[Dict]], List[Dict]]] None protect_keys: tuple ()这种声明式结构的价值在于一个模式就是一条可读的配置。产品经理说“投诉场景要多看两天前的内容”我们就改 picker 函数而不是去客服业务代码里翻 if-else。模式配置层还方便做灰度同一个模式可以跑 A/B比较不同历史策略对最终回答质量的影响。2.2 记忆存储层短期记忆与长期记忆的分工context-mode 本身不负责存储但需要一个规范的数据流约定。我习惯把原始历史拆成两部分短期记忆和长期记忆。短期记忆是最近的原始消息保存在内存里的环形队列或者 Redis 的有序列表容量一般限制在 50 到 200 条。它保留的是最完整、最准确的近期信息比如用户刚刚报出的地址、刚确认的退款金额。长期记忆则是更早的消息经过摘要后的结果它只保留骨架不保留细节。两者的分工是短期记忆负责“准确”长期记忆负责“背景”。在设计上长期记忆不应该直接参与每一次 prompt 构建而是当模式需要宽泛背景时才被调入。比如客服机器人刚接通时只需要看一眼长期摘要来判断用户是新人还是老客户到了具体处理退款的环节就必须回到短期记忆里去找原始订单号。2.3 调度与构建层按需组合 prompt模式配置层定义规则记忆存储层提供原料第三层负责把这些原料组合成最终 messages。这一层是 context-mode 的心脏核心就是一个 build_context 函数。我先定义最基本的调用方式class ContextManager: def __init__(self, session_id: str): self.session_id session_id self.short_term: List[Dict] [] self.long_term_summary: str self.mode: str full def append(self, message: Dict) - None: self.short_term.append(message) if len(self.short_term) 200: self.short_term self.short_term[-50:] def build_context(self, query: Dict) - List[Dict]: mode MODE_REGISTRY.get(self.mode) if mode is None: raise ValueError(funknown mode: {self.mode}) picked mode.picker(self.short_term) if mode.picker else self.short_term compressed mode.compressor(picked) if mode.compressor else picked messages [{role: system, content: SYSTEM_PROMPT}] if self.long_term_summary: messages.append({role: system, content: 历史摘要 self.long_term_summary}) messages.extend(compressed) messages.append(query) return messages可以看到 build_context 做了三件事先根据模式从短期记忆里挑选消息再对消息做压缩最后把长期摘要、系统提示词、挑选后的历史和当前 query 拼成完整消息序列。顺序本身也是设计的一部分系统提示词必须放在最前面历史摘要作为第二条 system 消息这样模型在正式进入对话前就已经获得了背景信息。2.4 衰减与保护层防止上下文污染有了挑选规则还不够还要防止“毒上下文”混进来。我在实践里遇到过三种污染第一种是用户历史中说过的临时性信息被长期保留比如“今天先不买了”变成了摘要里的一句话第二天系统还认为用户不打算购买第二种是历史中混入了 prompt 注入内容用户可能在聊天记录里写“忽略之前所有指令”如果不加防护这行字会跟着历史进入模型第三种是个人敏感信息被反复传递增加了合规风险。为此context-mode 增加了两个机制。一个是衰减机制每条历史消息带一个时间权重超过一定时效后自动降级为摘要甚至丢弃。另一个是保护名单机制在构建上下文之前扫描消息内容把 order_id、phone、address 等关键字段单独抽取到结构化变量中正文里的原始值则被打码。这样模型需要的是“订单号是多少”这个问题而不是把订单号本身重复传给它。3. 实操落地用 Python 实现一个轻量 context-mode3.1 项目结构与核心依赖我不会直接上框架而是从零搭一个可以跑通的最小实现。这个实现完全够用于中小型项目也可以作为扩展成微服务的基础。项目结构保持极简context_mode/ ├── __init__.py ├── modes.py # 模式定义与注册 ├── manager.py # ContextManager 核心 ├── compress.py # 压缩策略 └── token_budget.py # token 预算计算依赖方面只需要两个额外包tiktoken 用来估算 token 数量openai 用来调用模型接口。如果后续要接国内模型或其他兼容 OpenAI 协议的接口openai 库本身也支持自定义 base_url改动很小。3.2 三种内置模式full、summary、focus我一开始设计了五个模式后来砍到三个理由后面会讲。三个内置模式分别对应三种最常见的上下文需求。full 模式适用于必须看到完整原始历史的任务比如客服处理投诉用户可能追溯十几天前的一句话模型需要完整消息才能判断责任。这个模式不做压缩只做数量截断默认保留最近 50 条完整消息。summary 模式适用于需要长背景但不需要细节的任务典型场景是周报助手。它把历史消息交给一个专门的摘要模型生成一段 200 字以内的背景描述然后只保留最近两轮原始对话。focus 模式适用于需要精确定位某个主题的任务典型场景是代码问答。它根据当前 query 中的关键词从历史消息里筛出包含相关文件路径、报错信息或函数名的消息其余全部丢弃。这个模式最关键的是 picker 函数def focus_picker(messages: List[Dict], keyword: str) - List[Dict]: keywords set(keyword.lower().split()) picked [] for msg in messages: content str(msg.get(content, )).lower() if any(k in content for k in keywords): picked.append(msg) return picked[-10:] or messages[-4:]注意最后一行如果一条相关消息都没筛出来就退回最近 4 条避免模型完全失去对话上下文。这个小逻辑是实测后加上的不加的话focus 模式在用户首次提问时经常误伤。3.3 接入 OpenAI 兼容接口的完整示例整个 context-mode 的接入方式非常直白核心就是把 build_context 的结果直接传给 chat.completions 接口。下面是一个完整的调用链from context_mode.manager import ContextManager cm ContextManager(session_iduser_12345) def ask(user_text: str) - str: cm.append({role: user, content: user_text}) messages cm.build_context({role: user, content: user_text}) response client.chat.completions.create( modelgpt-4o-mini, messagesmessages, temperature0.3, ) answer response.choices[0].message.content cm.append({role: assistant, content: answer}) return answer这里有一个容易忽略的细节query 参数同时被追加到短期记忆和 build_context 的返回结果里。这样做是因为短期记忆是给后续轮次用的而 query 本身是给当前轮次用的。如果你只在 append 里加入build_context 里就少了最新的用户消息如果只在 build_context 里加下一轮历史里就丢了当前问题。3.4 Token 预算的计算与自动降级token 预算是 context-mode 里最需要量化的一环。我自己用的规则是先估算当前模式选出的消息有多少 token再和预设阈值做比较超了就自动降级到更省 token 的模式。import tiktoken enc tiktoken.encoding_for_model(gpt-4o-mini) def count_tokens(messages: List[Dict]) - int: total 0 for msg in messages: total len(enc.encode(str(msg.get(content, )))) return total def auto_downgrade(messages: List[Dict], budget: int): num_tokens count_tokens(messages) if num_tokens budget: return messages if num_tokens budget * 1.5: return [m for m in messages if m[role] ! tool] return messages[-8:] [messages[0]] if messages else messages这个降级逻辑写得比较粗暴但思路是对的先去掉 tool 消息再截断中间的普通消息始终保留 system 提示词和最近几轮对话。实际项目里我还会把降级前和降级后的 token 数打到日志里方便观察哪些场景频繁触发降级后续针对性地优化 picker。4. 真实项目中的接入方式与效果4.1 客服机器人按会话状态切换模式客服机器人是 context-mode 收益最明显的场景。我们把用户会话划分成三个状态售前咨询、售后处理和投诉处理。售前咨询时使用 focus 模式只保留用户当前关心的商品信息和最近两轮对话售后处理时切到 full 模式完整保留从下单到当前的记录确保订单状态不丢投诉处理则用 summary 模式加保护名单先由摘要模型把长时间对话压成关键时间线同时把用户地址、手机号打码后再传给主模型。上线一个月后的主要收益是 token 消耗下降了约 32%而用户问题的首答满意率提升了 7 个百分点。token 下降好理解因为大部分售前对话根本不需要完整历史满意率提升的原因更微妙以前模型会从冗长历史里捡到一些过期信息比如用户两天前问过退款今天问新商品时模型还在想着退款现在模式切换把无关历史直接过滤掉模型的注意力集中多了。4.2 代码助手focus 模式精准注入相关文件另一个接入方是代码助手。很多 AI 编程工具一上来就把整个仓库的 README、目录结构、十几个文件全塞进上下文结果 token 爆炸回答质量也没上去。用 context-mode 之后我们把策略改成 focus 模式每一次用户提问先让一个轻量模型抽取问题相关的文件路径关键字然后去消息历史里检索只把这些文件的内容和最近一轮 diff 注入上下文。有一个很典型的例子用户问“order_service 里的方法为什么一直报空指针”全量模式会把整个微服务目录都带进去模型反而去参考其他无关文件focus 模式会精准定位到 order_service.py 和最近的调用链日志回答直接定位到倒数第二个方法里一个未判空的对象。这里的核心启发是代码上下文不是越多越好相关才重要。4.3 RAG 场景临时上下文与全局上下文分离RAG 系统是最容易被误认为“不需要上下文管理”的场景因为很多人以为检索到的片段已经是上下文了。但多轮 RAG 有一个隐藏问题用户后面的提问往往依赖前几轮的检索结果比如先问“这家公司的毛利率是多少”再问“那净利率呢”第二问没有主语必须结合第一轮的检索片段才能正确回答。我们用 context-mode 把 RAG 上下文拆成两层临时上下文保存最近两轮 query 和对应的检索片段用于解决指代问题全局上下文保存用户画像和长期偏好比如用户喜欢详细解释还是简短回答。build_context 时先拼接全局上下文再拼接临时上下文最后是当前轮检索片段。这样既不会把用户三个月前的问题全带进来也能正确处理“那下一个指标呢”这类省略式提问。5. 踩坑记录context-mode 最容易翻车的 6 个问题5.1 模式切换后用户信息丢失第一次在设计客服机器人时用户从售前咨询切到售后处理mode 从 focus 切成 full结果发现前面几轮用户已经说过的收货地址找不到了。原因是 focus 模式只保留当前商品相关内容历史里的地址早被过滤掉。切换时如果旧模式的上下文没有交接新模式的上下文里就是一片空白。解决方案是在 switch 方法里做快照。每次切换前把旧模式最近 10 条原始消息单独存到一个保留区新模式开始时先把这个保留区注入历史再执行自己的 picker。这样既保留必要的信息又不会让全套历史污染新模式。def switch(self, new_mode: str) - None: if self.mode new_mode: return keep self.short_term[-10:] self.long_term_summary summarize(keep [{role: system, content: self.long_term_summary}]) self.mode new_mode5.2 摘要上下文产生的“幻觉放大”summary 模式最大的坑是把事实性信息压缩掉了。有一次我们把一段很长的售后对话压成摘要里面“退款金额 128 元”变成了“退款成功”结果模型第二天在用户询问金额时一本正经地回答“已全额退款”。摘要模型天然倾向于概括数字、日期、商品规格这类精确信息最容易丢。之后的策略是摘要里只保留“发生了什么”的背景所有可结构化的关键信息单独抽出来存成 key-value 字段。构建上下文时摘要作为正文文本关键字段作为附加 system 消息。宁可摘要写得啰嗦一点也不能丢掉事实细节。5.3 并发复用导致上下文串号这是我在异步项目里踩过的大坑。ContextManager 挂在了一个全局单例上两个用户同时提问A 用户的订单号串到了 B 用户的上下文里。原因是 Python 的全局变量在 asyncio 场景下会被多个协程共享而 append 操作不是原子的。正确做法是让 ContextManager 以 session_id 为 key 存在字典里并且在每次 build_context 前用深拷贝或 immutable 快照确保当前请求的上下文不会被其他请求修改。更稳妥的方案是接入 contextvars把当前 session 的 manager 绑定到每个请求的 ContextVar 上。5.4 系统提示词的上下文位置我一开始把历史摘要放在了最后面认为它是“补充信息”结果模型经常忽略它。后来查了相关研究才意识到模型对 prompt 开头和结尾的内容关注度最高中间部分容易丢失。把系统提示词和长期摘要放在 messages 最前面把当前 query 放在最后面把历史放在中间是最稳妥的排列。如果消息里包含工具调用结果tool 消息必须紧跟在对应的 assistant 消息后面不能统一堆在末尾。context-mode 在做压缩时如果删掉了某个 assistant 消息对应的 tool 消息也要同步删除否则模型会看到一个没有来源的工具返回。5.5 内置函数调用上下文被压缩在 agent 场景里函数调用的上下文比普通聊天历史更重要。用户问题本身不重要重要的是模型上一次调用了哪个函数、传了什么参数、得到了什么结果。如果压缩策略把 tool 消息截断模型下一次就不知道该基于哪个结果继续执行操作。我的做法是给函数调用设置独立模式工具上下文永不压缩只做条数截断且最多保留最近 20 条 tool 消息。普通聊天消息则按 summary 模式压缩。这样 token 预算主要花在真正需要精确信息的工具链路上而不是闲聊历史里。5.6 过度设计模式太多等于没有模式我最初设计了七个模式包括 customer、engineer、analyst、debug、repo_scan、short_chat、long_chat。结果团队没人愿意用因为每个新需求都要纠结选哪个模式。后来砍到 full、summary、focus 三个反而所有人都能快速判断场景用哪个。适度的模式数量是 context-mode 能持续运行的前提。如果一个模式只比另一个模式多保留两条消息那就不要单独建模式直接调参数就够了。模式应该是任务形态的区分而不是参数组合的堆砌。最后再分享一个真实体会从第一次被客服机器人坑到现在我最深刻的感受是大模型应用的稳定性很多时候不取决于模型本身而取决于你喂给它的上下文是不是干净、精准、有序。context-mode 不是什么高深架构它只是一个倒逼你把“上下文”当回事的工程约束。我在新项目里都会先把上下文模式图画出来再动手写业务代码这个顺序让后面省了非常多改 bug 的时间。如果你也在和长对话较劲不妨先把项目里所有“把历史整段塞进去”的代码找出来逐个问一句这个场景到底需要看到哪些历史答案写下来你的第一个 context-mode 就诞生了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑