context-mode:对话系统如何动态组织上下文,避免大模型答非所问

发布时间:2026/10/7 13:12:42
context-mode:对话系统如何动态组织上下文,避免大模型答非所问 1. 从一次线上事故说起没有context-mode的系统有多脆弱去年年中我接手了一个智能客服项目功能很简单用户在前端提问后端从知识库里检索相关内容拼进提示词后调大模型回答。上线头两个月一切正常直到有一天我们收到了一个让人哭笑不得的用户反馈——用户先问了“怎么退款”又紧接着问“你们几点下班”结果系统回复“退款客服的工作时间是早上九点到晚上六点”。问题出在哪两个问题本身各自都能回答但第二个问题里模型被上一轮的“退款”上下文牵着鼻子走了把“几点下班”理解成了“退款客服几点下班”。我当时的第一反应是“把对话历史截断就行”结果改了之后又出现新问题用户连续追问“那保价呢”“那发货呢”一旦截断历史模型根本不知道用户在问什么。折腾了大半个月我才意识到问题的本质不是“历史该不该留”而是系统根本不知道当前该以哪种方式组织上下文。用户在一个连续会话里其实在做不同性质的事情单纯追问商品细节、需要结合历史进行复杂推理、临时切换到另一个话题。这三种场景对上下文的需求完全相反。而当时我的系统只有一种策略把最近的几轮对话全部塞进提示词。这就是我今天想聊的东西——context-mode也就是上下文模式。它的核心思想是不做一套上下文策略打天下而是让系统根据当前对话所处的状态显式地切换上下文组织方式。这听起来不复杂但做起来涉及的细节非常多状态判定、token预算、缓存失效、检索策略切换、提示词结构重组每一步都有坑。这篇文章适合正在做LLM应用、RAG管道或者对话系统的开发者。我会先拆解context-mode最常见的三种形态然后给出一个可以直接抄作业的最小实现框架再讲清楚token预算和状态同步这两个最容易被忽视的环节最后分享我在真实项目里踩过的三个典型翻车现场。全文没有空话都是能直接落地的经验。2. context-mode的三种典型形态对话态、检索态与任务态在开始写代码之前先把概念理清楚。我在实践中发现绝大多数对话系统里出现过的上下文问题都可以归入三种模式之一。我把它们命名为对话态、检索态和任务态。2.1 对话态上下文就是“这段对话本身”对话态是最朴素的一种模式适用于你来我往的普通聊天气氛。用户问“这个手机支持防水吗”你答“支持IP68”用户再问“那能在浴缸里泡澡吗”模型需要知道“这个手机”“那”指代的是什么。这种场景下上下文就是对话历史的自然截取一般取最近N轮加上一个系统提示词设定角色。我见过不少团队在对话态上犯的错是试图在系统提示词里塞入所有业务规则。结果提示词越来越长模型开始“忘记”真正的对话内容因为注意力被系统提示词里的规则稀释了。对话态的正确做法是系统提示词保持精简只负责设定语气和边界把大部分上下文额度留给真实的对话历史。2.2 检索态上下文来自外部知识库的“即时查证”检索态出现在用户的问题需要外部知识支撑的场景典型的如企业知识库问答、电商售后FAQ。用户问“你们的退货政策是什么”系统不能靠模型记忆回答必须从知识库检索出相关片段再组织答案。检索态下上下文的主体不是对话历史而是检索回来的信息片段。我见过很多RAG项目在这里踩坑他们把对话历史和检索结果统统塞进提示词长度失控不说检索到的无关内容还会干扰模型判断。检索态的核心是“克制”——对话历史上限放宽一些没关系但检索片段必须是高精度的宁可少不能杂。2.3 任务态上下文是“达成某个目标所需的全部状态”任务态最复杂常见于多步骤操作的场景订机票、排查故障、填表单。用户说“帮我订一张下周去上海的机票”系统不只是回答一个问题而是要维护一个“预订任务”的内部状态目的地、日期、预算范围、备选方案。任务态的上下文和前两种完全不同。它不适合用对话历史来表达因为用户可能在第3轮改过预算、在第7轮换了目的地如果模型只看到最近几轮对话就会丢掉关键约束。正确做法是维护一个结构化的任务状态对象比如JSON每次更新对话后同步修改这个对象然后把这个对象作为上下文的主体注入提示词。2.4 三种模式的关键差异对比我把三种模式的核心差异整理成一张表方便你对照自己的场景维度对话态检索态任务态上下文主体最近N轮对话历史检索结果片段结构化任务状态上下文来源会话本身外部知识库系统内部维护典型长度中数K tokens短几百到1-2K短结构化数据更新方式追加最近消息按需重新检索每次对话后增量更新失效条件话题切换/时间流逝查询意图变化任务完成/取消常见错误系统提示词过肥检索片段过杂状态对象和对话历史割裂这张表是我在多次重构项目后总结出来的。一个常见误区是认为模式有优劣之分好像任务态比对话态高级。实际上不是这样它们适配的是不同的对话性质。一个客服机器人可能在一场会话里同时用到三种模式用户先查政策检索态然后问“那我这样的情况能退吗”结合历史变成对话态最后“帮我提交一个退货申请”进入任务态。系统能否识别这种切换直接决定用户体验的好坏。3. 核心实现一个可切换context-mode的最小框架概念说完了上代码。我下面给出的框架是我在多个项目里反复迭代后沉淀下来的通用版本。它不依赖特定框架核心思想是用一个显式的ModeController来决定当前用什么方式构建上下文。你可以在任何LangChain、LlamaIndex或原生OpenAI调用的外围套上它。3.1 定义模式与上下文构建器首先定义枚举类型和每个模式对应的上下文构建函数。这里我用Python做示例其他语言照思路移植即可。from enum import Enum from dataclasses import dataclass, field from typing import Any, Dict, List, Optional class ContextMode(Enum): CHAT chat # 对话态 RETRIEVAL retrieval # 检索态 TASK task # 任务态 dataclass class ContextPackage: 每种模式产出的最终上下文包 mode: ContextMode system_prompt: str context_blocks: List[str] # 按优先级排好的上下文块 token_budget: int # 当前块的token预算 meta: Dict[str, Any] field(default_factorydict)接下来是上下文构建器。每个构建器都是一个独立的函数输入会话状态输出ContextPackage。这里最关键的一点是每种模式的system_prompt不同因为对话态、检索态和任务态对模型的角色指令完全不一样。def build_chat_context(session: Any) - ContextPackage: recent_msgs session.messages[-8:] # 最近8轮对话 system_prompt ( 你是一个友好的智能助手。 请基于最近对话内容自然回应不要提及内部规则。 ) return ContextPackage( modeContextMode.CHAT, system_promptsystem_prompt, context_blocks[m.to_plain_text() for m in recent_msgs], token_budget2500, meta{history_rounds: len(recent_msgs)} ) def build_retrieval_context(session: Any, retriever: Any) - ContextPackage: query session.current_query() docs retriever.retrieve(query, top_k4) # 只取4条高精度片段 system_prompt ( 你是一个基于资料回答问题的助手。 只能使用【参考资料】中的信息回答不要编造。 ) context_blocks [] for i, doc in enumerate(docs, 1): context_blocks.append(f【参考{i}】\n{doc.text}) return ContextPackage( modeContextMode.RETRIEVAL, system_promptsystem_prompt, context_blockscontext_blocks, token_budget1200, meta{doc_count: len(docs)} ) def build_task_context(session: Any) - ContextPackage: task_state session.task_state # 结构化的任务状态对象 system_prompt ( 你是一个任务执行助手。 根据【当前任务状态】判断下一步该问什么或做什么。 不要重复询问用户已经提供的信息。 ) state_json task_state.to_json() if task_state else {} return ContextPackage( modeContextMode.TASK, system_promptsystem_prompt, context_blocks[f【当前任务状态】\n{state_json}], token_budget800, meta{task_stage: task_state.stage if task_state else none} )3.2 模式判定器先判模式再组上下文整个框架的灵魂不是构建器而是模式判定器。判定器决定当前该用哪个构建器。我在实践里摸索出一套比较稳的判定逻辑按优先级排列class ModeController: def __init__(self, retrieverNone): self.retriever retriever def decide_mode(self, session: Any) - ContextMode: # 优先级1任务态存在且未完成 if session.task_state is not None and not session.task_state.is_finished(): return ContextMode.TASK # 优先级2当前查询明显需要外部知识 query session.current_query() if self._needs_retrieval(query): return ContextMode.RETRIEVAL # 优先级3普通对话 return ContextMode.CHAT def _needs_retrieval(self, query: str) - bool: retrieval_keywords [ 政策, 规则, 规格, 怎么退, 如何申请, 支持吗, 包含什么, 价格, 文档 ] return any(kw in query for kw in retrieval_keywords) def build_context(self, session: Any) - ContextPackage: mode self.decide_mode(session) builders { ContextMode.CHAT: build_chat_context, ContextMode.RETRIEVAL: lambda s: build_retrieval_context(s, self.retriever), ContextMode.TASK: build_task_context, } return builders[mode](session)你可能会说关键词匹配太粗糙了。确实我早期用关键词后来换成了一次“意图分类”的轻量模型调用准确率更高但代价是每次都要多一次网络请求。对于绝大多数内部工具和客服场景关键词规则少量人工维护的词典已经能覆盖80%以上的情况而且零延迟、零成本。剩下的20%可以靠下面两个优化弥补。3.3 两个重要的增强路由字典和模式转换信号第一个增强是“路由字典”。业务方往往能提前告诉你“什么问题该走什么模式”把这个知识固化成字典比机器学习模型更可控。我习惯维护一个rule_map键是业务功能号或问题模板值是模式名每次发版前由业务方确认上线后还能根据日志反向校验规则是否正确。第二个增强是模式转换信号。我把它称为“上下文断点”。当用户切换话题时系统检测到当前模式和上一轮不同就应该在对话历史里插入一个显式的分隔标记比如[话题切换]。此后历史不再引用。这个信号同时告诉构建器前一轮的检索结果或任务状态可以清空或存档避免串味。举个具体场景用户在任务态下订机票进行到一半突然问“你们公司楼下有没有咖啡店”。判定器会切到检索态此时如果不做断点处理模型可能还在惦记机票任务回答就变得四不像。插入断点后提示词变成“此前在进行的机票任务已暂停当前是一个新问题”。效果立竿见影。4. 上下文预算管理token的分配、压缩与回收有了模式切换框架之后下一个绕不开的问题是每个模式的上下文块往提示词里塞多少大模型的上下文窗口是硬约束超出就报错接近上限时不仅费用高效果还会急剧下滑——我实测过当提示词占用超过窗口的70%时回答质量开始变得不稳定模型容易忽略靠前的指令。4.1 先做减法给每个模式设定token预算上限我一般按“总量-安全余量-分块预算”的顺序来设计。假设模型窗口是8K我给自己定一条铁律提示词总token数不超过窗口的70%也就是5.6K。为什么要留30%因为输出也要占空间而且模型在接近满窗时注意力会明显衰减。这不是玄学是大量实测的统计规律。在这5.6K里再往下分系统提示词固定占200-400 token取决于业务复杂度对话态历史最多占2.5K超了就滚动丢弃最早的轮次检索态检索块最多占1.2K单条片段超过300 token的先截断任务态结构化状态最多占800 token超出就要精简字段。每个模式的预算上限就是上一节代码里token_budget的取值。这些值不是拍脑袋定的它们来自我当时逐步压测的经验把预算从大到小调整观察回答质量拐点。每个项目的拐点不一样但从保守值开始往下压比一开始就塞满再找问题要高效得多。4.2 高效截断按块优先级而不是按字符砍很多人截断上下文的方式是“从头开始裁”这是错的。对话上下文的重要性不是均匀分布一般来说用户最近的消息最重要系统自己的回复次之更早的历史重要性持续衰减。所以我用“分层丢弃”策略把消息按轮次分成多组从最旧的一组开始丢弃而不是按字符硬切。给大家一个具体的量化方法。假设保留最近8轮对话token预算2500那么def truncate_history(messages, budget_ratio[0.5, 0.3, 0.2]): 将历史分成三层 - 最近1-2轮权重0.5必须保留完整 - 中间3-5轮权重0.3超长时按句子截断 - 最旧6-8轮权重0.2必要时整体丢弃 if not messages: return [] recent messages[-2:] middle messages[-5:-2] old messages[-8:-5] budget 0 result [] for group, ratio in [(old, budget_ratio[2]), (middle, budget_ratio[1]), (recent, budget_ratio[0])]: group_budget 2500 * ratio # 组内追加到budget超限为止recent层级完整保留 for msg in group: msg_tokens estimate_tokens(msg) if budget msg_tokens 2500: break result.append(msg) budget msg_tokens return result这个分层比例不是固定的你可以按业务特点调整。比如金融客服最近一单recent的重要性可能占80%那就把比例改成[0.8, 0.15, 0.05]。关键是显式地分层而不是遇到超长就随便从头砍。4.3 检索态的预算分配宁可砍条数不要砍质量检索态的token预算有另一个原则上下文包里检索片段的数量和单条长度都要设上限先保证质量再保证数量。我给自己的标准是单条片段不超过250 token一个回复最多塞4-6条。为什么是4-6条因为实测中超过6条后模型开始出现“引用漂移”——它会把不同来源的信息混在一起编出原文里没有的内容。如果检索结果很多正确的做法是“重新排序之后只留前几条”而不是“把前N条都塞进去”。我通常先跑一次轻量的重排rerank根据query和候选片段的语义相似度排序然后只取top4。这样虽然检索阶段多花了几十毫秒但最终答案的准确性提升非常明显。4.4 任务态的预算回收完成任务后立刻清空任务态有一个很特殊的预算问题——回收。当任务进行到确认下单、完成退款等终点时任务状态对象不再是资产而是负债因为它会把模型“捆绑”在旧任务上。我的习惯是检测到任务的终止动作后立即将session.task_state置为None同时清空与之关联的临时缓存让系统自动回到对话态或检索态。这里要特别留意一个边界情况用户中途放弃任务。比如订机票订到一半用户说“算了不订了”。如果系统不主动识别这种放弃信号任务态会一直霸占上下文导致后续所有问题都被任务逻辑污染。我在框架里加了一个简单的“放弃信号”检测当用户消息里出现“算了”“不订了”“取消”“下次再说”等短语且当前是任务态时就自动结束任务并给出一句“好的已为您取消本次操作”。5. 模式切换时的状态同步缓存失效与历史标记框架搭起来、预算设好了还不是结束。真实生产环境里最折磨人的是模式切换过程中出现的数据不一致。我遇到过三类典型问题逐一拆解。5.1 检索缓存换向量还是换关键词缓存就得失效检索态的缓存策略和对话态完全不同。对话态一般都做“按对话ID缓存”同一场会话复用但检索态不行因为两个相邻问题的检索条件是独立的。我见过有人图省事给整个会话打了一个大缓存结果用户问完“退款政策”又问“发货政策”系统直接把前一个缓存喂给模型答非所问。我的做法是检索缓存的key必须包含“查询意图签名”。所谓签名就是对该查询做一次轻量化归一化——去掉停用词、统一同义词、抽出命名实体得到一个字符串。只要意图签名变了缓存必然失效。实现这个签名不需要大模型用分词规则就够了。加了签名之后检索态缓存的命中率没有下降误用率几乎降为零。5.2 历史标记明确告知模型“这一段已经过时”模式切换时旧上下文中可能有对当前无效的信息。比如用户在检索态查了“A型号手机价格”然后切到任务态想下单。如果任务态的上下文包里还带着“A型号价格3000”这种旧信息模型在下单确认时可能会混淆“3000”和实际支付金额。这里用到一个我在3.3节提过的机制上下文断点标记。具体实现是在构建新模式的上下文时先输出一行系统指令例如[上下文已更新] 之前的对话/检索结果与本任务无关请勿引用。仅使用下面的信息完成任务。别小看这句话。它相当于给模型的注意力画了一条清晰的“时间线”。我做过对比实验加与不加这行标记在连续多轮切换场景下的准确率差了将近15个百分点。原因是LLM本质上没有“时间概念”它把所有内容当做一个整体来理解你必须通过显式文字告诉它哪些内容已经被淘汰了。5.3 任务状态的并发写入串行化是保命底线任务态还有一个容易被忽略的坑并发写入。用户可能同时开两个窗口问同一个客服系统前端把消息并发发到后端两个请求同时读到同一个task_state各自修改最后写入时互相覆盖。这类问题平时不出现一出现就是线上事故级别。我的处理策略很简单一个会话的任务对象只允许串行修改。具体到实现可以在内存里给每个会话ID加一把分布式锁或者用Redis的原子操作对任务状态的每次读取-修改-写入都包在锁里。读操作可以并发写操作必须排队。如果消息是异步进来的就把后到的请求放入队列等前一个状态更新完成后再处理。这个设计看起来笨但在真实项目中极稳。后来我翻了一些开源项目发现它们处理这类问题的思路也都类似不是搞复杂的乐观锁而是简单粗暴地串行化毕竟任务状态的并发度通常极低串行带来的性能损失可以忽略不计。6. 真实踩坑记录context-mode上手后的三个典型翻车最后分享三个我在真实项目里踩过的坑。这三个坑都不是理论推演而是线上事故复盘得来的希望你看到的时候能少走几周弯路。6.1 翻车一关键词误判把闲聊当成知识查询最早版本的_needs_retrieval用关键词表我吃了个大亏。有一次用户问“你们系统怎么这么卡支持优化吗”关键词表里有“支持吗”于是系统判定为检索态去知识库检索“优化支持”相关内容结果答非所问用户很生气。复盘后我把判定逻辑改成了“必含规则排除规则”双保险触发检索态需要同时满足“包含任一检索关键词”且“不包含任一闲聊排除词”。排除词表里加了“怎么了”“为什么这么”这类表达情绪的词。后来又发现更稳妥的办法让业务方把高频问题模板喂进来用模糊匹配替代纯关键词。这个改动让检索态的误判率降了一大半。6.2 翻车二任务态状态对象和对话历史脱节另一个我吃了很久的亏是任务态下用户改口。用户先选了“明天上午10点的航班”下一句说“算了改成下午”。如果只更新任务状态对象而不处理对话历史模型就会看到两套冲突的信息它倾向于相信对话历史里的“明天上午”导致状态更新失效。解决这个问题我总结的经验是三条同时做修改task_state时同步在对话历史里标记旧值已被覆盖构建任务态上下文时输出一段“用户最新确认”摘要把最新值放在历史之前在系统提示词里额外写一句“以下信息以【用户最新确认】为准历史描述若冲突一律忽略。”这三条合起来才真正解决了“状态是新的、历史是旧的”这种不一致。光改状态对象不处理历史等于在提示词里埋雷。6.3 翻车三token预算设得太满长上下文场景全线崩溃我犯过最严重的一个错误是把4.1节那条“70%红线”当成耳旁风贪心地把预算设到窗口的95%。第一次出现是在一个长文档问答项目里用户上传一份50页的PDF系统要把全文压缩后和问题一起发给模型。结果模型输出开始胡言乱语引用完全不存在的章节。后来我做了一组对比实验同一批测试集分别在70%、80%、90%、95%的占用率下跑结果70%那组的准确率最高95%那组直接跌到接近随机。原因我猜是模型在满窗状态下注意力被海量上下文摊薄关键指令的权重被稀释。从那以后“70%红线”成了我所有项目的硬性标准。如果你也遇到“模型突然变笨”的情况先检查你的提示词占用率这比调任何prompt模板都见效。7. 我在反复重做context-mode之后的一些体会做到现在我越发觉得context-mode不是一个独立的模块而是一种思考方式先判断当前对话的“性质”再决定如何组织信息而不是把所有能拿到的信息一股脑塞进去。这套思路不仅适用于LLM应用。我后来在做日志分析、数据管道设计时也用同样的眼睛去看“哪些数据是当前决策真正需要的”效果同样很好。给正准备动手的朋友几个可落地的建议。第一先不要急着代码实现拿你现有的对话日志跑一遍标注把每一轮对话归类到对话态、检索态或任务态看看分布比例。这个比例直接决定了你应该把优化重心放在哪里。第二模式判定器不要一上来就上模型分类先做关键词规则字典跑起来之后再迭代。第三token预算的红线要写在配置里、写进代码注释里否则团队里总有人会忍不住把提示词塞满。最后再分享一个小技巧每次模式切换我都习惯把切换前后的上下文快照存一份日志。这样一旦线上出现问题可以直接复现“当时模型到底看到了什么”而不是靠猜。这套日志在排查问题时救了我很多次。如果你也在做对话系统我很好奇你的context-mode是怎么设计的——你用的判定信号是什么任务态和对话态之间是怎么转换的欢迎在评论区聊聊我们互相补补坑。