拨开迷雾看本质:从零推导 Agent Reduction Middleware (上下文缩减)

1. 第一性原理:工具调用的“输入/输出爆炸”危机

抛开中间件,这个功能解决的最核心、最原初的业务问题是:工具调用的参数或返回结果过大,瞬间打爆 LLM 的上下文窗口。

summarization(解决“多轮对话历史逐渐变长”)不同,reduction 解决的是“单点爆炸”:

  1. 输入爆炸 (Argument Bloat):模型写了一段 10 万行的代码作为参数,传给了 compile_code 工具。
  2. 输出爆炸 (Result Bloat):模型调用了 search_database,工具直接返回了一个 50MB 的巨大 JSON 字符串。

如果不用任何框架,我们最朴素的处理方式是直接在业务代码里强行截断:

// The Naive Approach: 暴力截断工具结果
func ExecuteTool(name string, args string) string {
    result := myTools[name](args)
    
    // 如果结果太大,直接咔嚓掉后面的
    if len(result) > 50000 {
        return result[:50000] + "...(数据太大,已截断)"
    }
    return result
}

2. 第一次演进:解决“截断导致的信息永久丢失” (Truncation & Offload)

痛点
暴力截断看似解决了 Token 爆炸,但引入了一个致命的逻辑漏洞:被截断的那部分数据(比如 JSON 的后半段,或者长文章的结尾)永远丢失了。如果大模型刚好需要被截断那部分的数据来回答问题,它将永远无法完成任务,甚至会因为 JSON 结构不完整而反复报错重试。

抽象方案
我们需要引入一种 “离线存储 (Offload)” + “按需加载 (Retrieve)” 的机制。当数据过大时,我们不是直接丢弃,而是把它存入一个后端存储(文件系统/对象存储),并告诉大模型:“数据太大了,我把完整版存在了文件 X 里,这里只给你看前 5 万个字。如果你需要完整数据,请调用 read_file 工具去读那个文件”。

// 引入第一次抽象后的本质伪代码 (Truncation Phase)

type Backend interface {
    Write(path string, content string) error
}

func ExecuteToolWithOffload(backend Backend, name string, args string) string {
    result := myTools[name](args)
    
    if len(result) > 50000 {
        // 1. 生成一个唯一的文件路径
        filePath := "/tmp/trunc/" + generateUUID()
        
        // 2. 将完整结果离线存储 (Offload)
        backend.Write(filePath, result)
        
        // 3. 将“截断的头尾”和“读取指南”返回给大模型
        notice := fmt.Sprintf(`
            ... (中间数据省略)
            完整数据过长,已保存至路径: %s。
            如果需要分析完整内容,请调用 read_file 工具读取该路径。
        `, filePath)
        
        return getPreview(result) + notice
    }
    return result
}

这也就是 Eino reduction 中间件的第一阶段:Truncation Phase(截断阶段)

3. 第二次演进:解决“历史包袱的温水煮青蛙” (Clear Phase)

痛点
上面的机制解决了“单次巨大输出”的问题。但是,如果每次工具调用只返回 1 万字(没有触发 5 万字的截断阈值),大模型连续调用了 10 次工具,历史记录里就会堆积 10 万字的工具返回结果,最终依然会打爆上下文。
此时我们不能用 summarization 中间件去总结它,因为工具调用的精确参数和 JSON 结构,一旦被总结成自然语言,就会丢失精确语义

抽象方案
我们需要引入第二阶段:Clear Phase(清空阶段)
当历史记录总 Token 即将超限时,我们回溯历史,把那些过去发生过的、目前不再需要的工具调用的内容给掏空(即 Mask 处理),只保留最近几次的工具调用。

为什么是掏空(Mask)而不是直接删除(Delete)?
正如我们在 从生产级Agent理解Function calling中推导的,大模型对 Function Calling 有极其严格的格式洁癖(Schema Validation)。如果直接把某个旧的 ToolCall 从历史中删掉,或者只删了 Tool 消息不删 Assistant 的发起调用,整个对话链路就断裂了,会导致 API 报错。
因此,最优雅的做法是保留骨架,掏空血肉:保持 Assistant(Call) -> Tool(Result) 的结构不变,只是把里面巨大的 JSON 结果替换成一个轻量级的“文件指针”(借条)。

// 引入第二次抽象后的本质伪代码 (Clear Phase)

// 在发送给大模型之前执行
func BeforeModelGenerate(history []Message) {
    if countTokens(history) > 30000 {
        // 倒序遍历,保留最近 1 次的工具调用不动 (RetentionSuffixLimit=1)
        for i := 0; i < len(history) - 2; i++ {
            msg := history[i]
            
            if msg.Role == "tool" {
                // 1. 将历史结果存入文件 (Offload)
                filePath := "/tmp/clear/" + msg.ToolCallID
                backend.Write(filePath, msg.Content)
                
                // 2. 掏空历史消息,只留下一张“借条” (Masking)
                history[i].Content = "历史结果已归档至: " + filePath
            }
            // 注意:对于 Assistant 发起的 ToolCall 结构 (Arguments),默认保留以维持语境,
            // 但如果 Arguments 也极其巨大,同样可以掏空存入文件。
        }
    }
}

这种做法保证了历史记录的结构(Schema)完全合法,但里面的巨大负载被全部替换成了轻量级的“文件指针”。

4. 映射到真实源码 (Mapping to Reality)

Eino 的 adk/middlewares/reduction/reduction.go 极其优雅地将这两个阶段融合在了一个 Middleware 中,并提供了极高的自定义扩展性。

  1. 第一阶段:Truncation Phase (拦截单次巨大产出)

    • 切面注入:它拦截了 WrapInvokableToolCallWrapStreamableToolCall (L276, L320)。这意味着它是在工具刚刚执行完毕、还没有写入历史记录时立刻介入的。
    • 核心逻辑:如果结果过长,调用 defaultTruncHandler (L567-L598),把完整内容写入 Backend,并将返回给模型的文本替换为“首尾预览 + 文件路径提示”。
  2. 第二阶段:Clear Phase (清理历史包袱)

    • 切面注入:它拦截了 BeforeModelRewriteState (L386-L518)。这意味着它是在所有历史数据准备就绪、即将发给大模型前介入的。
    • 核心逻辑:首先估算 Token(连 Tools 的 JSON Schema 都算进去了)。如果超过 MaxTokensForClear,就跳过最近的 ClearRetentionSuffixLimit 次调用,然后把更早的 schema.Assistant(里的 Arguments)和 schema.Tool(里的 Result)全部掏空,调用 ClearHandler 离线存储。
  3. 工程上的精细封装:Tool 级别的颗粒度

    • 源码引入了 ToolConfig (L98)。你可以为不同的工具配置不同的清理策略。例如,对于 python_interpreter 工具,你可能希望它的报错信息永远不被截断;而对于 search_web 工具,你希望只保留前 1000 字。

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

优势

  1. 完美的内存/上下文双重保护:Truncation 保护了单次调用的显存/Token,Clear 保护了长程历史的 Token 累积,形成了一个极其坚固的防御网。
  2. “离线借条”模式的优雅:它不是粗暴地删除,而是用文件路径替换了庞大的 Payload。只要给 Agent 配备了 read_file 工具,Agent 随时可以通过“借条”找回被清理的历史数据,实现了逻辑上的“无限上下文”。

代价与局限

  1. 依赖外部文件系统与清理机制:由于所有的截断和清理都会向 Backend 写入真实文件(通常是本地 /tmp 或对象存储),如果 Agent 运行极其频繁,会产生海量的垃圾小文件。目前中间件没有提供自动的垃圾回收 (GC) 机制,需要业务方自行维护生命周期清理。
  2. 大模型的“阅读理解”能力要求极高:当大模型看到 完整数据过长,已保存至路径: X 时,它必须足够聪明,知道主动去调用 read_file 工具。对于小参数模型(如 7B 级别),它们往往会忽略这句话,或者产生幻觉,假装自己看到了完整数据开始瞎编。

更优解的探讨

目前的 Clear Phase 是一种“被动防御”:等 Token 满了再去掏空历史。
更现代的 Agent 框架(如 LangGraph 的状态管理)倾向于主动的状态投影(State Projection)
不把工具调用的原始结果(几十 KB 的 JSON)直接放进对话历史里。而是工具执行完后,先通过一个轻量级的纯代码函数(Reducer/Extractor),把 JSON 里真正对对话有用的 3 个字段提取出来放进历史,把完整的 JSON 挂载到独立于 LLM 上下文的 Context KV 中。这样从源头上就避免了对话历史的臃肿。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐