06中间件:给 Agent 叠能力
中间件「叠加」心智;filesystem / plantask / summarization / skill / toolsearch 速览。
adk/handler.goadk/middlewares/filesystem/filesystem.go:173不改核心,给 Agent 叠能力
前五章的 Agent 已经会用模型、调工具、记忆、中断续跑。但真实项目里,你还会不断冒出新需求:让它能读写文件、能把超长历史自动压缩、能先规划任务再执行、能动态检索该用哪个工具……
如果每来一个需求就去改 ChatModelAgent 的核心逻辑,这个类会迅速膨胀成谁都不敢碰的怪物。Eino 的答案是中间件:核心保持不变,能力像叠罗汉一样一层层叠上去。
先感受一下叠加的样子:
agent, _ := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{ Name: "coder", Instruction: "你是一名工程师,可以读写文件、规划任务。", Model: chatModel, Handlers: []adk.ChatModelAgentMiddleware{ filesystemMW, // 叠加:文件系统工具 plantaskMW, // 叠加:任务规划 summarizationMW, // 叠加:超长历史自动摘要 },})核心的 Agent 一行没改,三种能力却都长上去了。这一章拆开这套「叠加」机制。
flowchart TB REQ["一次运行"] --> S["summarizationMW<br/>超长历史自动摘要"] S --> P["plantaskMW<br/>任务规划"] P --> F["filesystemMW<br/>文件系统工具"] F --> CORE["ChatModelAgent 核心<br/>(一行未改)"] CORE --> F F --> P P --> S
核心不变,能力一层层叠上去
中间件是什么:九个钩子
现代中间件接口是 TypedChatModelAgentMiddleware[M](adk/handler.go:139),默认别名 ChatModelAgentMiddleware(adk/handler.go:259)。它不是一个笼统的 Handle(),而是把 Agent 一轮运行里的九个关键时刻都开了口子:
// TypedChatModelAgentMiddleware defines the interface for customizing TypedChatModelAgent behavior.//// IMPORTANT: This interface is specifically designed for TypedChatModelAgent and agents built// on top of it (e.g., DeepAgent).//// Why TypedChatModelAgentMiddleware instead of AgentMiddleware?//// AgentMiddleware is a struct type, which has inherent limitations:// - Struct types are closed: users cannot add new methods to extend functionality// - The framework only recognizes AgentMiddleware's fixed fields, so even if users// embed AgentMiddleware in a custom struct and add methods, the framework cannot// call those methods (config.Middlewares is []AgentMiddleware, not a user type)// - Callbacks in AgentMiddleware only return error, cannot return modified context//// TypedChatModelAgentMiddleware is an interface type, which is open for extension:// - Users can implement custom handlers with arbitrary internal state and methods// - Hook methods return (context.Context, ..., error) for direct context propagation// - Wrapper methods (WrapToolCall, WrapModel) enable context propagation through the// wrapped endpoint chain: wrappers can pass modified context to the next wrapper// - Configuration is centralized in struct fields rather than scattered in closures//// TypedChatModelAgentMiddleware vs AgentMiddleware:// - Use AgentMiddleware for simple, static additions (extra instruction/tools)// - Use TypedChatModelAgentMiddleware for dynamic behavior, context modification, or call wrapping// - AgentMiddleware is kept for backward compatibility with existing users// - Both can be used together; see AgentMiddleware documentation for execution order//// Use *TypedBaseChatModelAgentMiddleware as an embedded struct to provide default no-op// implementations for all methods.type TypedChatModelAgentMiddleware[M MessageType] interface { // BeforeAgent is called before each agent run, allowing modification of // the agent's instruction and tools configuration. BeforeAgent(ctx context.Context, runCtx *ChatModelAgentContext) (context.Context, *ChatModelAgentContext, error)
// AfterAgent is called after the agent run reaches a successful terminal state. // Successful terminal states are: final answer (model response with no tool calls), // and return-directly tool result. // // AfterAgent is NOT called when the agent terminates with an error (e.g., // ErrExceedMaxIterations, context cancellation, model errors). // // The state parameter contains the final conversation state, including all messages // from the completed run. // // AfterAgent handlers are called in the same order as BeforeAgent handlers // (first registered = first called). Consistent with all other middleware hooks, // if any handler returns an error, subsequent handlers are NOT called (fail-fast) // and the error is sent to the event stream.// … 省略 98 行;完整声明 L110–255,点击上方「浏览完整文件」// ChatModelAgentMiddleware is the default middleware type using *schema.Message.// See TypedChatModelAgentMiddleware for full documentation.type ChatModelAgentMiddleware = TypedChatModelAgentMiddleware[*schema.Message]type TypedChatModelAgentMiddleware[M MessageType] interface { // Agent 级:进入/退出 BeforeAgent(...) // 改指令、改工具集 AfterAgent(...) // 成功收尾时观测
// 模型级:每次调模型前后(状态会被持久化) BeforeModelRewriteState(...) // 改消息、改工具列表——推荐在这里改 AfterModelRewriteState(...) // 拿到模型回复后加工
// 工具级:包裹一次工具执行 WrapInvokableToolCall(...) // 同步工具 WrapStreamableToolCall(...) // 流式工具 WrapEnhancedInvokableToolCall(...) // 增强版同步 WrapEnhancedStreamableToolCall(...) // 增强版流式
// 模型调用级:包裹模型本身 WrapModel(...) // 重试、降级、改温度、发进度事件}钩子的命名暴露了设计意图。带 Before/After 的是观测与改写点;带 Wrap 的是包裹点——它给你原始的执行端点(endpoint),你返回一个包了一层的新端点。Wrap 系列正是”洋葱模型”的体现:你的逻辑裹在真正的调用外面,调用前后你都能插手。
📝 改消息去 BeforeModelRewriteState,不要去 WrapModel
源码在
WrapModel的注释里明确劝退:不要在WrapModel里改输入消息。原因有二——(1)WrapModel的改动不会被持久化进 state,只影响单次模型调用;(2)它会打断 prompt 缓存。要改消息或工具列表,请用BeforeModelRewriteState(adk/handler.go:171),那里的改动才是状态的真相来源。WrapModel(adk/handler.go:254)只该做”围绕模型调用本身”的事:重试、故障切换、改温度、发流式进度。
只写你关心的钩子
九个钩子听着吓人,但你几乎不会全部实现。诀窍是内嵌 TypedBaseChatModelAgentMiddleware[M](adk/handler.go:261),它为九个方法都提供了空操作(no-op)默认实现:
type TypedBaseChatModelAgentMiddleware[M MessageType] struct{}type LoggingMW struct { *adk.BaseChatModelAgentMiddleware // 内嵌:白拿八个 no-op 默认实现}
// 只覆写你真正关心的那一个func (m *LoggingMW) BeforeModelRewriteState( ctx context.Context, state *adk.ChatModelAgentState, mc *adk.ModelContext,) (context.Context, *adk.ChatModelAgentState, error) { log.Printf("即将调用模型,历史长度=%d", len(state.Messages)) return ctx, state, nil}内嵌之后,LoggingMW 自动满足整个接口,你只需写那一个真正有逻辑的方法。这是 Go 里”接口默认实现”的惯用法,ADK 用它把中间件的编写成本压到最低。
🔑 本章的设计钥匙
中间件的本质是用组合替代修改。核心
ChatModelAgent对所有能力一无所知,它只在九个固定时刻回调钩子;能力则被拆成一个个独立、可插拔、可复用的中间件。这带来三个直接好处:核心稳定(加能力不改核心)、能力正交(文件系统与摘要互不知晓)、顺序即语义(叠加的先后决定包裹的内外)。这正是”开闭原则”在 Agent 运行时里的落地——对扩展开放,对修改封闭。
顺序即语义:谁先注册,谁在最外层
多个中间件叠在一起,顺序不是无所谓的。对 Wrap 系列的包裹钩子而言:先注册的中间件在最外层。也就是说,请求进来时先经过它,响应出去时最后经过它——标准的洋葱模型。
这里还有一条历史包袱要知道:除了新的 Handlers(接口切片),Config 里还有一个已废弃的 Middlewares 结构体字段。执行顺序上,老的 Middlewares 整体先于新的 Handlers 运行。新代码只用 Handlers 即可,遇到老代码时记得这条优先级。
跨中间件传数据也有专门通道:SetRunLocalValue(adk/handler.go:338)/ GetRunLocalValue(adk/handler.go:366)。它们把值绑定到”当前这一次 Run”,而且——注意——能安全穿越中断/续跑:值会被序列化进 checkpoint,恢复时原样还原。这让中间件的状态与第 5 章的 HITL 机制天然兼容。
// SetRunLocalValue sets a key-value pair that persists for the duration of the current agent Run() invocation.// The value is scoped to this specific execution and is not shared across different Run() calls or agent instances.//// Values stored here are compatible with interrupt/resume cycles - they will be serialized and restored// when the agent is resumed. For custom types, you must register them using schema.RegisterName[T]()// in an init() function to ensure proper serialization.//// This function can only be called from within a ChatModelAgentMiddleware during agent execution.// Returns an error if called outside of an agent execution context.func SetRunLocalValue(ctx context.Context, key string, value any) error { if err := checkGobEncodability(key, value); err != nil { return err }
err := processTypedState(ctx, func(extra map[string]any) map[string]any { if extra == nil { extra = make(map[string]any) } extra[key] = value return extra }) if err != nil { return fmt.Errorf("SetRunLocalValue failed: must be called within a ChatModelAgent Run() or Resume() execution context: %w", err) }
return nil}// GetRunLocalValue retrieves a value that was set during the current agent Run() invocation.// The value is scoped to this specific execution and is not shared across different Run() calls or agent instances.//// Values stored via SetRunLocalValue are compatible with interrupt/resume cycles - they will be serialized// and restored when the agent is resumed. For custom types, you must register them using schema.RegisterName[T]()// in an init() function to ensure proper serialization.//// This function can only be called from within a ChatModelAgentMiddleware during agent execution.// Returns the value and true if found, or nil and false if not found or if called outside of an agent execution context.func GetRunLocalValue(ctx context.Context, key string) (any, bool, error) { var val any var found bool err := processTypedState(ctx, func(extra map[string]any) map[string]any { if extra != nil { val, found = extra[key] } return extra }) if err != nil { return nil, false, fmt.Errorf("GetRunLocalValue failed: must be called within a ChatModelAgent Run() or Resume() execution context: %w", err) } return val, found, nil}内置中间件速览:能力工具箱
ADK 在 adk/middlewares/ 下自带了一批开箱即用的中间件,每个都遵循同样的构造范式(泛型版 NewTyped[M] + 默认版 New)。挑几个高频的:
- filesystem(
adk/middlewares/filesystem/filesystem.go:395):给 Agent 一整套文件操作工具——ls / read / write / edit / glob / grep,还能把过大的结果转存、避免撑爆上下文。让 Agent 能”在项目里干活”的基础件。
// New constructs and returns the filesystem middleware as a ChatModelAgentMiddleware.//// This is the recommended constructor for new code. It returns a ChatModelAgentMiddleware which provides:// - Better context propagation through WrapInvokableToolCall and WrapStreamableToolCall methods// - BeforeAgent hook for modifying agent instruction and tools at runtime// - More flexible extension points compared to the struct-based AgentMiddleware//// The middleware provides filesystem tools (ls, read_file, write_file, edit_file, glob, grep)// and optionally an execute tool if the Backend implements ShellBackend or StreamingShellBackend.//// Example usage://// middleware, err := filesystem.New(ctx, &filesystem.Config{// Backend: myBackend,// })// agent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{// // ...// Handlers: []adk.ChatModelAgentMiddleware{middleware},// })func New(ctx context.Context, config *MiddlewareConfig) (adk.ChatModelAgentMiddleware, error) { return NewTyped[*schema.Message](ctx, config)}- plantask:注入 TaskCreate / Get / Update / List 一组任务管理工具,让 Agent 先把大任务拆成清单、逐条推进。复杂多步任务的骨架。
- summarization:当历史超过阈值(约 16 万 token)时自动摘要压缩,把长对话塞回上下文窗口。长会话的续命件。
- skill:加载
SKILL.md定义的技能,支持 inline / fork / fork_with_context 三种执行模式。把可复用的”工作流”注入 Agent。 - toolsearch:注入一个
tool_search元工具,让 Agent 在成百上千个工具里动态检索该用哪个,而不是一次性把全部工具塞给模型。
📝 过时提示:NewMiddleware vs New
你可能在老代码里见到
NewMiddleware(如adk/middlewares/filesystem/filesystem.go:173),它返回旧的AgentMiddleware,已被标注废弃。新代码请用New,它返回现代的ChatModelAgentMiddleware,通过Wrap钩子提供更好的上下文传播。两者能力相近,区别在接口世代。// NewMiddleware constructs and returns the filesystem middleware.//// Deprecated: Use New instead. New returns// a ChatModelAgentMiddleware which provides better context propagation through wrapper methods// and is the recommended approach for new code. See ChatModelAgentMiddleware documentation// for details on the benefits over AgentMiddleware.func NewMiddleware(ctx context.Context, config *Config) (adk.AgentMiddleware, error) {err := config.Validate()if err != nil {return adk.AgentMiddleware{}, err}ts, err := getFilesystemTools(ctx, &MiddlewareConfig{Backend: config.Backend,Shell: config.Shell,StreamingShell: config.StreamingShell,LsToolConfig: config.LsToolConfig,ReadFileToolConfig: config.ReadFileToolConfig,WriteFileToolConfig: config.WriteFileToolConfig,EditFileToolConfig: config.EditFileToolConfig,GlobToolConfig: config.GlobToolConfig,GrepToolConfig: config.GrepToolConfig,CustomSystemPrompt: config.CustomSystemPrompt,CustomLsToolDesc: config.CustomLsToolDesc,CustomReadFileToolDesc: config.CustomReadFileToolDesc,CustomGrepToolDesc: config.CustomGrepToolDesc,CustomGlobToolDesc: config.CustomGlobToolDesc,CustomWriteFileToolDesc: config.CustomWriteFileToolDesc,CustomEditToolDesc: config.CustomEditToolDesc,})if err != nil {return adk.AgentMiddleware{}, err}var systemPrompt stringif config.CustomSystemPrompt != nil {systemPrompt = *config.CustomSystemPrompt}m := adk.AgentMiddleware{AdditionalInstruction: systemPrompt,AdditionalTools: ts,}if !config.WithoutLargeToolResultOffloading {m.WrapToolCall = newToolResultOffloading(ctx, &toolResultOffloadingConfig{Backend: config.Backend,TokenLimit: config.LargeToolResultOffloadingTokenLimit,PathGenerator: config.LargeToolResultOffloadingPathGen,// … 省略 5 行;完整声明 L167–219,点击上方「浏览完整文件」
这些内置件本身就是最好的教材——想深入某个能力时,直接读它的中间件源码,九个钩子怎么用、状态怎么传、事件怎么发,一目了然。Part II 第 10 章会回到中间件的设计层面,讲清这套钩子为何这样切、内置件如何分类。这里你只需建立”叠加”的心智。
本章小结
- 中间件让你不改核心、只叠能力:核心在九个固定时刻回调钩子,能力被拆成可插拔的独立件。
- 九个钩子分三级:Agent 级(
Before/AfterAgent)、模型级(Before/AfterModelRewriteState,改动会持久化)、包裹级(Wrap*,洋葱模型)。 - 改消息用
BeforeModelRewriteState;WrapModel只做重试/降级/发事件,别在里面改输入。 - 内嵌
BaseChatModelAgentMiddleware白拿 no-op 默认实现,只写你关心的钩子。 - 顺序即语义(先注册=最外层);老
Middlewares先于新Handlers;跨件传值用SetRunLocalValue/GetRunLocalValue,可穿越中断续跑。 - 内置 filesystem / plantask / summarization / skill / toolsearch 是能力工具箱,也是学习范例。
下一章,我们把单 Agent 升级为多智能体:先跑通 supervisor 与 DeepAgent 两条路线,亲手体验两种组合哲学的差别。