1. 一次真实的 context-mode 折腾记:先说结论
我最近接手了一个挺有意思的内部工具项目,标题就一行字:context-mode。当时拿着这个标题我愣了半天——因为这个词在不同场景下的含义差太多了。有的是指编辑器里的上下文模式,有的是指命令行工具的上下文匹配,还有的是指大模型对话时的上下文切换策略。说白了,context-mode这个标题背后藏着的是一整类问题:怎么让系统在"上下文有限"的前提下,干好"上下文敏感"的活儿。
这篇文章我想把这些年做 context-mode 类功能踩过的坑、拆过的方案、最后沉淀出来的那套打法,完完整整梳理一遍。内容包括:context-mode 到底解决了什么问题、不同技术选型之间的取舍、一套可以在生产环境直接落地的实现流程,以及那些日志里不会告诉你的排查技巧。适合做 AI 应用开发、对话系统、工具链设计的同学参考,也适合那些正准备上 context 管理功能但是还没想清楚怎么做的人。
先说结论:context-mode 不是一个单一功能,而是一整套"上下文管理策略"。它要解决的三个核心矛盾是——信息过载、记忆混乱、成本失控。后文我会一层层把这三个问题拆开讲,并给出对应的实现方案。
2. context-mode 的核心需求拆解:三个不能回避的问题
2.1 信息过载:不是所有上下文都值得进模型
做对话类应用的人应该都有体会,用户丢给你一段几百页的文档,或者一整年的聊天记录,说要"基于这些内容给我一个产出"。如果你真的把所有内容一股脑塞给模型,结果往往是灾难性的:模型被无关细节分散了注意力、关键信息反而被淹没、生成质量断崖式下跌。
context-mode 最基础也是最要紧的工作,就是做信息筛选。它要回答一个核心问题:在所有可用的上下文中,哪些才是在当前任务里真正有用的?这个筛选不是简单的"截断前 N 个字符",而是一个信息论意义上的取舍:保留高价值信息、降低噪音,让模型在有限的上下文窗口里看最该看的东西。
实际操作中,我一般把信息过载问题分成三种情况来处理:一是长文档场景,需要做分段、索引、检索,而不是全文搬运;二是多轮对话场景,需要对历史消息做摘要压缩,而不是原样保留;三是多源信息场景,比如同时有文档、网页、数据库记录,需要做融合与优先级排序,而不是简单拼接。
拿我最近做的一个客服问答系统举例。之前团队的做法是把用户所有历史工单记录全部拼到 prompt 里,结果模型经常被一份三年前的退货纠纷带偏,回答新用户问题时莫名其妙扯到退货政策上。后来上了 context-mode 的信息筛选逻辑,在送入模型之前先做一次"相关性打分",只保留与当前问题匹配度最高的几段历史记录,效果提升非常明显,而且 token 消耗直接降了差不多六成。
提示:判断上下文是否值得进模型,可以给每条候选内容打一个"信息密度"分。信息密度 = 该内容对当前任务的必要性 / 内容的长度。高密度内容优先保留,低密度内容直接丢弃。这个思路比单纯按时间倒序截取要科学得多。
2.2 记忆混乱:上下文之间的衔接与一致性
第二个必须解决的问题是记忆混乱。多轮对话或者跨 session 的任务中,模型需要在不同时间点之间建立联系。前面几轮说过的关键信息,后面对话要用上;早上生成的一份分析结论,下午的讨论也要引用。如果没有一个清晰的记忆管理机制,模型就会"选择性失忆",甚至前后矛盾。
context-mode 里的记忆管理,我做过三层的设计:
第一层是短期记忆,也就是当前会话窗口内的高优先级信息,比如用户明确表达过的偏好、关键约束、当前正在处理的任务状态。这一层直接存在于对话上下文中,保证即时可访问。第二层是中期记忆,指跨越几轮对话但还不能进长期存储的信息,一般用摘要形式沉淀,每次对话开始时注入。第三层是长期记忆,指用户画像、历史偏好、领域知识这类相对稳定的内容,存放在外部存储中,通过检索按需召回。
这三层记忆之间有明确的升降级规则。比如一段信息如果在连续多次对话中都被引用,它就具备了进入长期记忆的资格;反之,一段长期记忆如果长时间未被触发,也会被降级甚至淘汰。这个机制参照了人脑记忆的工作原理,但实话说,工程实现上比人脑记忆可靠得多——至少不会因为"今天太累了"就忘事。
记忆一致性是我花了最多精力踩坑的地方。最常见的问题场景是:用户在第一轮说"我不喜欢重口味的东西",第三轮叮嘱"帮我推荐一家川菜馆"。如果记忆系统只记住了"川菜馆"而没有保留"不喜欢重口味"这条约束,推荐结果就会翻车。所以 context-mode 的记忆管理必须支持"约束记忆"和"偏好记忆"的分类,并且在生成推荐时做冲突检测。
2.3 成本失控:上下文越长,钱烧得越快
第三个问题最现实,也最容易被忽视:成本。在 API 按 token 计价的现实下,上下文越长就意味着每次调用的费用越高。我见过有团队为了让模型"记住更多",把一条 2000 字的历史记录原文全部塞进 prompt,结果同样的任务,成本翻了三四倍,效果还不一定更好。
context-mode 在这件事上扮演的角色,是上下文长度的"调节阀"。它的职责是在不影响核心效果的前提下,把上下文压缩到最优长度。这里的"最优"不是最短,而是在"信息完整性"和"token 成本"之间取平衡点。我通常用一个简单公式来指导压缩策略:
压缩后保留的信息量 ≥ 任务完成所需的最小信息量
换句话说,不是把上下文压得越短越好,而是保证删除掉的信息对当前任务不构成致命影响。为此我设计了一套上下文压缩的评分机制:每个信息块按"关键性"和"时效性"两个维度打分。关键性高且时效性强的信息必须保留;关键性低且时效性弱的可以直接丢弃;中间地带的信息走摘要压缩。
成本控制还有一个容易被忽略的点:上下文重用。很多场景下,同一份背景资料会在不同的对话轮次中被反复使用。如果每次调用都重新传入完整内容,那就是在燃烧预算。我在实践中会做一次简单的缓存:同一份资料,只要内容没有变更,在当次会话中就不再重复注入,而是用引用 ID 指向它。这个方法看起来不起眼,但对成本的影响非常显著,尤其在高频交互场景下。
3. 技术选型与整体方案设计:context-mode 的三种实现路径
3.1 路径一:Prompt 工程式实现——轻量但天花板明显
最简单的 context-mode 实现方式,完全靠 prompt 设计来完成。做法是设计一套规则化的指令模板,让模型自己处理和过滤上下文。比如在系统提示词里写清楚:"以下资料中,只关注与用户问题直接相关的部分,忽略无关细节。"
这种方式的优势很明显:实现成本几乎为零,不需要额外的基础设施,不依赖向量数据库,也不需要写检索服务。对于个人项目、原型验证、上下文总量不大的场景,这个方案完全够用。
但它的天花板也非常明显。一是模型对"忽略无关细节"的理解并不稳定,你没法精确控制它到底忽略了什么;二是 prompt 本身也会占据 token,如果提示词写得太长,省下的上下文又被吃回去了;三是一旦信息量变大了,模型的注意力机制依然会被长文本干扰,prompt 指令的力量有限。我的经验是,这个方案适用于上下文总长度在 2000 token 以内、结构相对简单的场景,再往上就开始吃力了。
如果走这条路,我给你几个实操上的小技巧:指令要具体,不要写"忽略无关信息"这种模糊表达,可以写"如果用户问题涉及价格政策,则只关注资料中的价格表部分";同时要指定输出格式,让模型以结构化方式返回它认为有用的信息,这样你能看到它到底保留了哪些内容,便于人工校验。
3.2 路径二:基础检索增强式实现——生产环境的最低标准配置
第二个档位,是引入检索机制来从更大的信息池中定向提取相关上下文。这才是真正意义上的 context-mode,也是我和团队在生产环境里用得最多的方案。核心思路分三步:把大文档或历史记录切片并向量化;用户发起新请求时,做向量相似度检索;把最高相似度的 TOP-N 结果作为上下文注入模型。
这个方案的选型上有两个关键决策。第一是切片策略,我建议按语义完整性来切割,而不是按固定字数来切。一个段落、一节、一个对话回合,都可以作为天然切片单元。固定切片的坏处是会切断语义,比如一个产品的价格表被切成两半,检索时可能只召回前一半,导致模型回答的信息不完整。第二是向量模型的选择,一般来说通用的 embedding 模型已经够用,但如果你处理的是专业领域内容,比如法律文书、医疗记录、代码仓库,建议用领域微调过的向量模型,召回质量会有明显提升。
检索增强式 context-mode 的生产流程我搭了很多次,最终的稳定配置是:用向量数据库存切片,用类似 RAG 的方式召回,但比经典 RAG 多了一道"重排序"环节——初召回 20 条,经过重排序模型过滤后取前 5 条。这道重排序步骤非常重要,它能把向量检索偶尔带偏的结果兜回来。具体参数和操作流程我在后面第 4 章展开讲。
3.3 路径三:完整上下文管理层——多策略融合的高阶玩法
第三档是完整的上下文管理体系。在这个层级上,context-mode 不再是一个简单的检索模块,而是一个贯穿整个系统的上下文生命周期管理框架。它包含信息摄入、切片存储、记忆分层、检索调度、压缩摘要、注入编排、缓存复用等一整套环节。
这套体系适合什么样的场景?我认为是那些上下文来源丰富、交互链路长、对输出质量要求高的系统。比如一个企业级的智能助手,需要同时引用制度文件、员工数据、历史工单、实时业务数据;或者一个跨时区协作的知识库问答系统,需要在不同 session 之间维持连续性。
完整方案的架构我一般分成四个层次:存储层,负责放置向量库、摘要缓存、结构化记忆库;处理层,负责做切分、向量化、摘要、重排序;调度层,负责决定在每一次请求中,从哪个存储中取什么内容、取多长、放什么位置;接入层,负责把调配好的上下文组装成 prompt 模板。每个层次之间的接口都要定义清楚,否则整个系统会变成一个后期根本不敢动的泥潭。
这一档的代价也很真实:开发周期长、调试难度大、需要多人协作维护。如果你只是做一个工具脚本,我建议不要直接上全套方案,用第二档就足够了。方案选型永远不要追求"最强",要追求"够用且可控"。
4. 实操过程与核心环节实现:手把手搭一个 context-mode
4.1 前置环境准备与依赖安装
在动手之前,先把需要的依赖和环境准备好。我用的是 Python 生态,版本要求 3.10+,核心依赖包括:openai官方 SDK,用来调用大模型与 embedding 接口;qdrant-client,因为自托管向量库在国内网络环境下更可控,你也可以换成 milvus/pgvector;tiktoken,用于精确的 token 计数;以及用于文本切片的langchain-text-splitters或者直接手写递归切割器。
安装命令如下:
pip install openai qdrant-client tiktoken langchain-text-splitters如果你在国内环境部署,需要注意依赖下载速度问题,建议先配置好 pip 镜像源。另外我强烈建议在正式开发前,先确认你选择的嵌入模型在量化后的维度是多少。不同模型的维度差异会影响向量库的索引配置,比如 OpenAI 的text-embedding-3-small是 1536 维,而某些开源模型可能只有 384 维,这个参数后面建集合时要用。
4.2 切片与向量化:从源数据到可检索单元
数据处理的第一个环节,是把长文档切成一个个语义完整的"块"。我推荐的切片策略是按段落结构优先,段落过长的再按句子边界二次切分,段落过短的则合并到相邻段落,避免产生大量碎片。
切片之后是向量化。需要注意的一点是:嵌入模型对单条文本的长度有上限,通常是 512 到 8192 token 不等。所以切片大小要配合嵌入模型的窗口来调整。我常用的是递归字符切割器,chunk_size设在 800 到 1200 token 之间,chunk_overlap设在 80 到 120 token 之间。这个 overlap 的作用是避免一个完整语义被切开后,检索时恰好把关键句子的前半段和后半段拆散到两个块里。
以下是我线上在用的切片代码,直接贴出来供参考:
from langchain_text_splitters import RecursiveCharacterTextSplitter splitter = RecursiveCharacterTextSplitter( chunk_size=1000, chunk_overlap=100, separators=["\n\n", "\n", "。", "!?", ";", ",", " ", ""], ) def split_document(text: str) -> list[str]: return splitter.split_text(text)这里有个踩坑经历:我早期在中英混排的文档上翻过车,因为英文按空格切和中文按句号切的边界完全不是一个逻辑。后来统一把separators调整成先按段落、再按句末标点、最后才按空格和字符的顺序,切出来的块语义完整性明显好很多。
切片完成后,对每个块生成 embedding,然后连同原文、来源信息、标题等元数据一起写入向量库。写入时务必带上元数据字段,后续做 metadata 过滤、做 debug 追踪都离不开它。
4.3 检索与重排序:让模型只看到高相关的上下文
检索环节,我的标准流程是:用户问题向量化 -> 向量检索 TOP 20 -> 重排序模型过滤 -> 取 TOP 5。这个"20 进 5"的比例是我实测下来召回质量与 token 成本的最优平衡点。
第一步的向量检索,直接用 Qdrant 的相似度搜索即可。检索时注意设置score_threshold,低于阈值的直接不取,避免把明显不相关的块也带进来。阈值具体取值取决于 embedding 模型的分布特征,我建议先用 50 条已知问答对做一次检索测试,画出分数分布后,取分界点作为阈值。
第二步的重排序,我强烈建议不要省。向量检索本质上是语义粗排,它可能召回"表面相似但实际无用"的内容。重排序模型能更精细地计算查询与候选段落的相关性,把最精准的几条顶上来。业界常用的是 bge-reranker 系列,开源免费,效果不输商业 API。重排序阶段的输入才是真正要进模型上下文的候选内容,所以这一步的质量直接决定了最终效果。
重排序的关键代码大致长这样:
from FlagEmbedding import FlagReranker reranker = FlagReranker("BAAI/bge-reranker-base", use_fp16=True) def rerank(query: str, candidates: list[dict], top_k: int = 5) -> list[dict]: pairs = [[query, candidate["text"]] for candidate in candidates] scores = reranker.compute_score(pairs, normalize=True) scored = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True) return [item[0] for item in scored[:top_k]]在实际接入时,我建议把重排序步骤做成独立服务,而不是在请求链路里同步计算。原因有两个:一是重排序模型加载在 GPU 上,独立服务便于统一管理和扩缩容;二是重排序的输入输出清清爽爽,独立部署后排查问题可以单独对重排序发请求做测试,不用牵动整个链路。
4.4 上下文注入与 Prompt 组装:最后的组装逻辑
检索和重排序之后,就进入了 context-mode 的最终环节:把选中的上下文组装成 prompt,送入模型。
Prompt 组装有两条红线,我写在开头。第一条是:不要让检索出来的多个上下文块之间互相矛盾,如果出现矛盾信息,要在 prompt 里给出优先级排序规则;第二条是:不要让模型分不清指令和上下文,两者之间必须用明确的标记隔开。
我的实际组装模板是这样的:
你是企业内部知识库问答助手。 【当前用户问题】 {user_query} 【参考上下文】 {retrieved_contexts} 【约束条件】 1. 仅基于参考上下文回答,不要使用上下文之外的知识。 2. 如果上下文信息不足以回答问题,明确回复"资料库中未找到相关信息"。 3. 回答时使用与用户问题相同的语言。这里的retrieved_contexts需要做格式预处理:每条上下文前加序号,不同上下文之间用分隔线隔开。如果上下文数量超过 5 条,我建议额外加一句指令,让模型优先关注序号靠前的上下文——这样重排序输出的质量梯度可以在 prompt 内被模型感知。
组装好的 prompt 还需要做一个 token 预算检查。我通常给答案预留 500 到 800 token,其余全部留给指令和上下文。用tiktoken预先计算 prompt 的 token 数,超过预算就逐条裁掉排名最低的上下文块,而不是简单截断字符串。
import tiktoken enc = tiktoken.encoding_for_model("gpt-3.5-turbo") prompt_tokens = len(enc.encode(final_prompt)) budget = 7000 while prompt_tokens > budget - reserved_answer_tokens: retrieved_contexts = retrieved_contexts[1:] # 丢掉排名最低的(按顺序) final_prompt = build_prompt(user_query, retrieved_contexts) prompt_tokens = len(enc.encode(final_prompt))这个预算裁剪逻辑看起来很简单,但它在生产环境的价值很高。有一次模型突然频繁报 context length exceeded 错误,排查到最后就是文档切片大小在变更后悄悄增大了,而预算裁剪逻辑没配上,直接把超限问题从用户体验层转移到了错误日志层——至少现在它不会报错了,而是自动降级处理。
5. 常见问题与排查技巧实录
5.1 为什么向量检索召回了错误的内容?
这是 context-mode 上线后最常见的投诉,用户问 A,系统给了 B 相关的上下文。根据我的排查经验,优先级从高到低的检查点分别为:切片方式是否切断了语义关键句、检索阈值是否太低导致无关内容混入、重排序模型是否生效(有时模型服务挂掉了链路静默降级为纯向量检索)、向量模型与用户问题的领域是否错配。
一个很有用的排查技巧是:把每次请求的检索结果、分数、最终注入 prompt 的上下文块,全部打印到结构化日志里。有用户投诉时直接翻日志看"当时模型看到了什么",问题往往一眼就能定位。我在这个日志功能上吃过亏——早期没做,每次排查都要重新复现问题,后来加了日志,排查效率提升了不止一倍。
5.2 模型回答质量不稳定,时好时坏
不稳定问题比稳定差更麻烦。稳定差说明方案整体有问题,而不稳定通常意味着某个变量在波动。正常检查顺序是先看检索结果是否稳定。同一个问题,跑两次检索,召回的集合如果不一样,那就十有八九是检索环节的不稳定传导到了最终回答。
导致检索不稳定的三个常见原因:一是文档数据在两次请求之间被重新写入,向量 ID 变了,切片顺序变了,召回结果跟着变;二是向量服务的索引还未完成合并,刚写入的向量搜不到,导致一次能搜到一次搜不到;三是随机对候选集做了采样,如果你代码里写了random.sample这种逻辑,请立刻删掉。
5.3 token 消耗怎么压不下来?
如果 context-mode 上了之后,token 消耗不降反升,先别急着优化,检查两个地方。第一是缓存有没有生效,看同一份上下文本应在会话内被复用一次,但因为缓存 key 设计不合理,导致每次请求都重新读取并注入。第二是检索的 TOP-K 是不是设大了。我见过有人为了"保证召回质量"把 TOP-K 设为 10,结果每次都要处理 10 块上下文,prompt 长度直线上升。实际上 3 到 5 条高质量上下文在大多数场景下已经完全够用,多出来的那几条不但帮不上忙,还稀释了模型的注意力。
另一个隐蔽的 token 消耗点是 embedding 调用的费用。对话每多一轮,用户问题就要重新向量化一次,这个成本虽然单次很低,但高频场景下积少成多。可以考虑对相同或相似的问题做 embedding 缓存,命中后直接复用向量,省下这部分调用。
5.4 Context-mode 模式开关引起的系统联动问题
这个稍微有点经验之谈的意思。context-mode 通常会设计成可开关的配置项,方便做 A/B 测试。但在上线前一定要确认清楚:关闭 context-mode 之后,系统是否还能正常运行?是否会自动切换回全量注入模式?还是直接变成无上下文的裸模型对话?这三种降级策略产生的体验是完全不同的。我建议默认的降级策略设为"无上下文模式",并且在前端给用户一个明显的提示,避免模型回答看起来像是"失忆了"。
另外,如果系统中同时存在多个上下文来源,比如文档库、会话记忆库、实时工具返回结果,在做开关切换时一定要注意各来源的隔离性。不要出现关了文档库,结果会话记忆也一起消失的情况。这种联动的 bug 最隐蔽,也最难查,建议在配置层面就做好各开关的逻辑隔离。
6. 我用了这么久,最终的体会是什么?
梳理完这一整套 context-mode 的实现和踩坑,我自己最大的感受是:这个模式的名字虽然起得高大上,但落到实处,无非就是"聪明的裁剪"、"有结构的记忆"和"克制的注入"。技术水平上的门槛其实不高,真正花时间的永远是那几个问题——怎么判断什么信息重要、怎么在成本与效果之间做权衡、怎么让系统在极端案例下依然体面地工作。
如果这篇文章只能留下一句话,我会说:context-mode 做得好的标准,不是让模型"看到更多",而是让模型"恰好看到该看到的"。留白和取舍,比塞满更考验功力。
最后送大家一个小技巧:在你上线 context-mode 的那个版本,强烈建议在日志系统里记录每一次请求的"上下文命中率"——也就是最终注入的上下文块中,被用户在反馈里明确点赞或点踩的内容占比。用这个指标回归评估你的切片大小、检索阈值、TOP-K 设置,你会发现每一轮调整都有数据依据,而不是靠感觉拍脑袋。我自己就是靠这个指标,把最初拍脑袋定的 1000 token 切片大小硬生生调到了 800,理解出来的质量反而涨了一截。
希望这篇折腾记录能帮你在做 context-mode 时少踩几个坑。有更好的方案,欢迎在评论区交流。