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_factory=dict)接下来是上下文构建器。每个构建器都是一个独立的函数,输入会话状态,输出ContextPackage。这里最关键的一点是:每种模式的system_prompt不同,因为对话态、检索态和任务态对模型的角色指令完全不一样。
def build_chat_context(session: Any) -> ContextPackage: recent_msgs = session.messages[-8:] # 最近8轮对话 system_prompt = ( "你是一个友好的智能助手。" "请基于最近对话内容自然回应,不要提及内部规则。" ) return ContextPackage( mode=ContextMode.CHAT, system_prompt=system_prompt, context_blocks=[m.to_plain_text() for m in recent_msgs], token_budget=2500, meta={"history_rounds": len(recent_msgs)} ) def build_retrieval_context(session: Any, retriever: Any) -> ContextPackage: query = session.current_query() docs = retriever.retrieve(query, top_k=4) # 只取4条高精度片段 system_prompt = ( "你是一个基于资料回答问题的助手。" "只能使用【参考资料】中的信息回答,不要编造。" ) context_blocks = [] for i, doc in enumerate(docs, 1): context_blocks.append(f"【参考{i}】\n{doc.text}") return ContextPackage( mode=ContextMode.RETRIEVAL, system_prompt=system_prompt, context_blocks=context_blocks, token_budget=1200, 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( mode=ContextMode.TASK, system_prompt=system_prompt, context_blocks=[f"【当前任务状态】\n{state_json}"], token_budget=800, meta={"task_stage": task_state.stage if task_state else "none"} )3.2 模式判定器:先判模式,再组上下文
整个框架的灵魂不是构建器,而是模式判定器。判定器决定当前该用哪个构建器。我在实践里摸索出一套比较稳的判定逻辑,按优先级排列:
class ModeController: def __init__(self, retriever=None): 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是怎么设计的——你用的判定信号是什么?任务态和对话态之间是怎么转换的?欢迎在评论区聊聊,我们互相补补坑。