
前阵子我在打磨一个基于大模型的对话应用最头疼的还真不是模型选型而是上下文这块。聊到第8轮用户前面明明说过的偏好就“蒸发”了聊到第20轮模型甚至开始一本正经地编造前面根本没出现过的东西。后来我干脆自己动手写了一个可插拔的context-mode模块专门接管多轮对话里的“记忆”和“取用”逻辑。这篇文章就是把这个模块的设计思路、实现细节和踩坑过程完整复盘一遍。如果你也在做聊天机器人、RAG问答或者Agent任务编排遇到“上下文总是丢”“历史太长成本爆炸”这类问题这篇应该能给你一套直接能落地的参考方案。1. context-mode到底解决什么问题1.1 三个每天都在踩的坑先说说我为什么要单独做一个context-mode而不是直接无脑把历史消息全塞给模型。第一个坑是上下文溢出。现在很多开源模型还是8K上下文窗口你做个客服机器人用户一天内反复进来问问题累积几十轮对话硬塞全量历史根本不现实token直接爆掉。有人第一反应是把消息从前往后截断但往往截掉的恰好是最重要的早期用户需求。第二个坑是无关上下文干扰。多轮对话里有大量寒暄、确认、重复信息这些跟当前问题完全无关。如果全部塞给模型等于让它在噪音里找信号。模型再强也会被无关指令带偏轻则回答变得啰嗦重则干脆忽略真实意图。第三个坑是成本不可控。每多转发一轮历史都是实打实的token开销长会话场景下成本随轮次线性增长产品还没上线API账单先扛不住了。这就像开会的时候你不可能每场会都把过去三个月的所有会议纪要一字不差念一遍。有经验的会议主持人是先提炼决议、明确待办遇到争议时再翻出与该议题相关的旧记录。context-mode就是给模型配了这样一个“会议助理”按需取用而不是全量搬运。1.2 三种模式对应三种场景我最初想的很简单给context-mode做两种模式一种是只带短期记忆一种是全量加摘要。但实际用下来发现不同业务场景对上下文的需求差异实在太大了所以最后定成了三种内部工作模式。模式携带内容优势劣势适用场景short-term只带最近N轮原文延迟低、成本低、实现简单无法处理跨轮长期任务闲聊、快问快答、一次性问答long-term全量历史滚动摘要信息完整很少丢细节token占用高无关信息多需要严格审计的长对话、人工复盘hybrid最近N轮原文窗口前摘要按需检索兼顾近期细节与长期信息实现复杂度最高需要调参客服、助理、Agent等绝大多数场景我最后的生产环境全部跑的是hybrid模式。它的核心思路是最近的对话保留原文因为模型回答需要“临场感”用户上一句说了什么机器人必须逐字看到更早的历史压成摘要因为那部分只需要“大概发生过什么”当摘要里信息不够时再从原始消息里检索相关片段补进去。这套组合下来既不会丢关键信息也不会让历史无限膨胀。1.3 这套方案的适用范围与扩展边界context-mode这个名字听起来像只解决聊天机器人的问题实际上它的适用范围要比这宽得多。RAG问答系统里用户的“对话历史”本身就是一个上下文源query加历史再走检索效果比单查一次好很多而历史部分完全可以用同样的模式管理。Agent任务编排里一次任务可能拆成多步工具调用每一步的中间结果其实就是“上下文”如果不做压缩一个复杂任务的上下文能冲到几万token。我目前只在纯文本对话里做了验证但数据结构上特意留了扩展位后续可以塞图片描述、工具结果、结构化表单。唯一要注意的是跨会话长期记忆这个方向单靠context-mode还不够需要把摘要向量化之后存到向量数据库等检索的时候再召回。我会在文章结尾再提一下这个演进思路。2. 核心细节与实现设计2.1 先把数据模型定清楚写代码之前我纠结了很久要不要直接复用现成的LangChain memory组件后来放弃了因为那些组件的抽象层次太高出问题不好排查。我决定自己定义一套简单的数据结构所有上下文逻辑都挂在这个结构上。{ session_id: sess_abc123, mode: hybrid, global_summary: 用户是电商运营正在策划618大促偏好简洁回复已确认使用A方案, messages: [ { role: user, content: 帮我看看这个活动的转化率, ts: 1717000000000 }, { role: assistant, content: 当前转化率是3.2%相比上周提升0.5个百分点。, ts: 1717000001000 } ], token_budget: 8000 }session_id是一切上下文操作的锚点绝对不能省。mode字段记录当前会话用哪种策略方便在同一个产品里对不同用户群体做实验。global_summary就是滚动摘要messages只保留窗口内的近期消息token_budget是这次请求允许占用的上限。为什么不把摘要和消息塞在一个数组里因为它们是两种完全不同的东西摘要是“压缩产物”不允许修改和删除消息内容消息是“原始证据”需要保持原样。混在一起会导致后续逻辑判断非常麻烦。2.2 摘要压缩是怎么做的摘要的核心不是让模型“总结一下”而是让模型“提取关键事实”。我踩过的最大一个坑是最初用了一个很泛的提示词请总结这段对话。结果模型把用户随口说的“我今天心情不好”也当成重要信息保留把真正关键的业务数据漏掉了。后来我把摘要模板改得非常具体。你是对话摘要器。请将以下对话压缩成要点式摘要只保留四类信息 1. 用户的明确目标或需求 2. 关键约束条件时间、预算、偏好 3. 已经确认的决策或结论 4. 尚未完成的待办事项 不要添加对话中不存在的内容不要猜测用户意图数字和专有名词必须原样保留。摘要生成也不能每次都从头开始。假设窗口是8轮第9轮来了如果每次都对前8轮重新摘要摘要还好说如果是第50轮历史累积了42轮你不可能让模型每次看42轮原文。所以必须做滚动摘要把上一轮生成的旧摘要和新进入窗口的消息拼在一起再让模型生成新的摘要。滚动摘要有个衍生问题摘要会越滚越短因为旧摘要已经是压缩过的再压缩一次细节就没了。我的处理方式是给滚动摘要加一个“分层”策略每次滚动时保留最近两次的旧摘要片段而不是只保留最新一个。这样虽然多占一点token但能保证关键数据不会在一次滚动中被彻底抹掉。2.3 滑动窗口的大小怎么定窗口大小是context-mode里最关键的参数调不好整个系统就废了。很多人直接拍脑袋定个“最近10轮”这在生产环境是不行的因为每轮消息长度差异很大有时用户一句话只有几个字有时粘贴了一大段日志。我的做法是先做token估算再反推窗口大小。比如我用的模型上下文是8K系统提示占500 token工具定义占800 token本次用户输入预计占1000 token回答预留1200 token那么历史部分最多可以占4500 token。如果一条消息平均250 token窗口理论可以放18轮但如果还要放一份1500 token的摘要窗口就只能压到12轮12乘以250等于3000加摘要1500正好4500。实际代码里我不会写死窗口轮数而是一个动态计算函数每次请求前先数一下现有消息总token如果超过预算就从最老的消息开始弹出弹到预算以内如果窗口已经很小还是超就触发一次摘要压缩。这个“先算预算再定窗口”的思路比固定一个数字稳健很多。2.4 关键信息检索让“旧记忆”在需要时出现摘要最大的问题是信息粒度太粗。用户10轮前提过一个报价摘要里可能只剩一句“用户提到过报价相关事宜”但当用户现在问“之前那个报价单上的含税价是多少”时这句摘要完全不够用。解决办法是把窗口之前的历史消息切成小块做一层轻量检索。我第一版用的是向量检索接了个embedding接口效果确实不错但延迟和成本都上来了。后来发现一个性价比更高的方案用关键词重叠度做粗筛。先把当前用户问题分词提取出名词和数字然后跟历史消息做倒排匹配挑出重叠度最高的Top-K条原文注入到prompt里。对大多数业务场景这种轻量级检索的效果已经够用而且延迟可以忽略不计。如果对话量特别大我还是建议上向量库但要注意给每条历史消息打时间戳检索结果按时间倒序排列避免模型把旧消息当成新消息理解。3. 实操过程与核心环节实现3.1 先算清楚上下文预算动手写核心逻辑前我强烈建议先把预算公式列出来不然很容易写出一个“在本地测试没问题、一上线就爆token”的代码。我的计算公式是这样的历史可用预算 模型上下文窗口 - 系统提示词token - 工具定义token - 用户当前输入token - 回答预留token模型上下文窗口是硬上限系统提示词和工具定义基本固定用户当前输入每次不同需要单独估算回答预留token是给模型生成答案留的空间不能省否则输出会被截断。举例来说一个8K窗口的模型系统提示500、工具定义800、用户输入1000、回答预留1200历史最多只能分到4500 token。这个4500就是context-mode所有压缩和窗口动作的基准线。这里有三个容易忽略的地方。第一回答预留要按业务场景调如果是代码生成类任务输出经常很长预留就得从1200提到2000以上。第二系统提示词可能在运行时动态变化比如用户选择了不同的角色设定这时不能用缓存必须实时计算。第三一些模型的tokenizer不是按字符数线性换算的特别是中文夹杂英文和数字时最稳妥的方法是直接调用模型的tokenizer离线统计而不是用简单的“字符数乘系数”估算。3.2 核心代码实现我把context-mode的核心逻辑封装成了一个类主要暴露两个方法一个是put用来写入一条新消息并触发压缩策略一个是build_context用来生成最终拼进prompt的上下文内容。为了演示方便下面是我的简化版实现。import json from typing import List, Dict, Optional class ContextManager: def __init__(self, model_ctx8192, window8, summary_limit1200, reserved_reply1200, system_tokens500, tool_tokens800): self.model_ctx model_ctx self.window window self.summary_limit summary_limit self.reserved_reply reserved_reply self.system_tokens system_tokens self.tool_tokens tool_tokens def estimate_tokens(self, text: str) - int: # 简化估算中文场景约1.5 token/字英文约1 token/4字符 # 生产环境建议直接用模型tokenizer离线统计 return int(len(text) * 1.5) if any(\u4e00 c \u9fff for c in text[:20]) \ else max(1, len(text) // 4) def historical_budget(self, user_input: str) - int: input_tokens self.estimate_tokens(user_input) return self.model_ctx - self.system_tokens - self.tool_tokens - input_tokens - self.reserved_reply def build_context(self, session: Dict) - Dict: messages session.get(messages, []) summary session.get(global_summary, ) budget self.historical_budget(session.get(current_input, )) # 第一步优先保留最近window轮原文 recent messages[-self.window:] if len(messages) self.window else messages[:] older messages[:-self.window] if len(messages) self.window else [] # 第二步老消息进入滚动摘要 if older: summary self._roll_summary(summary, older, self.summary_limit) # 第三步检查是否超预算超了就继续压缩 while (self.estimate_tokens(json.dumps(recent, ensure_asciiFalse)) self.estimate_tokens(summary)) budget: if len(recent) 2: break moved recent.pop(0) summary self._roll_summary(summary, [moved], self.summary_limit) return { summary: summary, recent_messages: recent, token_usage: { summary: self.estimate_tokens(summary), recent: self.estimate_tokens(json.dumps(recent, ensure_asciiFalse)), budget: budget } } def _roll_summary(self, old_summary: str, messages: List[Dict], limit: int) - str: # 实际场景这里调用LLM配合前面提到的摘要提示词 # 简化示例直接拼接生产环境请替换为真实LLM调用 raw old_summary \n json.dumps(messages, ensure_asciiFalse) if self.estimate_tokens(raw) limit: raw raw[-limit * 4:] # 粗略截断仅作示例 return raw这段代码有几个设计要点。第一build_context返回的是结构化的summary和recent_messages而不是直接拼好的prompt字符串这样后续还可以决定是加进system还是加进user消息。第二超预算时优先从recent的最前面弹出这保证了离当前问题越近的消息越完整深层逻辑还是“近期比远期重要”。第三_roll_summary里给LLM调用留了位置实际项目里这就是摘要生成函数上面那段截断只是占位。3.3 接入聊天循环和工具调用单独封装一个类之后接入聊天主循环就很简单了。每来一条新消息先把它存进session的messages数组再调用build_context拿到摘要和近期消息拼出最终请求发给模型。def chat(session, user_input): session[messages].append({role: user, content: user_input}) ctx ctx_manager.build_context(session) prompt [] if ctx[summary]: prompt.append({role: system, content: f以下为更早对话的摘要仅作背景参考\n{ctx[summary]}}) prompt.extend(ctx[recent_messages]) prompt.append({role: user, content: user_input}) # 调用模型省略具体请求代码 reply call_llm(prompt) session[messages].append({role: assistant, content: reply}) return reply工具调用场景比普通聊天要复杂一些因为工具的返回结果也可能是“上文”的一部分。比如一个查询天气的工具返回了一大段JSON如果不做处理这些JSON会全部留在messages里很快就把窗口占满。我的做法是每次工具返回后不把它作为完整消息存进历史而是先压缩成一行摘要比如“get_weather工具返回北京晴25度湿度40%”。这样既保留了关键信息又不会让原始JSON污染上下文。3.4 测试效果对比我把同一个测试集分别跑在无context-mode、short-term和hybrid三种配置下用50轮超长对话做对比结果差别非常明显。配置记住用户早期偏好回答上下文准确率平均token消耗/轮无context-mode只保留最近5轮基本遗忘62%1.2Kshort-term最近8轮原文7轮后开始遗忘71%2.5Khybrid窗口8轮摘要轻量检索50轮后仍能准确引用88%3.1Ktoken消耗方面hybrid虽然没有short-term省但考虑到准确率提升的幅度这个成本完全是值得的。如果接入轻量检索后用户主动问“我之前说的那件事”时准确率还能再往上拉几个点。对于追求极致成本的项目可以直接用short-term模式但前提是用户对“机器人记不住事”的容忍度要高。4. 常见问题与排查技巧实录4.1 摘要越压越“失真”我第一次跑通context-mode后发现模型偶尔会说出用户根本没提过的细节排查半天根因在滚动摘要。旧摘要第一次生成是对的第二次滚动压缩时模型为了“连贯”自己脑补了一些可能的内容第三次再压脑补的内容就被当成了事实。解决方法是两管齐下一是严格限制摘要提示词明确写“这是已有事实的压缩不是创作”二是给摘要体增加元信息比如在摘要末尾附上一行“本摘要生成于{时间戳}基于前{数量}轮消息”。一旦发现模型引用了可疑细节就能快速定位是哪一次滚动产生的问题。另外一个技巧是对金额、日期、人名、订单号这类硬性数据在摘要里必须单独列出来不要混在长句子里。因为长句子压缩时模型很可能把次要细节丢掉。单独列一行“关键数据报价含税价1500元/件”之后即使其他文字被压缩核心数据仍然能保留。4.2 多会话上下文串扰这个问题在我的多租户产品里出现过一次A用户问了一句“我上次说的那个报价”结果B用户的消息被拼了进来。查了半天发现是session_id没有严格从请求头里取而是用了服务端默认值。修复方式很简单所有context-mode操作都显式传入session_id不能依赖默认参数。生产环境我把session信息存在Redis里key模式是context:{session_id}并且给每条消息都带ts时间戳即使读出来也能按时间重新排序避免并发写入导致顺序错乱。排查串扰时的经验是先看请求日志里的session_id和消息条数是否匹配再对比上下文拼装后的最终prompt。建议开发环境里把build_context的输出直接打印出来一出现串扰从prompt内容一眼就能看出是哪条消息混进去了。4.3 token超限导致请求报错即使有了预算公式和窗口动态压缩还是会出现超限的情况。最常见的原因有两个一是系统提示词或工具定义在运行时被动态修改了实际token比预估的多二是用户当前输入特别长比如粘贴了一整段代码或长文档直接吃掉了大部分预算。我的处理方式是在build_context末尾加一道“安全阀”如果计算出recent和summary的总token仍然大于剩余预算就忽略窗口轮数限制直接从最老的消息开始截断。宁可这次对话少一点历史也不能让整个请求失败。还有一个细节有些模型对超限是返回错误有些是静默截断后者更隐蔽因为不会报错但模型会漏掉中间一段内容。所以我在build_context返回的token_usage里加了日志每次请求都会记录summary和recent各自的token数。上线观察几天后基本就能摸清业务里用户输入长度的分布再针对性地调整系统提示词的精简程度。4.4 性能与成本优化滚动摘要每次都要调用一次LLM如果用户消息频繁成本会明显上升。我的优化办法是把摘要触发改成“攒批”模式不是每新增一条消息就立刻摘要而是消息数超过窗口之后每满5条新消息才触发一次滚动摘要。这样在长对话中摘要调用次数能减少70%左右而近期消息窗口本身已经覆盖了那几条尚未摘要的消息所以效果几乎不受影响。如果项目对延迟更敏感还可以把摘要生成做成异步任务build_context同时返回“可用上下文”和“待更新摘要标记”模型正常回复后台线程再去更新摘要。这样用户完全感知不到摘要等待时间代价是代码复杂度稍微高了一点。4.5 问题排查速查表我把实际运行中比较容易遇到的问题整理成了一个速查表方便你直接对标排查。现象可能原因解决方案模型引用了用户未提过的信息滚动摘要过程中模型进行了推断严格摘要提示词禁止添加对话外信息关键数字被漏掉摘要压缩粒度太粗数据混在长句中单独保留关键数据列表不参与压缩A用户消息出现在B用户对话中session_id未隔离或使用了默认值所有操作显式传入session_idRedis按用户隔离请求偶尔超限报错用户输入长度波动大预算计算滞后增加安全阀强制截断最老消息摘要生成延迟高每轮消息都触发了滚动摘要改为攒批触发或异步更新模型把摘要当成了当前原文摘要和近期消息没有区分标识在摘要前加“以下为更早对话的摘要”提示检索到的历史消息太旧与当前问题矛盾检索结果未按时间排序对检索结果按ts倒序排列必要时加时间标签我个人实际操作下来的体会是上下文管理没有银弹hybrid模式只是“工程上最稳”的起点。你完全可以根据自己的业务去调窗口大小、摘要频率和检索策略但有两个原则一定要守住一是把当前对话的细节和长期记忆分开对待二是给token预算留够安全余量。最后分享一个小技巧在把摘要注入prompt时我习惯把它放在最近窗口消息之前并且在摘要前加一行“以下为更早对话的摘要”模型对“摘要”与“原文”的区分非常敏感这个细节实测下来能明显减少张冠李戴的情况。如果后面有时间我打算再把摘要向量化接入长期记忆让context-mode支持跨会话联想到时再写一篇续集。