--- tags: ["Eino", "Agent", "Middleware", "DeepAgent", "错误处理", "重试"] create time: "2026-04-29 16:00" --- # 第五章:Middleware(中间件模式) ## 概述 第四章为 Agent 加入了 Tool 能力后,Agent 已经可以「看见」和「触碰」真实世界了。但现实中的 API 会限流、文件会不存在、网络会超时——**直接暴露的错误会让 Agent 流程中断**。本章通过 Middleware 模式引入拦截器机制,让 Agent 具备错误自愈和自动重试的能力。 ## 为什么需要 Middleware 第四章结束时,Tool 报错或 ChatModel 报错会直接中断整个对话流程: ``` [tool call] read_file(file_path: "nonexistent.txt") Error: open nonexistent.txt: no such file or directory // 💥 对话中断,用户需要重新开始 ``` 这类错误很常见: | 场景 | 错误类型 | 常见原因 | |------|---------|---------| | **Tool 报错** | 业务错误 | 文件不存在、参数错误、权限不足 | | **ChatModel 报错** | 临时错误 | API 限流(429)、网络超时、服务不可用 | > [!tip] 关键洞察 > > 这些错误**不应该终止 Agent 流程**。更好的做法是把错误信息交给模型,让它自动调整策略继续执行: > > ``` > [tool call] read_file(file_path: "nonexistent.txt") > [tool result] [tool error] open nonexistent.txt: no such file or directory > [assistant] 抱歉,文件不存在。让我先列出当前目录的文件... > [tool call] glob(pattern: "*") > // ✅ 对话继续,模型自行纠错 > ``` > [!question] 深入思考 > > 既然可以直接把错误返回给模型,为什么不直接在每个 Tool 内部写 `if err != nil` 判断? > 提示:考虑开闭原则(OCP)——如果明天要加 10 个新 Tool,是不是每个都要改一遍?**Middleware 的本质是将横切关注点从业务代码中剥离**,这也是 AOP(面向切面编程)的核心思想。 ## 什么是 Middleware **Middleware 是 Agent 的拦截器**,可以在调用前后插入自定义逻辑: - **拦截调用**:在 Tool 或 ChatModel 执行前/后包装自定义行为 - **错误转换**:将错误转为模型可理解的字符串,而非中断流程 - **自动重试**:对临时错误(如限流)实现指数退避重试 - **可组合**:多个 Middleware 串联形成责任链 **简单类比:** - **Agent** = "业务逻辑" - **Middleware** = "AOP 切面"(日志、重试、错误处理等横切关注点) > [!note] 装饰器模式 > > Middleware 的本质是**装饰器模式**(Decorator Pattern)——每个 Middleware 包装原始调用,可以修改输入、输出或错误,而不改变被包装对象的接口。 ## 核心概念 ### Middleware 接口 `ChatModelAgentMiddleware` 是 Agent 中间件的统一接口: ```go type ChatModelAgentMiddleware interface { BeforeAgent(ctx context.Context, runCtx *ChatModelAgentContext) (context.Context, *ChatModelAgentContext, error) BeforeModelRewriteState(ctx context.Context, state *ChatModelAgentState, mc *ModelContext) (context.Context, *ChatModelAgentState, error) AfterModelRewriteState(ctx context.Context, state *ChatModelAgentState, mc *ModelContext) (context.Context, *ChatModelAgentState, error) WrapInvokableToolCall(ctx context.Context, endpoint InvokableToolCallEndpoint, tCtx *ToolContext) (InvokableToolCallEndpoint, error) WrapStreamableToolCall(ctx context.Context, endpoint StreamableToolCallEndpoint, tCtx *ToolContext) (StreamableToolCallEndpoint, error) WrapEnhancedInvokableToolCall(ctx context.Context, endpoint EnhancedInvokableToolCallEndpoint, tCtx *ToolContext) (EnhancedInvokableToolCallEndpoint, error) WrapEnhancedStreamableToolCall(ctx context.Context, endpoint EnhancedStreamableToolCallEndpoint, tCtx *ToolContext) (EnhancedStreamableToolCallEndpoint, error) WrapModel(ctx context.Context, m model.BaseChatModel, mc *ModelContext) (model.BaseChatModel, error) } ``` **方法分组:** | 分组 | 方法 | 作用时机 | |------|------|---------| | **Agent 生命周期** | `BeforeAgent` | 每次 Agent 运行前,可修改指令和工具配置 | | **状态处理** | `BeforeModelRewriteState` / `AfterModelRewriteState` | 每次模型调用前后的状态变换 | | **Tool 调用** | `WrapInvokableToolCall` / `WrapStreamableToolCall` | 包装同步/流式 Tool 的执行 | | **模型调用** | `WrapModel` | 包装底层 ChatModel 的调用 | ### 洋葱模型:Middleware 执行顺序 Handlers 按**数组正序**包装,形成洋葱模型: ```go Handlers: []adk.ChatModelAgentMiddleware{ &middlewareA{}, // 最外层:最先 Wrap,最后生效 &middlewareB{}, // 中间层 &middlewareC{}, // 最内层:最后 Wrap,最先生效 } ``` ```mermaid flowchart LR subgraph Request ["📥 请求方向 →"] A["Middleware A\n(最外层)"] --> B["Middleware B\n(中间层)"] B --> C["Middleware C\n(最内层)"] C --> T["实际 Tool/Model\n执行"] end subgraph Response ["📤 响应方向 ←"] T --> CR["Middleware C\n返回"] CR --> CB["Middleware B\n返回"] CB --> CA["Middleware A\n返回"] end style A fill:#fce4ec style B fill:#e8f5e9 style C fill:#e3f2fd style T fill:#fff3e0 ``` > [!warning] 实用建议 > > 将 `safeToolMiddleware`(错误捕获)放在最内层(数组末尾),确保其他 Middleware 抛出的中断错误能正确向外传播,不被吞掉。 ### 深入:为什么安全中间件要放在最内层? 很多开发者会问:**为什么不在最外层放一个全局的 `RecoverMiddleware` 来兜底?** 要理解这一点,需要区分三种不同的错误处理方式: | 方式 | 捕获目标 | 处理策略 | 放置位置 | |------|---------|---------|---------| | **安全转换** (`safeToolMiddleware`) | 业务错误(文件不存在、参数错误) | 转为字符串喂给模型,让 Agent 自愈 | **最内层**(工具调用前) | | **中断传播** | `InterruptRerunError` 等控制信号 | 不做任何转换,原样向上抛出 | 贯穿所有层 | | **系统兜底** (`defer recover()`) | `panic`(nil pointer、数组越界) | 记录日志,保护进程不崩溃 | **入口层**(如 `main()` / `http.Server`) | ```go // ❶ 最内层:业务错误转换 —— 模型可以继续执行 func (m *safeToolMiddleware) WrapInvokableToolCall(...) (...) { return func(ctx context.Context, args string, opts ...tool.Option) (string, error) { result, err := endpoint(ctx, args, opts...) if err != nil { if _, ok := compose.IsInterruptRerunError(err); ok { return "", err // ⚠️ 中断错误必须穿透所有中间件 } return fmt.Sprintf("[tool error] %v", err), nil // ✅ 业务错误转字符串 } return result, nil } } // ❷ 入口处:panic 兜底 —— 保护进程 func main() { defer func() { if r := recover(); r != nil { log.Printf("recovered from panic: %v", r) } }() // ... 启动 Agent 服务 } ``` > [!question] 进阶思考 > > 如果把 `safeToolMiddleware` 移到最外层(数组首位),会发生什么? > >
> 🔍 点击查看推导 > > 考虑这个场景:用户在 Agent 多轮对话中点击了"停止"按钮,底层产生了一个 `InterruptRerunError`。 > > 1. Tool 执行器检测到中断信号,抛出 `InterruptRerunError` > 2. 如果 `safeToolMiddleware` 在外层——此时请求还在外层尚未进入内层,**中断信号在内层往外冒泡时会首先经过内层中间件** > 3. 因为中断信号是在最内层的 Tool 处产生的,无论 `safeToolMiddleware` 在哪一层,只要它检查了 `IsInterruptRerunError` 就不会吞掉它 > 4. 但真正的问题是:如果内层的其他逻辑(非中断)也出错,外层中间件还没来得及处理就被内层的错误"跳过"了 > > 更准确地说,顺序的关键在于:**中间件是按装饰器模式嵌套的,外层包裹内层,内层最先执行也最先返回**。内层先做错误分类,外层再做全局处理,这样既保证了中断信号的畅通,又保证了业务错误的收敛。 >
### ModelRetryConfig:内置重试配置 `ModelRetryConfig` 提供了 ChatModel 级别的自动重试能力: ```go type ModelRetryConfig struct { MaxRetries int // 最大重试次数 IsRetryAble func(ctx context.Context, err error) bool // 哪些错误可重试 } ``` **重试策略:** | 策略 | 说明 | |------|------| | **指数退避** | 每次重试间隔递增,避免频繁请求加剧限流 | | **条件过滤** | 通过 `IsRetryAble` 精确控制哪些错误值得重试 | | **自动恢复** | 无需用户干预,模型调用失败后自动重试 | ## 实现细节 ### SafeToolMiddleware:错误转换 `SafeToolMiddleware` 捕获 Tool 执行时的错误,将其转换为字符串返回给模型而非中断流程: ```go type safeToolMiddleware struct { *adk.BaseChatModelAgentMiddleware } func (m *safeToolMiddleware) WrapInvokableToolCall( _ context.Context, endpoint adk.InvokableToolCallEndpoint, _ *adk.ToolContext, ) (adk.InvokableToolCallEndpoint, error) { return func(ctx context.Context, args string, opts ...tool.Option) (string, error) { result, err := endpoint(ctx, args, opts...) if err != nil { // ❗ 中断错误不转换,需要继续向外传播 if _, ok := compose.IsInterruptRerunError(err); ok { return "", err } // ✅ 普通错误转为字符串,交给模型处理 return fmt.Sprintf("[tool error] %v", err), nil } return result, nil }, nil } ``` **设计要点:** - **区分错误类型**:中断错误(如主动要求停止)必须传播,业务错误(如文件不存在)可以转换 - **不吞错**:只转换预期的业务错误,真正的系统异常仍向上抛出 - **格式化**:使用 `[tool error]` 前缀方便模型识别并回复时引用 流式 Tool 的错误处理同理,需将错误封装为单帧流: ```go func (m *safeToolMiddleware) WrapStreamableToolCall( _ context.Context, endpoint adk.StreamableToolCallEndpoint, _ *adk.ToolContext, ) (adk.StreamableToolCallEndpoint, error) { return func(ctx context.Context, args string, opts ...tool.Option) (*schema.StreamReader[string], error) { sr, err := endpoint(ctx, args, opts...) if err != nil { if _, ok := compose.IsInterruptRerunError(err); ok { return nil, err } // 返回包含错误信息的单帧流 return singleChunkReader(fmt.Sprintf("[tool error] %v", err)), nil } return safeWrapReader(sr), nil }, nil } ``` ### 注册 Middleware 与重试配置 将 Middleware 注入 DeepAgent 的配置中: ```go agent, err := deep.New(ctx, &deep.Config{ Name: "Ch05MiddlewareAgent", Description: "ChatWithDoc agent with safe tool middleware and retry.", ChatModel: cm, Instruction: agentInstruction, Backend: backend, StreamingShell: backend, MaxIteration: 50, // ⭐ 注册 Middleware Handlers: []adk.ChatModelAgentMiddleware{ &safeToolMiddleware{}, // 将 Tool 错误转为字符串 }, // ⭐ 注册模型重试配置 ModelRetryConfig: &adk.ModelRetryConfig{ MaxRetries: 5, IsRetryAble: func(_ context.Context, err error) bool { return strings.Contains(err.Error(), "429") || strings.Contains(err.Error(), "Too Many Requests") }, }, }) ``` > [!note] Handlers vs Middlewares > > `Handlers` 字段(在 Config 中)和 "Middleware"(文档讨论的概念)是同一回事——`Handlers` 是配置字段名,而 `ChatModelAgentMiddleware` 是对接口的命名。 ## 执行流程 结合 Middleware 后,一次 Tool 调用的完整生命周期如下: ```mermaid flowchart TD U["用户:读取不存在的文件"] --> A{"Agent 分析意图"} A -->|"决定调用 Tool"| M["SafeToolMiddleware\n拦截 Tool 调用"] M --> T["执行 read_file\n返回错误"] T --> E["SafeToolMiddleware\n捕获错误"] E -->|"非中断错误"| S["转换为字符串\ntool error: no such file"] E -->|"中断错误"| EP["向上抛出中断"] S --> R["返回 Tool Result"] R --> AG{"Agent 整合信息"} AG -->|"生成解释性回复"| O["抱歉,文件不存在...\n尝试列出目录"] AG -->|"需要更多信息"| A style M fill:#e8f5e9 style E fill:#fff3e0 style S fill:#e3f2fd style EP fill:#ffebee ``` > [!example] 逐步拆解 > > **Step 1 — 用户输入** > 用户请求读取一个不存在的文件。 > > **Step 2 — 意图分析** > Agent 判断需要文件系统操作,决定调用 `read_file` Tool。 > > **Step 3 — Middleware 拦截** > `SafeToolMiddleware.WrapInvokableToolCall` 在 Tool 执行前被触发,注册了自己的回调逻辑。 > > **Step 4 — Tool 执行** > 实际文件读取操作失败,返回 `open nonexistent.txt: no such file` 错误。 > > **Step 5 — 错误转换** > Middleware 发现这不是中断错误,将其包装为 `[tool error] open nonexistent.txt: ...` 字符串。 > > **Step 6 — Agent 自愈** > Agent 收到带错误的 Tool Result,理解后回复用户并调整策略(如改用 `glob` 列出可用文件)。 ## 扩展:Eino 内置 Middleware Eino 生态还提供了以下开箱即用的中间件:
Middleware功能说明
reduction工具输出缩减——当工具返回过长时自动截断并存入文件系统,防止上下文溢出
summarization对话历史摘要——Token 超阈值时自动生成摘要压缩历史,节省上下文空间
skill技能加载——让 Agent 按需动态加载预定义的 SKILL.md 知识包
### 多 Middleware 组合示例 ```go import ( "github.com/cloudwego/eino/adk/middlewares/reduction" "github.com/cloudwego/eino/adk/middlewares/summarization" ) // 创建 reduction:管理工具输出长度 reductionMW, _ := reduction.New(ctx, &reduction.Config{ Backend: filesystemBackend, MaxLengthForTrunc: 50000, MaxTokensForClear: 30000, }) // 创建 summarization:自动压缩对话历史 summarizationMW, _ := summarization.New(ctx, &summarization.Config{ Model: chatModel, Trigger: &summarization.TriggerCondition{ ContextTokens: 190000, }, }) // 组合使用 agent, _ := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{ Handlers: []adk.ChatModelAgentMiddleware{ summarizationMW, // 外层:对话历史摘要 reductionMW, // 内层:工具输出缩减 }, }) ``` > [!question] 扩展思考 > > 在这个例子中,`summarizationMW` 在外层、`reductionMW` 在内层。如果把顺序反过来,会有什么影响?试着根据洋葱模型的执行顺序推导一下。 ## 代码位置 - 入口代码:[cmd/ch05/main.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/cmd/ch05/main.go) ## 前置条件 与第一章一致:需要配置一个可用的 ChatModel(OpenAI 或 Ark)。同时,需要与第四章一样设置 `PROJECT_ROOT`: ```bash export PROJECT_ROOT=/path/to/eino # Eino 核心库根目录 ``` ## 运行 在 `examples/quickstart/chatwitheino` 目录下执行: ```bash export PROJECT_ROOT=/path/to/your/project go run ./cmd/ch05 ``` **输出示例:** ``` you> 列出当前目录的文件 [assistant] 我来帮你列出文件... [tool call] list_files(directory: ".") you> 读取一个不存在的文件 [assistant] 尝试读取文件... [tool call] read_file(file_path: "nonexistent.txt") [tool result] [tool error] open nonexistent.txt: no such file or directory [assistant] 抱歉,文件不存在... ``` ## 本章小结 | 概念 | 一句话理解 | |------|-----------| | **Middleware** | Agent 的拦截器,在调用前后插入自定义逻辑 | | **SafeToolMiddleware** | 将 Tool 错误转为字符串交给模型,而非中断流程 | | **ModelRetryConfig** | 配置 ChatModel 的自动重试,处理限流等临时错误 | | **洋葱模型** | 请求从外向内穿过 Middleware,响应从内向外返回 | | **装饰器模式** | 每个 Middleware 包装原始调用,可修改输入、输出或错误 | | **中断错误不转换** | 只有业务错误才转字符串,中断错误继续传播 | ## 关联笔记 - [[Eino/quick_start/_index]] - [[Eino/quick_start/chapter_04_tool_and_filesystem]] — Tool 与文件系统访问(上一章) - [[Eino/quick_start/chapter_06_callback_and_trace]] — Callback 与 Trace 可观测性(下一章)