你有没有遇到过这种情况------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 协议,转载请注明出处。