尧图精选

Context-Mode:AI应用上下文管理的分层设计与工程实践

🕒 发布时间:2026/9/11 11:12:09 📁 来源:尧图网络
前阵子有个朋友找我诉苦说他的AI客服项目效果越做越差明明模型没换、提示词也没大改但用户稍微多聊几句回复就开始前言不搭后语。我问他上下文怎么管理的他愣了一下反问上下文不就是把聊天记录都丢给模型吗这个反问让我意识到很多人其实根本没把上下文当成一个需要专门设计的环节而是把它当成模型自带的缓存。后来我帮他把上下文管理重构了一遍效果立刻就不一样了。这个过程中我整理沉淀出的那套东西就是今天想聊的context-mode。它可以是一套代码结构也可以是一组设计原则但核心就一句话在正确的时机用正确的格式把正确范围的上下文交给模型。这篇文章我会从设计思路、代码实现、踩坑过程到主流框架的落地方式把整个链路完整拆开希望对正在做AI应用或者对上下文管理头疼的人有实际帮助。1. 缘起模型能力再强也架不住上下文失控1.1 一个典型的能力天花板假象先说那个客服项目。技术栈不复杂一个RAG流程接大模型接口知识库里有产品文档和售后政策。上线初期一切正常准确率也算拿得出手。但运营了两周后出现了一个非常奇怪的现象单轮问题都是好的一旦用户连续追问三四轮模型就开始犯迷糊有时候甚至会把别的用户的需求串进来。我当时排查了一圈第一反应是prompt被污染了检查之后发现不是。然后怀疑检索效果下降单独跑检索测试也正常。最后把发给模型的完整请求打出来一看问题一目了然用户A问过的产品型号、用户B提到过的优惠码、半个多月前的对话片段全都被拼在了一个巨大的messages数组里。这就是典型的上下文失控。模型的上下文窗口是有上限的但很多人以为只要没超上限把所有历史一股脑塞进去就万事大吉。实际上上下文里的信息密度、相关度、噪声比例共同决定了模型输出质量的上下限。上下文越长模型越容易迷失在中间越难聚焦到当前真正有用的信息上。1.2 context-mode到底在解决什么问题所谓context-mode我觉得更准确的翻译是上下文工作模式或者上下文管理模式。它不是某一个具体算法而是一整套对上下文进行加工、筛选、组织和切换的工程方案。我给它下的定义是这样的context-mode是对AI应用中上下文信息的全生命周期管理方式它决定了哪些信息该进、哪些信息该出、以什么顺序排布、占多少权重、以及在不同任务阶段如何切换上下文集合。这种管理方式需要解决四个核心问题存什么原始数据那么多哪些值得进入上下文怎么存用什么结构承载上下文才能让模型高效利用怎么取不同任务场景下如何精准提取当前最需要的上下文子集怎么更新上下文和现实同步变化时如何增量更新而不是全部重建如果你接触过操作系统的概念会发现context-mode和进程上下文切换有异曲同工之处——操作系统不会让所有进程共享同一份CPU寄存器状态每个进程都有自己独立保存的上下文切换时只恢复当前需要的部分。AI应用的上下文管理也应该有这种隔离按需加载的思路。2. 设计思路把事情变简单的分层上下文架构2.1 先把上下文拆成五个层次很多人把上下文当成一个大口袋什么都能装。我自己的经验是必须先分层。不分层的上下文管理只会在项目规模变大之后彻底失控。我自己设计了五个上下层次层级生命周期典型内容示例系统层整个项目生命周期角色设定、全局规则、价值观约束你是购物助手回复简洁业务层单个业务场景当前业务领域知识、工具定义优惠规则、商品库schema会话层单次会话对话历史、用户偏好、临时状态聊天记录摘要、当前订单编号请求层单次请求本次用户输入、检索命中的知识块用户当前提问、RAG相关片段工具层按需动态注入函数调用结果、临时计算输出库存查询返回值、价格计算结果这五层不是凭空分的每一层都有明确的生命周期和更新频率差异。系统层几乎不变业务层跟随知识库更新会话层跟随对话进度变化请求层每次请求都不同工具层则完全由运行时的中间结果决定。分层的好处立刻就能体现出来你可以为不同层次配置不同的更新策略、不同的缓存机制和不同的token预算。比如系统层内容可以做成预编译的模板字符串业务层可以做版本化管理会话层可以做摘要滚动请求层只保留当前轮次。2.2 优先级与token预算怎么分配分层之后紧接着的问题是每一层能占多少token预算。很多人的做法是靠感觉调但我觉得事先规划一个基础比例是更可控的做法。我目前比较常用的初始比例是这样的系统层固定留出600~800 token业务层根据场景复杂度预留800~1500 token会话层根据上下文窗口大小一般控制在总窗口的三分之一到二分之一请求层是核心保留至少1000~2000 token给用户输入和检索结果工具层按需分配用完及时释放这个比例不是死的。比如模型上下文窗口是128K的时候会话层可以放宽很多但如果是8K窗口的小模型就必须严格限制会话层甚至只保留摘要。核心原则是请求层永远不能被挤爆因为它是当下任务成败的关键。我见过不少人把大量token花在堆砌历史对话上结果真正重要的用户当前输入反而被截断了这属于本末倒置。context-mode应该让模型看得全而不是让它看得多。2.3 上下文切换从一个prompt打天下到分场景编排另一个容易被忽视的点是不同任务需要的上下文组合是不一样的。比如同样一个AI客服处理查订单状态这个任务需要的主要是用户身份信息、订单号、物流接口返回结果但处理推荐商品这个任务需要的是用户之前看过什么、当前偏好是什么、库存有什么。如果硬用同一套完整上下文去应对所有任务那不管窗口多大都会出现信息冗余或缺失。我实现的context-mode方案里有一个ContextBuilder它可以根据当前意图动态组装一个任务上下文集合。当意图分类结果是查订单就只注入订单相关模板检索结果当意图是闲聊就只注入简洁的会话摘要连知识库都不检索。这样做之后不仅响应质量提升了token成本也明显下降。3. 核心实现一套可直接照搬的context-mode代码骨架3.1 用数据类定义上下 文结构说了一堆设计原则接下来上点实用的东西。我用Python写了一个比较通用的context-mode实现骨架贴合大多数AI应用的后端场景可以直接改改就用。首先定义上下文的统一存储结构。不客气地说如果你连上下文的数据结构都没有设计那后面的一切管理都是空谈。我的做法是用dataclass定义每一层的描述from dataclasses import dataclass, field from typing import Any, Optional from enum import Enum import time class ContextLayer(str, Enum): SYSTEM system BUSINESS business SESSION session REQUEST request TOOL tool dataclass class ContextBlock: layer: ContextLayer content: str priority: int 5 # 1-10数字越大越重要 expires_at: Optional[float] None # 过期时间戳None表示不过期 metadata: dict[str, Any] field(default_factorydict) dataclass class ContextWindow: blocks: list[ContextBlock] field(default_factorylist) max_tokens: int 8000 reserved_request_tokens: int 2000 def add(self, block: ContextBlock) - None: self.blocks.append(block) def purge_expired(self) - None: now time.time() self.blocks [ b for b in self.blocks if b.expires_at is None or b.expires_at now ]你可能会问为什么要用expires_at做过期时间。这看起来多此一举但实际项目中非常有用。比如工具层的库存查询结果可能5秒之后就失效了业务层的秒杀活动规则可能活动结束就要移除而系统层可以永不过期。让每一块上下文都有明确的生命周期是context-mode和一把梭塞历史记录的根本区别。3.2 组装上下文的核心逻辑有了结构之后下一步是组装。这里的关键在于你要能按需排序和裁剪。我会先按层分组再组内按优先级排序最后做token预算裁剪。import tiktoken class ContextManager: def __init__(self, max_tokens: int 8000, model_encoding: str cl100k_base): self.window ContextWindow(max_tokensmax_tokens) self.encoder tiktoken.get_encoding(model_encoding) self._layer_order [ ContextLayer.SYSTEM, ContextLayer.BUSINESS, ContextLayer.SESSION, ContextLayer.REQUEST, ContextLayer.TOOL, ] def build_prompt(self, user_query: str, rag_results: list[str] | None None, tool_outputs: list[str] | None None) - str: # 1. 组装当前请求层和工具层 self.window.blocks [ b for b in self.window.blocks if b.layer not in (ContextLayer.REQUEST, ContextLayer.TOOL) ] request_block ContextBlock( layerContextLayer.REQUEST, contentf用户当前问题:\n{user_query}, priority10, ) self.window.add(request_block) if rag_results: self.window.add(ContextBlock( layerContextLayer.REQUEST, content参考资料:\n \n---\n.join(rag_results), priority9, )) if tool_outputs: self.window.add(ContextBlock( layerContextLayer.TOOL, content工具返回结果:\n \n.join(tool_outputs), priority8, )) self.window.purge_expired() truncated self._truncate_to_budget() parts [block.content for block in truncated] return \n\n.join(parts) def _truncate_to_budget(self) - list[ContextBlock]: self.window.blocks.sort( keylambda b: (self._layer_order.index(b.layer), -b.priority) ) result: list[ContextBlock] [] total 0 for block in self.window.blocks: count len(self.encoder.encode(block.content)) if total count self.window.max_tokens: continue # 超出预算整块丢弃 result.append(block) total count return result代码本身不复杂但有几个细节值得展开说说。第一build_prompt的第一步是先清空请求层和工具层再注入当前请求。这一步特别重要它保证了每次请求都是上一轮的历史这一轮的新请求结构而不是上一个请求的完整包这一个请求。很多人处理翻车就是栽在这里——上一轮的工具返回结果被原封不动带到下一轮造成信息冗余甚至串台。第二层级的排序规则是先块层再优先级。无论请求层的优先级有多低它都必须排在系统层之后、工具层之前。这个顺序不是拍脑袋定的而是符合大模型理解习惯的顺序先知道你是谁、规则是什么再知道历史发生了什么最后知道现在要做什么。3.3 会话历史的滚动摘要机制会话层是最复杂的一层。全程Raw聊天记录都塞进去窗口很快就会爆。我的方案是三层递进最近两轮对话保留原文一字不改再往前的内容压缩为摘要用独立LLM调用生成更早的内容只保留关键信息点比如用户已确认收货地址、“用户要求不要短信打扰”这个设计背后考虑的其实是信息的时效性差异。模型最能利用的是最近几轮的直接上下文中间部分的信息需求是知道大概发生了什么而不是每个字都还原最早期部分只需要留下影响后续行为的约束条件。实现上我会在会话层维护一个带摘要的滚动缓存。当原文超过N个块或N个token时触发一次摘要重写。摘要本身的token预算也要受限我自己通常控制在整个会话层预算的40%以内。def summarize_old_sessions(self, old_blocks: list[ContextBlock]) - str: # 用一次轻量模型调用压缩旧对话历史 old_text \n.join(block.content for block in old_blocks) summary_prompt ( f请将以下对话历史压缩为300字以内的中文摘要 f保留所有影响后续回答的关键事实、用户偏好和待办事项\n{old_text} ) # 这里调用你的LLM接口假设有个 llm.complete() 函数 summary llm.complete(summary_prompt, max_tokens400) return summary摘要做完之后用一个新的ContextBlock替换掉旧的多个块再塞回ContextWindow。注意摘要块也设置一个过期时间比如10分钟无人回复就自动过期防止跨会话串味。提示摘要不是万能的。如果做的是法律、医疗这类对细节要求极高的领域摘要可能造成关键信息丢失。这种场景建议改成原文裁剪策略——只保留最近的N轮早期内容直接丢弃也不要乱压缩。宁可让模型不知道也不能让它知道一个错误版本。4. 实战复盘一次生产环境的上下文事故排查全记录4.1 现象同样的prompt换个时间段效果天差地别我自己的一个知识库问答项目曾经出过一起很难查的故障。现象是每天早上刚部署完回答质量非常高跑了两三个小时之后质量就开始下滑到下午基本就属于能用但明显变蠢的状态。最初我怀疑是模型服务端的问题但同模型的接口在别的地方没有问题。后来我把日志里发给模型的prompt完整留存下来前后对比发现了一个规律出问题的时候prompt里多出了很多看起来有用但其实没用的旧知识块。原来我当时参照网上常见做法把每轮用户提问之后的RAG检索结果都直接追加到历史数组里想着多轮问答时早期检索到的知识对后面的问题可能有帮助。这个推理本身有道理但忽略了检索结果的时效性和相关性会衰减。用户在上午问了A产品怎么退换货检索结果当时有用下午用户改问B产品保修政策上午检索到的A产品退换货流程就成了纯噪声还占据了不少token。4.2 定位过程用白盒日志还原每一步决策这个问题的排查过程我总结成一套可复用的排查链路分享出来供参考。第一步全链路日志打点。凡是进入context-mode管理代码的每个块都记录它所属的层、来源、注入时间、token数。这样后面任何一次输出异常都能回放这条pompt是怎么组装出来的。第二步对比前后差异。把好的prompt和差的prompt做结构化对比。我最终用脚本统计了两者的token分布比例发现差的prompt里来自历史RAG块的token占比从正常的15%左右飙升到了50%以上。第三步验证假设。手动构造一组只含检索当轮知识的prompt发给模型效果恢复正常。这就确认了问题出在历史RAG块的污染。第四步修复回归。修复方案是给RAG检索块加上严格的expires_at并且在同一轮次结束之后就把该块的优先级降为1一旦新一轮检索结果产生旧块自动被新块覆盖。修复后连续观察一周效果稳定问题消失。4.3 修复后的一个衍生优化请求级缓存与多路召回融合定位并修复之后我又顺手做了一个衍生优化在请求层里对用户问题做意图分类。如果是简单闲聊完全不检索知识库直接走一个精简的上下文模板如果是售后问题才注入商品订单相关的检索结果。这个优化的收益很直观token消耗下降了差不多30%响应延迟也降低了因为省掉了大量检索和拼装的时间。另外关于检索结果本身我也做了一个融合处理。以前RAG只返回Top3知识块但现在我会用简单规则做一次标题相似度内容关键词的轻量排序再把排序后的块统一放进请求层。这样能减少无关知识块进入上下文让context-mode在源头上就少摄入垃圾信息。5. 主流框架里怎么落实context-mode这套思想5.1 LangChain与LlamaIndex的对照很多人在用LangChain这类框架觉得框架里没有现成的context-mode叫法所以不知道从哪下手。其实框架只是工具思想可以迁移。LangChain里面最接近context-mode概念的是ConversationBufferWindowMemory、ConversationSummaryMemory和create_history_aware_retriever。这三个东西分别对应了原始历史、摘要历史和检索感知历史。你完全可以组合它们来实现我前面说的三层递进会话层。不过我的使用经验是LangChain的Memory模块在简单Demo里很顺手但到生产环境往往不够灵活。它的更新逻辑是每次自动追加没有内置的过期、分级、按任务切换机制。所以我目前自己的主力框架不太依赖LangChain Memory而是直接用自定义的ContextManager代替LangChain只负责Retriever和LLM调用。LlamaIndex这边ChatMemoryBuffer算是更接近context-mode思路的东西它可以限制token数量并自动淘汰旧消息。但坦白说LlamaIndex的淘汰策略是线性的不支持按层按优先级淘汰。所以真要搞复杂场景我建议还是自研一个调度层把框架的chat memory当成存储元件接入而不是当成完整解决方案。5.2 Claude和OpenAI的上下文接口怎么接如果直接使用大模型APIcontext-mode的落地方式就更简单了因为你可以完全控制messages数组。以Claude API为例它的system字段天然适合存系统层业务层内容。我用的时候会把系统层和业务层预渲染成一整段静态字符串每次请求直接带上不算在对话历史里。messages数组里第一轮放会话摘要后面放最近几轮的原始对话最后一个user消息放当前请求。这个结构和我前面手写的ContextManager生成结果几乎一致。用OpenAI API也类似但有个小差别OpenAI没有专门的system字段数组只在messages里用rolesystem。我会把系统层放第一个rolesystem的消息业务层如果有需要动态更新的也放rolesystem或者作为roleuser但加这是一个业务规则前缀。实测下来把业务规则放system比塞进user消息更稳定因为模型对system角色的指令遵循率通常更高。5.3 自研与框架的边界感写完这些落地方式我想强调一个观点context-mode不是非黑即白的二选一而是应该形成一套组合方案。我现在的项目架构基本是这样的存储层用向量数据库和Redis负责上下文持久化和快速检索调度层用自研的ContextManager负责层管理、预算控制和任务编排模型接口层用官方SDK比如LangChain或直接API监控层记录每次请求的各层token占比和响应质量分这种组合的维护成本比纯框架高一点但换来的可控性提升非常值得。尤其是当你需要精细控制上下文的时候框架自带的默认行为往往帮不了你反而会碍事。6. 关于context-mode我的几点实践忠告6.1 什么场景必须用什么场景可以先不用先给场景做个粗略分类方便你判断是否值得投入精力。必须认真设计context-mode的场景多轮对话产品尤其是用户交互超过5轮的RAG问答系统知识库规模大且不断变化Agent类应用要调用多个工具涉及多步推理对token成本敏感的商业化项目可以先不用太复杂context-mode的场景单轮问答的简单客服入口问题只有一个回合内部工具类小应用只给自己用数据量很小纯文本生成任务比如摘要、文案和对话历史无关如果你属于后者直接构造一个固定prompt就好不需要为了看起来专业去过度设计等规模上来了再迭代。最怕的是反过来小项目用了很重的上下文管理系统结果维护成本比收益还高。6.2 一些容易被忽略的工程细节有几个细节是我从多次翻车经历里总结出来的不一定写在教科书上但特别重要。第一上下文必须可观测。如果没有完整的日志记录每次发出去的prompt长什么样你根本没法排查任何质量问题。我给自己定的规矩是线上环境默认保存完整prompt和response到日志系统至少保留30天。出现投诉时可以按时间去查离线复盘。第二上下文管理要可测试。我建了一组回归测试用例包含几十个典型场景用户问过老问题又在后面问新问题、用户中途切换话题、用户提供错误信息又自行纠正、长时间不说话的安静会话恢复等等。每次修改ContextManager的代码都会先跑一遍这组测试确保不会把之前修好的场景打回原形。这个习惯帮我避过好几次回归灾难。第三token计算要和真实模型保持一致。不同模型的tokenizer逻辑不同用近似方法估算会导致裁剪不准。我的方法是建立一张模型名到tokenizer的映射表代码里直接调用对应模型的tokenizer而不是统一用cl100k_base。虽然运行时多了一点点开销但精确度提升明显尤其对中文内容。6.3 接下来的演化方向最后说说我认为context-mode未来还能向哪个方向走。一个是动态化、自适应的上下文编排。现在的方案里层的优先级和token预算是静态配置的不够智能。我在尝试的一个方向是让系统根据用户行为和响应质量分自动调整层token比例。如果连续几轮检索引用率很高就自动加大请求层检索部分的token预算如果用户频繁回到之前讨论过的话题系统会触发对早期内容的重新召回和注入。另一个是跨会话的用户长期画像与短期意图分离。context-mode不能只局限于单次会话要把用户在多个会话中沉淀下来的稳定偏好和当前会话的临时意图分开管理。长期画像慢更新短期意图快更新两者在组装prompt时以不同权重融合。这种方案对电商、社交、服务类产品价值非常大。还有一个是多模态上下文的统一管理。VLM模型越来越普及图片、音频、视频都可能是上下文的一部分。context-mode的设计原理依然适用——信息分层、按需加载、生命周期管理——但从单文本扩展到多模态之后存储、索引和token计算的复杂度会指数级上升。提前做好层与块的抽象会让以后接入多模态变得顺畅很多。我在实际项目中踩过不少坑一步一步把这些经验收敛成了上面这套做法。每次看到有人讨论模型输出又开始胡说八道的时候我都会建议他们先别急着换大模型打开日志看看自己发给模型的到底是什么。很多时候问题不在模型而在你交给模型的上下文。context-mode不是什么高深理论它就是帮你把喂给模型的信息这件事管起来的一套工程手段。下次你的AI应用效果不稳定不妨先从这个角度自查一遍。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →