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 协议,转载请注明出处。

相关推荐
1名持续学习的码农4 小时前
GPT Plus、GPT Pro用户第一次用Codex,项目权限和Git分支怎么设置?
人工智能·git·gpt·elasticsearch·ai编程·codex
yuanyxh5 小时前
AI Agent 的使用进化史
agent·ai编程·claude
kyriewen6 小时前
Claude自己跑出去hack了3家公司——我为什么还在用它写代码
前端·ai编程·claude
西安小哥6 小时前
AI时代全栈面试通关指南:从背八股到聊架构
面试·ai编程
妙码生花6 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(四十八):菜单规则管理实现
前端·后端·ai编程
FantasticPerson7 小时前
46 · 综合实战:从零到上线,把所学串成一条线
ai编程·claude
windliang7 小时前
Claude Code 源码分析(四):Tool 工具系统-从 Schema 到真实执行
前端·ai编程·claude
Lx3527 小时前
用 TRAE Work 排查 Smartbi 报表数据不一致:从 3 小时缩短到 20 分钟
ai编程·trae
西安小哥7 小时前
用 TRAE 5 分钟生成一份客户能看的功能清单 Excel——l_prd_skills 实战复盘
产品经理·ai编程·产品
众人皆醒我独醉7 小时前
Triton Inference Server:NVIDIA 的推理"瑞士军刀"——LLM 只是它的一种负载
人工智能·面试·ai编程