什么是Eino?
基于大语言模型的软件应用正处于快速发展阶段,作为应用开发者迫切需要能快速集成LLM、Agent的框架。
然而,目前较为主流的框架如 LangChain,LlamaIndex 等,都基于 Python,虽然能借助 Python 较为丰富的生态快速实现多样的功能,但是同时也继承了 Python 作为动态语言所带来的“弱类型检验”和“长期维护成本高”等问题。
作为在 Golang 领域深耕多年、经验成熟的团队,字节跳动的工程师们基于 Golang 这一强类型语言,开发出了具备高可靠性和高可维护性的Eino,允许 Golang 开发者快速搭建 Agent 应用。
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. 工具定义与绑定
在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->...。既然我们的工作就是组装组件,那么组件的连接处要对应上,才能拼在一起。
这里给出我在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
})
而且还要有些并行来让流程更清晰。那么组件编排结构如下:
所以这里又要用到Eino中的 Parallel 组件和 Passthrough 组件。Parallel用于标注需要多条并行分支,Passthrough 则用于作为无需处理、直接传数据的流程的占位。所以实际的组件情况如下:
可以看到有一条 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的官方文档进行一定的浏览。
有写的不好的地方还请谅解与指正,谢谢!