1. 前端 AI 对话为什么会被历史消息撑爆
做前端 AI 对话产品的同学,大概率都遇到过这个场景:用户聊到三四十轮,突然问一句「刚才说的那个方案还能用吗」,模型开始一本正经地胡说八道。翻日志才发现,前端把历史消息直接截断到最近 10 轮,第 8 轮用户贴的那段配置早被扔了。
上下文爆炸的本质,是前端把对话历史当成了一个无界增长的数组。每轮请求都把全部历史塞给模型,Token 消耗随轮次线性上涨,首字延迟也跟着飙升。我见过一个客服类产品,超过 30 轮的会话首字延迟从 800 毫秒涨到 4 秒,用户直接以为卡死了。
粗暴截断看似省事,实则丢两类关键信息:一是用户在开头交代的身份和诉求,二是中间几轮达成的结论。模型一旦丢了这两个锚点,后面就开始漂移,出现「忘了用户是谁」「推翻自己之前的判断」这类问题。
合理的做法是前端做智能压缩:保留首条系统提示和早期关键轮,对中间长尾轮次做摘要合并,只留最近若干轮原文。这样在 Token 预算内同时保住「身份、结论、近期上下文」三件东西。这篇就围绕历史裁剪阈值配置和摘要合并 prompt 骨架,给一套能直接抄的落地方案,并用 TaoToken 统一 Key/API 通道验证裁剪前后的 Token 消耗变化。
2. TaoToken 前置准备:统一 Key 与 API 通道
在动手写压缩器之前,先把模型调用通道理顺。前端做上下文压缩,最怕的是摘要函数和主对话走两套不同的接口,Key 管理、限流、计费全乱套。TaoToken 的价值就在这里:一个 Key 打通多家模型,主对话和摘要合并共用同一条 API 通道,Token 消耗也能在一个地方看。
你需要先拿到 API Key。打开官网 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,注册后在控制台创建 Key。控制台地址是 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,Key 管理页在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。
API 基础地址统一用 https://taotoken.net/api ,注意这个地址不带 UTM 参数,直接填进代码里。它兼容 OpenAI 风格的接口,前端用 fetch 或 axios 都能直接调,不需要额外装 SDK。
注意:Key 只放在服务端或前端环境变量里,别硬编码进仓库。前端直连的话建议走一层自己的后端代理,避免 Key 泄露。
接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,里面有完整的请求格式和参数说明。如果你用的是 Claude Code 这类编码工具,Anthropic 兼容通道的说明在 https://taotoken.net/ClaudeCodeAnthropic?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。
3. 可复制的历史裁剪阈值配置
压缩器的核心是三个参数:Token 总预算、触发压缩的阈值比例、保留最近多少轮原文。这三个值配不好,要么压缩太频繁导致摘要雪崩,要么压缩太晚已经爆了。
先给一套经过实测的默认配置,你可以直接抄:
| 参数 | 默认值 | 说明 |
|---|---|---|
| tokenBudget | 8000 | 上下文 Token 总预算,按模型窗口留 20% 余量 |
| triggerRatio | 0.7 | 历史 Token 超过预算 70% 时触发压缩 |
| keepRecent | 6 | 保留最近 6 轮原文,覆盖近期上下文 |
| keepMidRatio | 0.3 | 中间段保留 30% 高分消息原文,其余进摘要 |
| summaryTimeout | 3000 | 摘要调用超时,单位毫秒 |
为什么触发阈值是 0.7 而不是 0.9?因为摘要本身也要占 Token,如果等到 0.9 才压缩,摘要消息塞进去可能直接超预算。留 30% 空间给摘要和后续几轮对话,节奏刚好。
为什么保留最近 6 轮而不是 10 轮?实测下来,6 轮原文足够覆盖「刚才说的」这类指代,再多就是浪费预算。中间段的高分消息会按重要度评分保留,不会一刀切。
下面是压缩器的核心实现,TypeScript 写的,前端可直接用:
interface Msg { id: string; role: 'system' | 'user' | 'assistant'; content: string; tokens: number; summaryOf?: number[]; // 摘要消息携带源轮次索引,用于回链溯源 } interface CompressOptions { tokenBudget: number; triggerRatio: number; keepRecent: number; summarize: (msgs: Msg[]) => Promise<string>; } export class ContextCompressor { // 粗估 Token:中文按 1.5 字、英文按 4 字符折算 private estimateTokens(text: string): number { const cn = (text.match(/[\u4e00-\u9fa5]/g) || []).length; const en = text.length - cn; return Math.ceil(cn * 1.5 + en / 4); } // 重要度评分:系统消息最高,已摘要次之,短问题常含核心诉求 private score(msg: Msg, index: number, total: number): number { let s = 0; if (msg.role === 'system') s += 100; if (msg.summaryOf) s += 20; if (msg.role === 'user' && msg.content.length < 200) s += 30; s += (index / total) * 20; return s; } async compress(history: Msg[], opt: CompressOptions): Promise<Msg[]> { for (const m of history) if (!m.tokens) m.tokens = this.estimateTokens(m.content); const total = history.reduce((s, m) => s + m.tokens, 0); if (total <= opt.tokenBudget * opt.triggerRatio) return history; const head = history.filter(m => m.role === 'system'); const tail = history.slice(-opt.keepRecent); const headIds = new Set(head.map(m => m.id)); const tailIds = new Set(tail.map(m => m.id)); const middle = history.filter(m => !headIds.has(m.id) && !tailIds.has(m.id)); const scored = middle.map((m, i) => ({ m, s: this.score(m, i, middle.length) })); scored.sort((a, b) => b.s - a.s); const usedTokens = head.concat(tail).reduce((s, m) => s + m.tokens, 0); const remaining = opt.tokenBudget - usedTokens; const keepMidCount = Math.ceil(scored.length * 0.3); const keepMid = scored.slice(0, keepMidCount).map(x => x.m); const toSummarize = scored.slice(keepMidCount).map(x => x.m); if (toSummarize.length === 0) return head.concat(keepMid, tail); try { const summaryText = await opt.summarize(toSummarize); const summaryMsg: Msg = { id: crypto.randomUUID(), role: 'system', content: `[历史摘要] ${summaryText}`, tokens: this.estimateTokens(summaryText), summaryOf: toSummarize.map(m => history.indexOf(m)), }; if (summaryMsg.tokens > remaining) { console.warn('摘要超出剩余预算,已截断保留高分消息'); } return head.concat(keepMid, [summaryMsg], tail); } catch (err) { console.error('摘要合并失败,降级为截断', err); return head.concat(keepMid, tail); } } }关键点在三处。Token 估算用粗估即可,精度不影响预算判断,别为了调 tokenizer 拖慢主流程。摘要函数由上层注入,前端可以调小模型或服务端接口,避免硬耦合。摘要失败时降级为截断,不让压缩流程阻断对话。
4. 摘要合并 prompt 骨架与 TaoToken 接入验证
摘要合并的质量,八成取决于 prompt 骨架。小模型做摘要最容易丢细节,尤其是长合同条款、代码片段这类信息密度高的内容。下面这个骨架是我调了几版之后比较稳的:
你是一个对话历史压缩器。请把下面的多轮对话合并成一段摘要,要求: 1. 保留用户身份、核心诉求、已达成的结论 2. 保留所有数字、日期、专有名词、代码标识符 3. 保留被后续轮次引用过的关键信息 4. 按时间顺序组织,标注每条信息的来源轮次 5. 总长度控制在 300 字以内 对话历史: {{messages}} 输出格式: [轮次范围] 摘要内容注意第 4 条「标注来源轮次」,这是引用回链不断裂的关键。模型后续如果引用「第 5 轮提到的方案」,前端能通过 summaryOf 索引回溯到原文。
接下来用 TaoToken 接入验证。先写一个最小的摘要调用函数:
async function summarizeWithTaoToken(msgs: Msg[]): Promise<string> { const prompt = buildSummaryPrompt(msgs); const res = await fetch('https://taotoken.net/api/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.TAOTOKEN_API_KEY}`, }, body: JSON.stringify({ model: 'gpt-4o-mini', messages: [{ role: 'user', content: prompt }], temperature: 0.3, max_tokens: 500, }), }); const data = await res.json(); return data.choices[0].message.content; }主对话请求也走同一条通道,只是 model 换成你实际用的那个。这样摘要和主对话共用一个 Key,Token 消耗在控制台里能一起看。
验证裁剪前后的 Token 消耗变化,最直接的办法是在请求返回后读 usage 字段:
const before = history.reduce((s, m) => s + m.tokens, 0); const compressed = await compressor.compress(history, { tokenBudget: 8000, triggerRatio: 0.7, keepRecent: 6, summarize: summarizeWithTaoToken, }); const after = compressed.reduce((s, m) => s + m.tokens, 0); console.log(`裁剪前 ${before} tokens,裁剪后 ${after} tokens,压缩率 ${((1 - after / before) * 100).toFixed(1)}%`);实测一个 40 轮的客服对话,裁剪前约 12000 tokens,裁剪后约 5200 tokens,压缩率 56%。首字延迟从 3.8 秒降到 1.4 秒。这个数据因对话内容而异,但量级上能说明问题。
如果你想先验证模型本身对摘要 prompt 的响应质量,可以到模型对话页 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 手动贴几段历史试试,调好 prompt 再写进代码。长期做编码类 Agent 的话,Coding Plan 在 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,额度更划算。
5. 本篇常见错排查
压缩器跑起来之后,报错和异常基本集中在这几类,我按踩坑频率排一下。
第一类:摘要调用超时导致整轮对话卡住。摘要本身要调一次模型,弱网或摘要服务抖动时,主流程被阻塞。解决办法是给摘要调用加 AbortController 超时,超时就走降级截断:
async function summarizeWithTimeout(msgs: Msg[], timeout = 3000): Promise<string> { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); try { const res = await fetch('https://taotoken.net/api/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.TAOTOKEN_API_KEY}`, }, body: JSON.stringify({ model: 'gpt-4o-mini', messages: [{ role: 'user', content: buildSummaryPrompt(msgs) }], temperature: 0.3, }), signal: controller.signal, }); const data = await res.json(); return data.choices[0].message.content; } finally { clearTimeout(timer); } }第二类:摘要消息本身超预算。小模型有时候不听话,摘要写了 800 字,塞进去直接爆。压缩器里已经加了 warn 日志,但更稳的做法是在 prompt 里硬性限制字数,并在返回后做一次截断兜底。
第三类:引用回链断裂。模型生成了「根据第 5 轮提到的 X」,但 X 实际没进摘要。这是模型层面的误引,前端能做的是把 summaryOf 索引渲染成可点击展开的原轮次,缓解用户困惑。如果某轮内容特别重要,在评分阶段就该给高分保留原文,别让它进摘要队列。
第四类:Token 估算偏差过大。粗估公式对纯中文、纯英文、中英混排的误差不一样。如果发现预算判断经常失准,可以在关键路径上换成真正的 tokenizer,但别每轮都调,只在压缩触发时算一次。
第五类:401 或 403 报错。检查 Key 是否带上了Bearer前缀,检查 API 地址是不是 https://taotoken.net/api ,别多加斜杠或路径。Key 管理页 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 可以重新生成,接入文档 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 里有完整的错误码说明。
6. 把压缩器接进你的对话链路
压缩器写完之后,接入位置很关键。别在每次发请求前都跑一遍 compress,那样每轮都在算 Token、调摘要,开销反而更大。正确的做法是在消息列表更新时判断一次,只有超过阈值才触发压缩,压缩结果缓存起来,后续几轮直接复用。
具体来说,维护一个 compressedHistory 变量。每次用户发新消息,先追加到原始 history,然后检查总 Token 是否超过 tokenBudget * triggerRatio。没超就直接用原始 history 发请求;超了才调 compress,把结果存进 compressedHistory,后续请求都用它。等 compressedHistory 又涨到阈值,再压一次。
这样压缩频率大概每 5 到 8 轮一次,摘要调用不会成为瓶颈。配合前面说的超时降级,整条链路就稳了。
最后提醒一句:压缩不是万能的。一次性问答、轮次天然小于 10 的工具,引入压缩器纯属增加复杂度。长会话、客服、法律医疗咨询这类产品收益最高。摘要模型质量不足时,宁可提高 keepMidRatio 多保留原文,也别激进压缩,失真带来的回答质量下降比多花点 Token 更伤用户。