Agent 聊了 20 轮 token 翻了 8 倍——三种截断方案实测,最蠢的那种反而最稳

你有没有遇到过这种情况------Agent 上线前跑 demo 一切正常,上线后跑了半天突然开始报 context_length_exceeded

我遇到过。而且不是一次。三次。

每次都是同样的剧本:测试环境 3-5 轮对话稳得一批,老板看了直夸"这个智能客服做得不错嘛",结果上线第二天,真实用户聊到 15-20 轮,token 从 800 一路飙到 6400,API 费用翻了 8 倍,然后 context window 撞墙,Agent 直接罢工------返回一堆乱码或者干脆卡死不动。

最气人的不是它崩了,最气人的是崩的原因。用户聊的那些历史里,80% 都是废话。"你好"、"帮我查一下"、"还有别的吗"、"算了不要了"------这些寒暄和犹豫,Agent 每次请求都得全量塞进 messages 里,给模型复读一遍又一遍。你在花钱让模型反复阅读自己两天前的寒暄。说白了,这就是一个 token 的黑洞。

我第一次碰到这事的时候愣了十分钟,盯着监控面板上那条 token 曲线像坐火箭一样往上冲,心想这不科学啊------我 prompt 才 200 token,怎么一轮对话就吃掉了 800。翻了一下 messages 数组才明白:每一轮都把完整历史塞进去,包括上一轮工具调用返回的那坨 600 token 的商品详情。聊到第 20 轮,历史里堆了 10 次工具返回,6000 token 纯粹是在复读旧数据。难怪账单像洪水一样。


token 怎么涨的

写了个计数器跑了一遍真实对话日志,数据挺吓人:

轮数 token 数 单次 API 费用(GPT-4o)
5 800 $0.012
10 2100 $0.032
15 4200 $0.063
20 6400 $0.096
25 8800+ 💥 撞墙

token 增长不是线性的,是指数型的。前 5 轮涨得慢,10 轮之后开始加速------因为每轮都在前面所有历史的基础上叠加新内容。工具调用的返回值是增长主力,一个 queryProductDetails 返回 600 token,下一轮就变成 600+新对话,再下一轮变成 600+600+新对话......滚雪球。

typescript 复制代码
function countTokens(messages: ChatMessage[]): number {
  return messages.reduce((sum, msg) => {
    const text = typeof msg.content === 'string' 
      ? msg.content : JSON.stringify(msg.content);
    return sum + Math.ceil(text.length * 1.2);
  }, 0);
}

function trackGrowth(history: ChatMessage[]): Record<number, number> {
  const result: Record<number, number> = {};
  for (let i = 1; i <= history.length; i++) {
    result[i] = countTokens(history.slice(0, i));
  }
  return result;
}

这不是理论推算,是真实日志跑出来的数据。我拿生产环境的对话记录统计了两周,平均对话轮数 12.4,最长的一条聊了 37 轮------37 轮!那条对话的 token 消耗是 14800,光这一条就花了 $0.22。一天几百条这样的对话,月底账单能把你吓出冷汗。


方案一:暴力截断------只保留最近 N 轮

最蠢的方案。砍掉所有超过 10 轮的历史,只留最近的对话和 system prompt。不压缩、不摘要、不评分,直接删。

typescript 复制代码
function truncateMessages(
  messages: ChatMessage[], 
  maxRounds: number = 10
): ChatMessage[] {
  const systemMsgs = messages.filter(m => m.role === 'system');
  const conversation = messages.filter(m => m.role !== 'system');
  const recent = conversation.slice(-maxRounds * 2);
  return [...systemMsgs, ...recent];
}

任务完成率:91%

坦白说,我一开始觉得这方案简直不配叫"方案"。截断了历史,Agent 怎么知道用户之前要查什么?万一用户在第 3 轮说了一个关键需求,被截断了,后面 17 轮都白跑?

但实测下来,大部分任务根本不需要 20 轮前的上下文。用户问"帮我查一下 XX 的价格",Agent 在最近 5 轮里就能搞定。前面那 15 轮的"你好"、"还有吗"、"算了"------删了反而让模型注意力更集中,不会被一大堆无关历史牵着鼻子走。

我跑了一百条真实对话做测试,暴力截断只丢了 9 条。那 9 条有个共同特点:用户在第 3-5 轮说了一个明确意图,然后聊了 15 轮无关的废话,第 20 轨突然回到那个意图------"我刚才说的那个东西还能买吗"。截断把第 3-5 轨删了,Agent 忘了用户要什么。

但说实话,这种场景本身就挺反人类的------谁会在第 3 轨说需求,中间聊 15 轨废话,再突然翻回第 3 轨的话题?真要解决这种极端场景,后面有更好的办法。


方案二:滑动摘要------把旧轮次压缩成一句话

听起来很聪明。超过 10 轨的历史不直接删除,用 LLM 压缩成一句摘要,塞进 system prompt 里当"对话记忆"。既保留了上下文,又控制了 token------多优雅。

typescript 复制代码
async function summarizeOldMessages(
  oldMessages: ChatMessage[], 
  llm: OpenAI
): Promise<string> {
  const prompt = `将以下对话历史压缩为一段简短摘要,保留关键意图和结论,忽略寒暄和重复:

${oldMessages.map(m => `${m.role}: ${m.content}`).join('\n')}

摘要:`;

  const res = await llm.chat.completions.create({
    model: 'gpt-4o-mini',
    messages: [{ role: 'user', content: prompt }],
    max_tokens: 200,
  });
  return res.choices[0]?.message?.content ?? '';
}

async function compressConversation(
  messages: ChatMessage[],
  keepRounds: number = 10,
  llm: OpenAI
): Promise<ChatMessage[]> {
  const systemMsgs = messages.filter(m => m.role === 'system');
  const conversation = messages.filter(m => m.role !== 'system');
  const splitIdx = conversation.length - keepRounds * 2;
  const oldMsgs = conversation.slice(0, splitIdx);
  const recent = conversation.slice(splitIdx);
  
  if (oldMsgs.length === 0) return messages;
  
  const summary = await summarizeOldMessages(oldMsgs, llm);
  return [
    ...systemMsgs,
    { role: 'system', content: `[对话摘要] ${summary}` },
    ...recent
  ];
}

任务完成率:76%

等一下,这里我漏说一个前提------摘要压缩本身也要花钱。每压缩一次就额外调一次 gpt-4o-mini,高频客服场景一天几百次压缩调用,别看 gpt-4o-mini 单次便宜,一个月下来多花 $40 不是小事。但更致命的问题不在钱,在于摘要会吞掉工具调用的关键参数。

这个 bug 当时在雷达鸭的客服 Agent 里卡了我两天。我至今记得那个场景------用户问"红色款 XL 码有没有货",Agent 调了 checkStock(color='红色', size='XL'),返回"有货,剩余 3 件"。聊了 10 轨后触发摘要压缩,整段被压成了"用户询问了库存情况,Agent 确认有货"。用户过了一会儿追问"我刚才问的那个红色 XL 码的,能直接下单吗?"------摘要里没有颜色和尺码,Agent 只能回一句"您说的是哪个款式?"

模型压缩时觉得"颜色和尺码"是次要细节,省掉不影响理解。但业务上这才是核心------没有颜色尺码,库存查询就失去了意义。我个人特别讨厌这种"自以为聪明"的压缩,它丢掉的,恰好是你最不能丢的东西。

还有个坑:摘要本身占 system prompt 的位置,跟你的规则挤在一起。我见过一条摘要写"用户表示不满意",Agent 后面每一轮都主动道歉------它把历史摘要当成了当前指令来执行。这种"摘要入侵规则"的问题调试起来极难定位,因为你翻 messages 看不出毛病,但模型的注意力被摘要里的情绪词劫持了。


方案三:重要性评分淘汰

最复杂的方案。给每条消息打一个重要性分数,高分保留,低分淘汰。听起来很科学------不像暴力截断那么粗暴,不像摘要那样会丢信息。

等一下,你可能觉得评分方案至少比暴力截断好吧?别急,往下看。

typescript 复制代码
function scoreMessage(msg: ChatMessage, index: number): number {
  if (msg.role === 'system') return 100;
  let score = 0;
  
  if (msg.role === 'tool') score += 30;
  
  const bizKeywords = ['价格', '库存', '下单', '退款', '尺码', '颜色', '地址'];
  const content = typeof msg.content === 'string' ? msg.content : '';
  if (bizKeywords.some(k => content.includes(k))) score += 20;
  
  score += Math.min(index, 15);
  
  const greetings = ['你好', '谢谢', '好的', '嗯', 'ok'];
  if (greetings.some(g => content.trim().toLowerCase() === g)) score -= 10;
  
  return score;
}

function pruneByScore(
  messages: ChatMessage[], 
  maxTokens: number = 4000
): ChatMessage[] {
  const scored = messages.map((msg, i) => ({ ...msg, score: scoreMessage(msg, i) }));
  scored.sort((a, b) => b.score - a.score);
  
  const kept: ChatMessage[] = [];
  let tokens = 0;
  
  for (const msg of scored) {
    const t = Math.ceil(
      (typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content)).length * 1.2
    );
    if (tokens + t <= maxTokens) { kept.push(msg); tokens += t; }
  }
  
  kept.sort((a, b) => messages.indexOf(a) - messages.indexOf(b));
  return kept;
}

任务完成率:82%

评分方案的毛病------"重要性"这个词本身就是个坑。你觉得"价格"和"库存"重要,打 30 分;但用户此刻正在纠结退货流程,"退款"才是关键------你的关键词列表里有没有"退款"?就算有,你给它打了多少分?够不够让它留在 context 里?

关键词打分永远跟不上业务变化。你每上线一个新场景就得更新关键词列表,维护这套评分逻辑的成本比它省下来的 API 费用还高。说句不好听的,这方案像个不停加补丁的破系统------每发现一个新场景就加几行关键词,半年后 scoreMessage 函数变成了两百行的怪物。我试这方案的时候,光是更新关键词就花了一整个下午,比写文章还累。

还有一个问题:评分排序后恢复时间顺序,对话的连贯性就断了。Agent 看到的历史不是一段自然对话,而是被人按"重要性"随机抽了几页出来拼在一起------第 3 轮的用户消息保留了,第 4 轮的 Agent 回复被删了,第 8 轮的工具返回又保留着。模型看着这种跳跃的上下文更容易理解出错,跑出来的结果比暴力截断还差。


我最终用的方案:暴力截断 + 关键信息锚定

就是方案一的升级版------还是暴力截断最近 10 轨,但有几类消息钉死不动,永远不会被截断。

顺便插一句,其实我在试方案二和方案三的时候就已经觉得方向不太对了,但总想着"也许调调参数就能好"------这种执念浪费了我整整两天。如果让我重来,第一天就选暴力截断加锚定,省下的时间够我写三篇文章了。

typescript 复制代码
interface ManagedMessage extends ChatMessage {
  pinned?: boolean;  // 锚定消息,永不截断
}

function smartTruncate(
  messages: ManagedMessage[], 
  maxRounds: number = 10
): ManagedMessage[] {
  const pinned = messages.filter(m => m.pinned || m.role === 'system');
  const unpinned = messages.filter(m => !m.pinned && m.role !== 'system');
  const recent = unpinned.slice(-maxRounds * 2);
  
  const result = [...pinned, ...recent];
  result.sort((a, b) => messages.indexOf(a) - messages.indexOf(b));
  return result;
}

function markPinned(
  messages: ManagedMessage[],
  criticalTools: string[] = ['checkStock', 'getPrice', 'createOrder'],
  intentKeywords: string[] = ['下单', '购买', '退款', '投诉']
): ManagedMessage[] {
  return messages.map((msg, i) => {
    // 关键工具的返回值锚定
    if (msg.role === 'tool') {
      const prev = messages.slice(0, i).filter(m => m.role === 'assistant').pop();
      if (prev?.tool_calls?.some(tc => criticalTools.includes(tc.function.name))) {
        return { ...msg, pinned: true };
      }
    }
    // 用户表达核心意图的消息锚定
    if (msg.role === 'user') {
      const content = typeof msg.content === 'string' ? msg.content : '';
      if (intentKeywords.some(k => content.includes(k))) {
        return { ...msg, pinned: true };
      }
    }
    return msg;
  });
}

实测结果:完成率 94%,20 轨对话 token 从 6400 降到平均 2800,费用省了 56%。

你可能觉得这不就是方案一加了个标记嘛------没错,就是这样。但这个标记解决了一切问题。system prompt 永远保留,关键工具的返回值永远保留,用户表达核心意图的消息永远保留。其余的,该删就删。锚定的规则很简单,不用维护几百行的关键词列表,不用额外调 LLM 压摘要,不用评分排序破坏对话顺序。你只需要定义哪些工具是"关键的",哪些用户意图是"核心的"------这比写评分算法省事一百倍。

说白了就两句话:大部分历史是废话,直接砍;少部分是关键信息,钉死不动。粗暴,但管用。


你遇到过 Agent 的 context 撞墙吗?我好奇大家怎么处理的------有没有比我这个"暴力截断+关键信息锚定"更优雅同时也稳的方案?


关于作者:老三,10+ 年软件开发老兵,软件设计师 & 人工智能应用工程师。专注鸿蒙 ArkTS 北向开发和 Web 前端,最近在折腾 AI Agent 落地和自动化。不定期在 CSDN 分享踩坑实录。
本文遵循 MIT 协议,转载请注明出处。

相关推荐
奋飛2 小时前
Agent系列:从 grill-me 学写 Skill——先找默认错误,再写最小约束
ai编程
赫媒派2 小时前
MCP Server 安全防护:5 个实用配置清单
ai编程
咏方舟【长江支流】2 小时前
【开源】跨语言·跨平台·跨数据库(3) —— 为用宝框架增加ORM,让AI执行
数据库·人工智能·开源·ai编程·orm·咏方舟-长江支流·用宝框架
云卷云舒___________2 小时前
Grok CLI 泄密用户文件、Codex推理优化、GPT-5.6 Sol 登顶 Arena 前端榜 | 7月13日 AI日报
openai·codex·xai·ai日报·grokcli·gpt56sol·designarena
怕浪猫2 小时前
第14章 性能优化与成本控制
openai·agent·ai编程
程序员黎剑2 小时前
我把算卦变成了一段if-else
java·vue.js·spring boot·后端·ai编程
NiceCloud喜云2 小时前
Claude API 价格完全指南:多少钱?怎么算?怎么省?(2026最新)
人工智能·gpt·ai·aigc
Sirius Wu3 小时前
OpenClaw SubAgent 三层安全约束完整详解
人工智能·安全·ai·架构·aigc