我参加LLM聚会相对较晚,但很少在炒作初期露面。
例如,我从未对区块链产生兴趣,这个仍在寻找问题来解决的解决方案,也不对微服务产生兴趣,微服务似乎是当前 IT 趋势中的最新“跟风趋势”。尽管我是 LLM 领域的后来者,但我一直在使用 LLM。我使用 OpenAI 来回答不具争议性的问题,超出我的知识范围,例如语言学或法律;我使用 GitHub Copilot 来改进我的代码。
这篇文章的重点是将聊天机器人集成到我的应用程序中。然后看看它能做什么。
选择一个LLM模型现在有很多大型语言模型可以选择。我提到了OpenAI,但还有很多其他模型同样值得关注:Google Gemini、Cohere、Amazon Bedrock,等等。每个模型都有其优缺点,但这与这篇入门帖子的内容无关。
我的主要要求是它需要本地运行。另外,我还希望在LLM之上有一个抽象层来,以便学习抽象而非具体细节。
我选择了LangChain4J和Ollama,因为它们俩广为人知并且符合这个项目的需求。
快速介绍 LangChain4J 和 OllamaLangChain4J 是这样介绍自己的:
LangChain4j 的目标是简化将大语言模型(LLM)集成到 Java 应用程序中的过程。
实现方式如下:
- 统一的 API:大语言模型提供商(如 OpenAI 或 Google Vertex AI)和嵌入(向量)存储(例如 Pinecone 或 Milvus)使用专属 API。LangChain4j 提供统一的 API,以避免学习和实现每个提供商特有的 API。实验不同的大语言模型或嵌入存储时,你可以轻松地在它们之间切换,无需重写代码。LangChain4j 目前支持超过 15 个流行的大语言模型提供商和超过 20 个嵌入存储。
- 全面的工具箱:自 2023 年初以来,社区一直在构建由大语言模型驱动的应用程序,识别出常见的抽象化、模式和技术。LangChain4j 已将这些内容提炼成一个现成可用的工具包。我们的工具箱包含从低级提示模板、对话记忆管理、函数调用到高级模式如检索增强生成(RAG)的各种工具。对于每个抽象,我们提供一个接口以及基于常见技术的多个现成实现。无论你是构建聊天机器人还是开发具有从数据摄取到检索完整流水线的 RAG,LangChain4j 都提供了广泛的选择。
- 众多示例:这些示例展示了如何开始创建各种由大语言模型驱动的应用程序,为您提供灵感并帮助您快速开始构建项目。
-— https://docs.langchain4j.dev/intro(LangChain4J文档简介)
奥拉玛的介绍特别短:
快速启动并熟悉大模型。
运行 Llama 3.2、Phi 3、Mistral、Gemma 2 等等,以及其他模型。自定义并创建自己的。
—— https://ollama.com/
一个运行环境,可以支持多个模型。
先试试脚我将把这一部分分成LangChain4j的应用部分和Ollama的基础设施部分。
LangChain4j 提供了一个 Spring Boot 集成启动器。这里是我们最小的依赖项:
``
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-ollama-spring-boot-starter</artifactId> <version>0.35.0</version> </dependency> </dependencies>
全屏模式。退出全屏。
LangChain4j 提供了一个抽象的 API,用于抽象不同 LLM 的具体细节。这里重点介绍我们将在这部分使用的内容。
基本的 API model.generate(String)
将用户的消息传给 Ollama 实例并返回响应。我们需要创建一个端点来封装这个调用过程;具体的实现细节不重要。
LangChain4J的Spring Boot启动器会自动从确切的依赖项集(例如Ollama)生成一个ChatLanguageModel
。它还提供了很多通过Spring Boot进行配置的选项。
langchain4j.ollama.chat-model: 基础URL:http://localhost:11434 #注释1 模型名称:llama3.2 #注释2
进入全屏模式 退出全屏模式
当应用程序启动时,LangChain4j 会创建一个类型为 ChatLanguageModel
的 Bean,并将其添加到上下文。需要注意的是,具体的类型取决于类路径中存在的依赖项。
为了使用方便,我将使用Docker,更具体地说是使用Docker Compose。我的Compose文件如下:
services: langchain4j: build: context: . environment: LANGCHAIN4J_OLLAMA_CHAT_MODEL_BASE_URL: http://ollama:11434 #1 ports: - "8080:8080" depends_on: - ollama ollama: image: ollama/ollama #2 volumes: - ./ollama:/root/.ollama #3
注释1:LANGCHAIN4J_OLLAMA_CHAT_MODEL_BASE_URL
设置了与 Ollama 服务的连接地址。
注释2:image
指定了 Ollama 服务使用的 Docker 镜像。
注释3:volumes
指定了 Docker 容器中挂载的目录。
全屏, 退出全屏
正如上面提到的,Ollama 是一个具有可切换模型的运行时,默认情况下没有模型。要下载模型,请使用 docker exec
进入容器并运行以下命令:docker exec
... [具体命令示例]
ollama 跑 llama3.2
切换到全屏 / 退出全屏
小心点,llama3.2
达到了惊人的 20GB;因此,你不想在每次运行 docker compose up
时都下载模型。这就是上面提到的卷映射的原因。
当然,你可以用任何其他类似 tinyllama
的更小模型来替代 llama3.2
。
这时,我们可以 curl
这个应用并查看结果。
curl localhost:8080 -d '你好,我叫尼古拉,我是一名DevRel.'
全屏模式 | 退出全屏
利用流处理增强上述解决方案虽然有效,但用户体验还有改进的空间。命令会卡住一段时间,几秒钟后才会有回应,这与传统的OpenAI界面不同,后者会实时将内容反馈给用户。
我们可以轻松地将 ChatLanguageModel
替换为 StreamingChatLanguageModel
,从而实现这一目标。这些方法略微有些不同:
我们需要相应地调整应用程序的设置,
services: langchain4j: build: context: . environment: LANGCHAIN4J_OLLAMA_STREAMING_CHAT_MODEL_BASE_URL: http://ollama:11434 #1 ports: - "8080:8080" depends_on: - ollama
全屏
退出全屏
同时,我们必须从Spring Web MVC迁移到Spring WebFlux。然后,我们将LLM的结果流与应用的结果流对接,如下:
class AppStreamingResponseHandler(private val sink: Sinks.Many<String>) : StreamingResponseHandler<AiMessage> { override fun onNext(token: String) { //1 处理接收到的令牌 sink.tryEmitNext(token) } override fun onError(error: Throwable) { //1 处理接收到的错误 sink.tryEmitError(error) } override fun onComplete(response: Response<AiMessage>) { //2 处理请求完成的响应 println(response.content()?.text()) sink.tryEmitComplete() } } class PromptHandler(private val model: StreamingChatLanguageModel) { suspend fun handle(req: ServerRequest): ServerResponse { val prompt = req.awaitBody<String>() //3 从请求中获取提示内容 val sink = Sinks.many().unicast().onBackpressureBuffer<String>() //4 创建一个可以缓冲的单播流 model.generate(prompt, AppStreamingResponseHandler(sink)) //5 使用获取的提示调用模型生成 return ServerResponse.ok().bodyAndAwait(sink.asFlux().asFlow()) //6 返回包含响应体的服务器响应 } }
切换到全屏,退出全屏
我们现在可以使用curl的流模式,并启用-N
标志,如下所示:
curl -Nlocalhost:8080 -d '你好,我是尼古拉斯,我是一名开发者关系专家'
点击全屏模式,点击退出全屏
结果已经好很多了!
铭记历史目前,每个聊天请求都是独立的,它们不保留上下文。聊天历史是一个我们缺少的重要功能,而现成的AI助手通常都有这个功能。我们需要从两个方向重构应用:首先,存储用户和模型的每条消息,其次,将用户的聊天记录彼此隔离开。
我最初是在内存中自己存储历史记录的。如果感兴趣,可以查看提交历史以了解我是如何做到这一点的。然而,LangChain4j 提供了一个集成的方式,通过其 AiServices
类实现。ChatLanguageModel
表示与大规模语言模型交互的基本请求响应接口,而 AiServices
则提供了额外的服务:对话记忆、检索增强生成(RAG)和外部函数调用。
这里就是相关代码:
data class StructuredMessage(val sessionId: String, val text: String) //1: 会话ID和消息文本 interface ChatBot { //2: 定义聊天机器人的接口 fun talk(@MemoryId sessionId: String, @UserMessage message: String): TokenStream //3-4-5: 发送会话ID和用户消息,返回Token流 } class PromptHandler(private val chatBot: ChatBot) { suspend fun handle(req: ServerRequest): ServerResponse { val message = req.awaitBody<StructuredMessage>() val sink = Sinks.many().unicast().onBackpressureBuffer<String>() chatBot.talk(message.sessionId, message.text) //6: 通过聊天机器人的talk方法发送消息 .onNext(sink::tryEmitNext) //7: 处理数据的下一步 .onError(sink::tryEmitError) //7: 错误处理 .onComplete { sink.tryEmitComplete() } //7: 处理完成 .start() return ServerResponse.ok().bodyAndAwait(sink.asFlux().asFlow()) } } fun beans() = beans { bean { coRouter { val chatBot = AiServices //8: 使用AiServices来构建聊天机器人 .builder(ChatBot::class.java) .streamingChatLanguageModel(ref<StreamingChatLanguageModel>()) .chatMemoryProvider { MessageWindowChatMemory.withMaxMessages(40) } .build() POST("/")(PromptHandler(chatBot)::handle) } } }
进入全屏 退出全屏
@MemoryId
标记相关ID@UserMessage
标记从用户发送给模型的消息TokenStream
可以订阅TokenStream
的数据传递给接收端,就像在我们的自定义实现中那样ChatBot
:AiServices
将实时创建其实现用法是这样的
curl -N -H 'Content-Type: application/json' localhost:8080 -d '{ "sessionId": "1", "message": "你好,我是 Nicolas,我是一名 DevRel" }' curl -N -H 'Content-Type: application/json' localhost:8080 -d '{ "sessionId": "2", "message": "你好,我是 Jane Doe,我是一个测试示例" }'
进入全屏。退出全屏。
添加检索增强生成功能LLM的表现如何,完全取决于它们训练的数据,你很可能希望自己的聊天机器人能用你自己定制的数据进行训练。RAG就是为了解决这个问题而来的。其想法是在事先将内容索引并存储,然后在搜索时添加这些索引数据,这叫检索。关于RAG的解释,LangChain4j做得很出色。
接下来,我们将利用我的博客的数据在我们的应用程序中添加RAG的雏形。
LangChain4j 提供了一个名为 Easy RAG 的依赖项。它提供了两种来源:文件和 URL,并且有一个内存中的嵌入存储。通常情况下,你会离线创建索引并将嵌入存储在常规的数据库中,而我们则会在启动时将这些嵌入存储在内存中。这对于我们原型设计的目的来说已经足够了。
class BlogDataLoader(private val embeddingStore: EmbeddingStore<TextSegment>) { private val urls = arrayOf( "https://blog.frankel.ch/speaking/", // 其他URL... ) @EventListener(ApplicationStartedEvent::class) // 1 fun onApplicationStarted() { val parser = TextDocumentParser() val documents = urls.map { UrlDocumentLoader.load(it, parser) } EmbeddingStoreIngestor.ingest(documents, embeddingStore) } } fun beans() = beans { bean<EmbeddingStore<TextSegment>> { InMemoryEmbeddingStore<TextSegment>() // 2 } bean { BlogDataLoader(ref<EmbeddingStore<TextSegment>>()) // 3 } bean { coRouter { val chatBot = AiServices .builder(ChatBot::class.java) .streamingChatLanguageModel(ref<StreamingChatLanguageModel>()) .chatMemoryProvider { MessageWindowChatMemory.withMaxMessages(40) } .contentRetriever(EmbeddingStoreContentRetriever.from(ref<EmbeddingStore<TextSegment>>())) // 4 .build() } } }
进入全屏,退出全屏
我们可以通过对输入的文档提问来测试RAG系统。
在OpenAI上,我问:“尼古拉斯·弗雷内尔写了哪些书?”它回答说:《Vaadin学习》(正确),《实战Spring Security》(可能是,但它在乱说),以及《精通Java EE开发使用WildFly》(没戏,它又在乱说)。
让我们在带有RAG功能的app上做同样的事情。
curl -N -H 'Content-Type: application/json' localhost:8080 -d '{ "sessionId": "1", "message": "尼古拉斯·弗伦凯尔写了哪些书?" }'
开启全屏,关闭全屏
答案好多了
所提供的信息未提及尼古拉斯·弗兰克尔撰写的特定书籍。仅提供了他的博客的元数据,该博客设有一个专门的“书籍”专区等。
其实不太对——我确实提到过我写的那些书,但至少没有乱说。
最后的总结在这篇文章中,我展示了如何通过几个逐步的步骤开始你的Langchain4j旅程。首先,我们将Langchain4j用作Ollama上的简单接口。然后,我们切换到令牌流。我们将代码库重构,利用Langchain4j的抽象添加了聊天历史。最后,我们通过简单的内存存储和静态链接实现了RAG来完成演示。
本文的完整源代码可以在 GitHub 上查看:
ajavageek / langchain4j-musings 在 GitHub 上的项目想了解更多:
使用REST API实现LangChain应用的流式传输*参考文章链接
最初发布于一位Java极客,2024年11月10日