1. 为什么“上下文管理”是AI应用里最被低估的硬功夫
很多人第一次接触“上下文管理”这个词,是在做对话类应用或者智能体开发的时候。一开始大家的注意力几乎都放在模型选型、提示词怎么写、工具怎么调上,觉得只要模型够强、提示词够精妙,效果自然就好。但真正把应用推到线上、面对真实用户之后,你会发现一个反直觉的事实:决定一个AI应用能不能长期稳定运行的,往往不是模型本身,而是上下文管理做得好不好。
我先把话说直白一点。所谓上下文管理,就是在一个多轮交互或者多步骤任务里,决定“每一轮到底把哪些信息塞给模型、以什么顺序塞、塞多少、旧的怎么丢、新的怎么留”的一整套机制。它听起来像是工程细节,实际上直接决定了三件事:回答质量、响应成本、以及系统在长对话下的稳定性。
举个生活化的类比。你可以把大模型想象成一个记忆力很好但“工作台很小”的专家。他能力很强,但你每次只能往他桌上放有限几页纸。上下文管理就是那个负责整理桌面的人:哪些资料必须放最上面、哪些可以压箱底、哪些过期了直接扔掉、哪些要提前摘要成一句话。桌面整理得好,专家干活又快又准;整理得乱,专家要么被无关信息带偏,要么因为桌面堆满而根本没法思考。
这篇内容适合几类人看:正在做对话机器人、智能客服、知识问答、Agent 工作流的开发者;被“聊到十几轮就开始胡言乱语”困扰的产品同学;以及想系统理解上下文机制、而不是只会调 API 的技术爱好者。我会从原理、策略、实操、踩坑几个角度,把这件事讲透,并且给出可以直接抄作业的方案。
需要先说明的是,下面涉及的具体参数、阈值、代码示例,都是基于行业里常见的工程实践做的合理补充,不同模型和框架会有差异,你在落地时要结合自己的实际情况调整。
2. 上下文窗口到底装了什么,为什么它会“满”
2.1 一次请求里,token 都花在哪了
要管好上下文,先得知道上下文里到底有什么。很多人以为“上下文 = 聊天记录”,其实远不止。一次典型的对话请求,token 消耗大致分布在这么几块:
| 组成部分 | 说明 | 是否随轮次增长 |
|---|---|---|
| 系统提示词 | 角色设定、规则约束、输出格式要求 | 否,固定 |
| 工具/函数定义 | Agent 场景下每个工具的 schema 描述 | 否,固定但可能很大 |
| 历史对话 | 用户和助手的往返回合 | 是,线性增长 |
| 检索内容 | RAG 召回的文档片段 | 是,按需注入 |
| 当前用户输入 | 本轮问题 | 否 |
| 预留输出空间 | 给模型生成回答留的额度 | 否 |
看清楚这张表,你就能理解一个常见现象:为什么有些应用聊到第五轮就开始变笨。因为系统提示词和工具定义可能已经吃掉了窗口的一大半,历史对话再一涨,留给“真正思考”和“生成回答”的空间就被挤没了。
这里有个容易被忽略的点:工具定义非常占 token。一个稍微复杂点的 Agent,挂十几个工具,每个工具的 JSON schema 描述加起来轻松上千 token。这部分是固定的,每轮都要重复发送。如果你的窗口是 8K,光工具定义就占 2K,那你能用来放对话历史的空间其实非常有限。
2.2 窗口“满”了之后会发生什么
当输入接近窗口上限时,不同系统的行为不一样,但常见的几种都很坑:
- 直接报错:请求被拒绝,用户体验直接断裂。
- 静默截断:系统从最早的消息开始丢,用户发现“它怎么忘了我刚才说的”。
- 模型性能下降:即使没超限,当上下文很长时,模型对中间部分信息的注意力会明显减弱,这就是业内常说的“lost in the middle”现象——开头和结尾记得清,中间的内容容易被忽略。
提示:不要等到报错才处理上下文。真正稳的系统,是在接近上限之前就主动做压缩和整理,而不是被动等它溢出。
理解了“装了什么”和“满了会怎样”,接下来的问题就变成:怎么在有限空间里,让最有价值的信息始终留在桌面上。这就是各种上下文管理策略要解决的核心问题。
3. 四种主流上下文管理策略,以及它们各自的适用边界
3.1 滑动窗口:最简单,但最容易丢关键信息
滑动窗口的思路非常朴素:只保留最近 N 轮对话,更早的直接丢掉。实现简单,成本可控,是很多初版应用的选择。
def sliding_window(messages, max_turns=10): # 保留系统提示词 + 最近 max_turns 轮对话 system = [m for m in messages if m["role"] == "system"] dialog = [m for m in messages if m["role"] != "system"] return system + dialog[-max_turns * 2:]它的优点是可预测:无论聊多久,token 消耗都稳定在一个范围内。但缺点也很致命:如果用户在第三轮说了一个关键约束(比如“预算不超过五千”),聊到第十五轮时这条信息早就被滑出去了,模型自然就忘了。
我的经验是,滑动窗口只适合短会话、无强记忆需求的场景,比如一次性的问答、简单的工具调用。只要涉及多轮任务、需要记住用户偏好或约束,就不能只靠它。
3.2 摘要压缩:把旧对话“折叠”成一段话
摘要压缩的思路是:当历史对话变长时,用模型把较早的对话总结成一段简短的摘要,然后用这段摘要替代原始对话。
def summarize_old_messages(old_messages, llm): prompt = "请把以下对话压缩成不超过150字的摘要,保留关键事实、用户偏好和未完成的待办:\n" text = "\n".join(f"{m['role']}: {m['content']}" for m in old_messages) return llm.invoke(prompt + text)这个方案的价值在于用少量 token 保留长期记忆。但它有两个坑必须注意:
第一,摘要是有损的。模型总结时可能丢掉它认为不重要、但实际很关键的细节。所以摘要的提示词里一定要明确要求“保留关键事实、数字、约束和待办”。
第二,摘要本身也要花一次模型调用,有延迟和成本。所以不要每轮都摘要,而是设定一个触发条件,比如历史超过 20 轮、或者 token 超过某个阈值时才做一次。
3.3 向量检索记忆:按相关性召回,而不是按时间
前面两种都是“按时间”管理上下文,而向量检索记忆是“按相关性”。做法是把历史对话或知识切块存入向量库,每轮根据当前问题检索最相关的片段注入上下文。
这套方案特别适合长期记忆型应用,比如个人助理、需要记住大量历史偏好的系统。用户三个月前说过“我对花生过敏”,今天问“推荐个餐厅”,系统能通过语义检索把这条信息捞回来。
但它也有边界:检索依赖语义相似度,如果当前问题和历史信息在字面上不相关、但逻辑上相关,就可能召回失败。所以实践中常常是向量检索 + 摘要 + 滑动窗口三者组合,而不是单用某一种。
3.4 结构化状态:把关键信息抽成字段,而不是靠模型记
这是我个人最推荐、也最容易被忽视的一种策略。与其让模型从一堆对话里“回忆”用户的偏好,不如在对话过程中主动把关键信息抽取成结构化字段。
# 维护一个显式的会话状态 session_state = { "user_name": None, "budget": None, "constraints": [], "current_task": None, "completed_steps": [] }每轮对话后,用一次轻量的抽取把新信息更新进这个状态,然后每轮请求时把状态序列化成一小段文本放进系统提示词。这样无论聊多少轮,关键信息永远在“桌面最上面”,而且占用的 token 极少。
注意:结构化状态和摘要不冲突,反而互补。状态负责“精确记住关键字段”,摘要负责“保留对话的上下文氛围”,两者一起用效果最好。
把这四种策略放在一起对比,你会更清楚怎么选:
| 策略 | 核心思路 | 优点 | 主要风险 | 适用场景 |
|---|---|---|---|---|
| 滑动窗口 | 只留最近N轮 | 简单、成本稳定 | 丢失早期关键信息 | 短会话、一次性问答 |
| 摘要压缩 | 旧对话折叠成摘要 | 保留长期记忆、省token | 有损、额外调用成本 | 长对话、任务型助手 |
| 向量检索 | 按相关性召回 | 精准捞回相关记忆 | 依赖语义匹配质量 | 长期记忆、知识密集 |
| 结构化状态 | 抽取关键字段 | 精确、token占用极低 | 需要设计抽取逻辑 | 有明确槽位的任务 |
4. 一套可落地的上下文管理流水线怎么搭
4.1 先定预算:给每一块分配 token 额度
动手写代码之前,先做一件很多人跳过的事:给上下文做预算分配。假设你用的模型窗口是 16K,输出预留 2K,那输入可用 14K。这 14K 怎么分?
- 系统提示词 + 工具定义:最多 3K
- 结构化状态:最多 1K
- 检索/摘要记忆:最多 4K
- 最近对话原文:最多 5K
- 当前输入:剩余空间
有了这个预算表,你就知道每一块该在什么时候触发压缩。比如最近对话超过 5K 时,就把最老的部分转成摘要;检索记忆超过 4K 时,就减少召回条数。预算是上下文管理的“财务纪律”,没有它,所有策略都是拍脑袋。
4.2 组装顺序:把最重要的信息放在开头和结尾
前面提到过“lost in the middle”现象,所以信息的摆放顺序很讲究。一个经过验证的组装顺序是:
- 系统提示词(角色和规则)
- 结构化状态(关键事实,必须靠前)
- 长期记忆摘要(背景)
- 检索到的相关内容(按相关性排序)
- 最近几轮对话原文(越近越靠后)
- 当前用户输入(放最后)
把当前输入放最后,是因为模型对结尾位置的内容注意力最强,这样能保证它“盯着”用户最新的话来回答。而关键状态放开头,是因为开头位置同样有注意力优势。
4.3 触发时机:什么时候压缩,什么时候不动
不是每轮都要做全套处理,那样又慢又贵。合理的触发逻辑是这样的:
def build_context(session, user_input, token_budget): messages = [system_prompt, serialize_state(session.state)] # 只在历史超过阈值时才做摘要 if count_tokens(session.recent_dialog) > token_budget["recent"]: session.summary = summarize(session.old_dialog) session.recent_dialog = session.recent_dialog[-6:] if session.summary: messages.append(session.summary) # 只在需要时检索 if needs_retrieval(user_input): messages.extend(retrieve(user_input, top_k=3)) messages.extend(session.recent_dialog) messages.append(user_input) return messages核心思想是按需触发:平时轻量运行,接近预算上限时才做重活。这样既控制了成本,又保证了长对话下的稳定性。
4.4 一个完整的会话状态更新示例
光有读取还不够,每轮结束还要把新信息写回状态。下面是一个简化的更新逻辑:
def update_state(state, user_input, assistant_reply, llm): extract_prompt = f""" 从下面这轮对话中抽取需要长期记住的信息,输出JSON。 只抽取明确出现的事实,不要臆测。 对话: 用户:{user_input} 助手:{assistant_reply} 字段:user_name, budget, constraints, current_task """ new_info = llm.invoke(extract_prompt) for key, value in new_info.items(): if value: state[key] = value return state这里有个实操心得:抽取提示词里一定要写“只抽取明确出现的事实,不要臆测”。否则模型很容易自作主张地填充字段,导致状态被污染,后面所有轮次都跟着错。
5. 实测中最容易踩的五个坑
5.1 摘要越摘越糊,关键数字全丢了
我见过最常见的翻车场景:用户说“我要订下周三下午三点、两个人、靠窗的位置”,聊了几轮之后摘要变成“用户想订餐厅”。时间、人数、位置全没了。
根因是摘要提示词太笼统。修复方法是在摘要提示词里强制要求保留数字、时间、专有名词和未完成待办,并且给出正反例。实测下来,加上“所有数字和时间必须原样保留”这一句,信息丢失率会明显下降。
5.2 工具定义悄悄吃掉了半个窗口
前面提过,工具 schema 很占 token。有个项目挂了 20 多个工具,光定义就 4K token,结果对话才聊三轮就超限。排查时一度以为是历史对话的问题,最后用 token 计数工具一拆解才发现是工具定义。
解决办法有两个:一是按场景动态加载工具,当前任务用不到的工具不注入;二是精简 schema 描述,把冗长的说明压到最短但语义完整。
5.3 检索召回了“看起来相关但没用”的内容
向量检索的相似度高,不代表内容有用。有一次用户问“帮我改一下上周那份方案”,检索系统召回了五段关于“方案写作技巧”的通用内容,却没有召回那份具体方案。因为“上周那份方案”和方案正文的语义相似度并不高。
这类问题的修复思路是混合检索:向量相似度 + 关键词匹配 + 时间过滤一起用。对于“上周”“那份”这种带指代和时间限定的查询,纯向量检索很容易失效。
5.4 状态字段被模型“脑补”污染
前面提过,抽取时模型会臆测。更隐蔽的一种污染是:用户随口说“预算大概五千左右吧”,模型把 budget 字段填成 5000,后面用户改口“其实三千也行”,如果抽取逻辑没做覆盖更新,状态里就同时存在两个值,模型无所适从。
所以状态更新要明确覆盖规则:同一个字段,新值覆盖旧值,除非是列表型字段(如 constraints)才追加。
5.5 压缩触发太晚,用户已经感觉到卡顿
有些系统是等报错了才压缩,这时候用户已经经历了一次失败请求。正确做法是设置预警阈值,比如用到预算的 80% 就开始压缩,留出缓冲。这个阈值需要根据你的模型响应速度和用户容忍度来调,我一般设在 75% 到 85% 之间。
6. 把上下文管理做成可观测、可迭代的系统
6.1 必须埋的几项监控指标
上下文管理不是设好就完事,它需要持续观测。我建议至少埋这几个指标:
| 指标 | 含义 | 异常信号 |
|---|---|---|
| 每轮输入token数 | 单次请求的输入规模 | 持续逼近上限 |
| 压缩触发频率 | 多久做一次摘要 | 过于频繁说明预算太紧 |
| 摘要后信息丢失率 | 关键字段是否还在 | 上升说明摘要质量下降 |
| 检索命中率 | 召回内容是否被采用 | 偏低说明检索策略有问题 |
| 状态字段填充率 | 关键槽位是否被填上 | 偏低说明抽取逻辑漏了 |
有了这些指标,你才能判断问题出在哪一环,而不是笼统地觉得“模型不行”。
6.2 用回归测试守住长对话质量
上下文管理最怕的是“改了一处,别处长对话崩了”。所以需要一套回归测试:准备若干条长对话脚本(比如 30 轮以上),每条脚本里埋几个关键信息点,跑完之后检查这些信息点是否还在最终回答里被正确使用。
这套测试不需要很复杂,用脚本模拟用户逐轮输入,最后断言关键信息是否被记住即可。它能帮你在改动压缩策略、调整预算时快速发现回归。
6.3 迭代方向:从“能跑”到“跑得省”
当系统稳定之后,优化方向就转向成本。几个实测有效的做法:
- 分级摘要:近期对话用详细摘要,远期对话用一句话摘要,越老越简。
- 缓存系统提示词:很多模型支持提示词缓存,固定的系统提示词和工具定义可以缓存,省下重复计算的成本。
- 按需检索:不是每轮都检索,而是判断当前问题是否真的需要外部记忆,不需要就不检索。
这些优化叠加起来,在长对话场景下能省下相当可观的 token 成本,同时响应速度也会更好。
7. 我在实际项目里沉淀下来的几条经验
做上下文管理这几年,踩过的坑比写过的代码还多。最后分享几条我认为最值钱的经验,都是文档里不会写、只有真正跑过线上系统才会懂的。
第一条,永远不要相信模型会“自己记住”。任何你认为重要的信息,都要么放进结构化状态,要么写进摘要,要么确保它在最近几轮原文里。指望模型从几十轮前的对话里准确回忆,是最容易翻车的地方。
第二条,上下文管理的本质是“取舍”,不是“塞满”。新手总想把所有信息都塞进去,觉得信息越多越好。实际上,无关信息越多,模型越容易被干扰。宁可少而精,不要多而杂。
第三条,先做可观测,再做优化。没有监控数据,你根本不知道瓶颈在哪。我见过团队花大力气优化摘要算法,结果发现真正的瓶颈是工具定义太大。先量化,再动手。
第四条,预算要留缓冲,不要卡着上限跑。把预算用到 95% 的系统,遇到稍微长一点的输入就会崩。留 15% 到 20% 的缓冲,系统才有韧性。
第五条,测试要覆盖“长对话”,而不是只测单轮。单轮效果好不代表长对话好,很多问题只在第十轮、第二十轮之后才暴露。把长对话回归测试纳入 CI,是保证质量的关键一步。
这套东西没有银弹,不同业务的最优解不一样。但只要你理解了“窗口里装了什么、怎么取舍、怎么观测”,就能根据自己的场景调出一套稳的方案。上下文管理做扎实了,你的 AI 应用才算真正从 Demo 走向了可用。