
1. 多轮对话记忆为什么会膨胀到失控多轮对话记忆和上下文压缩这件事最开始我也觉得简单把历史消息拼进 prompt 不就行了。真跑起来才发现LLM 本身没有记忆每一次 API 调用都是独立会话你不带历史它就失忆你全带历史它又会被上下文窗口卡死。多轮对话记忆的本质就是把之前的对话历史一起塞进当前提示词再发给模型让模型根据上下文模拟出连续对话的感觉。问题出在上下文窗口有上限。假设你保留最近 50 条原始消息超过这个数就得截断截断就会丢信息丢信息用户就会觉得“它怎么忘了刚才说的”。于是上下文压缩登场把溢出的旧消息交给 LLM 压成一段摘要后续请求只带“摘要 最近窗口”既控制 token 又保住长时记忆。滑动窗口负责短期细节摘要压缩负责长期脉络两者结合就是大多数 Agent 场景的默认解法。这套东西适合谁做 RAG 问答、面试模拟、客服机器人、代码助手这类需要连续多轮交互的工程同学。如果你正在被“对话越长越贵、越聊越傻”折磨下面这套配置骨架可以直接抄。我试过把压缩模块单独解耦成一个 memory 模块只负责压缩文本和拼接最终消息查询最近 K 条消息交给各业务自己管维护起来清爽很多。2. TaoToken 统一 Key 的前置准备在写 config.toml 之前先把模型调用这一层统一掉。多轮对话压缩会频繁触发 LLM 调用如果每个模块各自管 Key、各自配 base_url后面排查超时和限流会非常痛苦。我的做法是用 TaoToken 统一 Key 接入一个 Key 覆盖对话、压缩、摘要合并这些调用。TaoToken 的定位是统一模型接入层官网在 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 入口是 https://taotoken.net/api 。你需要先去控制台创建 Key地址是 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 创建完在 API Keys 页面复制页面在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 参数对不上的时候翻这个最快。如果你只是先验证模型能不能正常返回可以直接用模型对话页面试一条 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 。长期做编码类 Agent、需要稳定跑压缩任务的可以看 Coding Plan https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 。用 Claude Code 这类工具接的参考 https://taotoken.net/claudecode?utm_sourcetaotoken_aicg_blog_endutm_contentclaudecodeutm_campaignrewrite 。注意Key 只放在服务端环境变量或配置中心不要写进前端代码也不要提交到 Git。压缩模块会异步调用Key 泄露的风险比普通对话更高。3. 可复制的 config.toml 与 settings.json 骨架先给记忆模块的参数骨架。下面这份 config.toml 是我实测下来比较稳的一组window-size 保留最近 50 条原始消息compress-batch 每次最多压 30 条trigger-threshold 到 60 条才进入压缩判定。# config.toml —— Agent 记忆模块配置 [agent.memory] window-size 50 # 保留最近 N 条原始消息 compress-batch 30 # 每次压缩合并的新消息数量上限 trigger-threshold 60 # 消息总数超过此阈值才触发压缩判定 summary-max-chars 500 # 单条摘要最大字数 async-thread-pool 4 # 压缩异步线程池大小 retry-on-conflict 1 # 乐观锁冲突重试次数 [agent.memory.constraints] # 约束规则启动时校验不满足直接抛异常 rule-1 compressBatch windowSize rule-2 triggerThreshold windowSize rule-3 triggerThreshold windowSize compressBatch [llm] base-url https://taotoken.net/api api-key ${TAOTOKEN_API_KEY} model claude-sonnet timeout-ms 30000对应的 settings.json 版本方便 Spring Boot 或 Node 侧直接读{ agent: { memory: { windowSize: 50, compressBatch: 30, triggerThreshold: 60, summaryMaxChars: 500, asyncThreadPool: 4, retryOnConflict: 1 } }, llm: { baseUrl: https://taotoken.net/api, apiKey: ${TAOTOKEN_API_KEY}, model: claude-sonnet, timeoutMs: 30000 } }三条约束必须成立否则逻辑会崩。compressBatch 不能超过 windowSize不然一次压不完溢出triggerThreshold 必须大于 windowSize否则永远进不了压缩判断triggerThreshold 不能超过 windowSize compressBatch否则缓冲区太大溢出堆积会超过窗口导致消息空洞。摘要表用一张就够一个会话只保留一条增量覆盖CREATE TABLE conversation_summaries ( id BIGSERIAL PRIMARY KEY, source VARCHAR(32) NOT NULL, -- rag_chat / interview session_id BIGINT NOT NULL, summary_text TEXT NOT NULL, start_msg_order INTEGER NOT NULL, end_msg_order INTEGER NOT NULL, version INTEGER NOT NULL DEFAULT 1, created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW() ); CREATE UNIQUE INDEX idx_summary_source_session ON conversation_summaries(source, session_id);4. 压缩触发与读取的验证请求参数配好接下来验证压缩到底有没有按预期触发。核心是三个锚点lastOrder 是当前最后一条消息位置endOrder 是上次压缩结束位置startOrder endOrder 1 是本次压缩起点。// 计算溢出区间与触发层级 int lastOrder messageMapper.getLastOrder(sessionId); int windowSize 50; int compressBatch 30; int overflowEnd lastOrder - windowSize; // 溢出右边界 int startOrder summary null ? 0 : summary.getEndMsgOrder() 1; int overflowCount overflowEnd - startOrder 1; // 未压缩溢出量 if (overflowCount compressBatch overflowCount windowSize) { // 第一层攒批不压 } else if (overflowCount compressBatch) { // 第二层正常触发取前 30 条 doCompress(startOrder, Math.min(startOrder compressBatch - 1, overflowEnd)); } else if (overflowCount windowSize) { // 第三层强制兜底防止消息空洞 doCompress(startOrder, overflowEnd); }第三层为什么必须有理想情况下 LLM 100% 成功第二层就够了。但压缩接口会超时、会抛异常一旦第二层持续失败溢出量会一直堆。堆到超过 windowSize 时读端只取最近 50 条压缩端又因为没攒够一批不压中间那段消息既没被压缩也没被读取就成了消息空洞。第三层就是兜底溢出堆到危险线强制重试不能再攒。读取端跟着 endOrder 走这是避免空洞的关键// 读端从上次压缩结束位置 1 开始读 int readFromOrder summary null ? 0 : summary.getEndMsgOrder() 1; ListMessage recent messageMapper.selectByOrderRange( sessionId, readFromOrder, lastOrder);验证时你可以造一段 90 条消息的会话观察日志里 overflowCount 的变化。正常轮次它小于 30攒着不压到 30 触发一次如果人为让压缩接口抛异常溢出会涨到 50 触发第三层兜底。用模型对话页面发一条带历史的问题看返回里有没有带上摘要内容就能确认读端拼接生效 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 。5. 本篇常见错排查消息空洞最典型。现象是用户发现某几轮对话“凭空消失”。根因是读端固定读最近 windowSize 条压缩端又没压到那段。排查方法打印 readFromOrder 和 endOrder确认 readFromOrder endOrder 1。如果读端写死成 lastOrder - windowSize必出空洞。重复压缩烧 token不记录 startOrder每次从头打包全部消息。第 15 轮压 0~9第 20 轮压 0~19前 10 条被重复压。改成增量压缩startOrder endOrder 1每次只压新溢出部分。切出孤立问或孤立答message_order 偶数是人问奇数是 AI 答。压缩右边界 overflowEnd 落在偶数USER时会切出没有回答的问题。规则是左边界取偶数右边界取奇数overflowEnd 为偶数就退一位到上一条 ASSISTANT。摘要越传越离谱多条摘要叠加合并信息被反复改写。解法是一个会话只留一条摘要每次把旧摘要 新对话一起发给 LLM输出完整摘要覆盖旧记录LLM 合并不可逆所以每次输出必须是完整版。压缩阻塞主流程压缩同步执行用户等摘要生成完才拿到回答。改成 Async Lazy 异步执行独立线程池 2~4 线程乐观锁冲突自动重试一次。参数校验缺失compressBatch 配成 60 大于 windowSize 50溢出一次压不完。启动时加约束校验不满足直接 fail fast。6. 接入与排障的下一步压缩模块跑通后建议先把 Key 和接入方式固定下来再去调阈值。统一 Key 的创建在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 接入参数对照 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。如果你在排查压缩超时、限流、返回格式异常优先看接入文档里的错误码说明再回到 API Keys 页面确认 Key 状态。长期跑编码类 Agent、压缩任务调用频繁的Coding Plan 会更省心 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 。用 Claude Code 接的走 https://taotoken.net/claudecode?utm_sourcetaotoken_aicg_blog_endutm_contentclaudecodeutm_campaignrewrite 。控制台总入口在 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 。阈值这块别照抄我的 50/30/60它只是测试值。真正合理的 window-size 和 compress-batch 要结合你的 token 消耗和评测指标跑实验。我的经验是先把 window-size 设小一点观察摘要质量再逐步放大同时盯住每次压缩的 token 消耗曲线。压缩端和读取端解耦、一个锚点 endOrder、三层触发控制这套骨架搭好之后剩下的就是调参和补异常兜底了。