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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 注册后在控制台创建 Key。控制台地址是 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content Key 管理页在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。API 基础地址统一用 https://taotoken.net/api 注意这个地址不带 UTM 参数直接填进代码里。它兼容 OpenAI 风格的接口前端用 fetch 或 axios 都能直接调不需要额外装 SDK。注意Key 只放在服务端或前端环境变量里别硬编码进仓库。前端直连的话建议走一层自己的后端代理避免 Key 泄露。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 里面有完整的请求格式和参数说明。如果你用的是 Claude Code 这类编码工具Anthropic 兼容通道的说明在 https://taotoken.net/ClaudeCodeAnthropic?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。3. 可复制的历史裁剪阈值配置压缩器的核心是三个参数Token 总预算、触发压缩的阈值比例、保留最近多少轮原文。这三个值配不好要么压缩太频繁导致摘要雪崩要么压缩太晚已经爆了。先给一套经过实测的默认配置你可以直接抄参数默认值说明tokenBudget8000上下文 Token 总预算按模型窗口留 20% 余量triggerRatio0.7历史 Token 超过预算 70% 时触发压缩keepRecent6保留最近 6 轮原文覆盖近期上下文keepMidRatio0.3中间段保留 30% 高分消息原文其余进摘要summaryTimeout3000摘要调用超时单位毫秒为什么触发阈值是 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[]) Promisestring; } 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): PromiseMsg[] { 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[]): Promisestring { 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 换成你实际用的那个。这样摘要和主对话共用一个 KeyToken 消耗在控制台里能一起看。验证裁剪前后的 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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 手动贴几段历史试试调好 prompt 再写进代码。长期做编码类 Agent 的话Coding Plan 在 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 额度更划算。5. 本篇常见错排查压缩器跑起来之后报错和异常基本集中在这几类我按踩坑频率排一下。第一类摘要调用超时导致整轮对话卡住。摘要本身要调一次模型弱网或摘要服务抖动时主流程被阻塞。解决办法是给摘要调用加 AbortController 超时超时就走降级截断async function summarizeWithTimeout(msgs: Msg[], timeout 3000): Promisestring { 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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 可以重新生成接入文档 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 里有完整的错误码说明。6. 把压缩器接进你的对话链路压缩器写完之后接入位置很关键。别在每次发请求前都跑一遍 compress那样每轮都在算 Token、调摘要开销反而更大。正确的做法是在消息列表更新时判断一次只有超过阈值才触发压缩压缩结果缓存起来后续几轮直接复用。具体来说维护一个 compressedHistory 变量。每次用户发新消息先追加到原始 history然后检查总 Token 是否超过 tokenBudget * triggerRatio。没超就直接用原始 history 发请求超了才调 compress把结果存进 compressedHistory后续请求都用它。等 compressedHistory 又涨到阈值再压一次。这样压缩频率大概每 5 到 8 轮一次摘要调用不会成为瓶颈。配合前面说的超时降级整条链路就稳了。最后提醒一句压缩不是万能的。一次性问答、轮次天然小于 10 的工具引入压缩器纯属增加复杂度。长会话、客服、法律医疗咨询这类产品收益最高。摘要模型质量不足时宁可提高 keepMidRatio 多保留原文也别激进压缩失真带来的回答质量下降比多花点 Token 更伤用户。