LLM

golang/eino eino框架的基础使用 在ChatModel中使用工具

字节跳动 Eino 框架工具调用教程,介绍 Tool 接口定义、ToolInfo 构造方法、JSON Schema 参数定义、限流重试处理以及完整的交互式工具调用示例。

Tool

  • 参考
  • 定义
    // BaseTool provides the metadata that a ChatModel uses to decide whether and
    // how to call a tool. Info returns a [schema.ToolInfo] containing the tool
    // name, description, and parameter JSON schema.
    //
    // BaseTool alone is sufficient when passing tool definitions to a ChatModel
    // via WithTools — the model only needs the schema to generate tool calls.
    // To also execute the tool, implement [InvokableTool] or [StreamableTool].
    type BaseTool interface {
        Info(ctx context.Context) (*schema.ToolInfo, error)
    }
    
  • 大模型如何调用工具
    mermaid
    sequenceDiagram
            participant User as 用户
            participant Eino as Eino 框架 / 应用程序
            participant LLM as 大模型 (ChatModel)
            participant Tool as 工具 (InvokableTool)
    
            User->>Eino: 发起请求 (Query)
            Eino->>LLM: 发送消息 + 工具定义 (Messages + ToolInfos)
            Note over LLM: 思考:是否需要调用工具?
            
            alt 需要调用工具
                LLM-->>Eino: 返回工具调用请求 (Tool Call: name, arguments)
                Eino->>Tool: 执行工具 (Invoke/Stream)
                Tool-->>Eino: 返回执行结果 (Tool Result)
                Eino->>LLM: 发送消息 (历史消息 + 工具结果)
                LLM-->>Eino: 生成最终回答 (Final Response)
            else 不需要调用工具
                LLM-->>Eino: 直接生成回答 (Response)
            end
            
            Eino->>User: 返回最终结果
  • 简单来说,就是将工具描述成字符串,告诉大模型这个工具是做什么的,调用这个工具需要哪些参数,然后大模型返回一条消息,告诉我们要调用这个工具,参数也发过来。然后本地代码执行工具完成之后将结果返回给大模型。

创建工具

  • 在eino中,有几种基本的tool类型
    type InvokableTool interface {
        BaseTool
    
        // InvokableRun call function with arguments in JSON format
        InvokableRun(ctx context.Context, argumentsInJSON string, opts ...Option) (string, error)
    }
    
    type StreamableTool interface {
        BaseTool
    
        StreamableRun(ctx context.Context, argumentsInJSON string, opts ...Option) (*schema.StreamReader[string], error)
    }
    
    增强型工具接口,支持多模态
    // EnhancedInvokableTool 是支持返回结构化多模态结果的工具接口
    // 与返回字符串的 InvokableTool 不同,此接口返回 *schema.ToolResult
    // 可以包含文本、图片、音频、视频和文件
    type EnhancedInvokableTool interface {
        BaseTool
        InvokableRun(ctx context.Context, toolArgument *schema.ToolArgument, opts ...Option) (*schema.ToolResult, error)
    }
    
    // EnhancedStreamableTool 是支持返回结构化多模态结果的流式工具接口
    type EnhancedStreamableTool interface {
        BaseTool
        StreamableRun(ctx context.Context, toolArgument *schema.ToolArgument, opts ...Option) (*schema.StreamReader[*schema.ToolResult], error)
    }
    
  • 了解接口定义之后,除了工具本身的逻辑实现,最麻烦的是如何构造schema.ToolInfo

构造ToolInfo

  • 官方文档描述了两种实现方式,这里主要使用第二种,即使用JSON Schema
  • eino提供了func GoStruct2ParamsOneOf[T any](opts ...Option) (*schema.ParamsOneOf, error)来封装这个过程,只需要将工具的参数定义用结构体表示,然后调用这个函数即可。
  • 在定义结构体时,有几个tag需要注意
    • jsonschema_description:"xxx"
    • jsonschema:"enum=xxx,enum=yyy,enum=zzz"
    • jsonschema:"required"
    • jsonschema:"xxx,omitempty" => 代表非 required
  • 贴个官方的示例
    type User struct {
        Name   string `json:"name" jsonschema_description:"the name of the user" jsonschema:"required"`
        Age    int    `json:"age" jsonschema_description:"the age of the user"`
        Gender string `json:"gender" jsonschema:"enum=male,enum=female"`
    }
    

限流处理

  • 在使用第三方的大模型时,可能会遇到限流错误,即429错误。可以添加重试机制来处理。
    // streamWithRetry calls cm.Stream with exponential backoff on 429 errors.
    func streamWithRetry(ctx context.Context, cm model.ToolCallingChatModel, messages []*schema.Message) (*schema.StreamReader[*schema.Message], error) {
        const maxRetries = 5
        const baseDelay = time.Second
    
        for attempt := range maxRetries {
            stream, err := cm.Stream(ctx, messages)
            if err == nil {
                return stream, nil
            }
            if !strings.Contains(err.Error(), "429") || attempt == maxRetries-1 {
                return nil, err
            }
            delay := time.Duration(math.Pow(2, float64(attempt))) * baseDelay
            fmt.Fprintf(os.Stderr, "\n[rate limited, retrying in %s]\n[Assistant] ", delay)
            select {
            case <-time.After(delay):
            case <-ctx.Done():
                return nil, ctx.Err()
            }
        }
        panic("unreachable")
    }
    

交互式完整示例

package main

import (
    "bufio"
    "cc/tools"
    "context"
    "errors"
    "flag"
    "fmt"
    "io"
    "math"
    "os"
    "strings"
    "time"

    "github.com/cloudwego/eino-ext/components/model/openai"
    "github.com/cloudwego/eino/components/model"
    "github.com/cloudwego/eino/schema"
)

func main() {
    var instruction string
    flag.StringVar(&instruction, "instruction", "You are a helpful assistant.", "")
    flag.Parse()

    ctx := context.Background()

    globTool := tools.GlobTool{}
    toolInfo, err := globTool.Info(ctx)
    if err != nil {
        _, _ = fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }

    // tool registry: name → InvokableRun
    toolRegistry := map[string]func(context.Context, string) (string, error){
        globTool.Name(): func(c context.Context, args string) (string, error) {
            return globTool.InvokableRun(c, args)
        },
    }

    cm, err := newChatModel(ctx)
    if err != nil {
        _, _ = fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }

    cm, _ = cm.WithTools([]*schema.ToolInfo{toolInfo})

    fmt.Println("Chat session started. Type 'exit' or 'quit' to end.")
    scanner := bufio.NewScanner(os.Stdin)

    messages := []*schema.Message{
        schema.SystemMessage(instruction),
    }
    for {
        fmt.Print("\n[User] ")
        if !scanner.Scan() {
            break
        }

        query := strings.TrimSpace(scanner.Text())
        if query == "" {
            continue
        }

        if query == "exit" || query == "quit" {
            break
        }

        messages = append(messages, schema.UserMessage(query))

        fmt.Print("[Assistant] ")

        // Agentic loop: keep calling the model until it stops issuing tool calls.
        for {
            stream, err := streamWithRetry(ctx, cm, messages)
            if err != nil {
                fmt.Fprintf(os.Stderr, "\nError: %v\n", err)
                break
            }

            var textContent string
            var toolCallFrames []*schema.Message
            for {
                frame, err := stream.Recv()
                if errors.Is(err, io.EOF) {
                    break
                }
                if err != nil {
                    fmt.Fprintf(os.Stderr, "\nStream error: %v\n", err)
                    break
                }
                if frame == nil {
                    continue
                }
                if frame.Content != "" {
                    textContent += frame.Content
                    fmt.Print(frame.Content)
                }
                if len(frame.ToolCalls) > 0 {
                    toolCallFrames = append(toolCallFrames, frame)
                }
            }
            stream.Close()

            if len(toolCallFrames) == 0 {
                // No tool calls — we have the final answer.
                messages = append(messages, schema.AssistantMessage(textContent, nil))
                break
            }

            // Concatenate streaming tool-call chunks into a single message.
            assistantMsg, err := schema.ConcatMessages(toolCallFrames)
            if err != nil {
                fmt.Fprintf(os.Stderr, "\nError concatenating tool calls: %v\n", err)
                break
            }
            messages = append(messages, assistantMsg)

            // Execute each tool call and append its result.
            for _, tc := range assistantMsg.ToolCalls {
                fn, ok := toolRegistry[tc.Function.Name]
                if !ok {
                    fmt.Fprintf(os.Stderr, "\nUnknown tool: %s\n", tc.Function.Name)
                    messages = append(messages, schema.ToolMessage(
                        fmt.Sprintf("error: unknown tool %q", tc.Function.Name), tc.ID,
                    ))
                    continue
                }
                result, err := fn(ctx, tc.Function.Arguments)
                if err != nil {
                    result = fmt.Sprintf("error: %v", err)
                }
                fmt.Printf("\n[Tool: %s] %s\n[Assistant] ", tc.Function.Name, result)
                messages = append(messages, schema.ToolMessage(result, tc.ID))
            }
            // Continue loop — ask model for next response.
        }

        fmt.Println()
    }
}

// streamWithRetry calls cm.Stream with exponential backoff on 429 errors.
func streamWithRetry(ctx context.Context, cm model.ToolCallingChatModel, messages []*schema.Message) (*schema.StreamReader[*schema.Message], error) {
    const maxRetries = 5
    const baseDelay = time.Second

    for attempt := range maxRetries {
        stream, err := cm.Stream(ctx, messages)
        if err == nil {
            return stream, nil
        }
        if !strings.Contains(err.Error(), "429") || attempt == maxRetries-1 {
            return nil, err
        }
        delay := time.Duration(math.Pow(2, float64(attempt))) * baseDelay
        fmt.Fprintf(os.Stderr, "\n[rate limited, retrying in %s]\n[Assistant] ", delay)
        select {
        case <-time.After(delay):
        case <-ctx.Done():
            return nil, ctx.Err()
        }
    }
    panic("unreachable")
}

func newChatModel(ctx context.Context) (model.ToolCallingChatModel, error) {
    return openai.NewChatModel(ctx, &openai.ChatModelConfig{
        APIKey:  "",
        Model:   "qwen/qwen3.6-plus:free",
        BaseURL: "https://openrouter.ai/api/v1",
    })
}
Chat session started. Type 'exit' or 'quit' to end.

[User] 帮我分析下当前目录的结构
[Assistant] 
[rate limited, retrying in 1s]
[Assistant]
[Tool: Glob] go.sum
go.mod
docs/01.md
docs/02.md
tools/glob.go
main.go

[Assistant] 当前目录结构如下:

```
.
├── main.go          # 主入口文件
├── go.mod           # Go 模块依赖文件
├── go.sum           # Go 依赖校验文件(自动生成)
├── docs/            # 文档目录
   ├── 01.md
   └── 02.md
└── tools/           # 工具包目录
    └── glob.go      # glob 工具源码
```

**说明**:
- **Go 项目**:这是一个标准的 Go 项目,包含 `go.mod` `go.sum` 依赖管理文件,以及一个可执行的入口文件 `main.go`
- **docs/**:存放了一些文档文件(01.md, 02.md),可能是使用说明或笔记
- **tools/glob.go**:一个工具包,从文件名来看,似乎是与文件 glob 匹配功能相关的代码

需要我查看某个具体文件的内容吗?

[User]