做LLM应用开发,绕不开的一个词就是context-mode,也就是上下文模式。说白了,你决定每次都把哪些对话历史、背景资料、用户状态塞给模型看,哪些不看、看多少、用什么顺序看。context-mode这个词看着简单,真正用起来几乎是所有聊天机器人项目翻车率最高的一环:要么上下文越积越多,钱烧得飞快;要么硬塞太多东西,模型反而把关键信息丢了;要么窗口撑爆直接报错。这篇文章,我就围绕context-mode的四种主流设计方案、一个可落地的上下文管理器实现,以及我在生产环境里踩过的坑,给需要做对话系统、Agent、文档问答的开发者一个能直接参考的完整方案。
1. 为什么单独设计context-mode:长上下文带来的三个麻烦
先聊一个很多人忽略的前提。模型支持长上下文,不代表你就该把所有东西都往里面塞。我从去年年中开始做企业级知识库问答机器人,最早也是“偷懒派”——把用户会话从第一条到最后一条全部拼进prompt,想着反正模型上下文窗口那么大,应该没关系。结果第一周就出事了。
1.1 先弄明白“上下文模式”到底管什么
context-mode管的是三件事:一是上下文的范围,也就是保留哪些历史消息、保留多久;二是上下文的结构,也就是历史消息、系统指令、工具返回结果、用户当前输入这几类信息怎么组织;三是上下文的生命周期,什么时候追加、什么时候裁剪、什么时候把旧内容压缩成摘要、什么时候彻底清空。
这三件事听起来简单,但组合起来直接决定了系统效果、成本和延迟。我用一个实际的数字给你算笔账:假设你的模型上下文窗口是128K token,一条用户消息大约500 token,模型回答800 token,历史消息保留30轮,那么一轮对话下来,实际发送给模型的token数大约是500 + 800×30 + 500×30,差不多四万左右。如果每分钟有50个并发用户,每分钟就要处理200万token。按输入5美元/百万token、输出15美元/百万token估算,一天下来光是调用费就是好几千人民币,而且这个数字会随着对话轮数增加一路涨上去,因为每多一轮,整个历史都要重新发送一遍。
1.2 三种典型业务场景下的上下文设计对比
我接触过的项目里,最常见的三种场景对context-mode的需求完全不一样,直接套一套方案就会出问题。
客服问答类机器人,特点是对话轮数多、话题分散、用户随时可能跳转新问题。这类场景适合滚动窗口模式,保留最近8到10轮足够,时间超过一小时的早期内容直接丢,不用心疼。
文档问答类应用,特点是单轮依赖重、输入文本量大、用户经常追问细节。这类场景适合全量直通加分层索引,把当前文档切片完整塞进去,历史会话只保留摘要,重点不在轮数而在内容覆盖。
编程助手类Agent,特点是工具调用频繁、状态变化复杂。这类场景需要分层记忆模式,用户的长期偏好、项目结构、近期操作记录分开存,因为模型经常需要回查几步之前的中间结果,纯靠滚动窗口会丢失关键信息。
三种场景用一个简单的表格对照一下,会更直观:
| 场景类型 | 核心诉求 | 推荐上下文模式 | 关键参数 |
|---|---|---|---|
| 客服问答 | 多轮短对话、话题跳跃 | 滚动窗口 | 窗口8-12轮 |
| 文档问答 | 单轮长文本、追问细节 | 全量直通+摘要历史 | 当前文档不裁剪 |
| 编程助手 | 工具调用、状态回查 | 分层记忆 | 长期记忆+短期操作栈 |
1.3 为什么不能只靠“把窗口调大”
你可能觉得,既然模型支持128K上下文,那我直接把窗口拉满不就行了?这里有两个坑。
第一个坑是“迷失在中间”。有不少研究发现,模型对长上下文不同位置的注意力并不均匀,尤其是当关键信息被埋在大量无关历史中间时,模型经常“视而不见”。我实测过一个案例:把用户三十轮前的需求藏在五十多轮闲聊中间,然后问模型“我刚才让你做什么来着”,它给出的答案和最初的需求完全对不上。不是模型蠢,是上下文里噪声太多,把有效信息淹没了,这已经超出了模型自身的能力问题,而是信息架构设计不合理。
第二个坑是成本曲线是线性的,甚至超线性的。每多保留一万token,单次请求成本就固定增加一部分,而响应延迟也会因为输入变长而增加几百毫秒到几秒不等。对线上服务来说,延迟上去了,用户体验立刻变差;成本上去了,老板立刻找你谈话。
所以context-mode的核心任务不是“尽量多看”,而是“聪明地看”——用最少的token,让模型拿到决策所需的关键信息。
2. 四种核心的context-mode实现思路
聊完痛点,直接上方案。我在生产环境里用过的上下文模式,归纳下来就是四种:全量直通、滚动窗口、摘要压缩、分层记忆。它们之间不是互斥关系,实际系统里往往是组合使用,但理解每种模式本身的特点和适用边界,是第一步。
2.1 全量直通模式:适合短文本场景
全量直通是最原始的方案:把用户当前消息,加上所有相关的历史上下文,全部原封不动地拼进prompt发送给模型。它的优势非常明显——实现简单、信息无损、不会丢失用户说过的任何细节。
但它的劣势同样明显:成本高、延迟高、容易超窗。而且在实际对话场景里,对话轮数一旦超过二十轮,全量直通基本就不可用了。我记得有一次测试,一个用户连续聊了四十多轮,单次请求发送的token数直接超过了两万,响应时间快赶上慢速接口了,用户等了七八秒才看到输入中的提示。
所以全量直通模式我只建议用在这两种场景:一种是单轮问答,比如用户问“这份合同的违约金条款是什么”,你只需要把合同内容加上用户问题发过去;另一种是短对话的初期阶段,比如前五轮对话,历史信息量小,全量塞进去也没问题。我自己习惯的做法是,前五轮走全量直通,后续动态切换成其他模式,这个切换逻辑在代码里就是一个条件判断的事,但收益很大。
2.2 滚动窗口模式:固定窗口的“先进先出”
滚动窗口模式是生产环境里最常见的context-mode实现。它的核心理念是按“轮次”或“token数”维护一个固定大小的滑动窗口,每次新消息进来时,把最旧的消息挤出去,保证发送给模型的历史始终是最近的一个窗口。
这里有三个细节值得说一下:
第一,窗口大小最好按token数算,不要按轮数算。因为有些用户一句话只有二十个token,有些用户会直接丢给你一篇两千字的报告,按轮数管理很容易出现窗口容量忽大忽小的问题。我的做法是给每条消息加一个estimated_token字段,用tokenizer估算后存储,然后从最新消息向前遍历累加,直到超过窗口上限,剩下更早的消息全部裁剪。
第二,系统指令和关键背景约束不参与滑动淘汰。system prompt无论如何都要保留在上下文里,它在滚动窗口里相当于“固定页”,只滚动对话历史部分。
第三,为了防止模型在窗口滚动后“失忆”,需要在被淘汰的消息位置插入一条轻量级提示,告诉模型“更早的对话已被截断,如有需要请让用户重新提供细节”。这么做不是给模型看的,是给用户一个清楚的预期,避免用户问“我之前说过的地址呢”时,模型一脸茫然地反问,让整个交互显得很傻。
下面是一个滚动窗口的简化实现,用Python写的话大概长这样:
class RollingWindow: def __init__(self, max_tokens: int, system_prompt: str): self.max_tokens = max_tokens self.system_prompt = system_prompt self.messages = [] self.system_tokens = estimate_tokens(system_prompt) def add_message(self, role: str, content: str): self.messages.append({ "role": role, "content": content, "tokens": estimate_tokens(content) }) self._trim() def _trim(self): total = self.system_tokens # 从最新消息往回遍历,保留尽量多的消息 keep = [] for msg in reversed(self.messages): if total + msg["tokens"] > self.max_tokens: break keep.append(msg) total += msg["tokens"] self.messages = list(reversed(keep)) def build_prompt(self): return [{"role": "system", "content": self.system_prompt}] + self.messages这段代码里有一点要注意:_trim是从最新消息往回遍历的,这样才能保证最新的对话永远不被裁剪,只裁剪旧消息。如果你写反了,从最旧开始删,那模型每次看到的都是残缺的历史,效果会很糟糕。
2.3 摘要压缩模式:让模型自己当“内容压缩器”
滚动窗口的尽头是摘要压缩。因为无论窗口怎么设置,总有旧内容会掉出去,一旦掉出去的内容里有关键信息,系统就永久丢失了。摘要压缩模式的核心思路是:当对话轮次超过阈值、或者累积token数达到某个上限时,触发一次自动摘要,把旧的对话记录浓缩成一段几百字的摘要,然后把摘要作为上下文的一部分保留下来,替换掉原始消息。
这个方案的精髓在于“牺牲细节换范围”,用有限的token覆盖更长的对话历史。我实测下来,一个二十轮的闲聊对话压缩成摘要后大约只需要原来十分之一的token,但核心信息保留率能到八成以上,对多数业务场景完全够用。
触发摘要的时机也是关键。我自己参考了一套严格的标准,不满足条件坚决不触发摘要:
一是总消息数超过20条。二十轮以内的对话,信息密度没有高到需要压缩的程度,直接保留原文更安全。
二是消息token总和超过窗口上限的60%。如果窗口是8000,消息累积到4800左右,就该考虑启动了,否则后续几条消息一进来就可能超窗。
三是距上一次摘要触发之后,新增加的消息超过了10条。这个条件是为了避免高频触发——如果用户每说两句话就要摘要一次,不仅浪费模型调用次数,还会因为摘要太频繁导致信息碎片化。
我建议把摘要任务单独用一个轻量级模型来做,不要占用主对话模型。给摘要模型的prompt设计也很讲究,我踩过坑之后总结了三个必须包含的要素:角色定位、输出格式、保留优先级。
你是对话摘要助手。请将以下对话历史压缩为一段简洁的中文摘要。 要求: 1. 保留所有用户明确表达过的偏好、指令、时间和地点信息。 2. 保留所有尚未完成的待办事项和承诺。 3. 按时间顺序组织内容,省略寒暄和无关闲聊。 输出格式:纯文本,不超过500字,不要添加任何前后缀。2.4 分层记忆模式:短期记忆与长期记忆的“分离舱”
最后一种模式,也是我现在的主力方案:分层记忆模式。它的核心逻辑是把上下文分成三个隔离层,每层有自己的生命周期和管理策略。
短期工作记忆层,负责保存当前任务相关的对话记录。这个层使用滚动窗口,只保留最近5轮,因为当前任务的信息通常集中在这里。
业务事实层,负责保存从对话中抽取出来的用户事实和业务数据。比如用户的地址、喜欢的品牌、项目的代码规范、之前确认过的决策。这一层只追加、不裁剪,每一条都是结构化的,带着时间戳和置信度。
摘要历史层,负责保存过往对话的压缩表示。当短期记忆层溢出时,被挤出的旧消息先进入摘要历史层,而不是直接丢弃。
这三层在构造prompt的时候,按照“系统指令 + 摘要历史 + 业务事实 + 最近对话 + 用户当前输入”的顺序拼接。为什么是这个顺序?因为模型对上下文两端的内容注意力更强,中间部分容易被忽略。把最重要的系统指令放在最前面,把用户当前输入放在最后面,中间放历史信息,是实测下来信息利用效率最高的排列。
经过这种分层之后,上下文管理的灵活性会大幅提升。就算用户开了个新对话,只要业务事实层还在,模型依然能记住用户叫李工、他偏好简洁的回答、他上周定过一个方案,这种长期记忆能力是前三种模式都很难做到的。
3. 实操:怎么把一个ChatBot改造成支持context-mode
前面几种模式都有各自的适用场景,真正落到线上我建议走“混合式”方案:平时用滚动窗口加分层记忆,触发条件后自动切摘要压缩,前五轮走全量直通。下面我拆解一下具体的改造步骤。
3.1 第一步:把你的对话流程拆成“三明治”
大多数ChatBot的请求流程都长这样:用户发消息进来,后端拿到整段对话历史,拼成messages数组发给模型接口,拿到回复后把这对新消息存入数据库,完成一个回合。
要做context-mode改造,第一步就是不要再用平铺的messages数组当作数据库里的存储格式。我现在的做法是把每次请求的上下文分成三个独立的对象:system_block、history_block、current_block。system_block存系统指令,包括角色设定、输出格式约束、业务规则,这部分基本不变;history_block是经过上下文管理器处理后的历史内容,可以是原文、窗口切片或摘要,取决于当前模式;current_block是用户最新消息以及需要临时附加的内容,比如用户上传的文件内容。
这三个block分开存,分开管理。很多系统死就死在把所有的东西全部塞在一个messages数组里,导致系统指令被历史消息淹没,用户随意说一句“好了”都可能干扰模型对系统规则的记忆。分开之后,system_block在每次请求前可以重新注水,保证它永远是完整的。
大概长这样:
def build_request(user_input: str, session: Session): system_block = session.build_system_prompt() history_block = session.context_manager.get_history_for_request() current_block = { "role": "user", "content": user_input } messages = system_block + history_block + [current_block] return messages3.2 第二步:写一个能感知token的上下文管理器
上下文管理器是整个改造的核心,它负责决定history_block里放什么、不放什么。这里我给一个我线上在用的核心逻辑,一个结合了滚动窗口和摘要触发的ContextManager。注意,为了可读性我简化了部分存储操作,实际生产环境要用数据库或Redis代替内存列表。
class ContextManager: def __init__(self, max_context_tokens: int, summary_threshold: int, tokenizer_fn): self.max_context_tokens = max_context_tokens self.summary_threshold = summary_threshold self.estimate_tokens = tokenizer_fn self.system_prompt = {"role": "system", "content": ""} self.messages = [] self.summary = "" self.last_summary_index = 0 def set_system_prompt(self, content: str): self.system_prompt = {"role": "system", "content": content} def append_message(self, role: str, content: str): self.messages.append({ "role": role, "content": content, "tokens": self.estimate_tokens(content), "timestamp": time.time() }) self._maybe_summarize() def build_messages(self): budget = self.max_context_tokens - self.estimate_tokens(self.system_prompt["content"]) if self.summary: budget -= self.estimate_tokens(self.summary) retained = [] used = 0 # 从最新消息往回选,保留尽量多的最近内容 for msg in reversed(self.messages): if used + msg["tokens"] > budget: break retained.append(msg) used += msg["tokens"] retained.reverse() messages = [self.system_prompt] if self.summary: messages.append({"role": "system", "content": "以下是稍早前的对话摘要:" + self.summary}) messages.extend(retained) return messages def _maybe_summarize(self): total_tokens = sum(msg["tokens"] for msg in self.messages) new_since_summary = len(self.messages) - self.last_summary_index if total_tokens > self.summary_threshold and new_since_summary >= 10: self._generate_summary() def _generate_summary(self): # 用轻量级模型对历史消息做摘要,然后清空原始消息 # 真实实现里这里会调用summary_llm.chat() self.summary = summarize_messages(self.messages) self.last_summary_index = 0 self.messages = []这个实现里有几个细节值得拎出来说:
build_messages里面有一个重要的预算计算逻辑,就是用窗口上限减去system prompt和摘要占用的token,剩下的才是给消息历史的预算。很多新手会直接把窗口上限当成历史消息的上限,结果系统提示和消息历史加在一起就超窗了,模型接口直接报错。
estimate_tokens这个函数,我强烈建议不要自己拍脑袋估,而是用不同模型对应的tokenizer。OpenAI的模型用tiktoken,开源模型很多用transformers的AutoTokenizer,Claude的tokenizer也有官方工具。不同tokenizer对同一段中文内容的统计差异能到两三倍,你按错的tokenizer做裁剪,误差会非常大。
3.3 第三步:处理好摘要触发后的“先斩后奏”问题
摘要模式有一个天然缺陷:一旦触发摘要,原始消息全部被替换成摘要,模型就永远失去了对细节的访问能力。用户之后追问“你刚才说的那个具体方案是什么”,摘要里只可能留下“用户和助手讨论过数据迁移方案”这样一句话,方案细节已经丢了。
我目前用的是两个补偿方案,实测效果不错:一是“摘要+关键消息保留”,触发摘要时不是把所有消息都清空,而是保留那些带有关键内容的用户消息原文,比如用户发过地址、发过一串ID、发过明确指令的消息,挑出来单独保留。二是“摘要回查”,如果用户在摘要生成后说“我刚才说的那个细节是什么”,系统能识别出这是历史细节查询请求,自动从完整的历史存储中检索对应的原文,再以临时消息的形式注入当前上下文,检索可以用简单的关键词匹配,也可以用向量检索。
这两个方案都不复杂,但一定要在最开始设计的时候就想好,不要等上线了用户反馈说“你怎么失忆了”,再来补就很被动了。
3.4 第四步:context-mode的“路由决策”
一个好的上下文管理器还需要一个路由开关,根据当前会话的状态动态决定走哪条模式。我总结了一套很简单的决策逻辑,分享出来给大家参考:
判断当前会话累积的token数是否小于1000,如果是,直接走全量直通模式,不调用任何裁剪逻辑,省事儿也不丢信息。
如果大于1000但小于窗口上限的60%,走滚动窗口模式,保留最近的消息,同时在必要时触发摘要。
如果已经超过窗口上限的60%了,走摘要压缩模式加上分层记忆的读取,把长期事实注入,旧历史做摘要,新消息走窗口。
这套逻辑的核心思想是“能用简单方案就不用复杂方案”。context-mode不是越复杂越好,而是越匹配越好。一个会话只有三轮对话,你偏偏要搞摘要压缩,那是杀鸡用牛刀,还会白白浪费一次模型调用。
4. 常见问题与排查技巧实录
我整理了一张排查清单,基本覆盖context-mode上线后最常见的坑,都是我实际踩过的,按出现频率排列。
| 症状 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 接口报“context length exceeded” | token预算没算上system和输出token | 打印messages数组逐段统计token | 预留输出token空间,裁剪逻辑不卡在极限值 |
| 模型回答质量突然下降 | 滚动窗口把关键信息挤掉了 | 查看裁剪日志 | 对包含地址、ID、指令的消息设保护标签,不参与淘汰 |
| 摘要后模型“失忆” | 摘要压缩粒度太粗 | 检查摘要prompt是否明确要求保留偏好和时间 | 在摘要prompt中加入“必须保留用户偏好和待办事项” |
| 上下文重复信息严重 | 摘要和原文在同一个请求里同时出现 | 检查摘要触发后的旧消息是否已清空 | 摘要生成后立即清空对应的原始消息,只保留摘要 |
| 长对话响应越来越慢 | 消息列表未做裁剪直接全量发送 | 在请求入口打日志统计prompt长度 | 严格执行每次请求前的token计算与裁剪 |
| 不同模型token统计不一致 | 用错tokenizer | 对比不同tokenizer统计结果 | token计数函数与线上模型对齐 |
其中“摘要后模型失忆”是大家反馈最多的一个问题,我展开讲讲。因为模型接口的上下文范围有限,摘要和原文同时存在时,模型反而会更倾向于参考摘要,对原文里的细节“视而不见”。这个问题有两种解法:一种是在摘要文本前加一句“如需获取精确细节,请参考用户原始消息”,措辞上引导模型以原文为主;另一种更彻底,摘要生成并注入后,把对应的原始消息从messages里彻底删除,只留下能触发回查的索引字段。我推荐第二种,干净、无歧义,第一种在小模型上效果不太稳定。
“上下文重复信息严重”这个现象也值得多说一句。触发摘要前,系统会在某个请求里同时把摘要和原始历史一起发送,模型把摘要里的信息和原始历史里的信息一对比,发现两边有出入的概率很高。我遇到过一次最严重的情况是模型直接在回答里说“系统提供的摘要与对话记录存在矛盾”,场面一度很难看。所以这个“先摘要、后清空”的顺序一定不能反,需要在代码层把这个时序写成不可跳过的两步:先调一次摘要接口,成功拿到摘要后,立刻把旧消息清空。
另外还有一个不敢说很多人提到过的问题——上下文里的system prompt被挤没了。有些模型的接口会把system角色转成assistant消息来处理,如果你在历史消息里也放了很多role=assistant的内容,模型就有可能把system消息当成普通聊天内容。排查方法是在请求日志里把最终的messages打印一遍,看system消息是否还在数组最前面,如果被挤出窗口了,就要修改在build_messages里对system prompt做强制保留的设计,确保system永远不被裁剪,它优先于任何历史消息。
5. 不同context-mode的部署策略与成本控制
前面讲的都是怎么管理上下文内容,接下来聊一聊部署层面的事。毕竟context-mode的行为最终会影响token消耗,而token消耗直接影响账单。
5.1 为不同模式设置独立的模型路由
我现在的架构里,不同上下文模式对应的模型路径是分开的。全量直通模式因为处理的是短输入,往往走响应质量最好的旗舰模型,让模型把单轮回答的质量拉满;滚动窗口模式走的是性价比均衡的模型,因为窗口裁剪后输入变短,没有必要用最强的模型,省下来的成本很可观;摘要压缩模式单独调用轻量模型来完成摘要任务,这个模型不需要很强的生成能力,但长文本理解能力要好,否则摘要质量会拖累主对话效果。
这个路由策略用了三个月之后,我的经验是整体成本能下降三四成,效果几乎没有变化,因为上下文管理和生成质量本来就是相对独立的两个环节。你用强模型去处理被裁剪后的优质上下文,效果其实比用同一个模型处理一堆堆砌的乱糟糟历史要好得多。
5.2 给每条消息打上“重要性标签”
这个思路来源于一个很直白的观察:不同消息对后续对话的价值完全不同。“我今天下午三点要去机场接人”,这句话对后续对话的价值极高;“嗯嗯好的”,基本为零。我的做法是给每条消息在保存时打上优先级标签,用户在消息里提到地址、时间、人名、金额等实体时,优先级直接拉高,滚动窗口裁剪时这些高优先级消息会被豁免,优先裁掉那些低优先级的寒暄消息。
实现这个功能有两种路径。追求低延迟、不想每次都调模型的场景,可以用规则匹配加实体识别;追求高准确率的场景,可以用一次独立的模型调用,让模型判断每条消息的信息价值等级。我采用的是规则加人工兜底的混合方案,准确率大概在八成五左右,至少比无差别裁剪强很多。
5.3 用缓存协议降低重复输入成本
这是一个容易被忽略但实际收益极高的点。在你的上下文模式确定不变的情况下,请求中很长一部分内容(比如system prompt、不变的历史摘要、固定的知识库上下文)是重复发送的,这部分重复内容的成本可以通过缓存机制全部省掉。
具体来说,如果你用的模型接口支持缓存回源功能,开启后命中缓存的token会大幅降价,甚至低到原来的十分之一。但前提是:这部分上下文必须在多次请求间保持完全一致,模版里的任何位置哪怕多了个空格,缓存都会失效。这就要求你在build_messages时把system prompt和摘要内容作为不变的部分放在前面,把用户当前输入放在最后,这样每次请求时前面大部分内容都能命中缓存。我实测过,开启缓存后,长对话场景的输入成本能降一半以上,非常可观。
不过要注意,缓存协议是按“前缀”生效的,如果你在prompt结构图里把系统指令放在历史消息后面,或者把会变化的内容放在前面,就永远无法命中缓存。这又是一个“前置设计>事后调优”的典型案例。
5.4 status层面的监控指标
最后提供三个我们团队一直在看的监控指标,用来衡量context-mode设计是否健康:
第一个是“单请求平均输入token数”,这个数越大说明上下文里塞的东西越多,成本越高,但也不能无限小,因为太小可能影响质量。
第二个是“上下文裁剪率”,也就是被裁剪掉的旧消息占全部历史消息的比例。如果这个比例长期是0,说明你的窗口设计得太大了,白白烧钱;如果长期超过50%,说明窗口太小,历史信息丢失风险很高。
第三个是“摘要触发频率”,如果一天内某个会话频繁触发摘要,说明你的对话轮次非常长,需要检查摘要压缩的质量是否足够好,以及是否需要引入分层记忆来分担压力。
这三个指标组合起来,基本能判断一个对话系统在context-mode设计上是否健康。如果你上线后发现自己完全没见过这些指标,那就更要留个心眼——说明系统的上下文管理还处在“野蛮生长”阶段,随时可能爆发事故。
6. 番外:从context-mode里延伸出的两个进阶玩法
聊完基础实现,再分享两个我最近在玩的进阶方向,能明显把对话系统的体验拉高一个档次。
6.1 用户级长时记忆与context-mode的结合
context-mode管理的不仅是单次会话里的历史,也可以扩展到用户跨会话的长期记忆。做法是把用户明确表达过的偏好、身份信息、历史行为习惯,以一种结构化的形式存入长期记忆库,每次构建system prompt时,动态读取并注入与当前请求相关的记忆片段。
这样做的收益很大。用户第二次来的时候,你说“上次您提到想找一个蓝牙音箱”,用户会觉得你敏锐,实际上是你在请求里悄悄多塞了一段记忆抽取结果。我的实现方案是给每个记忆打上embedding,每次请求时取出用户最新的若干条记忆,和当前问题做相似度匹配,只把最相关的那几条注入system prompt,避免把所有记忆无脑塞进去,把上下文白白撑大。
6.2 让模型自己参与上下文管理决策
现代模型具备很强的判断能力,你可以把“是否需要更多上下文”这个决策委托给模型来发起。当模型发现当前上下文不足以回答用户问题时,它可以显式地请求一个特定编号的历史片段,而你的上下文管理器根据这个编号去完整对话存储库中检索并注入对应原文。这个机制解决了所有一次性注入式上下文方案的盲区:你在构建prompt的时候根本不知道用户会问什么,但你可以在运行时通过模型反馈来动态补充。
实现起来就是在消息结构里增加一个context_request字段,模型需要时填充这个字段,系统拦截到这个字段后执行一次检索,再把检索结果以system消息形式插入下一轮请求。这个机制我目前还在打磨阶段,但方向基本明确了,感兴趣的可以在自己的项目里试试,会有种“模型主动要资料”的新奇体验。
7. 我的建议
做个总结性发言的话,我只想强调一句:context-mode的核心原则不是“尽量多带”,而是“精确匹配”。你在选模式之前,先想清楚你的用户到底会在什么情况下触发问题,需要哪些背景信息才能回答好,然后只为那些必要的上下文付费。
有很多人一上来就搞很复杂的摘要压缩加向量检索,结果自己的对话场景根本不需要那么多历史,白白浪费了几周的开发时间。我个人的建议是,先用最简单的方式跑通流程,打上监控指标,然后在数据里看上下文裁剪率、摘要触发频率、用户流失率之间的关系,用数据来驱动你升级哪一种模式,而不是拍脑袋做技术选型。
最后再分享一个我踩坑换来的技巧:不管用哪种模式,一定记得在每条消息里保存完整的时间戳和数据来源编号。这句话值不值钱,等你在排查上下文丢失问题时就会懂——很多“模型失忆”根本不是模型的问题,而是你自己没把消息索引做好。