---
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. 但真正的问题是:如果内层的其他逻辑(非中断)也出错,外层中间件还没来得及处理就被内层的错误"跳过"了
>
> 更准确地说,顺序的关键在于:**中间件是按装饰器模式嵌套的,外层包裹内层,内层最先执行也最先返回**。内层先做错误分类,外层再做全局处理,这样既保证了中断信号的畅通,又保证了业务错误的收敛。
>
| Middleware | 功能说明 |
|---|---|
| reduction | 工具输出缩减——当工具返回过长时自动截断并存入文件系统,防止上下文溢出 |
| summarization | 对话历史摘要——Token 超阈值时自动生成摘要压缩历史,节省上下文空间 |
| skill | 技能加载——让 Agent 按需动态加载预定义的 SKILL.md 知识包 |