Files
CamTalk/docs/Eino/quick_start/chapter_05_middleware.md

434 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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)
## 前置条件
与第一章一致:需要配置一个可用的 ChatModelOpenAI 或 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 可观测性(下一章)