从零推导 Agent Reduction Middleware (上下文缩减)
拨开迷雾看本质:从零推导 Agent Reduction Middleware (上下文缩减)
1. 第一性原理:工具调用的“输入/输出爆炸”危机
抛开中间件,这个功能解决的最核心、最原初的业务问题是:工具调用的参数或返回结果过大,瞬间打爆 LLM 的上下文窗口。
与 summarization(解决“多轮对话历史逐渐变长”)不同,reduction 解决的是“单点爆炸”:
- 输入爆炸 (Argument Bloat):模型写了一段 10 万行的代码作为参数,传给了
compile_code工具。 - 输出爆炸 (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 中,并提供了极高的自定义扩展性。
-
第一阶段:Truncation Phase (拦截单次巨大产出)
-
第二阶段:Clear Phase (清理历史包袱)
- 切面注入:它拦截了
BeforeModelRewriteState(L386-L518)。这意味着它是在所有历史数据准备就绪、即将发给大模型前介入的。 - 核心逻辑:首先估算 Token(连 Tools 的 JSON Schema 都算进去了)。如果超过
MaxTokensForClear,就跳过最近的ClearRetentionSuffixLimit次调用,然后把更早的schema.Assistant(里的 Arguments)和schema.Tool(里的 Result)全部掏空,调用ClearHandler离线存储。
- 切面注入:它拦截了
-
工程上的精细封装:Tool 级别的颗粒度
- 源码引入了
ToolConfig(L98)。你可以为不同的工具配置不同的清理策略。例如,对于python_interpreter工具,你可能希望它的报错信息永远不被截断;而对于search_web工具,你希望只保留前 1000 字。
- 源码引入了
5. 批判性总结 (Critical Trade-offs)
优势
- 完美的内存/上下文双重保护:Truncation 保护了单次调用的显存/Token,Clear 保护了长程历史的 Token 累积,形成了一个极其坚固的防御网。
- “离线借条”模式的优雅:它不是粗暴地删除,而是用文件路径替换了庞大的 Payload。只要给 Agent 配备了
read_file工具,Agent 随时可以通过“借条”找回被清理的历史数据,实现了逻辑上的“无限上下文”。
代价与局限
- 依赖外部文件系统与清理机制:由于所有的截断和清理都会向
Backend写入真实文件(通常是本地/tmp或对象存储),如果 Agent 运行极其频繁,会产生海量的垃圾小文件。目前中间件没有提供自动的垃圾回收 (GC) 机制,需要业务方自行维护生命周期清理。 - 大模型的“阅读理解”能力要求极高:当大模型看到
完整数据过长,已保存至路径: X时,它必须足够聪明,知道主动去调用read_file工具。对于小参数模型(如 7B 级别),它们往往会忽略这句话,或者产生幻觉,假装自己看到了完整数据开始瞎编。
更优解的探讨
目前的 Clear Phase 是一种“被动防御”:等 Token 满了再去掏空历史。
更现代的 Agent 框架(如 LangGraph 的状态管理)倾向于主动的状态投影(State Projection):
不把工具调用的原始结果(几十 KB 的 JSON)直接放进对话历史里。而是工具执行完后,先通过一个轻量级的纯代码函数(Reducer/Extractor),把 JSON 里真正对对话有用的 3 个字段提取出来放进历史,把完整的 JSON 挂载到独立于 LLM 上下文的 Context KV 中。这样从源头上就避免了对话历史的臃肿。
更多推荐

所有评论(0)