我上个月排查一个个人助理类应用的线上问题用户反馈说机器人“越聊越笨”上午聊好的需求下午再问它已经彻底忘了有时候又会突然冒出一段之前完全无关的讨论把当前的问题带偏。我翻了日志发现每一轮请求都往提示词里塞了最近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(rolesystem, contentsession.summary)] return session.window.get_history() elif mode retrieval: hits session.retriever.recall(session.session_id, current_input) return [Message(rolesystem, contenth) 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先看清楚线上真实发生了什么再考虑要不要加复杂度。很多时候问题不是方案不够高级而是连问题是什么都没观察清楚。