摘要:Agent 聊到后面答非所问,不是模型变笨,是上下文窗口被工具结果塞爆了。本文从 token 估算、DeepSeek 三层递进(spill/prune/compact)、multi-step 循环超限踩坑三个维度展开,附代码示例与排查思路。


多轮 Agent 上线后,经常出现一个诡异现象:前几轮对话好好的,聊到某个节点突然答非所问,甚至直接报 400 错误。

日志看多了,规律出来了。

不是模型变笨,是它的“短期记忆”被塞爆了。

上下文窗口是什么:Agent 的工作台模型

模型的上下文窗口有硬上限。你可以把它理解成 Agent 的工作台——用户说的话、模型自己的回复、工具返回的结果,全摆在台面上。

台面就那么大。

东西堆满了,就得扔。坑的是,它不会告诉你扔了什么,也不会告诉你它已经扔了。

工作台被塞满

多轮对话里,每一轮的工具结果都会累积。

第一轮读了三个文件,文件全文留在台面上;第二轮查接口,返回的 JSON 又堆上去;第十轮,台面可能已经堆了几十万字。

这时候模型找不到你最新那句话了。表现就是“变笨”。

Agent 上下文占用估算:token 怎么算才准

我自己的代码里有一个专门估算 token 占用的工具函数。规则如下:

估算规则:
  中文字符 ÷ 1.5 ≈ token 数
  英文字符 ÷ 4   ≈ token 数
  JSON 特殊字符({},[],")每个算 1 个 token
  最终 × 3 倍安全系数(防止低估)

为什么乘以 3?

因为真实的 tokenizer 比字符估算产生更多 token。尤其是 JSON 格式的工具返回,花括号、引号、冒号每个都单独算 token。一个看起来不大的 JSON 响应,实际 token 数可能是字符估算的三到四倍。

这个 3 倍系数,是线上事故换来的。

DeepSeek 上下文管理三层递进策略详解

DeepSeek 在 Harness 里用三层递进解决上下文超限问题。每一层我都趟过,讲真,这个顺序设计得很讲究。

层级 名称 做什么 调模型? 原文还在?
第一层 外溢(spill) 大结果存成文件,模型只看摘要和路径 不调 在,能取回
第二层 裁剪(prune) 机械掐头去尾,中段永久消失 不调 没了
第三层 压缩(compact) 调模型写摘要,替换原文 调,花钱 对模型消失

顺序是从便宜到贵。

裁剪完会重新量一次压力,降下去了就不调模型。压缩最贵,信息损失也最大。

第一层:外溢(spill)—— base64 剥离实战

外溢的思路:大结果存文件,模型只看摘要和路径。

我们的 Agent 有图片生成功能。一张图的 base64 编码,一兆多。

如果把它塞进历史记录,下一轮请求直接爆。

我的做法是在持久化历史之前,把所有 base64 数据剥掉,替换成一行占位符:

匹配规则:data:[任意类型];base64,[200个字符以上的编码串]
替换为:[image:base64-stripped]

简单粗暴,但有效。模型知道“这里曾经有张图”,但不用背着一兆数据到处跑。

实现就是一段正则替换,放在持久化之前执行。

一轮对话省下来的 token,够多聊好几轮。

第二层:裁剪(prune)与第三层:压缩(compact)取舍

裁剪是机械掐头去尾,中段永久消失。

不调模型,便宜。

压缩是调模型写摘要替换原文,贵,但对模型来说原文消失了。

我自己的压缩策略:当历史消息估算超过 2 万 token,调一个便宜模型(gemini-flash),把老对话总结成 200-400 字的摘要,替换原始历史。

摘要里保留关键事实、实体 ID、决策结论。

这样后续对话还能接上,不会断片。

DeepSeek 压缩策略的一个关键设计:原文不删

第三层压缩有一个设计,是我最开始没料到的。

压缩完之后,原文不删。

你在别的 Agent 里敲 /compact,发生的是一大段对话被换成摘要,原文没了。

DeepSeek 不是。

被压掉的内容一条不少地留在日志原位,只是从“模型能看到的那一面”上被遮住了。摘要是另外新写的一条,上面标着“我遮住了第几条到第几条”。

这意味着什么?

出了问题你能倒带。

你能看到“模型当时看到的版本”和“实际发生了什么”的差异。

三层递进策略

这个设计在排查问题的时候,简直是救命。

Multi-step 循环上下文超限踩坑:只在开头检查一次

我自己的实现没这么优雅。

我的做法是硬顶——设一个 15 万 token 的上限:

MAX_INPUT_TOKENS = 150_000

超了就砍。

但有个坑我踩了很久。

这个检查只在对话开始前做一次。

而多步工具调用是循环的。每一步都可能追加新的工具结果。

流程大概是:

用户发消息
  → trimMessages() 检查一次 ✓ 没超
  → 第一步:模型调工具,返回 5000 token 结果,追加到消息列表
  → 第二步:模型又调工具,又返回 8000 token,又追加
  → 第三步:模型再调工具,再返回 12000 token,再追加
  → 第四步:💥 超出上下文限制,模型返回 400 错误

用户看到的就是 Agent 突然死了。

修复方案:每一步开始前重新 trim

后来的修法是在 prepareStep 回调里,每一步开始之前都重新 trim 一次:

prepareStep({ stepNumber, messages }) {
  const trimmed = trimMessages(messages, systemTokens)
  if (trimmed !== messages) → 用修剪后的版本继续
}

注释里我写了一行:

// initial trimMessages() only runs once before streamText,
// while the multi-step loop keeps appending fresh tool results

这是线上真实事故换来的教训。

不是看文档能学到的。

Agent 上下文管理排查思路

下次你的 Agent 突然变笨,别急着骂模型。

先看台面是不是堆满了。

排查顺序:

  1. 估算当前历史消息的 token 占用,确认是否接近窗口上限
  2. 检查工具返回结果,尤其是 JSON 和 base64 类大对象是否进了历史
  3. 检查 trim 或 compact 逻辑是否只在对话开始时执行,multi-step 循环里有没有重复检查
  4. 如果用了 compact,确认原文是否保留,能不能倒带排查

本文作者来自 Adgine 团队,提供 GEO 服务(让品牌被 AI 推荐),官网 https://adgine.cn

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐