带你用Eino实现Agent与RAG

2,886 阅读12分钟

什么是Eino?

基于大语言模型的软件应用正处于快速发展阶段,作为应用开发者迫切需要能快速集成LLM、Agent的框架。

然而,目前较为主流的框架如 LangChain,LlamaIndex 等,都基于 Python,虽然能借助 Python 较为丰富的生态快速实现多样的功能,但是同时也继承了 Python 作为动态语言所带来的“弱类型检验”和“长期维护成本高”等问题。

作为在 Golang 领域深耕多年、经验成熟的团队,字节跳动的工程师们基于 Golang 这一强类型语言,开发出了具备高可靠性高可维护性Eino,允许 Golang 开发者快速搭建 Agent 应用。

Eino: 概述 | CloudWeGo

LLM框架需要什么?

也许这个问题有许多答案,但现在的主流框架 LangChain 无疑是个较好的答案。

  • Components。组件化是大型项目必不可少的处理方式,通过将结构划分成多个部分,提高项目的可读性和可复用性。对于一个 LLM 项目来说,需要调用 LLM、读写知识库、大文本分块、文本转向量、提示词管理......。框架需要进行组件的抽象和接口的设计。
  • Chains。各组件的使用是有先后顺序的,一个常见的 RAG 流程就是:获取用户输入->文本向量化->与知识库已有知识匹配->匹配到的知识嵌入提示词模板中->将完整的提示词发送给LLM-> LLM返回与用户输入相关的内容。作为一个便于使用的 LLM 框架,需要提供 Chain 对象,让开发者能快速修改整个流程结构。(当然,对于一些高阶需要,如回路、并行等,也要能提供 Graph 对象以便网状结构的设计)
  • Agents。现在的 LLM 项目并不只是一问一答的 chat 了,还需要在语言模型之上,进行如天气查询、日程添加等一系列与外部交互的操作。在用户看来,就好像一个只会对话的程序变成了一个能干活的智能体。这就需要框架允许开发者自定义工具函数,并让模型根据用户输入去理解应该调用哪个工具函数。同时对于一些带参函数,还要能根据形参类型,生成对应的数据传入工具函数中。

而这些,都在Eino中都有所实现。

Hello Eino Agent

关于与大模型chat的demo,想必大家已经非常熟悉。因此这里直接给出基于eino的Agent Demo,参考自官方文档 Agent-让大模型拥有双手 | CloudWeGo

效果

// main函数
userInput := "搜索评论数在20到30之间、价格小于等于50的红色手机壳"
userPrompt := fmt.Sprintf("%s%s", userTemplate, userInput)
variables := map[string]any{
    "task": userPrompt,
}
resp, err := agent.Invoke(context.Background(), variables)
// 终端日志
1. LLM已生成sql,传入SearchProductFunc并准备与数据库交互: {SQL:SELECT * FROM product WHERE is_deleted=false AND comment_num BETWEEN 20 AND 30 AND price <= 50 AND color = '红色' AND name LIKE '%手机壳%' LIMIT 10 Filename:product_search_result.sql}

2. products: [map[color:红色 comment_num:120 id:1 is_deleted:false name:手机壳1 price:50] map[color:红色 comment_num:130 id:2 is_deleted:false name:2手机壳 price:40]]

实现细节

1. 工具定义与绑定

Tool 的实现方式

在eino中有三种定义Tool的方式,这里选取一种进行展示:

type SearchProductParams struct {
    SQL      string `json:"sql" jsonschema:"description=SQL for searching products."`
}

type SearchProductsResponse struct {
    Success  bool             `json:"success"`
    Message  string           `json:"message"`
    Products []map[string]any `json:"products"`
}

func SearchProductFunc(ctx context.Context, params *SearchProductParams) (SearchProductsResponse, error) {
    fmt.Printf("1. LLM已生成sql,传入了SearchProductFunc并准备与数据库交互: %+v\n", *params)
    // 具体的调用逻辑
    // 如:gorm.find(params.SQL, &products)
    // 假装是查到的数据
    products := []map[string]any{
            {"id": 1, "name": "手机壳1", "color": "红色", "price": 50, "comment_num": 120, "is_deleted": false},
            {"id": 2, "name": "2手机壳", "color": "红色", "price": 40, "comment_num": 130, "is_deleted": false},
    }
    resp := SearchProductsResponse{
            Success:  true,
            Message:  "查询成功",
            Products: products,
    }
    return resp, nil
}

func init() {
    // 工具定义
    searchProductTool, err := utils.InferTool("search_product", "基于用户需求进行商品搜索", SearchProductFunc)
    // 工具数组
    tools := []tool.BaseTool{
        searchProductTool,
    }
    // 工具信息数组
    toolInfos := make([]*schema.ToolInfo, 0, len(tools))
    for _, tool := range tools {
        info, err := tool.Info(ctx)
        toolInfos = append(toolInfos, info)
    }
    // LLM对象
    chatModel, err := openai.NewChatModel(ctx, &openai.ChatModelConfig{...})
    // 将工具信息与LLM绑定
    err = chatModel.BindTools(toolInfos)
    ...
}

想让 LLM 具备与外界交互的能力,首先要给他提供工具函数。同时,还要为工具函数设置对应的描述,在 Eino 与 LLM 交互时,会将工具信息也一起发给LLM。LLM 知晓有哪些工具可调,并结合 Prompt 分析得到要调用哪个工具后,返回给Eino,Eino再拿到相关信息(如函数名、实参)后,主动调用对应的工具函数,这就实现了所谓的 agent 的工具调用。

在这个例子中,SearchProductFunc就是工具函数,LLM负责填充一个SearchProductParams类型的实参,由 Eino 调用该工具,并返回一个自定义类型的结果SearchProductsResponse。在 SearchProductFunc 中实现工具的对应逻辑。在这里是拿到了一个SQL,可以使用 Gorm 进行数据库查询,得到相关数据后返回。在用户看来就是大模型主动与数据库交互、查询数据了。

2. 链的定义

Eino有 Chain 和 Graph 两种组件编排结构,我们这里使用 Chain。在这个Demo中,相关组件有 ChatTemplate、ChatModel、ToolNode。

chatModel, err := openai.NewChatModel(ctx, &openai.ChatModelConfig{...})
toolsNode, err := compose.NewToolNode(ctx, &compose.ToolsNodeConfig{
    Tools: tools,
})
template := prompt.FromMessages(schema.FString,
    &schema.Message{
        Role:    schema.System,
        Content: "你是一个SQL专家。",
    },
    &schema.Message{
        Role:    schema.User,
        Content: "{task}。",
    },
)
// 构建链
chain := compose.NewChain[map[string]any, []*schema.Message]()
chain.
    AppendChatTemplate(template, compose.WithNodeName("template")).
    AppendChatModel(chatModel, compose.WithNodeName("chat_model")).
    AppendToolsNode(toolsNode, compose.WithNodeName("tools"))
// 编译 chain
agent, err = chain.Compile(ctx)

3. 链的调用

链已建好,一直保存在内存中。我们还需要在不同用户发起请求时,将请求参数发给chain,让它处理。注意eino对工具的返回值进行了序列化,因此在获取调用结果后,还要进行反序列化。

// 替代 template 里 UserPrompt 的{task}占位符
userInput := "搜索评论数在20到30之间、价格小于等于50的红色手机壳"
// 这里的 userTemplate 也是一些预设模版
userPrompt := fmt.Sprintf("%s%s", userTemplate, userInput)
variables := map[string]any{
    "task": userPrompt,
}
// 调用
resp, err := agent.Invoke(context.Background(), variables)
// 调用方获取处理结果
var respData SearchProductsResponse
err = json.Unmarshal([]byte(resp[0].Content), &respData)

具体代码见:eino-demo/hello_agent - github

RAG

由于LLM并非全知全能,比如向它询问一个刚面世产品的说明书内容,它就无法准确回答了。而为了一个说明书,重新加训模型又不太值得。在这种情况下,我们可以维护一个动态的知识库,平日里可以对知识库进行知识的增删改。

当用户再对 LLM 询问说明书内容后,应用会先向知识库中匹配有关这个说明书的知识。拿到这部分的文本后,作为一个已知内容,嵌入到Prompt中,再发给LLM。LLM 便能基于自身的理解能力,从Prompt中提取这部分已知信息,用来回答用户的提问。

Eino中的已有组件

在上面的 Agent 例子中,我们已经了解了如何基于 Chain 编排组件。事实上,Eino还提供了下面这些组件。

  • Document Loader:从HTML、PDF、Markdown等文档中解析内容。
  • Document Transformer:将文本进行分块、过滤等转换。
  • Embedding:将文本转换为向量表示。
  • Indexer:将文本及其向量表示存到向量数据库中,即插入知识库。
  • Retriever:根据传入的文本去知识库中匹配,返回匹配到的TopK个知识文档(ID、向量、文本)。

对于初学者来说,可以适当了解和利用,但我认为复杂应用还是要灵活使用Lambda组件。下面会提到。

组件编排

关于前面的Agent例子,读者是否还是一知半解?为什么chain是NewChain[map[string]any, []*schema.Message]chain.AppendXxx的顺序可以变吗?为什么agent.Invoke的返回值resp要取resp[0]?我这里详细讲讲。

在Eino中,Chain实际上是由Graph封装成的易用组件。所以我们这里基于Graph来讲。在Graph上,从入口输入一个数据,经过各个组件处理,最终生成一个输出。但在Graph内部,数据是在不断变化的,比如 map->Message->string->...。既然我们的工作就是组装组件,那么组件的连接处要对应上,才能拼在一起。

image.png

这里给出我在Agent例子中使用到的几个官方写好的组件的接口。

type ChatTemplate interface {
    Format(ctx context.Context, vs map[string]any, opts ...Option) ([]*schema.Message, error)
}

type ChatModel interface {
    Generate(ctx context.Context, input []*schema.Message, opts ...Option) (*schema.Message, error)
    BindTools(tools []*schema.ToolInfo) error
}

可以看到分别有 map[string]any -> []*schema.Message , []*schema.Message -> *schema.Message的转换。

由于暂时没有理解数据是怎么在 ToolNode 组件中转换的,暂且认为数据被转为了[]*schema.Message

对于第一个转换,结果[]*schema.Message其实就对应一些历史记录,比如 [system,user,assistant,user,assistant],而第二个就对应这些历史记录被转为一条最新的输出信息。这两个转换其实就可以实现一个基本的chat了。至于工具调用,似乎不好通过直接的链式实现,暂时没有看懂是怎么做前后组件的出入参类型对齐,只知道是输出的多个工具的调用结果。

那么就可以确定chain的类型了,即chain := compose.NewChain[map[string]any, []*schema.Message]()。传入要填充进template的几个值,最终chain输出若干个工具的调用结果。

实现RAG

重写已经写好的组件

有了一个Agent的demo,并且了解了chain的使用和各组件的类型对齐,我们接下来可以自行实现RAG了。

只要有 Golang 开发经验的都知道,实现对应接口的方法,就可以让自己自定义的组件能作为chain.AppendXxx()的参数。

type Embedder interface {
    EmbedStrings(ctx context.Context, texts []string, opts ...Option) ([][]float64, error)
}

type Retriever interface {
    Retrieve(ctx context.Context, query string, opts ...Option) ([]*schema.Document, error)
}

比如目前官方实现的 Retriever 组件只有es8、redis、volc_knowledge、volc_vikingdb,如果说我有其他的向量数据库选择,比如milvus、pinecone等,我就只能自定义。那么就编写一个自己的type PineconeRetriever struct{}并且实现Retrieve方法,在方法里面使用Pinecone相关的API。

编排设计

我们基于Eino的理念,设计我们的数据流图。基本需求是传入填充 template 的map,传出LLM的输出字符串。那么就是

chain := compose.NewChain[map[string]any, *schema.Message]()

然后,由于还要和知识库做交互,需要把用户输入传给 Retriever 组件,并且他的返回值要修改掉原先 template 组件的 Role 为 User 的 Message。之后再把最新的 Messages 传给 ChatModel ,生成输出内容。

但是,我们发现有些额外的处理过程,比如修改template组件输出的message,没有现成的组件可以用。所以还需要真正的自定义组件,即Lambda组件。这种组件对象创建起来很简单,如下面的,这个组件的行为就是:获取传入的messages的第2条,获得内容并以字符串内容传给下一个组件。

getUserMsgCompose := compose.InvokableLambda(func(_ context.Context, input []*schema.Message) (string, error) {
        return input[1].Content, nil
})

而且还要有些并行来让流程更清晰。那么组件编排结构如下:

image.png

所以这里又要用到Eino中的 Parallel 组件和 Passthrough 组件。Parallel用于标注需要多条并行分支,Passthrough 则用于作为无需处理、直接传数据的流程的占位。所以实际的组件情况如下:

image.png

可以看到有一条 subChain 和一个 Passthrough 作为并行的两个处理流程,他们接收同一个输入,但可以各自传出不同类型的输出。在具体实现中,这两个组件必须要传入唯一组件名称,而 MsgReplacer 则实际上会收到一个map[string]any入参,它可以根据前面提到的组件名称获取对应组件的输出值,这样我们既可以拿到初始的Msgs,又可以拿到处理后的新 UserMessage,替换对应数据后再传给后面的ChatModel。

具体代码

由于本人喜欢白嫖,在查找一番后,选择使用开源的 jina-embeddings-v3 作为embedding模型、Pinecone Database 作为向量数据库、glm-4-flash 作为大语言模型服务(ps: 都是免费的)

代码见 eino-demo/hello_rag - github

效果

我这里通过 LLM 编写了一个不存在的产品的说明书:OmniPort任意门产品说明书 ,同时在里面的售后服务部分有:- **技术支持**:如遇任何问题,请联系我们的客服团队(400-123-4567)。

然后我的用户提问:userInput := "我需要联系OmniPort任意门制造公司的客服,他们的联系电话是多少?"

如果不使用RAG,即

chain.
    AppendChatTemplate(template).
    AppendChatModel(chatModel)

则输出为

很抱歉,但作为一个AI,我无法提供具体的联系电话,因为这些信息通常需要通过官方渠道或公司网站来获取。OmniPort任意门制造公司的客服联系电话通常可以在他们的官方网站的“联系我们”页面找到。......

而使用RAG,即

chain.
    AppendChatTemplate(template).
    AppendParallel(parallel).
    AppendLambda(ragReplacer).
    AppendChatModel(chatModel)

则能得到想要的结果

您好,OmniPort任意门制造公司的客服联系电话是400-123-4567。如有任何问题,欢迎随时拨打此电话咨询。祝您使用愉快!

个人评价

理念好,Golang好,文档坏。不过还在项目初期,可以理解。

有利于减少Golang开发者进行大模型应用开发的心智负担。

套盾

本文是在青训营期间接触Eino、深度使用后,诞生出的一篇教程文章,既是自学,也是为他人引路。虽然对很多Eino的很多内容都未涉及,比如Callback、EinoDev等,但已经可以最基本的实现Agent、为项目集成大模型相关了。但有部分细节未详细说明,比如Document和Message的参数等,还是需要读者对Eino的官方文档进行一定的浏览。

有写的不好的地方还请谅解与指正,谢谢!