有些坑,只有把上下文不当回事的人才会踩到。做 AI 应用开发这两年,我见过太多的项目一开始跑得欢快,对话一长就开始胡说八道、丢信息、超 token,最后全卡在一个问题上——上下文到底该怎么管?“context-mode”这个词,现在越来越多人挂在嘴边,但真正把它做成一套可靠方案的并不多。这篇文章不谈理论口水仗,直接从我自己的实践出发,拆解上下文模式的完整设计思路、可落地实现和踩坑记录,希望能给正在做大模型应用、Agent 或长对话系统的你一些参考。
1. 从“对话上下文”到“上下文模式”:概念拆解与设计思路
1.1 上下文不只是聊天记录,它是模型眼里的“工作记忆”
很多人对上下文的第一反应就是“把历史对话拼在一起丢给模型”。说实话,这个理解太粗糙了。我见过最简单粗暴的实现,就是把 messages 数组无限追加,直到报出context_length_exceeded才被迫处理。这种做法在 demo 阶段没问题,但一旦进入生产环境,你会发现上下文其实就是模型的工作记忆——它决定模型“记得什么、忘记什么、优先级是什么”。
举个例子,你在做一个客服机器人,用户前五轮在问退货政策,第六轮突然问“那你们退款大概几天到账?”如果上下文模式设计得好,模型应该自动把“退货政策”相关细节保留,把中间的寒暄、重复问句丢弃,再把最新问题的注意力集中到“退款时效”上。如果只是简单堆历史记录,模型不仅可能被无关信息干扰,还可能因为 token 溢出直接崩溃。
所以我们要做的事,是把“上下文”从一个被动累积的历史数组,变成一种主动管理的资源。context-mode 的核心就是把上下文当作可调度、可分片、可压缩、可检索的系统来设计,而不是任由它野蛮生长。
1.2 为什么必须引入“模式”:不同场景对记忆的要求截然不同
同一个上下文管理策略,不可能同时满足所有场景。以我自己的项目为例,我同时维护着三个类型完全不同的 AI 应用:
- 多轮对话服务:用户和助手之间是连续、强依赖的历史对话,需要完整保留关键信息。
- 知识库问答:用户的每次提问相对独立,上下文主要来自检索到的文档片段,历史对话反而容易造成干扰。
- Agent 任务执行:模型需要一边执行工具调用、一边追踪任务状态,上下文不仅包含对话,还包含工具返回结果、执行计划、中间变量。
这三个场景如果共用一套“全量保留历史消息”的上下文策略,效果可想而知。Agent 场景下你根本不敢把全部工具返回都塞进历史,知识库场景下上一次问答的内容可能污染下一次检索的语义。
这就是 context-mode 存在的理由。模式不是噱头,而是用一套统一框架去管理不同记忆需求。我后来做的一个简单抽象,就是把上下文拆成几个可配置的模块:短期对话缓冲、长期摘要记忆、外部检索片段、工具调用痕迹,然后按模式决定哪些模块参与当前请求、每个模块占多少 token 预算。
2. 核心细节:上下文管理的关键参数与实现要点
2.1 token 预算分配:先把钱算清楚再动手
不管什么模式,第一步永远是算 token 预算。我见过很多开发者的做法是“大概估一下”,结果模型输出到一半被截断,或者系统提示词被挤掉导致行为失控。token 预算分配有一套我实测下来比较稳的公式:
- 先确认模型的最大上下文窗口,假设是 128k。
- 留出20% 的输出空间,这部分是模型生成回复要用的,绝不能省。
- 固定占用部分包括系统提示词、当前用户输入、工具定义。这些是硬性需求,先扣掉。
- 剩余空间才是历史上下文可以使用的部分。
我习惯用一个比例去约束:系统提示词约占 10%~15%,历史消息约占 40%~50%,检索片段或工具结果约占 20%~30%,给输入留 5%,最终确保输出余量不低于 15%。具体比例按场景调整,但“先留输出余量”这条铁律我从来没打破过。
注意:很多初学者把上下文塞得满满当当,觉得 token 不用白不用。实际上模型在接近窗口上限时,注意力会变得很分散,早期信息很容易被稀释。给输出留足空间,比多塞几轮历史对话重要得多。
2.2 上下文压缩与摘要:不是简单“总结一下”
当历史消息超过预算时,最自然的想法是“让模型把前面的对话总结成摘要”。听起来简单,实际坑很多。第一版我直接调用大模型做 summarize,把转成摘要文本塞回 messages,结果发现模型在后续对话里反复出现“根据之前的对话”这样的空洞指代,因为摘要丢失了具体的实体名称和数值。
后来我调整了策略,摘要不是扁平的一段话,而是结构化的三层:
- 关键实体层:人名、订单号、地址、金额等必须原样保留,不允许被模糊化。
- 状态层:当前任务进展、用户明确表达过的偏好、已经确认过的决定。
- 过程层:争议点、试探性提问、与当前目标无关的寒暄可以丢弃。
这样压缩后的上下文,模型在后续推理时依然能拿到事实性锚点,而不是只得到一个“大概说了什么”的印象。另外,我还加了一个比较实用的技巧:摘要按重要性加权保留原文片段。如果某句原文是用户明确说过的指令,比如“我要的是红色版”,那这句话直接原样塞进摘要里,不允许模型改写,宁可多占几十个 token。
2.3 上下文持久化与检索增强:让记忆跨越会话边界
短时对话的上下文管理,窗口内解决就够了。但真正让我感觉到 context-mode 价值的,是把它扩展到了跨会话场景。
我的做法是给每个用户、每个任务建一个独立的上下文存储区。每次会话结束后,不是把整个 messages 丢弃,而是把结构化摘要、关键实体、未完成事项写入存储。当同一用户再次发起请求时,先检索存储区里的历史上下文,再决定要不要带进当前窗口。
这里有个微妙的权衡:全量历史都检索会导致 token 超限,完全不检索又会让跨天对话“失忆”。我用的方案是分层检索 + 命题召回:本地向量库存历史片段,每次只召回和当前输入语义最相关的 top-K 条,每条附带时间戳和会话 ID,模型在推理时可以明确知道哪些信息是新的、哪些是历史。这个方案我目前跑了四个多月,跨会话记忆的准确率比单纯拼接全量摘要高出不少。
3. 实操过程:从零实现一个可用的 Context Mode 管理器
3.1 整体结构与代码实现
概念说了这么多,直接上代码。下面是我在实际项目中简化后的一个ContextModeManager,保留了核心逻辑,去掉了一些业务耦合,方便你参考。
from dataclasses import dataclass, field from enum import Enum from typing import List, Optional class ContextMode(str, Enum): CHAT = "chat" # 多轮对话模式:偏重历史连续性 RAG = "rag" # 知识库问答模式:偏重检索片段 AGENT = "agent" # 智能体任务模式:偏重工具调用链 @dataclass class ContextConfig: max_window: int = 128_000 reserved_output: int = 25_000 system_prompt: str = "" tools_def: Optional[str] = None @property def history_budget(self) -> int: fixed = len(self.system_prompt) // 3 if self.tools_def: fixed += len(self.tools_def) // 3 return self.max_window - self.reserved_output - fixed - 1000 class ContextModeManager: def __init__(self, mode: ContextMode, config: ContextConfig): self.mode = mode self.config = config self.short_term: List[dict] = [] # 当前窗口内的原始消息 self.summary: Optional[str] = None # 结构化摘要 self.entities: dict = field(default_factory=dict) # 关键实体 def set_mode(self, mode: ContextMode): # 切换模式前要做状态收敛 if self.mode == ContextMode.CHAT and mode == ContextMode.RAG: self._summarize_history() self.mode = mode def add_message(self, role: str, content: str): self.short_term.append({"role": role, "content": content}) # 简单 token 估算:中文约 1 个汉字 ≈ 1.5 token,此处粗算 current_usage = sum(len(m["content"]) for m in self.short_term) if current_usage > self.config.history_budget * 0.8: self._summarize_history() def _summarize_history(self): # 省略具体 LLM 调用,核心是:实体层不压缩,过程层可裁剪 pass def build_messages(self) -> List[dict]: messages = [{"role": "system", "content": self.config.system_prompt}] if self.summary and self.mode != ContextMode.RAG: messages.append({"role": "system", "content": f"历史摘要:{self.summary}"}) if self.mode == ContextMode.RAG: rag_context = self._retrieve_snippets() messages.append({"role": "system", "content": f"检索片段:{rag_context}"}) messages.extend(self.short_term) return messages def _retrieve_snippets(self) -> str: # 向量检索逻辑省略,返回 top-K 片段 return ""这段代码的结构其实遵循一条很基础的原则:把上下文拼装的动作收敛到单一入口。所有模式差异,在build_messages里就分清了。后面的业务代码永远只需要调用build_messages(),不需要关心具体上下文策略。这个设计看着简单,但能让后续迭代省很多事。
3.2 token 估算的准确性问题
代码里我用了简单的字符长度估算 token,这对中文场景其实误差不小。有人会引入tiktoken做精确计算,但代价是每次都多一次编码耗时。我的处理方式是:线上用粗估,离线跑回归测试时用精确计费。粗估系数按模型调整,比如对中文内容,len(text) * 1.5基本够用;对英文代码,len(text.split())之后再乘一个小系数更合理。不要追求绝对精确,关键是预算守恒——宁可早触发压缩,也不要等到溢出再被动兜底。
3.3 模式切换的衔接处理
模式切换是 context-mode 里最容易出 bug 的地方。最简单的错误是:用户先聊了十轮天,然后突然问一个知识库问题,系统还是把十轮对话全塞进去,检索片段也被硬塞,结果模型完全被历史带偏。
我在set_mode里加了一个设计:切换到 RAG 模式时,先把短期历史收敛成摘要,但摘要只保留用户明确表达过的偏好类信息(比如“我之前说过我对价格最敏感”),业务细节全部让位给检索片段。切换到 AGENT 模式时,则要把已经完成的工具调用结果从短期缓冲区中剔除,只保留当前执行计划相关的信息。这个过程我称之为“状态收敛”,它保证了每个模式启动时拿到的上下文是干净、聚焦的。
4. 常见问题与排查技巧实录
4.1 上下文漂移:模型记错或张冠李戴
现象:对话进行到十几轮以后,模型开始把 A 用户的信息安到 B 用户头上,或者把早期对话里的错误事实当成既定前提。
原因:摘要压缩时丢失了实体锚点,早期原文被过度改写。
排查思路:先看摘要里是否保留了关键实体的原文,再看历史消息进入窗口时是否被截断过。我踩过最狠的一次,是因为窗口预算不足,历史消息直接从头开始截断,结果连用户的名字都给截没了。后来我在截断逻辑里加了一个保护:无论预算多紧张,最后 N 条用户消息必须完整保留,摘要必须原生携带关键实体。
4.2 token 溢出依然是头号问题
有一类溢出是最阴间的:估算时感觉没问题,实际请求时却被 API 抛错。原因在于模型内部的实际 tokenizer 计算和我的粗估系数有偏差,特别是在处理带大量代码、URL、JSON 的文本时,偏差能到 30% 以上。
我的解决方案是加了一个保险丝机制:构建 messages 之前先做一次 token 上限校验,一旦超过阈值,优先丢弃最旧的工具结果,然后再压缩历史消息。这样至少保证请求不会失败。保险丝的阈值要留足余量,我个人是卡在 85%,超过就开始触发,而不是等到 99%。
4.3 模式误切换导致的“变笨”
现象:Agent 模式下模型突然变得极其啰嗦,或者 RAG 模式下回答明显脱离检索结果开始自由发挥。
排查思路:检查切换时是否把系统提示词也同步切换了。很多上下文管理器只切换了消息组装策略,忘了系统提示词里还残留了上一个模式的指令。比如刚从 Agent 模式切回来,系统提示词里还留着“你可以调用工具”的说辞,模型自然倾向于编造工具调用。我的建议是,系统提示词必须和模式强绑定,每次切换模式都整段替换,不要做局部拼接。
这里再补充一个排查技巧:把所有模式切换事件和最终 token 构成打到日志里。出现问题的时候,不要凭感觉猜,直接从日志里看这次请求到底带了哪些上下文、摘要是什么时候生成的、压缩触发了多少次。我靠这个日志结构定位过不下十个看似玄学的问题。
5. 一些实操后的真心话
做 context-mode 这个事,技术难度不在一行代码,而在对场景的深刻理解。我改过很多版设计,最后沉淀下来的核心认知就是一条:上下文管理永远是在“保留信息”和“控制注意”之间找平衡。你保留得越多,模型越容易迷失;你压缩得越狠,模型越容易失忆。
我个人现在更倾向于把 context-mode 设计成一种可观测的状态机,而不是一段被动的数组拼接。模式切换的时间点、摘要压缩的策略、token 预算的分配,全部显式化,留好日志和监控。每个项目的数据分布不一样,我的这套配置不一定直接适配你手上的业务,但状态机这个思路、预算守恒这条铁律,普适性很强。
最后分享一个微小但很提升体验的小技巧:在用户输入进入上下文之前,先做一遍轻量清洗,把重复的粘贴文本、无意义的感叹词、连续换行压缩掉。这比任何花哨的压缩算法都省 token,而且对模型的理解效果没有任何负面影响。我试过在一个高频客服场景里,光这一层清洗就把 token 消耗降低了差不多 15%,用户的体感反而更好了。