做 LLM 应用开发满一年后,我发现自己反复在处理同一个问题:模型说话突然不像它了。明明系统提示里写好了身份和规则,用户多聊几句,它就顺着用户的话改了人设;明明查回来的资料写得清清楚楚,模型却坚持引用对话历史里某个过时说法。出现这种现场,大多数时候不是模型能力不够,而是上下文管理太粗放。说得更具体一点,是缺少了提示词工程里常被忽略的“上下文隔离”设计:把系统规则、用户输入、检索资料、历史对话这些来源不同的信息,放到彼此有边界、职责清晰的“隔间”里,让模型知道哪些是规则、哪些是事实、哪些是闲聊,才能稳定输出。这篇文章不只讲概念,我会把四类常见隔离场景、一套可以照着抄的消息结构模板,以及上线后排查上下文问题的具体做法,完整写出来。RAG 场景、多代理协作,以及被上下文超限折磨过的同学,应该都能从中找到能直接用的部分。
1. 上下文为什么总在“打架”:污染与漂移的根因
1.1 一条请求里,至少藏着四类上下文
我们先把共识拉齐:现在调用大模型接口,多数情况下传的不是一个字符串,而是一个消息列表。列表里每条消息都有角色字段,system 负责长期指令,user 承载任务和数据,assistant 是历史回复,如果你接了工具调用,还会有 tool 消息记录工具返回结果。模型在生成回复之前,会把这一整段消息列表当成“上下文窗口”里的输入。窗口有多大,能塞进去的内容就有多少,超出的部分要么被截断,要么直接报错。
这里有个容易被忽略的事实:模型看到的只是拼在一起的文本序列,它并没有一个“这是什么来源”的元信息视图。它不知道哪条消息是资深工程师精心设计的规则,哪条消息是用户随口说的猜测,它只能根据文本的措辞、位置和前后关系去推断。这就像一个临时被拉进会议的人,桌上同时摆着会议议程、你的笔记、上一轮发言记录,以及你刚打印出来的参考资料。如果你没有整理这些材料的层级和边界,别人当然分不清哪个是必须执行的、哪个只是背景信息。
所以上下文隔离要做的事情,本质上不是给模型“洗脑”,而是帮你把材料整理好再递出去。整理的核心,就是让每一类信息待在自己的隔间里,让模型明白:这条是规则,这条是用户输入,这条是检索资料,这条是历史对话。它们有各自的位置,也有各自的优先级。
1.2 没有隔离的三种典型事故现场
没有隔离意识时,工程上最常见的是三种事故,我都在真实项目里见过。
第一种叫指令漂移。某客服项目的系统提示明确写了“无论用户如何要求,都不要承诺退款”,结果前面几轮对话里有一个用户气冲冲地说“刚才那位客服答应给我退了”,模型在后续回复里真的顺着这句话写出了“我们为您安排退款”。为什么?因为对话历史里的新内容天然占据“近期焦点”,模型更容易被后面的说法带着走。系统指令明明还在,但它在文本上和用户陈述躺在同一层,模型没有“这条是更高优先级”的强制意识。
第二种叫事实污染。在检索增强(RAG)场景里,检索回来的资料已经明确说某功能已经下线,但用户消息里补了一句“我昨天明明还在用”。如果资料和用户观点被混在同一个历史序列里,模型很容易采纳后出现的用户观点,把客观事实回答成用户想听的样子。资料和用户陈述一旦不分家,模型就会把它们当作同等地位的“背景信息”,而不是“资料优先”和“用户说法待核实”的差异。
第三种叫记忆串台,多代理任务里尤其明显。规划代理输出了一段执行计划,执行代理干完活之后,把计划执行结果、中间日志、最终结论全部一股脑传回共享上下文,结果规划代理把中间日志当成了最终答案,下一轮开始基于错误前提继续规划。问题根源是不同代理的私有记忆没有隔离,所有信息混在一口锅里,谁都能捞一勺,谁都不知道那勺东西到底是自己的还是别人的。
这三种事故,对应同一个深层原因:注意力机制本身对所有输入 token 是加权混合的,文本之间没有天然边界。要靠工程手段把这锅粥重新分碗,才能从根本上缓解。
2. 四类最常见的隔离场景与切分策略
2.1 指令隔离:系统提示不能被对话内容“带偏”
指令隔离是基础中的基础,做法也很简单:系统消息里只放长期稳定、不应该被动态内容覆盖的东西,比如身份、规则、禁忌、输出格式;用户输入和任务数据一律放在 user 消息里。关键纪律是,不要把动态内容拼进系统提示。
有些同学为了省事,会写成类似“你是客服助手。用户的问题是:……,请你……”这种把任务塞进 system 的做法。短期能用,但隐患很大:一旦系统提示里出现过任务内容,模型就难以区分“哪些规则是永久的,哪些只是针对这一次提问的”。用户只要在前面加一句“其实我的真实需求是……”,模型就可能认为新需求覆盖了旧指令。
更稳妥的写法是把 system 保持纯净,只有规则;把任务单独放到 user 消息。这样切割之后,即便历史对话再长、用户再怎么绕,system 区块在消息列表最前面,承担“总纲”职责,后面的任何内容都会被模型以“受这个总纲约束”的方式去理解。你甚至可以把 system 当成一份不会变化的配置文件,用户消息只是这份配置的输入参数。
实操时我还有一个建议:系统提示中的占位符要少用。确实有些业务需要动态用户名、当前时间,这可以通过模板渲染实现,但请在渲染后仍然放在固定位置,不要让占位符替换之后改变整个 system 的结构。我见过有人把一大段用户数据直接替换进 system,结果就是系统提示彻底变成了“用户档案”,身份规则名存实亡。
2.2 会话隔离:全局规则与私有记忆的分层
指令隔离解决的是“一条请求内部的边界”,会话隔离要解决的是“不同会话之间的边界”。做过多人客服系统或者 C 端产品的同学应该深有体会:每个用户都有自己独立的会话历史,但产品本身又需要一些全局规则,比如合规红线、品牌语气、敏感词屏蔽。
我的处理方式是把它做成一个分层结构。最底层是平台全局规则,所有会话都必须遵守;往上一层是业务线配置,比如某个活动页需要更活泼的语气;再往上是用户级偏好,如果用户明确说过“请用简洁风格回复”,这条偏好只对当前用户生效;最后才是当前输入本身。组装消息时,全局规则进入 system,业务线配置和用户偏好合并成一段动态的上下文说明,放在任务的 user 消息之前,当前输入放在最后。
这样做的好处是,你永远不会把一个用户的偏好暴露给另一个用户。我踩过的坑是早期把所有信息都塞进 system,导致只要改一个用户的配置,就相当于改了所有人的 system,最后一位用户的人设覆盖了其他人的人设,事故现场惨不忍睹。把动态的、私有的内容从 system 挪到 user 侧消息区之后,这种跨会话污染才算真正止住。
另外提一句,多轮对话里比较推荐把“用户级上下文摘要”单独作为一个 user 消息,而不是把它和当前问题拼在同一个字符串里。摘要是历史沉淀,问题是当前意图,分开写模型更容易区分“背景”和“指令”,后续做 token 裁剪时也可以优先丢摘要。
2.3 资料隔离:RAG 检索内容不跟聊天记录混为一谈
RAG 场景是上下文隔离的重灾区。常见做法是把检索到的文本片段拼进对话历史,甚至有的直接追加到 user 消息后面,然后交给模型。这种做法的问题在于:模型会把“检索资料”当成“用户刚说的一句话”,同样地,用户后来再说一句和资料矛盾的话,模型也会一视同仁地采纳后者。
我的做法是给资料开辟独立的 user 消息区,放在任务输入附近,并且用定界标记把每份资料隔开。典型的结构是:
[检索资料 1] 这里放第一份文档摘录 [检索资料 2] 这里放第二份文档摘录 请参考上述资料回答我的问题。若资料与对话历史冲突,以资料为准。这样模型在生成时能明确感知到:这段内容不是用户说的,而是“参考资料”。在资料区结束处加一句“以资料为准”,是为了处理资料与用户观点冲突的场景。模型会把这个声明理解成一条内部指令,从而优先引用资料内容,而不是盲目听从用户。
除了隔离,还得管数量。一次塞 40 个检索片段的做法非常危险,不仅占用窗口,还会稀释模型的注意力。我通常控制在 2 到 5 个高相关片段,每个片段 256 到 512 token。宁可通过压缩、重排工具把片段提炼成一段完整答案,也不要让模型自己从一大团不相干文本里大海捞针。
资料内部的优先级也值得写清楚。如果资料本身存在矛盾,比如版本迭代导致新旧文档说法不同,我会在参考区开头加一句“资料冲突时,以日期最新的为准”。这句话看着不起眼,实际能把很多知识库不一致的问题提前解决掉。
2.4 代理隔离:多代理协作中“谁的记忆归谁”
多代理架构是上下文隔离的进阶考场。一个典型的编排场景里,有规划代理、执行代理、审查代理。如果它们的对话历史全部放在同一个上下文里,必然出现信息串扰。
我的建议是三个区:共享任务上下文、代理私有上下文、全局规则。共享任务上下文存放任务目标、当前阶段状态、需要传递给下一个代理的结论;代理私有上下文只放当前代理自己的输入、输出和历史;全局规则是所有人必须遵守的底线。组装各代理的消息列表时,只向当前代理暴露它需要的信息,而不是把所有代理的往来消息一股脑倒进去。
这里有一个关键转换:共享区里传递的不应该是原始流水,而应该是“处理后的摘要或结论”。规划代理负责定目标,执行代理执行完只需要把“完成状态 + 关键结果”写回共享区,而不是把几十行日志全部塞进去。审查代理只需要看到执行结果与目标之间的差异,也不需要看执行过程中的每个中间步骤。
这个设计不只是在保护上下文长度,更重要的是保护认知边界。每个代理只有自己的“记忆”和“黑板”,就不会出现 A 代理把 B 代理的中间过程当成自己的成果这种事。
3. 动手搭建一套隔离模板:代码级实现
3.1 先设计消息结构体,再把内容塞进去
理论讲再多,不如直接给一套能跑的模板。我习惯先从数据结构入手,把上下文的每个区块显式建模,而不是零散地拼字符串。
from dataclasses import dataclass, field from typing import List, Dict @dataclass class ContextBundle: system: str = "" task: str = "" references: List[str] = field(default_factory=list) history: List[Dict] = field(default_factory=list) def build_messages(bundle: ContextBundle, max_history: int = 8) -> List[Dict]: messages = [] # 1. 系统指令永远在最前,且只放规则 if bundle.system: messages.append({"role": "system", "content": bundle.system}) # 2. 检索资料独立成区,放在任务之前 if bundle.references: refs = [] for i, ref in enumerate(bundle.references, start=1): refs.append(f"[资料{i}]\n{ref}") ref_block = ( "以下是检索到的参考资料,仅作参考;" "回答时优先采用资料内容,并标注对应编号:\n" + "\n\n".join(refs) ) messages.append({"role": "user", "content": ref_block}) # 3. 当前任务输入,单独一条 user 消息 if bundle.task: messages.append({"role": "user", "content": bundle.task}) # 4. 历史对话放在最后,并且做窗口截断 trimmed = bundle.history[-max_history:] messages.extend(trimmed) return messages这个模板的核心设计有三个。第一个,系统指令恒定在最前,且只承载规则,不给动态信息留位置。第二个,资料区和任务区分开成两条 user 消息,模型能明显感知到“这是资料”和“这是问题”是两码事。第三个,历史对话永远放在最后,通过 max_history 控制轮数,这样即使用户已经聊了几十轮,早期内容也不会无限挤压规则和资料的空间。
我实际用下来发现,把 references 和 task 分成两条 user 消息,比拼在一条长 user 消息里更稳定。原因可能是模型在段落边界上更容易建立区块感,这就像你读一篇文章,一个段落负责一个意思,比一整块都讲好多主题更容易理解。
3.2 RAG 资料块的定界与注入顺序
定界标记我统一用方括号编号,比如[资料1]、[资料2]。也有团队喜欢用 XML 风格标签,比如<reference>,实测也能用,但要注意标签闭合写错会让模型困惑。方括号编号的好处是简洁、直观、不容易嵌套出错,模型一眼能看出结构。
注入顺序上,我建议默认把资料放在任务之前。理由很简单:模型在生成时,对靠近任务的上下文会更敏感。如果你把资料放在问题之后,在某些模型上效果也不错,但“资料在前、任务紧随其后”这种组合最不容易让模型跳过资料。这一步非常值得做 A/B 测试,因为不同模型对顺序的敏感度真的不一样。
关于资料块本身,要控制每块的长度,并且保证编号和内容能对上。检索系统经常返回“长文截断”,如果截断不做好,模型引用[资料3],但[资料3]里只有半句话,就很容易引发幻觉。我会在切分阶段就按段落切,避免一句话被拦腰截成两段。
还有一个小技巧:当资料数量较多时,在参考区末尾加一句“请优先使用编号靠前的资料”。这句话配合相关性排序,能让模型聚焦在最优质的信息上,而不是盯着后面几段低相关的文本发挥。
3.3 窗口预算分配:给每种上下文限流
上下文窗口是有限资源。很多人只关心它有多大,不关心怎么分配,结果就是某个区块悄悄吃掉全部空间,别的区块被挤到没地方。
我常用的默认预算分配是这样的:
| 区块 | 建议占比 | 超出时的处理策略 |
|---|---|---|
| 系统指令 | 约 10% | 不裁剪,只做模板压缩 |
| 检索资料 | 约 30% | 丢弃低相关片段或做摘要 |
| 任务输入 | 约 20% | 压缩表达,必要时截断 |
| 历史对话 | 约 30% | 滑动窗口 + 摘要 |
| 输出预留 | 约 10% | 用 max_tokens 强制限制 |
这张表不是死的,但它给了团队一个共识:每个区块都有配额,超出的部分按优先级裁剪。裁剪顺序默认是:历史对话 > 检索资料 > 任务输入 > 系统指令。系统指令永远不动,因为它是稳定性的底座。
有人会问,为什么历史对话要排在第一个被丢弃?因为历史对话是“锦上添花”的信息,丢了最多让模型不记得细节,而丢了系统指令或任务输入,整个回答方向都会跑偏。基于这个原则,我才把“历史对话优先裁”写进所有项目的代码里。
估算 token 的时候,可以用 tokenizer 精确统计,也可以先按字符数粗略估算:中文场景大约 1 个汉字接近或略多于 1 个 token,英文场景大约 4 个字符 1 个 token。产品上线前建议用官方 tokenizer 校准,避免上线后出现“明明没超,怎么请求失败”的尴尬事。
3.4 滑动窗口与摘要衰减的后备方案
窗口预算分配好了,但对话永远在增长,迟早会碰到“历史太长”的问题。最粗暴的方案是砍掉前半部分,但这会让模型彻底失忆。稍微好一点的做法是滑动窗口加摘要衰减。
我在实践中推荐这样一个流程:当历史超过阈值时,把最老的一半拿出来,交给模型生成一段摘要,然后删掉原文,把摘要作为一条新的 user 消息放在历史区。下一次再超时,继续对更早的摘要和较新的对话做融合压缩。
def compress_history(history: List[Dict], limit: int) -> List[Dict]: if estimate_tokens(history) <= limit: return history old_part = history[: len(history) // 2] new_part = history[len(history) // 2:] summary = summarize_dialogue(old_part) # 调用模型生成摘要 return [ {"role": "user", "content": f"以下是更早对话的摘要:{summary}"} ] + new_part这里最需要警惕的是:摘要本身会丢失信息。如果旧对话里有特别重要的用户偏好或约定,而你又只靠摘要,模型很容易忘记。所以我一直强调,真正重要的长期约束必须提前写进系统指令,旧内容只是“过程记录”,丢掉摘要不会伤筋动骨。如果某种约束只出现在对话历史里,而没有沉淀到 system,那它本身就是工程缺陷。
4. 上线后最容易踩的坑与排查技巧
4.1 五个高发症状,先对着这张表自查
我在诊断过不少团队的问题之后,把典型症状整理成了下面的速查表:
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| 模型回答越来越“失忆”,开头规则到第二轮就不遵守 | 系统指令被后续对话压制,或历史过长把规则挤出窗口 | 检查消息顺序,统计各区块 token 占比 |
| 用户聊几句,模型身份就被人设带偏 | 用户输入没有与指令隔离,动态内容直接污染了 system | 改用纯净 system,用户内容单独放 user 消息 |
| RAG 回答不按检索资料来,反而信用户随口说的话 | 资料区和对话历史混排,模型分不清哪个优先 | 资料独立成区,写明“资料与历史冲突时以资料为准” |
| 多代理任务中,规划代理把执行代理的中间日志当成最终结论 | 共享了完整流水,没有做摘要隔离 | 共享区只传结论和状态,不传原始日志 |
| 请求频繁报上下文超限 | 某个区块无限增长,缺少预算管理和裁剪 | 实现滑动窗口、摘要衰减,给各区块设配额 |
出现问题时,不要第一时间怀疑模型不行。我踩过太多次坑了,最后发现重建同样的输入,换一个模型照样出错,问题几乎都在上下文结构上。
4.2 给上下文做一次“CT”:结构化调试法
排查上下文问题,需要一个可重复的流程。我的做法分四步。
第一步,开启请求日志,把发送给模型的完整消息列表原样打印出来,同时统计每个区块的 token 数。不要只打 prompt 字符串,要按 role 一条一条打,这样才看得出哪条消息位置不对、是否内容串台。
第二步,做减法。先只保留 system 加当前任务,看问题是否消失。如果消失了,说明问题来自某个附加区块;如果没有消失,说明问题出在 system 或任务本身。
第三步,逐步加回。先加参考资料,看问题是否复现;复现了就说明是资料区的定界或优先级没写对;没复现就继续加历史对话,通常加着加着就复现了。这种“二分法”找污染源,比盯着整个对话猜要快得多。
第四步,检查格式层。有时候问题出在 Markdown 分隔符被渲染或吞掉,模型根本没看到边界标记。这种情况可以把分隔符换成更简单的方括号编号,或者改成单独一条 user 消息来承载区块。
我还有一个调试小习惯:在日志里把每条消息的前 30 个字打出来,形成一列“摘要式消息列表”。一眼扫过去,哪条消息出现在不该出现的位置,基本十个字以内就能发现。
4.3 几个值得长期坚持的实践习惯
排查过一轮之后,我沉淀了几个习惯,现在写进团队规范里。
第一个,prompt 模板和代码分离。不要在业务代码里直接拼接字符串,尤其是 system 内容。我会把它们放到模板文件或配置中心,版本化保存。这样改了提示词不会误伤代码,回头排查也方便对比版本差异。
第二个,保持系统提示纯净。系统提示里允许出现的变量越少越好。昵称、当前时间、临时规则这类动态内容,统统放到 user 侧消息区。系统提示一旦被“污染”,后面无论怎么调别的参数都很难救回来。
第三个,为每类上下文设计明确标签。比如[用户输入]、[检索资料]、[系统摘要]。这些标签在 prompt 里能帮模型建立区块感,在日志里也能帮你快速定位问题。它们看起来只是几个字,实际上是给模型和工程师共用的“坐标轴”。
第四个,每次改完提示词,先跑一遍最小回归集。哪怕就是五六个用例,也能把指令漂移、资料冲突这类高频问题在发布前拦下来。这个习惯带来的安全感和省下的排查时间,难以用语言形容。
5. 进阶:隔离之后,还有继承与覆盖
5.1 隔离不等于全隔离:继承规则的三种写法
讲了这么多隔离,但“全隔离”并不总是最优解。业务上经常需要合理的覆盖:用户喜欢简洁回复,你总不能因为系统规则里说“要热情详细”就永远不给用户想要的简洁。
继承规则我常用三种写法。第一种是模板变量:在业务系统提示中留出占位符,由上层配置填充,比如“请用{风格}的语气回复”。第二种是分层拼接:全局 system 负责红线,业务线 system 负责细化,用户输入负责当前任务,三层各司其职,后一层可以补充但不可以推翻前一层。第三种是覆盖白名单:显式定义哪些字段允许被用户级覆盖,哪些字段是红线字段,任何层级都不允许改。
这里要特别注意覆盖的显式性。最怕的是上层在不知情的情况下覆盖了下层规则。比如全局规则说“不要承诺退款”,但某个业务线系统提示里写“只要客户满意可以适当补偿”,这就会直接击穿红线。我的建议是,红线字段在代码层做校验:组装消息之前,检查低优先级的配置里是否包含与红线模板冲突的词,有就直接拒绝上线。
5.2 用一个配置矩阵管理不同业务的隔离强度
不是所有业务都需要同样强度的隔离。创意写作场景甚至需要模型“记住”大量历史素材和用户风格,这时候把历史和资料全部隔离出去反而会把体验做坏。所以我会用一个配置矩阵来管理不同业务的策略。
| 业务场景 | 隔离重点 | 参考区大小 | 历史轮数 | 系统提示是否可被覆盖 |
|---|---|---|---|---|
| 客服机器人 | 强隔离:规则固定,动态输入弱信任 | 2-5 段 | 6-8 轮 | 不允许覆盖 |
| 文档摘要 | 中隔离:资料独立,规则可扩展 | 按文档块 | 可保留较多 | 可补充不可推翻 |
| 代码生成 | 中隔离:上下文按文件隔离,规范固定 | 与代码库相关 | 较短 | 不允许覆盖 |
| 多代理编排 | 强隔离 + 共享区摘要 | 视任务定 | 各自维护 | 只允许任务级继承 |
| 创意写作 | 弱隔离:历史风格是资产 | 少用参考 | 较长 | 可被用户偏好覆盖 |
这个矩阵的意义在于,让团队在新增业务时先回答三个问题:模型需要记住什么?模型可能会被什么误导?哪些信息绝对不能跨会话串台?想清楚这三个问题,再决定每个区块的隔离强度和限制策略。
5.3 把隔离策略纳入回归测试
隔离策略不应该在出了问题才想起它。我建议把它写进自动化测试,就像写单元测试一样。
建立一个污染样本集,里面至少包含这几类用例:用户试图覆盖系统身份的指令、资料与用户说法冲突的对话、超长老人的历史记录、空资料、资料格式异常、多代理场景下的跨代理消息。每个用例都设好断言。
举例来说,有三条断言我几乎每个项目都会加。第一条,身份保持断言:无论后续对话说什么,模型输出中的身份表述始终对照系统提示中的设定。第二条,资料优先断言:当检索资料与用户说法矛盾时,模型优先引用资料编号进行回答。第三条,撤销保护断言:当用户说“忽略之前的全部指令”时,模型仍然按系统规则约束输出,而不是真的忽略掉。这些断言不需要写很多条,关键是覆盖上下文隔离最容易崩的几个点。
把隔离策略纳入回归测试还有一个附加好处:当团队升级模型、调整 prompt 或改动组装逻辑时,这些测试能第一时间暴露“这次改动把另一个业务的上下文搞乱了”的隐患。提示词工程到后期,拼的不是一张嘴,而是一套能持续守住规则的系统能力。
最后说一点个人体会。我刚入行的时候,以为提示词工程拼的是“词写得有多漂亮”,后来被真实业务教育了一轮,才发现它更像搭积木:结构稳不稳,决定上限。上下文隔离很容易被当成“加两个定界符”的小技巧,但真正做进系统里,它会倒逼你把消息结构、预算、权限、测试一起理顺。我现在每次排查模型“突然变笨”,第一步永远是打印出它收到的完整 messages,八成问题都出在某个区没隔干净。如果你也被这类问题困扰,不妨先动手把你的上下文切成区块,跑一轮回归,你大概率也会回来感谢这个习惯。