做AI对话应用最头疼的一件事就是上下文管理。Model本身是“无记忆”的,每一次请求都是独立的一次推理,可用户并不会迁就你。聊着聊着就翻旧账:“刚才说的那个方案,你给个代码呗。”如果你不去实现一个像样的 “context-mode”,那对话体验就是灾难级别的崩坏。
这个项目的标题很精准,“context-mode” 说白了就是给对话系统装上“记忆力”。它不是简单地把所有历史消息一箩筐塞进去,那token爆炸的速度比你月底流量还快,而是要设计一套机制,在有限的理解窗口内,既保住最关键的信息,又让整个对话流保持连贯。这篇文章我打算把我在实际项目里踩过的坑、总结的办法、还有一行行调出来的经验全部分享出来,从设计思路到具体代码实现,再到排查那些玄学Bug,尽量讲透,适合正在做聊天机器人、AI客服或者任何基于大模型交互应用的同学参考。
1. 整体设计与思路拆解
1.1 为什么需要独立的上下文模式
很多前端工程师最初的做法是直接拼接字符串:把用户所有历史对话加上最新提问,一股脑发给模型接口。这个方案在对话只有两三轮的时候没问题,但聊到二十轮的时候就彻底失控了。一方面,模型对超长输入的注意力会严重下降,中间段的历史信息几乎会被“忽略”掉;另一方面,成本会肉眼可见地上升,每个请求的token数量都在浪费钱。
所以我们要做的 context-mode,本质上是解决三个核心问题:存什么、丢什么、怎么送。存什么决定了我们如何记录每一轮对话的结构化信息;丢什么决定了我们如何控制token预算,防止超限;怎么送决定了我们把哪些内容、以什么顺序拼装进最终的prompt里。
1.2 核心设计原则:不是越全越好
有一种常见的误解,觉得上下文当然是越全越好。实际测试下来恰恰相反。我记得有一次做一个客服机器人,把历史工单记录全部塞进去,结果模型被几千字的旧信息带偏,回复反而越来越敷衍。这就好比你和朋友聊天,朋友光顾着回忆你们五年前的旧账,根本听不进你现在的处境。
我后来总结出三条原则,直接体现在整个架构里:
- 分层存储:把上下文按照对话轮次、知识库片段、系统指令分成不同的层。
- 动态压缩:对早期的对话内容做摘要,而不是原样保留。
- 关键信息强化:把用户明确表达过的偏好、需求、身份信息单独提取出来,放在prompt最显眼的位置。
这三条原则对应到代码上,就是一个上下文管理器(Context Manager),它负责建一个“临时记忆表”,每次模型请求前做一次组装和裁剪。
1.3 方案选型的背后考量
当时我也对比过几种实现路径。一种是直接用现成的框架比如LangChain里的记忆模块,另一种是自己写一套轻量级的逻辑。框架解决方案好处是省事,坏处是黑盒,一旦调试出问题,很难定位是记忆模块的问题还是自己逻辑的问题。我自己最后选择了手写一个上下文管理器,原因其实很现实:我们的业务场景里有很多特定的压缩规则,比如要把日期都转成相对时间(“三小时前”),还要把商品SKU信息单独缓存。这种定制逻辑,别人家的框架很难满足。
另外一个关键点是语言模型接口的差异。有的模型支持把上下文拆分成多个消息对象(system, user, assistant),有的模型只接受纯文本prompt。所以设计的时候要做一个适配层,保证核心逻辑与具体模型接口解耦。我用一个config对象来配置不同的适配函数,这样换模型的时候只需要新增一个文件,不至于把整个业务逻辑摧毁。
2. 核心细节解析与实操要点
2.1 Token预算:一切的起点
Context-mode最基础的技术参数就是token预算。你需要知道你用的模型的上下文窗口长度,比如某模型窗口是8192个tokens,这意味着你送进去的所有内容加在一起不能超过这个数字,而且还要给模型的回复留出空间,通常至少要留出四分之一。所以实际可用的输入上下文大概是窗口的七成左右,比如8192就留6000个tokens作为输入上限。
确定这个预算之后,下一步就是给各个信息源分配额度。我的做法是按比例划分:
- 系统指令(system prompt):固定占用 300 tokens
- 关键用户画像信息:占用 200 tokens
- 最近完整对话轮次:占用 4000 tokens
- 更早的历史压缩摘要:占用 1500 tokens
这个比例不是拍脑袋定的,而是根据实际业务调试出来的。如果客服场景里业务知识更重要,就把知识库片段的比例调高;如果是情感陪伴类对话,历史轮次的比例就要调高,相关细节一点不能丢。每次调整后,我会用一个脚本统计用户问题的命中率,看回复质量有没有提升。
2.2 滑动窗口与摘要压缩的取舍
滑动窗口(Sliding Window)是最简单的方案:只保留最近N轮对话,其余全部丢弃。好处是实现简单,坏处是用户一旦在早期提过关键信息,后期就会遗忘。比如用户第五轮说过“我家在南方”,到第三十轮问“适合养什么植物”,如果采用简单滑动窗口,这条信息已经丢了。
所以我采用了两级策略:最近8轮对话原样保留,更早的对话每5轮合并成一个摘要。摘要不是简单的截断,而是用模型自己来做一次“遗忘笔记”。具体来说,我会把早于当前窗口的对话单独抽出来,调一次模型让它生成一段100-200字的摘要,然后把摘要存进一个专门的历史摘要区。这个过程我称之为“回忆压缩”。实测下来,摘要里包含的信息能覆盖早期对话中约七成的重要事实。
注意一个关键细节:摘要生成本身要消耗token,不能每次对话都全量重新生成。我会做增量式压缩,每次保留前一次的摘要和新增的几轮对话,生成新的摘要。这样压缩成本很低,几乎不会影响对话时延。
2.3 关键信息提取:从对话里挖金子
除了滑动窗口和摘要,我还单独实现了“关键信息提取器”。它的作用是把用户在当前会话中暴露出来的硬性约束,比如用户说“不要辣椒”、“价格控制在五十以内”、“我喜欢周杰伦”,这些信息会被实时抽取出来,放进一个专门的“用户约束字典”里。这个字典在每次请求拼装prompt的时候,会被放到很靠前的位置,因为模型对prompt前部的信息注意力最强。
一开始我没做这个模块,导致用户重复提相同的要求。后来加了提取器之后,用户体验提升非常明显,因为模型不再需要靠庞大的历史记录去推理这些约束。实现这个模块也不复杂,我用了一个结构化的JSON输出提示,让模型每次回复前先返回一个“info_retrieval”字段,里面包含提取到的实体和约束。这相当于多了一次内嵌的“小模型调用”,成本很低,但效果立竿见影。
3. 实操过程与核心环节实现
3.1 定义上下文管理器的基础数据结构
我实现的第一部分是数据层,用简单的Python字典和列表来组织。这里我给出一个可以直接参考的骨架代码,后续所有逻辑都围绕它展开。
class ContextRecord: def __init__(self, role, content, timestamp, extra=None): self.role = role # 'user' 或 'assistant' self.content = content self.timestamp = timestamp self.extra = extra or {} class SessionContext: def __init__(self, session_id): self.session_id = session_id self.system_instruction = "默认系统指令" self.user_constraints = {} # 关键约束字典 self.history = [] # 完整历史,按时间排列 self.summary = "" # 早期对话摘要 self.recent_count = 8 # 最近完整保留的轮数 def add_turn(self, user_msg, assistant_msg): # 每次对话结束,添加用户和助手两个记录 self.history.append(ContextRecord('user', user_msg, time.time())) self.history.append(ContextRecord('assistant', assistant_msg, time.time())) self._trim_and_compress()这段代码里最关键的方法是_trim_and_compress(),它负责在每次新增对话后判断是否需要把更早的内容滚入摘要。这个设计保证了内存不会无限增长,同时摘要触发的频率也很稳定。
3.2 实现增量摘要压缩
增量摘要的核心逻辑是先判断当前历史长度是否超过设定的轮数阈值。如果超过,就把最前面的一组对话和已有的摘要合并,交给模型生成新的摘要。这里我给出代码的简化版本。
def _trim_and_compress(self): # 如果历史长度超过 2 * recent_count,就触发一次压缩 if len(self.history) > self.recent_count * 2: # 找到需要压缩的旧记录:从开头到倒数 recent_count 条 compress_size = len(self.history) - self.recent_count old_records = self.history[:compress_size] # 拼装压缩输入 compress_prompt = self._build_compress_prompt(old_records, self.summary) compressed = call_model(compress_prompt) # 实际替换为你的模型调用 self.summary = compressed # 删除已压缩的旧记录 self.history = self.history[compress_size:]这里要注意的是:compress_prompt里的文本格式会影响摘要质量。我用的是三部分结构:之前的摘要、待压缩的对话轮次、以及一个明确指令“生成一段连贯的摘要,保留所有涉及用户偏好、未完成任务、具体数字的信息”。经过反复调优,这个提示词比单纯的“请总结对话内容”效果好很多,因为它明确告诉模型什么东西值得保。
3.3 组装最终Prompt:顺序决定成败
很多人忽略prompt内部顺序对回复质量的影响。我实际测试过同一个session,如果系统指令放在最后,回复风格会走样;关键约束放在太靠后的位置,有时会被模型忽略。最终沉淀下来的顺序是:
- 系统指令(风格与角色)
- 用户约束字典(硬性要求,比如“不吃辣”)
- 当前最新用户问题(放这里,保证模型注意力最强)
- 最近的完整对话轮次(按从旧到新排列)
- 历史摘要(压缩后的早期记忆)
- 明确的回复格式要求(比如输出JSON或markdown)
我把这个顺序封装在一个build_prompt()方法里。由于我们的模型接口支持消息列表,我就按照上面的层次生成对应的消息对象。如果是纯文本prompt接口,我就用不同的分隔符拼接,但层次顺序保持一致。
3.4 关键约束提取的实时化
提取关键约束这一步,我是在每次用户发送消息时同步做的。实现方式是让模型在正常回复之外,额外返回一个结构化的字段。这里给出一个简化示例,展示如何解析。
def parse_constraints(model_response, extra_schema): # 假设模型返回的是JSON字符串,其中包含 constraints 字段 import json try: resp = json.loads(model_response) constraints = resp.get('constraints', {}) # 合并进会话上下文 for k, v in constraints.items(): session.user_constraints[k] = v # 实际使用时需要过滤掉无效值(如空字符串) return constraints except Exception: # 解析失败则忽略,不影响主流程 return {}这个设想实现起来比较简单,但要注意不要阻塞主流程。把约束提取放在一个单独的异步任务里,等模型主回复完成后更新上下文。如果用户之后问道相关内容,约束就已经生效了。
4. 常见问题与排查技巧实录
4.1 问题一:上下文莫名超长,请求直接报错
我自己最常遇到的就是token超限。一开始以为只有用户狂发长文本才会触发,后来发现系统指令加摘要加历史,很容易就接近上限。排查的第一步是打印出每次请求前的总token数,建立一个日志中间件。我把所有输入内容按token估算汇总,估算方法很简单,英文按字符数除4,中文按字符数直接(中文token通常1字约1-2 token)。
| 现象 | 可能原因 | 排查手段 |
|---|---|---|
| 请求409错误 | 输入总token超模型上限 | 在库层打印token统计,定位占用最大的来源 |
| 早期约束失效 | 历史摘要时把关键信息丢掉了 | 检查压缩prompt的指令是否明确要求保留偏好 |
| 首字响应慢 | 上下文过长,模型计算量大 | 优化滑动窗口大小,降低摘要刷新频率 |
我自己踩过的坑是:把一次对话压缩摘要的生成直接放在主请求流程里,导致每次对话都额外等一次模型调用,用户感知到明显的卡顿。后来我把摘要生成挪到异步队列里,同一时间只允许一个压缩任务。这个优化非常有效,首字响应时间从原来的3秒降到了1.2秒。
4.2 问题二:摘要越来越“干”,核心情绪丢了
用摘要压缩历史确实能省token,但有个副作用:早期对话中的情绪和语气细节会丢。某次做情感陪伴类Demo时,用户明显表达过“我今天很难过”,但摘要里只记下了“提到情绪低落”。后来模型回复时丝毫没有同理心,因为摘要没有带上情绪强度。解决办法是在压缩提示词里增加一行:“如果对话中出现用户情绪波动,必须保留其情绪词汇原句或者在摘要中标注情绪强度(1-5)”。从那以后,摘要不仅仅是事实的堆砌,还有情绪的锚点。
4.3 问题三:并发会话导致的内存泄漏
另一个不踩不知道的问题:SessionContext对象如果不做清理,长时间运行会堆积在内存里。尤其是那种用户只聊了一轮就离开的会话,永远滞留在“活跃会话”字典中。我后来加了一个惰性回收策略:如果一个会话超过30分钟没有新消息,就把它从内存中移除,并持久化到磁盘或数据库。这个策略看似简单,但避免了内存以百MB级别膨胀。
4.4 问题四:模型输出的JSON解析失败
提取关键约束的时候,我最初直接要求模型“返回JSON”,但模型偶尔会在JSON前后加上自然语言解释,导致解析失败。后来我改用结构化输出约束方式,把返回格式实例化成一个具体例子放进提示词,要求模型只输出JSON对象,不要任何多余文字。同时对JSON解析做了容错处理,即使解析失败也不会让主对话流程崩溃。这个容错机制非常关键,生产环境不能因为辅助功能影响了主功能的可用性。
写在最后的一点心得
做 context-mode 失败过很多次,最深刻的体会是:它不是一个单纯的技术组件,而是一个需要和业务深度耦合的策略层。你设计的压缩规则、关键约束的优先级、甚至prompt的排列顺序,都要根据实际用户反馈反复调整。初期实现一个基本可用的版本没那么难,三五百行代码就能搞定,但真正让它“好用”,靠的是后期在真实对话数据上不断试错。我后来会给每次对话打上标签,记录模型是否主动重构了用户早前提过的需求,这个数据能直接告诉我摘要和约束提取做得好不好。如果你也在做类似的东西,别急着上复杂度,先把一个简单的循环跑通,再一点点加规则,这比一开始就追求大而全要牢靠得多。另外,上下文管理相关的日志一定要留全,排查起问题来你会发现它是你最大的救星。