简介

在这篇文章中,我们将详细讨论LangChain,这是一个用于开发由语言模型驱动的应用程序的框架。尽管LangChain主要提供了Python和JavaScript/TypeScript的库,但市面上也存在Java版本的库。我们将讨论LangChain框架的构建模块,然后继续在Java中进行实验。

背景

在深入探讨为什么我们需要一个用于构建由语言模型驱动的应用程序的框架之前,首先理解什么是语言模型是非常重要的。我们还将涵盖在处理语言模型时遇到的一些典型复杂性。

大型语言模型 (Large Language Models)

语言模型是一种自然语言的概率模型,可以生成一系列词汇的概率。大型语言模型(LLM)是一种以其巨大尺寸为特征的语言模型。它们是人工神经网络,可能具有数十亿的参数。

大型语言模型通常会使用自监督学习 (self-supervised) 和半监督学习 (semi-supervised learning) 技术,在大量未标记的数据上进行预训练。然后,预训练模型会使用各种技术,如微调和提示工程,来进行特定任务的适应:
Large-Language-Model

这些大型语言模型能够执行多种自然语言处理任务,如语言翻译和内容摘要。它们还能够进行生成式任务,如内容创作。因此,它们在回答问题等应用中可以非常有价值。

几乎所有主要的云服务提供商都已将大型语言模型纳入其服务提供中。例如,Microsoft Azure提供了LLM,如Llama 2和OpenAI GPT-4。Amazon Bedrock提供了来自AI21 Labs、Anthropic、Cohere、Meta和Stability AI等公司的模型。

提示工程 (Prompt Engineering)

LLM(大型语言模型)是在大量文本数据上训练的基础模型,因此它们能够捕捉人类语言固有的语法和语义。然而,它们必须经过适应,以执行我们希望它们执行的具体任务。

提示工程是一种迅速适应LLM的方法之一。它是一种将文本结构化的过程,可以被LLM解释和理解。在这里,我们使用自然语言文本来描述我们期望LLM执行的任务:
Prompt-Engineering

我们创建的提示有助于LLM进行上下文学习,这是一种临时性的学习方式。我们可以使用提示工程来促进LLM的安全使用,并构建新的能力,例如将LLM与领域知识和外部工具相结合。

这是一个活跃的研究领域,不断涌现出新的技术。然而,像“思维链提示” (chain-of-thought prompting) 这样的技术已经变得相当流行。这里的思路是,LLM在给出最终答案之前,将问题分解为一系列中间步骤来解决。

词嵌入(Word Embeddings)

正如我们所见,LLM能够处理大量的自然语言文本。如果我们将自然语言中的单词表示为词嵌入,LLM的性能将大幅提高。词嵌入是一种能够编码单词含义的实值向量。

通常,词嵌入是使用像Tomáš Mikolov的Word2vec或斯坦福大学的GloVe这样的算法生成的。GloVe是一种无监督学习 (unsupervised learning) 算法,它是根据从语料库中聚合的全球单词共现统计数据进行训练的:
Word-Embedding

在提示工程中,我们将提示转化为其词嵌入,这使模型更好地理解和响应提示。此外,这也非常有助于增强我们提供给模型的上下文,使其能够提供更具上下文的答案。

例如,我们可以从现有数据集生成词嵌入,并将它们存储在一个向量数据库中。然后,我们可以使用用户提供的输入对这个向量数据库进行语义搜索。然后,我们可以将搜索结果用作模型的附加上下文。

使用LangChain的LLM技术堆栈

正如我们已经看到的,创建有效的提示是成功利用LLM在任何应用中的力量的关键要素。这包括使与语言模型的交互具有上下文感知性,并依赖语言模型进行推理。

为了实现这一点,我们需要执行多项任务,如为提示创建模板,调用语言模型,并从多个来源提供用户特定的数据给语言模型。为了简化这些任务,我们需要像LangChain这样的框架,作为LLM技术堆栈的一部分:
LLM-Tech-Stack-with-LangChain

这个框架还有助于开发需要链接多个语言模型和能够回忆与语言模型的过去交互信息的应用程序。此外,还有更复杂的用例,涉及到使用语言模型作为推理引擎的场景。

最后,我们可以执行记录、监控、流式处理以及其他维护和故障排除的基本任务。LLM技术堆栈正在迅速发展以解决许多这些问题。然而,LangChain正在迅速成为LLM技术堆栈中的有价值的一部分。

在Java中使用LangChain

LangChain于2022年作为一个开源项目推出,并很快得到社区支持的推动。最初由Harrison Chase在Python中开发,很快成为人工智能领域中增长最快的初创公司之一。

在Python版本之后,于2023年初推出了JavaScript/TypeScript版本的LangChain。它很快变得非常受欢迎,并开始支持多个JavaScript环境,如Node.js、Web浏览器、CloudFlare workers、Vercel/Next.js、Deno和Supabase Edge functions。

但遗憾的是,目前没有官方的Java版本的LangChain可用于Java/Spring应用程序。但是,有一个叫做LangChain4j的Java社区版本可供使用。它兼容Java 8及更高版本,并支持Spring Boot 2和3。

LangChain的各种依赖项可以在Maven Central上获得。根据我们使用的功能,我们可能需要在我们的应用程序中添加一个或多个依赖项:

<dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j</artifactId>
    <version>0.23.0</version>
</dependency>

例如,在本文的后续部分,我们还需要支持与OpenAI模型集成、提供嵌入支持以及句子转换模型(例如all-MiniLM-L6-v2)的依赖项。

与LangChain的设计目标类似,LangChain4j提供了一系列简单和一致的抽象层,以及众多的实现。它已经支持了多个语言模型提供商,如OpenAI,以及嵌入存储提供商,如Pinecone。

然而,由于LangChain和LangChain4j都在快速发展,可能存在在Python或JS/TS版本中支持的功能,尚未在Java版本中提供。尽管如此,基本概念、一般结构和术语基本相同。

LangChain的构建模块

LangChain提供了一些模块组件,为我们的应用程序提供了多种价值主张。模块化组件提供了有用的抽象以及一系列用于处理语言模型的实现。接下来让我们用Java示例来介绍一些这些模块。

模型的输入/输出(Models I/O)

在与任何语言模型一起工作时,我们需要与之进行接口交互的能力。LangChain提供了必要的构建模块,如将提示模板化的能力以及动态选择和管理模型输入的功能。此外,我们还可以使用输出解析器从模型输出中提取信息:
LangChain-Model-IO

提示模板是用于生成语言模型提示的预定义配方,可能包括指令、少量示例和特定上下文:

PromptTemplate promptTemplate = PromptTemplate
  .from("Tell me a {{adjective}} joke about {{content}}..");
Map<String, Object> variables = new HashMap<>();
variables.put("adjective", "funny");
variables.put("content", "computers");
Prompt prompt = promptTemplate.apply(variables);

在这里,我们创建一个能够接受多个变量的提示模板。这些变量是我们从用户输入中接收并提供给提示模板的内容。

LangChain支持与两种类型的模型集成,语言模型和聊天模型。聊天模型也由语言模型支持,但提供了聊天功能:

ChatLanguageModel model = OpenAiChatModel.builder()
  .apiKey(<OPENAI_API_KEY>)
  .modelName(GPT_3_5_TURBO)
  .temperature(0.3)
  .build();
String response = model.generate(prompt.text());

在这里,我们创建一个具有特定OpenAI模型和相关API密钥的聊天模型。我们可以通过免费注册获取OpenAI的API密钥。参数"temperature"用于控制模型输出的随机性。

最后,语言模型的输出可能不够结构化以便展示。LangChain提供输出解析器,帮助我们构建语言模型的响应,例如,将输出中的信息提取为Java中的POJO(普通Java对象)。

记忆 (Memory)

通常,利用LLM的应用程序具有对话界面。任何对话的重要方面是能够引用先前在对话中引入的信息。关于过去互动的信息存储能力称为"记忆":
LangChain-Memory

LangChain为向应用程序添加记忆提供了关键支持。例如,我们需要具备从记忆中读取信息以增强用户输入的能力。然后,我们还需要有能力将当前运行的输入和输出写入记忆中:

ChatMemory chatMemory = TokenWindowChatMemory
  .withMaxTokens(300, new OpenAiTokenizer(GPT_3_5_TURBO));
chatMemory.add(userMessage("Hello, my name is YellowBean"));
AiMessage answer = model.generate(chatMemory.messages()).content();
System.out.println(answer.text()); // Hello YellowBean! How can I assist you today?
chatMemory.add(answer);
chatMemory.add(userMessage("What is my name?"));
AiMessage answerWithName = model.generate(chatMemory.messages()).content();
System.out.println(answer.text()); // Your name is YellowBean.
chatMemory.add(answerWithName);

在这里,我们使用TokenWindowChatMemory实现了一个固定窗口的聊天记忆,它允许我们读取和写入与语言模型交换的聊天消息。

LangChain还提供了更复杂的数据结构和算法,以便从记忆中选择特定的消息,而不是返回所有内容。例如,它支持返回过去几条消息的摘要,或者仅返回与当前运行相关的消息。

检索 (Retrieval)

大型语言模型通常是在大量的文本语料库上进行训练的。因此,它们在一般任务上通常表现出色,但在特定领域的任务中可能不太有用。为了解决这个问题,我们需要检索相关的外部数据,并在生成步骤中将其传递给语言模型。

这个过程被称为检索增强生成(Retrieval Augmented Generation - RAG)。它有助于使模型基于相关和准确的信息,并为我们提供有关模型生成过程的见解。LangChain提供了创建RAG应用程序所需的必要构建模块:
LangChain-Retrieval

首先,LangChain提供了文档加载器,用于从存储位置检索文档。然后,有可用的转换器来准备文档以进行进一步处理。例如,我们可以让它将一个大文档分割成较小的块:

Document document = FileSystemDocumentLoader.loadDocument("simpson's_adventures.txt");
DocumentSplitter splitter = DocumentSplitters.recursive(100, 0, 
  new OpenAiTokenizer(GPT_3_5_TURBO));
List<TextSegment> segments = splitter.split(document);

在这里,我们使用FileSystemDocumentLoader从文件系统加载文档。然后,我们使用OpenAiTokenizer将该文档分割成较小的块。

为了使检索更加高效,通常会将文档转换为它们的嵌入,并存储在向量数据库中。LangChain支持多种嵌入提供商和方法,并与几乎所有流行的向量存储集成:

EmbeddingModel embeddingModel = new AllMiniLmL6V2EmbeddingModel();
List<Embedding> embeddings = embeddingModel.embedAll(segments).content();
EmbeddingStore<TextSegment> embeddingStore = new InMemoryEmbeddingStore<>();
embeddingStore.addAll(embeddings, segments);

在这里,我们使用AllMiniLmL6V2EmbeddingModel来创建文档段的嵌入。然后,我们将这些嵌入存储在内存中的向量存储中。

现在我们的外部数据以嵌入形式存在于向量存储中,我们准备好从中检索。LangChain支持多种检索算法,例如简单的语义搜索以及复杂的算法,如集合检索器:

String question = "Who is Simpson?";
//The assumption here is that the answer to this question is contained in the document we processed earlier.
Embedding questionEmbedding = embeddingModel.embed(question).content();
int maxResults = 3;
double minScore = 0.7;
List<EmbeddingMatch<TextSegment>> relevantEmbeddings = embeddingStore
  .findRelevant(questionEmbedding, maxResults, minScore);

我们创建用户问题的嵌入,然后使用问题嵌入从向量存储中检索相关匹配项。现在,我们可以将检索到的相关匹配项添加到我们打算发送给模型的提示中,作为上下文发送给模型。

LangChain的复杂应用

到目前为止,我们已经看到如何使用单个组件来创建具有语言模型的应用程序。LangChain还提供了构建更复杂应用的组件。例如,我们可以使用链和代理来构建具有增强能力的更适应性应用程序。

链 (Chains)

通常,一个应用程序需要按特定顺序调用多个组件。这就是LangChain中所谓的"链"。它简化了开发更复杂的应用程序,并使调试、维护和改进变得更加容易。

这对于组合多个链以形成更复杂的应用程序也非常有用,这些应用程序可能需要与多个语言模型进行接口。LangChain提供了方便的方式来创建这些链,并提供了许多预构建的链:

ConversationalRetrievalChain chain = ConversationalRetrievalChain.builder()
  .chatLanguageModel(chatModel)
  .retriever(EmbeddingStoreRetriever.from(embeddingStore, embeddingModel))
  .chatMemory(MessageWindowChatMemory.withMaxMessages(10))
  .promptTemplate(PromptTemplate
    .from("Answer the following question to the best of your ability: {{question}}\n\nBase your answer on the following information:\n{{information}}"))
  .build();

在这里,我们使用一个预构建的链ConversationalRetreivalChain,它允许我们同时使用聊天模型、检索器、记忆和提示模板。现在,我们可以简单地使用这个链来执行用户查询:

String answer = chain.execute("Who is Simpson?");

该链具有默认的记忆和提示模板,我们可以覆盖它们。同时,创建自定义链也非常容易。创建链的能力使得更容易实现复杂应用程序的模块化实现。

代理 (Agents)

LangChain还提供了更强大的功能,如代理。与链不同,代理使用语言模型作为推理引擎,以确定要采取的行动以及顺序。我们还可以为代理提供访问所需工具来执行必要的操作。

在LangChain4j中,代理以AI服务的形式可用于声明式定义复杂的AI行为。让我们看看是否可以将计算器作为AI服务的工具,并使语言模型能够执行计算。

首先,我们将定义一个类,其中包含一些基本的计算器功能,并用自然语言描述每个函数,以便模型能够理解:

public class AIServiceWithCalculator {
    static class Calculator {
        @Tool("Calculates the length of a string")
        int stringLength(String s) {
            return s.length();
        }
        @Tool("Calculates the sum of two numbers")
        int add(int a, int b) {
            return a + b;
        }
    }

然后,我们将定义我们的AI服务的接口以构建。这里很简单,但也可以描述更复杂的行为:

interface Assistant {
    String chat(String userMessage);
}

现在,我们将使用LangChain4j提供的生成器工厂,使用我们刚刚定义的接口和我们创建的工具来构建一个AI服务:

Assistant assistant = AiServices.builder(Assistant.class)
  .chatLanguageModel(OpenAiChatModel.withApiKey(<OPENAI_API_KEY>))
  .tools(new Calculator())
  .chatMemory(MessageWindowChatMemory.withMaxMessages(10))
  .build();

现在我们就可以开始向我们的语言模型发送包含一些计算的问题了:

String question = "What is the sum of the numbers of letters in the words \"language\" and \"model\"?";
String answer = assistant.chat(question);
System.out.prtintln(answer); // The sum of the numbers of letters in the words "language" and "model" is 13. 

当我们运行这段代码时,我们会发现语言模型现在已经可以执行计算了。

要注意的是,语言模型在执行一些需要具有时间和空间概念或执行复杂算术程序的任务时可能会遇到困难。然而,我们总是可以提供模型所需的工具来解决这个问题。