1. 上下文窗口管理:Agent长跑中的隐形瓶颈
如果只用一个指标来评判一个Agent实不实用,我的答案不是它能调用多少工具,也不是它的提示词写得有多精巧,而是——它能不能在一件需要持续十分钟以上的真实任务上稳稳跑完不跑偏。
我做过一个粗糙但典型的小实验:让同一个Agent去调研一个开源仓库的代码结构,然后输出一份技术报告。任务本身不算难,前期几步也执行得行云流水。但随着它读过的文件越来越多、调用的搜索工具次数越来越频繁,到了后半程,它开始“失忆”——把仓库里已经确认过的接口说成另一个版本,反复调用同一类工具返回相同结果,甚至在总结时漏掉最开始定下的核心目标。起初我以为是模型的指令遵循能力不行,后来把整个会话日志拉出来一看,原因特别直白:上下文窗口已经被中间过程塞满了,真正重要的信息和最开始的指令早被挤到了模型的“视线”之外。
这就是上下文窗口管理的意义。它不是简单地“控制token数量”,而是当Agent面对一个开放式、多步骤、需要持续累积信息的真实任务时,你能不能保证整个决策链路始终围绕目标运转、关键证据始终可被访问、必要的历史经验始终在线。这也是我在这套Agent系列里单独把“上下文窗口管理策略”列为一节的原因——在架构、工具、记忆之外,窗口管理处理的是Agent运行时最基础也最容易翻车的资源调度问题。无论你是在做RAG流程编排、写代码生成Agent,还是搭一个能自主完成调研的通用助手,只要任务时长超过几步,这个话题就绕不过去。
这篇文章会从我在实际项目中遇到的“失控现场”讲起,拆开上下文膨胀的三种典型成因,再把一套从预算规划到动态裁剪、再到结构化记忆的完整管理策略摆到台面上,最后给一份可直接复用的代码实现和踩坑记录。内容偏实战,适合已经在做Agent开发、正被“跑着跑着就乱了”折磨的开发者参考。
2. 上下文窗口为何失控:三类资源劫持的真实场景
要看懂管理策略,先得知道窗口里那些token到底被谁吃掉了。我拆过几份跑崩的任务日志,发现所谓的“上下文爆炸”并不是平均膨胀,而是集中在三个来源。它们各自的特征和解决方式完全不同,混在一起处理一定会出问题。
2.1 系统提示词:慢慢“发福”的初始区
系统提示词是最容易被忽视的。刚开始做Agent的时候,大家都倾向于把规则写得尽量完整:角色定义、任务说明、工具使用规范、输出格式要求、禁止事项……第一版500字,跑了几次发现模型偶尔会漏步骤,于是再补一段“你必须要先分析、再调用工具、最后输出”;后来又发现格式偶尔不稳定,于是把JSON示例也塞进去。半年下来,系统提示词可能已经膨胀到几千字。它不会像工具日志那样突然暴涨,但它像一块压在窗口底部的石头——永远占着地方,而模型每一次生成都要带着它“负重前行”。
更要命的是,系统提示词里的信息权重并不是均等的。模型对提示词开头和结尾的内容更敏感,堆在中段的冗长规则容易变成“摆设”。有一段时间我发现Agent频繁违反“先确认参数再调用工具”的约束,查了很久才发现这条规则被埋在了一大段工具说明中间,早就不起作用了。后来我把这份提示词拆了:最核心的一句话目标放在开头,后置的格式说明全部挪到最终输出阶段再注入,体积降了一半,规则遵守率反而明显提升。这里想说明的是:窗口管理不是只盯着动态增长的那部分,静态提示词同样需要做瘦身和位次优化。
2.2 工具调用日志:真正的体积大户
真实Agent跑任务时的上下文增长主力,几乎永远是工具调用记录。每调用一次工具,至少要产生四份文本:Assistant发起的调用请求、工具返回的结果、模型对结果的理解或反思、以及下一轮它基于结果做出的新决策。这个循环下来,一次工具交互的token开销经常在1000到3000之间。当Agent要检索30份文档、执行20次搜索时,光是这些记录就能轻松突破窗口的中段区域。
我见过一个很典型的场景:Agent调用了一个代码搜索工具,返回了整个文件内容,8000多token。它本意只是确认一个函数名,但这份结果被原封不动丢进了上下文。紧接着下一步,它又搜索另一个符号,结果再次拉进来一个完整文件。三轮下来,窗口就被大量只有局部相关性的原始内容占满了。模型在处理后续任务时不得不从这么一堆庞杂内容里大海捞针,准确率自然急剧下滑。工具日志管理的本质,是你要替模型决定“哪些信息值得完整保留、哪些信息只需要留一个摘要、哪些信息压根不该进入窗口”。
2.3 中间观察与多轮历史:无差别堆积的缓存陷阱
第三个来源最隐蔽:Agent在中间过程产生的自我反思、阶段性计划、临时笔记,以及跟环境交互时的无关对话历史。很多框架默认“把上下文完整传给模型”,这在短任务里没问题,但任务一旦拉长,早期步骤的中间思考对当前决策往往已经没有参考价值了。它们就像后台运行的旧进程,不释放内存,越积越多。
我之前排查过一个线上Agent的异常行为:它在执行中期开始回复得越来越“敷衍”,后期干脆只输出工具调用参数而不再解释判断逻辑。翻日志发现,这个任务在两小时里累积了超过5万token的历史消息,而系统提示词早被淹没在第2万token之后的位置。模型的注意力被大量早期低价值内容分散,既找不到任务目标,也找不到关键约束,最后进入了“机械执行工具调用”的降智状态。这说明一个核心道理:上下文窗口不是缓存,它应当被当作一块需要动态分配资源的工作空间。什么阶段放什么内容、旧内容什么时候退场、重要信息通过什么方式常驻——都需要一个主动的管理者。
3. 分层管理策略:预算、裁剪、摘要与结构化记忆
吃了上面这些亏之后,我开始把上下文窗口当作CPU中的L1缓存来设计:空间有限、访问极快、但内容必须精准。围绕这个思路,我把管理策略拆成四个层面,分别是预算、裁剪、摘要和结构化记忆。前两个负责“节流”,后两个负责“保真”。
3.1 先做Token预算表,别等窗口失控才补救
很多Agent项目的上下文管理是“救火式”的:发现窗口满了,才写一段裁剪逻辑把旧消息删掉。这种做法治标不治本,因为你不知道哪些内容正在被消耗、哪些内容是可以被牺牲的。我现在的做法是在Agent启动前先定义一份预算表,给窗口内每个内容区域划定上限,像给项目排期一样给token分配额度。
这份预算表大概长这样:
| 区域 | 预算占比 | 内容构成 |
|---|---|---|
| 系统提示词与核心目标 | 5%以内 | 角色、任务目标、关键约束 |
| 任务线索与计划 | 10%以内 | 当前计划、已完成事项列表、TODO |
| 关键上下文与证据 | 30%-40% | 工具返回的重要事实、代码片段、数据 |
| 短期交互历史 | 20%-25% | 最近几次推理与行动记录 |
| 摘要缓冲 | 15%-20% | 旧过程的压缩摘要 |
| 预留空间 | 20% | 模型输出与临时内容 |
注意一个细节:预留空间往往被人忽略。模型的每次输出都要占用窗口空间,如果上下文已经堆到了90%,那模型生成内容时就会变得极其局促,甚至出现输出被截断的情况。我一般会把动态预留控制在20%左右,宁可少放一些历史内容,也要保证模型有足够的“书写空间”。
预算表的另一个作用是定义各类操作的触发阈值。比如“当前使用量超过总窗口70%”时启动整理程序;“最近3轮工具调用超过8000 token”时立刻对工具日志执行压缩。量化指标负责驱动决策,管理者不用每次去估算上下文状态,让阈值自动触发即可。
3.2 裁剪要有顺序,先动结构性内容再动关键依据
裁剪是整个策略里最容易踩坑的一环。很多新手实现会用最粗暴的方式:对话历史超过N条就弹掉最旧的一条。但Agent上下文里每类内容的价值和可替代性完全不同,一刀切裁剪很可能把现在还依赖的关键证据给切掉。
我的裁剪顺序是这样的,从“先牺牲”到“最后动”:
- 中间过程的推理碎语。Agent在自主思考时产生的“嗯,这个结果看起来不太对,我再看看另一个接口”——这些话当时有用,但一旦行动完成就几乎没有复盘价值,优先裁剪。
- 已完成工具调用的详细输入输出。比如一次搜索结果已经用完了,总结出了结论,那么原始搜索结果可以被替代。
- 早期轮次中的计划描述。计划一旦被执行过,它的详细版本就不需要再三出现,只要保留“计划A已完成”这个状态即可。
- 阶段性状态记录。如果任务分多个阶段执行,前一阶段的完整过程对当前阶段往往只剩参考价值。
- 最近一轮到两轮的原始内容。这部分最贴近当前决策,只要空间允许就尽量保留。
这份顺序背后的原则是:时效性越低、可替代性越强的内容,裁剪优先级越高。跟当前决策直接相关的工具结果、从用户需求里拆出来的验收标准、中间确认过的硬性约束,这些属于“不可再生资源”,一旦裁掉就再也找不回来,优先级必须放到最后。
3.3 摘要压缩:时机、预算与内容保真
当裁剪已经无法满足空间需求时,就该做摘要压缩了。摘要的核心挑战不是“把长文本变短”的这一步,而是“压缩之后信息还能不能准确复原”。模型做摘要时经常会丢掉一些当时觉得不重要、但后续决策恰恰需要的关键细节,比如一个具体数值、一个被否定的方案,或一句用户强调过的偏好。
我给摘要压缩定了几条硬性规则,执行一段时间后效果稳定了不少。
第一,固定触发时机而非常态执行。我一般设置两个触发点:一是当前上下文总量超过窗口上限的70%;二是单轮工具交互记录超过3000 token且短期内不再需要原始内容。满足任一条件,就对最早的部分做一次批量压缩,而不是每一轮都做。频繁做摘要的成本极高,既增加一次额外的模型调用,还可能把新信息与旧摘要搅在一起造成混淆。
第二,给每条摘要打上结构化标签。我要求模型在生成摘要时按固定模板输出:时间范围、涉及工具、核心事实、未完成事项、关键数值。这样压缩出来的东西才能被后续逻辑检索和使用,而不是一段语义含糊的散文。
第三,摘要本身也要“长大”。如果任务持续几个小时,早期步骤的摘要又会越积越厚,等到它本身也超过阈值,就需要做二次摘要——把旧摘要进一步提炼。这时候我会给摘要目标增加一条规则:不保留过程,只保留结论和状态,确保二级压缩后信息仍然是原子化的、可索引的。
3.4 记忆分区:把上下文变成Agent可查询的数据库
这一层是我个人认为管理策略里上限最高的部分:不是单纯在窗口里腾挪空间,而是主动重建信息存储结构。前面几层把上下文当成“一块工作区”来维护,而记忆分区是在工作区之外加了一个“外置硬盘”。
我现在习惯把Agent的记忆拆成三块分区:
- 工作上下文区:只存放当前任务正在使用的数据和推理链,保持精简与活跃。
- 任务档案区:存放当前任务的完整目标、验收标准、已经确认的关键决策。这个区的内容体积不大,但优先级很高,每一轮都会注入。
- 长期知识区:跨任务沉淀的领域知识、历史项目经验、常用代码模式,平时不占窗口空间,只在需要时通过检索召回并临时注入。
这个设计参考了人类做事的模式:你不会把几个月前学过的所有知识都背在脑子里才开始干活,而是在需要用到某个细节时去查资料、翻笔记。Agent也一样,让模型把所有东西都“记住”既不经济也不可靠。把长期知识交给向量检索,把任务状态交给结构化缓存,把窗口留给当前的动作决策——每一层各司其职,上下文就不会成为任务时长的硬约束。
4. 一个可直接复用的上下文管理器实现
理论讲再多,不如贴一份能跑的代码。下面这个ContextManager是我在一个内部Agent项目里沉淀下来的简化版本,去掉了业务耦合之后,核心逻辑大概160行左右。它能做的事情包括:维护消息列表、估算Token开销、监听阈值、在触发条件满足时对旧内容执行摘要压缩。
4.1 数据结构与初始化
"""Agent上下文管理器——分层预算+动态摘要压缩""" from dataclasses import dataclass, field from typing import List, Dict, Optional import time import tiktoken @dataclass class ContextMessage: """一条带元数据的消息""" role: str # system / user / assistant / tool content: str category: str = "history" # system / plan / evidence / history token_count: int = 0 timestamp: float = field(default_factory=time.time) compressible: bool = True # 是否允许被摘要压缩 class ContextWindowManager: def __init__(self, token_limit: int = 12000, compress_threshold: float = 0.7, encoder_name: str = "gpt-4o"): self.token_limit = token_limit self.compress_threshold = compress_threshold self.messages: List[ContextMessage] = [] self.encoder = tiktoken.encoding_for_model(encoder_name) self.total_tokens = 0 # 摘要压缩回调:外部可注入具体的摘要实现 self.summarizer = None这个结构里的关键点在于每条消息都带着category和compressible标记。category用来区分这条消息属于目标声明、路径证据还是普通会话历史,compressible决定它能否被摘要替换。为什么这么设计?因为不同的内容在窗口里的生命周期策略不同:系统目标无论如何不能被裁掉,而工具日志一旦确认处理完,就标记为可压缩。
4.2 核心操作:追加、计算与自动压缩
def _count_tokens(self, text: str) -> int: """估算文本token数""" return len(self.encoder.encode(text)) def add_message(self, role: str, content: str, category: str = "history", compressible: bool = True) -> None: """向管理器追加一条消息,同时更新token总量""" token_count = self._count_tokens(content) msg = ContextMessage( role=role, content=content, category=category, token_count=token_count, compressible=compressible ) self.messages.append(msg) self.total_tokens += token_count def _current_usage_ratio(self) -> float: """当前窗口使用率,预留空间的计算要算上最近两轮输出预估""" # 这里额外加上800个token,作为模型本轮输出的预估值 estimated_output = 800 return (self.total_tokens + estimated_output) / self.token_limit def build_context(self) -> List[Dict[str, str]]: """输出给模型的context消息列表""" context = [] for msg in self.messages: context.append({ "role": msg.role, "content": msg.content }) return context追加消息时实时统计token数,这是后面一切管理逻辑的记账基础。build_context把内存数据结构转成模型标准的messages格式。 _current_usage_ratio里的“预估输出空间”是我吃过亏之后加上的——以前我只统计已存在的内容,结果经常在模型生成长答案时窗口溢出,后半段直接被静默截断。加上这个预估值之后,窗口“假满”的概率低了很多。
真正的核心逻辑在自动压缩这一步:
def step(self) -> Optional[str]: """每一轮结束后调用,判断是否需要压缩,返回压缩动作描述""" if self._current_usage_ratio() <= self.compress_threshold: return None compressible = [m for m in self.messages if m.compressible] if len(compressible) < 2: return None # 可压缩内容太少时,强行压缩会伤害上下文 # 找出最早的可压缩消息作为压缩批次 batch = [] batch_tokens = 0 max_batch_tokens = int(self.token_limit * 0.25) # 单次压缩最多释放25%窗口 for msg in compressible: if batch_tokens + msg.token_count > max_batch_tokens: break batch.append(msg) batch_tokens += msg.token_count if not batch: return None # 调用外部摘要器 if self.summarizer is None: raise RuntimeError("需要先注入summarizer实现") original_texts = [f"[{m.role}] {m.content}" for m in batch] summary = self.summarizer("\n".join(original_texts)) summary_tokens = self._count_tokens(summary) # 从消息列表中移除批次并插入摘要 for msg in batch: self.messages.remove(msg) self.total_tokens -= msg.token_count summary_msg = ContextMessage( role="system", content=f"[上下文摘要]\n{summary}", category="summary", token_count=summary_tokens, compressible=True # 摘要本身在后续也可以被二次压缩 ) self.messages.insert(0, summary_msg) self.total_tokens += summary_tokens return f"压缩完成:释放 {batch_tokens - summary_tokens} tokens"4.3 关键参数的选择逻辑
这里的几个参数是我反复调试后定下来的,解释一下为什么这么选:
单次压缩释放上限设为窗口的25%,是一个保守但安全的数值。如果一次摘要吞掉的内容太多,生成的摘要粒度太粗,关键细节更容易丢失。分多次小步压缩、每次只处理最早的部分,虽然引入了更多次摘要调用,但保真度明显更好。用摘要调用成本换取关键信息不丢,这笔账怎么算都划算。
触发阈值设在70%,留出30%的空间给后续模型输出和新内容。很多公开的Agent项目把这个阈值调到85%甚至90%,表面看“用得更满”,但实际运行时经常出现一种尴尬:压完没多久又触发压缩,模型隔几轮就被打断一次去处理摘要请求,任务流畅性大打折扣。70%是我在“空间利用率”和“运行稳定性”之间找到的比较舒服的平衡点。
这个summarizer你必须自己注入实现——因为它和你的任务类型强相关。简单通用任务直接用LLM调用即可,但如果你的Agent跑的是代码分析这类高精度场景,我建议摘要函数里额外传一条提示词:“保留函数名、接口签名、文件路径、关键技术决策;不保留原文中的情绪化表述和无关示例。”任务定制化的摘要策略,比任何通用摘要配方都有效。
5. 常见问题排查:为什么摘要后模型反而变笨了
策略落地之后,新的问题会浮上来。我把自己踩过的坑和排查经验整理成一张速查表,应该能帮你省掉不少调试时间。
| 症状 | 排查方向 | 解决方案 |
|---|---|---|
| 摘要压缩后Agent开始重复问同一件事 | 摘要里丢了“已完成事项”状态 | 摘要模板必须包含Completed / Pending两项列表 |
| 压缩频率过高,任务频繁被打断 | 阈值设得太低,或系统提示词太长 | 将触发阈值调到0.75-0.8;压缩单批体积调大一点 |
| 模型违反早期定下的规则 | 规则被淹没在长摘要之后 | 核心规则放system prompt开头;关键约束不要塞进摘要区 |
| 上下文还剩30%,但模型输出开始乱 | 内部有接近窗口极限的挤压感 | 检查是否有超大消息(几万token)被一次性写入 |
| 工具调用日志被摘要压缩后,数值出现幻觉 | 摘要器未能保留精确数字 | 摘要提示词里明确要求:数值一律原样保留,不得改写 |
| 摘要后的系统提示区出现低质内容 | 压缩批次混入了不可归类内容 | 所有需要长期保留的消息必须设置compressible=False |
5.1 摘要导致的“信息偏食”与二次召回
我不止一次遇到这种情况:任务跑了一个小时,中间压过三轮摘要,结果模型在总结阶段漏掉了用户最早需求里的一个重要限制条件。排查之后发现,那个“重要限制”确实被写进了摘要——但摘要里它是简短的一句“要求结果基于Python 3.10以上版本”,后面还跟着几十条其它事项。当模型在长时间任务末尾读取这段高度浓缩的摘要时,它在注意力分布上对这句话的权重远不如任务刚下发时那么高。
这个问题的本质是:摘要保留了信息,但没有保留信息的重要性优先级。所以我后来在摘要模板里增加了一个Priority字段:凡涉及“不能做什么”“必须满足什么”的规则,摘要时必须标记为High并汇总在摘要开头。这个方法实测下来很有用,模型在长任务末端的规则遵守率回升了一大截。
5.2 被工具结果淹没的“事实漂移”
还有一类隐蔽问题:在工具调用特别密集的任务里,Agent对同一个事实可能会得到多份互相矛盾的观测结果。比如先搜到某个接口在某版本已被废弃,后来又搜到旧的调用案例还在广泛使用。两条信息都留在上下文里之后,模型会根据新看到的内容偏向错误结论。这就是上下文中的“事实漂移”。
针对这个问题,我的做法是在Agent的关键决策点上强制做一次“事实核对”:把已经确认的事实单独提炼出来,清理与之冲突的旧消息,而不是放任矛盾证据共存在工作区里。简单来说,你需要在Agent的执行流程里加一个轻量级的“去冲突”动作——当新旧工具结果冲突时,保存新结论,并标记旧结论已被取代。这个细节在短期任务里几乎用不上,但那种跑很久的调研型Agent一定会碰到。
5.3 预算表要根据模型规格动态调整
最后一类问题出在模型选择上。我在不同的Agent任务里切换过多个模型。不同模型上下文规格不同,同一个管理器在128K窗口和32K窗口上跑出来的行为差异巨大。后来我把窗口大小抽象成了参数,并在启动时根据模型规格做一次预算权重初始化:窗口越大,历史区和工具日志区可分配的比例就越高;窗口越小,越要把空间留给系统提示词和当前证据。
真正把Agent放到长时间真实任务里实测之后我才意识到,上下文窗口的紧张感永远不会消失,因为实际问题总是比预估的更复杂。管理策略的目标不是“让窗口永远不爆”,而是“让窗口即使面临紧张,也不会牺牲任务的核心目标”。说到底,Agent跑得远不远、跑得稳不稳,真正比拼的就是它对自身资源的调度艺术。上面给的预算表、裁剪顺序和摘要规则,是我跑坏了无数个任务之后沉淀下来的实用经验,希望能让你少走一些我用token和时间踩出来的弯路。