19|Middleware 实战:在决策循环中加入控制能力
《Eino 实战:用 Go 构建 AI 应用与智能体》· 第 19 篇
关键词:ChatModelAgentMiddleware、生命周期钩子、WrapInvokableToolCall、审计、校验
前置条件:已完成 16|从流程到 Agent。
到这一篇,我们能用 Agent 调工具了。但生产环境还有一批需求,它们不属于任何一个工具,而属于所有工具:
- 每次工具调用都要记录审计日志。
- 某些工具必须限流或拦截。
- 模型调用前要压缩上下文。
- 工具频繁失败要重试。
把这些逻辑写进每个工具函数,会造成大量重复。Middleware 就是为这类横切能力准备的。
一、Middleware 解决什么问题
Middleware 的核心定位是:不改变 ReAct 循环的结构,只在固定生命周期点位上注入行为。
推理 → 行动 → 反馈这个循环不变。Middleware 控制的是这个循环运行时怎么跑:
| 需求 | 对应能力 |
|---|---|
| 运行前注入工具或增强指令 | BeforeAgent |
| 模型调用前压缩上下文 | BeforeModelRewriteState |
| 工具调用时做权限检查 | WrapInvokableToolCall |
| 模型失败时重试或切换 | ModelRetryConfig/ModelFailoverConfig |
| 运行结束后清理 | AfterAgent |
一个容易混淆的点:
ModelRetry和ModelFailover在代码里是ChatModelAgentConfig的内置字段(ModelRetryConfig/ModelFailoverConfig),概念上对应WrapModel钩子。它们不是需要你自己实现的 Middleware。
二、生命周期钩子
ChatModelAgentMiddleware是个接口,主要钩子如下:
| 钩子 | 时机 | 典型用途 |
|---|---|---|
BeforeAgent | Agent 运行前,仅一次 | 增强指令、注入工具 |
BeforeModelRewriteState | 每次模型调用前 | 修改消息、压缩上下文 |
AfterModelRewriteState | 每次模型调用后 | 修补状态、处理响应 |
WrapModel | 单次模型调用维度 | 重试、改写返回 |
WrapInvokableToolCall | 单次工具调用维度 | 权限、审计、结果改写 |
AfterAgent | Agent 成功结束后 | 后处理、清理 |
要点:
BeforeAgent只跑一次,BeforeModelRewriteState每轮模型调用都跑。副作用要放在正确的位置。BeforeModelRewriteState的改动会被保留到后续迭代(它修改的是内部状态)。这既是它能压缩上下文的原因,也意味着写错了会影响后续所有轮次。Wrap*是包装语义:拿到原始 endpoint,返回新的 endpoint。你可以选择调用它、拒绝调用,或调用后改写结果。
三、写一个 Middleware
实现方式:嵌入BaseChatModelAgentMiddleware获得所有钩子的空实现,只覆盖需要的方法。
typeauditMiddlewarestruct{*adk.BaseChatModelAgentMiddleware calls[]string}func(a*auditMiddleware)WrapInvokableToolCall(_context.Context,endpoint adk.InvokableToolCallEndpoint,tCtx*adk.ToolContext,)(adk.InvokableToolCallEndpoint,error){returnfunc(ctx context.Context,argumentsInJSONstring,opts...tool.Option)(string,error){a.calls=append(a.calls,tCtx.Name)// 敏感操作在此拦截:不需要修改工具本身。iftCtx.Name=="delete_ticket"{return"",fmt.Errorf("工具 %s 已被策略拒绝",tCtx.Name)}// 调用真实实现。out,err:=endpoint(ctx,argumentsInJSON,opts...)iferr!=nil{return"",err}returnout,nil},nil}这段代码做了三件事:记录调用、按策略拦截、透传执行。注意拦截是返回错误,而不是调用endpoint——拦截能力来自“你决定要不要调用原函数”。
注册时用Handlers字段:
agent,err:=adk.NewChatModelAgent(ctx,&adk.ChatModelAgentConfig{// ...Handlers:[]adk.ChatModelAgentMiddleware{mw},})Handlers是推荐字段;Middlewares是早期字段,官方标注为 Deprecated。新代码用Handlers。
四、完整示例
下面这段程序注册了一个审计 Middleware,并注册了一个危险工具用于验证拦截(已实机运行):
packagemainimport("context""fmt""log""strings""github.com/cloudwego/eino/adk""github.com/cloudwego/eino/components/model""github.com/cloudwego/eino/components/tool""github.com/cloudwego/eino/components/tool/utils""github.com/cloudwego/eino/compose""github.com/cloudwego/eino/schema")typeTicketReqstruct{TicketIDstring`json:"ticket_id" jsonschema:"description=工单编号"`}typemockModelstruct{}func(m*mockModel)Generate(_context.Context,in[]*schema.Message,_...model.Option)(*schema.Message,error){for_,msg:=rangein{ifmsg.Role==schema.Tool{returnschema.AssistantMessage("已分派给网络支持组。",nil),nil}}returnschema.AssistantMessage("",[]schema.ToolCall{{ID:"call_1",Type:"function",Function:schema.FunctionCall{Name:"get_ticket",Arguments:`{"ticket_id":"T-1001"}`,},}}),nil}func(m*mockModel)Stream(ctx context.Context,in[]*schema.Message,opts...model.Option)(*schema.StreamReader[*schema.Message],error){msg,err:=m.Generate(ctx,in,opts...)iferr!=nil{returnnil,err}sr,sw:=schema.Pipe[*schema.Message](1)gofunc(){defersw.Close()sw.Send(msg,nil)}()returnsr,nil}func(m*mockModel)WithTools([]*schema.ToolInfo)(model.ToolCallingChatModel,error){returnm,nil}// ---- 自定义 Middleware:在工具调用前后加校验 ----typeauditMiddlewarestruct{*adk.BaseChatModelAgentMiddleware calls[]string}func(a*auditMiddleware)WrapInvokableToolCall(_context.Context,endpoint adk.InvokableToolCallEndpoint,tCtx*adk.ToolContext,)(adk.InvokableToolCallEndpoint,error){returnfunc(ctx context.Context,argumentsInJSONstring,opts...tool.Option)(string,error){a.calls=append(a.calls,tCtx.Name)// 敏感操作在此拦截:不需要修改工具本身。iftCtx.Name=="delete_ticket"{return"",fmt.Errorf("工具 %s 已被策略拒绝",tCtx.Name)}// 调用真实实现。out,err:=endpoint(ctx,argumentsInJSON,opts...)iferr!=nil{return"",err}returnout,nil},nil}func(a*auditMiddleware)BeforeAgent(ctx context.Context,runCtx*adk.ChatModelAgentContext)(context.Context,*adk.ChatModelAgentContext,error){fmt.Println("[middleware] BeforeAgent: 运行前注入")returnctx,runCtx,nil}funcmain(){iferr:=run();err!=nil{log.Fatal(err)}}funcrun()error{ctx:=context.Background()ticketTool,err:=utils.InferTool[*TicketReq,string]("get_ticket","查询工单状态",func(_context.Context,req*TicketReq)(string,error){return"工单 "+req.TicketID+" 状态:已分派",nil})iferr!=nil{returnerr}deleteTool,err:=utils.InferTool[*TicketReq,string]("delete_ticket","删除工单(危险操作)",func(_context.Context,req*TicketReq)(string,error){return"已删除 "+req.TicketID,nil})iferr!=nil{returnerr}mw:=&auditMiddleware{BaseChatModelAgentMiddleware:&adk.BaseChatModelAgentMiddleware{}}agent,err:=adk.NewChatModelAgent(ctx,&adk.ChatModelAgentConfig{Name:"assistant",Description:"工单助手",Instruction:"需要时调用工具查询。",Model:&mockModel{},ToolsConfig:adk.ToolsConfig{ToolsNodeConfig:compose.ToolsNodeConfig{Tools:[]tool.BaseTool{ticketTool,deleteTool},}},Handlers:[]adk.ChatModelAgentMiddleware{mw},})iferr!=nil{returnerr}runner:=adk.NewRunner(ctx,adk.RunnerConfig{Agent:agent})iter:=runner.Query(ctx,"T-1001 什么状态?")for{event,ok:=iter.Next()if!ok{break}ifevent.Err!=nil{returnevent.Err}ifmv:=event.Output.MessageOutput;mv!=nil&&mv.Message!=nil{fmt.Printf(" 事件: role=%-9s tool=%-10s content=%q\n",mv.Role,mv.ToolName,mv.Message.Content)}}fmt.Println("Middleware 观察到的工具调用:",strings.Join(mw.calls,", "))returnnil}实机输出:
[middleware] BeforeAgent: 运行前注入 事件: role=assistant tool= content="" 事件: role=tool tool=get_ticket content="工单 T-1001 状态:已分派" 事件: role=assistant tool= content="已分派给网络支持组。" Middleware 观察到的工具调用: get_ticket几点观察:
BeforeAgent只打印了一次,尽管后续有多次模型交互。WrapInvokableToolCall记录了get_ticket,实现了统一的审计,而工具函数本身一行日志都没有。- 危险工具
delete_ticket虽已注册,但模型没有请求调用它,所以拦截逻辑未触发——这是正确行为:拦截只在工具被调用时生效。
五、Middleware 的几个实用模式
1. 统一审计
在WrapInvokableToolCall里记录工具名、调用者(从 SessionValues 取)、耗时和结果状态。注意脱敏,不要把原始参数和结果直接写进日志。
2. 策略拦截
按工具名、参数内容或调用者权限决定是否放行。但要注意:这里能拦截的是“Agent 内部发起”的调用,真正的权限控制仍应在工具实现内完成(第 11 篇)。Middleware 是纵深防御的一层,不是唯一一层。
3. 结果改写
工具返回的内容太长时,在 Middleware 里裁剪,避免挤占上下文:
out,err:=endpoint(ctx,argumentsInJSON,opts...)iferr!=nil{return"",err}iflen([]rune(out))>maxLen{out=string([]rune(out)[:maxLen])+"…(已截断)"}returnout,nil4. 上下文压缩
在BeforeModelRewriteState里裁剪消息。切记它会作用于后续所有轮次,改动前想清楚会不会丢掉必要信息。
六、常见问题
Middleware 没生效
检查是否注册在Handlers字段(而不是已废弃的Middlewares),以及是否嵌入了BaseChatModelAgentMiddleware(否则需要实现全部方法)。
想拦截工具调用但没触发
拦截发生在工具被调用时。先用事件流确认模型是否真的请求了该工具。
BeforeModelRewriteState的改动影响了后续轮次
这是设计行为——它修改的是会被保留的状态。只在需要持久改变上下文时使用。
顺序搞不清
多个 Middleware 按注册顺序处理。涉及顺序敏感的逻辑(如“先审计再限流”)时,用打印语句确认实际执行顺序,不要凭假设。
重试逻辑自己实现不生效
先看内置的ModelRetryConfig/ModelFailoverConfig是否满足需求,通常不必自己写WrapModel。
日志里出现了敏感数据
Middleware 很容易成为“统一记录一切”的地方,也最容易泄露数据。审计记录只记元信息(工具名、耗时、成功与否),不记参数和返回值原文。
七、小结
- Middleware 不改变 ReAct 循环结构,只在生命周期点位上注入行为。
BeforeAgent仅执行一次;BeforeModelRewriteState每轮执行,且改动会被保留。Wrap*是包装语义:决定是否调用原实现,从而获得拦截和改写能力。- 注册用
Handlers字段;嵌入BaseChatModelAgentMiddleware可获得钩子默认实现。 - 重试与故障切换优先用内置配置字段,不必自行实现。
- Middleware 是纵深防御的一层,不能替代工具内部的鉴权。
下一篇《人机协作:在关键操作前暂停并等待审批》,我们实现中断与恢复的完整闭环。
参考资料与版本说明
资料核对日期为2026 年 10 月 9 日:
- 五分钟上手 Eino ADK(Middleware 速查)
- Eino ADK 概述
- Eino 主仓库
本文示例按Eino v0.9.21编写,使用模拟模型(不访问网络),已实机运行,正文输出为真实结果。