17 KiB
tags, create time
| tags | 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 中间件的统一接口:
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 按数组正序包装,形成洋葱模型:
Handlers: []adk.ChatModelAgentMiddleware{
&middlewareA{}, // 最外层:最先 Wrap,最后生效
&middlewareB{}, // 中间层
&middlewareC{}, // 最内层:最后 Wrap,最先生效
}
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) |
// ❶ 最内层:业务错误转换 —— 模型可以继续执行
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。
- Tool 执行器检测到中断信号,抛出
InterruptRerunError- 如果
safeToolMiddleware在外层——此时请求还在外层尚未进入内层,中断信号在内层往外冒泡时会首先经过内层中间件- 因为中断信号是在最内层的 Tool 处产生的,无论
safeToolMiddleware在哪一层,只要它检查了IsInterruptRerunError就不会吞掉它- 但真正的问题是:如果内层的其他逻辑(非中断)也出错,外层中间件还没来得及处理就被内层的错误"跳过"了
更准确地说,顺序的关键在于:中间件是按装饰器模式嵌套的,外层包裹内层,内层最先执行也最先返回。内层先做错误分类,外层再做全局处理,这样既保证了中断信号的畅通,又保证了业务错误的收敛。
ModelRetryConfig:内置重试配置
ModelRetryConfig 提供了 ChatModel 级别的自动重试能力:
type ModelRetryConfig struct {
MaxRetries int // 最大重试次数
IsRetryAble func(ctx context.Context, err error) bool // 哪些错误可重试
}
重试策略:
| 策略 | 说明 |
|---|---|
| 指数退避 | 每次重试间隔递增,避免频繁请求加剧限流 |
| 条件过滤 | 通过 IsRetryAble 精确控制哪些错误值得重试 |
| 自动恢复 | 无需用户干预,模型调用失败后自动重试 |
实现细节
SafeToolMiddleware:错误转换
SafeToolMiddleware 捕获 Tool 执行时的错误,将其转换为字符串返回给模型而非中断流程:
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 的错误处理同理,需将错误封装为单帧流:
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 的配置中:
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 调用的完整生命周期如下:
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_fileTool。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 组合示例
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
前置条件
与第一章一致:需要配置一个可用的 ChatModel(OpenAI 或 Ark)。同时,需要与第四章一样设置 PROJECT_ROOT:
export PROJECT_ROOT=/path/to/eino # Eino 核心库根目录
运行
在 examples/quickstart/chatwitheino 目录下执行:
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 可观测性(下一章)