1. 先搞清楚:context-mode 到底在解决什么问题
1.1 从一次糟糕的对话说起
我先讲一个几乎每个做 LLM 应用的人都会遇到的场景。你搭了一个 AI 客服机器人,用户和它聊了二十轮,前面几轮用户明确说过"我的预算是 5000 块以内",结果聊到第十五轮,模型开始推荐 8000 块的产品。你回头看日志,发现那轮消息里用户的预算信息早就被挤出了上下文窗口。用户骂机器人傻,你查代码发现逻辑没错,问题出在你根本没说清楚"该保留什么、该丢什么"。
这就是 context-mode 要解决的核心问题。所谓 context-mode,直白讲就是上下文管理模式——一套决定"哪些信息能进入模型视野、哪些信息必须让位、以及以什么顺序呈现"的机制。它不是一个具体的库,也不是某个框架的专有功能,而是所有 LLM 应用在工程化时都必须面对的通用命题。
我最早接触这个概念是在做对话系统的时候。当时被 token 限制逼得没办法,只能手动截断历史消息,结果截出来的效果时好时坏,后来才意识到,截断只是 context-mode 最粗糙的一种形态。真正成熟的上下文模式,需要考虑信息的重要性分级、时效性、甚至是对话任务当前所处的阶段。
1.2 为什么会话一长就"失忆"
要理解上下文模式,先得明白底层模型的工作方式。Transformer 架构的模型在处理输入时,会把所有 token 一次性喂进去,注意力机制理论上可以关注到任意位置的 token,但实际效果远非理想。经验数据表明,模型对上下文中间部分的内容关注度明显低于开头和结尾,这个现象在圈内叫"lost in the middle"。
举个例子,一段 4000 token 的对话历史,如果你把关键信息放在正中间的位置,模型漏掉它的概率比放在开头或结尾要高得多。这意味着上下文管理不只是"塞得下"的问题,还涉及信息摆放位置的问题。
另一个现实约束是成本。以常见的商用模型为例,输入 token 的价格通常是输出的四分之一到三分之一,但对话一旦拉长,历史消息反复重新传入,输入 token 会随轮次线性膨胀。一个聊了五十轮的对话,可能每轮要传几万 token 的历史,成本就从"可以忽略"变成了"需要盯着账单"。
这两条——注意力衰减和成本膨胀——才是 context-mode 存在的根本原因。它本质上是在有限资源下做信息取舍,追求的是"用最少的 token 保留最多的决策价值"。
1.3 上下文模式背后的三个基本问题
我做了几年 LLM 应用后回头总结,发现一切上下文模式的设计,归根到底都在回答三个问题。
第一个问题是该保留什么。对话历史、检索到的文档、用户画像、工具返回的结果,这些都是候选信息。全部塞进去不行,必须按与当前意图的相关性排序。
第二个问题是该以什么形态保留。原始文本、抽取后的摘要、结构化的 JSON 字段、向量化的语义表示,不同形态的信息密度和还原度完全不一样。摘要省 token 但有损,原文无损但耗 token,结构化信息最省但依赖抽取质量。
第三个问题是该在什么时候更新。是每轮对话都完整重建上下文,还是增量追加?是用户提问后立即触发检索,还是预先缓存?这个问题的答案决定了系统的实时性和资源消耗。
后续要讲的滑动窗口、摘要压缩、检索增强、结构化上下文,本质上都是对这三个问题的不同解答组合。没有绝对的最优方案,只有基于你的业务场景做取舍。
2. 解剖四种主流上下文模式:思路、适用场景与局限
2.1 滑动窗口模式:最简单,也最容易"断片"
滑动窗口是最直觉的方案:维护一个固定长度的窗口,新消息进来,旧消息出去,像一列火车车厢,先进先出。实现上就是维护一个 deque,超过最大长度就弹出最老的元素。
这种模式的优点显而易见。实现成本极低,不需要额外的模型调用,没有延迟开销,逻辑清晰容易调试。我见过不少早期项目就是用这种方式起步的——毕竟它确实能让对话"基本能用"。
但它的缺点同样致命。滑动窗口不考虑信息重要性,一律按时间线淘汰。用户五轮前提的预算约束、两天前确认的日期安排,这些关键约束型信息和"好的""嗯嗯"这类寒暄,在滑动窗口面前地位平等。一旦关键信息被挤出窗口,模型就会表现得像失忆了一样。
在实际业务中,滑动窗口通常和"系统提示词"配合使用。把最关键的约束条件固化在 system prompt 里,不让它参与窗口滚动,对话历史才用滑动窗口管理。这个组合拳简单有效,适合短期记忆足够用的场景,比如单人客服、简单问答机器人。
2.2 摘要压缩模式:让信息"越滚越精炼"
摘要压缩是对滑动窗口的升级。核心思路是:当历史即将超出窗口时,调用模型对早期对话生成一段摘要,用摘要替换原文,腾出空间。
举个例子,假设窗口上限 6000 token。前 30 轮对话已经占了 5500 token,第 31 轮进来就会超。这时把前 15 轮交给模型,生成一段 300 token 的总结:"用户咨询了退款流程,已确认订单号 A123,客服承诺 3 个工作日内原路退回",然后把这段摘要放回上下文,原始对话全部丢弃,腾出约 2500 token 的空间。
这个模式的关键在于摘要的更新策略。我踩过的坑是"每轮都摘要",那是灾难——每轮调用模型生成摘要,既慢又贵。更合理的做法是设置触发条件:对话轮次达到阈值、上下文占用超过比例(比如 70%)、或者检测到话题切换。等触发再压缩,成本可控得多。
摘要压缩还有一个衍生技巧叫"递归摘要"。对话特别长时,摘要本身也会累积,于是对摘要再生成摘要,形成金字塔结构。这样最长对话可以无限延伸,模型始终有全局脉络感。缺点是摘要层级多的时候,细节丢失严重——二十层摘要下来,最初的用户意图可能已经完全变形。
2.3 检索增强模式:按需召回,不再全量携带
检索增强的思路和前面两种完全不同。它不再追求"保留所有历史",而是默认历史消息全部存放在外部存储里,每轮只把"与当前问题最相关的片段"拉回上下文。
具体做法是:每轮用户提问后,把新问题作为查询,对历史消息做向量相似度检索或关键词检索,召回 Top-K 条最相关的历史片段,和当前问题拼在一起发给模型。
这种模式的最大优势是上下文预算不再随对话轮次增长。无论用户聊了 100 轮还是 1000 轮,检索出的片段数量是恒定的,token 消耗稳定。信息检索的质量取决于嵌入模型的效果和切片策略。我的经验是,对话历史的切片不能像文档检索那样按固定长度切,最好按"轮次"切,或者按"用户意图单元"切——一次完整的问答闭环作为一个切片,相关性更准。
但检索模式有它的问题。如果某条关键信息在历史里只是顺带一提,没有独立成片,检索召回率就可能把它漏掉。还有就是依赖的组件变多了——向量库、嵌入模型、排序策略,任何一个环节抖动都会影响最终效果。这在工程上意味着更多的运维成本。
2.4 结构化上下文模式:把关键信息抽成字段
结构化上下文是另一种思路。它不维护对话历史的自然语言文本,而是维护一组结构化的"状态字段"。如图:
context_state = { "user_intent": "申请退款", "order_id": "A123", "budget_limit": 5000, "confirmed": False, "steps_completed": [] }每轮对话结束后,系统调用模型从最新对话中抽取结构化信息,更新这些字段。下一轮构造 prompt 时,把这份结构化描述放在最前面,然后再拼接最近的几轮对话原文。
这个模式在业务型对话里非常实用。比如售后客服、预约系统、购物导购,这些场景的本质就是"状态逐步填完、条件逐步明确"。用结构化字段表示状态,信息密度极高——10 个字段就能完整表达一个复杂会话的状态,而对应的自然语言历史可能需要几千 token。
结构化上下文的难点在于抽取可靠性。模型抽取字段是否准确、字段设计是否完备、遇到信息冲突时怎么处理,这些都需要仔细设计。我的经验是字段要加置信度标记,低置信度的字段宁可留空也不要硬填,否则错误信息会持续污染后续对话。
2.5 四种模式横向对比:怎么选
| 对比维度 | 滑动窗口 | 摘要压缩 | 检索增强 | 结构化上下文 |
|---|---|---|---|---|
| 实现难度 | 低 | 中 | 高 | 中高 |
| Token 成本 | 随轮次线性增长 | 压缩后可控 | 稳定 | 极低 |
| 信息保真度 | 最老的信息丢失 | 有损但保留脉络 | 依赖检索命中率 | 依赖抽取质量 |
| 延迟开销 | 无 | 触发时有额外调用 | 每次检索有耗时 | 每轮有抽取调用 |
| 典型场景 | 简单问答 | 长对话叙事 | 知识库问答 | 业务表单类对话 |
| 核心风险 | 关键信息丢失 | 摘要失真 | 召回遗漏 | 字段抽取错误 |
选型的核心逻辑只有一个:看你的对话里,什么信息最不能被丢。如果是用户明确说过的预算、时间、地址,那结构化和摘要优先;如果是文档型知识问答,检索增强是正解;如果只是闲聊或简单咨询,滑动窗口加系统提示词就够用。没有银弹,但组合用可以覆盖绝大多数场景。
3. 实操:从零构建一个混合上下文管理器
3.1 架构设计与模块划分
接下来进入正题。我用 Python 从零实现一个可用的 context-mode 管理器。为了让方案具有实战价值,我采用混合策略:滑动窗口兜底 + 结构化字段保关键 + 摘要压缩兜住超长对话。
整体架构分四层:
- 入口层:接收用户消息,触发上下文更新流程
- 状态层:维护结构化上下文字段,每轮更新
- 历史层:管理原始对话历史,含滑动窗口和摘要归档
- 组装层:按优先级把结构化字段、摘要、近期对话拼成最终 prompt
选定这种组合的原因是:结构化字段负责"绝对不能丢"的约束型信息,滑动窗口负责"近期对话"的自然连贯性,摘要负责"远期脉络"的粗粒度记忆。三层各司其职,token 消耗比单纯滑动窗口低,信息保真比单纯摘要高。
3.2 核心代码实现
先定义数据结构:
from collections import deque from dataclasses import dataclass, field from typing import Any, Callable, Optional import json import time @dataclass class Message: role: str # "user" 或 "assistant" content: str timestamp: float = field(default_factory=time.time) msg_id: str = "" @dataclass class ContextState: """结构化上下文,保存关键业务字段""" fields: dict = field(default_factory=dict) updated_at: float = field(default_factory=time.time) def update(self, new_fields: dict): self.fields.update(new_fields) self.updated_at = time.time() def to_prompt_block(self) -> str: if not self.fields: return "" lines = [f"{k}: {v}" for k, v in self.fields.items()] return "【关键上下文】\n" + "\n".join(lines)然后是核心的 ContextManager 类:
class ContextManager: def __init__( self, window_size: int = 10, # 滑动窗口保留的最近对话轮数 summary_threshold: int = 20, # 超过多少轮触发摘要 max_context_tokens: int = 6000, # 组装层 token 预算 tokenizer: Optional[Callable[[str], int]] = None, summarizer: Optional[Callable[[list[Message]], str]] = None, field_extractor: Optional[Callable[[list[Message]], dict]] = None, ): self.window_size = window_size self.summary_threshold = summary_threshold self.max_context_tokens = max_context_tokens self.tokenizer = tokenizer or (lambda s: len(s) // 2) # 粗略中文 token 估算 self.summarizer = summarizer self.field_extractor = field_extractor self.history: deque[Message] = deque() self.summaries: list[tuple[str, float]] = [] # (摘要文本, 生成时间) self.state = ContextState() self._summary_buffer: list[Message] = [] # 待摘要的早期消息 def add_message(self, role: str, content: str) -> None: msg = Message(role=role, content=content) self.history.append(msg) self._summary_buffer.append(msg) # 超出滑动窗口,把最老的挪进待摘要区(已在 buffer 里) if len(self.history) > self.window_size: self.history.popleft() # 触发摘要:待摘要区达到阈值或历史总长超限 if len(self._summary_buffer) >= self.summary_threshold: self._compress() def _compress(self) -> None: if not self.summarizer or not self._summary_buffer: return old_messages = list(self._summary_buffer) summary = self.summarizer(old_messages) self.summaries.append((summary, time.time())) # 对窗口内的消息做加权,避免窗口内也被重复摘要 self._summary_buffer.clear()组装最终 prompt 的逻辑:
def build_prompt(self, new_user_msg: str) -> str: parts = [] # 第一优先级:结构化字段 state_block = self.state.to_prompt_block() if state_block: parts.append(state_block) # 第二优先级:远期摘要(拼接,限制总长) summary_block = self._build_summary_block() if summary_block: parts.append(summary_block) # 第三优先级:近期对话原文 recent_block = self._build_recent_block() if recent_block: parts.append(recent_block) # 当前请求 parts.append(f"用户当前问题:{new_user_msg}") # 统一截断到预算上限 return self._truncate_to_budget("\n\n".join(parts))3.3 关键参数怎么定
这里的参数选择直接决定了系统表现,我逐个说实测的经验值。
window_size(滑动窗口轮数):我习惯设为 6 到 10 轮。少于 6 轮,对话连贯性明显下降,模型经常接不上话;多于 12 轮,token 消耗增大但效果提升有限。具体还要看单轮平均长度——如果单轮就很长,窗口可以设小一点。
summary_threshold(摘要触发轮数):这个值要配合 window_size。我通常设为 window_size 的 2 倍,比如窗口 8 轮、阈值 16 轮。这样窗口内的近期对话始终是全文,最早被挤出窗口的消息积攒到 16 轮后一次性摘要,避免频繁触发。
max_context_tokens(组装预算):先查你用的模型上下文上限,然后留出 20% 的安全余量。剩下 80% 再往下分:结构化字段固定占用约 10%,摘要块约 30%,近期对话约 50%,当前问题约 10%。这个比例是我调过的比较稳的分配方案。
token 估算可以这样算:
def _truncate_to_budget(self, text: str) -> str: target = self.max_context_tokens if self.tokenizer(text) <= target: return text # 从后往前截断,优先保开头(结构化字段)和结尾(当前问题) # 实际实现按 token 边界截断,这里简化为字符截断 ratio = target / self.tokenizer(text) cut_len = int(len(text) * ratio) return text[:cut_len]注意截断策略要保住首尾。开头是结构化字段和摘要,不能丢;结尾是当前问题,也不能丢。真正可以牺牲的是中间部分的近期对话——这又回到了开头讲的"lost in the middle",模型本来对中间内容关注度就低,被截掉影响最小。
3.4 与 LLM 接口对接的实操细节
组装好的 prompt 字符串直接填进模型的 user 消息就行。但有几个细节值得注意。
第一,结构化字段要防止模型"过度采信"。字段可能过期或抽取错误,建议在组装时加一句提示:"以上关键信息可能有延迟,请结合最新对话判断"。我吃过亏,模型看到字段里写了"order_id: A123",即使后面用户说"我记错了其实是 B456",模型仍然自信地按 A123 走。
第二,摘要块的时效性标记。每条摘要生成时带上时间戳,组装时按时间顺序排列。模型需要理解"先发生的事在摘要前面"。我还会在摘要块开头加"【历史摘要】"字样,与近期对话的"【近期对话】"区分,帮助模型识别信息层级。
第三,注入顺序的影响。实测下来,结构化字段放最前面效果最好,它像一份"会议纪要"一样给出全局信息;摘要其次;近期对话放最后,贴近当前问题。这个顺序在不同模型上表现稳定,如果你用开源模型,建议自己 A/B 测一下。
4. 踩坑实录:上线后遇到的五个高频问题
4.1 结构化字段抽取错误污染后续对话
这是我在售后场景中踩得最深的坑。上线初期,字段抽取模型偶尔把"用户要求退款"错抽成"用户要求换货",结果后续所有回答都围绕换货展开,用户被气到直接投诉。排查时才发现是抽取 prompt 设计得太弱,没有让模型区分"用户陈述"和"模型推断"。
解决方案分两层。第一,抽取 prompt 里强制要求模型只提取"用户明确表达的客观事实",不要推断意图,意图识别单独作为一个字段处理。第二,加置信度阈值,低于 0.7 的字段不写进结构化上下文,保持为空让模型下次重新判断。实测把抽取错误率降低了六成以上。
4.2 摘要丢失关键否定信息
摘要压缩模式有个隐蔽的坑:模型生成摘要时喜欢总结"用户说了什么",却经常丢"用户否定了什么"。比如用户说"我不需要海外发货",摘要可能只写"用户讨论了发货方式","不需要海外发货"这个否定信息就没了,模型后面又开始推荐海外物流方案。
我给摘要 prompt 加了一条强制要求:"必须单独列出用户明确否定或排除的内容"。并且摘要格式分两段,一段写确认的事项,一段写否定的事项。这个改动简单粗暴但极其有效。如果你做摘要压缩,务必把这条加上。
4.3 业务字段和对话历史冲突时听谁的
这是结构化上下文和对话历史最经典的冲突。用户先说了"预算 5000",字段里记了 5000,对话进行到后面用户改口"算了,预算提高到 8000 吧"。如果字段更新不及时,或者更新了但模型没注意到新对话,就会按旧字段走。
我的处理方式是在每次对话更新后,比较新对话和已有字段的差异。发现有矛盾时,把矛盾本身作为一条摘要记入摘要块,例如"用户预算从 5000 改为 8000(第 12 轮确认)"。这样结构字段虽然还是 5000,但摘要块里有明确的变更记录,组装 prompt 时字段在前、矛盾记录在后,模型会以最新信息为准。
4.4 长对话延迟问题出在组装而不是模型
有些场景下,用户聊得非常长,几百轮那种。滑动窗口和摘要都没问题,但用户每发一条消息,系统都要重新组装 prompt,如果摘要块里塞了十几条历史摘要,组装时每个摘要重新拼接、反复截断,耗时随摘要数量线性增长,性能和模型调用一样成了瓶颈。
优化办法是给摘要块加"摘要的摘要"机制。当摘要数量超过比如 8 条时,对旧摘要再做一次聚合,形成二级摘要。这样摘要块永远不超过 8 条二级摘要 + 1 条一级摘要,组装耗时稳定。代价是最早的对话细节被聚合得更模糊了,但相比延迟危机,这笔交易是划算的。
4.5 token 预算分配导致"头重脚轻"
我调试时遇到过一种很微妙的现象:模型答非所问,不是上下文里没有答案,而是答案被埋在中间。后来发现是 token 预算分配失衡。早期把 80% 的预算都给了摘要和字段,近期对话被挤压得只剩两三条,模型的短期连贯性完全丧失。
调整后的分配原则是:近期对话优先。因为大部分问题的答案都藏在最近几轮里,摘要和字段只提供背景,占比不要超过 40%。我现在的默认分配是近期对话 55%、摘要 25%、字段 15%、当前问题 5%,效果明显改善。
5. 经验沉淀:context-mode 设计的三个进阶原则
5.1 上下文是"状态",不是"历史记录"
如果你只记住一条,那就是这句。早期我一直把上下文当成对话历史的搬运工——历史记录完整就对了,后来才意识到,模型真正需要的是"对话状态":双方已经确立了哪些事实,还缺哪些信息,下一步要做什么。不重要的寒暄、重复的确认、中间过程的犹豫,这些都可以舍弃。
我的做法是每一轮对话结束后,脑子里过一遍:"一个新人接手这段对话,最少需要知道哪些事才能继续?"答案就是上下文该保留的最小集。按这个标准建设上下文,token 消耗通常会降到原来的三分之一以下,效果反而更好。
5.2 上下文结构要让模型"一眼看懂"
模型的注意力有限,上下文设计得像一团乱麻,再多的信息也白搭。我常用的技巧是三段式结构:开头放结论性信息(字段、摘要),中间放过程性信息(近期对话),用明确的分隔标记。很多模型已经接受过大量"【系统】【用户】【助手】"的格式训练,合理借用这些标记能让模型更快理解信息层级。
5.3 加一层"遗忘开关"做兜底
我后来在系统里加了一个手动遗忘机制,用户说"刚才说的都不算"或者客服专员需要重置对话时,能一键清空结构化字段和摘要,保留空白上下文重新开始。别小看这个功能,长对话系统里信息一旦污染,靠自然对话修正的成本远大于直接重置。有条件的团队还可以加自动遗忘规则,比如超过 48 小时的会话自动归档,进一步降低上下文出错的概率。
我在实际项目里见过太多团队在模型选型上花大价钱,却在上下文模式上糊弄过去,最后用户体验一塌糊涂。模型决定的只是输出的上限,上下文模式决定的是实际达到的上限。把这一层做好,比盲目换更强的模型来得更实在。这套混合上下文管理器的思路我已经在多个场景落地,按上述参数起步,再根据你的业务微调,跑通并不难。