前言
随着大型语言模型(LLM)的浪潮席卷整个技术圈,如何将这些强大的"大脑"集成到我们的应用程序中,成为了一个热门话题。对于广大的 Go 开发者而言,我们同样需要一个顺手的工具来编排和构建复杂的 LLM 工作流。Eino 框架应运而生,它借鉴了 Python 社区 LangChain 的成功经验,为 Go 语言带来了声明式、可组合、易于扩展的 AI 应用开发范式。
与此同时,Ollama 的出现极大地降低了在本地环境运行开源 LLM 的门槛。它让开发者可以在自己的机器上轻松部署和调用各种模型,无需依赖昂贵的云服务,这为快速原型设计和保护数据隐私提供了极大的便利。
本文将作为一篇由浅入深的实战教程,带领你将 Eino 与 Ollama 这对强大的组合运用起来。我们将从一个最简单的问答机器人开始,逐步深入,最终构建一个具备智能路由、质量检查和自动重试能力的复杂 Agent。
第一部分:环境准备
工欲善其事,必先利其器。在开始编码之前,我们需要先搭建好本地的 LLM 运行环境和 Go 开发环境。
1. 安装并运行 Ollama
Ollama 支持 macOS、Windows 和 Linux。这里我们以 macOS 为例,使用 Homebrew 进行安装是最便捷的方式。
bash
# 1. 使用 Homebrew 安装 Ollama
brew install ollama
# 2. 启动 Ollama 服务(它会在后台运行)
ollama serve
服务启动后,Ollama 会在本地 11434 端口上提供一个与 OpenAI API 兼容的接口,这是我们稍后连接它的关键。
2. 拉取本地模型
Ollama 支持众多开源模型。在我们的教程中,将使用 qwen2.5:0.5b(通义千问 0.5B 版本)作为主力模型。它体积小、速度快,非常适合在普通笔记本电脑上进行开发和测试。
bash
# 从 Ollama Hub 拉取 qwen 0.5B 模型
ollama pull qwen2.5:0.5b
你可以通过 ollama list 命令来查看本地已有的模型。
3. 初始化 Go 项目
现在,让我们来设置 Go 项目并安装 Eino 框架。
bash
# 1. 创建项目目录
mkdir eino-ollama-demo
cd eino-ollama-demo
# 2. 初始化 Go Module
go mod init github.com/your-username/eino-ollama-demo
# 3. 安装 Eino 核心库和 OpenAI 组件
# 我们使用 OpenAI 组件是因为 Ollama 提供了兼容的 API
go get github.com/cloudwego/eino
go get github.com/cloudwego/eino-ext/components/model/openai
至此,我们的开发环境已经准备就绪!
第二部分:Eino 入门:创建你的第一个 LLM 应用
让我们从一个最简单的场景开始:向模型提一个问题,然后打印它的回答。这能帮助我们理解 Eino 最核心的概念。
Eino 核心概念
Eino 的核心思想是图(Graph) 。你可以将一个复杂的 AI 任务拆解成一个个独立的节点(Node) ,然后用边(Edge)将它们连接起来,定义数据的流向和处理逻辑。最终,这张图会被 编译(Compile)成一个可执行的对象(Runnable)。
- Graph: 构建工作流的"画布"。
- Node : 图中的操作单元。最常用的有:
ChatModelNode: 专门用于和 LLM 进行交互的节点。它的输入是标准的消息格式([]*schema.Message),输出是模型的回复(*schema.Message)。LambdaNode: 一个通用的"瑞士军刀",可以封装任意自定义的 Go 函数。它极其灵活,通常用于:- 数据转换 :将一种数据类型转换为另一种,例如将简单的字符串输入包装成
ChatModelNode需要的[]*schema.Message格式。 - Prompt 格式化:根据输入动态生成复杂的提示词。
- 输出解析:从模型的原始输出中提取和清理需要的信息,例如从一段文本中解析出 "code" 或 "general" 这样的分类标签。
- 数据转换 :将一种数据类型转换为另一种,例如将简单的字符串输入包装成
BranchNode: 实现工作流的条件分支。它内部包含一个函数,该函数根据输入动态地返回下一个应该执行的节点的名称。这使得图可以根据不同的情况执行不同的路径,是实现智能路由的关键。
- Edge: 定义数据在节点之间的流向。
- Runnable: 由图编译而成的可执行实例。
如何选择节点?
- 当你需要与 LLM 对话时,使用
ChatModelNode。 - 当你需要进行数据格式化、自定义逻辑处理、或者在调用模型前后进行预处理/后处理时,使用
LambdaNode。它是连接不同节点的"胶水"。 - 当你需要根据某个条件动态地决定下一步走向时(例如,根据问题类型选择不同的专家模型),使用
BranchNode。
代码实战:Simple Chain
go
package main
import (
"context"
"fmt"
"github.com/cloudwego/eino-ext/components/model/openai"
"github.com/cloudwego/eino/compose"
"github.com/cloudwego/eino/schema"
)
func main_simple(ctx context.Context) {
// 1. 配置并创建模型实例
qwenConfig := &openai.ChatModelConfig{
BaseURL: "http://localhost:11434/v1", // Ollama 端点
APIKey: "ollama", // 任意字符串
Model: "qwen2.5:0.5b",
}
qwenModel, _ := openai.NewChatModel(ctx, qwenConfig)
// 2. 创建图 (Graph)
sg := compose.NewGraph[string, *schema.Message]()
// 3. 添加节点 (Node)
// 节点1: LambdaNode,将 string 类型的输入转换成模型需要的 []*schema.Message
formatInput := compose.InvokableLambda(func(ctx context.Context, input string) ([]*schema.Message, error) {
return []*schema.Message{{Role: "user", Content: input}}, nil
})
_ = sg.AddLambdaNode("format_input", formatInput)
// 节点2: ChatModelNode,代表我们的大模型
_ = sg.AddChatModelNode("qwen_model", qwenModel)
// 4. 添加边 (Edge),定义数据流
_ = sg.AddEdge(compose.START, "format_input") // 数据从起点流入 format_input
_ = sg.AddEdge("format_input", "qwen_model") // format_input 的输出是 qwen_model 的输入
_ = sg.AddEdge("qwen_model", compose.END) // qwen_model 的输出是整个链的最终输出
// 5. 编译 (Compile) 成 Runnable
simpleChain, _ := sg.Compile(ctx)
// 6. 执行 (Invoke)
result, _ := simpleChain.Invoke(ctx, "Go语言的主要特点是什么?")
fmt.Println(result.Content)
}
这段代码清晰地展示了 Eino 的工作流程:定义节点 -> 连接节点 -> 编译 -> 执行。通过这种方式,我们用声明式的代码构建了一个简单但完整的 LLM 调用链。
第三部分:智能路由:让模型学会"分工合作"
单一模型无法胜任所有任务。一个更实际的场景是:我们有一个轻量级的"调度"模型,负责判断用户问题的类型,然后将问题分发给相应的"专家"模型(例如,一个擅长编码,一个擅长通用知识)。这就是 Agent 和路由思想的雏形。
核心思路:用链来控制链
我们将构建两个层次的链:
- 调度链(Dispatcher Chain):一个独立的、小而快的链,它的唯一职责是接收用户问题,并输出问题的分类(例如 "code" 或 "general")。
- 主路由图(Main Router Graph) :它包含两个并行的专家模型节点(
coder_expert和general_expert)。它使用一个特殊的BranchNode来调用"调度链",并根据其结果,动态地决定数据应该流向哪个专家节点。
代码实战:Router Chain
下面的代码展示了如何构建和编排调度链与主路由图。
go
package main
import (
"context"
"fmt"
"log"
"strings"
"time"
"github.com/cloudwego/eino-ext/components/model/openai"
"github.com/cloudwego/eino/compose"
"github.com/cloudwego/eino/schema"
)
// main_router 演示路由链:根据问题类型选择不同模型
func main_router(ctx context.Context) {
// 1. 创建三个模型客户端
// ... (模型创建代码,与之前类似)
fmt.Println("✅ 所有模型客户端创建成功")
// 2. 创建调度器链 (Dispatcher Chain)
dispatcherGraph := compose.NewGraph[string, string]()
// 2.1 格式化调度提示
_ = dispatcherGraph.AddLambdaNode("format_prompt", compose.InvokableLambda(
func(ctx context.Context, userQuestion string) ([]*schema.Message, error) {
prompt := fmt.Sprintf(`You are a routing expert. Your task is to classify the user's question into one of two categories: "code" or "general". You must respond with ONLY the category name and nothing else.\n\n- "code": Questions about programming, algorithms, data structures, software development, and code.\n- "general": Questions about general knowledge, history, science, writing, or explaining concepts.\n\nQuestion: "%s"\nResponse:`, userQuestion)
return []*schema.Message{{Role: "user", Content: prompt}}, nil
},
))
// 2.2 添加调度模型
_ = dispatcherGraph.AddChatModelNode("dispatcher_model", dispatcherModel)
// 2.3 提取决策
_ = dispatcherGraph.AddLambdaNode("extract_decision", compose.InvokableLambda(
func(ctx context.Context, response *schema.Message) (string, error) {
decision := strings.TrimSpace(strings.ToLower(response.Content))
if decision == "code" {
return "code", nil
}
// 默认返回 general,确保鲁棒性
return "general", nil
},
))
// 2.4 连接调度链
_ = dispatcherGraph.AddEdge(compose.START, "format_prompt")
_ = dispatcherGraph.AddEdge("format_prompt", "dispatcher_model")
_ = dispatcherGraph.AddEdge("dispatcher_model", "extract_decision")
_ = dispatcherGraph.AddEdge("extract_decision", compose.END)
// 2.5 编译调度链
dispatcherRunnable, err := dispatcherGraph.Compile(ctx)
if err != nil {
log.Fatalf("❌ 编译调度链失败: %v", err)
}
fmt.Println("✅ 调度链编译成功")
// 3. 创建主路由图 (Main Router Graph)
routerGraph := compose.NewGraph[string, *schema.Message]()
// 3.1 添加一个节点,将用户问题(string)转换为模型输入([]*schema.Message)
_ = routerGraph.AddLambdaNode("format_input", compose.InvokableLambda(
func(ctx context.Context, userQuestion string) ([]*schema.Message, error) {
return []*schema.Message{{Role: "user", Content: userQuestion}}, nil
},
))
// 3.2 添加专家模型节点
_ = routerGraph.AddChatModelNode("coder_expert", coderModel)
_ = routerGraph.AddChatModelNode("general_expert", generalModel)
// 3.3 定义路由逻辑 (Branch Condition)
// branchCondition 的输入是 format_input 节点的输出 ([]*schema.Message)
branchCondition := func(ctx context.Context, messages []*schema.Message) (string, error) {
userQuestion := messages[0].Content
fmt.Printf("🔄 调度器正在分析问题: %s\n", userQuestion)
// 在分支逻辑内部,调用已编译的调度链!
decision, err := dispatcherRunnable.Invoke(ctx, userQuestion)
if err != nil {
return "general_expert", err // 出错时默认到通用专家
}
fmt.Printf(" 调度决策: %s\n", decision)
if decision == "code" {
fmt.Println(" 路由到代码专家 (qwen2.5:0.5b)")
return "coder_expert", nil // 返回下一个节点的名称
}
fmt.Println(" 路由到通用专家 (qwen2.5:0.5b)")
return "general_expert", nil
}
branch := compose.NewGraphBranch(branchCondition, map[string]bool{
"coder_expert": true, // 声明分支可能的目标节点
"general_expert": true,
})
// 3.4 构建图结构
_ = routerGraph.AddEdge(compose.START, "format_input")
_ = routerGraph.AddBranch("format_input", branch) // 在 format_input 后添加分支
_ = routerGraph.AddEdge("coder_expert", compose.END) // 两个专家都连接到终点
_ = routerGraph.AddEdge("general_expert", compose.END)
// 4. 编译主路由图
finalRunnable, err := routerGraph.Compile(ctx)
if err != nil {
log.Fatalf("❌ 编译主路由图失败: %v", err)
}
fmt.Println("✅ 智能路由图编译成功")
// ... (测试用例执行代码)
}
通过 AddBranch,我们赋予了 Eino 图动态决策的能力。这种"链中调链"的模式是构建复杂 Agent 的基础,它让我们可以将不同的功能模块(如调度、执行、总结等)解耦,然后灵活地编排它们。
第四部分:高级技巧:为你的 Agent 增加"质检"与"重试"
智能路由解决了任务分发,但如果专家模型给出的答案质量不佳怎么办?我们可以再增加一个"质检"环节,如果答案不合格,就让它重做一次。这让我们的系统具备了自我修正的能力。
核心思路:解耦所有组件,外部循环编排
这次我们不再构建一个巨大的、包含所有逻辑的图。而是将每个核心功能都编译成一个独立的 Runnable:
dispatcherRunnable:负责调度。coderRunnable:代码专家。generalRunnable:通用专家。qaRunnable:质检员。
然后,我们在 Go 代码中用一个简单的 for 循环来编排它们的执行顺序,实现"执行 -> 质检 -> (失败则)重试"的逻辑。
代码实战:Self-Correcting Agent
下面的代码展示了如何将独立的 Runnable 组件在外部循环中进行编排,以实现重试逻辑。
go
// ... (创建 dispatcherRunnable, coderRunnable, generalRunnable, qaRunnable)
// 遍历测试用例
for _, tc := range testCases {
fmt.Printf("\n🔍 测试用例: %s\n", tc.name)
fmt.Printf("问题: %s\n", tc.question)
// --- 调度步骤 ---
fmt.Println("🔄 调度器正在分析问题...")
decision, err := dispatcherRunnable.Invoke(ctx, tc.question)
if err != nil {
log.Fatalf("❌ 调度失败: %v", err)
}
fmt.Printf(" 调度决策: %s\n", decision)
var selectedExpertRunnable compose.Runnable[string, *schema.Message]
if decision == "code" {
fmt.Println(" 路由到代码专家 (qwen2.5:0.5b)")
selectedExpertRunnable = coderRunnable
} else {
fmt.Println(" 路由到通用专家 (qwen2.5:0.5b)")
selectedExpertRunnable = generalRunnable
}
const MAX_RETRIES = 3
var finalResult *schema.Message
var finalErr error
// --- 重试循环 ---
for i := 0; i < MAX_RETRIES; i++ {
fmt.Printf("🚀 第 %d/%d 次尝试 (使用 %s 专家)...\n", i+1, MAX_RETRIES, decision)
result, err := selectedExpertRunnable.Invoke(ctx, tc.question)
finalResult = result
finalErr = err
if err != nil {
log.Printf("❌ 执行失败: %v", err)
break // 执行出错,直接中断重试
}
fmt.Printf(" 初步回答: %s\n", result.Content)
// --- 质检步骤 ---
fmt.Println("🔍 正在进行质量检查...")
quality, qaErr := qaRunnable.Invoke(ctx, &QAInput{Question: tc.question, Answer: result.Content})
if qaErr != nil {
log.Printf("⚠️ 质检步骤失败: %v. 接受当前结果。", qaErr)
break // 质检本身出错,不再重试,接受当前结果
}
fmt.Printf(" 质检结果: %s\n", quality)
if quality == "good" {
fmt.Println("✅ 质检通过,结果被接受。")
break // 质量合格,跳出重试循环
} else {
fmt.Println("❌ 质检未通过,正在重试...")
if i == MAX_RETRIES-1 {
fmt.Println("达到最大重试次数,接受最后一次结果。")
}
}
}
// ... 打印最终结果
}
这种模式的优势在于:
- 高度解耦 :每个
Runnable都是一个独立的、可测试、可复用的组件。 - 逻辑清晰 :复杂的业务逻辑(如重试、熔断)可以用原生的 Go 代码(
for,if)来表达,比在图结构中定义更加直观和灵活。 - 可扩展性强 :未来如果想加入新工具(如搜索引擎、数据库查询),我们只需将其封装成一个新的
Runnable,然后在编排逻辑中调用它即可。
总结
通过本次旅程,我们从一个简单的 Eino "Hello World"出发,一步步构建了一个具备智能路由和自我修正能力的复杂 Agent。我们学习了:
- 如何使用 Ollama 在本地搭建高效、免费的 LLM 服务环境。
- Eino 框架的基本概念:Graph 、Node 和 Runnable,以及如何选择它们。
- 如何使用 BranchNode 实现工作流的动态路由。
- 如何通过解耦组件 和外部编排,构建出逻辑清晰、可扩展性强的复杂 AI 应用。
Eino 作为一个 Go-native 的 LLM 应用框架,其声明式的图构建方式和强大的可组合性,为 Go 开发者打开了通往 AI 应用世界的大门。希望这篇博客能成为你探索 Eino 和大模型应用的起点。现在,就动手实践,构建你自己的智能应用吧!