
你有没有遇到过这种情况——Agent 上线前跑 demo 一切正常上线后跑了半天突然开始报context_length_exceeded我遇到过。而且不是一次。三次。每次都是同样的剧本测试环境 3-5 轮对话稳得一批老板看了直夸这个智能客服做得不错嘛结果上线第二天真实用户聊到 15-20 轮token 从 800 一路飙到 6400API 费用翻了 8 倍然后 context window 撞墙Agent 直接罢工——返回一堆乱码或者干脆卡死不动。最气人的不是它崩了最气人的是崩的原因。用户聊的那些历史里80% 都是废话。“你好”、“帮我查一下”、“还有别的吗”、“算了不要了”——这些寒暄和犹豫Agent 每次请求都得全量塞进 messages 里给模型复读一遍又一遍。你在花钱让模型反复阅读自己两天前的寒暄。说白了这就是一个 token 的黑洞。我第一次碰到这事的时候愣了十分钟盯着监控面板上那条 token 曲线像坐火箭一样往上冲心想这不科学啊——我 prompt 才 200 token怎么一轮对话就吃掉了 800。翻了一下 messages 数组才明白每一轮都把完整历史塞进去包括上一轮工具调用返回的那坨 600 token 的商品详情。聊到第 20 轮历史里堆了 10 次工具返回6000 token 纯粹是在复读旧数据。难怪账单像洪水一样。token 怎么涨的写了个计数器跑了一遍真实对话日志数据挺吓人轮数token 数单次 API 费用GPT-4o5800$0.012102100$0.032154200$0.063206400$0.096258800 撞墙token 增长不是线性的是指数型的。前 5 轮涨得慢10 轮之后开始加速——因为每轮都在前面所有历史的基础上叠加新内容。工具调用的返回值是增长主力一个queryProductDetails返回 600 token下一轮就变成 600新对话再下一轮变成 600600新对话……滚雪球。functioncountTokens(messages:ChatMessage[]):number{returnmessages.reduce((sum,msg){consttexttypeofmsg.contentstring?msg.content:JSON.stringify(msg.content);returnsumMath.ceil(text.length*1.2);},0);}functiontrackGrowth(history:ChatMessage[]):Recordnumber,number{constresult:Recordnumber,number{};for(leti1;ihistory.length;i){result[i]countTokens(history.slice(0,i));}returnresult;}这不是理论推算是真实日志跑出来的数据。我拿生产环境的对话记录统计了两周平均对话轮数 12.4最长的一条聊了 37 轮——37 轮那条对话的 token 消耗是 14800光这一条就花了 $0.22。一天几百条这样的对话月底账单能把你吓出冷汗。方案一暴力截断——只保留最近 N 轮最蠢的方案。砍掉所有超过 10 轮的历史只留最近的对话和 system prompt。不压缩、不摘要、不评分直接删。functiontruncateMessages(messages:ChatMessage[],maxRounds:number10):ChatMessage[]{constsystemMsgsmessages.filter(mm.rolesystem);constconversationmessages.filter(mm.role!system);constrecentconversation.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——多优雅。asyncfunctionsummarizeOldMessages(oldMessages:ChatMessage[],llm:OpenAI):Promisestring{constprompt将以下对话历史压缩为一段简短摘要保留关键意图和结论忽略寒暄和重复${oldMessages.map(m${m.role}:${m.content}).join(\n)}摘要;constresawaitllm.chat.completions.create({model:gpt-4o-mini,messages:[{role:user,content:prompt}],max_tokens:200,});returnres.choices[0]?.message?.content??;}asyncfunctioncompressConversation(messages:ChatMessage[],keepRounds:number10,llm:OpenAI):PromiseChatMessage[]{constsystemMsgsmessages.filter(mm.rolesystem);constconversationmessages.filter(mm.role!system);constsplitIdxconversation.length-keepRounds*2;constoldMsgsconversation.slice(0,splitIdx);constrecentconversation.slice(splitIdx);if(oldMsgs.length0)returnmessages;constsummaryawaitsummarizeOldMessages(oldMsgs,llm);return[...systemMsgs,{role:system,content:[对话摘要]${summary}},...recent];}任务完成率76%。等一下这里我漏说一个前提——摘要压缩本身也要花钱。每压缩一次就额外调一次 gpt-4o-mini高频客服场景一天几百次压缩调用别看 gpt-4o-mini 单次便宜一个月下来多花 $40 不是小事。但更致命的问题不在钱在于摘要会吞掉工具调用的关键参数。这个 bug 当时在雷达鸭的客服 Agent 里卡了我两天。我至今记得那个场景——用户问红色款 XL 码有没有货Agent 调了checkStock(color红色, sizeXL)返回有货剩余 3 件。聊了 10 轨后触发摘要压缩整段被压成了用户询问了库存情况Agent 确认有货。用户过了一会儿追问我刚才问的那个红色 XL 码的能直接下单吗“——摘要里没有颜色和尺码Agent 只能回一句您说的是哪个款式”模型压缩时觉得颜色和尺码是次要细节省掉不影响理解。但业务上这才是核心——没有颜色尺码库存查询就失去了意义。我个人特别讨厌这种自以为聪明的压缩它丢掉的恰好是你最不能丢的东西。还有个坑摘要本身占 system prompt 的位置跟你的规则挤在一起。我见过一条摘要写用户表示不满意Agent 后面每一轮都主动道歉——它把历史摘要当成了当前指令来执行。这种摘要入侵规则的问题调试起来极难定位因为你翻 messages 看不出毛病但模型的注意力被摘要里的情绪词劫持了。方案三重要性评分淘汰最复杂的方案。给每条消息打一个重要性分数高分保留低分淘汰。听起来很科学——不像暴力截断那么粗暴不像摘要那样会丢信息。等一下你可能觉得评分方案至少比暴力截断好吧别急往下看。functionscoreMessage(msg:ChatMessage,index:number):number{if(msg.rolesystem)return100;letscore0;if(msg.roletool)score30;constbizKeywords[价格,库存,下单,退款,尺码,颜色,地址];constcontenttypeofmsg.contentstring?msg.content:;if(bizKeywords.some(kcontent.includes(k)))score20;scoreMath.min(index,15);constgreetings[你好,谢谢,好的,嗯,ok];if(greetings.some(gcontent.trim().toLowerCase()g))score-10;returnscore;}functionpruneByScore(messages:ChatMessage[],maxTokens:number4000):ChatMessage[]{constscoredmessages.map((msg,i)({...msg,score:scoreMessage(msg,i)}));scored.sort((a,b)b.score-a.score);constkept:ChatMessage[][];lettokens0;for(constmsgofscored){consttMath.ceil((typeofmsg.contentstring?msg.content:JSON.stringify(msg.content)).length*1.2);if(tokenstmaxTokens){kept.push(msg);tokenst;}}kept.sort((a,b)messages.indexOf(a)-messages.indexOf(b));returnkept;}任务完成率82%。评分方案的毛病——重要性这个词本身就是个坑。你觉得价格和库存重要打 30 分但用户此刻正在纠结退货流程“退款才是关键——你的关键词列表里有没有退款”就算有你给它打了多少分够不够让它留在 context 里关键词打分永远跟不上业务变化。你每上线一个新场景就得更新关键词列表维护这套评分逻辑的成本比它省下来的 API 费用还高。说句不好听的这方案像个不停加补丁的破系统——每发现一个新场景就加几行关键词半年后 scoreMessage 函数变成了两百行的怪物。我试这方案的时候光是更新关键词就花了一整个下午比写文章还累。还有一个问题评分排序后恢复时间顺序对话的连贯性就断了。Agent 看到的历史不是一段自然对话而是被人按重要性随机抽了几页出来拼在一起——第 3 轮的用户消息保留了第 4 轮的 Agent 回复被删了第 8 轮的工具返回又保留着。模型看着这种跳跃的上下文更容易理解出错跑出来的结果比暴力截断还差。我最终用的方案暴力截断 关键信息锚定就是方案一的升级版——还是暴力截断最近 10 轨但有几类消息钉死不动永远不会被截断。顺便插一句其实我在试方案二和方案三的时候就已经觉得方向不太对了但总想着也许调调参数就能好——这种执念浪费了我整整两天。如果让我重来第一天就选暴力截断加锚定省下的时间够我写三篇文章了。interfaceManagedMessageextendsChatMessage{pinned?:boolean;// 锚定消息永不截断}functionsmartTruncate(messages:ManagedMessage[],maxRounds:number10):ManagedMessage[]{constpinnedmessages.filter(mm.pinned||m.rolesystem);constunpinnedmessages.filter(m!m.pinnedm.role!system);constrecentunpinned.slice(-maxRounds*2);constresult[...pinned,...recent];result.sort((a,b)messages.indexOf(a)-messages.indexOf(b));returnresult;}functionmarkPinned(messages:ManagedMessage[],criticalTools:string[][checkStock,getPrice,createOrder],intentKeywords:string[][下单,购买,退款,投诉]):ManagedMessage[]{returnmessages.map((msg,i){// 关键工具的返回值锚定if(msg.roletool){constprevmessages.slice(0,i).filter(mm.roleassistant).pop();if(prev?.tool_calls?.some(tccriticalTools.includes(tc.function.name))){return{...msg,pinned:true};}}// 用户表达核心意图的消息锚定if(msg.roleuser){constcontenttypeofmsg.contentstring?msg.content:;if(intentKeywords.some(kcontent.includes(k))){return{...msg,pinned:true};}}returnmsg;});}实测结果完成率94%20 轨对话 token 从 6400 降到平均 2800费用省了 56%。你可能觉得这不就是方案一加了个标记嘛——没错就是这样。但这个标记解决了一切问题。system prompt 永远保留关键工具的返回值永远保留用户表达核心意图的消息永远保留。其余的该删就删。锚定的规则很简单不用维护几百行的关键词列表不用额外调 LLM 压摘要不用评分排序破坏对话顺序。你只需要定义哪些工具是关键的哪些用户意图是核心的——这比写评分算法省事一百倍。说白了就两句话大部分历史是废话直接砍少部分是关键信息钉死不动。粗暴但管用。你遇到过 Agent 的 context 撞墙吗我好奇大家怎么处理的——有没有比我这个暴力截断关键信息锚定更优雅同时也稳的方案关于作者老三10 年软件开发老兵软件设计师 人工智能应用工程师。专注鸿蒙 ArkTS 北向开发和 Web 前端最近在折腾 AI Agent 落地和自动化。不定期在 CSDN 分享踩坑实录。本文遵循 MIT 协议转载请注明出处。