资讯动态

大模型多轮对话的上下文模式设计:从选型到状态机实战

发布时间:2026/10/7 6:38:54 来源:尧图企业网站定制
1. 先看三个翻车现场没有上下文模式会怎样context-mode这个词最近在 LLM 应用开发的圈子里被频繁提起。我最早看到它的时候以为只是一个简单的开关——开一下AI 就能记住对话关一下就是普通的单轮问答。直到我自己在项目里连续踩了几个大坑才意识到这东西远比想象中复杂。它本质上是一套对话上下文的管理策略决定了模型在每一轮生成时到底能看到哪些历史信息、以什么形态看到、以及这些信息如何被更新和淘汰。在做智能客服系统时我曾天真地认为只要把多轮对话的历史消息全部塞进 prompt 就算是支持上下文了。然后很快碰到了三个典型的翻车现场几乎每一个都让我怀疑人生。1.1 场景 A多轮对话中的失忆用户和客服机器人聊了十几轮局面已经非常清楚——用户要退一张机票并且明确说了退票原因是因为航班变动。结果因为我们把上下文窗口设置得太小前面的关键信息被挤出了窗口模型在第十轮的时候突然反问请问您是要退哪张机票那一刻用户心态直接崩了。更麻烦的是这种失忆不是偶发的而是随着对话轮数增长必然出现的。如果你采用最简单的固定窗口截断策略——只保留最近的八轮对话——那么第九轮开始第一轮的信息就被丢弃了。而用户的关键诉求往往恰恰是在前几轮里说清楚的。1.2 场景 B全局上下文的信息拥堵另一个项目是文档问答助手。我把整份产品手册全部塞进了 system prompt再加了用户的问题一次性发给模型。结果同样很惨。模型确实知道所有信息但它的注意力被稀释了回答变得模棱两可。更要命的是 token 消耗直线上升一次普通问答的调用成本是之前的五倍而且首字响应延迟从 0.8 秒拉到了 3 秒以上。这就是典型的上下文信息过载。你要知道Transformer 的注意力机制虽然是全局的但模型的表现会随着 token 数量的增长而退化尤其是在关键信息埋在一大堆无关内容里的时候。不是信息越多越好而是关键信息足够集中才最好。1.3 场景 C多 Agent 协作中的串台这个场景更隐蔽。我在做多智能体协作系统时让几个 Agent 共享一个全局上下文容器。本来设计的是读和写都通过统一接口调用结果某个 Agent 在调试阶段误操作往共享区域里塞了一条完全无关的信息——另一个 Agent 在下一次决策时居然参考了这条信息给出了一个逻辑荒谬的建议。这个问题的本质是共享上下文缺乏隔离机制。不同 Agent 的关注点不同、生命周期不同、信息粒度不同把它们塞进同一个上下文池里等于让所有人穿同一件衣服尺码不对是必然的。这三个场景让我下定决心认认真真设计一套可落地、可分层的context-mode管理方案。这篇文章就把我这几个月的设计和踩坑经验完整写出来包含代码级别的实现思路、模式切换的状态机设计、token 预算计算方式以及测出来的那些让人哭笑不得的边界情况。适合所有正在做 LLM 应用开发尤其是对话系统、Agent 系统、RAG 问答的工程师参考。2. 四种核心模式按需选型而不是一把梭先明确一个基本认知不存在一种上下文模式能同时解决所有问题。单轮、滑动窗口、摘要压缩、结构化记忆这四种模式各有各的适用场景而且它们不是互斥的是一个系统里可以根据情况动态切换的档位。我最终落地的时候系统里同时跑了四种模式靠一个模式分发器根据当前对话的状态来决定走哪条路。下面逐个说清楚每种模式的内存逻辑和适用边界。2.1 单轮模式Stateless Mode适合一次性问答场景这是最简单的一种模式。每一轮请求都是独立的不携带任何历史消息。模型只看到当前用户输入和 system prompt。听上去很原始但在大量真实场景里它反而是最优解。比如关键词抽取、实体识别、意图分类、单个事实问答。这些任务本身不依赖上下文强行加历史反而会引入干扰。我见过有人在情感分类任务里把历史聊天记录全塞进去结果模型被历史里的情绪带偏把当前这一句的正面情绪判成了负面。代码上单轮模式只需要在组装请求时跳过历史消息序列def build_messages(strict_mode: bool, current_input: str, history: list[Message]) - list[dict]: if strict_mode: # 单轮模式完全丢弃历史 return [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: current_input} ] # 其他模式继续走 history 拼接逻辑这个函数虽然简单但它是我整个上下文管理器的入口。所有模式最终都要经过这一层来组装 messages。2.2 滑动窗口模式Sliding Window Mode最常用的保底方案滑动窗口是最直觉、最容易实现、也是绝大多数项目默认在用的方案。核心逻辑就是一句话只保留最近 N 条历史消息。这个 N 怎么定不是拍脑袋定的需要根据 token 预算反推。我习惯先定一个窗口的 token 上限然后往里塞消息塞不下就从最老的开始丢。具体计算方式我在第 4 章详细展开。滑动窗口的优点是实现简单、延迟低、token 开销稳定。缺点是中期记忆必然丢失。假设窗口能装 20 条消息那第 21 条消息进来的时候第 1 条就被挤出去了。如果用户在第 1 条里说了自己的需求在第 30 条时又补充了一个关键细节模型大概率已经把第 1 条的约束忘了。我当前的做法是滑动窗口只作为默认档位一旦检测到对话涉及关键长期信息就升级到摘要模式或结构化记忆模式。2.3 摘要压缩模式Summary Mode跑赢窗口上限的唯一办法当对话轮数实在太多、无法在 token 预算内完整放进去时摘要压缩是绕开上限的常规路径。核心思路是用一条高度提炼的摘要代表那些已经超出窗口范围的历史对话让模型在有限上下文里感知到全局信息。我第一版自己写摘要逻辑用老办法——每隔 5 轮调用一次模型把前面的聊天记录压缩成 150 字以内的要点存起来作为上下文的前缀。后来发现效果不稳定因为模型会把很多细节丢掉导致后面问答的精确度下降。后面我加了结构化摘要的思路效果好了很多。所谓结构化摘要就是按照事先定义好的 schema 输出而不是让模型自由发挥。比如客服场景下摘要必须包含四个字段SUMMARY_SCHEMA { user_intention: 用户当前的核心诉求, key_facts: [已确认的关键事实如订单号、时间、金额], user_emotion: 情绪状态平静/不满/愤怒, unresolved: 尚未解决的事项 }用 JSON 格式约束模型输出之后摘要模式才变得真正可靠。后续 Agent 从摘要里取信息时能稳定地按照字段解析而不是在自由文本里大海捞针。2.4 结构化记忆模式Memory Mode长期对话的战略储备如果说摘要压缩是压缩过去的思路那么结构化记忆就是提炼资产的思路。摘要仍然要围绕已有的对话内容转述而记忆模式则是把重要信息显式存成结构化条目越积越多永不丢失直到被主动更新或删除。我在系统里给每个用户维护了一个 Memory Bank里面长这样{ user_id: u_1024, facts: { airline_preference: 国航, seat_preference: 靠窗, member_level: 金卡 }, current_order: { order_no: CA1234, status: refunding, reason: 航班变动 }, negations: [ 不接受改签到第二天 ] }这个 Memory Bank 不是一次性全量塞进 prompt。它是按需读取的——在组装上下文时根据当前用户问题动态挑选相关的条目填入。比如用户下一次提到我要退票系统就把current_order、negations相关条目读出来和最近的滑动窗口拼接形成最终上下文。这个按需读取很关键避免把所有记忆全部塞入 prompt 导致的 token 膨胀。3. 状态机设计四种模式是如何被调度起来的上面四种模式如果只是各跑各的那还谈不上是系统。真正让它成为一个完整方案的是中间那层模式调度逻辑——什么时候用单轮什么时候从滑动窗口升级到摘要什么时候把信息写入 Memory Bank。我把这一层实现成了一个状态机。每个对话 session 在任意时刻都处于某一种上下文模式下根据特定事件触发模式切换。3.1 上下文生命周期从创建到回收每个 session 在创建时先处于STATELESS单轮模式因为此时还没有任何历史不存在维护上下文的必要。第一条用户消息进来后系统判断是否需要开启多轮模式。如果用户的问题是一个独立任务比如翻译这句话就保持单轮如果是帮我规划行程这种天然需要后续追问的就切到SLIDING_WINDOW。我把生命周期分成了四个阶段阶段模式触发条件退出条件初始化STATELESSsession 创建检测到多轮意图正常对话SLIDING_WINDOW多轮意图触发累计 token 超预算长对话压缩SUMMARY窗口 token 超限摘要写入成功记忆沉淀MEMORY出现可提炼的长期信息记忆条目落库这个状态机的切换方向常规下是单向流动的STALENESS → SLIDING_WINDOW → SUMMARY → MEMORY。但允许回退——比如用户明确说我们换一个话题那么旧的 SUMMARY 和 MEMORY 都会清空或标记失效session 回到 SLIDING_WINDOW 重新积累。3.2 触发条件到底在什么阈值下切换状态机的价值不在于状态定义而在于切换条件的合理性。先说从 SLIDING_WINDOW 切到 SUMMARY 的时机。我一开始的做法是当最近 5 轮对话的 token 数总和超过了窗口预算的 80%就触发摘要。这样做的坏处是频繁触发——用户稍微多说几句就压缩一次压缩本身要调一次模型延迟和成本都上去了。后来我把触发方式改成了惰性压缩def need_compress(session) - bool: # 当前对话总token含历史已经接近窗口上限的 85% 时才触发 return session.estimated_tokens() session.window_budget() * 0.85这样只有在真正快装不下的时候才压缩尽量减少无谓的模型调用。再说写 Memory 的时机。不是每轮对话都值得写入记忆。我使用了一个基于规则的特征过滤只有当对话中出现明确的偏好类关键词我喜欢我不喜欢以后都千万不要时才会触发一次记忆抽取。这个规则虽然粗暴但召回率高而且几乎不会遗漏重要信息。3.3 状态机实现一个极简但完整的状态核心实际代码里我用了一个简单的枚举加一个 manager 类来管理状态。没有上复杂的状态机框架因为目前的模式数量有限手写更可控。from enum import Enum, auto class ContextMode(Enum): STATELESS auto() SLIDING_WINDOW auto() SUMMARY auto() MEMORY auto() class ContextManager: def __init__(self, user_id: str, window_budget: int 12000): self.mode ContextMode.STATELESS self.history: list[Message] [] self.summary: Summary | None None self.memory MemoryBank(user_id) self.window_budget window_budget def add_turn(self, user_msg: str, assistant_msg: str): self.history.append(Message(roleuser, contentuser_msg)) self.history.append(Message(roleassistant, contentassistant_msg)) # 1. 更新模式 if self.mode ContextMode.STATELESS: if detect_multi_turn_intent(user_msg): self.mode ContextMode.SLIDING_WINDOW elif self.mode ContextMode.SLIDING_WINDOW: if self.estimate_tokens() self.window_budget * 0.85: self.compress_history() # 触发摘要压缩 self.mode ContextMode.SUMMARY elif self.mode ContextMode.SUMMARY: if detect_long_term_facts(user_msg): self.memory.extract_and_store(user_msg) # 2. 管理窗口大小防溢出 self.trim_history(self.window_budget) def build_request_messages(self, current_input: str) - list[dict]: if self.mode ContextMode.STATELESS: base [{role: system, content: SYSTEM_PROMPT}] elif self.mode ContextMode.SUMMARY: base [ {role: system, content: SYSTEM_PROMPT}, {role: system, content: f[对话摘要] {self.summary.text}} ] base self.history[-8:] # 只保留最近部分细粒度消息 else: base [{role: system, content: SYSTEM_PROMPT}] base self.history[-self.recent_visiable_count():] # 记忆条目按需注入 relevant_memory self.memory.relevant_to(current_input) if relevant_memory: base.insert(1, {role: system, content: f[用户长期偏好] {relevant_memory}}) base.append({role: user, content: current_input}) return base这段代码是我实际项目里跑过的简化版几个设计取舍值得说add_turn先更新模式再处理历史列表顺序避免模式切换和消息追加互相踩。trim_history在每次追加后执行确保self.history永远处在窗口预算内防止内存膨胀。build_request_messages中摘要模式和记忆注入都在 system 层完成不用占用 user/assistant 消息位置模型能更稳定地把它们当作背景设定而不是待回复内容。摘要模式下我只保留最近 8 条细粒度消息self.history[-8:]前面的一律交给摘要。这个 8 是我实测下来细粒度信息和摘要信息平衡得最好的一个值——太少模型会失忆太多又压缩了摘要的作用。4. Token 预算计算与窗口规划把账算明白再动手上下文模式的设计如果脱离 token 预算基本就是空中楼阁。窗口开多大、摘要压缩频率多高、历史消息保留多少条全部由预算决定。这一章我把整个计算过程展开你可以照着算自己项目的参数。4.1 一个完整的预算计算例子假设我使用的模型支持 32,768 token 的上下文窗口很多主流模型的标准配置。这个窗口里需要放下四类东西占用项数量说明system prompt1,500角色设定、回答规则、格式要求工具定义3,000如果涉及 function calling当前输入~500本轮用户输入按需估算模型输出4,096预留生成空间那么留给历史上下文的安全预算就是32768 - 1500 - 3000 - 500 - 4096 23672但这还不是最终可用的全部我习惯再留 15% 的余量防止单条消息特别长导致的估算偏差23672 * 0.85 ≈ 20121所以滑动窗口的有效预算大约是20000 token。接下来要记住一个大数中文会话里1 个汉字大约等于 1.5 到 2 个 token。换句话说用户说一句 40 字的自然语言模型回一句 120 字的回答一轮消息的 token 消耗大约在 350 到 500。按照这个估算20000 token 的窗口大约能容纳40 到 55 轮对话。这比我最初想的能放多少放多少要少得多。这也是为什么滑动窗口在真实场景里不够用——正常客服对话超过 60 轮非常常见窗口必然被撑爆。4.2 怎么算摘要模式节省了多少摘要模式下我每 10 轮压缩一次。前面 50 轮对话按原始形态需要大约 20000 到 25000 token压缩成摘要后大约只需要 800 到 1200 token。这时上下文的结构变成摘要(1000) 最近8轮原始消息(约3200) 4200 token整个上下文被压缩到了原来的五分之一还不到。这不是免费午餐成本在于中间调了 5 次模型做压缩。但综合考虑成本和延迟摘要模式的性价比依然很高——它换来的是模型对长对话的全局感不再因为早期信息被挤出而犯低级错误。4.3 预估偏差怎么处理算不准的问题token 估算永远不可能 100% 准确。不同模型的分词器对同一段文本的切分结果不同。好在工程上不需要那么精确。我用了两套冗余机制上限硬保护在组装 messages 时如果发现总 token 数用tiktoken或transformers的 tokenizer 精确计算超过窗口上限就触发强制裁剪。裁剪顺序是先丢最老的历史消息再丢工具定义仍然超限就强制触发摘要压缩。软阈值预警只要估算 token 超过预算的 85%就提前做一次压缩或降级避免到了下一轮直接爆掉。def trim_history(self, budget: int): while self.estimate_actual_tokens(self.history) budget * 0.9: if len(self.history) 2: break # 丢弃最老的一条消息 self.history.pop(0)这段代码是我在实际线上服务里直接运行的逻辑。它不完美——丢弃最老消息策略在信息价值上并不是最优的但胜在简单可控。更聪明的方案是按信息价值丢弃比如优先保留包含用户明确约束的消息但那个需要额外的语义判断成本高我目前没有在核心路径上启用。5. 实测中的翻车点与解决链路方案设计得再漂亮到了实测环节照样会翻车。我在压测和线上灰度阶段碰到的这几个问题每一个都值得单独拿出来说。5.1 模式切换瞬间的信息断裂第一次做模式切换时我遇到了一个非常尴尬的 bug在第 52 轮滑动窗口模式切换到摘要模式摘要生成完成之后模型突然忘了用户的姓名。排查链路是这样的先确认摘要内容——摘要里确实没有包含用户姓名。模型在压缩时把它当作不重要的信息丢掉了。再看窗口裁剪——切换时trim_history把老消息全裁了姓名只存在于被裁掉的那部分里。最终定位这是模式切换本身的问题摘要生成时丢了一个对当前任务并不重要、但对后续对话有长期价值的字段。解决方案也简单在触发压缩之前先检测需要保留的关键实体清单把清单里的信息强制追加到摘要中CRITICAL_ENTITIES [user_name, order_no, contact_phone, invoice_required] def compress_history(self): critical_info self.extract_critical_entities(self.history) summary_text generate_summary(self.history) self.summary Summary(textsummary_text 关键信息: str(critical_info))这样即使在摘要的正文把姓名丢掉后面的关键信息后缀也会兜底。5.2 上下文重复注入的回音壁第二个坑更隐蔽。在摘要模式和 Memory 模式同时开启后我发现模型开始复读某些信息。比如用户明明只在第 3 轮说过一次我喜欢靠窗座位到了第 30 轮模型每次回答都会提到靠窗座位哪怕当前话题根本不涉及座位。查到最后发现同一个事实被同时存在了摘要里和 Memory Bank 里。摘要模式把靠窗写进了摘要文本Memory 模式又把靠窗存成了一条结构化记忆。组装上下文时两处信息同时生效模型接受到的信息被重复加权就会倾向于过度强调它。解决方式有两个层面。第一是去重在写入 Memory 之前先查重如果摘要中已经存在该事实就不再重复写入 Memory或者反过来一旦写入 Memory就从摘要中移除该事实的显式描述。第二是给上下文内容加权重在 prompt 中明确标注以下摘要中包含的信息已过时以 Memory 条目为准。第二个方案虽然有点粗暴但在工程实践中反而更有效。因为摘要的去重是一件很难精确完成的事情——摘要文本是自由文本你要判断它是否包含某条记忆信息又得做一次语义匹配成本太高。5.3 多会话并发的上下文污染这个问题出现在我把 ContextManager 接入 Web 服务之后。最初我把所有用户的 ContextManager 实例存在一个全局字典里key 是 user_id。看起来没问题直到某次线上事故两个 user_id 恰好被某个上游服务写错了导致 B 用户看到了 A 用户的摘要直接串号。排查链路检查代码发现 ContextManager 创建时把 MemoryBank 和 user_id 绑定了但全局字典的 key 用的是另一个 request 级别的 session_id。当 session_id 不重复时没问题一旦 session_id 在网关层被复用连接池场景下常见就会串。这个问题的根本原因是上下文容器和会话标识的一致性没有在同一层保证。修复方案是在 ContextManager 内部强制校验class ContextManager: def __init__(self, session_id: str, user_id: str): if not session_id or not user_id: raise ValueError(session_id and user_id must not be empty) self.key f{user_id}:{session_id} self.memory MemoryBank(user_id) # 关键Memory 永远绑 user不绑 session另外还加了一层字典访问的防护在取出实例时检查内部绑定的 user_id 是否与当前请求一致不一致就重建实例并记录告警日志。自从上了这个校验串号问题再没出现过。5.4 滑动窗口在流式输出场景下的着装后置最后一个是很容易被人忽略的细节。我们的客服系统采用了流式输出——模型一边生成用户一边看到内容。这意味着模型输出在最后一个 token 落地之前是未完成的。我最初的设计是在add_turn中立即把 assistant 消息追加到 history。但流式输出时assistant 消息是逐步生成的如果在生成中途就追加会导致 history 里出现被截断的半句话。下一轮请求时这些残句会被模型当成正常内容产生很怪异的影响。修复方式是引入一个 pending 缓冲class ContextManager: def start_streaming(self): self.pending_assistant def append_stream_chunk(self, chunk: str): self.pending_assistant chunk def finish_streaming(self): if self.pending_assistant: self.history.append(Message(roleassistant, contentself.pending_assistant)) self.pending_assistant self.trim_history(self.window_budget)只有完整接收完整个生成结果后才把它写入 history。这个细节看起来简单但它直接影响下一轮问答质量——毕竟拿别人说了一半的话当参考谁都会理解错。6. 进阶调优模式嗅探、热冷分层与可观测性基础版本跑通之后我还有三个方向在做持续优化这里一并分享一下思路和已落地的优化点。6.1 按问题类型动态选模式模式嗅探前面提到的状态机是被动式切换——先积累到阈值再切。现在我在尝试更主动的方式在第一轮请求进入时就通过一个快速分类器预测该对话的会话深度预期直接决定初始模式。比如用户说帮我把这段文字翻译成英文这是一个典型的单次任务直接把会话置为STATELESS省去了切来切去的开销。而帮我比较一下三款手机哪个适合打游戏天然携带多轮属性直接就置为SLIDING_WINDOW加摘要预备。实现上我用了一个极轻量的意图分类层本质上是一个几千条规则加一个小的 embedding 分类器判别速度在 10ms 以内。规则部分的几个典型特征包含为什么怎么样具体说说 → 多轮意图包含翻译一下总结一下提取关键词 → 单轮任务包含记住以后都我不喜欢 → 启用 Memory 模式包含 对比比较哪个更好 → 启用长窗口模式这层嗅探不需要很精确。它有 80% 的准确率就能帮系统省下大量无谓的模式切换成本。剩下 20% 的不准会由状态机在运行中自动纠正。6.2 上下文热度分层热、温、冷三层另一个显著提升效果的优化是上下文热度分层。不再把历史消息简单地留 N 条而是分为三层热层最近 5 轮完整保留精细到字。温层更早的历史抽取关键信息以要点列表保留。冷层已经存入 Memory Bank 的长期事实按需读取。热层的消息直接拼接到 request温层的要点放在摘要前缀里冷层的记忆条目按当前问题相关性动态注入。这样一个三层结构能同时照顾到短期对话的连贯性、中期信息的可回溯性、长期知识的持久性。我测试下来这个分层结构比单一滑动窗口 摘要的效果稳定得多。而且它天然配合状态机——热层由滑动窗口管理温层由摘要模式管理冷层由 Memory 模式管理每一层各司其职。6.3 可观测性不看数据就调不好上下文做上下文管理最怕的就是感觉不对但说不清哪里不对。我建议从一开始就把可观测性纳入设计至少要记录以下指标指标获取方式用途每轮 token 消耗组装 request 时精确统计定位预算超支点模式切换次数状态机事件埋点发现抖动切换摘要命中率人工抽测评估压缩质量Memory 读取命中率Memory 查询日志判断记忆条目是否对回答有实际帮助上下文裁剪次数trim_history调用日志判断窗口是否长期偏小这些指标上线之后你会发现很多玄学问题其实都是数据问题。比如之前我总觉得某个用户的对话质量时好时坏查日志才发现——他每次对话都会在窗口边缘被裁剪说明窗口对该类用户来说偏小这人应该走摘要模式而不是滑动窗口。7. 一些实用的兜底建议在你决定照抄上面的方案之前有几条我这个过来人想多说两句的坑。第一不要为了支持上下文强行上多轮模式。很多需求其实是单轮任务硬上多轮反而会把历史里的噪声带进来。我的经验是能单轮解决的问题就不要让状态机增加复杂度。第二摘要压缩不能做得太频繁。压缩本身要调一次大模型是有成本的。我见过有人每三轮就压缩一次结果就是 ContextManager 变成调模型狂魔成本翻了 20 倍效果却没有明显提升。压缩的触发阈值宁可调低一点、保守一点让摘要晚一点出现、大一点概括。第三Memory Bank 里的记忆条目一定要带时间戳和置信度。我一开始没带结果用户后来明确说我不喜欢靠窗了以后订中间的位置系统不知道该信哪条。加上时间戳后逻辑非常清楚后来写入的覆盖先前的且我们可以设置权威覆盖标志——某些字段比如会员等级以业务系统数据为准模型抽取的记忆不能覆盖。第四严格处理好流式输出和异步写入的并发问题。如果不加锁或者缓冲区流式产生半截消息进入 history 的情况几乎必然出现。这属于那种不炸不知道一炸就摸不着头脑的隐形 bug。第五给 ContextManager 加上 trace_id 贯穿日志。我见过太多人调试上下文问题时因为找不到日志链路而无从下手。每组装一次 request就输出一条 trace_id 加消息骨架结构的日志后面排查问题能省一半时间。这几个建议谈不上优雅但拿它们去兜底基本能保证你的上下文管理系统不往失控的方向跑。用户对对话质量的感知往往非常敏感——一旦模型说出一句我不记得你刚才说了什么提升十个点准确率换来的信任也会当场归零所以宁可谨慎不要冒进。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑