
用过ChatGPT、Claude这类产品的人大概都有这种经历聊着聊着模型突然忘了你半小时前说的关键信息或者你丢了一篇很长的文档进去它回答到后面就开始胡编。问题不在模型本身而在于应用层对上下文的管理方式。做AI应用开发这两年我几乎所有项目里都栽过这个跟头直到把context-mode上下文模式当成一个正经模块来设计而不是顺手拼几个变量整条链路才算真正稳定下来。context-mode本质上是解决一个问题大模型的上下文窗口是有限的但用户的对话和输入是无限的。怎么在这两者之间做取舍决定了你的AI应用是聪明还是智障。这篇文章我会从设计思路讲到代码落地再聊聊参数调优和踩坑实录面向正在做大模型应用、智能客服、AI Agent的开发者也适合刚入门想搞懂上下文机制的同学。读完你至少能判断自己项目该用哪种模式并且能直接抄一套能跑的方案。1. 为什么context-mode成了大模型应用的隐形瓶颈1.1 所谓的上下文其实是一笔预算先搞清楚一个概念大模型的上下文窗口不是内存更像是一张白纸上的写作空间。你给它多少字它就只能在这段文字范围内寻找线索来回答。GPT-4级别模型的窗口通常有128K甚至200K token听起来很大但换算一下一个中文字大概占1到2个token128K大概相当于8到10万字的容量。一本书呢几十万字。一段技术文档呢几千到几万字很正常。所以上下文窗口不是给你无限装东西的硬盘而是每个月要精打细算的预算。你塞进去的东西越多模型出错的概率越高响应越慢成本也越高。我实测过同一个问答任务上下文从1万token涨到8万token首字响应时间能慢两三秒单次调用成本翻好几倍。这些数字背后全是真金白银。context-mode就是一套管理这笔预算的策略决定哪些信息放进去、哪些踢出去、哪些压缩后放进去、哪些从外部检索后再放进去。它决定了AI在长对话和大文档场景下的记忆力上限。1.2 没有上下文模式长对话必然崩很多开发者第一版AI应用是直接拼接历史消息全部塞给模型。短期demo没问题一旦真实用户聊到第20轮、第50轮问题就来了。我接过一个智能客服项目的烂摊子用户和机器人聊了40多分钟中间提到了订单号、收货地址、退款金额。结果到第35轮机器人问您刚才说的订单号是多少——它忘了。不是模型笨是前面35轮的消息把所有窗口都挤满了模型在满屏的嗯嗯然后呢好的里根本找不到那个订单号。这就像你在一堆草稿纸里找一份重要合同合同被埋在最底下你当然找不到。所以context-mode不是一个锦上添花的优化而是AI应用从demo走向生产的及格线。没有它长对话、大文档、多轮工具调用这些场景全都做不稳。2. 三种主流context-mode实现路线2.1 滑动窗口最简单也最暴力滑动窗口的思路是只保留最近N轮对话更早的统统丢掉。实现起来非常直接维护一个列表超过长度就弹出最早的消息。优点实现成本低性能好不会引入额外的大模型调用。缺点粗暴。用户在第2轮提到的关键信息到第50轮早就被弹出了。它管用的是短期记忆管不了长期记忆。适合那些聊天比较短、信息时效性强的场景比如售后客服的当次问题处理。2.2 摘要压缩用模型给记忆做笔记既然全部保留不现实全部丢弃太可惜那就折中让模型定期把早期对话整理成摘要用摘要替代原始内容。打个比方滑动窗口是看完就忘摘要模式是写课堂笔记。你不可能把整节课的每句话都记下来但你记下重点期末复习时看笔记就够了。摘要模式每隔几轮触发一次把前面的对话浓缩成几百字的要点模型基于笔记最近几轮原始对话来回答。优点能跨很长的时间保留关键信息记忆容量远大于滑动窗口。缺点每次摘要都要调用模型成本和延迟增加摘要本身可能丢细节多次压缩后信息可能失真。适合长对话、长期助手类场景。2.3 向量检索注入只取用得上的记忆第三种路线是把历史对话切成片段做向量化存入数据库每次提问时只检索最相关的几段注入上下文。这是RAG检索增强生成在对话历史上的应用。我一般把这套方案称为图书馆模式你不必把整本书背下来但每次回答问题前去图书馆按关键词查几页参考书。它不关心对话轮数只关心相关性。用户提到退款系统就把历史上所有关于退款的对话片段捞出来。优点理论上记忆容量可以无限扩展不受上下文窗口限制信息召回精准。缺点架构复杂度高需要向量数据库检索质量直接决定回答质量检索不到等于失忆。适合知识库问答、AI助手带长期用户画像的场景。这三种模式不是互斥的实际项目中我通常组合使用滑动窗口做底层缓冲摘要做中层记忆压缩检索做顶层长期记忆。后面我会给出一个完整的设计框架。3. 实操从零实现一个可用的context-mode3.1 先定义上下文的数据结构动手写代码之前先想清楚一条消息长什么样。我推荐用统一的消息结构后面无论哪种模式都好处理from dataclasses import dataclass, field from typing import Optional, List import time dataclass class Message: role: str # user 或 assistant content: str # 消息正文 msg_id: str # 唯一ID用于定位 timestamp: float 0.0 meta: dict field(default_factorydict) # 扩展字段 dataclass class ConversationState: session_id: str messages: List[Message] field(default_factorylist) summary: str # 摘要文本 last_summary_ts: float 0.0 token_usage: int 0 # 当前上下文估算token数这里有个容易被忽略的点timestamp和msg_id。很多初版代码只存角色和内容等到要做摘要轮次判定、删除过期消息、检索定位时才发现没有ID和时间戳寸步难行。我就是吃过这个亏后来重写了整个状态模块。token估算也建议做进去。不用精确到token级别中文字符数乘以1.5、英文按空格分词再乘以1.3粗略估算就够用。目的是在塞消息之前先判断会不会超限。3.2 滑动窗口的核心逻辑滑动窗口看起来简单但有一个细节很多人做错删除早期消息时不要只按条数删要按token数删。def slide_window(state: ConversationState, max_tokens: int 8000): 按token预算裁剪历史消息保留最近的消息 while state.messages and estimate_tokens(state.messages) max_tokens: # 始终保留最近一条assistant回复往前删 if len(state.messages) 1: break # 从最早的非最后一条消息开始删除成对删除更好 removed state.messages.pop(0) # 如果弹出的不是最后一条尽量连带下一条一起删保持对话连贯性 return state为什么尽量成对删除user和assistant消息因为单删一条user消息后面assistant的回复就失去了引用的上下文模型看到的是一段残缺的对话。当然如果消息本来就是单轮问答结构可以按条删但多轮对话场景务必成对处理。窗口大小怎么定一般取模型支持窗口的40%-60%。比如模型窗口是16K窗口预算设在6K到8K比较稳妥。为什么不是越满越好要给模型的回复留空间也要给后续注入的摘要、检索结果留buffer。窗口塞到95%模型想回答都只能写一小段体验很差。3.3 摘要压缩的触发与实现摘要触发时机是个学问。我测试下来两个触发条件组合最稳定消息累计token超过窗口的50%距离上次摘要超过5轮对话。两者满足其一就触发一次摘要。这样既不会频繁调用模型烧钱也不会等到窗口爆炸才处理。def maybe_summarize(state: ConversationState, llm_func, max_window_tokens: int 8000): current estimate_tokens(state.messages) if current max_window_tokens * 0.5: return False # 远没到阈值不处理 # 这里保留最近4轮作为原始记忆更早的进入摘要 keep_recent 4 recent_messages state.messages[-keep_recent:] history_to_summarize state.messages[:-keep_recent] if not history_to_summarize: return False new_summary llm_func(summarize_prompt(state.summary, history_to_summarize)) state.summary new_summary state.messages recent_messages state.last_summary_ts time.time() return True摘要的prompt是关键。我踩过的坑是prompt写得不够细模型会把用户是张三下单了3件T恤这种关键信息丢掉。后来我总结了一套结构化摘要模板要求模型必须输出四块内容你是一个对话记忆整理员。请阅读以下对话片段输出结构化摘要 1. 用户目标用户当前想完成的核心诉求 2. 关键实体订单号、姓名、金额、地址、日期等具体信息 3. 已确认事项双方已经达成一致的结论 4. 待办/未决事项还没有解决的、用户期待后续处理的内容 请用简洁中文不要遗漏具体数字和专有名词。这样处理之后摘要里的信息密度高了很多。之前模型会把退款金额98.5元写成退款事宜已沟通损失巨大要求输出关键实体后数字就不会丢了。3.4 向量检索注入的最小实现如果做完整的检索模式需要引入Embedding模型和向量数据库。很多团队在这里被复杂度劝退但其实最小可跑版本并不复杂。我介绍一个轻量落地的做法。先把每轮对话按一定粒度拆分生成向量存入向量库同时存原始文本和元信息。查询时用用户当前的问题生成查询向量召回TopK个片段拼进上下文。这里有一个我反复强调的经验不要只按对话轮切块要按语义段落切块。一轮对话可能涉及三个话题一个话题也可能跨好几轮。简单按轮切会导致检索召回噪声。def retrieve_relevant_messages(query: str, vector_store, top_k: int 4) - List[Message]: query_vec embed(query) hits vector_store.search(query_vec, top_ktop_k) # hits 里带原始消息内容、会话归属、时间戳 return [h.raw_message for h in hits if h.session_id current_session_id]注入时注意顺序检索结果放在摘要和最近对话之间而不是最前面。放在最前面会干扰模型对当前任务的感知。我见过一个项目把检索片段塞在系统提示词后面结果模型状态被带偏回答风格都变了。正确顺序一般是系统提示词 → 历史摘要如果有 → 检索到的相关历史片段按时间正序 → 最近N轮原始对话 → 用户的当前问题4. 关键参数怎么调一份实测参考4.1 各参数的作用与推荐区间调参是context-mode的核心工作。不同项目最优参数差很多但有一些基准参考。我整理了一张表参数作用推荐区间说明窗口预算历史消息最多占多少token模型窗口的40%-60%留出回复和检索注入空间保留最近轮数不摘要的原始对话条数4-8轮太短模型缺乏临场感知太长摘要意义不大摘要触发阈值总token达到多少触发压缩窗口预算的70%-90%提前量太小容易触发频繁检索TopK每次注入几条相关记忆3-5条太少不够用太多上下文膨胀摘要条数上限摘要本身超过多少重新压缩800-1500 token多轮对话摘要会越滚越长要二次压缩这些参数不能拍脑袋决定最好是压测后微调。我的习惯是先用推荐值上线然后专门构造30轮长对话测试集观察回答准确率、上下文token均值、延迟三个指标。4.2 三种模式的真实对比我拿同一套多轮客服对话数据40轮、含订单查询、售后沟通、个人信息确认分别用三种模式跑了个对比。结果很有参考价值指标滑动窗口摘要压缩向量检索单次调用平均token数4.2K6.8K7.5K关键信息召回率提到订单号能否正确回答31%82%88%平均响应延迟0.8s1.1s1.4s实现复杂度低中高滑动窗口到了第15轮以后基本就在裸聊模型只能靠最近几轮的信息答题早期信息全丢。摘要模式在40轮内表现相当好但跑到了80轮后我观察到摘要开始稀释——第一轮的重要信息被后续摘要层层覆盖逐渐丢细节。向量检索最稳但前提是切块质量高、Embedding选对。我的结论如果你的对话平均不超过10轮滑动窗口够用10到50轮摘要模式是性价比之王50轮以上或者有长期用户画像需求必须上向量检索。大部分商业化客服项目摘要检索混合已经能覆盖99%场景。5. 常见问题与排查思路实录5.1 模型聊着聊着就忘了但日志里信息明明都在这是最诡异的bug检查代码历史消息没丢但模型就是答不对。我排查过几次之后发现问题出在消息顺序上。有些框架的messages数组是倒序存储的新的在前直接拼接后模型看到的是倒叙对话逻辑混乱。解决办法很蠢也很简单在发给模型之前强制做一次messages sorted(messages, keylambda m: m.timestamp)然后用debug日志打印出实际发给模型的完整prompt人肉检查一遍顺序和内容。任何上下文诡异问题第一步永远是dump完整prompt。我见过太多人抱着代码猜半天最后发现是prompt拼装时漏了某个字段。5.2 摘要越压越失真关键数字丢失摘要模式最常见的问题。有时不是prompt没写好而是摘要多次迭代后信息在复述的复述中流失。第一次摘要还留着订单号第二次摘要把第一次的内容再压缩订单号就没了。我目前比较有效的方案是两层结构第一层是滚动摘要——每当新摘要生成和旧摘要合并但prompt里明确要求保留所有数字、订单号、金额、地址等不可压缩信息第二层是关键事实库——单独维护一个列表凡是出现订单号、金额、身份证等强实体直接抽取到库里不经过摘要。查询的时候把关键事实库拼到系统提示词里。# 关键事实抽取示例正则LLM结合 import re def extract_factoids(content: str) - List[str]: facts [] # 订单号/编号类模式 for match in re.findall(r(?:订单号|编号|单号)[:]?\s*[A-Z0-9]{6,20}, content): facts.append(match.strip()) # 金额类 for match in re.findall(r(?:金额|价格|费用)[:]?\s*[0-9](?:\.[0-9])?\s*元, content): facts.append(match.strip()) return facts这样即使摘要做得再烂关键数字也不会丢。5.3 检索模式召回了但没用上向量检索命中率很高但模型还是答错。我调试了几次发现是召回片段和当前问题的时间线对不上。比如用户第30轮问之前那个退款处理好了吗系统召回了第5轮的退款申请片段但没召回第20轮退款已驳回的片段模型以为退款还在处理中。解决思路检索结果注入前按时间戳排序并加上时间段标注。在每条检索片段前加一行元信息[对话片段 第12-15轮 发生在用户询问退款进度之后] 用户退款大概什么时候到账 客服已提交财务预计3-5个工作日。加上时间锚点之后模型对信息的先后关系判断准确多了。另外一个经验是检索TopK不要贪多3条高质量片段远好于8条鱼龙混杂。多了模型会捡了芝麻丢西瓜。5.4 一个容易被忽略的性能坑摘要和检索都是额外的大模型调用或网络IO如果每个用户请求都同步触发接口延迟会非常难看。我的方案是把摘要和向量化做成异步任务用户请求进来先用现有状态返回流式回答后台异步更新摘要、更新向量库。下次请求自然就能用上更新后的记忆。这个写后读模式在并发场景下非常稳但注意要加锁或版本号防止两个异步任务同时写导致状态覆盖。6. 场景化选型建议你的项目该用哪种模式做技术选型的时候别急着跟风。我见过很多团队一听RAG就上向量库结果数据量还没到一千条白搭了整套基础设施。我的选型判断依据很简单看三件事对话轮数的分布、信息的时效性要求、团队能接受的运维复杂度。短对话场景均值5轮以内滑动窗口就够。别的不说成本最低没有额外依赖。硬上摘要或检索属于杀鸡用牛刀还会引入新的故障点。中等长度对话10-50轮摘要模式是甜点区。单次调用增加的时间在可接受范围且记忆保留能力提升了几个档次。如果你的业务有订单号、账号这类强实体再加上关键事实库的抽取基本能做到不忘事。超长对话或跨会话记忆50轮以上/需要记住用户偏好上向量检索。这里我建议不要自己写向量化中间层直接用成熟的向量数据库把精力花在切块策略和召回质量调优上。还有一个很多人的盲区不同功能模块可以用不同模式。比如一个AI助手闲聊模块用滑动窗口资料查询模块用检索长任务执行模块用摘要。不要试图用一种context-mode包打天下全局统一反而在特定场景上都不够好。7. 我最后想说的一个心得做了这么多轮context-mode的迭代最大的体会是上下文管理本质上是在做取舍而不是在做优化。你不可能同时做到全记住零延迟低成本这三者是互相拉扯的。想清楚你的业务最在意哪个维度然后接受另外两个维度的损失比什么都重要。拿我自己来说刚开始做智能客服的时候总觉得记忆越全越好结果延迟上来、成本飙升用户反而抱怨回复变慢了。后来把记忆策略改成该记的记、不该记的丢体验反而更好了。用户根本不在乎你有没有记住他第3轮说的那句废话他只在乎订单号、退款进度、地址这些关键信息别丢。最后分享一个我一直在用的小技巧上线前用一套固定的长对话脚本做回归测试每周跑一遍对比关键信息召回率。这个指标比什么延迟、成本更能反映context-mode的健康度。只要召回率不跌参数随便调都不会出大问题一旦跌了赶紧检查是不是最近的改动动了摘要或检索的逻辑。这套方法救过我很多次也推荐你们试试。