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

61 阅读9分钟

本文搬运自本人CSDN博客【极客日常】用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模型也比较简单,示例代码:

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的抽象,此处不赘述了。

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本地持久化向量文本的话,可以这样写:

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:

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很容易,但打磨产品的任务仍然任重道远。