
做 AI 应用这段时间我最大的感受是选模型只是第一步真正决定产品体验的往往是你怎么管理上下文。尤其是做 agent 类、深度对话类应用时用户聊着聊着模型就开始“失忆”——要么忘记前面说过的关键信息要么 token 超限直接被接口报错打断。后来我设计了一套 context-mode 机制把上下文管理从“一刀切”变成了可配置、可切换、可观测的模式体系算是彻底解决了这个老大难问题。这篇内容就把我这套 context-mode 的完整思路、代码实现和踩坑记录整理出来。对于正在做 LLM 应用、AI 客服、知识库问答、Agent 工作流的开发者来说应该能直接抄作业。1. context-mode 是什么先想清楚上下文管理的核心矛盾1.1 从一次线上事故说起印象很深的一次事故是这样的一个知识库问答机器人平时回答都挺正常但某天一位用户连续问了二十多个问题中间还带着几条长文档粘贴结果机器人突然开始胡言乱语——不仅把前面对话里已经确认过的结论推翻了还把用户名字张冠李戴到另一个客户身上。查日志发现那次会话的上下文已经累计接近 3 万 token而模型的上下文窗口是 8K。我们的程序没有做任何截断或压缩直接把超长内容往模型里塞后端接口报错后重试机制又自动把最老的对话丢掉了一部分导致信息残缺和语义错乱。这件事让我意识到上下文管理不是“要不要保留”的问题而是“保留什么、去掉什么、以什么形态保留”的问题。不同场景下需求的答案完全不同。1.2 context-mode 的核心定义Context-mode 本质是一套上下文管理策略集合允许你在不同的场景下切换不同的上下文处理方式。它回答三个问题哪些信息必须留在窗口里例如用户的明确指令、关键约束哪些信息可以压缩成摘要之后再放进去例如冗长的中间推理过程、旧轮次的寒暄哪些信息可以从窗口里挪出去放到外部存储中按需检索例如大段的参考资料、历史订单明细我把它拆成了四个具体模式完整模式Full Mode、压缩模式Compressed Mode、检索模式Retrieval Mode和混合模式Hybrid Mode。每个模式不是简单的“多留点上下文”或“少留点上下文”而是对上下文的处理方式有本质区别。完整模式所有消息原样保留适合对信息保真度要求极高的场景压缩模式对旧消息做自动摘要只保留核心结论适合长会话检索模式上下文瘦身为“索引指针”内容按需从外部存储中召回混合模式结合会话阶段和任务类型动态路由到上述任一模式这四个模式的存在相当于给上下文管理装了一个“档位”你可以根据业务场景实时切换而不是一套策略走到黑。1.3 这个机制适合谁用如果你正在做这几种类型的应用context-mode 大概率能帮上忙智能客服用户会话轮次长、问题跨度大需要保留用户偏好又要避免 token 爆炸知识库问答大量文档需要被模型参考但文档不可能全部塞进窗口Agent 工作流多个工具调用、中间结果、用户确认记录混杂在一起必须区分优先级长文写作助手需要用户提供大量背景资料但生成阶段其实只需要摘要不过也要泼一盆冷水如果你的应用只是单轮问答、上下文内容从来不会超过窗口大小那这套机制的收益就很有限反而会增加开发成本。动手之前先估算一下自己的会话长度分布别盲目上方案。2. 四种上下文模式的详细设计与取舍2.1 完整模式不做任何精简原样保留完整模式的实现最简单——所有对话消息按时间顺序全部放入上下文窗口。它的优点是信息零损耗模型能看到最完整的历史多轮对话中的细节、情绪、语气都保留下来适合那种“用户明确要求记住每一个细节”的场景。但完整模式有两个致命问题。第一是 token 成本线性增长对话越长越贵第二是窗口溢出风险一旦超过模型上下文限制就必须做截断处理而截断意味着信息丢失。这里我建议给完整模式设置一个“安全水位线”。用 Claude 的 200K 窗口举例我通常会把水位线设在 128K超过这个值就自动触发降级策略而不是等到 200K 才做处理。为什么因为模型在接近窗口上限时注意力分布会明显变差回答质量会下降。实测下来在窗口剩余空间低于 25% 时模型对长文细节的引用精度会明显下降。提示完整模式不是“无脑全放”一定要配合水位线和降级钩子。否则它只是把问题延后而不是解决问题。2.2 压缩模式用摘要换取更长会话压缩模式的核心思路是把旧消息转化为一个结构化的“会话摘要”然后只把摘要和新消息一起发送给模型。举个例子假设用户和机器人进行了一场 20 分钟的家装咨询对话。原始对话可能包含 20 条消息其中有大量关于瓷砖颜色的讨论、价格比较和反复确认。在压缩模式下这 20 条消息会变成一段 200 字的总结包含“用户偏好灰色系瓷砖”“预算每平米不超过 150 元”“比较倾向于品牌 A 和品牌 B”这几个关键结论。实现上我把它拆成三个步骤设定触发条件比如总 token 超过窗口的 70%或者轮次超过 10 轮执行摘要重写将最早的 N 条消息发送给模型生成结构化摘要替换原始消息用生成的摘要替换掉原始消息并将原始消息存入长期存储压缩模式的优点是显著延长会话长度成本低缺点是摘要过程本身存在信息失真风险尤其是对话中有数字、专有名词、否定表达时模型很容易在摘要中丢失或篡改关键信息。我把这个模式的适用范围限定在“过程性信息”上——也就是那些已经被后续对话确认或覆盖的内容。比如用户先说“我预算 10 万”后面又说“预算提高到 12 万”那前面的“10 万”就属于过时信息压缩时只需要保留最终值即可。但如果一条信息后续再没有被确认过那就不应该被压缩。2.3 检索模式从“全量记忆”到“按需召回”检索模式是完全不同的思路——不试图把历史信息压缩进窗口而是把历史内容向量化存入外部数据库每次请求时根据用户当前问题召回最相关的片段再把这些片段拼进上下文。这个方案最典型的应用场景是知识库问答。一千篇产品文档不可能全部塞进上下文但你可以提前把文档切块、embedding 成向量用户提问时用相似度检索找出最相关的 3 到 5 个块把这些块作为“参考资料”和当前问题一起发送给模型。检索模式我踩过一个典型坑早期我直接把整个文档塞进 embedding切块大小设为 2000 token结果召回效果非常差。原因是文档中一段内容可能包含多个不相关的知识点导致向量语义被稀释查询时匹配不准确。后来改为按语义边界切块每块控制在 500 token 左右召回准确率明显提升。再一个坑是召回位置偏差问题模型对排在中间位置的参考内容有注意力偏好所以我把按相关度排序改成“引言 最重要块 补充块”的结构化拼接方式回答质量更稳定。检索模式的优点是不再受窗口大小限制理论上可以接入无限量的历史信息缺点是依赖检索质量一旦向量召回不准模型就会答非所问。而且它的上下文连贯性弱——模型看到的是零散片段可能忽略片段之间的逻辑关系。2.4 混合模式根据任务阶段动态路由真正解决复杂问题的方案是混合模式。它的核心思路是把整个会话看作一个状态机每个状态对应不同的上下文处理策略。我的路由规则是这样的会话前 2 轮完整模式充分理解用户意图用户连续提问且上下文累积到阈值切换到压缩模式保持流畅对话问题涉及具体文档或历史记录临时切换到检索模式召回相关资料一次完整子任务结束时将整个子任务的结果整理成结构化摘要替换原始过程举例来说一个数据分析 Agent 的工作流可能是这样的用户上传了 CSV 文件并要求做销售分析。前两轮 Agent 用完整模式理解文件结构和用户需求。分析过程中模型需要进行多轮数据处理和结果展示这些中间过程被压缩成摘要。当用户突然问“去年 3 月华东区的数据是多少”时Agent 切换到检索模式从向量库中精确召回那个时间段的数据。最后整个分析任务完成时所有中间步骤被固化成一份最终报告摘要上下文窗口被彻底释放。混合模式的优势是兼顾保真和效率但实现复杂度最高需要一套可靠的“会话状态路由逻辑”。如果路由判断错误反而会引入不必要的开销和延迟。2.5 四个模式的选型对比模式信息保真度成本趋势会话时长上限实现复杂度典型场景完整模式极高线性增长受窗口限制低短会话、精确操作压缩模式中高亚线性增长长中客服、长对话检索模式依赖召回质量相对固定极长高知识库、文档问答混合模式可调节动态极长很高Agent、复杂工作流我的建议是不要一上来就追求混合模式。先让完整模式跑通业务逻辑压测发现窗口瓶颈后再按需叠加压缩或检索最后才考虑混合路由。做过几次之后你会对每个模式的边界条件有更清晰的感知。3. 动手实现一个 context-mode 管理器3.1 总体架构与关键数据结构我实现 context-mode 管理器时把它设计成一个独立的 Python 服务组件与业务逻辑解耦。它对外只暴露两个核心方法class ContextManager: def add_message(self, message: dict) - None: 向会话上下文追加一条新消息 pass def build_request(self, current_prompt: str, mode: str) - list[dict]: 根据当前模式构造发送给模型的messages数组 pass所有上下文状态存储在一个会话对象中结构如下session { session_id: sess_12345, messages: [], # 原始消息列表按时间排列 summary: , # 压缩摘要由summary生成器维护 vector_index: [], # 已向量化的消息和文档块索引 token_usage: 0, # 当前窗口已用token数 mode: full, # 当前生效的上下文模式 state: understanding # 路由状态机的当前状态 }这个数据结构的核心设计思路是原始消息、摘要、向量索引三者互相冗余但职责不同。原始消息保证可追溯摘要保证长程记忆向量索引保证可检索。build_request 方法负责根据当前模式将这三者组合成最终的 messages 数组。3.2 Token 估算与窗口预算管理做好 context-mode第一步是精确估算 token 占用。不要依赖模型的 tokenizer 在线调用太慢也不稳定。我建议用 tiktoken 或基于字符数估算的快速方法。import tiktoken def estimate_tokens(text: str, model: str gpt-4) - int: enc tiktoken.encoding_for_model(model) return len(enc.encode(text)) def estimate_safe_window(model_max_tokens: int) - int: # 安全水位线最大窗口的85% return int(model_max_tokens * 0.85)这里有一个关键点不同模型的 tokenizer 不同如果你在多个模型之间切换最好把 token 估算函数也做成可配置的根据当前模型加载对应编码器。我早期偷懒统一用 gpt-4 的编码器估算所有模型的 token 数结果换到另一个模型后请求频繁超限改为逐模型估算后恢复正常。窗口预算我是这样分配的固定开销system prompt、指令模板预留 20%当前用户输入预估后动态计算上下文部分摘要 检索片段 历史消息占剩余空间的一半生成空间模型输出 token至少预留 30%之所以给生成空间预留这么高是因为如果输出空间不足模型会被迫提前截断回答不完整。很多开发者只关注输入窗口忽略了输出窗口的占比这是很常见的坑。3.3 压缩触发策略何时切换模式我的触发策略不是一个简单的阈值而是组合判断def should_compress(session, user_input_tokens: int) - bool: total estimate_tokens(session) user_input_tokens output_reserve if total safe_window: return False # 如果已经接近窗口上限且当前轮次超过5轮 if total safe_window and session[state] understanding: return True # 检测到会话中存在可折叠的中间过程 if has_redundant_messages(session): return True return False触发压缩后我会执行一个“摘要重写”操作把最旧的若干轮消息发送给模型要求模型输出一个 JSON 格式的结构化摘要包含用户目标、关键偏好、已确认的信息、待办事项等字段。summary_prompt 请将以下对话整理为JSON格式摘要包含字段 - user_profile: 用户画像与偏好 - confirmed_facts: 已确认的关键信息 - pending_items: 待办事项或未解决问题 - decisions: 已经做出的决定 注意只保留与任务相关的信息忽略寒暄和无关内容。 ---对话内容--- {messages} def generate_summary(messages: list[dict]) - dict: resp llm.chat([ {role: system, content: summary_prompt}, {role: user, content: str(messages)} ]) return json.loads(resp)压缩完成后把摘要写回 session[summary]同时将原始消息存入一个单独的归档存储中方便需要时回溯。3.4 检索索引的更新与召回逻辑检索模式的实现核心是向量索引管理。我这里用了一个非常轻量的方案所有消息在进入会话时都逐一文本切块并调用 embedding 接口生成向量存入本地 FAISS 索引。当模式切换到检索模式时用当前用户问题作为查询向量进行相似度搜索。召回逻辑有一个细节不能直接搜索整个会话的拼接文本而是要对每一条消息或文档块分别建立向量。因为拼接内容会造成语义混淆召回精度下降。按消息粒度建索引后召回时还要做一次重排序——不能只看向量相似度还要参考消息的时间顺序和消息来源类型。def retrieve_context(query: str, session: dict, top_k: int 3) - list[str]: query_vec embed(query) scores faiss_search(session[vector_index], query_vec, top_k * 2) # 结合时间权重近期消息权重更高 ranked sorted(scores, keylambda x: x[score] * time_decay(x[timestamp]), reverseTrue) return [x[text] for x in ranked[:top_k]]加时间衰减权重这个操作是我在实测中琢磨出来的。有一次用户问“我刚才说想换什么样的”如果只看向量相似度模型召回的是最初几轮的消息但用户实际指的是最近一轮提到的方案——加时间衰减后近期消息的权重提高这类“指代当前会话语境”的问题召回准确率提升明显。3.5 完整模式下的消息组装逻辑build_request 方法中四种模式的组装逻辑如下def build_request(session, user_prompt, mode): if mode full: messages session[messages] [{role: user, content: user_prompt}] elif mode compressed: messages [ {role: system, content: session[summary]}, ] recent_messages(session, n4) [{role: user, content: user_prompt}] elif mode retrieval: hits retrieve_context(user_prompt, session) context_block \n---\n.join(hits) messages [ {role: system, content: 参考以下资料回答 context_block}, {role: user, content: user_prompt} ] elif mode hybrid: messages route_build(session, user_prompt) return messages这里注意 compressed 模式的一个细节压缩摘要之后必须保留最近 4 条原始消息。为什么是 4 条因为最近几轮对话通常包含用户最重要的即时指令摘要里这些信息往往是高度概括的细节会丢失。保留最近消息可以保证即时指令的完整性又不至于让窗口被全部历史消息占满。我在实际中调过 2、4、6 三个档位4 是保真和成本和性能之间的平衡点。4. 实操过程中的坑与排查记录4.1 摘要压缩导致关键信息失真这个坑真的踩了很多次。有次用户的对话里包含一个具体的价格数字“原价 1999促销价 1499”压缩摘要生成的文本却是“用户对价格有一定优惠预期”。模型基于这个摘要回答问题时给出了完全错误的建议。排查后发现摘要生成模型在压缩长文本时会倾向“概括语义”而非“保留精确数值”尤其是当数值出现在多个上下文中时模型很容易丢失具体数字。我的解法是调整摘要 prompt明确要求“所有数字、产品型号、日期必须原样保留禁止推测或概括”。同时在生成摘要后加一道校验逻辑提取原始文本中的所有数字和专有名词与摘要中的对应项做比对缺失的自动追加到“补充关键词”字段。提示摘要场景下模型默认行为是“意译”而不是“摘录”。必须在 prompt 中强调“原样保留关键实体”并且用程序做二次校验不要全部依赖模型自律。4.2 检索模式召回“过期内容”检索模式有一个副作用如果历史消息中有大量已经被推翻或修正的信息向量检索时这些过期内容会被优先召回因为它们的词面相似度往往更高。模型引用这些内容回答就产生了错误。我的解决办法是为每个向量块建立一个“有效期”字段。消息内容被后续消息明确修正时原始块的向量权重直接降为 0不再参与召回。这个“修正关系”可以在消息写入时通过一个简单规则识别如果新消息中包含“不对”“改成”“更正为”这类词且和前面的消息存在相同实体就判定为修正自动失效旧消息。另外在召回时加一个过滤条件只召回未失效且时间在最近 X 小时内的块。这个时间窗口可以根据业务自己调知识库场景可以设长一点客服场景短一点。4.3 token 计数不准导致请求超额刚开始我用字符数估算 token误差特别大。中文字符一个约等于 1.5 到 2 个 token英文一个单词约等于 1.3 个 token混排时估算偏差能达到 30%经常导致请求超额被拒绝。后来我彻底换了方案使用 tiktoken 精确编码但缓存编码结果避免每条消息都重复计算。我把 session 中每条消息的 token 数在写入时就算好并缓存会话上下文的 token 总数是增量更新的只有在摘要或检索片段变化时才重新计算。这样既保证了精度又没有性能问题。还有一个隐蔽的问题不同的模型有不同的 token 计算方式。如果你的应用支持多个模型切换比如用户可以在 GPT-4 和某开源模型之间选择那么 token 计数必须跟随模型动态切换不能沿用旧的计数缓存。这个我在线上切换模型时被坑过一个会话从模型 A 切到模型 B 后token 数瞬间超限因为两个模型的 tokenizer 差异很大。4.4 模式切换太频繁破坏对话体验模式切换如果做得太“自动化”会引发一个体验问题用户感觉模型突然变得“健忘”——上一秒还能记住的细节下一秒就被摘要替换掉了。原因是压缩模式触发得太频繁、太激进。我把触发压缩的条件从单一 token 阈值改成了“时间 轮次 token”三者组合判断。例如距离上次压缩至少 5 轮对话当前窗口使用率超过 70%当前会话状态不是“知识密集型任务执行中”三个条件同时满足才触发压缩。这样压缩不会频繁发生每次压缩的间隔足够长用户在体验上感知不强而窗口水位又始终保持在安全范围内。4.5 效果评估离线脚本与线上回归刚上线 context-mode 的时候我没有什么可靠手段验证它到底是变好了还是变坏了。后来我补了一套简单的离线评估脚本核心思路是准备 50 到 100 个真实的历史会话样本每条样本人工标注了“正确回答应该包含的关键点”。每次改动 context-mode 逻辑后用这批样本跑一遍统计关键点命中率。这套离线评估帮我发现了压缩模式的一个明显短板当摘要长度超过 500 字时模型的参考效率反而下降。原因是摘要过长时信息的组织方式很乱——它按时间顺序排列但不同主题的信息交织在一起模型要自己反复搜索。后来我把摘要改成“主题分区”格式把“用户目标”“关键决策”“未决问题”“已确认信息”分别作为一个小节模型参考时可以快速定位到对应分区。改造后关键点命中率从 68% 提升到 83%。线上回归我主要监控两个指标平均 token 消耗量和用户侧的评价数据。token 消耗量用于验证成本控制效果评价数据用于验证回答质量是否退化。如果 token 下降了但评价也下降了说明压缩策略太激进需要回调阈值。5. 上下文管理的几条经验与扩展思路做了这一整套 context-mode 之后我最大的收获是把“管理上下文”这件事从代码层面提升到产品层面。早期我只关心“如何塞进更多内容”现在我会先思考“用户在这个场景中真正需要哪些信息”。这是一个重要的思维转变。这里分享几条实践中沉淀下来的经验别追求“记住一切”要追求“该记住的记住、该忘掉的及时忘掉”。模型的上下文窗口本身就是一种稀缺资源把资源花在重要信息上才有价值所有的阈值和策略都不能拍脑袋定而是基于用户会话的统计分布来做决策。至少要花一周时间采集线上数据看看典型会话的轮次分布、token 分布、主题切换频率再设计触发策略上下文管理一定要可观测。我在 manager 里加了关键日志每次压缩发生在哪一轮、压缩前 token 数、压缩后 token 数、摘要长度、召回命中分数——这些数据比任何猜测都有说服力降级兜底非常重要。无论什么模式都要考虑极端情况模型返回为空、embedding 服务不可用、向量数据库超时。我通常做两层兜底第一层走完整模式强制不压缩第二层直接返回固定提示确保服务不挂至于 context-mode 在其他场景的扩展我的体会是这套思路不仅能用于大模型应用。日志系统的上下文聚合、代码编辑器的折叠策略、知识管理工具的自动归档本质上都属于“如何在有限空间里管理高价值信息”的问题。核心逻辑是一致的——判断信息的重要程度选择保留、压缩或索引然后设计一套自动化的策略来执行。将来我还打算加入一项能力让 context-mode 能根据用户提问的“风险程度”自适应调整。如果问题涉及的是财务数据或合同条款这种高风险信息就强制走完整模式即使 token 成本更高也绝不压缩如果是闲聊、寒暄类内容就放心大胆走压缩模式。毕竟上下文管理的最终目标不是省成本而是在成本和正确性之间找到最优解。这一点方向对了后面的事就好办了。