资讯动态

长对话压缩:ADK summarization 中间件源码(第82篇-E68)

发布时间:2026/8/14 5:07:07 来源:尧图企业网站定制
系列「企业级 AI Agent 实现拆解」E68 篇Part 14 记忆篇第六章。上一篇讲的是 Agent 怎么从对话里提炼用户画像。这篇讲另一个「记忆」问题——对话太长了塞不进模型的上下文窗口怎么办。先给你一个你可能没意识到的现场证据你现在正读的这个对话开头那段「会话延续自此前一段因上下文耗尽而终止的对话」的总结格式就是这套中间件定义的。这篇就讲 CloudWeGo Eino ADK 是怎么把「长对话压缩」做成一个可插拔中间件的以及 DeepFlux 在生产里为什么没直接用它、而是另起了一套。读完这篇你会知道ADK 的summarization中间件把「编码 Agent 的上下文压缩compact」抽成了通用件触发 → 分离 system → 调模型生成摘要 → 回填用户原话 → 替换历史token 估算用「基线 增量」不重算全量历史而是找最后一条 assistant 自报的TotalTokens当基线之后的增量按字符数/4粗估摘要 prompt 死命令「不要调用任何工具」——因为摘要模型可能也挂着工具集不拦住它就不压缩、反而去执行了摘要里的all_user_messages是 LLM 凭记忆写的占位符后处理会替换成真实的近期用户消息最多 30k token因为用户原话是意图的 ground truth不能让模型复述失真摘要消息被打上contentTypesummary标记下一轮再压缩时识别为「内部消息」不重复统计——这是避免「压缩的压缩」滚雪球的关键DeepFlux 没用这套中间件自己写了旁路存储版摘要另存仓库注入 system、还能双写进长期记忆让跨会话 Recall 召回——两种哲学对应两种产品形态一、这套东西你刚见过本对话开头那段就是它的模板很多人第一次遇到「对话压缩」是在 Claude Code 这类编码 Agent 里——聊着聊着它突然说「上下文快满了我来总结一下前面的对话」然后接着干。这个动作不是魔法是一段确定的代码。Eino ADK 把这段代码做成了一个中间件挂在eino/adk/middlewares/summarization/下。它的入口是BeforeModelRewriteState——顾名思义在调用模型之前重写整个对话状态// summarization.gofunc(m*TypedMiddleware[M])BeforeModelRewriteState(ctx,state,_)(...){triggered,err:m.shouldSummarize(...)// 1. 判断要不要压缩if!triggered{returnctx,state,nil}finalMsgs,err:m.Summarize(ctx,state)// 2. 压缩afterState:*state afterState.MessagesfinalMsgs// 3. 用压缩后的消息替换原历史returnctx,afterState,nil}三步判断 → 压缩 → 替换。中间件的好处是它对 Agent 主循环透明——Agent 本身不知道历史被换过它只看到「一段较短的历史」照常跑。那它生成的摘要长什么样看 prompt 文件里的两个常量// prompt.goconstsummaryPreambleThis session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.constcontinueInstructionContinue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary...把这两段对照本对话最开头的那段系统提示——逐字一致。这不是巧合本对话开头那段「会话延续自此前一段因上下文耗尽而终止的对话……从对话中断的地方继续不要再问用户任何问题」就是这套机制生成后塞回去的续接指令。ADK 把它做成了通用件任何接进去的长会话 Agent 都能获得同样的「压缩-续接」能力。二、什么时候触发token 估算的「基线 增量」压缩的第一步是判断「该压缩了吗」。TriggerCondition给了两个旋钮任一满足就触发// summarization.gotypeTriggerConditionstruct{ContextTokensint// token 数超过这个阈值ContextMessagesint// 消息条数超过这个阈值}// 注释Summarization triggers if ANY of the set conditions is met.默认 token 阈值是 16 万defaultTriggerContextTokens 160000——瞄准的是当前主流大模型 128k~200k 的上下文窗口留出余量在快爆之前动手。有意思的是它怎么数 token。最直觉的做法是每次把全部历史重新跑一遍 tokenizer但那样又慢又贵历史越长越亏。ADK 用了个聪明的「基线 增量」// defaultTypedTokenCounter · 简化funccountTokens(msgs[]Message)int{varbase,incStartintfori:len(msgs)-1;i0;i--{// 从后往前找ift:getAssistantTotalTokens(msgs[i]);t0{// 最后一条带 Usage 的 assistantbaset// ← 拿它当基线incStarti1break}}varincintfor_,m:rangemsgs[incStart:]{// 只估算基线之后的增量incestimateTokenCount(len(m.Content))// len/44 字符 ≈ 1 token}returnbaseinc}逻辑很直白模型自己最清楚它这一轮吃了多少 token——每次模型回复都会带回一个Usage.TotalTokens。找到最后一次回复里这个值当作基线之后新增的消息还没喂给模型的按字符数/4粗估累加。跑一遍 demo 看效果实验 Atoken 估算基线 增量 历史共 6 条最后一条带 Usage 的 assistant 自报 TotalTokens5200 → 基线 基线之后的增量消息: 1 条 user36 字节 → 9 token 估算总 token 5200 9 5209 实验 B触发判断任一条件满足即触发 B1 ContextTokens5000 token5209 → 触发true B2 ContextTokens999999 token5209 → 触发false B3 ContextMessages5 当前 6 条 → 触发truetoken 阈值很大靠消息数触发B1 阈值 5000、当前 5209 → 触发B2 阈值拉到 99 万 → 不触发B3 把 token 阈值拉满、改用消息条数5 条触发。两个旋钮是「或」关系哪个先到都行。字符数/4是个很粗的估计中文实际 1 字约 1~2 token这个公式会偏低但这里不需要精确——它只是用来判断「快爆了吗」偏一点不影响触发时机的合理性。真正精确的数交给模型自己报。三、摘要怎么生成system 分离 禁止调工具 两层容错触发之后进入Summarize。第一步是把开头的 system 消息单独拎出来——system 提示词你是一个 xx 助手不参与压缩它必须原样保留// splitSystemAndContextMsgs开头的 system 消息一组其余一组funcsplitSystemAndContextMsgs(msgs[]Message)(system,context[]Message){...}然后用这三段拼出喂给摘要模型的输入[摘要用的 system 指令, ...全部 context 消息, 摘要用的 user 指令]。实验 C摘要输入构造system 分离 三段拼接 分离system 消息 1 条context 消息 5 条 模型输入 [sysInstruction, ...5 条 context, userInstruction] 7 条 ① [system] 你是一个负责总结对话的 AI 助手。 ② [user] 第一问 ...中间是全部 context 消息... 末位 [user] 关键约束不要调用任何工具末位那条 user 指令是整套 prompt 里最值得注意的设计。它的开头反复强调一件事CRITICAL: Respond with TEXT ONLY. Do NOT call any tools. - Do NOT use Read, Bash, Grep, Glob, Edit, Write, or ANY other tool. - Tool calls will be REJECTED and will waste your only turn — you will fail the task.为什么要这么严厉地禁止调工具因为摘要用的模型往往就是 Agent 自己那个挂满工具的模型。如果不拦住它看到一大段对话历史可能不去总结、反而「顺手」继续执行比如看到半截代码任务它又去 Read 文件了。那就不叫压缩叫跑偏了。摘要必须是一次纯粹的「读写文本」调用。生成阶段还有两层容错generateWithRetryrunFailoverRetry重试主模型这次调用失败了网络抖动、限流按指数退避 随机抖动重试默认最多 3 次Failover故障转移主模型重试到底还是不行换一个备用模型再试默认也是 3 次。两层是递进的先假设是偶发错误重试同模型再假设是模型本身有问题换模型。退避算法封顶 10 秒避免把对话卡死在压缩上。还可以开EmitInternalEvents把before_summarize/generate_summary每次尝试含 attempt/phase/error/after_summarize三种事件发出去让「黑盒压缩」变成可观测的——你能看到压缩了几次、转没转备用模型。四、压缩之后用户原话必须回填不能靠 LLM 记忆模型吐出摘要后不能直接拿来用——因为摘要里的「用户消息」是 LLM 凭记忆复述的可能失真。看 prompt 要求模型输出的第 6 段6. All user messages: all_user_messages.../all_user_messagesLLM 会在这里填它「记得」的用户原话。但用户到底说了什么是整个对话的意图锚点ground truth不能让模型复述走样。所以中间件在拿到摘要后做了一步后处理replaceUserMessagesInSummary把这个标签块替换成真实的近期用户消息从后往前保留累计不超过 30000 token实验 Dall_user_messages 占位符回填真实用户消息 LLM 原始摘要占位符: ... all_user_messages - LLM 凭记忆写的占位由后处理替换成真实近期用户消息 /all_user_messages maxTokens10预算小从后往前只保留最近的 2 条: all_user_messages - 用户提问第四号 - 用户提问第五号 /all_user_messages maxTokens30000源码默认全部 5 条回填: all_user_messages - 用户提问第一号 - 用户提问第二号 - 用户提问第三号 - 用户提问第四号 - 用户提问第五号 /all_user_messages → 用户原话是意图的 ground truth摘要不能凭 LLM 记忆失真必须回填真实消息预算小的时候优先保留最近的用户消息最新的意图最重要预算够就全填。这正是你现在看到的这段总结里「All user messages」那段是逐字原文、而不是模型转述的原因。回填完再套上续接指令壳开头加summaryPreamble「延续自此前对话」结尾加continueInstruction「从中断处继续不要再问用户」。一个可续接的摘要消息就齐了。最后还有一个不起眼但关键的标记。这条摘要消息会被打上constextraKeyContentType_eino_summarization_content_type// 摘要消息Extra[_eino_summarization_content_type] summary它的作用是让下一轮压缩时识别出「这是上一轮的摘要不是真实用户消息」实验 E摘要消息标记 contentType下一轮识别为 internal 摘要消息 RoleuserExtra[_eino_summarization_content_type]summary isInternalUserMessage(摘要消息) true 下一轮历史 [system, 摘要消息]真实用户消息计数 0摘要不计入 → 再次触发摘要时上一轮的摘要不会被当成真实用户消息回填没有这个标记会怎样第二次压缩时会把第一次的摘要当成一条「用户消息」再回填进新的摘要第三次又把前两次的摘要塞进去……压缩的压缩的压缩滚雪球直到全乱。一个 Extra 字段堵住了这个口子。五、真实项目怎么用DeepFlux 的旁路存储 vs ADK 的就地替换读到这儿你可能会问DeepFlux 生产里用了这个中间件吗没有。它在server/internal/agent/application/command/run_turn_session_summary.go自己写了一套走的是完全不同的路线。文件头注释说得很清楚// run_turn_session_summary.go// 长会话累积大量 history 后滑动窗口截断context_window.go会丢失中间语义。// 本文件在每轮 turn 结束后检查是否到达摘要阈值到达则异步调一次非流式 LLM 做压缩// 摘要结果写入 ContextSummaryRepo供下一次 buildLLMReq 时注入 system prompt。//// 设计约束// - 摘要生成是 fire-and-forget失败不阻断主流程// - llm 字段 nil未注入 LLMClient时直接跳过两者的核心差异在「摘要之后怎么办」维度ADK summarization 中间件DeepFlux run_turn_session_summary摘要对象替换整个对话历史另存仓库历史用滑动窗口照常截断触发token(默认16万) 或 消息条数消息条数默认 20可配long_term_summary_everysystem 处理自动分离不压缩摘要注入 system prompt用户原话all_user_messages回填 30k token无纯文本摘要重试/故障转移Retry Failover 两层无fire-and-forget失败只记日志可观测三种 internal eventslog.Warn跨会话—摘要双写进 memorieskindsummary可被 Recall 召回这张表里最值得看的是最后一行——这是 DeepFlux 独有的、ADK 中间件没有的设计。dualWriteSummaryMemory把每次会话摘要额外写一条kindsummary的记忆// run_turn_session_summary.go · dualWriteSummaryMemory// 把会话摘要双写一份进 memorieskindsummary让 KindSummary 从孤儿枚举归位、// 摘要可被 Recall 统一召回。ns:[]string{user,userID,session,string(sessionID)}h.memory.WriteMemory(ctx,tenantID,userID,ns,memoryKindSummary,content,tags)为什么要双写因为 ADK 的摘要是「就地把长历史压短」只服务当前这一个长会话而 DeepFlux 是个多租户 SaaS 产品用户会开很多会话希望 Agent 跨会话记住「我们之前聊过什么」。把摘要写进 memories第 81 篇《用户画像》讲的那套 Recall 召回就能在用户开新会话时把它捞回来——短期压缩和长期记忆在这里打通了。DeepFlux 还设计了「三层混合上下文」layer1 摘要 layer2 关键词 LIKE 召回相关历史但源码里有个诚实的标记// ponytail: false集成测试 PASS 后翻转为 truehybridCtxEnabledfalse三层混合上下文写好了但默认关着等集成测试通过再开。这是个很真实的工程状态——功能可以先写、先不上用开关控制灰度。ADK 这边也有个 DeepFlux 没有的精巧设计FinalizerBuilder.PreserveSkills。它可以链式挂一个 handler扫历史里的 skill 工具调用把 skill 内容比如某个框架的编码规范原文保留在摘要前面最多 5 个、每个 5000 token、总预算 25000 token// finalizer_builder.go// finalizer, err : NewFinalizer().// PreserveSkills(PreserveSkillsConfig{}). // 保留 skill 原文// Build()为什么 skill 要原文保留因为 skill 是「行为准则」第 79 篇讲过的蒸馏产出也是同类压进摘要会失真必须整段留着。这也是它和「回填用户原话」同一个思路——关键信息不信任模型的复述能力宁可占点 token 也要留原文。两种哲学两种产品最后说一句为什么 DeepFlux 没直接用 ADK 中间件。这不是「谁好谁坏」是两种哲学对应两种产品形态ADK 就地替换哲学历史太长就压成摘要替换掉下一轮从摘要继续。适合「一个会话撑很久」的场景——典型就是编码 Agent你跟它在一个任务里聊几小时。DeepFlux 旁路存储哲学历史不动滑动窗口截断摘要另存仓库注入 system还能双写进长期记忆。适合「很多会话要互相记」的场景——SaaS 产品用户开无数个会话Agent 得跨会话记得你。ADK 中间件是优秀的通用件但它的「替换历史」语义和 DeepFlux 的「跨会话长期记忆」目标对不上——换掉历史就没东西可双写进 memories 了。所以 DeepFlux 选择了旁路存储牺牲了 ADK 那套成熟的「回填用户原话 / RetryFailover / PreserveSkills」换来了和第 81 篇记忆系统的打通。这是典型的架构权衡通用件的便利换领域特定集成的深度。小结ADK summarization 中间件把「上下文压缩」做成可插拔件入口BeforeModelRewriteState三步走「判断 → 压缩 → 替换历史」对 Agent 主循环透明。本对话开头那段总结就是这套机制的输出模板token 估算用「基线 增量」找最后一条 assistant 自报的TotalTokens当基线之后的增量按字符数/4粗估——精确的数交给模型自己报不重算全量摘要 prompt 死命令「不要调工具」摘要模型往往挂着完整工具集不拦住它会跑去执行而不是压缩用户原话必须回填all_user_messages占位符被替换成真实近期用户消息≤30k token因为用户原话是意图锚点不能靠 LLM 复述。摘要消息打contentTypesummary标记下一轮识别为内部消息避免「压缩的压缩」滚雪球DeepFlux 走旁路存储路线摘要另存仓库注入 system、双写进 memories 让跨会话 Recall 召回。两种哲学就地替换 vs 旁路存储对应两种产品单长会话 vs 多会话 SaaS不是谁好谁坏下一篇讲上下文窗口真不够用时怎么办——三层记忆优先级 裁剪策略ADK 里那个 66KB 的reduction包干的就是这件事。代码状态说明本篇 demo 真跑过输出原样粘贴自/tmp/e82demo/main.gogo run .go1.26.4 darwin/arm64。demo 复刻了defaultTypedTokenCounter、shouldSummarize、splitSystemAndContextMsgs、buildSummarizationModelInput、replaceUserMessagesInSummary、postProcessSummary、contentTypeSummary标记的核心逻辑去掉了 adk 泛型 / 工具 token / 多模态 / RetryFailover / 完整 9 段 prompt 依赖摘要 LLM 输出用fakeSummarize()模拟无真实模型调用。没在 demo 里实测的部分Retry / Failover 两层容错指数退避 故障转移只做源码阅读demo 里fakeSummarize恒成功没构造失败场景PreserveSkillsskill 原文保留只做源码阅读demo 没有工具调用历史可扫三种 internal eventbefore/generate/after只做源码阅读一个我注意到但未深究的边界源码getTriggerContextTokens在Trigger ! nil时直接返回ContextTokens即便为 0若只配ContextMessages而ContextTokens0token 判断会因tokens 0恒真而误触发——demo 回避了这一点B3 实验把ContextTokens设成大值。这可能是源码的有意行为或边界缺陷我没有下结论留给读者按实际配置评估DeepFlux 的「三层混合上下文」hybridCtxEnabled false默认关源码注释「集成测试 PASS 后翻转」我没跑过开启态引用源码eino/adk/middlewares/summarization/{summarization,consts,prompt,customized_action,finalizer_builder}.go、server/internal/agent/application/command/run_turn_session_summary.go。版本为当前 main 分支。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价