Go 博客

使用 Go 构建由 LLM 驱动的应用

Eli Bendersky
2024 年 9 月 12 日

过去一年中,LLMs(大型语言模型)及其相关工具(如嵌入模型)的功能显著增强,越来越多的开发者正在考虑将 LLMs 集成到其应用中。

由于 LLMs 通常需要专用硬件和大量计算资源,它们最常被打包为提供 API 访问的网络服务。领先的 LLMs(如 OpenAI 或 Google Gemini)的 API 就是这样工作的;即使是像 Ollama 这样的“自托管”LLM 工具,也会将 LLM 封装在一个 REST API 中供本地使用。此外,在应用中利用 LLMs 的开发者通常需要辅助工具,如向量数据库,这些工具也最常部署为网络服务。

换句话说,由 LLM 驱动的应用与许多现代云原生应用非常相似:它们需要对 REST 和 RPC 协议、并发性和性能有出色的支持。这些恰好是 Go 擅长的领域,使其成为编写由 LLM 驱动的应用的绝佳语言。

这篇博文通过一个简单的由 LLM 驱动的应用示例来讲解如何使用 Go。文章首先描述了演示应用要解决的问题,然后展示了该应用的几个变体,它们都完成相同的任务,但使用了不同的包来实现。这篇博文演示所需的所有代码均可在网上获取

用于问答的 RAG 服务器

一种常见的由 LLM 驱动的应用技术是 RAG——检索增强生成(Retrieval Augmented Generation)。RAG 是为特定领域的交互定制 LLM 知识库最具可扩展性的方法之一。

我们将使用 Go 构建一个 RAG 服务器。这是一个为用户提供两种操作的 HTTP 服务器

  • 将文档添加到知识库
  • 就此知识库向 LLM 提问

在典型的实际场景中,用户会将文档语料库添加到服务器,然后向其提问。例如,公司可以用内部文档填充 RAG 服务器的知识库,并利用它为内部用户提供由 LLM 驱动的问答功能。

下图展示了我们的服务器与外部世界的交互

RAG server diagram

除了用户发送 HTTP 请求(上述两种操作)外,服务器还会与以下对象交互

  • 一个嵌入模型,用于计算提交的文档和用户问题的向量嵌入
  • 一个向量数据库,用于高效存储和检索嵌入。
  • 一个 LLM,用于基于从知识库收集的上下文进行提问。

具体来说,服务器向用户公开了两个 HTTP 端点

/add/: POST {"documents": [{"text": "..."}, {"text": "..."}, ...]}: 向服务器提交一系列文本文档,以添加到其知识库。对于此请求,服务器会执行以下操作

  1. 使用嵌入模型计算每个文档的向量嵌入。
  2. 将文档及其向量嵌入存储在向量数据库中。

/query/: POST {"content": "..."}: 向服务器提交问题。对于此请求,服务器会执行以下操作

  1. 使用嵌入模型计算问题的向量嵌入。
  2. 使用向量数据库的相似性搜索功能,在知识数据库中找到与问题最相关的文档。
  3. 使用简单的提示工程技术,将步骤 (2) 中找到的最相关文档作为上下文来重新表述问题,并将其发送给 LLM,然后将 LLM 的答案返回给用户。

我们的演示使用的服务包括

将这些服务替换为其他等效服务应该非常简单。事实上,服务器的第二和第三个变体就是关于这个的!我们将从直接使用这些工具的第一个变体开始。

直接使用 Gemini API 和 Weaviate

Gemini API 和 Weaviate 都提供了便利的 Go SDK(客户端库),我们的第一个服务器变体直接使用了这些库。此变体的完整代码在此目录中

我们不会在这篇博文中重现全部代码,但这里有一些阅读时需要记住的注意事项

结构:代码结构对于任何用 Go 编写过 HTTP 服务器的人来说都会很熟悉。Gemini 和 Weaviate 的客户端库会初始化,并将客户端存储在一个状态值中,该状态值会传递给 HTTP 处理器。

路由注册:使用 Go 1.22 中引入的路由增强功能,设置我们服务器的 HTTP 路由非常简单

mux := http.NewServeMux()
mux.HandleFunc("POST /add/", server.addDocumentsHandler)
mux.HandleFunc("POST /query/", server.queryHandler)

并发性:我们服务器的 HTTP 处理器会通过网络访问其他服务并等待响应。这对于 Go 来说不是问题,因为每个 HTTP 处理器都在自己的 goroutine 中并发运行。这个 RAG 服务器可以处理大量并发请求,并且每个处理器的代码是线性且同步的。

批量 API:由于 /add/ 请求可能会提供大量文档添加到知识库,服务器利用嵌入(embModel.BatchEmbedContents)和 Weaviate 数据库(rs.wvClient.Batch)的批量 API 来提高效率。

使用 LangChain for Go

我们的第二个 RAG 服务器变体使用 LangChainGo 完成相同的任务。

LangChain 是一个流行的 Python 框架,用于构建由 LLM 驱动的应用。LangChainGo 是其 Go 等效版本。该框架提供了一些工具,可以将应用构建为模块化组件,并通过一个通用 API 支持许多 LLM 提供商和向量数据库。这使得开发者可以编写适用于任何提供商的代码,并且可以非常轻松地更改提供商。

此变体的完整代码在此目录中。阅读代码时,您会注意到两点

首先,它比前一个变体要短一些。LangChainGo 负责将向量数据库的完整 API 封装在通用接口中,初始化和处理 Weaviate 所需的代码更少。

其次,LangChainGo API 使得切换提供商相当容易。假设我们想用另一个向量数据库替换 Weaviate;在前一个变体中,我们必须重写所有与向量数据库交互的代码以使用新的 API。有了像 LangChainGo 这样的框架,我们就不再需要这样做。只要 LangChainGo 支持我们感兴趣的新向量数据库,我们应该只需替换服务器中的几行代码,因为所有数据库都实现了一个通用接口

type VectorStore interface {
    AddDocuments(ctx context.Context, docs []schema.Document, options ...Option) ([]string, error)
    SimilaritySearch(ctx context.Context, query string, numDocuments int, options ...Option) ([]schema.Document, error)
}

使用 Genkit for Go

今年早些时候,谷歌推出了Genkit for Go——一个用于构建由 LLM 驱动的应用的新开源框架。Genkit 与 LangChain 有一些共同特点,但在其他方面有所不同。

与 LangChain 一样,它提供了可以由不同提供商实现(作为插件)的通用接口,从而使得在提供商之间切换更加简单。然而,它并不试图规定不同的 LLM 组件如何交互;相反,它专注于生产功能,如提示管理和工程,以及集成开发者工具的部署。

我们的第三个 RAG 服务器变体使用 Genkit for Go 完成相同的任务。其完整代码在此目录中

此变体与 LangChainGo 的变体相当相似——使用用于 LLM、嵌入器和向量数据库的通用接口,而不是直接的提供商 API,使得在提供商之间切换更加容易。此外,使用 Genkit 将由 LLM 驱动的应用部署到生产环境要容易得多;我们没有在我们的变体中实现这一点,但如果您感兴趣,请随意阅读文档

总结 - 使用 Go 构建由 LLM 驱动的应用

本文中的示例仅展示了使用 Go 构建由 LLM 驱动的应用的可能性。它演示了用相对较少的代码构建一个强大的 RAG 服务器有多么简单;最重要的是,由于 Go 的一些基本特性,这些示例具备了相当程度的生产就绪性。

与 LLM 服务交互通常意味着向网络服务发送 REST 或 RPC 请求,等待响应,然后基于响应向其他服务发送新的请求等等。Go 在所有这些方面都表现出色,为管理并发性和处理复杂网络服务提供了强大的工具。

此外,Go 作为云原生语言的出色性能和可靠性使其成为实现 LLM 生态系统中更基础构建块的自然选择。例如,可以看看 OllamaLocalAIWeaviateMilvus 等项目。

下一篇文章:别名有何含义?
上一篇文章:分享您使用 Go 开发的反馈
博客索引