我上个月排查一个个人助理类应用的线上问题,用户反馈说机器人“越聊越笨”:上午聊好的需求,下午再问,它已经彻底忘了;有时候又会突然冒出一段之前完全无关的讨论,把当前的问题带偏。我翻了日志,发现每一轮请求都往提示词里塞了最近20轮历史,第18轮、第19轮里的临时闲扯,把用户最初的核心诉求完全淹没了。
这个场景让我下定决心,把上下文模式(context-mode)这件事彻底想清楚。
很多人以为“上下文模式”是大模型时代才有的新概念,其实不是。它本质上是:系统怎么保存、组织、取舍一份持续增长的历史信息。对话历史只是它最典型的表现形式,日志分析、推荐系统、甚至一个需要跨函数传递状态的程序,都面临同一道题。只不过大模型应用把这道题的代价放大了,放到了直接决定产品生死的地步。
这篇文章不打算讲抽象理论。我会基于自己实际做过的几个项目,把常用的context-mode方案拆开聊:它是什么,每种方案怎么实现,分别解决什么问题,以及实际落地会踩哪些坑。适合正在做聊天机器人、智能助手、长文档处理或Agent类应用的朋友参考。
1. 先搞清楚context-mode到底在解决什么问题
1.1 一个看似高端、实则是基本功的问题
你可以把大模型应用里的上下文理解成“会议室里的记录员”。
会议从早上9点开到下午6点,记录员不可能把每句话都记下来,也不可能只记最后半小时。他要判断什么时候完整记录、什么时候只写结论、什么时候引用前面的决定。这套判断规则搬到程序里,就是上下文模式。
但程序里的上下文比会议室更麻烦。会议记录员至少有脑子,能判断什么重要什么不重要;而大模型每次调用都是“无状态”的,你给它什么它就是什么,你不给它就不知道。也就是说,系统必须主动决定:这次请求要在提示词里放哪些历史信息、以什么形式放、放多少。
这就是context-mode的核心决策。更直白地说,它回答三个问题:
- 记什么:哪些历史信息对当前任务有价值
- 怎么记:原始全文、截断、摘要、还是检索出来的片段
- 放多久:信息什么情况下过期、什么时候被清理
想清楚了这三件事,大模型应用的“记忆感”就立住了一半。想不清楚,产品体验就会在“失忆”和“信息污染”之间反复摇摆。
1.2 为什么这个选择这么难做
难做的根源是三个约束同时存在,而且互相打架。
第一是窗口容量。模型能接收的token是硬性上限。你把上下文撑得越长,能放的新问题和新指令就越少。更麻烦的是,很多模型的上下文窗口标称很大,但真正稳定可用的有效长度要打折扣。我习惯把标称窗口的70%作为设计上限,剩下的留给波动和输出。
第二是token成本。上下文就是钱,尤其是长上下文的场景。每次请求都带几千token历史,乘以调用量,账单会很可观。即使现在很多平台提供长缓存优惠,缓存命中的上下文依然会在首次计算时产生较大开销。不考虑成本的上下文设计,很容易把项目拖死。
第三是信息有效性。上下文越长、越杂,模型越难抓住重点。有一个在行业里被反复验证的现象:模型对长上下文中间部分的关注度往往低于开头和结尾。塞太多无关信息,不仅浪费token,还会直接拉低回答质量。这就引出一个重要结论:上下文不是越长越好,而是“刚刚够用”最好。
| 约束维度 | 本质问题 | 典型后果 | 解法方向 |
|---|---|---|---|
| 窗口容量 | 硬性限制 | 超出窗口被截断或报错 | 精简上下文总量 |
| token成本 | 经济成本 | 账单快速增长 | 控制历史携带量 |
| 信息有效性 | 注意力稀释 | 回答质量下降 | 提高上下文信噪比 |
这三个约束经常让人顾此失彼。为了记住更多内容去拉长上下文,结果模型反倒找不到重点;用摘要压缩省了token,摘要本身又可能丢关键细节。这也是为什么不存在一套一劳永逸的方案,只能按场景去选择和组合模式。
2. 四种主流上下文模式:定位与取舍
2.1 零上下文模式:最简单也最容易被低估
先讲最容易理解的:零上下文模式。每次调用完全独立,不携带任何历史。用户问什么,就直接把当前问题交给模型处理,回答完就结束,下一次从零开始。
这个模式听起来“弱”,实际应用场景却比想象中广。批量文章摘要、文本分类打标、敏感词清洗、把用户口语转化为标准化查询——这些一次性任务,历史信息根本帮不上忙,带多了反而是负担。我做过一个某图像处理Demo的辅助功能,用户上传一张图片,系统把图片描述直接交给模型生成参数建议,全程不需要任何对话历史,零上下文模式跑得又稳又省。
它的优点非常突出:实现最简单,不存在上下文污染,token开销完全可控,响应速度也最稳定。缺点同样明显:完全没法做多轮对话,任务一旦需要拆成多步,每一步之间的状态必须由外部系统携带,比如把中间结果存到变量或数据库里。
这个模式给很多人的启发是:并不是所有功能都需要“记忆”。在设计一个功能时,先问自己一句“这次真的需要历史吗”。如果不需要,就不要硬塞。我曾经见过一个团队为了展示“聪明”,给一个天气查询功能硬加了历史上下文,结果用户问完北京天气再问上海,模型反而把两个城市的温度比较得稀里糊涂。老老实实用零上下文,反而什么问题都没有。
2.2 滑动窗口模式:最常见的默认方案
滑动窗口是目前最主流、也最容易上手的方案。核心思路是:只保留最近N轮对话,超过窗口的旧消息直接丢弃,保持历史体积固定。
别小看这个方案,它解决了80%以上常规聊天场景的需求。用户和机器人的对话通常是短周期的:问问价格、约个时间、确认个信息,三五轮之内就能结束。对于一个几分钟内完成的事情,保留最近几轮就够用了。
实现上,我推荐用一条循环队列。每来一条新消息就入队,当队列总token数超过预设上限时,从队首弹出最旧的消息。注意,tiktoken这类常见的BPE分词器在不同模型上可能会计算出不一致的token数,最稳妥的做法是直接用你接的那个模型配套的分词工具来计算token数,然后在算好的token数上进行队列控制。
关键参数只有一个:这个窗口设多大。我给出一个自己在用的谨慎估算公式:
max_history_tokens = 模型窗口上限 x 0.7 - 系统提示词token - 当前输入token - 输出预留token举例。假设模型标称窗口是8000 token,系统提示词用掉500,用户当前问题平均300,给输出预留1000。那么历史可用的token就是:
8000 x 0.7 - 500 - 300 - 1000 = 3800 token大概能塞下最近10到15轮正常中文对话。这个量对大多数客服、助手类场景足够了。
滑动窗口的优点是简单、延迟可控、开销可预期。缺点是超过窗口的早期信息会永久消失,而且“近因效应”特别明显——中间部分的重要信息容易被末尾的新对话冲掉。另外要注意,滑动窗口的“轮”最好按“用户一问 + 助手一答”算一轮,不要单独数消息条数,否则系统回复里的提示词和补充内容会把窗口快速占满。
2.3 摘要压缩模式:用结构性记忆应对长会话
如果会话时长远远超过一个滑动窗口能覆盖的范围,摘要压缩模式就该上场了。
它的思路也很直观:不把历史全文一直留着,而是定期把早期对话压缩成一段摘要,让模型基于摘要继续后续对话。相当于会议记录员每隔一小时,就把前面一小时的内容整理成一页要点,后续的讨论都基于这本“纪要”向前推进。
触发压缩有两种做法。一种是定时式:每对话N轮,就对所有旧消息做一次重摘要。另一种是触发式:当累积的原始消息token数超过某个阈值时,立刻触发压缩。我实测下来,触发式更符合实际情况,因为对话长短方差很大,定时压缩可能在消息很少时白烧token,也可能在消息爆发时来不及压缩。
比“何时压缩”更重要的是“摘要长什么样”。我见过很多失败的摘要压缩,问题都出在把摘要写成了自然语言流水账。模型生成的摘要内容冗长、重点不突出,过两轮压缩就直接坍缩成一句话“用户希望得到一个解决方案”。正确的做法是给摘要定义结构化格式,至少包含四个区块:
- 用户目标与当前意图
- 已确认的事实约束
- 已经做出的决定与待办事项
- 尚未解决的问题
代码里可以用一段提示词约束模型输出,让它在每次重摘要时严格按这四个区块组织。
def rollup(old_summary: str, new_messages: list[Message]) -> str: prompt = f""" 请把“已有摘要”和“新增对话”合并为一份新的结构化摘要。 必须包含以下四个部分,每个部分用不超过三句话: 1. 用户目标与当前意图 2. 已确认的事实约束 3. 已经做出的决定与待办事项 4. 尚未解决的问题 已有摘要: {old_summary} 新增对话: {format_messages(new_messages)} 新的结构化摘要: """ return call_model(prompt)摘要压缩的优势是能用可控的token量覆盖很长很长的会话,特别适合项目顾问、角色扮演、深度咨询这类需要“全局记忆”的场景。代价也不小:摘要有损,早期对话的细节一旦没被写进摘要,就再也找不回来;而且重摘要本身也在消耗token。递归合并多轮之后,还会出现一个我后面要专门讲的“信息坍缩”问题。
2.4 检索增强模式:让上下文按需加载
第四种模式是检索增强,它的思路和前面三种完全不同:不做全局记忆,而是每次根据当前的用户输入,从历史记录里找出最相关的片段,只把这几段拼进提示词。
你可能已经看出来了,这就是把RAG(检索增强生成)的思路用在了对话历史上。区别在于,经典的RAG检索的是外部知识库,而这里检索的是会话记录本身,本质上是“记忆版RAG”。
实现流程分四步。先把历史消息切成语义完整的片段,每个片段控制在两到四个句子,或者按“一次完整的结论”来切;然后把这些片段向量化,存入向量存储;收到新用户消息时,用同样的方式对学生提问做向量化,召回最相似的top-k个片段;最后把这些片段插进提示词里,让模型基于“用户现在的问题 + 召回的历史片段”作答。
这里有个关键细节:切片大小的设定。切片如果太大,召回结果的精准度会下降,可能整个片段只有一句和当前问题相关,却把其他无关内容也带进来了;如果太小,语义割裂,召回结果单薄,模型很难还原完整来龙去脉。我在实践中的经验是:按“完整语义块”切,一个语义块通常包含“一个结论 + 支撑它的论据或例子”,长度控制在50到150个token之间,召回数量控制在3到5个片段。
检索增强模式的好处是token开销相对可控,而且能跨过非常长的会话找回关键信息,不受摘要压缩那种“遗漏”的限制。它也有自己的问题:需要额外搭建向量化与召回的基础设施,实现成本高;召回结果有随机性,命中不佳的时候,效果甚至比朴素的滑动窗口还差。
它最适合的场景是知识密集、信息分散的长文档咨询,比如企业知识助手、政策问答机器人这类应用。用户问的问题往往散落在很长的会话或文档中,零上下文和滑动窗口都接不住,摘要压缩又可能漏细节,检索增强反而是最合适的选择。
2.5 四种模式横向对比
| 模式 | 记忆范围 | token开销 | 实现复杂度 | 主要风险 |
|---|---|---|---|---|
| 零上下文 | 无 | 最低 | 最低 | 无法多轮 |
| 滑动窗口 | 最近N轮 | 随轮数线性 | 低 | 早期信息丢失 |
| 摘要压缩 | 全部会话(经摘要) | 次线性缓慢增长 | 中 | 摘要质量损失 |
| 检索增强 | 全部会话(按需召回) | 按召回量线性 | 高 | 召回命中不稳定 |
我见过不少团队一开始都用滑动窗口,跑了一段时间后,遇到长会话需求,再加一个摘要压缩层,最近发生过信息检索类的需求,又引入检索增强,最终形成“滑动窗口 + 摘要 + 检索”的组合结构。这个演进路径很有参考价值:先跑通,再加复杂度。不要一开始就搭建全功能重型框架。
3. 实操:做一个能切换的context-mode实现
3.1 场景设定与总体设计
为了把上面的概念落地,我以一个“某跨平台系统”的个人助理模块为例,讲一下我是怎么设计、怎么实现的。这个模块的需求有两个:
- 普通闲聊与任务执行,要在多轮对话中有基本记忆
- 能处理较长篇幅的需求文档,用户可能分几次粘贴大量文本进来
基于这个需求,我决定采用可切换的组合结构:默认走滑动窗口,检测到单轮文本特别长时触发摘要压缩,同时预留检索增强的接口。
先说数据模型。每条消息都是下面这样一条记录,同样适用于不同模式的转化,也方便统计和排查:
from dataclasses import dataclass from datetime import datetime @dataclass class Message: message_id: str role: str # user / assistant / system content: str created_at: datetime token_count: inttoken_count在消息写入时就算好并保存,不要在请求时重复计算,这样能省掉大量线上开销。session维度上的数据,除了消息列表,至少还要保留:
- session_id:会话标识
- mode:当前使用的上下文模式
- summary:已压缩的摘要(摘要模式下使用)
- last_rollup_time:上次重摘要的时间
- trace_id:每次请求的追踪ID,排查问题全靠它
3.2 核心实现:滑动窗口与token控制
滑动窗口的代码是最容易写的部分,真正的重点在于控制好入队和出队的时机。
from collections import deque class SlidingWindowBuffer: def __init__(self, max_tokens: int): self.max_tokens = max_tokens self.messages: deque[Message] = deque() self.total_tokens = 0 def add(self, msg: Message) -> None: self.messages.append(msg) self.total_tokens += msg.token_count while self.total_tokens > self.max_tokens: old = self.messages.popleft() self.total_tokens -= old.token_count def get_history(self) -> list[Message]: return list(self.messages)入队之后立即检查总token数,一旦超限就从最老的开始弹出,注意这里弹出去的是“用户与助手回合对”的两条消息,而不是只弹一条。我在最初实现时只统计当前消息的长度,导致窗口里的历史总是超出预期,后来改成“用户消息入队时检查整条队列,超出就一起弹出旧的两条消息”,才把窗口稳定控制在预设范围内。
另一个必须处理的问题是:窗口上限不能写死在代码里。不同版本的系统提示词长度会变,用户输入的长度波动也很大,所以我在每次构造提示词前先读一次配置中心,动态计算本次的可用历史token上限。
3.3 摘要压缩的触发与重写
摘要压缩模式在滑动窗口的基础上增加了两个操作:触发条件和重写逻辑。我用的触发条件是“新增token数超过阈值且距上次压缩超过N轮”。这样既不会在消息很少时频繁压缩,也不会在消息很多时来不及压缩。
class SummaryContextManager: ROLLUP_TOKEN_THRESHOLD = 2000 MIN_ROLLUP_INTERVAL = 2 # 至少间隔两轮用户提问 def should_rollup(self, session, new_msg: Message) -> bool: tokens_since_last = session.tokens_since_last_rollup + new_msg.token_count if tokens_since_last >= self.ROLLUP_TOKEN_THRESHOLD: return session.last_rollup_round <= session.round - self.MIN_ROLLUP_INTERVAL return False def compress(self, session, new_messages: list[Message]) -> str: old_summary = session.summary or "暂无摘要" prompt = build_rollup_prompt(old_summary, new_messages) new_summary = call_model(prompt) session.summary = new_summary session.messages = [] session.tokens_since_last_rollup = 0 return new_summary“超过阈值且间隔两轮以上”这个双条件,是我踩过两次坑之后总结出来的。一开始只设了阈值,结果用户连续发了几条长文本,压缩完成后紧接着又触发压缩,每次都在烧token但摘要几乎没变化。加上最少间隔轮数之后,压缩频率变得健康很多。
重写摘要的提示词格式我在前面给出过一个简明版本。实际操作中,我还会把“之前摘要中的待办事项如果已完成,请明确标注为已完成”这类状态追踪要求也加进去。摘要不只是信息的压缩,更是会话状态的更新,这点很关键。
3.4 检索增强的接入方式
检索增强在这个模块里是作为可插拔组件存在的。我先把历史消息按“语义块”切片,写入向量存储;当用户输入到来时,通过运行嵌入模型完成查询向量的召回;命中结果拼进提示词。
class Retriever: def __init__(self, store): self.store = store def slice_and_index(self, session_id: str, messages: list[Message]) -> None: chunks = [] for msg in messages: sentences = split_by_semantic_block(msg.content) for s in sentences: chunks.append((msg.message_id, s)) if chunks: self.store.add_vectors(session_id, embed_all([c[1] for c in chunks])) def recall(self, session_id: str, query: str, top_k: int = 3) -> list[str]: query_vec = embed([query])[0] hits = self.store.search(session_id, query_vec, top_k) return [hit.content for hit in hits]这里有一个容易踩的坑:历史消息里的助手指令、系统提示词不要一起向量化。我最初把整个会话记录不分角色全部切片,结果召回结果经常把系统提示词的原样捎带出来,不仅浪费token,还容易让模型“人格错乱”。后来只对用户消息和关键结论性回复切片,情况立刻好转。
接入步骤上,我建议用异步任务做切片和向量化,不要让历史写入阻塞主请求链路。历史写入和向量化天然适合做批处理,每隔一段时间消化一次就能满足大多数场景。
3.5 模式切换与效果评估
整个模块在运行时的模式切换就用一个最朴素的switch逻辑:
def build_context(session, current_input: str) -> list[Message]: mode = session.mode if mode == "zero": return [] elif mode == "window": return session.window.get_history() elif mode == "summary": if session.summary: return [Message(role="system", content=session.summary)] return session.window.get_history() elif mode == "retrieval": hits = session.retriever.recall(session.session_id, current_input) return [Message(role="system", content=h) for h in hits] else: return session.window.get_history()“不要全局搞复杂配置”是我做这块的原则。把mode字段放在session级别,意味着不同会话可以使用不同策略,方便做A/B对比实验。对于某个用户在一次会话中临时粘贴了大段文档的情况,我会在这个session上临时把mode从window切换成summary,等会话结束后再重置。
评估方面,我在每次请求的日志里记录了三个核心指标:prompt总token数、历史context来源(window还是summary还是retrieval)、用户对回答的反馈。离线评估时,我准备了一组包含20个典型对话场景的测试集,分别用不同模式跑一遍,比较两个指标:
- 整体token消耗:直接看账单和日志合计
- 回答质量:对测试集中的明确事实性问题,统计回答是否与预期答案一致
这套简陋但有效的评估机制,帮我发现了很多设计上的问题。其中最典型的一个案例在下一节详细说。
4. 实测中最容易踩的坑与排查思路
4.1 上下文污染:不该出现的记忆影响当前判断
我排查过某图像处理Demo的助手功能,模型在判断图像参数时,突然引用了一段完全不相关的早前对话——那是用户之前关于报销规则的闲聊。根因就是全局上下文把整段历史都塞进去了,闲聊内容污染了当前任务。
排查思路:先看日志里的一次请求到底带了哪些历史内容,把prompt打印出来,逐条看是哪条消息引发了异常引用。修复手段要分两层:
- 结构上做上下文隔离:把用户偏好、业务约束、技术日志分成不同的上下文槽位,每次请求只注入当前任务需要的槽位
- 策略上做权限控制:闲聊类消息默认不进入技术类任务的上下文
这个坑非常普遍。很多团队在初期图省事,用一份全局历史覆盖所有请求,结果发现模型的行为“时灵时不灵”。其实模型本身没有错,是输入里混进了不该有的东西。把上下文按主题切片,问题的发生率会显著下降。
4.2 窗口被占满之后的静默截断
滑动窗口模式还有一个隐蔽的坑:当prompt总token超过模型窗口上限时,部分模型服务会静默截断超出的部分,而不是直接报错。你根本不知道丢的是哪一段。
有一次线上会话,用户贴了一段近万字的项目背景,滑动窗口立刻被占满,最早的消息全部被丢弃。模型回答问题时,对用户最开始的“预算上限是五万不要动”这个约束完全无视,直接给出了六万多的方案。用户很生气,而日志里没有任何报错。
排查方法:在每次请求的日志中记录发送出去的prompt总token数,并设置告警阈值。当prompt接近窗口上限时,主动触发摘要压缩或者缩减历史片段数量,而不是等到静默截断发生。生产环境里我还加了一个“截断哨兵”:构造一条固定内容作为历史末尾标记,如果回答里缺失了这个标记的引用,就说明历史大概率被截断了。
4.3 摘要压缩的“信息坍缩”
摘要压缩最怕的问题,是经过多轮递归压缩后,摘要里的信息细节越来越少,最后只剩一句语义正确但没有任何操作价值的空话。我把这个现象叫“信息坍缩”。
举个例子。第一轮摘要是“用户希望统计过去半年销售额,按区域和产品线拆分”,到第五轮重摘要后可能就变成“用户需要销售报告”。话没错,但帮不上任何忙。
解决这个问题的核心策略是给摘要做双层结构:
- 第一层是结构化摘要,仍然按“目标、事实、决定、待办”四个区块组织,但每轮都做一致性校验,如果上一轮摘要里的某个待办事项在新摘要里消失了,就在状态变化里保留一条提醒
- 第二层是关键句索引,把原始对话中那些“不能丢的原话”提取出来,只存原句,不存改写版
实际操作中,我会在重摘要提示词里加一句:“如果新摘要相比旧摘要减少了任何待办事项,必须在‘尚未解决的问题’中明确列出。”这个约束能有效遏制关键信息被无声吞掉。
4.4 token计费模型的误差和并发串号
还有一个容易忽略的问题:不同模型的tokenizer不同,同一个字符串在不同模型上计得的token数差别很大。用某个模型内置的分词器估算出的token数,很可能与另一个模型的实际计费token不一致,导致缓存和提示词预算出现偏差。我踩过一次预算误差超过20%的坑,之后统一在消息入库时用真实模型的分词器计算并保存token_count,估测就只用于预判容量。
并发场景也要警惕上下文串号。我处理过一个案例,某跨平台系统的多个会话在同一个工作进程里复用了一个全局上下文缓存,导致用户A的会话历史出现在用户B的回复推断里。排查了几个小时,最终定位到是共享对象被并发写入。解决方法是把session上下文严格绑定到session_id,内存缓存用单会话单实例,存储层做会话级隔离,绝不能用一个全局字典当所有会话的临时缓存。
这五个坑都属于“跑起来才发现”的问题。我自己的习惯是:上线初期先在日志里把prompt完整打出来,每一条请求都带上trace_id,看到异常就能立刻回溯到具体的会话和上下文来源。没有这套可观测的地基,所有排查都会变成大海捞针。
5. 写在最后的实操心得
做了几个项目的context-mode之后,我最深的体会是:这个模块没有银弹,也没有标准答案。零上下文、滑动窗口、摘要压缩、检索增强,四种模式各有各的适用边界,关键不是选“最先进”的,而是选“当前场景最稳”的。
我见过最成功的项目,往往不是一开始就上了全套组合架构,而是先用最简单的滑动窗口跑通线上,靠日志和用户反馈找到真实的记信短板,再逐步加入摘要压缩和检索增强。用数据驱动决策,比拍脑袋堆功能靠谱得多。
另外想特别提醒一点:上下文设计一定要在需求阶段就介入。很多产品经理和开发习惯于把“记忆”理解成“把历史全部塞进去”,等开发做到一半再改,成本很高。早一点把“记什么、怎么记、放多久”这三个问题摆到桌面上,能省下后面大量的返工时间。
如果你现在正被“上下文总是乱”的问题搞得头疼,我的建议是从最简单的地方做起:给每条消息打上token数,给每次请求打上trace_id,先看清楚线上真实发生了什么,再考虑要不要加复杂度。很多时候,问题不是方案不够高级,而是连问题是什么都没观察清楚。