
做 LLM 应用开发这半年我被同一个问题折磨了无数次上下文到底怎么塞才能既不爆 token 又不丢关键信息。后来我干脆写了一个叫 context-mode 的小工具把“上下文”这件事彻底拆开管理。如果你也在调对话系统、Agent 或者长文档问答这篇文章应该能帮你省下不少试错的时间。context-mode 不是什么新算法它是一套状态驱动的上下文管理思路把上下文分成静态规则、会话过程、长期记忆三个模式每次调用前显式指定要用哪一层、给多少预算由模式管理器统一装载和压缩。这套东西不挑模型GPT 系、Claude、开源模型都能接主要解决的是多轮对话里“历史越长、回答越飘、成本越高”的问题。下面我把设计的思路、核心实现、参数配置以及踩过的坑完整拆开讲。1. context-mode 的整体设计与思路拆解1.1 先从一段被 token 账单支配的经历说起之前我做一个售后客服机器人第一版特别 naive把所有历史消息全部拼进 prompt模型每次回答都把整段对话重新读一遍。上线跑了三天问题集中爆发。第一是成本用户聊到第 20 轮时一次请求光是历史消息就占了接近 8000 token每天几千个会话账单直接翻了好几倍。第二是效果历史越长模型越容易被中间某些无关紧要的闲聊带偏。有一次用户中途说了一句“你们这个物流是真的慢”后面几轮模型讨论的焦点直接从“退款流程”漂移到了“物流为什么慢”完全忘了用户最初是要申请退款。第三是稳定性很多 token 超限的报错都是在下班高峰期出现的用户聊到一半接口直接抛异常。我当时的反应是这不是模型能力的问题是上下文组织方式的问题。用户真正需要的只是在当前这一步回答里看到一个合适的上下文切片而不是每次都看到全部流水账。context-mode 就是从这个痛点出发设计的。1.2 context-mode 到底切换的是什么很多人一听“上下文模式”第一反应是“哦就是截断历史消息”。不是。截断只是把 prompt 剪短context-mode 做的是把上下文拆成不同的生命周期让每一段内容知道自己该活多久、优先级有多高、什么时候该被换掉。我用一个枚举来管理不同的上下文模式completion 模式只装载系统指令和用户当前输入不携带任何历史适合一次性生成任务比如单轮问答、标题生成、文本润色。chain 模式装载系统指令和最近 N 轮会话内容适合需要连续推理的对话场景比如客服聊天、代码调试助手。memory 模式在 chain 模式基础上额外注入经过压缩的长期记忆块适合跨会话场景比如会记住用户偏好的推荐系统、陪伴型助手。这几种模式之间不是互斥的而是层层叠加的关系。memory 是最重的模式chain 是中间档completion 是最轻量的一档。选定模式之后管理器会按照预设的预算分配规则去各层取内容最终组装成一份不会超限的 prompt。1.3 为什么把模式开关显式暴露出来最开始我也想过让系统自动判断该用哪种模式后来在实际测试里发现自动判断太容易出错。比如用户只是随口问一句“在吗”模型可能误判成要开启长对话模式结果把一堆历史记忆全灌进去浪费 token也可能是用户已经连续追了三轮细节模型还在用 completion 模式导致每轮都丢失上文回答前后矛盾。所以我把决定权交给了调用方——也就是我自己写的业务逻辑。后端可以根据路由规则、当前会话状态、用户意图显式指定 context_mode。这样每一步 prompt 的构成是可控、可复现、可 debug 的。真正遇到需要自动切换的场景可以在业务层写一个相对保守的规则引擎来触发切换但核心逻辑一定是显式的。2. 核心机制与实现原理2.1 上下文分层的核心模型context-mode 的核心数据结构不复杂就是一个带模式标记和预算字段的上下文块。我在 Python 里这样定义dataclass class ContextBlock: mode: str # completion / chain / memory layer: str # system / session / memory scope: str # global / project / user / turn priority: int # 0 - 100数值越大越优先保留 budget: int # 该层允许占用的 token 上限 content: list[dict] # 消息列表[{role: ..., content: ...}]所有被装进 prompt 的内容都必须挂在一个 ContextBlock 下。装载顺序按“优先级从高到低、预算从大到小”排列。管理器负责做最终检查如果所有 block 的 token 总和超过模型上限就按优先级从低到高逐层压缩或丢弃。这个数据模型的优势在于它把“内容是什么”和“内容怎么用”解耦了。同样一条用户消息存在 session 层时它只是当前这轮会话的临时信息经过摘要、结构化处理后放到 memory 层它就变成了跨会话的长期事实。同一个内容生命周期完全由所在的层决定。2.2 三层上下文的定位与协作我把上下文分成三个物理层每一层对应一个 context-mode 的构建阶段。第一层是系统层对应的是系统提示词和全局规则。这一层在三个模式下都存在内容不会因为对话轮数增加而变化优先级最高不允许被压缩。我踩过一个坑是刚开始把一些用户专属信息也塞进系统层结果切换用户时没有清掉导致数据串号。后来规则改成系统层只放与单一用户无关的通用规则所有用户维度信息必须走 memory 层。第二层是会话层存的是当前会话内的消息序列。chain 模式和 memory 模式下会启用。这一层允许滚动丢弃最常用的策略是保留最近的 N 轮或者按 token 百分比裁剪。会话层的优先级低于系统层高于 memory 层因为当前会话中最新的指令往往比很久之前的记忆更能指导下一步回复。第三层是记忆层存的是从历史会话中提炼出来的长期事实和用户偏好。这一层的构建方式比较特殊不是直接存原文而是定期把会话层的内容做一次摘要和结构化提取再存入记忆库。memory 模式下会启用这一层。记忆层里的每一条都可以额外打上时间戳和来源会话 ID方便追溯。2.3 模式切换的生命周期管理这部分的细节直接决定了整个系统的可靠性。我定义了几条硬性规则。规则一内容只能从高活跃层流向低活跃层。也就是会话层的内容经过提炼、压缩后可以写入记忆层但反向不行。记忆层不能把自己未经处理的内容倒灌回会话层否则摘要成本就白花了。规则二同一份内容在多个层里可以共存但以最新层为准。比如用户在第一轮说“我喜欢喝美式”这句话可能在会话层里还存在同时记忆层里也提炼了一条“偏好美式咖啡”。等到第五轮用户说“其实我现在更爱喝拿铁”这时会话层会出现一条新内容记忆层也更新为“偏好拿铁”。生成 prompt 时如果两层内容冲突以会话层最新的原始消息为准记忆层内容暂时退避。等这轮会话结束再触发一次记忆层更新。规则三每次调用只允许一个主模式被激活辅助模式最多叠加一层。例如 memory 模式下系统层 会话层 记忆层已经算完整组装不允许同时再叠加另一个 chain 主模式否则内容拼接逻辑会混乱预算也不好控制。模式系统层会话层记忆层典型场景completion固定加载不加载不加载单轮问答、文本改写、意图分类chain固定加载滚动装载最近 N 轮不加载多轮客服、代码调试memory固定加载滚动装载最近 N 轮注入长期记忆块跨会话推荐、用户画像问答3. 实操过程与核心环节实现3.1 上下文装载器的关键逻辑我写了一个 ContextLoader 类来负责组装 prompt核心逻辑是把可用的 ContextBlock 列表按优先级排序后塞入最终请求数组同时动态计算 token 消耗并给出预警。class ContextLoader: def __init__(self, model_limit: int 8192): self.model_limit model_limit self.blocks: list[ContextBlock] [] def add_block(self, block: ContextBlock): self.blocks.append(block) # 优先级从大到小排列方便后续裁剪 self.blocks.sort(keylambda x: x.priority, reverseTrue) def assemble(self, mode: str) - list[dict]: 组装最终 messages返回给 LLM API allowed_layers self._allowed_layers(mode) selected [b for b in self.blocks if b.layer in allowed_layers] messages [] used 0 for block in selected: block_tokens self._estimate_tokens(block) if used block_tokens self.model_limit: # 超限时对低优先级块做截断或摘要 block self._trim_block(block, self.model_limit - used) messages.extend(block.content) used self._estimate_tokens(block) return messages实际操作中我会在内存里维护一个 ContextStore按user_id session_id维度去存储各层的 block。每个 block 里除了消息内容还记录了最后的访问时间。装载时可以根据场景决定是否要加载最近 24 小时内有更新的记忆块。3.2 token 预算分配与参数选择对话模型的上下文窗口是有限的。拿 4096 token 的窗口举例我实际是按下面的比例切分的系统层固定 600 token里面放角色设定、回复格式、禁止事项。会话层预留 2400 token按最近对话轮次倒序填充如果超出则把最早一轮整体丢弃。记忆层预留 800 token存放跨会话的用户画像、关键结论。兜底区剩下约 300 token留给本次用户输入以及 API 返回的补全内容避免把窗口吃得太满。这里的计算逻辑是上下文窗口 系统层 会话层 记忆层 当前输入 模型输出预留。如果你用的模型是 8192 甚至 128K 窗口可以按比例放大但建议始终保留 10% 的缓冲因为 tokenizer 的统计误差在长文本上会被放大实测下来 5% 会因为特殊字符等情况估少。会话层的轮次选择也很关键。我试过无脑保留最近 10 轮效果其实不好因为很多中间轮次是“好的”“然后呢”这种无意义消息。更好的做法是按 token 上限滚动而不是按轮数滚动。设定一个最大 token 数从最新一条消息开始往前倒推塞不下了就停这样能保证有效信息利用率最高。3.3 记忆构建与压缩策略记忆层是最容易出现信息膨胀的地方。如果每次会话结束都把整段历史摘要放进记忆层要不了几次对话记忆就膨胀到无法收拾。我的做法是分层筛选第一轮筛选是去噪。原始会话里所有寒暄、语气词、重复表达都直接丢弃。这里用规则正则就够了不要依赖模型去识别成本高且不稳定。第二轮筛选是抽取“可更新的事实”。我维护了一个事实槽位表比如用户偏好、常用地址、正在处理的订单号、需要跟进的任务描述。匹配到槽位就更新对应的记忆条目。第三轮才轮到摘要。如果一轮会话中有多个事实需要压缩成一段话我会用一次 LLM 调用生成结构化摘要格式固定为 JSON方便写入记忆库。模板大致长这样{ summary: 一句话总结本轮会话核心内容, facts: [ {type: preference, value: 偏好美式咖啡}, {type: request, value: 申请退款订单 A1024} ], follow_up: 下一步需要跟进退款进度 }这个 JSON 会作为 memory 层的加载内容。下次开启 memory 模式时系统只注入这类结构化事实不再注入原始对话。效果是记忆层的 token 占用是可控的而且查询某个具体信息时可以直接走检索而不是全量注入。3.4 一个可运行的调用示例假设我要构建一个能记住用户偏好的咖啡点单助手context-mode 的调用流程大致是这样loader ContextLoader(model_limit4096) # 系统层所有模式都会加载 loader.add_block(ContextBlock( modememory, layersystem, priority100, budget600, content[{role: system, content: 你是一个咖啡点单助手回答简洁不超过50字。}] )) # 会话层chain 和 memory 模式加载 loader.add_block(ContextBlock( modememory, layersession, priority50, budget2400, contentsession_histories[-6:] # 最近6轮 )) # 记忆层只有 memory 模式加载 loader.add_block(ContextBlock( modememory, layermemory, priority30, budget800, content[{role: system, content: 用户偏好无糖中杯美式上次购买拿铁2024-05-10}] )) messages loader.assemble(modememory) response openai.ChatCompletion.create(messagesmessages, ...)如果切换到 chain 模式底层会自动跳过 memory 层只保留系统层和会话层。这相当于给同一个机器人两套记忆系统短期会话和长期用户档案互相独立切换成本降到最低。4. 配置策略与最佳实践4.1 三个典型场景的配置参数不同业务对上下文的依赖程度完全不一样。我把压测过比较顺手的参数组合列出来可以直接参考场景建议模式窗口分配比例系统/会话/记忆触发记忆更新的轮次客服机器人chain 为主特殊用户用 memory15% / 60% / 20%每轮会话正常逢 5 轮归档一次长文档问答chain20% / 70% / 5%不更新文档内容走系统层个性化推荐助手memory10% / 30% / 55%每次用户明确变更偏好时为什么长文档问答里记忆层比例这么低因为文档问答的核心是把文档内容作为静态知识放在系统层或全局会话层真正的“记忆”需求很小。如果你硬要用 memory 模式去记忆整份文档成本会非常吓人效果反而不如直接对文档做向量检索、命中片段后拼进系统层。个性化推荐助手则相反它的核心价值是跨会话记忆用户偏好所以记忆层预算占比最高。但这种场景下要注意记忆的时效性用户上周说“喜欢某品牌”这周可能就变心了。所以我给记忆条目加了updated_at字段超过 30 天未命中的记忆在注入时权重自动下调。4.2 模式切换时机怎么定最省事的方式是固定模式比如所有请求一律走 chain。但如果你想更精细一点可以用以下几个信号触发模式切换新会话第一轮用户在咨询明确问题而无历史主题时优先走 completion减少噪声。用户出现代词指代“这个”“它”“那家店”时强制切到 chain带上最近 4 轮历史。用户触发特定业务意图如“帮我记住”“以后都按这个来”切到 memory并准备提取新的记忆条目。会话轮数超过阈值比如 12 轮触发一次记忆归档操作把前面 12 轮压缩成结构化记忆然后清空会话层重新开始计数。实测下来这套规则在客服场景里可以有效减少大约 40% 的 token 消耗同时回答的连贯性没有明显下降。原因是它把大量“已经聊过去的内容”从原始会话中摘出去了模型只需要关注最近的有效上下文。4.3 给 context-mode 接上新模型时要做的事如果你换了一款接入成本更低的模型比如从闭源模型切到开源量化模型不要直接把 context-mode 里保存的 content 原样送过去。不同模型对 system prompt 的遵循度、对 JSON 格式的稳定性差别很大。我遇到过一次同一段记忆 JSON 在新模型下被原样输出回了用户而不是作为隐藏约束场面一度很尴尬。我的做法是在系统层加一句“不要向用户提及你的内部记忆结构”同时在记忆层内容外面包一层明确的分隔符标记。另外所有记忆内容在注入前会过一次脱敏和长度校验防止旧模型 tokenizer 统计不准导致超限。5. 常见问题与排查技巧实录5.1 高频问题速查表症状可能原因处理方式回答开始重复系统提示词内容系统层和会话层内容重叠模型把提示词当成对话内容系统层与消息数组分离存放不要在会话层重复注入同一份数据记忆层明明有内容却不生效主模式没有设置成 memory或记忆层优先级过低被裁剪检查 mode 参数和 block 的 priority/budget开启模式日志确认哪一层被加载切换用户后数据串号系统层错误缓存了用户维度信息确保用户维度内容只在 memory 层以 user_id 为粒度隔离切换用户时重建 loader多轮对话后上下文爆炸会话层无上限滚动或记忆层递归摘要没有覆盖前面轮次增加会话层最大 token 数控制定期将多余轮次摘要后写入记忆层长文本命中率反而低系统层塞入了过多静态知识挤占了会话层空间长文本知识改为向量检索命中片段注入不要整体塞进系统层5.2 印象最深的一个坑会话层污染有一次上线后我发现用户问“最近有什么新品”模型回答里居然混进了几天前另一个完全不相关产品的信息。查日志后发现问题出在会话层收纳规则上。我当时做了一个“把用户历史会话中所有含产品名的消息都保留”的规则本意是防止产品信息被误裁掉结果导致旧会话内容一直在会话层里滚最终污染了当前话题。修复方式是在会话层筛选中加入话题相关度判断。具体做法是先用 embedding 计算当前用户消息与会话历史每条消息的相似度低于阈值的旧消息直接不进会话层。这个操作看起来多了几步计算但对于长会话场景来说净效果是省钱的因为后续模型输出不会因为噪声信息多轮跑偏整体调用次数反而变少了。5.3 另一个坑过度摘要导致关键细节丢失压缩策略上线后的第二周有用户反映说“你们怎么不记得我上周说过的地址”。排查下去发现上周那轮会话的原始消息已经被摘要压缩掉了而摘要里只写了“用户咨询配送问题”把最关键的具体地址给弄丢了。这次之后我加了一条硬性规则所有涉及用户具体信息的内容地址、订单号、电话号码、日期时间在压缩前必须先进事实槽位保存摘要层只负责保存过程性信息不承载高精度事实。这样即使摘要写得很模糊具体事实也能通过记忆层完整恢复。6. 一些实操心得第一次实现 context-mode 时我走了不少弯路总结下来真正影响效果的就三点分层粒度、注入顺序、裁剪策略。先说分层粒度。分层过多会显著增加代码复杂度和调试成本。我一开始分了五层包括 intent、topic、slot、history、global结果半天时间全花在调各层优先级上很多场景下又发现层与层之间内容重复。最后收敛到三层系统层、会话层、记忆层每层职责边界非常清楚够用也容易维护。再说注入顺序。同样的一段记忆放在系统 prompt 前面和放在聊天历史最后模型的注意力完全不同。实测下来把记忆层放在 system prompt 之后、会话历史之前的位置效果最稳定。这样模型一开始就能看到全局约束再进入具体对话细节不会在连续多轮推理之后把记忆忘掉。最后是裁剪策略。没有绝对的“最优策略”我用的是带优先级的 Token 预算分配但这套思路在遇到超长输入时也会失效。后来我加了一个动态保护机制检测到某轮用户输入特别长时自动降级会话层预算把空间让给当前输入防止用户消息被挤掉导致接口报错。如果你也想做类似的事情建议先解决一个具体场景的问题比如先只做多轮客服的 chain 模式跑通之后再考虑跨会话的 memory。context-mode 的整体思路并不复杂真正花时间的是把每个层的内容来源、更新时机、淘汰规则理清楚。这个理清楚了后续接任何模型都会顺手很多。