ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

LLM上下文管理:context-mode四种模式实现与踩坑实践

LLM上下文管理:context-mode四种模式实现与踩坑实践

"context-mode"这个词,是我最近一个项目里给自己留的代号。起因很朴素:我在给一个企业内部知识问答机器人做升级,模型换了更大上下文窗口的版本,但体验不但没变好,反而翻车更频繁。对话到第 20 轮开始胡言乱语,检索知识库后答非所问,单轮成本还肉眼可见地在涨。后来我意识到,问题不在模型,而在我把"上下文"当成了一件可以无限堆叠的东西。于是我把上下文的管理方案单独拆出来做了一套可插拔的模块,代号就叫 context-mode。这篇文章把从需求拆解、模式设计、代码实现到实测踩坑的完整链路写出来,如果你正在做 LLM 应用、Agent 或者 RAG 服务,这份经验应该能直接搬过去用。

1. 先搞清楚问题:上下文窗口不是"越大越好",而是"够用且不浪费"

1.1 上下文窗口是硬约束,不是软提示

所有 LLM 服务都有上下文窗口限制。所谓"窗口",指的是模型一次生成时能同时看到的所有输入 token 与输出 token 之和。这就像一个临时桌面:桌面越大,你能摊开的东西越多,但桌面的物理尺寸始终是固定的。很多人(包括我最初)有个误区:只要选个窗口大的模型,历史对话全塞进去就完事了。

但实际写代码的时候会发现三个直接问题。第一是成本:很多大模型服务按 token 计费,输入 token 和输出 token 价格不一样,输入越长,每轮调用的费用线性上涨。第二是延迟:模型处理输入的时间随 token 数增加,尤其是使用长上下文模型时,超出一定长度后首字响应时间会被拉长。第三是注意力稀释:模型对长文本不同位置的信息敏感度并不均匀,"大海捞针"式的测试早就证明过,处在中间部分的关键信息经常被忽略。

我踩的第一版坑就是无脑全量历史。结果某天用户在第 40 轮时问"我刚才说的那个数字是多少",模型答了一个错误的数字。原因不是模型笨,而是完整历史里同时出现了五个数字,模型不知道用户指哪个;而正常情况下,一个只看到最近三到五轮的模型反而能答对。这让我第一次意识到,上下文管理的核心其实是"信息筛选",而不是"信息堆叠"。

1.2 对话历史的信息熵在快速衰减

多轮对话有一个很反直觉的特点:越靠前的轮次,信息密度越低。前几轮用户可能会交代背景、偏好、约束,这部分价值高;但聊到十几轮之后,大量都是"嗯""对""再举个例子"这类衔接内容。把这些低信息含量内容原样塞进上下文,等于用大量 token 稀释了真正有价值的信号。

我给模拟项目X做过一个统计:随机取 200 段真实用户对话,把每轮的原文和该轮对后续回答真正有影响的信息做对比。结果大概有六成以上的历史轮次在后续回答里根本没被用到。也就是说,全量历史模式里,大部分 token 是在扮演"氛围组"——模型每次都要遍历它们,却几乎不影响回答质量。所以,"上下文模式"的第一个设计前提就是:不要默认把所有历史都给模型看,而是根据当前请求的性质,决定给模型看哪几块内容。这个决策本身,就是我所说的 context-mode 要解决的第一个问题。

1.3 需求拆解:一个固定策略永远伺候不好所有场景

我从真实业务里梳理出四类常见诉求,它们对上下文的需求是冲突的:

  • 快速闲聊与简单操作:用户问"今天天气如何""帮我建个日程",这类请求只需要最近的对话状态,历史越多反而越容易让模型分心。
  • 深度追问与多轮讨论:用户围绕一个方案连续追问,前面讨论的前提处处影响后面的结论,这时候必须保留尽量全的前文。
  • 知识库查询:用户提的问题涉及某个内部制度或文档,需要从知识库检索相关片段并注入上下文,这部分内容是额外的,不能挤占对话历史的配额。
  • 关键信息核对:用户想确认某次沟通中提到的金额、日期、责任人,这时候需要精确的原始文本,而不是经过摘要转述的内容。

冲突点很明显:既要让简单场景用最小的代价快速响应,又要在关键场景保留原始细节。唯一的解法就是做多种模式,让系统根据场景去切换——这就是 context-mode 的雏形。

2. context-mode 的模式设计:四种模式,管好"给模型看什么"

2.1 compact 模式:克制到极致,只留最近状态

compact 模式是我最先实现的,逻辑最简单:系统提示词 + 最近 3 轮对话 + 当前用户输入。其他的历史一概不注入。它的优势非常明显,token 消耗最小、响应最快,而且模型注意力高度集中。

但它的代价也很明确:一旦用户在当前问题中引用到 5 轮之前的信息,模型就没有任何线索。所以我当时给它配了一个触发条件:只有当前问题不涉及历史中超过 3 轮之前的概念时才可用。实现上我用了一个很粗糙但有效的判断——用当前问题去和最近 3 轮的文本做重叠词检测,如果重叠率过低并且对话轮数已经超过 5 轮,就自动切到 summary 模式。这个启发式并不完美,但在初期足够用。

2.2 summary 模式:滚动摘要兜住“遗忘”的下限

summary 模式解决的是中等长度对话。做法是把早期对话压缩成一段结构化摘要:包括时间线摘要、已确认的约定、未决问题、用户明确表达过的偏好。摘要由 LLM 生成,生成之后作为一屏"前情提要"放在系统提示词之后,原始历史不再进上下文。

触发条件我定为两类:一类是轮数超过 10 轮但不超过 30 轮;另一类是最近对话的 token 数超过预算表的 40%。每 5 轮增量更新一次摘要,不整段重算,避免摘要本身变成大额开销。摘要格式我固定为四段式:背景一句话、关键约定列表、未决问题列表、最近主题一句话。这样后续检索和切换时也容易复用。

2.3 retrieval 模式:知识即插即用,但别喧宾夺主

需要查知识库时用 retrieval 模式。实现套路非常常见:把知识文档按语义切片后做向量化,用户提问后计算相似度取 top-k 片段,把片段拼接到上下文中。我在这块踩过两个比较大的坑,后面第 4 节会细说,这里只说设计原则。

第一条原则是:检索片段必须有"身份信息"。每条片段前面必须带来源文档标题和章节标题,否则模型很容易把知识库内容和对话内容混在一起,出现"从对话里臆造制度"的幻觉。第二条原则是给检索结果单独的 token 预算,默认不超过总预算的 30%。一旦检索结果太多,宁可减少召回数量,也不能让它挤占掉对话历史的配额。毕竟知识库内容再丰富,也只是"参考资料",真正的主线永远是用户当前的诉求。

2.4 full 模式:保留原始细节,用于"对账"场景

full 模式就是字面意思:完整对话历史全部进上下文。适用场景其实比想象中窄——主要是需要前后对账的场景,比如用户要求核对某次讨论中提到的具体报价、日期、人员分工,这时候摘要模式会给不出准确原文,compact 更是直接失忆。

对 full 模式我做了两个限制,避免它退回第一版的坑。一是"护栏预算":即便窗口有 128K,我也只允许历史最多占 70%,剩下 30% 留给模型输出和当前输入,防止模型在长篇输出中途被截断。二是"次数限制":默认只有命中核对意图时才强制进入 full 模式,普通对话即使轮数很长,也优先走 summary。用户也可以手动指定,但手动指定的模式会在会话结束后自动重置,不会污染下一次对话。

模式注入内容典型场景token 消耗信息保真度
compact系统提示词 + 最近 3 轮闲聊、简单操作低低
summary系统提示词 + 滚动摘要 + 最近 5 轮中等长度讨论中中
retrieval系统提示词 + 检索片段 + 最近对话知识库问答中高中高(限定领域)
full系统提示词 + 完整历史核对、长讨论收口高高

3. 核心实现:用 token 预算表驱动的上下文组装器

3.1 预算表先于代码

动手写第一个函数之前,我先在项目文档里画了一张 token 预算表。这张表是整套 context-mode 的基石,后面所有组装逻辑都围着它转。拿一个 32K 窗口的模型举例,我的分配是这样:

  • 系统提示词:1200 token(约 4%)
  • 滚动摘要:1600 token(约 5%)
  • 检索片段:6400 token(约 20%)
  • 最近对话:9600 token(约 30%)
  • 当前用户输入:1600 token(约 5%)
  • 预留输出:5600 token(约 17%)
  • 弹性缓冲:4000 token(约 13%)

合计刚好 32000。弹性缓冲是给模式切换留的,比如从 compact 切到 summary 时,摘要是新生成的,需要额外空间。如果你用的是 8K 窗口,建议把比例调成:系统 8%、摘要 8%、检索 15%、对话 30%、输入 10%、输出 25%、缓冲 4%。小窗口更要保守。

有个细节很容易被忽略:模型输出也要占窗口。预留输出通常要留整窗的 15% 到 25%,否则遇到需要长篇输出的请求,模型会在生成到一半时撞到窗口上限(实际表现为输出被截断或服务端报错)。我第一版把输出预算压到 5%,结果用户让模型列 20 条要点时,每次都在第 12 条左右断掉,这个教训印象很深。

3.2 组装管线:按优先级逐层拼接

组装 Prompt 的顺序我固定为:系统提示词 → 滚动摘要(若有)→ 检索片段(若有)→ 最近对话 → 当前用户输入。每一层注入前都先做 token 计数,超过预算就削减该层的内容,而不是削减后续层。比如检索片段超了预算,我会降低 top-k 或丢弃排名靠后的片段,但不会因此把系统提示词砍掉。

下面是组装器的一个核心类,我把关键方法写成了通用伪代码,具体方法名以你对接的大模型服务 SDK 为准:

class ContextManager: def __init__(self, config: dict): self.budget = config["token_budget"] # 各层预算,单位 token self.mode = config.get("default_mode", "compact") self.summary = "" # 滚动摘要缓存 self.history = [] # 原始对话历史 self.kb_cache = {} # 已检索片段缓存 def count_tokens(self, text: str) -> int: # 用模型服务商提供的 tokenizer 或统计接口 # 这里省略具体调用,返回估算值即可 return len(text) // 2 # 仅示意,勿用于生产 def select_mode(self, user_query: str) -> str: # 意图启发式 + 轮数 + token 水位 if self.is_verification_query(user_query): return "full" if self.is_kb_query(user_query): return "retrieval" if len(self.history) > 30 or self.context_usage() > 0.6: return "summary" return "compact" def build_prompt(self, user_query: str, kb_results=None) -> list: mode = self.select_mode(user_query) messages = [] tokens_used = 0 # 第一层:系统提示词 sys_msg = self.make_system_message(mode) messages.append(sys_msg) tokens_used += self.count_tokens(sys_msg["content"]) # 第二层:摘要(summary/full 有摘要则注入) if mode in ("summary", "retrieval", "full") and self.summary: summary_msg = {"role": "system", "content": f"前情提要:\n{self.summary}"} if tokens_used + self.count_tokens(summary_msg["content"]) <= self.budget.summary: messages.append(summary_msg) tokens_used += self.count_tokens(summary_msg["content"]) # 第三层:检索片段 if mode == "retrieval" and kb_results: kb_text = self.pack_kb_results(kb_results) kb_msg = {"role": "system", "content": f"参考资料:\n{kb_text}"} if tokens_used + self.count_tokens(kb_msg["content"]) <= self.budget.retrieval: messages.append(kb_msg) tokens_used += self.count_tokens(kb_msg["content"]) # 第四层:最近对话 recent = self.history[-self.recent_rounds:] for msg in recent: if tokens_used + self.count_tokens(msg["content"]) > self.budget.dialogue: break messages.append(msg) tokens_used += self.count_tokens(msg["content"]) # 第五层:当前输入 user_msg = {"role": "user", "content": user_query} messages.append(user_msg) return messages

这段代码在设计上强调"每层独立预算、顺序注入、超了砍当前层"。实际生产环境里,count_tokens必须换成精确计数,否则预算表形同虚设。字符串长度除以 2 的估算法只能用在原型阶段。

3.3 模式切换策略:让规则引擎决定,不让模型自由发挥

第一种是意图驱动:从请求里识别是否涉及知识库关键词、是否包含"核对/确认/上次说的"等核对意图词。第二种是轮数驱动:对话轮数超过阈值就强制升级为 summary。第三种是水位驱动:统计实际已占用的 token 数,超过预算表的 60% 就允许做一次摘要压缩。三种信号取或关系,谁先触发谁生效,但同一轮内最多切换一次,避免模式在两个模式间反复横跳。

我试过让模型自己通过函数调用来决定上下文模式,效果很不好。原因有二:模型倾向于高估自己对历史的依赖,动不动就选 full 模式;而且函数调用本身也要消耗 token,模式决策每轮多一次调用,成本直接上涨。规则引擎虽然笨,但它可解释、可灰度、可回滚,这在工程上远比"智能"重要。

3.4 配置化:把模式和预算从代码里剥出去

context-mode 做到第三个迭代版本后,我把所有模式定义和预算都抽成了配置文件。每次调参不用改代码、不用发版,改完配置热加载即可。下面是大致的配置结构:

{ "modes": { "compact": { "recent_rounds": 3, "summary": false, "retrieval": false }, "summary": { "recent_rounds": 5, "summary": true, "retrieval": false }, "retrieval": { "recent_rounds": 5, "summary": true, "retrieval": true }, "full": { "recent_rounds": -1, "summary": false, "retrieval": false } }, "token_budget": { "system": 1200, "summary": 1600, "retrieval": 6400, "dialogue": 9600, "user_input": 1600, "output": 5600, "buffer": 4000 }, "switch_rules": { "kb_keywords": ["制度", "政策", "文档", "流程"], "verification_keywords": ["核对", "确认", "上次说的", "刚才那个"], "summary_threshold_rounds": 10, "water_level_ratio": 0.6 } }

这个配置结构有几个值得注意的点:最近轮数用了-1表示不限,摘要和检索的开关直接对应到组装器里的注入逻辑;关键词列表只是第一版规则,后面可以换成更准的意图分类模型。

4. 实测与踩坑:看起来对的方案,跑起来全是细节

4.1 一组实测数据:混合模式性价比最高

模拟项目X上线 context-mode 后,我挑了三周的真实流量做对比,分别跑过全量历史、纯 summary、纯 retrieval 和"规则切换的混合模式",每组流量 2000 轮左右。人工评估口径统一为"回答是否准确且完整"。表格里是我挑出的核心数据:

模式回答准确率平均首字延迟每轮平均输入 token相对全量的成本
全量历史91%2.8s115001.00x
纯 summary84%1.6s42000.38x
纯 retrieval87%2.0s63000.55x
混合 context-mode90%2.1s71000.62x

最直观的结论是:混合模式把成本压到六成左右,准确率只掉了 1 个百分点;纯 summary 的准确性下滑比想象中严重,说明摘要丢信息的风险真实存在;纯 retrieval 在涉及自由讨论的问题上容易答偏,因为模型缺少完整上下文来理解"用户当前到底在纠结什么"。

4.2 踩坑一:摘要把关键数字“摘丢了”

有段时间 summary 模式的准确率掉到 80% 以下,追查后发现是摘要丢了数字。用户在第六轮说过"预算控制在 80 万以内",摘要压缩时模型把这句话概括成了"预算需控制",没有保留 80 万这个数值。等到第二十轮用户问"那个 80 万的方案现在怎么看",系统实际能看到的摘要里根本没有 80 万,回答自然出错。

解决思路有两层。第一层是提示词层面:追加一个硬约束指令,要求摘要必须原样保留所有数字、日期、人名和金额,不允许用"若干""左右"这类词替换。第二层是结构层面:把摘要拆成"叙述性摘要"和"关键事实表"两块,关键事实表用列表形式显式罗列数字型信息。每次生成摘要后我还加了一道校验逻辑:用正则从原文和摘要里各抽一次数字,数量相差超过阈值就重新生成或告警。这道低成本校验救回了大量准确性。

4.3 踩坑二:检索切片被硬切断,语义接不上

retrieval 模式上线初期,用户普遍反馈"回答里引用的内容看着像,但细节对不上"。定位后发现是切片方式的问题。我第一版用固定长度切知识文档,每片 256 个字符,结果很多完整句子被拦腰截断:一段制度规定里"有效期自……"和"起不再适用"被拆到了相邻两片。向量检索召回时,光召回前半段,模型看到的信息是不完整的,回答自然残缺。

改成按语义块切片后问题基本消失。具体做法是:先按 Markdown 标题拆成章节,再把每个章节按段落细分,段落超长时以句号、分号作为断点;每片控制在 300-800 字。检索时除了返回文本内容,还要返回"标题路径",比如"员工手册 / 第三章 / 考勤细则 / 请假审批",拼接到 Prompt 后,模型能清楚知道这段话出自哪里,回答时也会更克制。

4.4 踩坑三:模式频繁切换,用户觉得模型“失忆”

第三个坑是体验层面的。规则引擎有一版切换太激进,用户在第 8 轮正常讨论时,系统因为轮数超过阈值切到了 summary,把前 5 轮的原始对话压缩了。用户紧接着问"那你刚才说的第二点是什么",模型回答得含糊,用户原话是"你是不是换人了"。这类投诉连续出现后,团队差点把 context-mode 整个回滚。

复盘下来的结论是:切换本身没有错,错在切换前没有给用户心理预期。后来我加了两个动作。一是"切换提示":一旦模式从 compact/full 切到 summary,先主动发一条系统消息,内容是"为避免超长对话记不住关键信息,我已把前面的讨论整理成要点如下",把摘要原文展示出来,用户看到自己的信息被完整保留,就不会觉得模型失忆。二是"模式记忆":同一会话内优先复用上一次的模式,除非水位或核对意图明确触发切换,否则不来回跳。这两个动作上线后,相关投诉率直接降到了之前的十分之一以下。

4.5 其他小坑:增量摘要、缓存与并发

  • 摘要别整段重算:每次从第一条开始重算历史会非常慢且贵。我改成每 5 轮对新增的 5 轮做一次小摘要,再和已有摘要拼接。只在用户手动要求"重新总结"时才全量重算。
  • embedding 结果要缓存:知识库文档只要没变,向量化结果就一直是有效的。我在内存里做了热点片段缓存,命中率大约 40%,能省掉不少向量服务的调用。
  • 并发下的水位竞争:多个请求同时对同一个会话做摘要时,要靠会话级锁避免重复计算。否则两个请求同时触发 summary,历史会被压缩两次。

5. 扩展:把 context-mode 做成通用模块

5.1 对外接口三件套

做到这一步,context-mode 已经不是一个写死在机器人里的函数,而是一个可以独立复用的模块。我给它收敛成了三个对外接口:

# 1. 决策接口:给定当前会话状态和用户输入,返回推荐模式 decide_mode(history, user_query, metrics) -> mode # 2. 组装接口:按模式把各层内容拼成 messages,供模型调用 build_prompt(mode, history, user_query, kb=None) -> messages # 3. 压缩接口:把历史压缩成摘要,并对摘要质量做校验 compress(history) -> summary

三个接口彼此独立,只要保证输入输出结构稳定,内部实现可以随时替换。我见过有人把这三个接口直接做成一个服务,用 HTTP 暴露给多个业务方共用,效果也不错,但前提是每个业务方要自带 token 预算和模式规则,不能全局一套配置包打天下。

5.2 与 Agent 工具调用的配合

如果你的系统不是简单问答,而是有工具调用的 Agent,上下文管理会更难缠:工具返回的结果也占上下文,而且通常很长。比如一个查库存的工具可能返回 2000 token 的表格,但用户真正关心的只是其中几行。我在 context-mode 里给工具结果单开了一个"tool_result"预算层,默认不超过总预算的 25%,并且超过 3 轮以上的旧工具结果,在下一轮会被压缩成"工具执行摘要",只保留关键数值和结论。这个改法让 Agent 在连续调用多个工具后,依然有足够的空间响应后续问题,不会再出现"模型把自己刚调出来的结果忘了"的尴尬。

5.3 后续演进:自适应与评估闭环

目前这版 context-mode 的规则还比较朴素,我下一步想做的事情主要有三件。第一是自适应模式:把"用户当前问题和历史/知识库片段的 embedding 相似度"作为检索模式的触发依据,替代现在不太可靠的关键词列表。第二是多级记忆:把工作记忆(当前窗口内)、短期记忆(会话级摘要)、长期记忆(用户偏好库)分层管理,而不是只靠一个摘要字段。第三是评估闭环:建一个离线评测集,包含各模式下的标准问答,每次规则调整后先跑评测集,再灰度上线。其实很多上下文优化方案都挺好,但缺的就是"用数据证明它真的好",这是我踩完这些坑以后最大的体会。

写到这里,可能你会觉得 context-mode 是一堆琐碎的工程细节。但我的体会恰恰相反:上下文管理的本质不是"省 token",而是管理模型的注意力——决定在有限的窗口里,让模型把注意力放在哪几件事上。省 token、降成本、降延迟,这些都是把注意力管好之后自然会来的结果。如果你也在搞类似的东西,我建议先用一天时间把日志里的 token 消耗、模式命中分布打出来,看清楚现状再动手优化。最后再分享一个小技巧:在把历史压成摘要之前,先把摘要展示给用户看一眼,这个动作能消解掉绝大多数由模式切换带来的不信任感——用户不怕你省内存,怕的是你悄悄"忘了"他说过的话。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进