news 2026/10/20 3:35:43

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
大模型应用开发:context-mode上下文模式设计与实现

身为搞过几年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~80token,400够放好几条
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_total=8192, reserved_output_tokens=2048, system_prompt=None, max_history_turns=20, auto_summary_threshold=3000, summary_tokens=400): 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(maxlen=max_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_input=user_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(maxlen=max_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存入Redis,TTL设成7天。用户在任意端继续会话时,先加载持久化摘要,而不是从零开始。这样就实现了“跨天记忆”的能力,而且成本很低。

到这里,context-mode的核心设计、代码、参数和坑就都讲完了。我个人在实际操作中的体会是,做这类功能最忌讳一开始就堆复杂架构,先把滑动窗口、摘要、优先级这三板斧用扎实,绝大多数场景就已经跑得很好了。真等数据量起来、场景复杂了,再逐步引入主题分片、持久化和向量检索也不迟。你如果在集成的时候遇到我上面没提到的状况,照着那张速查表逐条排除,大概率能定位到问题。最后给你一个实用建议:上线前一定拿真实对话录一遍离线回溯测试,把几段典型的长对话跑一遍,看看模型在哪些轮次开始“失忆”,再针对性调整参数,这比你自己凭空猜要高效得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 3:35:24

从一本技能清单到可执行路线图:AI研究员的四层能力模型

最近在开发者社区刷到一份项目&#xff0c;标题很朴素&#xff0c;就叫“skills”。点进去之前我以为又是一张“我会哪些技能”的清单&#xff0c;但读完才发现&#xff0c;这份文档几乎把一名AI研究员从想法到上线要踩的所有坑都列全了。不管是刚入门的学生&#xff0c;还是带…

作者头像 李华
网站建设 2026/10/20 3:35:20

REA模式:用资源-事件-代理搞定企业采购领域建模

如果一个项目标题只有三个字母&#xff0c;最容易想到的是“React 拼到一半”或者某个文件名缩写。但对我这种常年做企业应用和账务建模的人来说&#xff0c;“rea”这三个字母更自然指向一种经典的业务领域建模方式——资源-事件-代理&#xff0c;也就是 REA 模式。它不绑定任…

作者头像 李华
网站建设 2026/10/20 3:34:32

VRRP虚拟路由器冗余协议:原理、配置与排错实战指南

1. 为什么VRRP至今仍是网络高可用的“压舱石”第一次接触VRRP是在一个园区网的改造项目里&#xff0c;核心交换机双机部署&#xff0c;下面挂了三十多台接入设备&#xff0c;用户要求“断一台核心&#xff0c;业务不能断”。当时我第一反应是用堆叠或者链路聚合&#xff0c;但堆…

作者头像 李华
网站建设 2026/10/20 3:34:22

用C#和ASP.NET Core在浏览器中查询Access数据库的轻量方案

简介&#xff1a;这份资源面向具备一定C#基础的开发者与小型项目维护人员&#xff0c;解决如何通过Web页面查询Access数据库的实际问题。内容围绕ASP.NET与ADO.NET技术栈展开&#xff0c;涵盖OleDbConnection连接建立、OleDbCommand执行SQL、OleDbDataReader读取结果&#xff0…

作者头像 李华
网站建设 2026/10/20 3:33:55

Linux数据链路层深度解析:从以太网帧到XDP性能优化实战

1. 为什么数据链路层值得单独拎出来讲很多人学网络&#xff0c;习惯性地从 TCP/IP 四层模型往下背&#xff0c;背到数据链路层就开始含糊其辞——“不就是网卡那一层嘛”“负责 MAC 地址的呗”。我在实际排查网络故障的头几年也是这个心态&#xff0c;直到有一次线上环境出现间…

作者头像 李华