☰
19|Middleware 实战:在决策循环中加入控制能力 - eino教程
2026/10/22 7:22:48 网站建设 项目流程

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是个接口,主要钩子如下:

钩子时机典型用途
BeforeAgentAgent 运行前,仅一次增强指令、注入工具
BeforeModelRewriteState每次模型调用前修改消息、压缩上下文
AfterModelRewriteState每次模型调用后修补状态、处理响应
WrapModel单次模型调用维度重试、改写返回
WrapInvokableToolCall单次工具调用维度权限、审计、结果改写
AfterAgentAgent 成功结束后后处理、清理

要点:

  • 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,nil

4. 上下文压缩

在BeforeModelRewriteState里裁剪消息。切记它会作用于后续所有轮次,改动前想清楚会不会丢掉必要信息。


六、常见问题

Middleware 没生效
检查是否注册在Handlers字段(而不是已废弃的Middlewares),以及是否嵌入了BaseChatModelAgentMiddleware(否则需要实现全部方法)。

想拦截工具调用但没触发
拦截发生在工具被调用时。先用事件流确认模型是否真的请求了该工具。

BeforeModelRewriteState的改动影响了后续轮次
这是设计行为——它修改的是会被保留的状态。只在需要持久改变上下文时使用。

顺序搞不清
多个 Middleware 按注册顺序处理。涉及顺序敏感的逻辑(如“先审计再限流”)时,用打印语句确认实际执行顺序,不要凭假设。

重试逻辑自己实现不生效
先看内置的ModelRetryConfig/ModelFailoverConfig是否满足需求,通常不必自己写WrapModel。

日志里出现了敏感数据
Middleware 很容易成为“统一记录一切”的地方,也最容易泄露数据。审计记录只记元信息(工具名、耗时、成功与否),不记参数和返回值原文。


七、小结

  • Middleware 不改变 ReAct 循环结构,只在生命周期点位上注入行为。
  • BeforeAgent仅执行一次;BeforeModelRewriteState每轮执行,且改动会被保留。
  • Wrap*是包装语义:决定是否调用原实现,从而获得拦截和改写能力。
  • 注册用Handlers字段;嵌入BaseChatModelAgentMiddleware可获得钩子默认实现。
  • 重试与故障切换优先用内置配置字段,不必自行实现。
  • Middleware 是纵深防御的一层,不能替代工具内部的鉴权。

下一篇《人机协作:在关键操作前暂停并等待审批》,我们实现中断与恢复的完整闭环。


参考资料与版本说明

资料核对日期为2026 年 10 月 9 日:

  1. 五分钟上手 Eino ADK(Middleware 速查)
  2. Eino ADK 概述
  3. Eino 主仓库

本文示例按Eino v0.9.21编写,使用模拟模型(不访问网络),已实机运行,正文输出为真实结果。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询