从零推导 Agent Summarization Middleware
1. 第一性原理:最原初的业务问题是什么?
自动摘要 解决的最核心、最原初的问题是:大模型(LLM)的上下文窗口是有限的,且 Token 是要收费的。
当一个 Agent 与用户(或其它 Agent)对话了几百个回合,或者调用工具返回了巨大的 JSON 结果时,历史消息(History)会无限膨胀,最终导致:
- 超过 LLM 的
MaxTokens限制,直接报错。 - 即使没超限,冗长的历史会让大模型"失焦"(Lost in the Middle),忘记最初的任务。
- 每次请求都要带上全部历史,API 成本极高。
如果不使用任何框架,用最朴素、最硬编码的方式解决这个问题,代码长这样:
go
// The Naive Approach: 暴力截断或定期总结
func RunAgent(history []Message) {
// 每次调模型前,先看看历史是不是太长了
if len(history) > 100 {
// 1. 拼出一个要求总结的 Prompt
prompt := "请总结以下对话历史,提取核心意图、已完成步骤和待办事项:" + concat(history)
// 2. 调一个便宜/快速的模型来写总结
summaryText := cheapLLM.Generate(prompt)
// 3. 用这段总结替换掉之前所有的长篇大论
history = []Message{
{Role: "system", Content: "你的系统人设..."},
{Role: "user", Content: "前情提要:" + summaryText}, // 压缩后的历史
}
}
// 继续正常执行主模型
mainLLM.Generate(history)
}
2. 第一次演进:更精确的触发机制与无感拦截 (Middleware)
痛点 :
上面的朴素代码有三个致命问题:
- 触发条件太粗糙 :用
len(history) > 100(消息条数)来判断是不准确的。一条包含巨大 JSON 的消息可能直接打爆 Token。 - 业务代码强耦合 :总结逻辑深深地嵌在了业务的
RunAgent循环里,一旦要换个判断条件或者总结模型,业务代码就得大改。 - "偷换历史"的时机 :我们应该在什么时候去"偷换"历史?如果我们在模型输出之后换,可能会丢失最新状态;最好的时机是在每次主模型读取 State 之前。
抽象方案 :
我们需要引入两个基础抽象:Token 计数器 和 拦截器(Middleware)。
go
// 引入第一次抽象后的本质伪代码
// 1. 抽象出一个 Token 计算器,通过字数或者 Tiktoken 来估算
func CountTokens(history []Message) int {
var total int
for _, msg := range history {
total += len(msg.Content) / 4 // 粗略估算
}
return total
}
// 2. 引入中间件模式,在模型读取 State 之前进行拦截
type SummarizeMiddleware struct {
Threshold int
Summarizer LLM
}
// 这是一个 Hook,在主模型执行前触发
func (m *SummarizeMiddleware) BeforeModelRewriteState(state *State) {
// 精确判断是否超限
if CountTokens(state.Messages) < m.Threshold {
return // 没超,放行
}
// 拦截并改写 State
summary := m.Summarizer.Generate("总结这些:" + concat(state.Messages))
// 清空历史,只留下 System Prompt 和 压缩后的 Summary
state.Messages = []Message{
GetSystemMsg(state.Messages),
{Role: "user", Content: summary},
}
}
通过中间件(Middleware),业务代码 RunAgent 完全不知道总结的发生,它只是每次去读 State,发现历史永远都不会超长。
3. 第二次演进:解决"语义丢失"与"用户指令漂移"
痛点 :
当我们把几十轮对话压缩成一段生硬的 Summary(比如:"用户想查天气,你查了北京天气,然后用户说换成上海"),主模型接手后会遇到两个棘手的新问题:
- 用户指令的语气丢失:压缩后的 Summary 通常是第三人称的客观陈述。如果用户在最后一句话里带有强烈的情绪或特定的格式要求(如"必须用 JSON 并且用大写返回"),大模型在看 Summary 时,可能会忽略这些要求。
- 连续的工具调用中断:如果之前正在进行一个复杂的多步工具调用(Plan-Execute),暴力的总结会把工具调用的中间状态(ToolCall ID 等)抹除,导致任务链断裂。
抽象方案 :
引入结构化的总结策略 (如 PreserveUserMessages),在总结中强制保留最近的、最关键的原始 User Message,并规范化输入输出的 Prompt 组装。
go
// 引入更高级抽象后的本质伪代码
func (m *SummarizeMiddleware) BeforeModelRewriteState(state *State) {
if !ShouldSummarize(state) { return }
// 1. 不是简单的 concat,而是结构化地让模型去总结
summaryInput := BuildSummarizationPrompt(state.Messages)
summaryText := m.Summarizer.Generate(summaryInput)
// 2. 关键演进:在组装新历史时,不仅要放入 Summary,还要把最近的原始 User Message 拼回来!
recentUserMsgs := ExtractRecentUserMessages(state.Messages, MaxTokens=2000)
// 3. 把客观的总结和鲜活的用户指令融合
finalContent := fmt.Sprintf(`
[前情提要]
%s
[最近的用户原始指令]
%s
请继续处理用户的最新诉求。
`, summaryText, recentUserMsgs)
state.Messages = []Message{
GetSystemMsg(state.Messages),
{Role: "user", Content: finalContent},
}
}
通过这种**混合保留(Summary + Raw User Msg)**的机制,既压缩了 Token,又保住了最近对话的"原汁原味"。
4. 映射到真实源码 (Mapping to Reality)
回到 adk/middlewares/summarization/summarization.go 源码,我们会发现它极其严谨地实现了我们推导的最后一步,并在工程上做了大量"脏活累活"的封装。
- 核心 Hook 的实现 :
源码中的BeforeModelRewriteState(L146-L227) 就是我们推导的中间件拦截器。它严格遵循了:检查触发条件 -> 拆分 System/Context 消息 -> 调模型生成 Summary -> 后处理合并 -> 改写 State的生命周期。 - 精细的 Token 计数机制 :
源码中的defaultTokenCounter(L271-L290) 不仅计算了Messages的长度,还极其严谨地把当前挂载的Tools(工具的 Schema JSON)也序列化并算入了 Token 消耗中。因为在复杂的 Agent 中,几十个 Tool 的描述本身就是一个巨大的 Token 消耗大户。 - 高阶特性:PreserveUserMessages (保留用户原声) :
源码中的replaceUserMessagesInSummary(L383-L450) 就是为了解决"语义丢失"痛点的终极武器。它会在触发总结时,从尾部倒序捞取没有超过MaxTokens的真实 User Message,然后替换掉 Summary 中由模型生成的<all_user_messages>标签位,完美实现了"前情提要是总结的,但最新诉求是原声的"。 - 可观测性 (EmitInternalEvents) :
在真实的生产环境中,开发者需要知道"我的 Agent 为什么突然卡了一下?是不是在做总结?" 源码在总结前后通过m.emitEvent(L168-L175) 发射了内部事件,使得外层的链路追踪(Tracing)或前端 UI 可以展示"正在压缩上下文..."的提示。
5. 补充思考:总结摘要 与 Function Calling 的严格格式冲突吗?
在 patchtoolcalls.md 中我们推导过,大模型对 Function Calling 的历史记录有极其严格的格式洁癖(Assistant 发起调用后,必须紧跟 Tool 的结果)。那么,summarization 中间件把这堆消息揉成一团,会不会导致大模型 API 报错?
答案是:不仅不会冲突,反而起到了"清道夫"的作用。
- 彻底重置状态机 :当触发总结时,中间件会将整个
[Assistant(Call) -> Tool(Return)]链路全部从state.Messages中抹除。既然发起调用的Assistant消息已经不在历史记录里了,大模型 API 自然就不会再去校验它是否闭环。 - 角色降维安全 :生成的总结默认被封装成了
User角色 (L342-L349)。它把复杂的"多轮工具交互"降维成了一段"用户提供的背景描述"。新历史以System + User(Summary)开头,这对于任何大模型来说都是一个绝对合法的初始状态。 - 只留原声,不留动作 :即使开启了
PreserveUserMessages,源码也极其谨慎地仅筛选User角色 的消息追加到结尾(L389-L391),绝不保留任何Assistant或Tool消息,从而在物理上杜绝了悬空工具调用(Dangling ToolCall)的产生。
6. 批判性总结 (Critical Trade-offs)
优势
- 完全解耦,业务无感 :作为
ChatModelAgentMiddleware,它可以热插拔。业务逻辑层完全不需要关心 Context Window 爆炸的问题,专注于写业务即可。 - 策略细腻 :
PreserveUserMessages的设计非常出彩,它是对纯暴力总结的一种极好的工程妥协,显著提高了长程对话中模型对最新指令的服从度。
代价与局限
- 总结的"有损压缩"本质 :无论总结模型多强,从
N个 Token 压缩到M个 Token,必然伴随信息熵的丢失。如果在几十轮对话前,用户提供了一个精确的配置 ID,而总结模型认为它"不重要"给丢弃了,主模型后续将永远无法找回这个 ID(除非开启检索)。 - 总结耗时导致的 P99 毛刺:当对话刚好跨过 190k Token 阈值时,用户发出一句话,系统会先去调一次模型生成 Summary,再调主模型回复。这一轮的响应时间(Latency)会突然飙升,造成体验上的毛刺。
更优解的探讨
为了解决"有损压缩"和"耗时毛刺"的问题,现代 Agent 架构的更优解是走向记忆外置化与异步化:
- 异步化:不要在关键的请求链路上同步做 Summarize。可以在对话结束的闲时,由后台 Worker 异步对历史数据进行总结和打标。
- RAG + 记忆库(Mem0 等模式):不把所有的 History 硬塞给模型。而是把历史对话实时写入向量数据库(Vector DB)或图数据库(Graph DB)。每次主模型响应时,只带上最近 3-5 轮的短上下文,并结合当前的 Query 动态检索出相关的历史记忆。这样既永远不会超 Token,又不会丢失细节信息。