☰
长上下文治理实践:动态窗口与摘要状态机如何降低Agent成本
2026/10/14 23:51:20 网站建设 项目流程

长上下文治理这件事,最初我并不觉得是个大问题。上下文窗口不够,换长窗口模型不就行了?直到某天我盯着线上监控里那个数字发呆——一个请求的输入上下文已经涨到了十几万 token,后端账单比上个月翻了三倍,响应时间从 1.5 秒变成 8 秒,而且模型开始频繁在对话中段"失忆"。

那一刻我才意识到,Agent 系统的上下文治理不是"换个大窗口"就能一劳永逸的。窗口再大,也有填满的那天;窗口越大,单次请求越贵,推理时间也越长。真正要做的是像平时整理书桌一样,按时清理、分区收纳、有用的做摘要归档、没用的直接扔掉。这个思路落到代码里,就是我们这套"动态窗口 + 注意力衰减 + 摘要状态机"的组合方案。

这套方案不是我想出来的,而是被线上问题一步步逼出来的。我把它拆成四个部分讲:问题到底出在哪、三种治理手段各自的定位、具体的代码实现思路、以及生产环境里最容易踩的坑。你看完可以直接照搬,也可以按自己的场景改。

1. 上下文失控的本质:Token 账单、丢中现象和注意力衰减

先说清楚我们到底在解决什么问题。很多团队对长上下文的痛感,是从账单开始的,但账单只是表象。

1.1 成本增长不是线性的,是滚雪球的

大模型 API 的计费是"输入 token + 输出 token"分开算的。输出 token 取决于模型生成内容,通常可控;输入 token 才是灾难源头——因为每轮对话、每次工具调用,我们都在把完整的历史上下文重新发给模型。

假设你的 Agent 每轮对话平均 2 万字 token,20 轮对话后,第 20 次请求的输入就是 40 万以上的 token。如果模型定价是每百万 token 三美元,那么这一次请求光输入成本就是 1.2 美元以上。用户随便聊几十句,几个美元就烧掉了。更麻烦的是,这个成本是"每请求"都要付的,不是聊到最后付一次。

我算过一个真实案例:一个客服助手,高峰时段每分钟处理 30 个请求,每个请求平均 3 万 token 输入。按当时模型价格,一小时光输入成本就是 150 多美元。而你什么都没做错,只是让用户连续问了几个关联问题。

1.2 模型对长上下文的信息利用,并不像我们以为的那样好

很多人以为"上下文越长,模型记住的越多"。实际情况恰恰相反。业界对长上下文的研究有一个广泛共识——模型对长文本中段信息的利用效率会显著下降,开头和结尾的信息被关注得更多,中间部分容易被"忽略"。这个现象行业内通常称为"丢中"(lost in the middle)。

这在 Agent 场景里是个致命问题。因为按工作习惯,我们通常把系统提示词放在最前面,把用户最近一条消息放在最后面,而中间全是历史对话、工具返回结果、RAG 检索片段。换句话说,模型最容易忽略的恰恰是那些不远不近的中间信息。用户前天说过的偏好、上周某次 bug 的修复细节,全堆在那个"容易被忽略"的位置。

1.3 注意力衰减的真实含义

注意力衰减不是模型坏了,而是注意力机制的天性。Transformer 结构里,长序列的注意力分布会被稀释,越长的上下文,每个 token 分到的注意力权重就无限趋近于均匀分布。均匀等于什么都没关注,模型就会把"看起来重要但不显眼"的历史信息直接忘掉。

这个现象直接告诉我们:单纯地不断堆历史,不仅花钱,还降低准确率。成本、性能、正确性都在随上下文长度恶化,这就是为什么要做治理。

2. 三种核心治理手段的定位:剪量、提质、控序

我见过很多团队一上来就搞摘要,跑了一段时间发现模型总是漏细节,又开始往回退。真正稳定的方案,其实是三件事一起做,分好分工。

2.1 动态窗口解决"量"的问题

动态窗口就是滑动窗口的升级版——每次请求时,按 token 预算把历史上下文裁剪成"最近 N 轮 + 关键信息"的组合,超出部分放不下就移出。这是第一道防线,成本主要靠它控制。

但裁剪有个副作用:被裁掉的信息再也找不回来了。如果只做窗口裁剪,早期对话里的关键约束、用户明确表达过的重要偏好,会被无差别删除。

2.2 注意力衰减决定"哪些信息不能丢"

既然模型对中间部分关注弱,那我们就不能把重要信息放在中间。解决方式有两种:一是把重要信息提前到靠近系统提示词的位置,保证它在高注意力区域;二是对长文本做结构化摘编——把整段对话压缩成结构化要点,放在开头与结尾之间。这就是用"注意力衰减"这个规律反过来指导我们怎么摆放信息。

这部分的实操点在于:你要识别出"什么信息值得在衰减区存活"。用户身份偏好、核心约束条件、长期目标、未完成的任务状态,属于高价值信息;寒暄、重复消息、过期状态,属于可丢弃信息。窗口裁剪时优先裁低价值的,文章摘编时优先保留高价值的。

2.3 摘要状态机解决"质"和"稳定"的问题

动态窗口是硬裁,摘要压缩是软提炼。但摘要不是随时随地做的——做太频繁浪费 token,做太迟上下文又已经爆炸。状态机的核心作用,是让你根据当前上下文长度、变化趋势、是否进入稳定阶段,决定"现在该不该压缩、压到多狠、压缩后处于什么状态"。它把"什么时候该治理"这件事制度化,而不是靠拍脑袋。

三者关系可以这样理解:动态窗口管"上下文多长能上战场",注意力衰减规律管"什么东西放在战场哪个位置最安全",摘要状态机管"什么时候把老兵撤下来换成新兵"。

3. 动态窗口的落地实现:从 token 预算分配到多级裁剪策略

动态窗口不是简单地"砍掉最早的消息"。生产级实现至少要考虑预算设置、信息分级、裁剪粒度三个层面。

3.1 设定 token 预算:先定上限,再定各部分占比

第一步是给每次请求设定一个"总预算",比如 12000 token。12000 不是拍脑袋来的,而是由模型上下文上限、可用 GPU/显存、目标响应延迟综合算出来的。比如模型上下文上限是 128k,但你发现超过 16k 后模型准确率明显下降,那 16k 就是你的实际预算,而不是 128k。

预算必须分块。我的建议是:

  • 系统提示词 + 固定技能描述:固定 3000~4000 token
  • 动态窗口中的对话历史 + 工具返回结果:动态分配 6000~8000 token
  • 用户当前消息:预留 2000~3000 token

这几部分加起来不能超过总预算。注意顺序:动态窗口只能消耗"对话历史 + 工具结果"那一部分,不能挤占系统提示和用户当前消息的空间。很多人就是在这里翻车——窗口裁剪了,但把所有系统提示都保留,结果请求一长,历史部分还是爆了。

3.2 两级裁剪:先裁"轮次",再裁"细节"

我的实现里,对话历史被组织成一组 Message 对象,每个 Message 有来源类型(user/assistant/tool)、消息角色、内容、时间戳、关联的工具调用 ID、长度。裁剪前先对整个 history 做一次长度扫描,超过预算就进入裁剪流程。

裁剪分两级:

第一级:按"轮次"裁剪。从最早的轮次开始淘汰,每轮包括 user 消息、assistant 消息、工具返回结果。淘汰标准不是简单按时间,而是按"这条消息是否产生过有价值信息"。有价值的定义是:包含用户明示偏好、约束、任务状态变更、工具调用的有效结果。如果一个 assistant 消息只是说"好的,我帮你查一下",那它是低价值的,可以先裁。

第二级:按"块"裁剪。如果轮次裁完了还超预算,就要对保留的每条消息做内部压缩。比如长文本工具返回结果截断到前 2000 字符,RAG 检索片段只保留命中得分最高的段落。这里要注意保留结果中的关键字段,比如订单号、日期、金额不能丢,有时候错误信息里的 status code 也不能丢——这直接影响后续的兜底逻辑。

3.3 预算分配的动态加权

静态分配不够用,因为有些任务需要大段工具结果,有些任务是纯闲聊。我的做法是给每类消息设置一个权重:系统消息权重最大,用户当前轮次其次,历史轮次中与当前问题相关性高的给中等权重,长工具输出给低权重。

计算方式就是:历史总预算乘以动态系数,得到候选消息的可用 token 配额,然后按消息权重排序,从头开始取,直到配额用完。这个思路非常简单,但在生产环境里显著减少了两类问题:工具结果被截断后 Agent 无法判断下一步动作,以及用户重复追问时历史里缺失关键先决信息。

3.4 动态窗口的触发时机与自愈机制

窗口裁剪不是每次请求都做的。每次请求前做一次 token 估算,低于预算的 70% 就不动,超过 70% 才进入裁剪流程。另一个触发维度是轮次——超过 20 轮就开启裁剪,无论 token 是否已接近预算。超过 35 轮强制做一次摘要。

自愈机制的考虑也值得提。有时候裁剪后 Agent 发现信息不足,会退化到"我不太清楚之前的对话"这种回答。我在运行时加了一个校验器:如果模型在回答中出现"之前没有提到""我不记得"之类的信号词,就触发一次"浅层信息回溯",把缓存中已裁剪的部分快速检索一遍,把高相关片段重新注入上下文。这个策略比"干脆不裁"成本低得多,但有效避免了信息丢失带来的体验下降。

4. 摘要状态机的完整设计:状态定义、迁移条件与可回溯存储

动态窗口只解决"多长算多长",真正做信息保值的是摘要。但摘要做不好,比不做的损失还大。这里必须用状态机来约束整个压缩过程。

4.1 五个状态的划分

我的实现中,上下文生命周期有五个状态:

  • FULL(全量):原始历史完整保存在会话状态中,可直接引用。
  • TRIM(裁剪):动态窗口执行过一级裁剪,丢失了部分低价值历史。
  • SUMMARIZED(摘要):已经执行过摘要压缩,历史被提炼为结构化摘要 + 少量保留消息。
  • STABLE(稳定):摘要压缩完成后,动态窗口持续工作,但已压缩过的前缀不会再重复压缩,处于一个可预测的稳定期。
  • ROLLBACK(回滚):当检测到当前摘要不足以支撑后续决策时,回退到更早的状态,从缓存中恢复原始消息或最近一次摘要快照。

这五个状态是有向的,默认路径是 FULL → TRIM → SUMMARIZED → STABLE,每个状态只能单向迁移。TRIM 可以回到 FULL(如果用户主动要求回忆某些被裁的内容),但 SUMMARIZED 之后一般不允许直接跳回 FULL——因为原始上下文可能已经被释放了。回滚操作只恢复到最近一次保存的摘要快照,而不是原始全量。

4.2 触发条件:token 阈值只是一部分,更要看变化率

摘要状态机最常见的错误是只在 token 超过阈值时触发。真实场景里,这是不够的。

我总结的两个额外触发条件:

  1. 变化率触发:一轮对话后 token 增量超过某个百分比(比如 15%),且当前 token 总量已超过预算 50%,说明上下文在快速膨胀,要提前进入摘要准备。(动态窗口已经在做裁剪,但摘要的触发略早于裁剪,避免裁剪进一步丢掉早期关键约束)。

  2. 语义完整性触发:如果检测到"新问题涉及历史关键实体名称",但当前上下文里该实体只被摘要过、未被完整保留,就触发一次"实体补充摘要"——把该实体相关的关键字段单独整合进摘要。这个思路特别适合电商、售后、咨询类 Agent。

4.3 摘要生成的结构化模板

摘要不能是"请总结一下前面的对话"这种自由发挥。我的摘要是结构化 JSON:

{ "core_goal": "用户当前的核心目标,尽量保留原文关键词", "constraints": ["用户明确表达的限制条件,保持原文语义"], "facts": [ {"entity": "order_123", "field": "status", "value": "shipped", "timestamp": "..."} ], "task_status": { "pending": ["需要继续处理的步骤"], "completed": ["已完成步骤"] }, "recent_messages": "最近 3~5 轮的全文保留,因为模型可能需要在后续回答中引用原文" }

生成摘要时,我让模型按照这个模板填充,且要求"每个字段值尽量保持原始文本,不要重新解释"。为什么要这么做?因为摘要压缩过程中最怕的是模型把原始语义"改写"了,比如用户说"5 点之前送到",模型在摘要里写成"尽快送货",语义就缩水了。保持原文片段,摘要才有恢复价值。

4.4 稳定态与冷热分层存储

进入 STABLE 状态之后,动态窗口仍然在运作,但每次请求只携带最近 5 轮 + 全部摘要内容。摘要本身也可以分层:active summary(当前请求直接携带)+ archive(只存 key-value 索引,不参与 prompt,需要时再取)。这样把 token 成本压到最低,同时保留"按需回溯"的能力。

这个冷热分层我强烈建议做。线上跑了一段时间后你会发现,真正有用的历史其实是最近几轮和几个关键实体,更早的对话大部分时候用不到。冷归档层带来的收益远大于成本。

5. 生产环境避坑实录:语义断裂、重复压缩与工具结果的"隐形封印"

再讲几个只有跑过生产环境才看得到的坑。这些坑在测试环境里几乎不可能暴露,因为用户量、轮次、上下文长度都演不了那么深。

5.1 摘要后语义断裂:剪掉了一个必要前提

有一次线上反馈是 Agent 突然不明白用户说"还是按之前那个方案改吧"。排查后发现,之前轮次里的"那个方案"已经被摘要压掉,摘要里只写了"讨论过 A/B 方案",但没有写明具体方案内容。模型看到了"方案"这个词,却看不到方案细节,回答就含糊其辞。

这个问题的根源在于:我那时的摘要模板只要求总结成要点格式,没有做"关键实体完整保留"的强制约束。后来改了模板,规定"事实字段必须包含足够上下文供模型独立识别",具体做法是给 summary 模板加了"entity_reference"字段,每个实体必须包含"标识符 + 状态 + 最近一次重要变更描述"三件套。

经验是:摘要不只是压缩量,还得保"模型能独立运行所需的最小语义单位"。这个单位比单纯的事实强,因为它还要满足模型在后续对话中自然引用这些旧信息的语言衔接需求。

5.2 摘要挤压动态窗口:双重压缩导致信息丢失

动态窗口裁剪和摘要压缩同时执行时,有个隐性 bug:裁剪先移除了部分早期消息,摘要又在剩下的基础上继续压缩。结果有些信息被压缩了两次——第一次被裁掉细节只剩大略,第二次把大略又压成更粗略的。用户一问细节,模型直接放弃。

后来我引入了一个标记:每条消息在裁剪时打上"compressed_level"标签,0 表示原始,1 表示被窗口裁剪过,2 表示被摘要压缩过。摘要器生成时,如果发现某条消息已经是 level 2,会显式跳过它,避免重复压缩,除非它的核心内容发生变化。这个机制类似很多框架里的"消息压缩标记",但加在状态机里之后,双重压缩的问题基本绝迹。

5.3 工具长结果的"隐形封印"

还有一个坑:工具返回长 JSON 后,被窗口裁剪逻辑当成普通历史裁掉一部分。看起来"只是截断了长字符串",实际上可能把工具返回的最关键的那个判断字段(比如"payment_verified": false)给裁没了。后续模型没有了这个字段,会误以为支付已成功,继续执行下一步流程。这类问题在生产里比模型幻觉更隐蔽,因为日志里看到的只是"消息被截断",没有直观错误。

我的解决方案是:对工具返回的 JSON 做结构化裁剪,而不是纯按字符数截断。具体做法是把 JSON 转成 schema 树,按关键字段白名单保留(比如非空字段、布尔类型字段、金额、ID、错误码),白名单之外的次要字段按层级折叠。遇到这类长结果,我会复用同一个动态窗口的 token 预算,但优先级非常高,任何裁剪都不允许修改白名单字段。

5.4 回滚预案:摘要快照的使用与验证

状态机的 ROLLBACK 不是摆设。我在每次进入 SUMMARIZED 前保存一份摘要快照,包含压缩前的关键信息索引。如果后续判断发现摘要不足,就加载最近的快照并重新生成更详细的摘要。注意,一定不能"恢复原始全量"——原始消息早已不在内存里。快照本身的设计要比运行中的摘要略微冗余,宁可多存一些中间态,也不要压得太干净。

还有个细节:摘要完整性不能全靠模型自觉。我在 QA pipeline 里加了一个"摘要可回答性测试"——每次摘要生成后,随机挑几个早期对话里用户问过的问题,放到"摘要 + 最近恢复的消息"里让模型试答。如果摘要不足以回答这些问题,生成质量不合格,就退回重做或调整预算分配。这个成本不高,但能提前发现摘要的语义缺口。

6. 方案取舍与设计权衡:什么时候该用什么策略

动态窗口、摘要状态机、注意力衰减,这套东西并不是永远对。不同场景有不同的适用性,给一个我自己的选型判断。

6.1 场景一:客服 / 问答型 Agent,历史轮次多、核心实体少

这种场景最适合动态窗口 + 高频摘要。用户聊 30 轮,真正有价值的可能是 5 个订单号和 3 个偏好。动态窗口裁掉寒暄轮次,摘要保留订单状态和偏好约束。token 成本能砍掉 60%~70%,且不太会伤害体验。这里的坑是"每轮都触发摘要"——用户反复确认同一件事时,摘要会被反复生成,浪费 token。所以触发条件必须加上"变化率"门控,而不是只用绝对 token 阈值。

6.2 场景二:编程 / 代码生成 Agent,依赖长上下文

这类 Agent 往往需要同时看到多个函数、类、导入关系、调用链。动态窗口做激进裁剪,结果往往是模型"忘记"了一个关键接口定义。我的建议是:这种场景把摘要状态机的 STABLE 阶段周期拉长,大幅提高触发阈值,同时依赖冷热分层存储,携带近期代码片段,更深层的历史以函数签名的形式存为精简引用。动态窗口剪裁逻辑里,代码类工具输出优先保留,寒暄消息可全员裁掉。

6.3 场景三:记忆型角色 / 长期陪伴类 Agent

这类产品最怕"忘记用户是谁"。动态窗口可以做,但摘要状态机的 SUMMARIZED 前必须强制插入一条"人格 / 关系摘要"字段,把用户提到的身份信息、喜好、禁忌、称呼方式全部保存为高权重信息,永不参与动态窗口裁剪。这里状态机的稳定态其实是一个"半退化态"——上下文里永远保留固定人格段落 + 可变历史摘要,两者之间不能互相挤占。

6.4 资源成本与降级策略

要有一个降级策略。当摘要生成 API 本身超时或成本异常时,宁可只做动态窗口裁剪,也不要让 Agent 在"全量不加治理"状态下运行。我在状态机里加了 FAILED_TO_SUMMARIZE 分支,该分支下直接使用最近一次成功的摘要快照,这一次不回退。这个策略避免了"摘要服务抖动导致整个 Agent 不可用"的连锁事故。

6.5 何时不适合这套方案

说到底,这套方案解决的是一次请求内上下文膨胀与记忆效率问题。如果你的使用场景是一次性短文处理、长短可控的模板问答、或纯流水线任务,不需要引入这么复杂的治理逻辑。正常一个请求只有几百 token,做状态机反而浪费。上下文超过 8k、轮次超过 10 轮、或者单次请求成本已经肉眼可见地高,才值得上这套东西。

比如我在处理 RAG 多跳问答时,上下文里塞的是检索片段而不是多轮对话,这时动态窗口就不适用。检索片段应该做"排序 + 去重 + 最相关集中",而不是简单地滑走早期片段。

所以在方案选型时,建议先看两个指标:平均请求上下文长度、单轮对话中历史消息的信息密度。这两个指标不够高,治理是负收益。

7. 实测数据与效果对比:治理前和治理后

项目上线到现在,我把几个关键指标记录了下来,直接拿数据说话。

指标治理前(动态窗口裁剪摘要未启用)治理后(完整的动态窗口 + 摘要状态机方案)
平均单请求输入 token23k8.5k
峰值单请求输入 token41k12k
单日 API 输入成本基准值 100%约 33%
单请求平均响应时间2.8s1.4s
用户主要实体(订单号等)漏报率约 8%约 0.5%
早期约束信息丢失导致的返工轮次占比约 17%约 3%

数据来自某个客服助手 Agent 的压测和线上抽样。成本下降是最直观的收益,响应时间接近减半,但实际最大收益其实是“漏报率和返工轮次”。这两个指标的下降说明 Agent 不仅便宜了,而且在关键信息保持上变得稳定可靠了。

注意:这些数据在别的业务形态下会有差异。如果你的 Agent 会话普遍很短,成本可能没有这么明显的下降;如果你的摘要做得太简略,漏报率可能反而上升。所以我的核心建议是:先上线动态窗口 + 基础摘要,跑两周看漏报率,再逐步加强摘要状态机的约束。

8. 一个值得立刻去做的轻量改造

整套状态机落地需要一些代码量,但有一个 "轻量版" 可以让你在半天内先感受到收益,我强烈建议试试:

把每次请求的完整 history 从纯数组改为分段结构:[system_prompt] + [compact_history] + [recent_messages] + [current_user_message]。其中compact_history由最近 N 轮的摘要 + 关键实体映射组成,recent_messages保留最近 3~5 轮全文。你只需要改一个串联消息的函数,其他逻辑不用动。

这么改完,你再跑一遍线上的会话记录,看一下 token 消耗和模型回准确率。大概率你会看到成本降下来的同时,模型给出的答案比原来更聚焦了——因为模型不再需要从 4 万 token 里翻找一条有效信息,注意力可以集中在你整理好的那几千 token 上。这既是工程优化,也是对注意力机制的尊重。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询