---
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` 移到最外层（数组首位），会发生什么？
>
> <details>
> <summary>🔍 点击查看推导</summary>
>
> 考虑这个场景：用户在 Agent 多轮对话中点击了"停止"按钮，底层产生了一个 `InterruptRerunError`。
>
> 1. Tool 执行器检测到中断信号，抛出 `InterruptRerunError`
> 2. 如果 `safeToolMiddleware` 在外层——此时请求还在外层尚未进入内层，**中断信号在内层往外冒泡时会首先经过内层中间件**
> 3. 因为中断信号是在最内层的 Tool 处产生的，无论 `safeToolMiddleware` 在哪一层，只要它检查了 `IsInterruptRerunError` 就不会吞掉它
> 4. 但真正的问题是：如果内层的其他逻辑（非中断）也出错，外层中间件还没来得及处理就被内层的错误"跳过"了
>
> 更准确地说，顺序的关键在于：**中间件是按装饰器模式嵌套的，外层包裹内层，内层最先执行也最先返回**。内层先做错误分类，外层再做全局处理，这样既保证了中断信号的畅通，又保证了业务错误的收敛。
> </details>

### 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 生态还提供了以下开箱即用的中间件：

<table>
<tr><th>Middleware</th><th>功能说明</th></tr>
<tr><td><strong>reduction</strong></td><td>工具输出缩减——当工具返回过长时自动截断并存入文件系统，防止上下文溢出</td></tr>
<tr><td><strong>summarization</strong></td><td>对话历史摘要——Token 超阈值时自动生成摘要压缩历史，节省上下文空间</td></tr>
<tr><td><strong>skill</strong></td><td>技能加载——让 Agent 按需动态加载预定义的 SKILL.md 知识包</td></tr>
</table>

### 多 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 可观测性（下一章）
