上下文耗尽不是一个瞬间故障

生产环境的 Agent 和演示用 Agent 有一个根本差异:演示系统只回答一个问题,生产系统要在几十轮交互中持续保持任务状态。每一轮的工具返回、中间推理、用户修正都会追加到上下文里,token 量单调增长。增长到某一轮时,调用会被模型上下文上限挡住;如果框架采用静默截断,Agent 还可能带着残缺状态继续执行,这种错误比直接失败更隐蔽。

从工程角度看,更准确的模型是:耗尽是一条渐进曲线,而不是悬崖。系统在真正溢出之前,已经经历了预算紧张、输出空间被挤压、早期约束开始丢失等多个阶段。降级设计的目标不是阻止溢出,而是让系统在接近上限时,按可预测的顺序牺牲低价值信息,保留本轮任务真正依赖的状态。

把上下文当成一份预算来管理

降级的第一个前提是:每次调用模型之前,都清楚这次请求要花多少 token、还剩多少。一个可行的做法是把上下文容量拆成固定科目:

  • 系统提示词
  • 工具定义与调用说明
  • 历史对话与记忆
  • 当前子任务状态与最新用户请求
  • 必须预留的响应空间

设模型上下文容量为 C,实际可用上限 U = λ·C。λ 是安全系数,示例取 0.8 左右,而不是直接用满上限,因为输出 token、重试和少量波动都需要余量。每次请求前需要满足:系统提示词、工具定义、历史对话与当前请求之和,仍然小于 U 减去响应预留 R。响应预留经常被忽略,但没有留给输出的空间时,模型会在输出中途被截断,需要 JSON 这类结构化输出的场景尤其严重。

这里真正值得关注的是记账方式:不要在降级时才做全量重算。每一轮 Agent 步骤结束时,记录本轮新增 token 并累加,维护一条单实例的上下文 token 曲线。增量记账成本低,而且它是后面所有降级判断的数据基础。

降级做成状态机,而不是一条 if

常见的简陋实现只写一层:历史太长就删掉最早的对话。这几乎是最差的策略,因为删掉的往往正是早期任务约束。

从工程角度看,降级应该组织成逐级下探的状态机,每一级的信息损失递增,触发条件也更激进。一个可用的分级:

  • Level 0:正常调用,不做任何处理。
  • Level 1:尾部裁剪。丢弃最早的非关键轮次,保留最近 N 轮。代价是可能丢失早期约束。
  • Level 2:摘要压缩。把已归档的历史改写成结构化摘要。代价是摘要改写本身要消耗一次模型调用。
  • Level 3:检索式记忆。把历史全文移出上下文,写入外部记忆库,按当前任务检索相关片段回填。
  • Level 4:优雅暂停。当当前任务的必需状态都无法放入时,主动结束本轮并返回已保存的状态摘要,而不是生成一次注定低质量的回答。

对应的触发逻辑可以用伪代码描述。这里 state.level 表示当前实例已进入的降级级别,初始为 0,每次成功执行某一级降级后,应将 state.level 更新为该级编号并持久化,以便后续请求能按预期进入更高级降级。更新逻辑应显式写在状态机中,而不是隐藏在 drop/summarize/retrieve 等函数内部。

def prepare_context(state, req):
    base = count_system(state) + count_tools(state)
    usage = base + count_history(state) + count_req(req)
    reserve = estimate_response_space(state.current_step)
    if usage + reserve <= state.upper_limit:
        return build_messages(state)              # L0

    if state.level < 1 and droppable(state):
        drop_oldest_inactive_rounds(state)        # L1
        state.level = 1
        return build_messages(state)

    if state.level < 2 and net_gain_positive(state):
        state.summary = summarize_archived(state) # L2
        state.level = 2
        return build_messages(state)

    if state.level < 3 and memory_ready(state):
        state.history = retrieve_relevant(state)  # L3
        state.level = 3
        return build_messages(state)

    return graceful_pause(state)                  # L4

这个状态机有两个关键判断。

第一个是 net_gain_positive:评估摘要压缩的净收益,也就是「释放的 token 减去生成摘要消耗的 token」。如果历史只剩 2000 token,却要花 800 token 去生成摘要,不如直接做裁剪。

第二个是压缩对象的边界:永远不要压缩当前进行中的子任务状态。工具调用的中间结果一旦被摘要化,下一步工具拼接参数就可能失效。能压缩的只有已经结束的历史阶段。摘要还应该采用快照加增量的方式,只对新增的已归档历史做追加摘要,而不是每次从头压缩全部历史,否则会出现「摘要的摘要」,信息逐层衰减。

触发条件与阈值设计

触发判断不能只用单一指标,也不应该按轮数判断。轮数相同的情况下,带长文档的调用和纯文本回复的 token 消耗可能差一个数量级。实际可行的做法是同时看两个值:剩余空间,以及下一步预计消耗。

触发分级大致如下,具体数值需要按业务验证:

  • usage 加 reserve 仍在上限内:维持原状。
  • 剩余空间不足,但执行裁剪或摘要后能放下当前请求加最小输出预留:提前降级。
  • 裁剪和摘要后仍然放不下:进入检索式记忆或优雅暂停,而不是硬发请求。

阈值建议拆成两层。硬阈值指剩余空间低于某个比例(例如 20%)时强制进入更高一级降级;趋势告警则观察上下文的增长速率——如果最近几轮每轮新增 token 持续偏高,即使当前还有余量也要提前压缩,因为 Agent 可能正进入一个工具调用密集的子任务。上述 0.8 安全系数、20% 硬阈值等数值均为示例参数,实际部署时应结合自身模型、输出格式和流量特征校准,避免被当作普适标准。

检索式记忆的边界

Level 3 是最容易被高估的一级。向量检索适合事实性回查:用户几天前提过一个配置要求,今天的子任务需要确认它。它不适合需要完整推理链的场景:要复现 Agent 当初为什么做某个判断时,保留因果结论的摘要反而比检索片段更可用。

另一个常见陷阱是检索结果为空。降级到 L3 后触发检索,却发现历史从未被切块索引,原因是记忆写入没有独立的 write path。正确的设计是每一轮结束后,异步把已归档历史写入外部记忆库,并和摘要互相建立引用。把建索引留到危机时刻是不可行的,那一刻已经没有预算做离线处理。

回填的检索片段同样占用上下文预算,所以回填后仍要做一轮预算核算,必要时对片段按相关度截断。

监控与告警

没有观测的降级等于没有降级。建议逐项采集以下指标:

  • 每步请求与响应的 token 数,按 Agent 实例聚合。
  • 单实例累计上下文 token 曲线。
  • 各降级级别触发次数及其所在的任务阶段。
  • 摘要压缩率,即压缩前后 token 比值,过低说明摘要没有保留关键信息。
  • 摘要生成本身的 token 成本。
  • L3 检索命中率与回填 token 量。
  • L4 优雅暂停次数。这一项应作为重点告警:它一旦出现,通常说明前面几级的阈值配置已经失效。

告警本身也需要分级。剩余空间接近硬阈值时给警告;同一实例短时间内连续触发同一降级级别,或者检索命中率骤降,说明压缩或检索没有真正解决问题,应升级为人工介入。对多任务类型的服务,还建议按任务维度统计,因为文档处理类与短对话类的 token 增长形态完全不同,全局平均只会掩盖真正出问题的负载。

优雅暂停后的恢复路径

L4 优雅暂停并非终止会话。暂停时应保存当前任务的状态摘要,包括已完成步骤、未完成目标、关键上下文引用等。恢复时,可以将状态摘要重新注入上下文,或结合外部记忆中的检索片段,让用户确认后继续任务。这样既避免了低质量回答,也为后续恢复保留了线索。

落地前需要确认的五件事

最后列出几个容易遗漏的工程检查项。

  1. 上下文上限必须配置化。用「模型文档标注上限乘安全系数」作为实际预算,而不是直接用满上限。安全系数没有普适值,需要在自身流量上验证。
  2. token 估算应抽象成统一接口。不同模型对同一段文本的计数可能有差异,如果存在多模型路由,降级逻辑不应绑定单一模型的计数方式。
  3. 降级判断必须无阻塞。每一步请求都做全量重算会明显放大延迟,这正是增量记账存在的意义。
  4. 降级逻辑应集中在请求组装前的拦截层。无论自研调度代码还是团队正在使用的 Agent 框架,只要这个拦截点存在,整台状态机就能统一接入,而不是在提示词模板里做分散修补。
  5. 降级方案不能替代长程记忆架构。上述所有层级回答的都是「上下文放不下时丢什么、留什么」,想真正降低信息损失,长期方案是把任务状态外置到持久存储,让上下文只保留可重建的视图。降级设计应当与外部记忆和状态存储一起做,而不是等线上溢出后再补救。