【Eino 框架入门】Middleware 中间件:给 Agent 加一层"异常保护罩"
写 Agent 最头疼的是什么?Tool 报错直接崩掉整个对话。用户说"读取 xxx.txt",文件不存在,程序炸了,用户得重新开始。
Middleware 就是来解决这个问题的。
Middleware 是什么?
打个比方:Agent 是干活的员工,Middleware 是旁边的保安。
- 员工干活时,保安在旁边看着
- 员工出错了,保安拦住错误,不让它炸掉整个系统
- 错误信息转成文字,告诉员工"出啥事了,你自己看着办"
核心效果:错误不再"终止程序",而是变成"提示信息",让模型自己决定下一步怎么办。
没有 Middleware 的痛点
// Tool 执行失败
result, err := read_file("nonexistent.txt")
// err != nil → 错误向上传播 → Agent 崩溃 → 用户重新开始
有 Middleware 之后
// Middleware 拦截 Tool 调用
result, err := endpoint(ctx, args, opts...)
if err != nil {
// 不返回 error,而是返回字符串
return fmt.Sprintf("[tool error] %v", err), nil
}
模型收到的不是程序崩溃,而是一段文字:[tool error] open nonexistent.txt: no such file or directory。模型看到后会说"抱歉文件不存在,让我看看目录里有哪些文件...",然后自己调整策略。
核心实现
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
}
关键点:endpoint 是原始的 Tool 调用,我们用装饰器模式包了一层,在出错时把 error 转成 string。
流式 Tool 怎么处理?
流式 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 {
// 同步错误:返回一个只有错误信息的流
return singleChunkReader(fmt.Sprintf("[tool error] %v", err)), nil
}
// 包装流,捕获流中途的错误
return safeWrapReader(sr), nil
}, nil
}
safeWrapReader 用 goroutine 监听原始流,一旦出错就转成错误信息发出去:
func safeWrapReader(sr *schema.StreamReader[string]) *schema.StreamReader[string] {
r, w := schema.Pipe[string](64)
go func() {
defer w.Close()
for {
chunk, err := sr.Recv()
if errors.Is(err, io.EOF) {
return
}
if err != nil {
_ = w.Send(fmt.Sprintf("\n[tool error] %v", err), nil)
return
}
_ = w.Send(chunk, nil)
}
}()
return r
}
模型限流怎么办?
除了 Tool 报错,模型 API 也可能限流(429)。这个用 ModelRetryConfig 解决:
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")
},
}
遇到 429 就自动重试,最多 5 次,带指数退避。
洋葱模型
多个 Middleware 会形成洋葱结构:
请求 → A.Wrap → B.Wrap → C.Wrap → 实际执行 → C返回 → B返回 → A返回 → 响应
代码里数组顺序决定谁在外层:
Handlers: []adk.ChatModelAgentMiddleware{
&middlewareA{}, // 最外层
&reductionMiddleware{}, // 中间层
&safeToolMiddleware{}, // 最内层(建议放错误处理)
}
实践建议:错误处理的 Middleware 放数组末尾(最内层),确保其他 Middleware 抛出的中断错误能正确传播。
完整配置示例
agent, err := deep.New(ctx, &deep.Config{
Name: "MyAgent",
ChatModel: cm,
Instruction: instruction,
Backend: backend,
StreamingShell: backend,
MaxIteration: 50,
Handlers: []adk.ChatModelAgentMiddleware{
&safeToolMiddleware{}, // Tool 错误处理
},
ModelRetryConfig: &adk.ModelRetryConfig{
MaxRetries: 5,
IsRetryAble: func(_ context.Context, err error) bool {
return strings.Contains(err.Error(), "429")
},
},
})
小结
Middleware 本质上就是装饰器模式:包一层,改行为。核心作用是把"程序崩溃"变成"错误提示",让模型有机会自己纠错。
Eino 内置了几个实用的 Middleware:
reduction:Tool 输出太长时自动截断summarization:对话历史太长时自动摘要skill:动态加载技能