ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

大模型上下文管理:五种 context-mode 模式详解与工程实践

大模型上下文管理:五种 context-mode 模式详解与工程实践 1. “context-mode”到底在解决什么问题1.1 一次让模型“失忆”的线上事故先讲个我自己遇到的事。早前维护一个客服机器人用户在前十轮对话里明确说了“我的订单号是 20240912-089”模型也正确回复了“好的已记录您的问题”。结果对话刚到第十三轮用户问“那这个订单现在到哪了”模型居然回答“请问您能提供订单号吗”。用户当时就炸了。问题不在模型本身而在上游。会话太长之后我们直接把超过窗口上限的早期消息从上下文里裁掉了系统指令、用户关键信息、工具返回结果全部混在一个滚动数组里谁先超谁被丢。这个“一刀切截断”的逻辑本质上就是一种非常粗糙的上下文模式——只是在当时我们没有把它当成一个需要专门设计的模块来看。后来我把所有跟“上下文如何组织、如何留存、如何提交给模型”的相关逻辑抽出来统一收敛成一个独立的 context-mode 模块这个问题才算从根上解决。这也是我写这篇内容的原因很多人聊到大模型应用注意力都放在 Prompt、微调、RAG 上但真正上了生产环境你会发现“上下文管理”才是决定体验上限的隐形瓶颈。1.2 为什么要把“上下文策略”抽象成模式context-mode 这个词在 AI 应用和 Agent 工程里现在越来越常见。它要回答的核心问题是用户发来一条新消息时你究竟把哪些内容拼进请求发给模型这个问题听起来简单实际展开却很复杂系统提示词要不要每次带历史对话带多少轮是带原文还是带摘要工具返回的结果是每轮都带上还是只带最新一次用户画像、偏好、临时变量放在哪里如果上下文总量超过模型窗口怎么办这些都是 context-mode 的职责范围。很多人一开始都习惯用 if-else 堆历史太长就截掉前面的摘要和原文混着存结果就是代码越改越乱而且不同场景互相打架。理想的做法是把它做成可插拔的模式上层调用方只需要声明“当前会话走哪种模式”具体怎么收集、怎么压缩、怎么截断由对应策略实现。这样有几个直接好处不同业务场景可以同时使用不同模式互不干扰新增策略不需要改动调用链路模式之间可以快速 A/B用数据说话而不是凭感觉调参。我个人的经验是无论你现在的项目是聊天机器人、Agent 还是 RAG 问答系统都值得在最早期就把上下文管理从业务代码里剥离开来。哪怕你现在只有一种实现也先定义好接口。否则等业务量上来再回头重构成本会是早期的好几倍。2. 五种主流上下文模式的取舍与适用场景context-mode 不是说只有一种“标准答案”它有不同粒度的实现从最省成本的单轮到复杂的多级记忆组合各有各的命门。我在实际项目里用得最多的是下面五种。2.1 单轮模式把每次请求当独立任务单轮模式最简单用户当前消息加上固定系统提示词没有历史对话。每次请求互相完全独立。这种模式的优点异常明显Token 消耗最小响应延迟最低逻辑不会因为历史污染而出错。缺点也很致命模型完全没有记忆能力。适合给工具调用校验、分类打标、抽取结构化信息这类“一次性”任务使用。比如你做一个内容审核接口每条文本进来独立判断是否违规历史记录在这里反而是噪音。但要注意如果用户明明在同一次流程里连续提问你却用了单轮模式体验会非常生硬。很多团队做多轮对话时为了省钱直接套这个模式结果用户上一句提供的信息下一句就没用上这是典型的省错地方。2.2 滑动窗口模式最简单又最容易偷懒出错滑动窗口是大多数团队的默认选择。实现思路很朴素把系统提示词、最近 N 轮对话、当前输入按顺序拼起来超过上限就从最早的消息开始丢。这个模式的好处是接入成本极低稍微处理下数组就能跑起来。坏处是“丢哪条”并不是按重要性判断的而是按时间先后。我的客服机器人事故就是这么来的系统把用户最初提供的订单号当成“最早的历史”丢掉了而后续的寒暄反而被保留了。所以滑动窗口模式不是不能用但必须在拼接前做一层“关键信息抢救”把用户明确提供的账号、订单号、地址等实体提前提取出来放到系统提示词里。窗口只管丢弃寒暄关键信息单独维护这就是最基础的结构化记忆雏形。2.3 摘要压缩模式用成本换记忆摘要模式的做法是对话超过一定轮数后不再保留早期对话的完整原文而是定期调用一次模型把早期内容归纳成一段摘要后续请求携带“摘要 最近几轮原文 当前输入”。这种模式特别适合客服、售前咨询这类“历史信息有一定价值但又不要求逐字精确”的场景。比如用户八轮前提到的使用场景、偏好、诉求压缩成摘要后后续轮次依然能用上。代价同样明显每一轮摘要可能有少量信息丢失摘要层级多了还会出现“摘要的摘要”细节漂移越来越严重。而且压缩本身要调一次模型会增加一次额外延迟和费用。我常用的策略是压缩频率不要太高至少攒够十几轮再触发一次摘要保存后完整原文仍可在本地持久化兜底只在提交给模型时用摘要替代。2.4 结构化记忆模式让关键信息独立于对话存在结构化记忆是把用户信息从对话流里剥离出来独立存储为字段。比如订单号、收货地址、用户偏好、当前意图、待确认列表。这些内容不随对话窗口滚动而丢失每次请求时作为结构化上下文拼进系统提示词。这是我在生产项目里最推荐优先落地的模式。原因很简单它对原有架构改动最小收益却最直接。你不需要维护复杂的摘要链路只需要在做实体抽取的地方顺势把记忆字段更新掉。窗口丢多少对话都无所谓只要结构化记忆还在模型的“短期失忆”就伤不到核心体验。缺点是它依赖高质量的抽取效果实体漏抽或多抽都会直接影响后续回复质量。我通常会把抽取做成独立的一次调用而不是在大模型的正常回复里顺带抽取避免出现“模型忙着回答忘了更新记忆”的漏网之鱼。2.5 检索增强模式别把所有历史都塞进去检索增强的核心思路是历史不用全带而是让用户当前的问题去“召回”最相关的历史片段。这其实是 RAG 在对话管理上的变体。适合用户会反复参考历史信息的场景比如技术文档助手、企业知识库问答。如果用户问“之前我们讨论的部署方案是什么”系统通过向量检索找出那段讨论原文而不是把三个月对话全部拼进去。这种模式多了一个检索链路工程复杂度最高。而且在对话历史这种信息高度稠密的数据上纯向量检索不一定靠谱我建议关键词召回和向量召回各跑一路再合并。无论如何这比把全部历史无脑拼进 Prompt 要科学得多。模式记忆粒度Token 成本实现难度适用场景主要风险单轮模式无记忆最低极低分类、抽取、工具调用校验多轮体验断裂滑动窗口最近 N 轮低低常规闲聊、简单问答关键早期信息丢失摘要压缩全量压缩中中客服、售前、长流程对话摘要信息漂移结构化记忆字段级低中订单、预约、偏好收集场景抽取质量不稳检索增强按需召回中高知识库问答、历史讨论回溯召回不准3. 动手实现一个可插拔的 context-mode 模块理论讲完进入代码。我会用 Python 写一个最小可用的 context-mode 模块你可以直接抄过去改。整体思路是三层结构数据结构层、策略层、管理入口。3.1 先给会话数据定一个干净的结构无论用哪种模式底层总归要存消息。但注意一个原则存储的原始数据要和提交给模型的数据分开。存储层尽量保留完整信息比如每条消息要有时间戳、角色、内容、附加元数据提交层才根据不同策略进行裁剪和变换。from dataclasses import dataclass, field from enum import Enum from typing import Optional class Role(str, Enum): SYSTEM system USER user ASSISTANT assistant TOOL tool dataclass class Message: role: Role content: str timestamp: float msg_id: str meta: dict field(default_factorydict) dataclass class Conversation: conversation_id: str system_prompt: str messages: list[Message] field(default_factorylist) # 结构化记忆独立于对话流 memory: dict field(default_factorydict)这里的memory就是结构化记忆字段它可以独立于消息列表存活。我的习惯是无论采用哪种模式memory都会同步维护因为它成本最低、兜底效果最好。3.2 策略接口把“如何组织上下文”变成可替换策略context-mode 的核心是一个策略接口。接口只做一件事接收完整的Conversation返回最终要提交给模型的messages列表。from typing import Protocol class ContextMode(Enum): SINGLE_TURN single_turn SLIDING_WINDOW sliding_window SUMMARY summary STRUCTURED_MEMORY structured_memory class ContextStrategy(Protocol): mode: ContextMode def build_messages(self, conv: Conversation, current_input: str) - list[Message]: ...这里用 Python 的Protocol定义行为契约也可以用抽象基类。每个具体策略只需要关心一个问题如何从完整会话数据中生成一个适合提交的列表。写具体策略时有一个我踩过坑才总结出的准则策略只做“读操作”不要在里面修改conv.messages更不要往里塞临时变量。因为策略可能被多次调用一旦有副作用排查起来会非常痛苦。所有裁剪、补全都在build_messages的返回值里完成。3.3 四种策略的典型实现单轮模式最简单就不贴完整代码了直接放最核心的滑动窗口和摘要压缩。class SlidingWindowStrategy: mode ContextMode.SLIDING_WINDOW def __init__(self, max_messages: int 12): self.max_messages max_messages def build_messages(self, conv: Conversation, current_input: str) - list[Message]: # 先拼系统提示词 result [Message(roleRole.SYSTEM, contentconv.system_prompt, timestamp0, msg_idsys)] # 结构化的关键记忆单独附加 if conv.memory: memory_text \n.join(f{k}: {v} for k, v in conv.memory.items()) result.append(Message(roleRole.SYSTEM, contentf[关键用户信息]\n{memory_text}, timestamp0, msg_idmem)) # 只取最近 max_messages 轮历史 recent conv.messages[-self.max_messages:] result.extend(recent) result.append(Message(roleRole.USER, contentcurrent_input, timestamp0, msg_idinput)) return result这就是“结构化的关键记忆 滑动窗口”的组合。我看过太多团队实现的滑动窗口只顾着裁历史完全没意识到conv.memory才是兜底关键。摘要模式稍微复杂一点核心在于维护一个摘要字段。class SummaryStrategy: mode ContextMode.SUMMARY def __init__(self, max_original_messages: int 10): self.max_original_messages max_original_messages self.summary: str def update_summary_if_needed(self, conv: Conversation): # 实际项目中这里是异步调用模型生成摘要 if len(conv.messages) self.max_original_messages * 2: old_messages conv.messages[:-self.max_original_messages] self.summary call_summarizer(old_messages, self.summary) def build_messages(self, conv: Conversation, current_input: str) - list[Message]: # 返回系统提示词 摘要 最近原文字段 当前输入 ...必须强调摘要的更新不要塞在build_messages的调用链里面同步做否则每次用户提问都会额外触发一次压缩模型调用延迟和费用都兜不住。正常做法是单独起一个异步任务在对话空闲期更新摘要或者至少记录一个“上次摘要更新时间”字段来限流。3.4 策略注册与统一入口到这里就需要一个ContextManager来对外屏蔽策略细节。调用方不用关心策略内部怎么实现只需要说“当前会话用哪种模式”。class ContextManager: def __init__(self): self._strategies: dict[ContextMode, ContextStrategy] {} def register(self, strategy: ContextStrategy): self._strategies[strategy.mode] strategy def build(self, conv: Conversation, current_input: str, mode: ContextMode) - list[Message]: strategy self._strategies.get(mode) if strategy is None: raise ValueError(funsupported context mode: {mode}) return strategy.build_messages(conv, current_input) # 使用示例 manager ContextManager() manager.register(SlidingWindowStrategy(max_messages8)) manager.register(SingleTurnStrategy()) manager.register(SummaryStrategy()) messages manager.build(conv, 我的订单现在到哪了, ContextMode.SLIDING_WINDOW)策略注册的机制非常值得保留。我遇到过一个业务场景同一个用户在前 5 轮用单轮模式做意图判断之后的对话切到结构化记忆模式最后再切换到检索增强模式。有了注册表整个切换在业务代码里只是一个参数变化不需要改动任何请求逻辑。4. 切换模式时的关键数据流转与边界处理模式不是静态的实际生产里经常会切换。切换时最容易出问题的不是策略代码本身而是边界数据的流转。4.1 哪些环节最容易“漏处理”上下文我归纳了一下context-mode 的上下文来源至少有五类你检查自己的模块时可以对号入座系统提示词全局指令、角色设定、回复约束必须在任何模式下都稳定存在。结构化记忆用户信息、偏好、待办事项独立于对话历史存储。对话历史分用户消息和助手消息可能有工具调用中间结果。工具返回内容调用外部 API 拿到的查询结果、计算结果。临时上下文上一次回复的附加信息、当前反问状态、条件分支标记。很多实现只看重“对话历史”这一项剩下四类要么挂在别的地方要么干脆被忽略。我建议在Conversation的数据结构里把这些来源显式建模而不是散落在各种变量里。漏处理一个临时上下文可能不会立刻暴露问题只会在某些特定分支下偶尔出错排查起来非常费劲。4.2 Token 计算的误差来源与校准上下文模式里的“截断”必然涉及 Token 数量判断。这里有个常见陷阱本地估算和模型实际分词是两套体系。我之前用len(text) / 2估算英文 Token看起来挺准但遇到中文、代码、特殊符号时偏差非常大。中文在不少分词器里一个字是一个甚至更多 Token代码片段因为空格和符号密集Token 消耗也比普通文本高出不少。经验做法是本地维护一个“按角色分档”的估算系数系统提示词按中英文混合宽松估算结构化记忆按固定开销估算对话历史按平均每字消耗实测值估算。正式上线前用目标模型的真实分词器离线跑一遍历史数据校准系数。def estimate_tokens(text: str, language: str mixed) - int: if language zh: # 中文实测校准值 return int(len(text) * 1.3) if language code: return int(len(text) * 0.8) return int(len(text) * 0.35)这只是个示意。重点是思路不要拿不区分语言的通用公式糊弄拿自己线上真实的数据去校准。还有一个反直觉经验给模型的max_tokens预留要留足我试过把上下文估算到刚好贴合窗口上限结果模型回复到一半就截断了那是因为回复本身也占用窗口。4.3 并发写、缓存与持久化的边界另一个容易被忽略的是并发问题。如果同一个会话同时有两条用户消息进来两条请求都会读取conv.messages然后各自处理最后写入时可能把对方的数据覆盖掉。context-mode 本身不解决并发但它在设计时要把数据访问收敛到一个入口。我的做法是会话级写操作放入一个同步队列或者对conversation_id做分片锁。读操作允许并发写操作串行化。这样既能保证吞吐又能防止两条消息互相覆盖。还有缓存问题。摘要模式下摘要字段的变化和消息列表的变化经常不同步导致后续请求带着旧摘要进入模型。我踩过一次用户已经修改了收货地址但摘要里还是旧的模型一直按旧地址回复。后来养成了一个习惯所有上下文数据写入时都带版本号构建消息时检查版本是否一致不一致就先刷新摘要再提交。5. 上线之后踩过的坑最后讲几个真实踩坑如果你正在做类似模块大概率会遇到其中之一。5.1 滑窗把系统指令“挤”掉了说起来很蠢但其实很常见。我在一个项目里把窗口设置成max_messages12当时以为系统提示词是固定注入的不在窗口计数范围内。结果某天日志显示模型回复突然开始“角色错乱”说话口吻完全变了。排查后发现代码里有一处历史清洗逻辑把早期的系统消息也当成普通消息折叠进了历史记录窗口滚动时把系统提示词给冲掉了。这个问题如果只是在本地单元测试很难测出来因为单测环境通常很短一旦线上对话长了隐患立刻暴露。解决方式是硬性约定build_messages返回的列表里前两位永远是“系统提示词”和“结构化记忆”任何策略都不得把裁历史逻辑作用到这两条上。我在策略接口的文档注释里明确写上“历史裁剪的起点从 index2 开始”并且在代码里加断言一旦系统提示词被裁剪就立刻报错。5.2 摘要压缩的“信息漂移”摘要模式跑了一段时间后我发现一个症状模型对早期信息的回答越来越模糊从“订单 20240912-089”变成“你那个订单”再到“你之前提到的订单”。这就是摘要漂移。根因有两个。一个是早期对话里的精确数据比如订单号、金额、日期经过一轮概括后摘要模型倾向于保留语义而丢掉精确字段。另一个是多次摘要递归压缩后原始细节被逐层磨平。我之前试过“摘要的摘要”第三轮之后就彻底面目全非。现在我的策略是双轨制摘要只负责概括“语义脉络”关键的精确字段必须由结构化抽取单独维护进memory。提交给模型时摘要段落负责解释“之前发生过什么”结构化记忆段落负责提供“具体数据是什么”二者叠加使用。这个改动上线后因为早期信息丢失产生的客诉直接降了大半。5.3 模式切换后缓存不失效有一次我们从滑动窗口切换到结构化记忆模式运行了半小时后收到反馈模型回复带着旧模式的痕迹。查了很久才发现是服务端缓存的问题。我们的上下文模块把build_messages的结果做了缓存Key 只包含会话 ID切换模式后同一个会话 ID 会命中旧缓存导致一直用旧策略的结果。这其实暴露了一个设计上的规范模式不是会话的属性而是请求级别的参数。同一个会话可以在不同请求里用不同模式。所以缓存的 Key 必须包含模式标识。我在ContextManager里把缓存 Key 从conversation_id改成了f{conversation_id}:{mode.value}:{version}问题立刻消失。5.4 兜底策略降级永远要存在最后一个建议也是我在生产环境里反复强调的无论你设计了多么优雅的模式组合都要有一个“最低可用兜底模式”。线上系统不稳定是常态。摘要模型的调用超时了、检索服务返回 5xx 了、结构化抽取抽出来一堆乱码这时候 context-mode 模块必须有降级路径。我的实现是在ContextManager里加了一个 exception handler捕获所有策略内部异常降级为“系统提示词 最近 5 轮 当前输入”的最简组合。虽然记忆可能丢失但至少用户还能得到回复不至于整个请求报 500。这个兜底逻辑建议在最早期就写好不要等到上线之后被监控系统追着打补丁。我就吃过这个亏当时觉得“这些策略都挺稳的”结果某天检索服务故障整个对话全都断了比回答质量问题严重得多。context-mode 模块做得好不好短期看不出来因为所有问题都会在对话很短时被掩盖。但把对话拉长到几十轮、几百轮再叠加并发和缓存模式设计的差异就会非常明显。现在回头看我把上下文管理抽成独立模块这个决定是我在 AI 应用项目里最正确的一次重构。如果你也在写类似的东西希望这套思路能帮你少走一段弯路。
返回列表