我那个常驻 Agent 跑一整晚之后,第二天再问它问题,它开始胡说八道——不是模型变笨了,是它的上下文里堆了几十轮对话,前面说过什么它自己也分不清了。更现实的问题是钱:每次调用都把整个历史原封不动再发一遍。

我决定给它装个预算器。不引第三方库,Node 原生写,先跑通再说。

先解决一个尴尬问题:我怎么知道一段话值多少 token

没有 tokenizer 的情况下只能估。我的估法很粗暴但够用:

function estTokens(s) {
  const zh = (s.match(/[\u4e00-\u9fa5]/g) || []).length;   // 中文 1 字 ≈ 0.6 token
  const en = s.replace(/[\u4e00-\u9fa5]/g, '').length;      // 英文 4 字符 ≈ 1 token
  return Math.ceil(zh * 0.6 + en / 4);
}

这个数字只用于做预算裁剪,不能拿去跟账单对账。它差个 10%~20% 完全不影响决策——我要的是"这条消息大概占我预算的百分之一还是十分之一",不是精确计价。真要算钱,用各家官方的 count_tokens 接口。

三层策略:谁先被牺牲

我的原则是离现在越近的越值钱,具体分三档:

  1. system prompt 永不动。它定义了这玩意儿是谁,动了它行为就漂移。
  2. 最后 4 轮完整保留。Agent 正在做的事全在这几轮里,压缩它们等于让它失忆。
  3. 夹在中间的老消息先做抽取式摘要,还超预算再从最老的开始丢。

摘要我特意没有再调一次模型去做。让 LLM 总结自己的历史,听起来优雅,实际三个问题:多一次调用、多花钱、它还会编。抽取式摘要虽然土,但可预测、零成本、不会无中生有:

function summarize(m) {
  const head = m.content.slice(0, 40).replace(/\s+/g, ' ');
  return {
    role: m.role,
    content: `[摘要] ${head}…(原文约 ${estTokens(m.content)} tokens)`,
    summarized: true,
  };
}

function fit(msgs, budget, keepLast = 4) {
  const head = msgs.filter(m => m.role === 'system');
  const rest = msgs.filter(m => m.role !== 'system');
  const keep = rest.slice(-keepLast);
  const older = rest.slice(0, rest.length - keepLast);
  const out = [...head, ...older.map(summarize), ...keep];
  const total = () => out.reduce((a, m) => a + estTokens(m.content) + 4, 0);
  let dropped = 0;
  while (total() > budget && out.length > head.length + keepLast) {
    out.splice(head.length, 1); // 从最老的中间段开始丢
    dropped++;
  }
  return { out, dropped, keptSummary: older.length - dropped };
}

+4 是每条消息的角色标签开销,别省,几十条累积起来不是小数。

真跑一遍:20 轮对话,预算 2000

我造了 20 轮对话(41 条消息,每轮都塞了撑大的背景材料),跑出来是这样:

=== 上下文预算器 ===
原始: 41 条消息 / 2578 tokens(估算)
预算: 2000 tokens
处理后: 41 条 / 1464 tokens  压缩到 57%
明细:system 保留 1 条 | 被摘要 36 条 | 被丢弃 0 条 | 完整保留最后 4 轮

--- 处理后的 prompt(前 6 条)---
1. [system] 你是一个能调用工具的 Agent。只回答与任务有关的内容,遇到不确定的数据必须调用工具核实,不许编。
2. [user] [摘要] #1 刚才那条命令的输出帮我解释下(这里是一段比较长的补充说明,用来把上下文撑大…(原文约 55 tokens)
3. [assistant] [摘要] 第 1 轮我做了这些:先确认了「刚才那条命令的输出帮我解释下」的范围,然后跑了一…(原文约 66 tokens)
...
41. [assistant] 第 20 轮我做了这些:先确认了「查一下这台 NAS 的磁盘使用率」的范围,然后跑了一遍,结果是 140 条记录。我判断这一…

--- 对照:不做摘要、只留最后 8 条 ---
消息数 9 / tokens 540 → 在预算内
丢掉的历史: 32 条(摘要方案只丢 0 条,其余保留线索)

--- 把预算收到 900 tokens ---
处理后: 23 条 / 871 tokens | 被摘要 18 条 | 被丢弃 18 条
(丢弃从最老的开始,system 和最后 4 轮永远不动)

两个数字值得看:

  • 2000 预算下,一条都没丢,全部 41 条都留下了线索,占 57% 预算。
  • 如果换成"只留最后 8 条"这种常见做法,确实更省(540 tokens),但丢掉 32 条历史。而我的方案在 1464 tokens 的代价下一条不丢。

这就是我为什么坚持要摘要这一层:它买的是"模型知道自己前面干过什么",这在长任务里比省的那几百 token 值钱。预算收到 900 时丢弃逻辑才介入,丢的也是最老的 18 条——顺序是对的。

我踩的四个坑

一、摘要自身也要计入预算。 我第一版把摘要当"零成本"处理,压完一算还是超。摘要也是要发出去的文本,必须一起参与 total()。

二、工具调用和它的结果不能拆开。 如果 assistant 那条带着 tool_call 被保留了,而对应的 tool 结果被丢掉,模型会看到一个"发起过但没结果"的调用,然后再调一次。要么成对保留,要么成对丢。我现在的做法是裁剪时按"轮"而不是按"条"处理工具消息。

三、摘要里别留结论性数字。 我早期摘要会把"结果是 140 条记录"这种话保留下来,后来发现模型会把摘要里的数字当成事实反复引用,即使后面已经变了。现在我的摘要只留"发生过什么",不留下结论。

四、不是所有任务都该裁剪。 短任务(三五轮)裁了纯属自找麻烦;需要精确回溯的任务(比如让它核对第 7 轮某个命令的原始输出)裁了就会出错。我的判断标准很简单:如果任务可能超过 15 轮,就上预算器;否则别碰。

我的判断

预算器的价值不在省钱。省下来的那点 token 费用,跟一次"因为上下文污染导致任务跑偏、要人工介入"的成本比,不值一提。

它真正解决的是可预期性:有了预算器,这东西跑一晚上和跑十分钟,上下文长度是稳定的,行为也是稳定的。对常驻型 Agent 来说,稳定比聪明重要。

还有一个附带好处:逼着我把 system prompt 写短。以前我什么都往 system 里塞,现在它有成本上限,我自然会去权衡"这句话值不值得永远占据预算"。

配套脚本在 daily/ctx-budget.js,零依赖,node ctx-budget.js 直接跑,改 BUDGET 那个常量就能看不同预算下的裁剪结果。

(写作与实测日期:2026-09-30,Node 22.22.2,本地真实运行输出,未做美化。)

Last modification:September 30, 2026
如果觉得我的文章对你有用,请随意赞赏