context-mode:LLM上下文管理的状态机设计与工程落地 如果你的 LLM 应用也遇到过这种尴尬——对话一长模型突然“忘了”用户五分钟前提过的需求为了不丢信息你无脑把整份历史一股脑塞进上下文窗口结果 token 费用翻倍、响应越来越慢、偶尔还因为超长截断把结论搞错——那你缺的其实不是更大的窗口而是一套context-mode上下文模式管理机制。这篇不打算讲空洞的提示词技巧而是直接拆解如何在工程上把“上下文”当成一种可调度的资源来管理什么时候带全量历史什么时候改用滚动摘要什么时候只检索相关片段以及这套状态机在真实项目里怎么落地、有哪些必须避开的坑。适合正在做 chat 应用、AI Agent、客服机器人或知识库问答的开发者也适合刚接触上下文工程但想知道“它到底在解决什么问题”的读者。1. 为什么你的 LLM 应用需要 context-mode1.1 失控的上下文现场我先描述一个很常见的翻车过程你做了一个内部知识库问答机器人上线第一天效果不错第二天开始有人反馈“同一个问题上午回答和下午回答不一样”。你查日志发现因为聊天轮次太多程序直接取了历史里最近 2000 字拼进 prompt而用户真正问的旧文档编号早就被截断了。于是你改成“把所有历史都带上”结果 128k 的窗口没几轮就满了API 报错你再改成“超了就截断”模型开始东拉西扯。这就是典型的有上下文、没有上下文管理。更麻烦的是很多人把“上下文”等同于“原始聊天记录”觉得只要把记录完完整整塞进去模型就一定记得住。但大模型的注意力在超长输入里是会被稀释的而且成本、延迟和错误率都会随 token 量上升。context-mode解决的核心问题就是给上下文窗口建立一套显式的、可切换、可回退、可观测的调度规则。1.2 五种模式的对照表我在实际项目里会把上下文模式收敛成五类。每个模式解决一类典型场景也都有明显的代价不能指望一个模式通吃。模式上下文构成典型场景优点代价zero_shot 零基模式只带系统指令 当前问题翻译、改写、关键词抽取成本最低、响应快、无历史干扰完全没有多轮记忆full 全量模式保留全部原始消息代码调试、法务核对、长文档分析信息保真度最高细节可查token 消耗快超长后注意力稀释summary 摘要模式滚动摘要 最近 N 轮原文客服、写作助手、陪伴型聊天全局语义连续性最好早期事实可能失真retrieval 检索模式系统指令 向量检索片段 最近 2 轮知识库问答、资料问答单轮事实准确支持超大知识集依赖检索质量多轮意图容易断hybrid 混合模式滚动摘要 最近 N 轮 按需检索片段复杂 Agent、深度咨询兼顾长期记忆、近期细节和事实检索实现最复杂链路最长这五类模式不是靠感觉临时切而是由一组规则驱动当前窗口占用率、用户是否开启了新任务、问题是否与早期细节强相关、是否需要外部知识。把这些规则固化下来就形成了context-mode的核心逻辑。1.3 它不是一个开关而是一套调度系统很多人第一次听context-mode以为就是加一个开关开带全部历史关不带历史。这理解太浅了。真正的context-mode是一套上下文调度系统至少要包含四个能力。第一状态感知。随时知道当前上下文里已经有多少 token、系统指令占多少、最近一轮占多少、摘要占多少。第二策略决策。根据使用率和任务类型决定当前应该处于哪种模式并在模式切换前预留充足的执行空间。第三内容变换。从全量切换到摘要时要调用一次模型把旧消息压成结构化摘要而不是简单把前面的消息删掉。第四可观测与回放。每条消息、每个模式切换动作都要留痕出问题时能定位“模型到底看到了什么”。把这四点拆开你就明白为什么context-mode值得认真设计。它本质上是在漫长对话里替模型做“记住什么、忘掉什么、去哪里捡回什么”的决定。2. context-mode 的核心设计状态机、策略与预算2.1 模式切换的状态机我常用状态机来管理模式切换。状态机的优势是显式、可控、容易加日志比起零散的 if-else 判断后续扩展新模式时改动也方便得多。一个最小可用状态集是这样FULL全量历史、SUMMARY摘要模式、RETRIEVAL检索模式、HYBRID混合模式、ZERO_SHOT零基模式。它们之间不是随便乱跳的常见迁移路径有三条。第一条是“降级路径”FULL 在上下文占用超过阈值后迁移到 SUMMARY把早期消息压缩成滚动摘要。第二条是“外挂路径”当用户在长对话中问了一个需要具体事实的问题系统从 SUMMARY 临时进入 HYBRID向量检索结果被拼进上下文回答完根据占用率决定回到 SUMMARY 还是继续 HYBRID。第三条是“重置路径”用户明确开启新任务、上传新文档或者发起了一个与当前话题无关的问题时直接迁移到 ZERO_SHOT 或一个新的 FULL 会话避免旧任务污染新任务。状态机里要格外留意两个点。第一模式切换本身要消耗 token尤其是生成摘要那一步所以在触发阈值上要留足余量。第二每一次切换都应该触发一个内部消息比如[system] context mode: full - summary写进日志和回放数据里否则后人看 prompt 会非常迷惑。2.2 上下文预算公式与触发阈值上下文窗口不是给你随便用的。以常见的 128k 窗口模型为例我会先把预算切成四块系统指令、可丢弃的历史、本次模型输出、工具/检索结果。每一块都要单独留量因为它们在一次请求里都真实占用窗口。history_budget max_context - system_reserve - output_reserve - tool_reserve假设max_context128000系统指令 2000输出预留 4000工具/检索结果预留 3000那么真正能给历史消息用的预算就是 119000。注意这个数字不是固定不变的如果你的应用还会注入大量工具定义那么 system_reserve 要相应调高。这一步很多人会漏他们把 system prompt 算进了总长却没意识到它其实占走了历史预算。有了预算再看触发阈值。我的经验是定义两个阈值light_ratio0.6和heavy_ratio0.85。当history_used / history_budget 0.6时维持 FULL 模式保留全部原始消息因为这个阶段 token 成本低信息保真度最宝贵。当比例超过 0.6进入 SUMMARY 模式用滚动摘要加最近 6 到 10 轮原文替代完整历史。当比例超过 0.85必须进入 HYBRID 或强制裁剪因为生成摘要本身还需要几百到上千 token 的输出空间拖到 99% 再压缩模型已经写不出完整摘要了这是最容易踩的隐形坑。2.3 三种上下文压缩/回溯手法的取舍实现context-mode时常遇到三种手法全量截断、滚动摘要、向量检索回溯。它们各有适用面我先说结论纯截断是最差方案滚动摘要适合语义连续性向量检索适合事实准确性复杂场景必须组合。全量截断就是超过长度后直接砍掉最早的消息。实现最简单但问题很多如果用户在两小时前提过一个关键账号 ID砍掉后就再也找不回来了模型此时不会知道“这里少了历史”它会认为这就是完整对话继续顺着残缺信息作答。所以除非你的对话很短否则不建议作为主力方案。滚动摘要的做法是每隔一定轮次让模型把已有的历史总结成一段压缩文本后续消息接在摘要后面。优点是长对话的全局语义保持得好缺点是压缩过程会丢失具体数字、专有名词和细节而且摘要链越长误差越容易累积。所以我会在摘要生成时要求模型单独输出一份“关键事实清单”用 JSON 保存实体、数字和结论后续需要细节时优先读取这份清单。向量检索回溯则是把原始消息存进向量库回答问题时只把最相关的片段拉回上下文。优点是大规模知识场景下效果好缺点是多轮对话的连贯性会被打断。因此实践中我更多把它放在 HYBRID 里作为滑动摘要的补充而不是单独替代历史记录。3. 从零实现一个 context-mode 管理器3.1 定义模式、消息与预算对象我习惯先定义一组基础对象模式枚举、消息结构、预算结构。这段代码可以直接跑注释里写清楚了每个字段的作用。from enum import Enum from dataclasses import dataclass, field import time import uuid from typing import Optional class ContextMode(Enum): ZERO_SHOT zero_shot FULL full SUMMARY summary RETRIEVAL retrieval HYBRID hybrid dataclass class Msg: role: str # system / user / assistant / tool content: str msg_id: str field(default_factorylambda: uuid.uuid4().hex) ts: float field(default_factorytime.time) token_count: int 0 replaced_by: Optional[str] None # 如果被摘要替代记录替代者 ID dataclass class Budget: max_context: int 128000 system_reserve: int 2000 output_reserve: int 4000 tool_reserve: int 3000 keep_latest: int 8 # SUMMARY/HYBRID 模式下保留的最近原始消息条数 property def history_budget(self) - int: return self.max_context - self.system_reserve - self.output_reserve - self.tool_reserve这里的replaced_by字段强烈建议保留。它记录了某条历史消息是被哪一份摘要替代的后面做问题排查、用户投诉回放时几乎全靠这个字段还原现场。3.2 核心逻辑模式决策与提示词组装接下来写一个ContextManager类负责维护消息列表、计算当前占用率、决定模式、并在模式切换时触发摘要压缩。import tiktoken class ContextManager: def __init__(self, model: str gpt-4o, budget: Optional[Budget] None): self.model model self.budget budget or Budget() self.encoder tiktoken.encoding_for_model(model) self.system_prompt self.messages: list[Msg] [] self.summary self.mode ContextMode.FULL def set_system(self, content: str) - None: self.system_prompt content def _count_tokens(self, text: str) - int: return len(self.encoder.encode(text)) def add(self, role: str, content: str) - Msg: msg Msg(rolerole, contentcontent, token_countself._count_tokens(content)) self.messages.append(msg) return msg property def history_used(self) - int: summary_tokens self._count_tokens(self.summary) if self.summary else 0 raw_tokens sum(m.token_count for m in self.messages) return summary_tokens raw_tokens property def usage_ratio(self) - float: return self.history_used / self.budget.history_budget def decide_mode(self, force_query: Optional[str] None) - ContextMode: ratio self.usage_ratio if ratio 0.6: return ContextMode.FULL if ratio 0.85: return ContextMode.SUMMARY return ContextMode.HYBRID if force_query else ContextMode.SUMMARYdecide_mode是整套机制的大脑。force_query参数用来处理“用户明确问了一个事实性问题”的情况这时即使摘要比重不高也可以主动进入 HYBRID去向量库捞相关原始片段。下一步是build_prompt。它把系统指令、滚动摘要、最近消息和检索片段组装成最终发给模型的消息数组并在切换模式时执行压缩。def _summarize(self, msgs: list[Msg]) - str: # 这里应该调用 LLM为演示可直接返回占位文本 text \n.join(f{m.role}: {m.content} for m in msgs) return f[summary demo] 已压缩 {len(msgs)} 条消息原始内容略。参考片段{text[:120]}... def build_prompt(self, user_query: str, retrieved: list[str] | None None) - list[dict]: target_mode self.decide_mode(force_queryretrieved is not None) if target_mode ContextMode.SUMMARY and self.mode ! ContextMode.SUMMARY: old self.messages[:-self.budget.keep_latest] if old: self.summary self._summarize(old) for m in old: m.replaced_by summary_index self.messages self.messages[-self.budget.keep_latest:] self.mode ContextMode.SUMMARY if target_mode ContextMode.HYBRID and self.mode ! ContextMode.HYBRID: if not self.summary: self.summary self._summarize(self.messages[:-self.budget.keep_latest]) self.messages self.messages[-self.budget.keep_latest:] self.mode ContextMode.HYBRID prompt: list[dict] [{role: system, content: self.system_prompt}] if self.mode in (ContextMode.SUMMARY, ContextMode.HYBRID) and self.summary: prompt.append({role: system, content: f[context summary]\n{self.summary}}) if retrieved: prompt.append({role: system, content: f[retrieved context]\n \n.join(retrieved)}) for m in self.messages: prompt.append({role: m.role, content: m.content}) return prompt这段代码很精简但已经把状态切换、摘要生成、最近消息保留、检索注入这四件事串起来了。生产环境里_summarize要换成真实 LLM 调用并要求模型输出固定格式方便后续解析和回放。3.3 接入一轮真实对话下面用一个简单模拟来演示调用过程。我初始化一个 128k 预算的管理器加入 20 轮用户和助手消息然后观察模式如何从 FULL 切到 SUMMARY。mgr ContextManager() mgr.set_system(你是一个项目助手回复要简洁。) for i in range(20): mgr.add(user, f第 {i1} 轮问题当前项目的进展如何) mgr.add(assistant, f第 {i1} 轮回答进展正常已完成里程碑 {i1}。) print(current mode:, mgr.mode) print(history used:, mgr.history_used) print(budget:, mgr.budget.history_budget) print(ratio:, round(mgr.usage_ratio, 3)) final_prompt mgr.build_prompt(user_query最近进展怎么样) for item in final_prompt: print(item[role], -, item[content][:80])如果此时 ratio 超过 0.6你会看到第一次调用build_prompt后messages数量从 40 条骤减到 8 条前面 32 条被折叠进了summary字段。后续所有请求的 prompt 里只有摘要和最近 8 条原始消息上下文占用大幅下降。这里要提醒一点tiktoken.encoding_for_model在模型名不支持时可能抛异常。实际项目中可以把 token 计算封装成可替换接口甚至在测试环境直接用len(content)占位但上线前必须换成真实分词器。4. 实战中我踩过的五个坑4.1 摘要压缩把关键事实压丢了最典型的问题就是滚动摘要把“合同编号 AB-2024-001”这种关键实体压成“某个合同”后续用户问编号模型只能瞎编。我的解决方案是在摘要生成阶段使用两段式 prompt先让模型抽取“关键事实清单”包含所有数字、专有名词、结论和任务状态再让模型生成自然语言摘要。关键事实清单用 JSON 结构单独存不回填到主摘要里。下次用户询问细节时先在清单里找找到就把对应原始片段重新放进 prompt而不是依赖摘要里的模糊表述。4.2 切换模式导致“失忆”如果程序在上一轮还是 FULL这一轮直接压缩用户紧接着问“你刚才不是说方案 B 有两个风险吗”模型很可能答不上来。因为压缩动作发生在新问题之前模型没有机会把当前状态“交接”给摘要。我后来强制要求任何 FULL 切换到 SUMMARY 的操作都要先向模型发送一条指令要求它“先输出当前对话的核心状态包括已完成事项、待办事项、关键结论再输出摘要”。等这条消息写进日志旧消息才被标记替代。这样新一轮模型看到的摘要天然带有交接性质而不是冷冰冰的复述。4.3 token 估算口径混乱初期我为了方便用len(content) / 4估算 token 数中文场景下误差非常大。后来排查一个线上事故发现日志里记录的上下文使用率只有 72%但实际 API 返回的用量已经超过 94%导致频繁截断。现在所有 token 统计都统一走 tiktoken 或模型自带的分词器并且只在两条边界做缓存一条消息进来算一次不再每次 build_prompt 重复计算。另一个细节是工具调用结果和检索片段也要计入 history_budget不能只算对话消息否则一样会超。4.4 系统指令被一起压缩了压缩时如果直接把所有消息送进_summarize系统指令和用户消息会混在一起生成的摘要可能包含“你是助手”这类废话或者把系统对输出格式的要求丢进摘要导致后面几轮格式全乱。正确做法是在消息对象里显式区分 role压缩前先过滤掉 system 和高优先级指令消息只对真正的对话历史做摘要。构建 prompt 时系统指令永远放在最前面摘要作为独立的 system 拼接块放第二位不要合并成一段长文本这样模型的指令遵循度会好很多。4.5 剪枝没有留痕出问题没法复盘我曾经在一个 Agent 项目里遇到用户投诉模型突然开始推荐错误的产品。打开日志一看prompt 里全是摘要文本根本不知道摘要怎么来的。因为代码只是简单地把旧消息清掉了没有记录替代关系。后来我在每个Msg上加了replaced_by字段每次压缩都写入对应摘要 ID并且把整个消息队列、 summary、模式迁移事件统一输出成可读文本。现在排查问题时我能从一条摘要一路追溯到最原始的几十条消息快速判断是摘要失真、检索漏召回还是模式切换时机太晚。这一步强烈建议你们也做成本很低收益很大。5. 沿着 context-mode 继续往前走5.1 从对话管理走向上下文工程现在很多项目把context-mode单独做成一个中间层前端只负责收消息发消息所有上下文决策都收口到这一层。再往前一步就是完整的上下文工程不只管理对话历史还要管理工具定义、知识库检索、用户画像、短期目标和长期偏好。我自己的做法是把模式决策规则外置成一份 JSON 配置运行期可以热更新。比如某天老板说“最近用户反馈历史问题老是答不准”我不需要重新发版只把摘要保留的最近消息数从 8 调到 15把检索召回阈值调低就能快速验证效果。这套机制配合线上日志指标能形成持续调优的闭环。5.2 一开始别急着上自动化说一个反直觉的建议新项目不要第一时间把模式切换做成全自动。先把手动模式开关暴露出来测试环境里人工切模式看 prompt 是否符合预期再做成“半自动”也就是自动触发、人工确认确认稳定后才改成全自动。原因很简单模式切换的 bug 不会立刻暴露它会在对话积累到一定长度后才出现而且复现成本很高。手动开关可以让你在开发阶段就反复验证切换边界。等前几种模式的切换逻辑稳定了再加 HYBRID、再加检索注入一步一步来比一口气写完五套策略要稳得多。我在实际项目里最大的体会是context-mode不是“省 token 的小技巧”它本质上是对话产品一致性的基础设施。用户不会感知到你的模式管理器切到了哪一档但他能明显感觉到这个助手是“记得住前因后果”还是“聊两句就失忆”。把这件事做扎实了后续加再多的工具、知识库对话体验都不会散。