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

大模型应用开发:context-mode上下文模式设计与实现

大模型应用开发:context-mode上下文模式设计与实现 ★ FEATURED ARTICLE
身为搞过几年AI应用开发的工程师我越来越觉得做聊天类产品的人迟早都会撞上同一个让我头大的问题用户明明上一句刚交代过“我叫小李家里养了一只叫毛球的猫”下一句再问“毛球今天吃什么”模型愣是回一句“你提到的宠物毛球是指哪只猫”——这还不是最气的最气的是你知道不能怪它因为它真的什么都不记得。后来我把大家常说的“带上下文”认真做成了一个正经功能内部代号就叫context-mode。它解决了“AI失忆”这件事也顺手解决了对话一长就超限、费用飙升、回答前后矛盾这几个老坑。这篇就拿出我实际踩过坑、改过版、最后稳定跑起来的方案把里面的设计取舍、核心代码、参数怎么定、坑在哪一次性讲清楚。如果你也在做对话机器人、AI客服、智能助理或者在给大模型应用加“记忆”这篇完全可以直接抄作业。1. 为什么AI应用需要context-mode三个让我头痛的瞬间1.1 从“单次问答”到“连续对话”中间隔着一道上下文墙开发聊天类AI应用的人都知道绝大多数大模型接口都是“无状态”的。你每次调接口它就像个从来没见过你的陌生人你说什么它听什么说完就忘。早期我做的那个FAQ问答机器人就吃了这个亏用户问“你们的退货政策是什么”答完之后再问“那运费呢”模型根本不知道这个“那”指的是退货时的运费直接答非所问。这就逼着我必须自己管理上下文。所谓context-mode本质上是我们在模型外面包了一层“记忆处理”逻辑把用户说过的关键内容、之前几轮问答按照一定的规则抓取、裁剪、组织然后重新拼装成prompt喂给模型。模型依然无状态但我们的应用有状态了对话自然就连贯了。这层逻辑看起来简单真做起来全是细节上下文带少了模型还是“半失忆”带多了接口报超限全带上了又贵又慢还会让模型抓不住重点。所以设计context-mode的第一步不是急着写代码而是先想清楚它到底要解决哪几类问题。1.2 context-mode要解决的三类核心问题第一类是短时记忆。就是刚才说的模型要能记住本段对话里前面几轮的内容比如用户的名字、当前正在聊什么话题。这是最基础的要求。第二类是关键信息稳定留存。聊天过程中用户可能无意间透露重要偏好比如“我只用安卓手机”“我对价格不敏感但很看重售后”。这些内容哪怕过了几十轮也应该继续影响后续回答。如果只是简单地把最近几轮对话全部塞进上下文这些信息很可能被后来更长的内容挤出去。第三类是上下文可控。对话再长上下文窗口是有限的每一轮请求的token数也直接决定费用和延迟。我不能无限制地把所有历史都塞进去必须想清楚哪些该留、哪些该丢、爆了怎么处理。1.3 方案选型先别急着上向量数据库一提到“让AI记住”很多人第一反应就是“上RAG上向量数据库”。我承认这是个方向但说实话对大部分对话场景来说这是杀鸡用牛刀。向量检索适合的是“从海量文档里捞相关的几段知识”但对话上下文这种量级几百条几千条消息而已用简单的消息队列加摘要完全够用而且延迟更低、实现更快、还能省掉一堆维护成本。我在做context-mode初版的时候采用的是“滑动窗口 消息优先级 自动摘要”混合方案后面会详细讲。结论是先做好规则再考虑上模型。规则能解决的不要轻易上重型组件。2. 上下文模式的核心设计token预算、消息队列与摘要压缩2.1 先算出你的“白板”有多大每个模型都有一块固定大小的“上下文窗口”你可以把它想象成办公室里的白板写字内容越多能继续写的新内容就越少。把这个窗口换算成token数就是我们每轮请求能支付的总预算。比如我用的模型上下文窗口是8192个token也就是说每次发给模型的内容加上模型生成的内容加起来不能超过8192。那么我们的context-mode就要在这8192的额度里精打细算系统提示词System Prompt固定占一块比如500 token。对话历史History我们要分配的额度。当前用户输入User Input这轮用户刚说的话。模型输出预留比如预留2048 token给答案。这么一算对话历史最多只能分到 8192 - 500 - 当前输入 - 2048。用户输入可能每次不一样所以对话历史的预算就得动态调整。实际代码里我是按比例算的历史预算 总窗口 - 系统提示词 - 当前输入 - 输出预留 - 安全余量。这个安全余量是我自创的因为接口偶尔会多算几个token留出200左右的富余能避免边缘case直接报错。2.2 消息队列谁能留在上下文里确定了预算之后下一步就是决定哪些消息能进“白板”。我采用的是一个带优先级的消息队列。规则很简单永远保留系统提示词、用户主动置顶的关键信息比如用户说“请一直记得我叫小李”。默认保留最近N轮对话。这是保证短期连贯性的基础。有条件保留包含指定关键词的消息比如用户提到“退换货”“价格”或者模型判定为高价值的信息。优先淘汰寒暄内容、重复内容、闲聊内容。实现的时候不用搞复杂的数据结构一个普通的双端队列就够了。每轮对话结束后把最新的用户消息和AI回复推入队列然后从队首也就是最老的消息开始算token数一旦超出预算就从旧到新依次弹出直到整体token数回到预算线以内。这个做法很简单但对于大多数短中长度的对话来说效果已经很好了。2.3 摘要压缩窗口装不下时就“记笔记”如果对话特别长光靠滑动窗口淘汰历史会导致一个很棘手的问题很早之前的关键信息被挤出去了。比如用户在第3轮说“我预算控制在3000到4000元”结果聊到第80轮的时候这条信息早就被弹出队列了模型就开始给你推荐8000元的东西。解决办法是“记笔记”。也就是在上下文里放一个专门存储会话摘要的区域。当消息队列淘汰掉一些较早的对话时被淘汰的内容不是直接丢弃而是先提炼成摘要存入这个区域。这样即使具体到某句话已经被挤出了窗口它的核心含义依然留在上下文里。这里又有一个决策点摘要怎么生成我试过两种方案。第一种是本地规则很简单就是从历史消息里提取包含“我叫”“我需要”“我喜欢”“我住在”“价格”“预算”等关键词的句子直接拼接成结构化摘要。优点是零成本、秒出缺点是只能处理明确的陈述句。第二种是调用一次小模型让模型把对话提炼成一段80字以内的要点。效果好很多但每次触发都要额外调一次接口会增加成本和延迟。我的做法是混合默认用规则摘要触发条件比较复杂时比如文本量很大、涉及多个主题才升级到模型摘要。2.4 关键参数配置清单做context-mode以下几个参数我建议一开始就明确下来不然后面会反复调参数含义我的默认值说明max_tokens_total模型上下文窗口大小按模型配置不同的模型不一样别写死reserved_output_tokens给模型回复预留的token2048太少了会让回答被强行截断max_history_tokens对话历史最大token数按比例动态算总窗口减掉固定开销后剩余的部分summary_tokens摘要区域token预算400一个精炼的摘要大约50~80token400够放好几条max_turns最多保留的原始对话轮数20防止极端情况下token数失控auto_summary_threshold触发自动摘要的token阈值原始历史达到3000低于这个值靠滑动窗口就够“摘要区域”这块我想再说明一下。它和对话历史是并列存在的组装的时候放在历史之前、系统提示词之后。模型看到的是系统提示 - 摘要 - 若干轮最近的原始对话 - 用户当前输入。摘要让它记得“很久以前聊过什么”原始对话让它回答“刚才聊了什么”两者分工明确。3. 实操从零实现一个context-mode的完整流程3.1 技术栈选择与整体架构我这边用的是Python写后端服务主要因为跟大模型SDK配合比较顺而且处理队列和token计算有现成的东西。前端不用管反正它只是把用户的话发过来然后等回复。项目的整体架构是用户输入 - 会话服务校验、负载上下文 - context-mode引擎加载历史、计算预算、裁剪/摘要、组装prompt - 模型API调用 - 得到回复后更新上下文并持久化context-mode引擎拆成三个小模块history_manager负责消息队列、token计数、淘汰策略。summarizer负责摘要生成。prompt_builder负责把系统提示词、摘要、历史、当前输入按正确顺序组装。3.2 核心数据结构设计我用了一个简单的消息结构每条消息包括{ role: user | assistant | system, content: 消息内容, timestamp: 1718112000, priority: 0, skip_summary: false }priority用来标识消息是否重要我设计了几个等级等级2用户明确要求记住的信息或者命中关键词的消息不参与普通淘汰。等级1一般消息按时间先后淘汰。等级0寒暄和无关消息优先被淘汰。skip_summary是一个小开关用来标记这条消息“不值得保留进摘要”。比如用户说“哈哈哈哈哈”这种没必要提炼。会话的总上下文结构会落在磁盘上或者存Redis里大致是这样{ session_id: abc123, system_prompt: 你是一名耐心的购物导购..., summary_memory: 用户预算3000~4000元偏好安卓重视售后已咨询过A款手机因续航问题放弃。, history_queue: [ {role: user, content: 续航短大概多短, timestamp: 1718112000, priority: 1}, {role: assistant, content: 那种手机日常用大概一天两充..., timestamp: 1718112003, priority: 1} ] }3.3 核心代码实现下面这段是实现context-mode核心逻辑的Python代码简化到能直接看懂流程。真实项目里可以在这个基础上加缓存、加埋点。from collections import deque import time # 简易token估算中文按1字约1token英文按4字符1token # 比真实接口略少但用于预判足够。 def estimate_tokens(text: str) - int: if not text: return 0 # 中文、日文、韩文等宽字符按1个token估算其余按4字符1token import re cjk re.findall(r[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af\u3000-\u303f\uff00-\uffef], text) others re.sub(r[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af\u3000-\u303f\uff00-\uffef], , text) return len(cjk) len(others) // 4 1 class ContextMode: def __init__(self, max_tokens_total8192, reserved_output_tokens2048, system_promptNone, max_history_turns20, auto_summary_threshold3000, summary_tokens400): self.max_tokens_total max_tokens_total self.reserved_output_tokens reserved_output_tokens self.system_prompt system_prompt or self.max_history_turns max_history_turns self.auto_summary_threshold auto_summary_threshold self.summary_tokens summary_tokens self.summary_memory self.history_queue deque(maxlenmax_history_turns) self.key_messages [] def _used_tokens(self, extra_input): used estimate_tokens(self.system_prompt) estimate_tokens(self.summary_memory) for m in self.history_queue: used estimate_tokens(m[role] m[content]) for m in self.key_messages: used estimate_tokens(m[role] m[content]) return used estimate_tokens(extra_input) def _trim_history(self, extra_input): # 先计算非历史部分的固定开销 fixed_cost (estimate_tokens(self.system_prompt) estimate_tokens(self.summary_memory) self.reserved_output_tokens estimate_tokens(extra_input) 200) # 安全余量 available self.max_tokens_total - fixed_cost if available 0: # 极端情况固定开销就已经超了。优先腾出摘要空间 self.summary_memory fixed_cost (estimate_tokens(self.system_prompt) self.reserved_output_tokens estimate_tokens(extra_input) 200) available self.max_tokens_total - fixed_cost # 从最老的消息开始淘汰直到token数低于预算 while self.history_queue and self._used_tokens(extra_input) available: old self.history_queue.popleft() # 触发自动摘要把被淘汰的消息里值得保留的内容提炼进摘要 if not old.get(skip_summary) and len(self.history_queue) 0: self._append_summary(old) def _append_summary(self, msg): # 规则摘要提取包含关键信息的文本 text msg.get(content, ) keywords [我叫, 我姓, 我需要, 我喜欢, 我不喜欢, 我预算, 我的要求, 价格, 售后, 住, 地址] if any(k in text for k in keywords): new_part f[{msg[role]}] {text[:80]} # 确保摘要区域不超预算 while estimate_tokens(self.summary_memory) estimate_tokens(new_part) self.summary_tokens: self.summary_memory self.summary_memory[: int(len(self.summary_memory) * 0.7)] self.summary_memory (self.summary_memory new_part)[:500] def add_turn(self, user_input, assistant_output): self.history_queue.append({ role: user, content: user_input, timestamp: time.time(), priority: 1 }) self.history_queue.append({ role: assistant, content: assistant_output, timestamp: time.time(), priority: 1 }) self._trim_history(extra_input) def build_prompt(self, user_input): self._trim_history(extra_inputuser_input) messages [] if self.system_prompt: messages.append({role: system, content: self.system_prompt}) if self.summary_memory: messages.append({role: system, content: 以下是之前对话的摘要注意使用但不重复提及 self.summary_memory}) for m in self.history_queue: messages.append({role: m[role], content: m[content]}) messages.append({role: user, content: user_input}) return messages这段代码的逻辑我先解释几句。_trim_history是主力它在每次新对话进来的时候先算一遍“固定开销”再倒推“历史最多能占多少token”。然后从最老的消息开始弹弹到历史部分低于预算为止。被弹出的消息一旦命中关键词就提炼成摘要塞进summary_memory里保证信息不“凭空消失”。你可能注意到我用了deque(maxlenmax_history_turns)这是对“最多20轮”做的一个硬性保护避免在极端情况下无限膨胀。但光靠maxlen不够因为每轮对话长短不一所以_trim_history里还要按token数二次裁剪。3.4 与模型API对接的细节在实际接入模型时注意把build_prompt返回的messages直接作为接口输入。不同的SDK入参略有差异但绝大多数都兼容OpenAI风格的messages数组格式所以上面这段伪代码基本能无缝搬过去。还有一个容易踩的坑很多模型对system角色消息的数量和长度有隐性限制而且摘要角色设为system容易让模型“分不清指令和摘要”。我后来改了一种做法把摘要放在一条单独的system消息里开头注明“这是历史摘要仅作背景参考不要复述给用户”这样能明显减少模型在回复里生硬地背出摘要的情况。调用模型之后一定要把模型返回的usage信息拿回来实时更新我们的token计数。因为本地估算和模型真实分词器之间存在误差比如一些特殊字符、标点、英文单词被拆成多个token我踩过几次“本地算没超接口却报超限”的坑。正确的做法是每一次真实请求的prompt_tokens和completion_tokens都解析出来回写进会话数据作为下一个轮次计算预算的修正基准。4. 实战问题与排查技巧实录4.1 上下文超限报错context-mode上线第一天我就遇到接口返回400超限。排查下来根因是本地估算的token数低于模型实际的token数差出几百个。模型对纯英文文本的切分比较碎尤其是代码、URL、表情符号一个字符都可能单独拆成token。这类问题有两个解决办法一是换用模型官方提供的tokenizer库在本地做精确计数二是像我之前说的以线上接口返回的真实usage为准每轮都做校准同时保留一定安全余量。我两个都做了后者作为兜底因为tokenizer库偶尔版本更新会和线上模型内部版本对不齐。4.2 AI“忘了”开头约定的事这种情况最典型的场景是用户在第一轮说了几个要求第十轮左右开始判断推荐结果时模型完全不记得。原因是我的策略里最老的消息排在队首会最先被淘汰。用户的第一轮恰好就是那条被挤出去的。我的解决办法是在priority机制上补了一刀凡是用户消息里出现“请记住”“务必注意”“我一直是”“始终”这类强调词这条消息直接进key_messages列表不参与滑动窗口淘汰。key_messages我做了专门额度限制确保即使所有的消息都重置了这些固定记忆依然还在。另外我也在前端埋了个入口允许用户主动“固定某一条重要的对话”。后端收到固定请求时把那条原始消息从历史队列里挪到key_messages里供后续所有轮次引用。4.3 上下文被污染导致的“精分”上下文保留多了还有一个副作用模型看了太多内容直接把几轮前自己犯的错误当成事实拿来用。比如我在调试过程中发现模型在前面某轮把一款手机的价格说错了用户没有纠正到了第八轮模型居然继承了错误的记忆还说“按照之前说的7200元”。这类问题的根源是“错误的上下文没有及时被纠正”。我的经验是在context-mode里实现一个“纠错优先”机制如果用户后续消息里出现“不是”“错了”“我说的是”这类否认词就在下一个轮次组装prompt时把最新的用户否定消息放到历史的最前面也就是更靠近当前输入的位置。因为大多数模型对靠近输入末尾的内容权重更高这样可以让纠错信息压过旧错误信息。4.4 上下文模式的成本控制这是我最想吐槽的一个点。如果不加节制地保留上下文费用真的会涨得很快。尤其是对话超过二三十轮后每轮请求带着一大坨历史来回跑大部分token其实都在重复传输同一份信息白白烧钱。我后来做了两层优化。第一在进入context-mode的prompt组装前先做“历史消息去重”把重复或相似度极高的消息标记掉不进入队列。第二对超过10轮以上的长会话提高摘要触发的积极性——也就是把auto_summary_threshold从3000调低到2000让原始历史更早进入压缩流程。这两步之后我的平均单轮token消耗降低了大约25%而用户主观体验到的连贯性基本没变。4.5 常见问题速查表我在动手写这篇文章前把我们群里常见的问题整理了一下做成一张速查表方便你对照排查现象可能原因对应解决办法接口报max context length exceeded本地token估算不准或安全余量不够用官方tokenizer精确计数并把每轮真实usage回写校准模型忘了开头设定旧消息被滑动窗口淘汰设置最高优先级消息列表不参与淘汰模型把之前错误当事实错误消息在历史中权重太高识别用户否认词强制把纠错消息放到最前每轮请求越来越慢历史消息太长或数量太多提前触发摘要压缩并过滤无意义寒暄用户问“你怎么知道”模型在回复中复述摘要信息历史摘要消息中注明“不必复述”或把摘要放在system里多轮对话后AI风格漂移历史里混入了多类角色或多主题对历史做主题分片维护一个“当前主题”标签5. 进阶让context-mode更聪明5.1 上下文分片与角色隔离如果同一个会话里有多个主题来回切换比如用户先问手机然后聊了两句今天的天气然后又跳回手机这时候如果只是把所有对话塞在一起交给模型模型很容易因为中间插入的“天气”而乱了分寸。我试过在context-mode里给每条消息打一个主题标签组装prompt时把与“当前主题”相关的消息排在最前面其他主题的消息合并压缩成一行摘要。这个改动看似简单但对长会话的连贯性和回答确定性提升非常明显。5.2 用户可控制的重置机制做了context-mode之后又出现了一个新问题会话时间太长用户自己都忘了之前聊过什么有时候明明是在评估另一套方案模型还在反复引用旧方案的约束条件。我在界面上加了一个“清除上下文”按钮同时在代码里做了一条深层重置接口调接口会把summary_memory清空history_queue清空但保留key_messages里用户主动置顶的信息。这个设计是经过斟酌的重要的用户属性不该被一键清掉但临时的方案讨论应该彻底翻篇。5.3 上下文持久化与多端同步最后一个经验是关于长期记忆的扩展。context-mode本质上解决的是“本段对话内”的记忆但用户下次再来可能是第二天甚至一周后这时候如果有持久化体验就会再上一个台阶。我用了一个很轻量的方案把会话的summary_memory和key_messages按session_id存入RedisTTL设成7天。用户在任意端继续会话时先加载持久化摘要而不是从零开始。这样就实现了“跨天记忆”的能力而且成本很低。到这里context-mode的核心设计、代码、参数和坑就都讲完了。我个人在实际操作中的体会是做这类功能最忌讳一开始就堆复杂架构先把滑动窗口、摘要、优先级这三板斧用扎实绝大多数场景就已经跑得很好了。真等数据量起来、场景复杂了再逐步引入主题分片、持久化和向量检索也不迟。你如果在集成的时候遇到我上面没提到的状况照着那张速查表逐条排除大概率能定位到问题。最后给你一个实用建议上线前一定拿真实对话录一遍离线回溯测试把几段典型的长对话跑一遍看看模型在哪些轮次开始“失忆”再针对性调整参数这比你自己凭空猜要高效得多。
阅读完成 · 觉得有帮助?
咨询建站