做了几年大模型应用,我越来越觉得真正决定产品体验的,往往不是模型选得多强、RAG 链路铺得多全,而是那个看起来不起眼的“context-mode”怎么设计。很多人第一次听到这个词是在 LangChain、LlamaIndex 或者部署框架的参数列表里,以为它只是一个开关,选一个模式就行。实际上,context-mode 指的是应用层如何管理“喂给模型的上文”——哪些信息保留、哪些信息裁剪、哪些信息压缩、哪些信息按需检索,这一整套策略才是多轮对话、知识问答、Agent 任务落地时最核心的工程问题。
这篇文章我打算用一次真实故障作为切入点,把 context-mode 的五种主流形态、上下文窗口的预算计算、可落地的框架设计以及排查技巧一次性讲透。无论你是正在做客服机器人、文档问答,还是想把 Agent 产品推上线,这篇内容都值得你花十分钟读完。我会把生产环境中踩过的坑、算过的账、压测过的参数全部摊开来说。
1. context-mode是什么:上下文管理模式在LLM应用里的准确位置
1.1 从一个真实故障说起
有次我维护一个电商客服机器人,某天用户连续问了几轮商品信息之后,突然来了一句“刚才那个红色的保温杯还有货吗”。模型直接回答“我们没有聊过保温杯”。运营那边炸了,用户那边的截图在群里传了一圈。
所有人第一反应都是“模型是不是太蠢了”。但我拉出日志一看,请求发给模型之前,程序已经把聊天窗口按时间顺序拼好了,问题出在上下文管理策略:系统设置了每轮最多保留 10 条消息,而用户这 10 条里恰好不包含最早提到“红色保温杯”的那条,消息一滚动,记忆就断了。
这个问题的本质不是模型能力,而是 context-mode 设计不匹配业务场景。当时的实现是一个最简单不过的固定窗口截断——只保留最近的 N 条消息,早于窗口的信息直接丢弃。它确实廉价、省 token、延迟低,但它违背了客服场景的核心诉求:用户一个会话里可能要来回对比多个商品,早期提到的关键信息在后期必须还能被引用。
所以我们在讨论 context-mode 的时候,本质上是在讨论一件事:在一个预算有限的上下文容器里,如何决定哪些信息值得被保留、哪些信息可以被压缩、哪些信息应该被主动检索回来。这个问题跟在操作系统里做内存换页、在缓存系统里做淘汰策略,逻辑上是一模一样的。
1.2 一个核心认知:context-mode不只是prompt写法问题
很多初学者以为上下文管理就是把历史消息拼在 system prompt 后面,顶多控制一下长度。这是把问题看小了。真正的 context-mode 是一套系统级策略,它横跨三个层面。
最底层是 Prompt 组装层。这一层解决的是“消息以什么样的格式进入模型”,比如 system、user、assistant 的角色如何区分,工具调用结果放在哪里,是否需要 XML 或 JSON 边界标记。很多模型“看不懂”上下文,其实是因为这里的分隔符、角色标记乱掉了。
中间层是记忆状态层。这一层维护的是跨请求的会话状态:原始消息列表、摘要缓存、检索索引、实体与关键事实的持久化存储。这一层决定了“模型能记得什么”。
最上层是调度策略层。这一层根据当前请求的意图,决定从记忆状态中取哪些内容:是直接取最近 N 条,还是先触发摘要压缩,还是走向量检索把历史上相关片段捞回来。常见的 LangChain ConversationBufferWindowMemory 就属于这一层的一个具体实现。
理解了这三个层面,你再看各种各样的 context-mode 参数就不会觉得玄了。所谓 mode,不过是这三层里某些策略的固定组合。下面我按五种最常见模式展开讲。
2. 五种主流上下文模式:选型对比与适用边界
2.1 无状态模式:简单但不该被嘲笑
无状态模式是最原始的形态:每次请求不带任何历史消息,模型只根据当前输入的 query 和系统指令作答。很多人一听说无状态就觉得“这也能叫模式”?但在实际生产里,它至少覆盖三类场景。
第一类是单轮工具型调用,比如翻译、抽取、分类、内容改写。用户不会追问上一轮,也不需要模型记得上一轮。第二类是独立请求密度极高的批处理任务,比如给一千条客服工单打标签,每条工单独立进入模型,带历史反而会造成串扰。第三类是前置过滤阶段,比如先判断用户这句话是否意图切换话题,如果匹配到新意图,直接无状态重启话题。
要命的是很多团队把无状态模式用错了地方。我见过一个文档问答产品,所有问题都走无状态模式加全局检索,用户问“第二个方案的风险有哪些”时,系统根本无法理解“第二个方案”指代的是上一轮提到的内容。这种场景做无状态,就是把模型当柴鸡用,用户稍微有点指代就崩。
无状态模式的价值在于可控性和确定性:没有历史就没有历史污染,没有记忆也就没有跨请求的信息泄漏。它应当被当作组合策略里的一个基础动作,而不是所有场景的默认选择。
2.2 固定窗口模式:工程上最省心,效果最看场景
固定窗口模式是生产环境最常见的入门配置。它的策略很直白:维护一个最近 N 条消息的队列,新消息进来,最早的消息出去,拼接成上下文送给模型。
这个模式的优点非常突出:实现简单、token 消耗可预测、请求开销稳定。对绝大部分产品来说,这是性能和安全性的底线方案。但它的问题也恰好藏在“先进先出”这个逻辑里。
假设用户在第 3 轮说了“我家预算 5000 块”,然后在第 15 轮问“刚刚说的那个价位还有别的推荐吗”,如果一个窗口只保留最近 10 轮,第 3 轮的信息早就被挤出去了。模型接到的只有一句孤立的问题,它能给的答案就只能是泛泛而谈。
固定窗口的窗口大小怎么定,也是一门学问。窗口开得太大,token 成本和延迟涨上去,而且模型对无关消息的注意力会被稀释;窗口开得太小,指代消解和跨轮事实跟踪能力马上退化。我见过一个粗略但实用的估算方式:统计业务对话的平均消息长度,乘以目标轮数,再留出 30% 的余量。如果平均每轮 600 token,想覆盖 20 轮对话,窗口大小按 600×20×1.3 ≈ 15600 token 来配,而不是拍脑袋写个 4096。
2.3 摘要压缩模式:用一次小模型调用换长期记忆
摘要压缩模式的思路是:当历史消息超过阈值时,调用一个压缩模型,把旧消息总结成几句话,然后把这些摘要与近期完整消息一起作为上下文。
它解决的核心问题是固定窗口模式下“远期关键信息丢失”的痛点。还是那个电商客服的例子,如果系统在窗口滚动前把包含“红色保温杯、499元、库存紧张”的历史消息压缩成摘要,那么用户哪怕在第 30 轮再提起保温杯,模型依然有能力接住。
但这个模式没那么容易做好。第一次做摘要压缩的团队通常会犯三个错误。
第一个错误是压缩频率过低或过高。过低就退化成固定窗口,过高则每轮都在调压缩模型,延迟和成本都失控。我的经验是:设定一个触发阈值,比如历史消息 token 数超过上限的 70% 时,才启动一次压缩。
第二个错误是压缩时丢了关键实体。摘要模型在信息密度不足的情况下,会倾向于保留语境更泛化的内容,把价格、颜色、型号、时间这些具体实体丢掉。解决办法是压缩前先用抽取式规则或小模型把实体列表抽出来,再和摘要一起放进上下文,或者用更直接的提示词强调“必须保留数字、专有名词和否定信息”。
第三个错误是摘要替换旧消息时没有边界标志。模型分不清哪部分是摘要、哪部分是实时对话,最后很容易把摘要内容当成用户刚说的信息。我在组装时会在摘要外层加一个明确的标签,比如“以下是更早对话的摘要”,并在系统指令里明确指示模型区分摘要与当前对话。
2.4 检索注入模式:RAG的上下文组织和取舍
检索注入模式是 RAG 系统里最常见的一环。它的策略是:把用户的历史消息或知识库文档切块、向量化,每次请求时只把检索到的 Top-K 片段放进上下文,而不是把整个库全部塞进去。
这种模式的伟大之处在于它把“上下文窗口”这个刚性约束变成了弹性资源——你不需要记住所有东西,但你可以随时找到需要的东西。它的核心难点也随之变成了两个:切分策略和召回质量。
切分策略方面,我见过太多团队直接用固定长度 500 token 硬切文档,结果把一段完整的产品参数表切成了两半,召回时永远只能拿到一半。更稳的方案是按结构切分:先按 Markdown 标题、段落、表格等自然边界切,再对过长段落做二次截断。每块之间保留 20-50 token 的重叠,保证落在这段边界附近的内容在上一块和下一块里都出现一次,召回率会明显提升。
召回质量方面,余弦相似度只是起点。真正生产级的检索注入,至少要做一层重排。第一轮用向量检索捞回 Top 50,再用一个轻量级重排模型按相关性排序,取 Top 5-8 注入上下文。我们发现仅这一步,问答准确率能提高 10 到 15 个百分点。
更要命的一个细节是:检索注入的内容和当前用户问题之间,需要用清晰的分隔标记隔开。否则当用户连续追问时,模型很容易把检索出来的文档片段当成用户话语的一部分,产生幻觉。我一般会在注入代码块前加一行“相关知识片段”,然后在 system 指令里写明:“遇到冲突时,以本次注入的知识片段为准;对于注入片段中不存在的信息,明确承认不知道。”
2.5 混合模式:生产环境里的大多数正确答案
不要试图在产品里只用一种模式。真实业务的对话形态是混的:前几轮可能是寒暄和意图确认,中间几轮是知识库检索,后面几轮是深度咨询,偶尔还夹杂一个需要跨上面所有轮次才能回答的问题。
混合模式的典型结构是:固定窗口保底 + 摘要压缩兜底 + 检索注入增强。窗口保留最近 8-12 轮完整消息,保证模型能流畅理解即时语境;窗口之前的历史消息压缩成摘要,保留远期关键事实;当用户问题命中知识库或历史事件时,从向量库取回 Top-K 片段注入。
选择混合模式不是追求技术上的炫技,而是因为它最符合人的聊天习惯。人对近期内容的记忆是完整的,对近期之前的内容是有概括性记忆的,遇到不确定的新信息时会去翻资料——这不就是我们想要模型实现的效果吗?
选型时我建议团队先做一张表,按业务场景的指代密度、关键实体保留需求、知识库依赖程度、延迟和成本预算四个维度打分,而不是直接照抄别人的架构。表格式的对照分析和缺陷,我放在章节 6 的统一排查部分一起讲。
3. 上下文窗口的预算分配与参数计算
3.1 别把窗口塞满:一个被反复忽视的常识
很多人的第一个错误认知是:模型的上下文窗口有多少 token,我就能用多少 token。以某些百万级 token 窗口的模型为例,如果你真的把整个窗口填满再去问问题,会立刻遇到两个麻烦。
第一个是时间和成本陡增。注意力机制的计算复杂度会随序列长度快速上升。即便现在的模型大多采用稀疏注意力和 FlashAttention 这类优化,序列翻倍带来的显存占用和延迟依然不是线性,而是接近超线性增长。我在实际压测中,把上下文从 8k 提到 64k,单次请求延迟涨了 6-9 倍,成本涨了接近 20 倍。
第二个是长程注意力坍缩。序列太长时,模型对早期信息的注意力权重会变得稀疏,出现“看见了但没注意”的情况,你可以把它理解成让一个人连续读三个小时材料,最后问第一页写了什么,他大概率答不上来。所以,预留一部分窗口空余是必要的,不是浪费,而是给模型留出推理空间。
3.2 预算分配公式:给窗口里的每个部分定比例
我在生产环境里常用的预算公式是这样的:总窗口记为 W,系统指令为 S,检索注入为 R,对话历史为 H,当前用户输入为 Q,预留输出为 O。必须满足:
S + R + H + Q + O ≤ 70% × W
剩下的 30% 用来给模型做 KV Cache、解码留白、以及应对极端输入。如果某些模型支持输出 token 数单独配置,那么预留输出的上限也要算进这部分。
举个例子:某个 128k 窗口的模型,系统指令经过精简后约 1.5k token;检索注入的知识片段按每段 800 token、取 5 段算,约 4k;当前用户输入平均 0.5k;输出设置上限 2k。那么对话历史 H 的可分配空间大约为 128k × 0.7 - 1.5k - 4k - 0.5k - 2k ≈ 81.6k token。这么多历史空间,大约能容纳 80 到 100 轮普通长度的对话。如果你的业务只需要 20 轮,那剩下的预算就不该浪费在历史里,可以考虑增加检索注入的片段数量,让模型获得更多外部知识。
有一种更精细的分配方式是把历史本身再分层:80% 给最近对话(保留完整表达细节),20% 给早期对话摘要(保事实、丢语气)。这种比例在进行长会话类产品设计时非常管用。
3.3 实际成本模拟:两种模式在同一业务下的差异
我们来做一个简单的成本模拟。假设一个客服机器人每天 10 万次请求,每轮对话平均 800 token,历史要覆盖 20 轮。
如果使用固定窗口模式,每次请求的历史 token 大约是 800 × 20 = 16k,加上系统指令和输出,单次上下文约 20k。如果按每 1k token 0.002 美元的模型单价估算,单次请求成本约 0.04 美元,每天约 4000 美元。
如果使用摘要压缩模式,早期 12 轮被压缩成约 1.2k 的摘要,最近 8 轮保留完整对话,历史 token 大约是 800 × 8 + 1200 = 7.6k,叠加系统指令和输出,单次上下文约 11k,单次成本约 0.022 美元,每天约 2200 美元,成本几乎减半,同时模型还保住了早期的事实。
真实生产里的差距还会更大,因为在固定窗口下,对话轮数超过窗口后,模型会出现重复回答、拒绝回答、指代混乱,这些都会引发用户重试或人工介入,背后的隐性成本很难量化。但从这个简单例子已经能看出来:context-mode 的选择直接决定你公司的 token 账单,而不只是产品体验。
4. 核心实现:一个可落地的混合模式框架
4.1 框架的整体结构
下面我给出一个浓缩版本的生产级实现思路,它综合了固定窗口、摘要压缩、检索注入三种模式。整体分为三层。
第一层是消息仓库层,负责存储完整会话消息,每条消息带有角色、内容、时间戳、token 数。第二层是记忆管理层,负责把消息仓库里的内容切成“最近窗口区”和“历史摘要区”。第三层是组装层,负责按优先级把系统指令、检索片段、历史摘要、最近消息、用户输入拼成最终请求。
我选择这样设计的原因很朴素:消息仓库是唯一事实源,任何截断、摘要、检索都只是仓库数据的不同投影。这样排障时,只要检查仓库数据就够了,不需要在各处同步维护多份状态。
4.2 核心代码示例
下面我用 Python 写一个最小但可跑的混合模式 ContextManager。它主要演示固定窗口加摘要压缩的核心逻辑,检索注入部分用占位函数表示。
import time from dataclasses import dataclass, field, asdict @dataclass class Message: role: str content: str token_count: int ts: float = field(default_factory=time.time) class ContextManager: def __init__(self, system_prompt: str, max_token_budget: int = 16000, recent_window_turns: int = 8, compress_threshold: float = 0.7): self.system_prompt = system_prompt self.max_token_budget = max_token_budget self.recent_window_turns = recent_window_turns self.compress_threshold = compress_threshold self.messages: list[Message] = [] def add_message(self, role: str, content: str, token_count: int) -> None: self.messages.append(Message(role=role, content=content, token_count=token_count)) def current_token_count(self) -> int: return sum(m.token_count for m in self.messages) def _compress(self, messages: list[Message]) -> str: # 实际生产环境这里应该调用压缩模型 # 为了演示,这里做简单拼接并截断 text = "\n".join(f"{m.role}: {m.content}" for m in messages) # 压缩模型逻辑:提取关键事实并保留实体数字 # 省略具体模型调用,实际可调用 gpt-4o-mini 或本地小模型 return f"[早前对话摘要] {text[:500]}" def _should_compress(self) -> bool: return self.current_token_count() > self.max_token_budget * self.compress_threshold def build_messages(self, user_query: str, query_token: int, retrieval_parts: list[str] | None = None): # 1. 如果超出压缩阈值,先对窗口外的历史做摘要压缩 if self._should_compress() and len(self.messages) > self.recent_window_turns * 2: older = self.messages[:-self.recent_window_turns * 2] recent = self.messages[-self.recent_window_turns * 2:] summary = self._compress(older) self.messages = [Message("system", summary, len(summary) // 3)] + recent # 2. 按 token 预算截断,确保总长不超过预算 budget = self.max_token_budget final_parts = [Message("system", self.system_prompt, len(self.system_prompt) // 3)] # 检索片段按优先级注入 if retrieval_parts: for part in retrieval_parts: if budget <= 0: break final_parts.append(Message("system", f"知识片段: {part}", len(part) // 3)) # 历史消息 for msg in self.messages: if budget <= 0: break final_parts.append(msg) budget -= msg.token_count final_parts.append(Message("user", user_query, query_token)) return [asdict(m) for m in final_parts] # 使用示例 cm = ContextManager( system_prompt="你是某电商平台的客服助手,回答请简洁。", max_token_budget=16000, recent_window_turns=8, ) cm.add_message("user", "我想买一个红色保温杯", 15) cm.add_message("assistant", "好的,有 500ml 和 350ml 两种", 15) cm.add_message("user", "500ml 的多少钱", 10) cm.add_message("assistant", "499元,今天下单有优惠", 12) payload = cm.build_messages( user_query="刚才那款还有货吗", query_token=10, retrieval_parts=["保温杯详情页:材质为316不锈钢,容量500ml,库存充足"] ) for msg in payload: print(msg)这段代码最核心的思路是三步:检查总量是否超阈值,超了就压缩旧消息;把优先级的资源依次分配出去,包括检索片段、历史摘要、最近消息;最后无论怎么截断,系统指令和当前用户输入一定在。
4.3 几个关键机制的解释:优先级、截断、压缩触发
为什么把检索片段放在历史之前?因为对大部分问答场景,外部知识比历史对话更能决定答案的准确性。模型在长上下文中对靠前内容的注意力更高,把“知识片段”放在偏前位置,相当于告诉模型“这些内容最重要,请优先参考”。
为什么压缩触发阈值设成 70% 而不是 90%?因为压缩本身也是一次模型调用,有成本和延迟。等到 90% 再触发,刚才堆积的上下文已经可能在单次请求里超预算,造成请求失败或服务端截断。70% 是一个比较稳妥的折中:留出足够空间完成一次压缩并把摘要放回上下文,同时又不会频繁触发压缩。
还有一个细节:上面代码里的 token 计数是简单用长度估算的。实际项目一定要用分词器精确计算,否则预算管理会失真。比如某些模型的分词器里,一个中文字符约等于 0.6-1 个 token,但一段代码里可能一个 token 能包含三四个字符,用字符长度估算会在关键时刻偏差很大。
5. 检索注入与摘要压缩的细节:两种机制如何共处
5.1 摘要偏爱事实,检索偏爱片段
摘要压缩和检索注入在功能上是有重叠的:它们都是在帮模型“想起”旧信息。如果设计得不好,同一个事实会同时出现在摘要和检索片段里,或者两边不一致,让模型不知该信谁。
我踩过一次这样的坑:知识库里的商品价格更改了,但旧版本片段还留在向量库里,检索后模型引用旧价格回答用户,同时摘要里又记录了更早的另一个价格,用户拿到一个完全混乱的答案。后来我设立了“事实优先级”机制:在某类事实类问答中,如果检索到的知识片段与对话摘要不一致,优先采用时间戳更新的数据;如果时间戳不存在,则优先采用检索片段,因为知识库的数据通常是权威来源。
更稳的做法是不要把摘要和检索片段混在一个区域,而是分别标注来源。比如摘要区前面加“以下内容是更早的真实对话摘要,可能有时间滞后”,检索区加“以下内容来自最新知识库,优先级高于摘要”。模型对来源感知会明显变强,冲突时也更可能做出正确抉择。
5.2 给摘要建立索引
摘要压缩模式下,早期历史被压缩成一个几百字的摘要。如果这个摘要本身已经很大,下一次再压缩时,摘要和消息一起被再次压缩,会引入“压缩误差的累积”——第一次漏掉的细节,第二次更不可能回来。
缓解办法是给摘要建立层级索引。每次生成新摘要时,把摘要文本写入一个独立的小型向量库,摘要本身带时间戳和覆盖范围。用户提问时,先用问题去检索这个摘要库,返回最相关的 2-3 段摘要片段,再连同当前窗口消息一起组装。
这本质上是把“线性压缩”变成了“按需解压”。线性压缩一次性把所有历史压缩成一份小抄,信息密度上必定有损;按需解压则只在用户真正需要那段时间的信息时才翻开对应的那一段,能够把信息损失降到一个很低的程度。
5.3 摘要压缩触发时机的完整链路
在混合模式里,压缩触发不能只靠 token 总量。我在设计中加了两个额外条件。
第一个条件是“当前对话轮数是否足够多”。如果对话只有 3 轮,即使 token 超了 70%,也应该先做截断而不是压缩,因为模型通过截断丢掉一些无关话术,问题不大;真正需要压缩的是那种已经累积了 20 轮以上、早期信息仍然可能被引用到的场景。
第二个条件是“压缩请求是否与用户请求并发”。如果触发压缩时机恰逢用户发来提问,压缩模型调用会占用资源,导致主请求延迟。生产做法是:压缩在后台异步执行,先把当前窗口做保守截断兜底,等压缩结果生成后再替换掉摘要区内容。这样用户不会感知到压缩延迟,但下一次请求能享受到摘要带来的记忆能力。
6. 常见问题与排查技巧实录
6.1 模型突然失忆:先看组装的上下文,别急着怪模型
无状态模式下模型失忆是非常正常的,因为你压根没给它记忆。固定窗口模式下模型失忆,最常见的触发点是消息队列被新消息挤掉。摘要模式下的失忆,大概率是摘要丢实体。检索模式下的失忆,往往是召回的这一批片段里没有包含用户指代的对象。
排查时我都建议先做一个动作:把每次请求实际发送到模型的完整消息体打出来,存成日志,并在 trace 里同步保存组装前的元信息。很多时候,光看消息体就能定位问题,完全不需要去动模型。
如果发现组装后的上下文里确实有“保温杯”相关信息,但模型仍然说“不知道”,那才需要怀疑模型的长程注意力问题。此时可以把关键信息从中间挪到开头或结尾,或者把关键信息在摘要和最近消息里各放一次,大部分情况下都能解决。
6.2 上下文太长带来的延迟和成本失控
延迟飙升不一定是上下文问题,也可能是服务端排队和网络抖动,但“上下文长度与延迟的关系”是最值得优先排查的一环。我的建议是用控制变量法做一次基线压测:固定输入,从 4k 逐步提到 128k,记录延迟、吞吐、token 消耗三个指标。压测数据会直接告诉你当前模型在什么长度上性价比最优。
成本失控的另一个隐蔽原因,是检索注入片段没有做去重。每次请求从向量库取 Top-K 段,如果这些片段在语义上高度重合,模型等于把同样内容读了好几遍。建议在注入前做一层简单的文本去重或相似度去重,两段余弦相似度超过 0.85 时只保留一段。
6.3 多轮对话里的知识冲突
你和用户在对话中提到一个说法,知识库里又是另一个说法,模型夹在中间就容易给出一个“既要也要”的模糊答案。解决思路是给每一类信息赋予优先级,并在 system 指令中明确写出。
我自己常用的一套优先级是:当前用户实时输入 > 检索注入的最新知识片段 > 摘要区记录的历史事实 > 模型内部预训练知识。这套规则不一定放诸四海皆准,但它能消除大部分冲突场景下的不确定性。如果模型仍旧做不出决断,可以在检索注入时将每段知识附上来源和时间戳字段,让模型能明确感知到“哪条信息更可信”。
6.4 典型问题速查表
下面这张表是我平时排查上下文问题时都会对着看的清单。它不代表所有情况,但能覆盖 80% 以上的生产问题。
| 现象 | 可能原因 | 优先排查点 | 解决建议 |
|---|---|---|---|
| 多轮指代失效 | 固定窗口截断了早期消息 | 查看组装后的上下文里是否有指代对象 | 增加窗口、启用摘要或检索注入 |
| 模型重复回答 | 上下文过长导致注意力漂移 | 压测不同上下文长度的输出质量 | 截断历史、压缩摘要、减少检索片段 |
| 回答内容张冠李戴 | 检索片段与对话历史冲突 | 检查摘要与片段时间戳 | 引入事实优先级与来源标注 |
| 请求报上下文超限 | 窗口预算配置不合理或无人值守增长 | 查看实际 token 消耗曲线 | 加预算熔断,超过阈值强制压缩 |
| 延迟突然翻倍 | 上下文长度或检索量突增 | 看单次请求上下文 token 数 | 限制历史轮数和检索 Top-K |
| 关键词全部命中但答案不对 | 检索片段没有按结构切分 | 检查文档切片是否截断语义 | 按段落切块并保留重叠 |
6.5 可观测性是时候补上了
上下文管理做得再好,不会观测就等于盲飞。我们在生产环境里的最低要求是:每条请求记录上下文总 token 数、各组成部分的 token 占比、是否触发摘要压缩、检索到的 Top-K 片段 ID、模型返回结果。把这些数据上报到可观测平台,配置好三个告警:上下文超过预算的 90%、摘要压缩触发频率超过每分钟若干次、检索召回数量为零。
排查上下文问题的时候,回放这些记录比看用户反馈截图高效得多。大多数 context-mode 问题,本质都是一个原因:实际发送给模型的内容,和设计者以为发送的内容不是一回事。有了观测,这个误差就会被迅速消灭。
我个人做 context-mode 这几个月最深的体会是,别把上下文管理当成“配个参数”的杂活。它决定了模型能不能接得住用户真正的话,决定了你每月的成本是五百还是五万,甚至决定了用户觉得“这 AI 到底是聪明还是智障”。一个看起来很小的模式选择,被放大到百万级请求量级时,就是完全不同的产品命运。
最后分享一个实用的小技巧:在 system prompt 里主动告诉模型“较早的对话已经做了摘要汇总,如果摘要与你当前的对话信息存在冲突,请以当前对话为主”。这句话看起来不起眼,但能让模型在面对摘要和实时对话的细微差异时,少很多自我矛盾。你如果正被 context-mode 的某些问题折磨,不妨先从这个最简单的提示词改起,再逐步上摘要和检索这套完整的组合拳。