从零推导 Agent Summarization Middleware

从零推导 Agent Summarization Middleware

1. 第一性原理:最原初的业务问题是什么?

自动摘要 解决的最核心、最原初的问题是:大模型(LLM)的上下文窗口是有限的,且 Token 是要收费的。

当一个 Agent 与用户(或其它 Agent)对话了几百个回合,或者调用工具返回了巨大的 JSON 结果时,历史消息(History)会无限膨胀,最终导致:

  1. 超过 LLM 的 MaxTokens 限制,直接报错。
  2. 即使没超限,冗长的历史会让大模型"失焦"(Lost in the Middle),忘记最初的任务。
  3. 每次请求都要带上全部历史,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)

痛点

上面的朴素代码有三个致命问题:

  1. 触发条件太粗糙 :用 len(history) > 100(消息条数)来判断是不准确的。一条包含巨大 JSON 的消息可能直接打爆 Token。
  2. 业务代码强耦合 :总结逻辑深深地嵌在了业务的 RunAgent 循环里,一旦要换个判断条件或者总结模型,业务代码就得大改。
  3. "偷换历史"的时机 :我们应该在什么时候去"偷换"历史?如果我们在模型输出之后换,可能会丢失最新状态;最好的时机是在每次主模型读取 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(比如:"用户想查天气,你查了北京天气,然后用户说换成上海"),主模型接手后会遇到两个棘手的新问题:

  1. 用户指令的语气丢失:压缩后的 Summary 通常是第三人称的客观陈述。如果用户在最后一句话里带有强烈的情绪或特定的格式要求(如"必须用 JSON 并且用大写返回"),大模型在看 Summary 时,可能会忽略这些要求。
  2. 连续的工具调用中断:如果之前正在进行一个复杂的多步工具调用(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 源码,我们会发现它极其严谨地实现了我们推导的最后一步,并在工程上做了大量"脏活累活"的封装。

  1. 核心 Hook 的实现
    源码中的 BeforeModelRewriteState (L146-L227) 就是我们推导的中间件拦截器。它严格遵循了:检查触发条件 -> 拆分 System/Context 消息 -> 调模型生成 Summary -> 后处理合并 -> 改写 State 的生命周期。
  2. 精细的 Token 计数机制
    源码中的 defaultTokenCounter (L271-L290) 不仅计算了 Messages 的长度,还极其严谨地把当前挂载的 Tools(工具的 Schema JSON)也序列化并算入了 Token 消耗中。因为在复杂的 Agent 中,几十个 Tool 的描述本身就是一个巨大的 Token 消耗大户。
  3. 高阶特性:PreserveUserMessages (保留用户原声)
    源码中的 replaceUserMessagesInSummary (L383-L450) 就是为了解决"语义丢失"痛点的终极武器。它会在触发总结时,从尾部倒序捞取没有超过 MaxTokens 的真实 User Message,然后替换掉 Summary 中由模型生成的 <all_user_messages> 标签位,完美实现了"前情提要是总结的,但最新诉求是原声的"。
  4. 可观测性 (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),绝不保留任何 AssistantTool 消息,从而在物理上杜绝了悬空工具调用(Dangling ToolCall)的产生。

6. 批判性总结 (Critical Trade-offs)

优势

  1. 完全解耦,业务无感 :作为 ChatModelAgentMiddleware,它可以热插拔。业务逻辑层完全不需要关心 Context Window 爆炸的问题,专注于写业务即可。
  2. 策略细腻PreserveUserMessages 的设计非常出彩,它是对纯暴力总结的一种极好的工程妥协,显著提高了长程对话中模型对最新指令的服从度。

代价与局限

  1. 总结的"有损压缩"本质 :无论总结模型多强,从 N 个 Token 压缩到 M 个 Token,必然伴随信息熵的丢失。如果在几十轮对话前,用户提供了一个精确的配置 ID,而总结模型认为它"不重要"给丢弃了,主模型后续将永远无法找回这个 ID(除非开启检索)。
  2. 总结耗时导致的 P99 毛刺:当对话刚好跨过 190k Token 阈值时,用户发出一句话,系统会先去调一次模型生成 Summary,再调主模型回复。这一轮的响应时间(Latency)会突然飙升,造成体验上的毛刺。

更优解的探讨

为了解决"有损压缩"和"耗时毛刺"的问题,现代 Agent 架构的更优解是走向记忆外置化与异步化

  • 异步化:不要在关键的请求链路上同步做 Summarize。可以在对话结束的闲时,由后台 Worker 异步对历史数据进行总结和打标。
  • RAG + 记忆库(Mem0 等模式):不把所有的 History 硬塞给模型。而是把历史对话实时写入向量数据库(Vector DB)或图数据库(Graph DB)。每次主模型响应时,只带上最近 3-5 轮的短上下文,并结合当前的 Query 动态检索出相关的历史记忆。这样既永远不会超 Token,又不会丢失细节信息。
相关推荐
Alvin千里无风4 小时前
在 Ubuntu 上从源码安装 Nanobot:轻量级 AI 助手完整指南
linux·人工智能·ubuntu
环黄金线HHJX.4 小时前
龙虾钳足启发的AI集群语言交互新范式
开发语言·人工智能·算法·编辑器·交互
Omics Pro4 小时前
虚拟细胞:开启HIV/AIDS治疗新纪元的关键?
大数据·数据库·人工智能·深度学习·算法·机器学习·计算机视觉
悦来客栈的老板5 小时前
AI逆向|猿人学逆向反混淆练习平台第七题加密分析
人工智能
KOYUELEC光与电子努力加油5 小时前
JAE日本航空端子推出支持自走式机器人的自主充电功能浮动式连接器“DW15系列“方案与应用
服务器·人工智能·机器人·无人机
萤火阳光5 小时前
13|自定义 Skill 创作:打造专属自动化利器
人工智能
我哪会这个啊5 小时前
SpringAlibaba Ai基础入门
人工智能
lifewange6 小时前
Go语言-开源编程语言
开发语言·后端·golang
白毛大侠6 小时前
深入理解 Go:用户态和内核态
开发语言·后端·golang
tianbaolc6 小时前
Claude Code 源码剖析 模块一 · 第六节:autoDream 自动记忆整合
人工智能·ai·架构·claude code