【极客日常】用Eino+Ollama低成本研发LLM的Agent

十一国庆正是充电的好时机,借着假期时间充裕,笔者又浅调研了一下本地LLM开发相关的工具链,看下如果是日常业余搞个人LLM的Agent项目,具体有哪些能力可用。工业界的话,因为知识保密性等各种原因,我们可能会用到兄弟部门的LLM模型或者相关Agent能力,以及市面上收费但企业内部免费的一些技术基建。但如果是个人搞LLM应用开发,就更加倾向于看有没有低成本甚至免费的办法去做本地研发了。

基于这个目的,经过一番调研实操,发现只需要一个Agent开发框架加上模型Provider就能解决问题。因此本文就介绍一下,以Agent开发框架Eino,加上Ollama这个模型Provider,如何能够低成本研发LLM的Agent。针对这个主题,虽然以前也写过用Coze开源版研发的Case,但Coze本身作为一套工业界产品基建,直接拿它工作还是比较重的,本文暂且只讨论一些比较轻量的事情。

首先咱们需要理解模型对标现实中的啥,具体怎么提升生产力。按笔者粗浅理解的话,一个模型实例就相当于一个大脑,它节省开发者工作量的地方在于,以前的程序是开发者一行行代码编写出来的,而现在我们可以通过微调或者工具增强等方式定制化一个大脑,使得在尽可能减少确定性折损的条件下,低成本做多模态的数据转换,甚至实现另一套我们需要的程序。不管这个理解是不是精确,但至少有了这个想法的话,开发一个LLM应用思路会清晰的多。

在本地,我们可以借助Ollama工具管理多个大脑,每个大脑有不同的能力,比如gemma3可以处理视觉信息,qwen3可以做外部工具识别,bge-m3可以做文本向量化(embedding),deepseek-r1具备自思考能力,然后基本上每个模型都有问答能力,等等。在具体实现上,我们可以组合不同的模型,打造一套完善的Agent。

比方说有用户问,想要去某个图片里面的地方旅游,有什么方案?那么我们的Agent可以实现成,首先借助deepseek-r1的思考能力做意图识别,发现问题包含额外图片信息,之后就调用gemma3模型(或是封装的Agent)做图片识别,识别图片里的关键地标信息,再之后结合向量数据库跟我们通过bge-m3模型embed的大量文本,我们可以构建一套地理知识库,在这个知识库里检索到这个地标对应的城市,最后再借助qwen3以及外部高德地图等工具,规划出一套完整的旅行方案,回给主脑deepseek-r1吐出来。具体怎么管理Ollama的模型,可以参考Ollama官方文档

为了实现这样的编排,我们需要有一套Agent开发框架,常见的就是基于Python的LangChain以及基于Golang的Eino。本文以Eino为例子,Eino内部有封装对Ollama的调用,所以通过Eino连接Ollama模型也比较简单,示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
func (a *EinoOllamaAgent) Run(ctx context.Context) {
// connect local ollama model
model, err := ollama.NewChatModel(ctx, &ollama.ChatModelConfig{
// 基础配置
BaseURL: ollamaURL, // Ollama 服务地址,通常为http://localhost:11434
Timeout: 30 * time.Second, // 请求超时时间

// 模型配置
Model: qwen3Model, // 模型名称,比如qwen3:latest
Format: json.RawMessage(`"json"`), // 输出格式(可选)

// 模型参数
Options: &api.Options{
Temperature: 0.7,
NumPredict: 8192,
},

// 推理配置
Thinking: &api.ThinkValue{Value: false},
})
if err != nil {
panic(errors.Errorf("create ollama chat model failed: %v", err))
}

messages := []*schema.Message{
schema.SystemMessage("你是一个助手"),
schema.UserMessage("请用一句话介绍Ollama"),
}

// 普通模式
response, err := model.Generate(ctx, messages)
if err != nil {
panic(errors.Errorf("generate msg failed: %v", err))
}
fmt.Printf("resp: %s\n", response.Content)
}

如果是需要构建知识库的场景,那么我们需要做的一是把embedding模型当成通用文本向量化工具,不单独写一套代码,二是引入一个向量数据库,持久化文本向量,提供知识访问能力。如果用Eino实现的话,先给一个以内存作为向量数据库的最简单例子,当然Eino本身也有很多向量数据库Client的抽象,此处不赘述了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
type Doc struct {
ID int
Content string
Embedding []float64
}

type EinoOllamaKnowledge struct {
docs map[int]*Doc
embedder *openai.Embedder
idIncr int
idMtx sync.Mutex
}

func NewEinoOllamaKnowledge(ctx context.Context) *EinoOllamaKnowledge {
embedder, err := openai.NewEmbedder(ctx, &openai.EmbeddingConfig{
BaseURL: ollamaV1URL, // Ollama服务v1地址,兼容OpenAI接口
Model: embedModel, // 模型名称,比如bge-m3:latest
Timeout: 30 * time.Second,
})
if err != nil {
panic(errors.Errorf("create ollama embed model failed: %v", err))
}
return &EinoOllamaKnowledge{
docs: make(map[int]*Doc),
embedder: embedder,
}
}

func (k *EinoOllamaKnowledge) Run(ctx context.Context) {
texts := []string{
"床前明月光,疑是地上霜。举头望明月,低头思故乡。",
"离离原上草,一岁一枯荣。野火烧不尽,春风吹又生。",
"白日依山尽,黄河入海流。欲穷千里目,更上一层楼。",
"煮豆燃豆萁,豆在釜中泣。本是同根生,相煎何太急。",
"鹅鹅鹅,曲项向天歌。白毛浮绿水,红掌拨清波。",
}
k.AddDocs(ctx, texts)

queries := []string{
"韧性",
"登高",
"夜晚",
"动物",
"兄弟",
}
for _, q := range queries {
doc := k.FindMostSimilarDoc(ctx, q)
if doc != nil {
fmt.Printf("query: %s, most similar doc: %s\n", q, doc.Content)
} else {
fmt.Printf("query: %s, no similar doc found\n", q)
}
}
}

func (k *EinoOllamaKnowledge) genID() int {
k.idMtx.Lock()
defer k.idMtx.Unlock()
k.idIncr++
return k.idIncr
}

func (k *EinoOllamaKnowledge) AddDocs(ctx context.Context, texts []string) {
embeddings, err := k.embedder.EmbedStrings(ctx, texts)
if err != nil {
panic(errors.Errorf("generate embedding failed: %v", err))
}
if len(embeddings) != len(texts) {
panic(errors.Errorf("embedding count not equal to text count: %d != %d", len(embeddings), len(texts)))
}

for i := 0; i < len(texts); i++ {
id := k.genID()
doc := &Doc{
ID: id,
Content: texts[i],
Embedding: embeddings[i],
}
k.docs[id] = doc
}
}

func (k *EinoOllamaKnowledge) GetDoc(id int) *Doc {
if doc, ok := k.docs[id]; ok {
return doc
}
return nil
}

// FindMostSimilarDoc 最简单的查找最相似文档的实现
func (k *EinoOllamaKnowledge) FindMostSimilarDoc(ctx context.Context, text string) *Doc {
if text == "" || len(k.docs) == 0 {
return nil
}

embeddings, err := k.embedder.EmbedStrings(ctx, []string{text})
if err != nil {
panic(errors.Errorf("generate embedding failed: %v", err))
}
if len(embeddings) != 1 {
panic(errors.Errorf("embedding count not equal to text count: %d != %d", len(embeddings), 1))
}
queryEmbedding := embeddings[0]

cosineSimilarity := func(a, b []float64) float64 {
if len(a) != len(b) {
return 0
}
var dotProduct, normA, normB float64
for i := range a {
dotProduct += a[i] * b[i]
normA += a[i] * a[i]
normB += b[i] * b[i]
if normA == 0 || normB == 0 {
return 0
}
}
return dotProduct / (math.Sqrt(normA) * math.Sqrt(normB))
}

var mostSimilar *Doc
maxScore := -1.0
for _, doc := range k.docs {
score := cosineSimilarity(queryEmbedding, doc.Embedding)
fmt.Printf("[FindMostSimilarDoc] query: %s, doc: %s, score: %v\n", text, doc.Content, score)
if score > maxScore {
maxScore = score
mostSimilar = doc
}
}
return mostSimilar
}

值得一提的是,如果这段代码转成Python也是比较容易的,比如Trae这种善于处理代码任务的Agent就可以做不同语言代码转换。假使用LangChain实现,外加ChromaDB本地持久化向量文本的话,可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
from langchain_community.embeddings import OllamaEmbeddings
from langchain_community.vectorstores import Chroma
from langchain.schema import Document
import uuid
import os
import shutil


class OllamaKnowledge:
def __init__(self, model="bge-m3:latest", ollama_base_url="http://localhost:11434",
persist_directory="./chroma_db"):
# 初始化Ollama嵌入模型
self.embeddings = OllamaEmbeddings(
model=model,
base_url=ollama_base_url
)

# 初始化Chroma向量存储
self.vector_store = Chroma(
embedding_function=self.embeddings,
persist_directory=persist_directory
)

def add_docs(self, texts):
"""添加文档到向量数据库"""
documents = []
for text in texts:
# 为每个文档生成唯一ID
doc_id = str(uuid.uuid4())
# 创建LangChain文档对象
document = Document(
page_content=text,
metadata={"id": doc_id}
)
documents.append(document)

# 将文档添加到向量存储
self.vector_store.add_documents(documents)
# 持久化存储
self.vector_store.persist()

def find_most_similar_doc(self, query, k=1):
"""查找与查询最相似的文档"""
if not query:
return None

# 执行相似度搜索
results = self.vector_store.similarity_search_with_score(query, k=k)

if not results:
return None

# 返回最相似的文档
most_similar_doc, score = results[0]
return most_similar_doc, score

def run_demo(self):
"""运行演示:添加文档并执行查询"""
# 示例文档(唐诗)
texts = [
"床前明月光,疑是地上霜。举头望明月,低头思故乡。",
"离离原上草,一岁一枯荣。野火烧不尽,春风吹又生。",
"白日依山尽,黄河入海流。欲穷千里目,更上一层楼。",
"煮豆燃豆萁,豆在釜中泣。本是同根生,相煎何太急。",
"鹅鹅鹅,曲项向天歌。白毛浮绿水,红掌拨清波。",
]

# 添加文档
print("正在添加文档到向量数据库...")
self.add_docs(texts)
print(f"成功添加了 {len(texts)} 篇文档\n")

# 查询示例
queries = ["韧性", "登高", "夜晚", "动物", "兄弟"]

for q in queries:
result = self.find_most_similar_doc(q)
if result:
doc, score = result
print(f"查询: {q}")
print(f"最相似的文档: {doc.page_content}")
print(f"相似度得分: {score:.4f}\n")
else:
print(f"查询: {q}, 未找到相似文档\n")


# 主函数
if __name__ == "__main__":
# 创建OllamaKnowledge实例
knowledge = OllamaKnowledge()
# 运行演示
knowledge.run_demo()

对于复杂编排,除了可以考虑用Dify之类的可视化工具做之外,纯程序的话,Eino也提供了一套ADK框架封装了更复杂的Agent编排功能。除了最基础的ChatModelAgent之外,再往上实现的是WorkflowAgents,里面包括Sequential、Loop以及Parallel等编排,也就是行为树的翻版,然后再继续往上就实现了Supervisor以及Plan-Execute两类封装好的应用级编排。

对于调研类任务的话,有一个封装好的Plan-Execute编排,加上靠谱的数据处理模型,就可以实现一个简单的调研类Agent:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
type EinoAdkAgent struct {
runner *adk.Runner
}

func NewEinoAdkAgent() *EinoAdkAgent {
a := &EinoAdkAgent{}
if err := a.init(context.Background()); err != nil {
panic(errors.Errorf("initialize EinoAdkAgent failed: %v", err))
}
return a
}

func (a *EinoAdkAgent) Run(ctx context.Context) {
userInput := []adk.Message{
schema.UserMessage("请用中文回答如何写一篇100000字的科幻小说?"),
}
events := a.runner.Run(ctx, userInput)
for {
event, ok := events.Next()
if !ok {
break
}
if event.Err != nil {
log.Printf("执行错误: %v", event.Err)
break
}
// 打印智能体输出(计划、执行结果、最终响应等)
if msg, err := event.Output.MessageOutput.GetMessage(); err == nil && msg.Content != "" {
log.Printf("\n=== Agent [%s] Output ===\n%s\n", event.AgentName, msg.Content)
}
}
}

func (a *EinoAdkAgent) init(ctx context.Context) error {
// init chat model
chatModel, err := a.initChatModel(ctx)
if err != nil {
return errors.Errorf("create ollama chat model failed: %v", err)
}
var agent adk.Agent

// init plan-executor
planExecutor, err := a.initPlanExecutor(ctx, chatModel)
if err != nil {
return errors.Errorf("create plan-executor agent failed: %v", err)
}
agent = planExecutor

// init runner
a.runner = adk.NewRunner(ctx, adk.RunnerConfig{Agent: agent, EnableStreaming: true})
return nil
}

func (a *EinoAdkAgent) initChatModel(ctx context.Context) (model.ToolCallingChatModel, error) {
return ollama.NewChatModel(ctx, &ollama.ChatModelConfig{
// 基础配置
BaseURL: ollamaURL, // Ollama 服务地址
Timeout: 300 * time.Second, // 请求超时时间

// 模型配置
Model: qwen3Model, // 模型名称
// Format: json.RawMessage(`"json"`), // 输出格式(可选)

// 模型参数
Options: &api.Options{
NumPredict: 4096,
},

// 推理配置
Thinking: &api.ThinkValue{Value: false},
})
}

func (a *EinoAdkAgent) initPlanExecutor(ctx context.Context, chatModel model.ToolCallingChatModel) (adk.Agent, error) {
// init planner
planner, err := planexecute.NewPlanner(ctx, &planexecute.PlannerConfig{
ToolCallingChatModel: chatModel,
ToolInfo: &planexecute.PlanToolInfo, // 默认 Plan 工具 schema
})
if err != nil {
return nil, errors.Errorf("create planner agent failed: %v", err)
}

// init executor
execAgent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
Name: "AnySolver",
Description: "你是一个专业的解答者,能够为任意问题生成解答方案。",
Instruction: "你只能根据用户的问题,生成具体可执行的解答方案,不能生成任何与问题无关的内容。",
Model: chatModel,
MaxIterations: 1,
})
if err != nil {
return nil, errors.Errorf("create executor chat model agent failed: %v", err)
}
execTool := adk.NewAgentTool(ctx, execAgent) // 一个纯ChatModel占位,MCP基本收费,先不管
executor, err := planexecute.NewExecutor(ctx, &planexecute.ExecutorConfig{
Model: chatModel,
MaxIterations: 3,
ToolsConfig: adk.ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{execTool},
},
},
})
if err != nil {
return nil, errors.Errorf("create executor agent failed: %v", err)
}

// init replanner
replanner, err := planexecute.NewReplanner(ctx, &planexecute.ReplannerConfig{
ChatModel: chatModel,
})
if err != nil {
return nil, errors.Errorf("create replanner agent failed: %v", err)
}

// init plan-executor agent
planExecuteAgent, err := planexecute.New(ctx, &planexecute.Config{
Planner: planner,
Executor: executor,
Replanner: replanner,
MaxIterations: 10,
})
if err != nil {
return nil, errors.Errorf("create plan-execute agent failed: %v", err)
}
return planExecuteAgent, nil
}

最后,如果说要把Agent效果继续优化的话,先是要有一套完善的评测系统,然后也需要有一个Trace工具了解整个Agent链路上的弱点,最后可以从工具、Prompt、模型FineTune等很多角度去做优化,从而不断完善Agent的能力。要实现一个Demo很容易,但打磨产品的任务仍然任重道远。

版权声明
本文为博客HiKariのTechLab原创文章,转载请标明出处,谢谢~~~