首页 / 资讯中心 / 文章详情

大模型上下文管理与Context-mode模式设计与实践

大模型上下文管理与Context-mode模式设计与实践 ★ FEATURED ARTICLE
我最近在做AI应用发现一个很有意思的现象很多项目demo时跑得飞起一上线就变蠢。排查到最后十有八九是卡在同一个地方——上下文没管好。给大模型的东西多了太贵太慢还容易“看不过来”给少了它又像金鱼一样只有七秒记忆。这段时间我把这个问题的解决方案单独抽出来做成了一个模块代号就叫“context-mode”也就是上下文模式。今天这篇就把它从设计到落地拆开讲一讲包括模式怎么划分、token预算怎么算、压缩策略怎么选、代码怎么写、以及线上最容易踩的坑是什么。如果你正在做带多轮对话的客服系统、AI编程助手、文档问答工具或者只是想搞清楚“为什么我的模型总是答非所问”这篇文章应该能给你一个很直接的参考。1. context-mode是什么先搞懂上下文为什么是瓶颈1.1 一次不该发生的AI“翻车”上个月我帮朋友调试一个智能客服用户反复问“我那个订单到底退了没有”AI却一会儿回答“您的订单已发货”一会儿回答“抱歉我不记得您问过这个问题”。后台日志一看问题特别狗血每个新请求都只把当前这一条用户消息发给了模型前面的对话历史根本没带过去。也就是说模型每次都在“失忆”状态下乱猜。这种翻车不是个例。你做大模型应用本质上做的是“和模型一起干活”而模型能依赖的只有你塞给它的那串文字。这串文字就是上下文context。你再怎么说“你要记住啊”它也不会自动记住任何东西。所以关键不是模型是否聪明而是你喂给它的上下文是不是组织得合理。context-mode这个概念就是我把“组织上下文”这件事做成的一套可配置、可切换的机制。1.2 上下文模式的本质给模型“分档记忆”打个比方。你去一家银行办业务前面一个客服接待你。他手头有三块信息源第一块是墙上贴着的业务规则必须一直遵守属于系统级指令第二块是你们俩刚才聊了什么当前会话历史第三块是你现在递过去的单据当下输入。这三类信息不可能全都一字不差背下来接待员得根据你的业务复杂程度决定自己“重点记什么、可以忽略什么”。context-mode就是这个“分档记忆”的工程化版本。我把它分成三个基础档位concise快速模式只保留系统指令和最近一两轮对话适合意图识别、简单问答、关键词提取。balanced平衡模式保留最近5到10轮对话老历史压缩成摘要适合客服咨询、AI写作辅助、多轮交互。full深度模式尽量完整保留所有历史和检索内容适合长文档问答、代码仓库分析、复杂推理。每个模式本质是一组参数和策略的组合token预算上限、保留几轮历史、用什么方式压缩老内容、是否触发自动切换。工程上做成模式而不是写死一套逻辑是为了让同一个后端服务能应对不同类型的请求。1.3 这套东西适合谁如果你是后端开发关心接口延迟和成本如果你是算法工程师关心召回效果和上下文污染如果你只是拿API做小工具关心“为什么我的AI像傻白甜”——本质上都需要处理上下文。context-mode不是一个开源软件也不是某个特定SDK它是一整套“怎么给大模型喂信息”的方法论。你可以在自己的服务里从零实现也可以用LangChain等框架配合实现核心思路完全一致。2. 设计思路从需求到“模式”拆分2.1 三个核心问题记住什么、忘掉什么、怎么切换我在设计context-mode之前先列了一张问题清单因为只有把需求问清楚才知道模式该怎么拆。这三个问题最重要第一记住什么模型窗口是有限资源。系统prompt是刚性的必须完整保留用户正在问的当前问题必须完整保留检索回来的相关资料如果和当前问题相关也要保留真正能被压缩、被删减的是中间的“旧历史”。第二忘掉什么要主动忘记的是那些又长又没用的客套话、已经完成的子任务、以及旧版本的信息。比如用户“哈哈”“好的”“然后呢”这种消息压缩时可以直接丢掉。这不是浪费而是为了避免上下文噪音干扰模型注意力。第三怎么切换很多服务固定用一个策略要么永远只留最近几轮要么不管三七二十一全塞进去。这样都不理想。理想情况是如果检测到用户说“刚才那段代码你再仔细看看”就应该自动把模式从balanced切到full如果只是问“你好”就保持在concise。切换可以手动触发也可以做成自动路由。2.2 模式划分的依据和参数模板我最初直接拍脑袋定义了五种模式后来发现维护成本太高收敛成三种。每一种模式的核心参数如下表所示模式token预算以8k窗口为例保留近期轮数历史压缩策略典型场景concise1k - 2k1 - 2轮不压缩直接丢掉意图识别、快捷问答、命令行交互balanced4k - 6k5 - 10轮旧历史做摘要 抽取关键信息客服多轮、写作辅助、日常助手full7k尽量全留只截断超越窗口的末尾部分长文档问答、代码审查、长上下文分析注意token预算不是越大越好。上下文越长模型处理每个token的注意力就越稀疏有时候塞了1万5千字的资料反而会把最关键的指令淹没。我倾向于默认用balanced只有当任务确实需要“看完整个项目”才切到full。2.3 为什么用“模式”而不是“全套塞进去”有人可能会说现在模型上下文窗口不是已经到128k甚至更大吗全塞进去不就行了真不行。第一是钱的问题全量发送的token费和算力都高得多第二是延迟问题请求体越大首token返回越慢我实测长上下文在低并发下也会多出两三倍耗时第三是效果问题模型对长文本的注意力容易下降尤其是中间部分被称为“迷失在中间层”。一个线上服务如果每个请求都全量塞成本会直接失控。把上下文拆成模式本质上是做“服务治理”。我们要在效果、成本、延迟之间取平衡。就像数据库查询不可能总用全表扫描一样给大模型的请求也不能总是“拉满”。3. 核心细节解析我是怎么把一个“context-mode”做出来的3.1 消息结构规范化角色标签是地基无论用哪种模式发给模型的messages都必须结构清晰。不要只存拼接好的字符串否则压缩、截断、统计token都会非常痛苦。我用的消息结构类似这样from dataclasses import dataclass from typing import Optional, List dataclass class ContextMessage: role: str # system / user / assistant / tool content: str timestamp: float # 用于按时间排序 priority: int # 越大越优先保留默认0 token_count: int # 缓存估算的token数 summary: Optional[str] None # 如果是压缩摘要条目存摘要这里有两个关键点role不能乱写。工具调用结果要标成tool而不是塞进user。很多模型API对角色名有要求乱标轻则报错重则导致模型无法区分别人说的话和自己说的话。priority这个字段是我最常用的。系统指令优先级是100工具执行结果是90当前用户消息是80历史对话默认是0。压缩时先删priority为0的旧内容再考虑低优先级。为什么要加timestamp因为多线程环境下消息到达顺序和后端处理顺序可能不一致。用时间戳排序能避免串话。3.2 token预算计算数字不能靠拍脑袋做context-mode第一件事就是限制总量。不同模型窗口不一样你不可能真等请求发出去了才知道超没超。我的做法是先设置一个“可用token预算”公式如下可用窗口 模型最大窗口 - 输出预留token - 安全buffer比如使用gpt-4o-mini128k窗口我设置输出预留8192安全buffer留2048那么可用窗口就是128k - 8k - 2k 118k。这118k就是所有输入内容的总预算。然后按优先级分配system内容固定占约2000 当前用户输入和检索片段占约6000 历史轮次预算 118k - 2000 - 6000 110k如果切换成balanced模式可以把“历史轮次预算”压到30k剩下的空间留给更多检索片段。实际开发中我会定义一个配置类class ModeConfig: def __init__(self, name, total_budget, history_budget, max_turns, reserve_output): self.name name self.total_budget total_budget self.history_budget history_budget # 历史最多占这么token self.max_turns max_turns # 最多保留多少轮 self.reserve_output reserve_output # 留给模型输出的token MODE_CONFIGS { concise: ModeConfig(concise, 4000, 1000, 2, 1024), balanced: ModeConfig(balanced, 8000, 4500, 10, 2048), full: ModeConfig(full, 12000, 9000, 100, 4096), }注意实际模型窗口是固定的这里的total_budget是为了“人工限制请求体”而设置防止把整个窗口填满导致输出空间不够。我在生产环境偶尔会收到“maximum context length exceeded”报错绝大多数就是没做这个预留。3.3 压缩策略滑动窗口 摘要 关键信息抽取当历史轮次超过max_turns或者总token超过history_budget时就进入压缩环节。我实现了三个层次的策略按激进程度递增第一层滑动窗口裁剪。只保留最近N轮之前的全部丢弃。这个策略最快但有明显缺点用户如果聊到第6轮才说“按照我第一次说的需求改”模型早就忘了第一次说的什么。所以不能纯滑动。第二层旧历史摘要。把超出窗口的部分交给模型或者用规则生成一段摘要。比如用户和AI在前20轮确定了“做一个带会员积分的电商小程序”后续30轮都在改颜色、调接口那摘要就写上“需求背景带会员积分的电商小程序当前进展已完成页面Demo未决事项支付接口和积分规则”。这样模型可以快速恢复“记忆”。第三层关键信息抽取。直接从历史里抽取结构化的“必保留项”。我通常会存三种用户明确提到的ID/数字/日期用户提出的约束“不要用蓝色”“必须本地部署”未完成的任务“下次继续改登录页”。这些项会被放到高优先级的“pinned”消息里即使摘要丢了它们也不能丢。压缩时按顺序执行先滑动窗口再关键信息抽取最后才摘要。不要一开始就全部丢给模型做摘要成本高且摘要也会“忘事”。我见过有人每轮对话都做一次摘要结果每次摘要都要消耗几千token很快预算又爆了。正确的做法是只在超出阈值时触发一次级的重写。3.4 动态切换容易忽略但非常有效的入口我做了个很简单的自动切换规则当用户消息里出现“重新回顾一下之前说的”“看看这个需求的第一版描述”“把整个项目分析一遍”这类长上下文意图词时直接把模式临时切换成full并把之前被压缩的原始消息从“历史存储”里捞出来全量放回。这就要求你不仅要把压缩后的summary存下来还得把原始消息存在Redis或数据库里不能真的删掉。另外我还在system prompt里写了一句“当前处于balanced模式如果问题比较复杂需要完整历史请直接调用contextModeSwitch()”。这个函数不是App里真实存在的但模型看到后会主动提醒我们切换模式。实测下来它比单纯的代码判断词法要灵敏得多因为模型能理解语义。你也可以在API请求里把模式名字作为环境字段放进去帮助模型“进入状态”。4. 实操过程核心代码实现与效果验证4.1 环境准备先说明一下这段实现不依赖LangChain直接走OpenAI SDK因为底层逻辑最好自己掌控。只需安装pip install openai tiktokentiktoken用于token计数。如果你用的是其他模型可能需要改用huggingface的tokenizer。注意不同模型的tokenizer不通用不要拿一个模型的估算值去卡另一个模型的预算。4.2 定义一个ContextManager类核心代码不长但每一步都有讲究。先写一个简洁版本import tiktoken import time from typing import List class ContextManager: def __init__(self, modelgpt-4o-mini, modebalanced, reserve_output2048): self.model model self.mode mode self.encoder tiktoken.encoding_for_model(model) self.messages: List[ContextMessage] [] self.reserve_output reserve_output def _count_tokens(self, text: str) - int: return len(self.encoder.encode(text)) def add_message(self, role: str, content: str, priority: int 0): msg ContextMessage( rolerole, contentcontent, timestamptime.time(), prioritypriority, token_countself._count_tokens(content), ) self.messages.append(msg) def set_mode(self, mode: str): self.mode mode def build_request_messages(self) - List[dict]: cfg MODE_CONFIGS[self.mode] # 按时间排序 self.messages.sort(keylambda x: x.timestamp) # 1. 系统消息和实时优先消息永远保留 system_msgs [m for m in self.messages if m.role system] pinned_msgs [m for m in self.messages if m.priority 80] history_msgs [m for m in self.messages if m.priority 80 and m.role ! system] history_msgs history_msgs[-cfg.max_turns:] # 滑动窗口 history_msgs self._apply_budget(cfg, history_msgs) final_msgs system_msgs pinned_msgs history_msgs # 最外层保证不超 final_msgs self._trim_to_fit(cfg, final_msgs) return [{role: m.role, content: m.content} for m in final_msgs] def _apply_budget(self, cfg, history_msgs): total sum(m.token_count for m in history_msgs) if total cfg.history_budget: return history_msgs selected [] used 0 # 从最近开始往回选保证后面的对话优先保留 for m in reversed(history_msgs): if used m.token_count cfg.history_budget: continue selected.append(m) used m.token_count if len(selected) cfg.max_turns: break selected.reverse() return selected def _trim_to_fit(self, cfg, msgs): total sum(m.token_count for m in msgs) max_input cfg.total_budget - cfg.reserve_output if total max_input: return msgs # 从最不重要的中间部分开始丢但保留system和pinned dropable [i for i, m in enumerate(msgs) if m.role not in (system, user) or m.priority 80] # 这里简化处理直接截断历史的低优先级部分 result [] used 0 for m in msgs: if used m.token_count max_input and m.priority 80: continue result.append(m) used m.token_count return result这段代码只是一个核心骨架。生产环境还需要加锁、日志、异步队列。尤其要注意self.messages.sort在多用户并发下会互相干扰所以每个session最好一个独立实例。4.3 对话过程的完整调用示例下面是一个模拟客服对话的调用过程cm ContextManager(modebalanced) # 系统指令 cm.add_message(system, 你是某电商平台的客服助手。当用户询问订单状态时你需要先确认订单号再查询物流。, priority100) # 用户问了问题 cm.add_message(user, 我的订单到了吗, priority90) cm.add_message(assistant, 请提供您的订单号。, priority0) cm.add_message(user, 订单号是20241011A帮我看看。, priority90) cm.add_message(assistant, 我查一下请稍等。, priority0) payload cm.build_request_messages() print(payload)输出会看到system在最前pinned高优先级的用户消息紧跟着低优先级的assistant内容最后。这样模型首先看到的是“当前用户的问题和关键信息”而不是被一堆“请稍等”“好的”淹没。如果你把模式切到concise同样这段对话低优先级的assistant“我查一下请稍等”很可能直接被过滤掉。这在敏感场景反而是好事因为不必要的客套话不会干扰模型判断。4.4 实测数据效果和成本的变化我在一个内部客服Demo上做了对比测试。原来无脑塞全部历史请求平均token数是21730首token延迟约1.8秒单次成本约0.03美元。切到balanced模式后请求token降到6580首token延迟降到0.7秒成本降了近70%。回答质量方面用同一批测试问题专家人工打分从78分提升到83分。为什么效果反而更好因为去掉了一堆无关的闲聊历史模型注意力更集中了。full模式我也测过请求token会回到17000以上但只有在处理“用户要求回溯整个项目变化”的请求时才会切过去平时不启用。这个模式不能成为默认否则前面省的都白搭。4.5 扩展到RAG和工具调用如果你做了RAG检索出来的文档片段怎么塞进context-mode我的原则是检索片段按优先级85塞进去介于系统指令和当前用户消息之间。这样做是因为系统指令决定了模型的整体行为当前用户消息是它必须回应的目标检索资料是回答目标所需的“参考资料”。这个顺序相当重要——如果你把参考资料放在最前面模型可能一上来就复述参考资料反而忽略了用户刚问的问题。工具调用结果也是一样。比如模型调用了查天气工具返回的结果要标记为tool角色优先级90。工具结果特别长时可以在存入时先用规则抽取关键字段再塞给模型不一定要存原始JSON。5. 常见问题与排查技巧实录5.1 上下文被截断核心信息仍然丢失有段时间我的服务总是“忘记”用户提供的订单号。明明手动查库消息里有啊。最后发现是我写的_apply_budget有Bug它从最近消息开始往前选但如果用户一开始就提到订单号而后面又聊了二十轮订单号早被滑出去了。解决方式就是关键信息抽取在用户消息入场时用正则把订单号、邮箱、地址等实体抽出来单独存成pinned消息。这样即使历史被裁剪关键实体依然会在每一轮请求里出现。排查技巧在build_request_messages里打一行日志输出每条消息的优先级、token数、是否被丢弃。写完当场就能看出是哪一步把信息丢了。5.2 摘要压缩之后模型开始“胡编乱造”我在一个文档问答项目里用户问“第一版本有哪些功能”模型回答时多写了一个“支持多人协作”而第一版根本没有这个功能。查下来是摘要压缩时模型把“后续计划”当成了“已有功能”写进了摘要。之后我给摘要环节加了一条硬性约束在摘要prompt里写“你只能整理已明确完成或确认过的事实任何未确认的计划都必须单独放到pending区域不得混入summary”。另外把原始历史存到外部数据库并保留每一轮压缩后的summary和原消息的对应关系。这样即使摘要错了还能溯源。5.3 多会话上下文串号这是最容易出的线上事故。我刚开始用了一个全局List存历史两个用户同时提问时互相能看到对方的消息。这个问题的根源不是context-mode本身而是会话隔离没做好。正确做法是给每个session一个独立的ContextManager实例存在类似session_id到CM的字典里并设置过期时间。我一般用Redis存序列化后的消息列表key带session前缀TTL设为2小时。上线前一定要做并发测试同时开5个会话各自发送随机数字然后检查A会话里有没有B的随机数。这种低级问题一旦发生用户信任直接崩塌。5.4 调试上下文最实用的三件套说实话调试context-mode比调试普通逻辑要难因为你看不到模型内部的“注意力热力图”。但有三样工具能大幅提升排查效率请求前日志把mode、总token、各段token、丢弃列表打出来。我一般只打summary不打印完整内容避免日志爆炸。Message导出重放提供一个debug接口把最终发给模型的完整messages按顺序dump到文件。如果你在用户报告问题后能立刻拿到这份文件80%的上下文问题都能定位。版本标记每个ContextManager实例启动时带一个版本号比如v1.0.3-balanced写入日志。防止线上跑的代码和本地不一致。另外还有一个独门技巧开启“自言自语测试”。在系统指令里加一句“在回答前先用一句话概括你收到的上下文主要内容”。模型会输出一个速览比如“当前用户问退款系统指令要求确认订单号历史中有订单号A123”。如果这句话和你预期不符立刻就知道上下文拼错了。5.5 别忽略输出预留最后想特别提醒一下token输出空间问题。我见过一个朋友的项目模型窗口设的是8k他把上下文塞到7900结果模型回一个完整答案需要1500 token直接报错“maximum context length exceeded”。所以不管你用什么模式都必须保留足够的输出token。实际中我至少留2048复杂推理留4096甚至更多。否则context-mode做得再花哨模型都没空间发挥一切白搭。6. 写在后面我对context-mode的实操体会这套东西我在自己项目里用了几个月最大的感受是不要追求“完美上下文”先跑起来再根据日志一点一点调。从我实测的结果看balanced模式是性价比最平衡的起点绝大多数对话场景都够用。等遇到长文档深度分析再切到full也不迟。想再分享一个小技巧在系统指令里加上“当前模式名”比你想的更有效。比如你写“你现在处于balanced模式请优先关注用户最近提出的需求并对较远的对话内容做简化处理”模型真的会按照这个模式去分配注意力。模式既是工程上的参数也应该变成模型行为的一部分。这个细节帮我解决了大量“模型过度关注老历史”的问题。后续我想把context-mode做成一个更通用的服务支持从Redis自动恢复会话、向量化历史摘要、多租户隔离。现在已经能把一次性请求延迟降到百毫秒级后面如果补齐持久化其实整个会话系统就可以完全托管了。希望这篇能帮你少走一些弯路。
阅读完成 · 觉得有帮助?
咨询建站