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

17 KiB
Raw Blame History

tags, create time
tags create time
Eino
Agent
Middleware
DeepAgent
错误处理
重试
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()) panicnil 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

  1. Tool 执行器检测到中断信号,抛出 InterruptRerunError
  2. 如果 safeToolMiddleware 在外层——此时请求还在外层尚未进入内层,中断信号在内层往外冒泡时会首先经过内层中间件
  3. 因为中断信号是在最内层的 Tool 处产生的,无论 safeToolMiddleware 在哪一层,只要它检查了 IsInterruptRerunError 就不会吞掉它
  4. 但真正的问题是:如果内层的其他逻辑(非中断)也出错,外层中间件还没来得及处理就被内层的错误"跳过"了

更准确地说,顺序的关键在于:中间件是按装饰器模式嵌套的,外层包裹内层,内层最先执行也最先返回。内层先做错误分类,外层再做全局处理,这样既保证了中断信号的畅通,又保证了业务错误的收敛。

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_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 组合示例

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 在内层。如果把顺序反过来,会有什么影响?试着根据洋葱模型的执行顺序推导一下。

代码位置

前置条件

与第一章一致:需要配置一个可用的 ChatModelOpenAI 或 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 包装原始调用,可修改输入、输出或错误
中断错误不转换 只有业务错误才转字符串,中断错误继续传播

关联笔记