1. 上下文管理到底在管什么
第一次听到“上下文管理”这个词,很多人会下意识觉得它是个很虚的概念,像是某种架构师画图时才用得上的术语。但如果你真正写过稍微复杂一点的对话系统、Agent 应用,或者哪怕只是一个带多轮记忆的客服机器人,你就会发现:上下文管理做得好不好,直接决定了这个系统是“能用”还是“没法用”。
我先把话说直白一点。所谓上下文管理,本质上就是解决一个非常朴素的问题:当模型一次能处理的文本长度有限时,我们该把哪些信息塞进去,把哪些信息丢掉,以及用什么方式组织这些信息,让模型在有限的窗口里做出最正确的判断。
你可以把它想象成一个会议记录员。一场会开了三个小时,老板只给你一页纸的篇幅去汇报。你不可能把每个人说的每句话都抄上去,你得判断:哪些是决议,哪些是背景,哪些是废话。上下文管理干的就是这个活,只不过对象换成了 token,判断标准换成了“对当前任务有没有用”。
这个领域之所以最近被反复提起,核心原因有三个。第一,长对话场景爆发,用户不再满足于问一句答一句,而是希望系统记住前面聊过什么。第二,Agent 类应用兴起,一个任务可能要调用十几个工具、经历几十轮推理,中间产生的中间结果如果全留着,窗口瞬间就爆了。第三,成本敏感,token 是要花钱的,上下文越长,推理越慢、越贵,没人愿意为了一堆无关信息买单。
所以这篇文章我想聊的不是某个具体框架的 API 怎么调,而是把上下文管理这件事拆开,讲讲它背后的设计思路、核心手段、实操中会踩的坑,以及我自己在项目里总结出来的一些土办法。适合正在做对话系统、Agent、RAG 应用的开发者,也适合刚接触这块、想搞明白“为什么我的机器人聊三句就失忆”的朋友。
2. 上下文管理的整体设计思路
2.1 为什么不能“全都塞进去”
新手最容易犯的错,就是觉得“窗口不是有 128k 吗,那我全塞进去不就行了”。我早期也这么干过,结果很快被现实教育了。
第一个问题是注意力稀释。模型在处理长上下文时,并不是每个 token 都同等重要。当无关信息太多时,真正关键的那句话反而可能被淹没。这在学术上叫“lost in the middle”,就是放在中间位置的信息最容易被忽略。你塞了一堆历史对话进去,结果模型偏偏忘了用户三分钟前说的那个关键约束。
第二个问题是成本与延迟。假设一次对话平均 5000 token,你每次都把完整历史带上,聊到第 20 轮就是 10 万 token。按现在的价格算,单次调用成本可能翻几十倍,响应时间也从一秒变成好几秒。用户可不会管你技术多先进,他只觉得“这破机器人怎么这么慢”。
第三个问题是信息冲突。历史里用户可能改过主意,先说“我要红色的”,后来说“算了还是蓝色吧”。如果你把两条都原封不动塞进去,模型有可能纠结,甚至选错。上下文管理的一个隐藏任务,就是帮模型消解矛盾,保留最新、最有效的状态。
所以核心思路不是“塞得越多越好”,而是在有限的预算内,最大化有效信息的密度。这跟做推荐系统有点像,你不可能把整个商品库推给用户,得排序、得截断、得做多样性。
2.2 三种主流策略的取舍
实际项目里,上下文管理大致可以归为三类做法,我按复杂度从低到高排一下。
第一类是滑动窗口。最简单粗暴,只保留最近 N 轮对话,超出的直接扔掉。优点是实现快、成本可控,缺点是“金鱼记忆”,用户很早说的关键信息会丢。适合那种单轮为主、偶尔多轮的场景,比如简单的问答。
第二类是摘要压缩。把历史对话定期用模型总结成一段简短的话,然后只带摘要加最近几轮。这个思路很符合直觉,相当于会议纪要。但坑在于摘要本身会丢信息,而且摘要也要花 token 和时间,如果摘要频率没控制好,反而更贵。
第三类是结构化记忆。把对话中的关键信息抽出来,存成结构化的键值对或者向量,需要的时候再检索回来。这是目前 Agent 类应用的主流做法,也是我认为最值得深入的方向。它把“上下文”从一段流水账变成了一个可查询的记忆库。
我自己的经验是,没有银弹,通常是组合拳。比如最近几轮原文保留,稍远的历史做摘要,关键实体和用户偏好存成结构化记忆,需要时再召回。下面几节我会把每一块拆开讲。
2.3 一个容易被忽略的原则:上下文要“可解释”
这一点很少有人提,但我觉得特别重要。你塞进模型的每一段上下文,最好都能回答一个问题:它为什么在这里?
如果你自己都说不清某段历史为什么被保留,那模型大概率也用不好它。我在项目里会要求每条上下文都带一个来源标记,比如“来自用户第 3 轮输入”“来自工具调用结果”“来自系统摘要”。这样出问题时能快速定位,也方便做 A/B 测试,看看到底是哪类信息在起作用。
3. 核心手段拆解与实操要点
3.1 滑动窗口:不是简单截断那么简单
滑动窗口听起来最简单,但真要做好也有讲究。最粗糙的做法是按轮数截断,保留最近 10 轮。但问题是,一轮对话的长度可能差很多,有的一轮就一句话,有的一轮带了一大段代码。按轮数截断,很容易出现“保留了 10 轮废话,丢掉了 1 轮关键需求”的情况。
更合理的做法是按 token 预算截断。你先设定一个总预算,比如 4000 token 给历史,然后从最近的消息往前累加,直到接近预算为止。这样能保证窗口利用率稳定。
def build_window(messages, budget=4000): selected = [] used = 0 for msg in reversed(messages): cost = count_tokens(msg["content"]) if used + cost > budget: break selected.append(msg) used += cost return list(reversed(selected))这里有个细节:系统提示词和当前用户输入要优先保留,不能被截断。所以实际预算应该是总预算 - 系统提示 - 当前输入,剩下的才给历史。我见过有人把系统提示也一起截了,结果模型人格都变了,这就很尴尬。
注意:截断时尽量以“完整消息”为单位,不要把一条消息从中间切断。半句话的上下文比没有更糟,模型可能基于残缺信息瞎猜。
3.2 摘要压缩:什么时候摘、摘多细
摘要压缩的关键参数有两个:触发时机和压缩粒度。
触发时机上,常见做法是“当历史 token 超过阈值时触发一次摘要”。比如历史超过 3000 token,就把最早的一半压缩成摘要。这样能保证摘要不会太频繁,也不会等到爆窗口才手忙脚乱。
压缩粒度上,我建议分层摘要。不要把所有历史压成一段话,而是保留一个“滚动摘要”加若干“阶段摘要”。滚动摘要记录整体背景和长期偏好,阶段摘要记录最近几个话题的结论。这样模型既知道大局,也知道细节。
摘要的 prompt 也有讲究。我一般会要求模型输出固定结构,比如:
【用户目标】... 【已确认事实】... 【待解决问题】... 【用户偏好】...结构化之后,后续检索和拼接都方便很多。纯自然语言的摘要,用起来很灵活,但不好做程序化处理。
实操心得:摘要一定要保留“否定信息”。用户说“不要用红色”,摘要里如果只写“讨论了颜色”,那这个约束就丢了。我一般会强制要求摘要包含“用户明确排除的选项”。
3.3 结构化记忆:把对话变成可查询的数据库
这是我认为最有价值的一块。核心思想是:对话是流,但记忆应该是表。
具体做法是,在对话过程中实时抽取关键信息,存成结构化字段。比如用户说“我住在杭州,平时喜欢喝美式”,就抽成{city: "杭州", coffee: "美式"}。下次需要的时候,直接查这个表,而不是去翻历史记录。
抽取的时机可以是每轮结束后异步做,避免阻塞主流程。抽取的内容一般包括:实体(人名、地点、时间)、偏好(喜欢什么、讨厌什么)、任务状态(做到哪一步了)、约束条件(必须满足什么)。
存储上,简单场景用 JSON 或 Redis 就够了,复杂场景可以上向量库做语义检索。我的建议是两者结合:结构化字段做精确匹配,向量做模糊召回。比如用户问“我之前说的那个地方”,精确匹配匹配不到,但向量能召回“杭州”。
memory = { "facts": {"city": "杭州", "coffee": "美式"}, "preferences": {"language": "中文", "tone": "简洁"}, "task_state": {"step": "已选好酒店", "next": "订机票"} }拼接上下文时,把这块记忆格式化成一段简短文本放在系统提示后面,模型就能“记得”这些事,而且不占太多 token。
3.4 检索增强:需要时才召回
结构化记忆解决的是“已知要记什么”,但有些信息你事先不知道会不会用到。这时候就需要检索增强,也就是常说的 RAG 思路用在上下文管理上。
做法是把历史对话切块、向量化、存库。当前用户提问时,先用这个问题去检索最相关的几段历史,只把这几段拼进上下文。这样既保留了长期记忆,又控制了 token 消耗。
这里的关键是检索质量。我踩过的坑是:直接用用户原话检索,效果往往一般,因为用户提问和历史表述用词可能完全不同。改进办法是先做查询改写,让模型把用户问题改写成几个可能的检索关键词,再去召回。这一步能明显提升命中率。
另一个坑是召回太多。检索出来 20 条,全塞进去又爆了。所以要设一个 top-k,一般 3 到 5 条就够,再配合一个相关性阈值,低于阈值的直接不要。
4. 完整实操流程与关键环节
4.1 一个可落地的上下文组装流程
把前面几块拼起来,我给一个我自己项目里用过的组装流程,你可以直接参考。
整个流程分五步。第一步,接收用户输入,先做意图识别和查询改写。第二步,从结构化记忆里取出相关字段。第三步,用改写后的查询去向量库召回相关历史片段。第四步,取最近 N 轮原文作为短期上下文。第五步,把系统提示、结构化记忆、召回片段、短期原文、当前输入按顺序拼起来,并做最终的 token 预算检查。
顺序很重要。我一般把最稳定的信息放前面(系统提示、长期偏好),最相关的信息放中间偏后(召回片段),最新的信息放最后(当前输入)。这样符合模型“近因效应”,最近的输入影响最大。
def assemble_context(user_input, memory, retriever, recent_msgs): query = rewrite_query(user_input) recalled = retriever.search(query, top_k=4, threshold=0.75) facts = format_memory(memory) short_term = build_window(recent_msgs, budget=2000) context = [ {"role": "system", "content": SYSTEM_PROMPT + facts}, *[{"role": "system", "content": f"相关历史:{r}"} for r in recalled], *short_term, {"role": "user", "content": user_input} ] return trim_to_budget(context, total_budget=8000)4.2 参数怎么定:给几个参考值
参数没有绝对标准,但可以给几个我实测下来比较稳的起点,你再根据自己场景微调。
| 参数 | 参考值 | 说明 |
|---|---|---|
| 总 token 预算 | 8000 | 留足余量给模型输出 |
| 短期原文预算 | 2000 | 约最近 5 到 8 轮 |
| 召回片段数量 | 3 到 5 条 | 太多会稀释注意力 |
| 相关性阈值 | 0.7 到 0.8 | 低于此值不召回 |
| 摘要触发阈值 | 3000 | 历史超过就压缩 |
| 结构化记忆上限 | 20 个字段 | 太多反而干扰 |
这些值不是拍脑袋来的。比如短期预算 2000,是因为我观察到大部分对话的关键信息集中在最近 5 轮内,再往前就明显衰减。召回 3 到 5 条,是因为超过 5 条后,模型对每条的平均注意力下降得很快,实测准确率反而降低。
4.3 实操现场:一次多轮订票对话的上下文变化
我拿一个订票场景举例,看看上下文是怎么随对话演进的。
第一轮,用户说“帮我订一张去北京的票”。此时上下文里只有系统提示加这句话,结构化记忆为空。
第三轮,用户补充“要下午的,靠窗”。结构化记忆更新为{destination: "北京", time: "下午", seat: "靠窗"}。短期原文保留这三轮。
第八轮,用户问“刚才说的那个时间还有别的班次吗”。这时候“刚才说的那个时间”指代不明,直接检索可能失败。查询改写把它变成“下午 北京 班次”,召回第三轮的偏好片段,模型就能正确理解。
第十五轮,历史已经很长,触发摘要。前七轮被压缩成一段阶段摘要,结构化记忆继续保留关键字段。此时上下文里是:系统提示 + 结构化记忆 + 阶段摘要 + 最近八轮原文 + 当前输入。总 token 依然控制在预算内。
这个过程里,结构化记忆是骨架,摘要和召回是血肉,短期原文是皮肤。三者配合,才能让模型在长对话里不迷失。
5. 常见问题与排查技巧实录
5.1 模型“失忆”了,怎么定位
失忆是最常见的问题,但原因可能有好几种,得逐个排查。
先看结构化记忆有没有更新。如果用户说了新信息但记忆没变,那是抽取环节的问题,可能是抽取 prompt 没覆盖这类信息,或者异步任务失败了。
再看召回有没有命中。把召回结果打日志,看看相关历史到底有没有被检索出来。如果没命中,是查询改写的问题还是向量库的问题。如果命中了但没进上下文,那是预算裁剪把它挤掉了。
最后看拼接顺序。有时候信息在上下文里,但被放在了很靠前的位置,模型没注意到。试着把它挪到靠近当前输入的地方,往往就“想起来”了。
5.2 上下文越长效果越差,怎么办
这个现象很典型,通常不是模型不行,而是信噪比太低。我的处理办法是三步。
第一步,做去重。历史里经常有大量重复表述,比如用户反复确认同一件事。去重能省下不少 token。
第二步,做冲突消解。同一字段有多个值时,只保留最新的。比如用户先说“要红色”后说“要蓝色”,记忆里只留蓝色,并在摘要里注明“用户已更改偏好”。
第三步,做重要性排序。给每条上下文打个重要性分,预算不够时优先保留高分项。重要性可以简单按“是否包含实体、是否是用户明确指令、是否是最新”来算。
5.3 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 聊几句就忘 | 窗口太小或没做记忆 | 检查预算和记忆更新 |
| 记错信息 | 冲突未消解 | 检查同字段是否多值 |
| 响应变慢 | 上下文过长 | 看 token 数和召回量 |
| 答非所问 | 召回不相关 | 检查查询改写和阈值 |
| 摘要丢关键点 | 摘要 prompt 太粗 | 加结构化输出要求 |
| 成本飙升 | 每次都带全量历史 | 引入摘要和检索 |
避坑技巧:上线前一定要做上下文长度监控。我见过太多项目上线后才发现平均 token 是预估的三倍,账单直接爆炸。埋个点,统计每次调用的 token 分布,心里才有底。
5.4 几个我踩过的坑
第一个坑是摘要太频繁。一开始我设的阈值很低,结果每两轮就摘要一次,不仅慢,而且摘要本身也消耗 token,算下来比不摘要还贵。后来把阈值调高,只在必要时才摘。
第二个坑是结构化记忆字段太多。我一度想把所有信息都结构化,结果记忆表几十个字段,拼进上下文反而成了噪音。后来砍到只留最关键的十几个,效果明显变好。
第三个坑是忽略时区。用户说“明天下午三点”,如果系统不记录当前时间和时区,模型可能算错日期。这类隐含信息一定要在系统提示里明确。
6. 上下文管理的扩展方向
6.1 从单会话到跨会话记忆
现在大部分实现都是单会话内的上下文管理,会话一结束记忆就没了。但真实产品里,用户希望系统“记得我上次说过什么”。这就需要把结构化记忆持久化,按用户 ID 存储,下次会话开始时加载。
跨会话记忆的难点在于隐私和隔离。不同用户的数据必须严格分开,同一用户的不同场景也要区分。我的做法是给记忆加命名空间,比如user_id + scene,检索时限定范围。
6.2 让模型自己管理上下文
目前上下文管理大多是规则驱动的,什么时候摘要、召回几条,都是人定的。一个有意思的方向是让模型自己决定。比如给模型一个工具,它可以主动调用“记住这个”“忘掉那个”“检索关于 X 的历史”。这样上下文管理就从被动变成主动,更接近人类的工作记忆机制。
我试过简单的版本,让模型在每轮结束时输出“需要记住的要点”,效果还不错。复杂版本还在探索,主要是稳定性和成本的问题。
6.3 多模态上下文
现在对话里越来越多图片、语音、文件。这些内容的 token 占用和文本完全不同,一张图可能就顶几千 token。多模态上下文管理需要额外考虑:图片要不要压缩、要不要只保留关键帧、语音要不要先转文字再管理。这块我经验还不算多,但可以肯定的是,不能简单套用文本那套预算逻辑。
我个人在实际操作中的体会是,上下文管理这件事,七分靠设计,三分靠调参。设计阶段想清楚“什么信息值得留、以什么形式留、什么时候召回”,比后面反复调阈值有用得多。另外,别指望一次做到位,先上一个能跑的简单版本,观察真实对话里的问题,再逐步加摘要、加检索、加结构化记忆。每加一层,都要用数据验证它到底有没有带来提升,而不是凭感觉堆功能。最后分享一个小技巧:把每次调用的完整上下文存一份日志,出问题时回放一遍,你会发现自己对“模型到底看到了什么”的理解,和实际情况经常差很远。