Eino Chain Agent

212 阅读5分钟

Eino 文档
Langfuse 官网
Exa 搜索

一. 创建 Tools

定义 tool 时要完成名称、描述定义,输出结果处理等流程
通过支持 function call 的大模型来进行意图识别,返回要调用的 tools

assistant : 用户需要搜索明天杭州的天气,调用 exa_search 函数获取相关信息。
{
    "role": "assistant",
    "content": "用户需要搜索明天杭州的天气,调用 exa_search 函数获取相关信息。",
    "tool_calls": [
        {
            "index": 0,
            "id": "call_0mjxaxv4x0smnh3tn4hi1oa2",
            "type": "function",
            "function": {
                "name": "exa_search",
                "arguments": {
                    "query": "明天杭州的天气"
                }
            }
        }
    ],
    "response_meta": {
        "finish_reason": "tool_calls",
        "usage": {
            "prompt_tokens": 374,
            "completion_tokens": 39,
            "total_tokens": 413
        }
    },
    "extra": {
        "ark-request-id": "0217400164055559141e38cd6c80180b3cca79c4cbce58072cbb7"
    }
}

1. 根据定义工具信息和处理函数

package agent_first

import (
	"context"

	"github.com/cloudwego/eino/components/tool"
	"github.com/cloudwego/eino/components/tool/utils"
	"github.com/cloudwego/eino/schema"
)

// 参数结构体
type TodoAddParams struct {
	Content  string `json:"content"`
	StartAt  *int64 `json:"started_at,omitempty"` // 开始时间
	Deadline *int64 `json:"deadline,omitempty"`
}

// 处理函数
func AddTodoFunc(_ context.Context, params *TodoAddParams) (string, error) {
	// Mock处理逻辑
	return `{"msg": "add todo success"}`, nil
}

func getAddTodoTool() tool.InvokableTool {
	// 工具信息
	info := &schema.ToolInfo{
		Name: "add_todo",
		Desc: "Add a todo item",
		ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
			"content": {
				Desc:     "The content of the todo item",
				Type:     schema.String,
				Required: true,
			},
			"started_at": {
				Desc: "The started time of the todo item, in unix timestamp",
				Type: schema.Integer,
			},
			"deadline": {
				Desc: "The deadline of the todo item, in unix timestamp",
				Type: schema.Integer,
			},
		}),
	}

	// 使用NewTool创建工具
	return utils.NewTool(info, AddTodoFunc)
}

2. 使用InferTool来映射结构体参数

package agent_first

import (
	"context"

	"github.com/cloudwego/eino/components/tool"
	"github.com/cloudwego/eino/components/tool/utils"
)

// 参数结构体
type TodoUpdateParams struct {
	ID        string  `json:"id" jsonschema:"description=id of the todo"`
	Content   *string `json:"content,omitempty" jsonschema:"description=content of the todo"`
	StartedAt *int64  `json:"started_at,omitempty" jsonschema:"description=start time in unix timestamp"`
	Deadline  *int64  `json:"deadline,omitempty" jsonschema:"description=deadline of the todo in unix timestamp"`
	Done      *bool   `json:"done,omitempty" jsonschema:"description=done status"`
}

// 处理函数
func UpdateTodoFunc(_ context.Context, params *TodoUpdateParams) (string, error) {
	// Mock处理逻辑
	return `{"msg": "update todo success"}`, nil
}

func getUpdateTodoTool() tool.InvokableTool {
	// 使用 InferTool 创建工具
	updateTool, err := utils.InferTool(
		"update_todo", // tool name
		"Update a todo item, eg: content,deadline...", // tool description
		UpdateTodoFunc)
	if err != nil {
		panic(err)
	}
	return updateTool
}

3. 通过实现 Tool 接口

package agent_first

import (
	"context"

	"github.com/cloudwego/eino/components/tool"
	"github.com/cloudwego/eino/schema"
)

type ListTodoTool struct{}

func (lt *ListTodoTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
	return &schema.ToolInfo{
		Name: "list_todo",
		Desc: "List all todo items",
		ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
			"finished": {
				Desc:     "filter todo items if finished",
				Type:     schema.Boolean,
				Required: false,
			},
		}),
	}, nil
}

func (lt *ListTodoTool) InvokableRun(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (string, error) {
	// Mock调用逻辑
	return `{"todos": [{"id": "1", "content": "在2024年12月10日之前完成Eino项目演示文稿的准备工作", "started_at": 1717401600, "deadline": 1717488000, "done": false}]}`, nil
}

func getListTodoTool() tool.InvokableTool {
	return &ListTodoTool{}
}

4. 使用官方的 tool

5. 自定义 Exa 搜索

package agent_first

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"log"
	"net/http"

	"github.com/bytedance/sonic"
	"github.com/cloudwego/eino/components/tool"
	"github.com/cloudwego/eino/components/tool/utils"

	"eino-demo/env"
)

type ExaConfig struct {
	APIKey string `json:"api_key"`
}

const (
	ExaSearchBaseURL = "https://api.exa.ai/search"
)

/*
	curl -X POST https://api.exa.ai/search \
  --header "content-type: application/json" \
  --header "Authorization: Bearer " \
  --data '
{
    "query": "startup that has developed a breakthrough AI model",
    "category": "company",
    "type": "keyword",
    "numResults": 3,
    "contents": {
      "text": {
        "maxCharacters": 500
      }
    }
  }'
*/

type ExaSearchParam struct {
	Query string `json:"query" jsonschema:"description=search query string"`
}

type ExaSearchRequest struct {
	Query      string `json:"query" jsonschema:"description=search query string"`
	Category   string `json:"category" jsonschema:"description=search category"`
	Type       string `json:"type" jsonschema:"description=search type"`
	NumResults int    `json:"numResults" jsonschema:"description=number of results returned"`
	Contents   struct {
		Text struct {
			MaxCharacters int `json:"maxCharacters" jsonschema:"description=text maximum character count"`
		} `json:"text" jsonschema:"description=text content"`
	} `json:"contents" jsonschema:"description=search content"`
}

type ExaSearchResponse struct {
	AutopromptString string             `json:"autopromptString"`
	Results          []SearchResultItem `json:"results"`
}

type SearchResultItem struct {
	// 根据 Exa API 的返回结构定义字段
	Title string `json:"title"`
	Text  string `json:"text"`
	Url   string `json:"url"`
}

func getExaTool() tool.InvokableTool {
	conf := &ExaConfig{
		APIKey: env.ExaSearchAPIKey,
	}
	toolName := "exa_search"
	toolDesc := "custom search json api of exa search engine"
	ex := &exaTool{conf: conf}
	tl, err := utils.InferTool(toolName, toolDesc,
		ex.Search, utils.WithMarshalOutput(ex.marshalOutput))
	if err != nil {
		return nil
	}
	return tl
}

type exaTool struct {
	conf *ExaConfig
}

func (t *exaTool) Search(ctx context.Context, query *ExaSearchParam) (*ExaSearchResponse, error) {
	log.Println("search query: ", query)
	reqBody := ExaSearchRequest{
		Query:      query.Query,
		Category:   "company",
		Type:       "keyword",
		NumResults: 3,
	}
	reqBody.Contents.Text.MaxCharacters = 500

	body, err := json.Marshal(reqBody)
	if err != nil {
		return nil, err
	}

	req, err := http.NewRequest("POST", ExaSearchBaseURL, bytes.NewBuffer(body))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+t.conf.APIKey)

	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("failed to search: %s", resp.Status)
	}

	var searchResponse ExaSearchResponse
	if err := json.NewDecoder(resp.Body).Decode(&searchResponse); err != nil {
		return nil, err
	}

	return &searchResponse, nil
}

func (t *exaTool) marshalOutput(_ context.Context, output any) (string, error) {
	// 将输出转换为 ExaSearchResponse 类型
	exaResponse, ok := output.(*ExaSearchResponse)
	if !ok {
		return "", fmt.Errorf("unexpected output type, expect %T but given %T", exaResponse, output)
	}

	// 创建简化的搜索结果
	simpleItems := make([]*SimplifiedSearchItem, 0, len(exaResponse.Results))
	for _, item := range exaResponse.Results {
		ssi := &SimplifiedSearchItem{
			Url:   item.Url,
			Title: item.Title,
			Text:  item.Text,
		}
		simpleItems = append(simpleItems, ssi)
	}

	// 创建最终的搜索结果
	sr := SearchResult{
		Query: exaResponse.AutopromptString,
		Items: simpleItems,
	}

	// 将结果序列化为 JSON 字符串
	return sonic.MarshalString(sr)
}

type SearchResult struct {
	Query string                  `json:"query,omitempty"`
	Items []*SimplifiedSearchItem `json:"items"`
}

type SimplifiedSearchItem struct {
	Url   string `json:"url"`
	Title string `json:"title,omitempty"`
	Text  string `json:"text,omitempty"`
}

二. 创建 Model

	// 创建模型
	chatModel, err := ark.NewChatModel(ctx, &ark.ChatModelConfig{
		BaseURL: "https://ark.cn-beijing.volces.com/api/v3",
		APIKey:  env.HuoshanAPIKey,
		Model:   "doubao-1-5-pro-32k-250115",
	})
	if err != nil {
		log.Fatal(err)
	}

三. 创建 Chain

1. 注册tools

	todoTools := []tool.BaseTool{
		getAddTodoTool(),    // 添加列表工具
		getUpdateTodoTool(), // 更新列表工具
		getListTodoTool(),   // 查询列表工具
		getExaTool(), // 搜索引擎工具
	}

2. 模型绑定tools

	// 获取工具信息并绑定到 ChatModel
	toolInfos := make([]*schema.ToolInfo, 0, len(todoTools))
	for _, tool := range todoTools {
		info, err := tool.Info(ctx)
		if err != nil {
			log.Fatal(err)
		}
		toolInfos = append(toolInfos, info)
	}
	err = chatModel.BindTools(toolInfos)
	if err != nil {
		log.Fatal(err)
	}

3. 创建tools 节点

	// 创建tools节点
	conf := &compose.ToolsNodeConfig{
		// 工具可以是 InvokableTool 或 StreamableTool
		Tools: todoTools,
	}
	toolsNode, err := compose.NewToolNode(ctx, conf)
	if err != nil {
		log.Fatal(err)
	}

4. 构建 chain

	// 构建完整的处理链
	chain := compose.NewChain[[]*schema.Message, *schema.Message]()
	chain.
		AppendChatModel(chatModel, compose.WithNodeName("chat_model")).
		AppendToolsNode(toolsNode, compose.WithNodeName("tools")).
		AppendLambda(compose.InvokableLambda(func(ctx context.Context, input []*schema.Message) ([]*schema.Message, error) {
			return []*schema.Message{
				{
					Role:    schema.Assistant,
					Content: "Only the weather for tomorrow, No superfluous content, format [date, city, weather, temperature]:" + input[0].Content,
				},
			}, nil
		})).
		AppendChatModel(chatModel, compose.WithNodeName("chat_model"))

5. 编译运行

	// 编译chain
	agent, err := chain.Compile(ctx)
	if err != nil {
		log.Fatal(err)
	}
	// 运行chain
	resp, err := agent.Invoke(ctx, []*schema.Message{
		{
			Role:    schema.User,
			Content: "搜索一下明天杭州的天气", // , 然后帮我添加一个todo待办:吃中午饭红烧茄子
		},
	})
	if err != nil {
		log.Fatal(err)
	}

	// 输出结果
	fmt.Println(resp.Content)
	// for _, msg := range resp {
	// 	fmt.Println(msg.Content)
	// }

四. 效果

image.png

五. 链路追踪

image.png

六. 注意事项

1. 节点之间的参数传递规则

模型和 tools 有自己指定的传参类型,参数转换等操作可以通过加入AppendLambda调整

		AppendLambda(compose.InvokableLambda(func(ctx context.Context, input []*schema.Message) ([]*schema.Message, error) {
			return []*schema.Message{
				{
					Role:    schema.Assistant,
					Content: "Only the weather for tomorrow, No superfluous content, format [date, city, weather, temperature]:" + input[0].Content,
				},
			}, nil
		})).

2. 如何确保意图识别可以识别多个tools调用

todo