LangChain 在生命科学与医疗保健领域的应用——介绍 LangChain

176 阅读1小时+

在全面了解了使用大型语言模型(LLMs)的优缺点后,可以肯定的是,将语言模型与强大的工具和组件结合起来,能够显著提升生成式 AI 应用的能力。而这正是 LangChain 所做的——一个专为大型语言模型应用开发打造的综合框架。

LangChain 这个名字融合了“语言(language)”和“链(chain)”,体现了其核心理念:通过将模块化组件串联起来,构建复杂的应用程序。它抽象化了常见任务,并为各种应用(如聊天机器人、文本摘要、问答系统等)提供了预制组件和模板,极大降低了开发者使用语言模型的门槛。这不仅简化了开发流程,也激励了社区的探索和创新。有了 LangChain,打造类似《钢铁侠》中 J.A.R.V.I.S. 这样的智能助理,比以往任何时候都更现实。

LangChain 构建生成式 AI 应用时,主要使用七类组件:模型(models)、索引(indexes)、链(chains)、提示(prompts)、记忆(memory)、工具(tools)和代理(agents),如图3-1所示。

image.png

第2章讨论了大型语言模型(LLMs)的一些局限性,特别是幻觉现象、信息过时以及在数学和逻辑推理上的困难。为克服这些限制,最流行的技术之一是“检索增强生成”(Retrieval-Augmented Generation,简称 RAG)。第4章会更详细地讲解这一主题,这里我们仅简要介绍 RAG 的基础知识以便学习 LangChain 的组件。

RAG 通过访问外部知识库,补充 LLM 训练数据中缺失或过时的信息,提供相关上下文,降低幻觉风险。索引可以是 SQL/NoSQL 数据库,也可以是向量存储。书中后续章节将展示如何将 LLM 连接到化学化合物、基因序列、蛋白质结构和临床试验数据等多种数据库,以增强模型在这些领域的知识能力。

另一种我们将使用的技术是链式调用(chaining)。通过构建链,将代理(agents)、工具(tools)以及搜索、计算等操作整合进 LLM 的工作流程。例如,在药物发现中,可以将 LLM 与计算工具串联,预测候选分子与目标蛋白的结合亲和力,辅助新药设计。

注意
“LangGraph”章节将探讨基于图结构的构建方法,与链式调用不同。

提示工程(Prompt Engineering)也是提升 LLM 效能的热门技术。你将学会如何精心设计提示,提供关键上下文,引导 LLM 产生准确的回应。在构建助理时,我们会配置设计良好的提示,帮助 LLM 精准理解和分析输入指令。

记忆(Memory)功能让 LLM 能够保留对话上下文并跨多轮交互持久存储数据,这对聊天机器人和其他序列化应用尤为重要。例如,LLM 可以处理患者的更新病史和持续治疗方案,提供更具针对性和一致性的建议。

索引(Indexes)

ChatGPT 火爆之后,许多“用 ChatGPT 聊你自己的文档”服务如雨后春笋般涌现。允许用户与自身数据对话成为生成式 AI 和 LangChain 的重要应用场景。关键目标是从文档、数据库和 API 中检索相关上下文信息,并传递给语言模型。掌握并优先考虑上下文,会显著提升 LLM 生成正确答案的概率——这也是“检索增强生成”的核心。

RAG 利用训练数据之外的额外上下文信息,提升生成回答的质量和准确度(见图3-2)。举例来说,在医疗健康领域,LLM 可以在推理过程中从学术文献、临床指南或电子健康记录数据库检索相关医疗信息,再将其整合到上下文中,使模型生成的回答更加流畅、一致,并符合最新领域知识和医学最佳实践。

image.png

我们可以从多种数据源中检索信息:结构化表格(第9章展示了病人数据记录的示例),以及非结构化的文档、文本、图像、图数据(第8章展示了药物发现图谱的查询案例)和网络数据。为高效提取数据,我们会使用一种特殊的查找表,称为索引。在本书中,索引和目录(TOC)帮助读者轻松导航,快速定位每个列出的术语或标题对应的具体页码。针对我们的数据需求,无论是构建自己的知识库,还是其他私有的信息和数据源,我们都会自行构建索引。整个网络的索引工作已经由搜索引擎负责,LangChain 中也内置了相应的工具。

接下来,我们来回顾一下文本数据的索引原理。想象一下,当你需要在文本中找到某个特定内容时,你会怎么做?大多数人会打开内置的搜索框,查找特定关键词,希望这些词在文档中不会太常见,且你要找的片段包含这些可搜索的词。但这种方法容易因拼写错误、同义词、高频词等问题而失效。

类似的问题也会出现在为模型提供上下文时——我们需要在上传的文档中搜索相关文本块。单纯使用词匹配往往无法奏效。不过,我们可以执行向量搜索,通过高效地找到最相似的向量(第2章讨论了向量嵌入,这是向量表示的一种特定形式),并根据查询向量与文档片段向量的相似度检索对应内容。

无论是文本数据还是图像数据,索引和查询向量存储的基本原理大致相同。文本和图像数据都会被转换为稠密的嵌入向量。主要区别在于,文本嵌入模型专门设计用来捕捉语言和语义关系。

提示
将文本转成向量的原因是为了进行恰当的比较。试想,如果有人让你比较“偶氮苯”、“苯”和“吡啶”,化学家会发现第一和第三其实是同一化合物,因为他们比较的是词语背后的化学结构表示,而不是单纯词语本身。将词语转换为向量格式,能够比较它们的数字表示,从而得到高度相似的结果,即便名字听起来不同。

相比之下,图像嵌入模型则捕捉视觉和感知层面的关系。使用向量存储的一个显著优势是能够进行多模态查询,即文本和图像数据可以同时组合搜索(这需要多模态模型支持)。例如,可以查询语义上与给定文本描述相关的图像,或者查询视觉上与某张图片相关的文本文档。在本书中,我们将开展多模态医学信息检索。

提示
为了更好理解索引的作用,想象你需要找到某个特定主题的文章,但不能使用聚合搜索索引。你可能会尝试在专门覆盖该主题的专业期刊中查找内容。现在,假设你能使用外部搜索,但它只在首页展示了感兴趣文章的一半。你可能会说我会翻几页看看,但你上次翻开 Google、Bing 或其他搜索引擎的第2页是什么时候?如果第一页没看到,AI 应用也很难看到。

索引在多个领域都非常重要,尤其是在搜索和检索数据可能存在问题时。一个包含病人信息、治疗效果和流行病学数据的庞大索引数据库,能够彻底改变医疗专业人员和公共卫生机构对疾病的研究与治疗方式。对于科学家和研究人员来说也类似:建立包含大量科学论文、研究文章和专利的索引数据库,能显著加快实验流程、路径和研究成果的检索。向量存储的工作通常包括建立索引和寻找最近邻,两者都有多种实现方式。

索引

类似于传统数据库系统中通过索引加快数据记录访问速度的原理,向量嵌入的索引旨在对向量进行结构化,以便高效地进行相似度搜索和邻近查询。可以将相似度搜索理解为:基于向量表示找到数据库中与查询项最相似、语义或特征上高度相关的项;而邻近查询则是在向量空间中查找彼此相近的项,关注它们相对于某个特定点或彼此之间的距离远近。通过将相似的向量存放得更靠近,索引技术能够快速识别并检索相关嵌入,对于依赖向量相似度比较的应用尤为关键。

索引算法主要有以下几类:

平面索引(Flat indexing)
这是一种基础方法,计算查询向量与索引中每个向量的相似度,保证了高精度,但速度较慢。适用于对绝对精度要求极高或数据量较小的场景。然而,当数据集较大时,这种暴力计算方式相比其他以牺牲部分精度换取性能的索引方法,速度会明显变慢。

乘积量化(Product Quantization,PQ)
该方法通过将向量空间划分为若干较小的子空间,并分别对每个子空间进行量化。此举有效降低了向量的维度,实现了高效存储和快速相似度搜索。尽管 PQ 在速度上具有优势,但搜索结果的准确度可能会有所降低。

基于树的索引(Tree-based)
这是向量嵌入中最常用的索引算法之一。k 维树(k-d tree)数据结构通过划分高维空间,将向量所在的空间分割开来,从而支持高效的最近邻搜索。但随着向量维度的提升,传统的如 k-d 树的索引方法效果会逐渐减弱。

在图3-3中,可以看到一旦我们开始按照字典序对字母建立索引,就只测量了每个字母与其最近的两个邻居之间的距离。特别是在最底排,字母 d 与字母 a 的距离最远,且不被认为足够接近以视为相似。未保存所有距离的原因是因为距离计算的复杂度为二次方——每新增一个节点都需要计算与所有已有节点的距离。从实际角度看,这并不理想,因为对于距离较远的节点,你不需要知道它们之间的距离,搜索时更关注的是找到与查询向量“近似”、相似的向量。随着越来越多文档的加入,这种方法的优势将更加明显。

image.png

我们的查询是在搜索过程中根据向量表示进行分配的。我们从节点 a 及其邻居开始,判断哪一个更接近查询点。由于节点 c 更近,我们像之前的步骤一样“考察”它的邻居,直到遇到所有邻居都比当前节点距离查询点更远的情况为止。在图3-3中,节点 e 离查询点最近,完成了路径 a-c-e 的搜索。

在向量数据库中,有两种流行的索引和搜索方法,对于高维数据的高效管理至关重要。除了文本,数据还可能包括分子指纹、基因表达谱或病人健康记录。这两种方法分别是局部敏感哈希(Locality-Sensitive Hashing,LSH)和分层可导航小世界图(Hierarchical Navigable Small World,HNSW),它们各自在不同类型数据和搜索需求中展现不同优势。

LSH 适合在大规模高维数据集中快速进行近似搜索。想象你有一个庞大的化学化合物库,每个化合物用高维向量表示其分子属性。如果你想快速找到结构与目标化合物相似的分子,且能接受一定的近似误差,LSH 是理想选择。它通过将相似项哈希到同一个“桶”中,使搜索只需检查潜在匹配的小子集。该方法以牺牲部分精度换取速度,非常适合对响应时间要求高、结果无需绝对精确的探索性搜索。

而 HNSW 是一种基于图的算法,兼顾速度、可扩展性和高搜索精度。设想进行基因表达分析,每个基因表达谱为高维向量,需要在数百万样本中找到最相似的表达谱。HNSW 构建多层图结构,连接体现相似度,方便高效地导航至最近邻。由于 HNSW 维护了分层结构,它能实现更快且更准确的最近邻搜索,尤其适合高精度要求的场景,如基于基因表达模式识别疾病亚型。

总体来说,LSH 适合优先考虑速度和扩展性而非完美准确性的近似搜索大数据场景;而 HNSW 在需要高准确度和良好扩展性的场景表现优异,特别是当搜索结果质量直接影响分析或决策时。如果内存使用成为关键因素,可以选择 PQ 等通过降维来减小向量存储需求的算法。可见,索引算法的选择依赖于具体应用需求及向量数据的特点。

向量搜索

在对数据建立索引后,我们便可以进行搜索。搜索算法通常与索引方法密切相关。我们不需要自己实现这些算法,而是采用已有成熟的开源库:

  • FAISS(Facebook AI Similarity Search)
    由 Meta 开发的高效相似度搜索与聚类库,支持多种索引算法,包括前述的 PQ、LSH 和 HNSW。FAISS 广泛应用于大规模向量搜索任务,支持 CPU 和 GPU 加速,是图像检索、推荐系统等对性能要求高的应用的有力工具。
  • Annoy
    由 Spotify 开发的 C++ 库(带 Python 封装),用于高维空间的近似最近邻搜索。其基于随机投影树的森林结构,设计高效且易扩展。Annoy 适合对速度要求高、精度要求相对较低的应用,如实时搜索和推荐系统。

除上述流行库外,还有值得关注的其他工具。Google 的 ScaNN 是基于 TensorFlow 的库,结合了压缩和哈希技术。NMSLIB(非度量空间库)提供多种算法支持大规模数据的索引和搜索,涵盖稠密和稀疏向量及多种距离度量。HNSWLib 是基于层次图结构的快速可扩展最近邻搜索库,支持稠密和稀疏向量,加速搜索过程。

我多次提到“相似度”,它可以用多种度量方式定义。其中欧氏距离和余弦相似度是两种最常用的方法。欧氏距离计算两个向量之间的直线距离,关注它们的绝对大小;而余弦相似度则计算两个向量夹角的余弦值,关注它们的整体方向和朝向,而不考虑大小。

提示
想象比较两个物种的基因组成,每个维度代表一个基因。欧氏距离就像在这些维度之间画一条直线,量化它们的基因差异程度;而余弦相似度则测量两个向量夹角的余弦,评估它们基因向量方向的相似性,无视长度差异。这反映出两个物种基因方向上的亲缘关系,即使一个物种的基因变异(向量长度)远大于另一个。综上,欧氏距离衡量的是绝对的遗传差异,而余弦相似度则强调遗传特征的方向性,提供不同的关系视角。

向量存储

向量存储是我们关于索引和向量搜索讨论的最后一个主题。到目前为止,你已经了解了上下文为何重要,数据为何及如何嵌入,如何定义相似度,以及如何执行向量搜索。现在,是时候深入了解向量如何存储,也就是所谓的向量存储。

“向量存储”(vector store)和“向量数据库”(vector database)这两个术语常被混用,但其实存在一些差别。向量存储是一种专门设计用于存储和检索嵌入向量的系统,这些向量是数据(如图像或文本)的高维数值表示。向量存储针对通过最近邻搜索算法处理向量嵌入进行了优化,而非精确匹配查询。它们主要关注高维数据操作的效率,采用简化的模式设计,数据类型灵活性有限,更侧重向量相似度搜索的性能而非多功能性。

相比之下,向量数据库是更全面的系统,内置向量存储功能的同时,还提供额外功能。它们在传统数据库技术基础上加入向量运算,支持向量与关系型数据的结合。与纯向量存储不同,向量数据库支持将向量相似度搜索与传统数据库操作结合的复杂查询,提供灵活的数据模型,处理向量与非向量数据类型,并采用先进索引技术优化性能。

提示
向量存储和向量数据库的关系呈层级结构——大多数向量数据库都包含向量存储作为核心组件,数据库层则作为包装,连接传统数据库操作与向量专用功能。

选择合适的向量存储对 RAG 应用的性能、扩展性和效果影响重大(相关内容将在第4章详细讲解)。目前,已有数百种向量存储与 LangChain 的集成,切换也相当方便。本文不逐一介绍所有存储,而聚焦使用不同向量存储时的实际考虑,比如云端与自托管、性能、扩展性、预算、易用性等。

下面以 LangChain 中基于 FAISS 的向量存储实现为例(示例3-1)。
我们先导入必要包,初始化嵌入模型,将文本转换成数值表示。示例中有两段与基因技术相关的短文和一段关于光合作用的文本。文本随后用 LangChain 的文本拆分器切分成块(本章后续会详细介绍拆分器),切分后的文本块经过嵌入后加载进向量存储。通过 similarity_search() 和 similarity_search_with_score() 方法,可以提取与查询最接近的文本块。注意示例中 FAISS 返回的较低分值代表查询与文本块距离更近。此方式高效地处理大规模文本数据,将文本拆成小块,仅将相关块传入 LLM,节省 token 并降低对 LLM 的噪声干扰。

# 示例3-1. FAISS similarity_search_with_score
from langchain_community.vectorstores import FAISS
from langchain_openai import OpenAIEmbeddings
from langchain_text_splitters import RecursiveCharacterTextSplitter

embedding_model = OpenAIEmbeddings(model="text-embedding-3-large")

# 创建生命科学相关文档
doc1 = """The human genome consists of approximately 3 billion base pairs of
DNA. These sequences contain the instructions for building and maintaining the
human body."""

doc2 = """CRISPR-Cas9 is a revolutionary gene-editing technology that allows for
precise, directed changes to genomic DNA. It has the potential to correct
genetic defects and treat diseases."""

doc3 = """Photosynthesis is the process by which green plants and some other
organisms use sunlight to synthesize foods with the help of chlorophyll from
carbon dioxide and water."""

# 仅因示范文本较短,合并为一体模拟实际文档拆分
documents = "\n".join([doc1, doc2, doc3])

# 拆分文档为文本块(通常必需)
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=50, chunk_overlap=10,
    separators=["\n"], keep_separator=False
)
chunks = text_splitter.split_text(documents)

# 初始化FAISS向量存储
vector_store = FAISS.from_texts(chunks, embedding_model)

query = "What is CRISPR-Cas9?"
results = vector_store.similarity_search_with_score(query)
print(results)
# 输出示例:
# [(Document(page_content='CRISPR-Cas9 is a revolutionary...'), 0.5625741),
#  (Document(page_content='The human genome consists ...'), 1.7101107),
#  (Document(page_content='Photosynthesis is the ...'), 1.7713351)]

正如前文所述,LangChain 支持多种向量存储集成,选择时首要考虑是云托管还是自托管方案。云托管向量存储(如 Azure Search、Google Cloud Vertex AI、AWS OpenSearch、Weaviate 和 Pinecone)提供托管服务便利和弹性扩展,但可能伴随额外成本及厂商锁定风险。自托管存储则更灵活可控,适合技术能力较强的用户,常用库包括 FAISS、HNSWLib 和 LanceDB。技术水平中等的用户可考虑 ClickHouse、PostgreSQL(搭配 PGVector、Chroma 等扩展)。Supabase 和 Rockset 则面向技术能力较低用户,提供更易用的自托管方案。

性能与延迟要求也是重要考量。若需实时或亚毫秒级延迟,可考虑 MemoryVectorStore 和 Pinecone。容忍毫秒级至数十毫秒延迟的应用,FAISS、HNSWLib 和 Tigris 是合适选项。若允许秒级延迟,OpenSearch、Elasticsearch 或基于数据库的 PGVector 也是可行方案。扩展性与分布式计算能力对大规模数据处理和高可用性需求尤为关键。FAISS、Pinecone 和 Weaviate 等向量存储具备良好的扩展架构与分布式支持,适合大规模部署。

预算和成本因素亦深刻影响选择。预算有限时,开源选项如 Chroma、FAISS、OpenSearch 和 ClickHouse 通常首选。中等预算则可考虑按需付费的托管服务,特别是云方案。

链(Chains)

链是 LangChain 中的基础构建模块。它们让你能够将复杂任务拆解为可管理的多个步骤,每一步都可以调用大型语言模型(LLM)、向量存储、工具、数据源、处理组件及其他 LangChain 模块。通过将不同模块的 LLM 组合成链,可以解决单一模型无法胜任的多步骤任务。这为自动化复杂的信息检索、分析和综合流程,尤其是在生命科学领域,开辟了新的可能性。

举个应用例子:构建一个管道来探索某个特定蛋白质的最新研究。这样的链可能包括:

  • 一个网页爬取组件,用于从在线数据库、期刊和资料库中收集相关研究论文;
  • 一个 LLM,用来分析抓取到的论文文本,提取蛋白质的功能、结构、相互作用伙伴等关键信息;
  • 一个摘要组件,将提取出的发现浓缩并综合成简明报告。

通过将这些或类似组件链式连接起来,研究人员可以自动化本来繁琐且耗时的文献综述和数据整合过程。LangChain 的模块化设计支持预构建的链复用,例如数据收集或分析的常见工作流程,只需将链本身作为组件集成即可。这促进了团队之间分析模块的协作与共享。

提示
如前所述,所有大型语言模型都在多样化数据上进行训练。假如你选择的模型在训练时使用了多种语言的文本数据,那么你就可以利用该 LLM 来搜索或分析其他语言的论文。

LangChain 表达式语言(LCEL)

那么,如何构建一个链(chain)呢?如果你曾涉足数据或技术领域,可能接触过数据管道和各种编排工具。LangChain 通过一种简洁的方式让链的创建过程变得简单。LangChain 表达式语言(LangChain Expression Language,简称 LCEL)提供了一种声明式的方法,将不同的语言组件和模型组合成链,完成复杂任务。LCEL 采用 Runnable 协议,这是一种操作 LangChain 组件的标准接口。该协议定义了诸如 invoke()、stream()、batch() 及其异步版本等方法,允许组件同步或异步地对单个输入、批量输入或增量流式输入执行操作。

警告
自 LCEL 在 0.1.0 版本发布以来,所有先前版本的链均转为遗留状态。LangChain 在 0.3 版本中从 Pydantic 1 迁移至 Pydantic 2,早期 0.0.x 版本的许多示例已不兼容。0.2.0 版本中,多个类和方法被弃用,转而使用基于 LCEL 的实现或更通用的替代方案。例如,ChatVectorDBChain 被 ConversationalRetrievalChain 取代,SequentialChain 被 LCEL 替代。

LCEL 有多项优势,其中之一是内置对流式输出的支持,允许输出边生成边传输,最大限度缩短用户等待时间。同时,它自动优化执行,将可并行的链步骤并发运行。你可以轻松配置重试策略和降级方案,提升链的规模化可靠性。另一大优势是根据所用组件自动生成输入输出 Schema,保障类型安全与验证,方便多元素组合入管道。你还能访问链中各步骤的中间输出,有助于调试及向用户反馈处理进度。底层实现中,LCEL 利用线程池自动批量并行执行同步操作,异步方法默认同样在线程池中执行其同步对应方法,实现原生异步且代码简洁。

LCEL 主要的组合原语有 RunnableSequence 和 RunnableParallel。RunnableSequence 顺序执行一系列组件,前一步输出作为后一步输入。示例3-2 定义了一个简单链,演示用法。目标是将华氏温度转换为摄氏度,通过先减去32再乘以5/9实现,两个步骤用 | 作为分隔符(| 表示“将左侧输出作为右侧输入传递”)。

# 示例3-2. RunnableSequence
from langchain_core.runnables import RunnableLambda, RunnableSequence

# 使用 `|` 运算符构建的 RunnableSequence
sequence = RunnableLambda(lambda x: x - 32) | RunnableLambda(lambda x: x * 5/9)

# 或者

sequence = RunnableSequence(
    first=RunnableLambda(lambda x: x - 32),
    last=RunnableLambda(lambda x: x * 5/9)
)

> sequence.invoke(32)
# await sequence.ainvoke(32)
>>> 0

> sequence.batch([32, 0, -40])
# await sequence.abatch([32, 0, -40])
>>> [0, -17.7777, -40]

RunnableParallel 允许多个组件并发处理同一输入。利用 RunnableParallel 可以同时转换为摄氏度和列氏温度(Réaumur),如示例3-3所示。你还可以结合 RunnableParallel 和 RunnableSequence,构建复杂嵌套链。

# 示例3-3. RunnableParallel
from langchain_core.runnables import RunnableLambda, RunnableParallel

# 包含字典字面量构造 RunnableParallel 的序列
sequence = RunnableLambda(lambda x: x - 32) | {
    'to_celsius': RunnableLambda(lambda x: x * 5/9),
    'to_reaumur': RunnableLambda(lambda x: x * 4/9)
}

# 或者

sequence = RunnableLambda(lambda x: x - 32) | RunnableParallel(
    to_celsius=RunnableLambda(lambda x: x * 5/9),
    to_reaumur=RunnableLambda(lambda x: x * 4/9)
)

> sequence.invoke(32)
# await sequence.ainvoke(32)
>>> {'to_celsius': 0, 'to_reaumur': 0}

> sequence.batch([32, 0, -40])
# await sequence.abatch([32, 0, -40])
>>> [{'to_celsius': 0, 'to_reaumur': 0},
     {'to_celsius': -17.7777, 'to_reaumur': -14.222},
     {'to_celsius': -40, 'to_reaumur': -32}]

除了核心组件,LangChain 还提供各种数据格式化和转换原语,可插入 LCEL 链中,用于绑定参数、调用自定义逻辑、操作输入输出,方便构建复杂管道。LCEL 与 LangChain 的其他功能无缝集成,如用于复杂链可追溯性的 LangSmith,以及仅需少量代码即可将链平滑部署到生产环境的 LangServe。这些将在第5章(构建不同链应用,如假设辩论应用)和第10章(专注于 LangChain 插件)中详细介绍。

另一个值得讨论的话题是输出解析器。LangChain 提供了一个关键桥梁,将 LLM 生成的文本转为应用所需的结构化数据格式,将非结构化输出转换为像 Pydantic 对象这样的明确定义结构,便于与现有系统集成。

示例3-4展示了输出解析器的使用流程。通常包括定义一个结构化模型以表示预期输出,创建对应模型类型的解析器实例,并在提示模板中加入格式指令。示例中,首先使用 Pydantic 定义 PatientAssessment 类,指定期望数据结构及类型:诊断(字符串)、疼痛等级(整数)、症状列表(字符串数组)、是否需住院(布尔值),确保模型输出格式正确、类型安全。随后代码创建典型处理管道,当管道执行时,LLM 根据格式指令生成文本响应,解析器将文本转换为 PatientAssessment 对象,最后展示结构化评估结果,每个字段及其类型清晰呈现。这种从非结构化文本到类型化对象的转换,使信息能轻松用于后续应用,如电子健康记录或临床决策支持系统。

# 示例3-4. 运行病人评估链
from langchain_core.output_parsers import PydanticOutputParser
from langchain_core.prompts import PromptTemplate
from pydantic import BaseModel, Field
from typing import List

# 定义患者数据结构
class PatientAssessment(BaseModel):
    diagnosis: str = Field(description="主要医疗诊断")
    pain_level: int = Field(description="疼痛等级,0-10分")
    symptoms: List[str] = Field(description="报告的症状列表")
    requires_hospitalization: bool = Field(description="是否需要住院")

# 创建解析器
parser = PydanticOutputParser(pydantic_object=PatientAssessment)

# 创建提示模板
template = """
    根据以下患者信息,提供医学评估:
    患者信息:{patient_info}
    {format_instructions}
"""
prompt = PromptTemplate(
    template=template,
    input_variables=["patient_info"],
    partial_variables={"format_instructions": parser.get_format_instructions()}
)

# 设置链
llm = ChatOpenAI(temperature=0, model_name="gpt-4o-mini")
chain = prompt | llm | parser

# 运行链
> chain.invoke({"patient_info": "45岁男性,过去两天出现胸痛、呼吸短促,体温101°F,有高血压病史。"})

>>> PatientAssessment(
        diagnosis='可能伴有胸膜炎的肺炎',
        pain_level=6,
        symptoms=[
            '胸痛', '呼吸短促', '体温101°F', '高血压'
        ],
        requires_hospitalization=True)

本章后续还将介绍内置解析器,如 JsonOutputToolsParser、StrOutputParser 和 OpenAIFunctionsAgentOutputParser 等。

LangGraph

LangGraph 构建于 LangChain 之上,支持使用大型语言模型(LLM)构建有状态、多角色的应用程序。虽然 LCEL 允许你将不同组件组合成线性链或有向无环图(DAG),LangGraph 则通过引入循环(cycles)扩展了这种能力。循环的特性使得 LangGraph 特别适合开发类似智能代理(agent)的行为,其中 LLM 会在循环中反复被调用,根据当前状态决定下一步动作。

提示
LangGraph 并不适合仅限于 DAG 的工作流程。对于需要更可预测、更简单流程的场景,LangChain 的 LCEL 链通常更易用。LCEL 遵循严格的步骤顺序,输出直接依赖于输入和链的结构,使得信息流动更可控、避免意外行为,且调试更方便,因为每一步都是固定顺序执行。对于许多用例,比如查询数据库或运行固定的 API 调用序列,这种结构已足够。

而 LangGraph 提供更大灵活性,但也可能带来更高复杂度,而这并非总是必要。如果工作流程严格线性且不需要循环操作,LCEL 更加合适。如果你的应用需要执行迭代任务并处理复杂决策过程,LangGraph 则是更佳选择。

我们来看一个带循环和不带循环的临床决策支持流程对比(见图3-4)。一个可能的无环流程计划如下:

  1. 收集患者症状、化验结果及病史。
  2. 利用 NLP/LLM 提取相关医疗信息。
  3. 建议患者重做一些较早前的检测。
  4. 应用基于规则或机器学习的模型,确定合适的护理等级。
  5. 向患者提供建议。

上述流程较为直接,没有循环或环路,整个工作流程可以看作是几个链的组合。但如果我们希望在做出最终决策前有一个反馈环呢?这时流程可能是:

  1. 收集患者症状、化验结果及病史。
  2. 利用 NLP/LLM 提取相关医疗信息。
  3. 生成假设和后续问题。
  4. 收集医生或临床人员的反馈。根据反馈结果,要么确定护理等级,要么建议患者进行特定检查 → 验证假设或生成新假设 → 继续收集反馈。
  5. 建议患者进行特定检查。
  6. 应用基于规则或机器学习的模型,确定合适的护理等级。
  7. 向患者提供建议。

这种带有反馈循环的流程,正是 LangGraph 的强项所在。

image.png

第二种理论抽象比第一种顺序模型更贴近现实,其中间环节包含医生的反馈。在 LangGraph 中,核心构建单元是节点(nodes)和边(edges)。每个节点代表一条计算链,可以使用 LangChain 的组件来执行,比如语言模型、检索器、解析器等。边定义了这些计算节点之间的数据流。图结构可以包含环路(cycles),即节点可以将数据发送回自身,或发送给之前给它传递过数据的其他节点。第9章将详细介绍此类医疗应用的实现。

LangGraph 的核心概念之一是“状态”(state),状态在执行过程中在图中各节点间传递。如果把一个 LangGraph 节点比作团队成员,那么状态就像团队的群聊信息。每个节点可以用自己的返回值更新这份内部状态。LangGraph 允许灵活定义状态的表现形式,以适应具体应用需求。例如,在使用聊天模型时,状态可以是迄今为止交换的聊天消息列表。

LangGraph 内置支持异步执行、流式令牌响应、状态持久化(以便保存和恢复图状态)、人机协作流程、复杂图结构的可视化工具,以及“时间旅行”调试功能(可以跳转到任意先前状态)。这些能力使 LangGraph 成为开发需要有状态、多步处理的先进 LLM 应用的强大工具。

本书将在第5章开始构建 LangGraph 团队,我们将打造由链和代理组成的团队(本章稍后会讨论代理),以创建特定主题的科学报告。

提示(Prompts)

“提示”(prompt)成为 2023 年最流行的词汇之一,在 ChatGPT 发布后人气暴涨(见图3-5),甚至改变了其原始含义。在生成式 AI 领域,提示通常被理解为给予 AI 的指令,决定或影响其生成内容。提示工程(prompt engineering)风靡一时,有人甚至戏称英语将成为最流行的编程语言。短时间内涌现了大量提示备忘单、提示大师班等。未来或许会有年度提示大奖或提示黑客马拉松举办。由于提示工程本质上不需编程,这一社区成为所有 AI 开发社区中规模最大的群体之一。

image.png

这种“提示狂热”(promptomania)源于提示的简洁与灵活,它能通过结构化的自然语言指令,有效“编程”日益复杂的 AI 模型。提示迅速从简单输入演变成类似小程序,指导生成式 AI 模型如何产出期望的结果。正如传统软件工程通过编写代码实现特定功能,提示工程则需要精心设计自然语言指令,利用 AI 模型的隐形“API”。提示开始架起人类意图与 AI 理解及生成高度相关内容能力之间的桥梁。

提示之所以强大,是因为它能引导 AI 模型产出高度定制且相关的内容,即使没有明确指出“函数调用”。这种灵活性区别于典型 API 交互,后者术语不匹配往往导致功能完全失效。而提示往往能让模型理解潜在意图,生成满足用户需求的输出,即便措辞不完美。

提示
学习提示工程有助于更好理解 LLM 在上下文中的响应生成。市面上已有多门提示工程课程和书籍,如 James Phoenix 和 Mike Taylor 合著的《Prompt Engineering for Generative AI》(O’Reilly 出版)。建议掌握基础技能,因为未来这项技能将如十几年前 Excel 一样必不可少。

将提示视为小程序,也暗示了编程语言演进的有趣平行。开发者需熟悉不同语言的特性,使用生成式 AI 亦需适应各模型的独特“方言”。精通某一 AI 系统的提示交互将成为重要技能。

在生命科学领域,提示工程的流行程度不及通用话题,主要因 LLM 在生命科学领域的表现有限。问题根源于训练数据和模型能力——它们并非为解方程、制定实验计划、医学诊断等设计。提示工程最大化利用 LLM 能力,但无法从通用语言模型中提炼出科学语言。

提示
别误会,提示有用,但“扮演专业医生”不会让模型生成更多“医疗”知识——顶多是模仿医生的行为。大多数医学情景喜剧也同样具有现实感。

不过,提示可高效用于专门训练和调优的模型。示例3-5展示了提示修改如何影响 multitask-text-and-chemistry-t5-base-augm 模型的输出。我们比较了模型对原始输入和带有提示“作为化学家继续以下短语”和“你是专业化学研究员,完成以下句子”的响应。根据第2章的解码策略讨论,提示的加入会影响模型输出。

# 示例3-5. 化学提示示例
from langchain_huggingface import HuggingFacePipeline
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM, pipeline
from langchain_core.prompts import PromptTemplate
from langchain_core.output_parsers import StrOutputParser

model_name = "GT4SD/multitask-text-and-chemistry-t5-base-augm"
model = AutoModelForSeq2SeqLM.from_pretrained(model_name)
tokenizer = AutoTokenizer.from_pretrained(model_name)

pipe = pipeline(
    "text2text-generation",
    model=model,
    tokenizer=tokenizer,
    temperature=0,
    max_length=512,
    num_beams=5
)

llm = HuggingFacePipeline(pipeline=pipe)

TEXT = "The formula of dihydrogen monoxide is"

basic_prompt = PromptTemplate.from_template("{input_text}")
prompt1 = PromptTemplate.from_template("Continue the following phrase as a chemist: {input_text}")
prompt2 = PromptTemplate.from_template(
    """You are a professional chemistry researcher. Finish the following sentence:
    {input_text}"""
)

basic_chain = basic_prompt | llm | StrOutputParser()
prompt1_chain = prompt1 | llm | StrOutputParser()
prompt2_chain = prompt2 | llm | StrOutputParser()

> basic_chain.invoke({"input_text": TEXT})
>>> [O-][Mn](=O)(=O)=O

> prompt1_chain.invoke({"input_text": TEXT})
>>> [O-][Mn](=O)(=O)=O.[O-][Mn](=O)(=O)=O.[O-][Mn](=O)(=O)=O

> prompt2_chain.invoke({"input_text": TEXT})
>>> The molecule is a dihydrogen monoxide. It is a conjugate base of a
dihydrogen monoxide(2+). It is a conjugate acid of a dihydrogen monoxide(1-).

显然,单纯延续原始短语并不能产生理想输出。只有通过提示模型“作为专业化学研究员完成句子”,结果明显改善,而“作为化学家继续短语”对模型指导作用较弱。

在后续应用中,我们将设计不同提示作为链和代理的一部分。以下是可用于科研场景的部分提示示例:

化学研究

  • 化学合成路径预测:基于现有知识库,规划[化合物名称]的潜在合成路径,包括反应条件和中间体。
  • 化学性质预测:基于分子结构,预测[化合物名称]的沸点和水溶性。
  • 反应机理探索:解释[特定反应名称]的机理,重点描述关键过渡态和中间体。

生物学研究

  • 基因功能注释:注释[特定基因]在[生物体]中的功能,包括其在代谢途径和疾病关联中的作用。
  • 微生物群落分析:分析[特定环境]中的微生物群落组成及其对生态系统功能的影响。
  • 生物通路模拟:模拟[特定生物通路],识别关键调控点和潜在治疗靶标。

药物发现

  • 药物-靶点相互作用预测:预测[药物化合物]与[生物靶点]的相互作用,包括潜在的脱靶效应。
  • 药代动力学建模:模拟[新药化合物]的吸收、分布、代谢和排泄特征。
  • 药物再利用机会:探索针对[特定疾病]病理生理新发现的现有药物再利用可能性。

医学与医疗

  • 诊断标准总结:总结[特定疾病]的诊断标准,包括必要的实验室和影像学检查。
  • 治疗方案比较:比较[治疗方案A]与[治疗方案B]针对[特定病症]的疗效和副作用。
  • 临床试验结果预测:基于干预机制和患者人口统计,预测临床试验[试验编号]的结果。

通过提示,你可以提供示例以指定期望的输入-输出对。许多科研案例包含逐步推理过程,我们可以通过示例向生成式 AI 应用展示期待的思考路径,如示例3-6所示。这种方法称为少样本学习(few-shot learning),即向模型提供少量示例以引导其响应,特别适合数据有限的任务,帮助模型理解任务并生成更准确、语境契合的输出。

提示
少样本学习让 LLM 在仅有极少训练数据时也能执行分类任务。方法是向模型提供每个类别的几个示例,让它推断潜在模式并应用于新数据。

设置示例列表后,将用 PromptTemplate 将示例转换为 LangChain 友好的提示格式,并通过 FewShotPromptTemplate 设置最终提示,变量用大括号 {} 标记。

# 示例3-6. 少样本示例
from langchain_core.prompts import FewShotPromptTemplate, PromptTemplate

examples = [
    {
        "question": "青霉素对大肠杆菌有效吗?",
        "answer": """是否需要后续问题:是。
            后续问题:青霉素属于哪类抗生素?
            中间答案:青霉素属于β-内酰胺类抗生素。
            ...
            后续问题:大肠杆菌对β-内酰胺抗生素有抗药性吗?
            中间答案:许多大肠杆菌菌株对β-内酰胺类抗生素产生了抗药性,包括青霉素。最终结论是:由于抗药性,青霉素通常对大肠杆菌无效。
        """,
    },
    {
        "question": "阿司匹林和布洛芬的作用机制相同吗?",
        "answer": """是否需要后续问题:是。
            后续问题:阿司匹林的作用机制是什么?
            中间答案:阿司匹林通过抑制环氧合酶(COX)酶,减少前列腺素和血栓素的生成,发挥抗炎和抗凝作用。
            后续问题:布洛芬的作用机制是什么?
            中间答案:布洛芬也抑制环氧合酶(COX)酶,减少前列腺素生成。
            所以最终结论是:阿司匹林和布洛芬的作用机制相同,都是抑制 COX 酶。
        """,
    },
    # 更多示例...
]

example_prompt = PromptTemplate(
    input_variables=["question", "answer"],
    template="问题:{question}\n回答:{answer}"
)

prompt = FewShotPromptTemplate(
    examples=examples,
    example_prompt=example_prompt,
    suffix="问题:{input}",
    input_variables=["input"],
)

现在,我们可以测试少样本示例的提示,并比较输出。生成的 few_shot_chain 输出符合示例描述。此方法或能提供更准确结果,虽然仅是模拟推理过程。第6章将更详细介绍推理模型。

TEXT = """癌基因和肿瘤抑制基因中的遗传突变如何协同驱动转移性癌症的进展?"""

from langchain_openai import ChatOpenAI
llm = ChatOpenAI(temperature=0, model_name="gpt-4o-mini")

> llm.invoke(TEXT).content
>>> 转移性癌症的进展是一个复杂过程,受癌基因和肿瘤抑制基因遗传突变的影响。这两类基因在调控细胞生长、分裂和凋亡中发挥关键作用,其突变可能导致细胞增殖失控和肿瘤形成……这两种基因改变之间的平衡决定了癌细胞的行为,包括其增殖、侵袭及远处转移能力。理解这些相互作用对于开发靶向疗法和改善癌症治疗效果至关重要。

few_shot_chain = prompt | llm | StrOutputParser()
> few_shot_chain.invoke({"input": TEXT})
>>> 是否需要后续问题:是  
后续问题:什么是癌基因?  
中间答案:癌基因是正常基因(原癌基因)的突变形式,促进细胞生长和分裂。突变后可导致细胞不受控增殖。  

后续问题:什么是肿瘤抑制基因?  
中间答案:肿瘤抑制基因正常调控细胞生长分裂、修复 DNA 损伤并促进程序性细胞死亡(凋亡)。突变导致功能丧失,引发细胞异常增生和存活。  
……  
最终结论是:癌基因的突变通过促进不受控细胞生长驱动癌症进展,而肿瘤抑制基因的突变则移除关键调控,促进肿瘤发展和转移。这些突变协同作用,加速转移性癌症的进程。

关于提示策略,有许多最佳实践。图3-6总结了基于多方资料的良好提示结构要素。

image.png

以下是撰写有效提示(prompt)的一些建议:

  • 提示应清晰且直接,开头明确主要目标和输入变量——你希望 AI 做什么、你有什么输入。这有助于立即设定预期。
  • 明确格式要求:主题、时间范围以及任何重要细节。如果需要列表,说明项目数量和格式——是否使用项目符号或完整句子。
  • 如果需要引用来源,指出信息应来自何处。
  • 需要摘要时,说明摘要长度。
  • 需要解释时,说明期望的详细程度。清晰明确可减少偏题或模糊回答的概率。
  • 若有关键规则,务必加入提示中,比如避免某些来源、使用特定写作风格或坚持已验证事实。简短背景介绍也有助于 AI 理解信息需求的原因。

如前所述,不同模型对提示的有效性存在差异。语言模型与推理模型的提示设计方法差异显著,因两者功能机制不同。表3-1总结了传统语言模型与推理模型在提示工程上的关键区别。主要差异体现在初始上下文提供量、交互模式(聊天 vs 报告生成)及重点是目标还是过程。传统语言模型依赖迭代指导和明确指令,推理模型更偏好一次性提供充分上下文,过程指导较少。

方面语言模型推理模型
提示结构包含详细上下文;使用思维链(CoT)提示;引导逐步推理;迭代细化注重目标而非过程;最小化“如何做”指导;避免思维链提示;一次性提供全面上下文
上下文处理逐步增加上下文;LLM 上下文放在末尾;中等量上下文足够提供比预想多10倍的上下文;包含所有失败尝试;相关数据库模式;解释公司背景及术语
格式与风格明确期望输出格式;可指定写作风格和语气;包含示例演示格式聚焦“是什么”而非“怎么做”;明确期望输出/产物;偏学术或企业报告风格;写作风格难控
示例使用提供一到少量示例引导格式;包含清晰示例示例较少;注重定义好坏输出标准

最近推出的 LangSmith Hub(前称 LangChain Hub)旨在解决提示工程中的常见问题。其主要目标是汇聚分散的提示知识与经验,解决不同 LLM 间提示不通用的挑战,并通过更便捷透明的提示管理促进跨团队协作。LangSmith Hub 是开发者发现新用例和优质提示的首选平台。你可以设计自有提示,也可自由测试领域内共享提示。示例3-7演示了如何定义自有提示及从 LangSmith Hub 拉取提示,用于摘要任务。

# 示例3-7. 从 LangSmith Hub 拉取提示
from langchain_core.prompts import PromptTemplate

template = (
    "Summarize the following abstract: {abstract}\n"
    "Use the following format: {format}"
)
prompt = PromptTemplate.from_template(template)

# 或者从 Hub 拉取提示
from langchain import hub
prompt = hub.pull("<someones>/abstract-formatting-prompt")
# 例如:rlm/rag-prompt 或 hwchase17/react

提示
近年来“上下文工程”(context engineering)被广泛讨论。上下文工程是对 AI 模型运行的整个信息环境进行系统设计与管理的过程。它可视为提示工程的进化版,从简单的指令编写拓展为优化 LLM 全部上下文窗口的综合方法。提示工程专注于有效指令的撰写,而上下文工程涵盖了设计和管理所有输入输出信息——包括结构化数据、动态元素(如日期/时间)、通过 RAG 实现的知识检索、工具定义、少样本示范及记忆管理等。此综合方法认识到,AI 性能最优不仅取决于你如何提问,更取决于你如何构建其运作的完整信息环境。

本书贯穿始终强调在生成式 AI 任务中合理利用上下文的重要性。提示工程在创意任务和一次性交互中依然重要,但其作用范围有限且较为被动、手动。上下文工程实质上包含了提示工程——把提示视为更大、系统化设计信息架构中的一个组成部分。这种从“提示优先”向“上下文优先”的转变,有望帮助组织构建不仅智能且可靠、能规模化生产的 AI 系统。

记忆

开箱即用的语言模型无法保持连贯的上下文,也无法利用之前提示或对话中的信息。因此,它们无法记住或基于之前提到的细节进行拓展,导致回答不完整。这一限制对需要保持一致对话或在长时间交互中理解上下文的应用构成挑战。

在 LangChain 中,记忆指的是链或代理执行之间持续存在的状态(见图3-7)。强大的记忆机制不仅支持构建会话式和交互式应用,还能通过存储聊天历史上下文,随着时间推移提升 LLM 回答的连贯性和相关性。实现方式是在每次调用时将对话记忆传递给模型,利用链来保持一致性,而不是将每次用户输入视为孤立的提示。

image.png

代理(Agents)(本章后续讨论)也可以在记忆中持久化事实、关系和推理内容,使代理始终保持上下文关联。记忆中存储的目标和已完成任务,帮助代理在多轮对话中跟踪多步骤目标的进展。此外,信息保存在记忆中还能减少对语言模型的重复调用次数。

提示
上下文记忆使聊天机器人变得有价值。试想如果没有记忆,聊天机器人将毫无用处。没有记忆,聊天机器人无法提供上下文相关的回答,也无法随着时间推移改进交互体验。

LangChain 提供了标准的记忆接口,并集成了多种存储选项(如数据库,详见 LangChain 文档),还设计了将记忆有效融入链和代理的设计模式。除了第三方集成,LangChain 内置多种记忆方案,各有权衡。例如,ConversationBufferMemory 存储所有消息,而 ConversationBufferWindowMemory 仅保留最近消息。

记忆方案的选择取决于持久化需求、数据关系和规模等因素。内存缓存提升性能,持久数据库存储支持长期状态保存,专用记忆服务器则针对会话上下文提供专门优化。示例3-8展示了 LangChain 中记忆的实现示例。示例使用 RunnableWithMessageHistory,通过在提示模板的 MessagesPlaceholder(variable_name="history") 中包含 get_session_history() 的结果,将之前消息融入可执行链中。run_chat() 方法通过传入 session 参数演示记忆的工作流程。

# 示例3-8. LangChain 记忆设置
from langchain_community.chat_message_histories import ChatMessageHistory
from langchain_core.chat_history import BaseChatMessageHistory
from langchain_core.runnables.history import RunnableWithMessageHistory
from langchain_core.prompts import MessagesPlaceholder, ChatPromptTemplate

class Chat:
    def __init__(self, store=None, model=None):
        self.store = store if store is not None else {}
        self.model = model if model is not None else ChatOpenAI(temperature=0)
        self.prompt_template = self._create_prompt_template()
        self.runnable = self.prompt_template | self.model
        self.chat = RunnableWithMessageHistory(
            self.runnable,
            self.get_session_history,
            input_messages_key="input",
            history_messages_key="history",
        )

    @staticmethod
    def _create_prompt_template():
        """ 构造并返回聊天提示模板 """
        return ChatPromptTemplate.from_messages([
            ("system", "你是一个擅长理解医学副作用和禁忌症的助手"),
            MessagesPlaceholder(variable_name="history"),
            ("human", "{input}")
        ])

    def get_session_history(self, session_id: str) -> BaseChatMessageHistory:
        return self.store.setdefault(session_id, ChatMessageHistory())

    def run_chat(self, input_text, session_id: str) -> str:
        response = self.chat.invoke(
            {"input": input_text},
            config={"configurable": {"session_id": session_id}},
        )
        return response.pretty_print()

示例3-9定义了两个会话:abc123 和 xyz123。
在第一个会话(abc123)中,系统回答了有关阿司匹林副作用的问题,列出了常见问题如胃部不适和胃灼热。当在同一会话内提出“我可以在飞行前服用吗?”时,系统正确识别指的是阿司匹林,提供了关于飞行中服用安全性的相关建议,提及其血液稀释作用并给出注意事项。

关键观察在于,当相同问题“我可以在飞行前服用吗?”出现在新会话(xyz123)时,因无先前阿司匹林相关上下文,系统无法判定所指药物,只能请求进一步说明。这展示了聊天系统如何为不同会话维护独立的对话历史,既保留了会话内上下文,又有效隔离不同用户或会话的对话,这对医疗信息系统准确且上下文相关的回答至关重要。

# 示例3-9. LangChain 记忆测试
# 初始化聊天
med_chat = Chat()

# 会话 abc123
> med_chat.run_chat("阿司匹林有哪些副作用?", "abc123")
>>> 阿司匹林的常见副作用包括:
1. 胃部不适或疼痛
2. 胃灼热
3. 恶心
......

# 会话 abc123 继续
> med_chat.run_chat("我可以在飞行前服用吗?", "abc123")
>>> 通常情况下,在飞行前服用阿司匹林是安全的,尤其是如果你使用它的血液稀释特性来防止长时间不动时血栓形成。但需注意以下几点:
......

# 新会话 xyz123
> med_chat.run_chat("我可以在飞行前服用吗?", "xyz123")
>>> 很乐意帮助您,但请您提供更多关于所询问药物的信息。知道具体药物后,我能更准确地回答其在飞行前服用的安全性。

示例3-9中未使用记忆,仅用简单字典存储。除了记忆集成外,还有多种优质方案可用于在链和代理间持久化相关状态:

  • ConversationBufferMemory
    最简单的会话记忆形式,存储用户与 AI 系统间的全部对话历史,无令牌限制和摘要。
  • ConversationBufferWindowMemory
    只保留对话中最近一小部分交互,适用于近期上下文最重要的任务,避免系统数据过载。
  • ConversationEntityMemory
    专注存储和检索对话中提及的特定实体,如姓名、地点、日期或系统需跟踪的其他数据点。
  • ConversationKGMemory
    知识图谱记忆,利用结构化信息及实体间关系,使 AI 系统能访问并融合外部知识图谱中的知识。
  • ConversationSummaryBufferMemory
    结合缓冲区和摘要理念,存储对话历史的简要版本,减少系统处理数据量,同时保留部分上下文。

随着本书出版,可能还会出现更多记忆及其他组件选项。

工具(Tools)

工具是 LangChain 的基础组件,架起语言模型与外部服务、API 及实际应用所需功能之间的桥梁。LangChain 的工具主要目的是扩展语言模型的能力,超越纯文本处理。换句话说,它们使语言模型能够通过调用定制的专业功能,完成更广泛的任务。

工具封装了与外部系统集成的复杂性,提供简单统一的接口,让语言模型可以直接使用,而无需了解底层实现细节,如示例3-10所示。该示例通过 BaseTool 类(GeneSequenceAnalyzer)和 @tool 装饰器(DilutionCalculator)两种方式创建工具。两者差异不大,@tool 装饰器定义更简洁快速,自动根据函数名和描述生成 Tool 或 StructuredTool;而 BaseTool 方式在同步和异步运行上更灵活。

# 示例3-10. 自定义工具示例
from typing import Type, Optional, Union
from pydantic import BaseModel, Field
from langchain_core.tools import BaseTool, tool
from langchain_core.callbacks.manager import (
    AsyncCallbackManagerForToolRun,
    CallbackManagerForToolRun,
)

# GeneSequenceAnalyzer - 方案1:继承 BaseTool
class GeneSequenceInput(BaseModel):
    sequence_a: str = Field(description="第一条DNA序列")
    sequence_b: str = Field(description="第二条DNA序列")

class GeneSequenceAnalyzer(BaseTool):
    name = "GeneSequenceAnalyzer"
    description = "比较两条DNA序列的相似度百分比。"
    args_schema: Type[BaseModel] = GeneSequenceInput
    return_direct: bool = True

    def _calculate_similarity(self, sequence_a: str, sequence_b: str) -> float:
        matches = sum(1 for a, b in zip(sequence_a, sequence_b) if a == b)
        length = max(len(sequence_a), len(sequence_b))
        return (matches / length) * 100 if length > 0 else 0

    def _run(
        self, sequence_a: str, sequence_b: str,
        run_manager: Optional[CallbackManagerForToolRun] = None
    ) -> str:
        similarity_percentage = self._calculate_similarity(sequence_a, sequence_b)
        return f"给定DNA序列的相似度为 {similarity_percentage:.2f}%."

# DilutionCalculator - 方案2:使用 @tool 装饰器
class DilutionInput(BaseModel):
    aliquot_volume_ml: float = Field(..., description="待稀释溶液体积(毫升)")
    final_volume_ml: float = Field(..., description="稀释后最终体积(毫升)")

@tool(args_schema=DilutionInput)
def calculate_dilution(
    aliquot_volume_ml: float,
    final_volume_ml: float,
    run_manager: Optional[CallbackManagerForToolRun] = None
) -> str:
    if aliquot_volume_ml <= 0 or final_volume_ml <= 0:
        return "待稀释体积和最终体积均须大于0。"
    if aliquot_volume_ml > final_volume_ml:
        return "待稀释体积不能大于最终体积。"
    dilution_factor = final_volume_ml / aliquot_volume_ml
    return f"稀释倍数为 {dilution_factor:.2f}"

提示
默认使用 bind_tools() 时,模型可以灵活地返回单个工具调用、多个调用,或不调用任何工具。但部分模型支持 tool_choice 参数,可强制模型调用指定工具,比如 tool_choice="xyz_tool_name"。也可以设为 tool_choice="any",要求模型至少调用一个工具,但不限定具体哪个。

我们定义了 GeneSequenceAnalyzer 和 DilutionCalculator 工具,接下来通过 model.bind_tools() 将这些工具绑定到 LLM,并定义 call_tool() 方法以供调用链使用(见下一代码段)。链中映射了可调用工具。接着会提出基因序列比较、稀释问题和一般药物发现问题。那么链如何决定调用哪个工具?

工具的设计(名称、描述、输入结构)至关重要,影响语言模型理解与使用效率。设计时要确保接口清晰、直观、无歧义。绑定后,LLM 可以访问所有工具名、描述和输入字段,基于上下文决定调用哪个工具。

提示
我们为 GeneSequenceAnalyzer 定义了 _run 方法,_arun 方法专为异步操作设计。使用 _arun 可实现任务并发执行,避免阻塞,显著提升性能,尤其在网络请求或文件处理等 I/O 密集场景。异步特性使 LangChain 能同时处理多个请求,提升用户体验。

看示例3-10中的 DilutionInput,字段均有明确描述:初始体积与目标体积。模型从查询中提取信息,参考工具列表,返回应调用的工具函数(calculate_dilution())及参数(aliquot_volume_ml 和 final_volume_ml)。LLM 决定调用哪个工具及其参数,链执行选定函数,获取结果并传回模型,由模型生成最终回复(见示例3-11)。

# 示例3-11. 自定义工具示例续
from langchain_openai import ChatOpenAI
from langchain_core.output_parsers.openai_tools import JsonOutputToolsParser
from langchain_core.runnables import (
    Runnable,
    RunnableLambda,
    RunnablePassthrough,
)
from operator import itemgetter

tools = [GeneSequenceAnalyzer(), calculate_dilution]
tool_map = {tool.name: tool for tool in tools}

model = ChatOpenAI(model="gpt-4o", temperature=0)
model_with_tools = model.bind_tools(tools)

def call_tool(tool_invocation: dict) -> Union[str, Runnable]:
    tool = tool_map[tool_invocation["type"]]
    return RunnablePassthrough.assign(output=itemgetter("args") | tool)

call_tool_list = RunnableLambda(call_tool).map()
chain = model_with_tools | JsonOutputToolsParser() | call_tool_list

> chain.invoke("AGCTGACCTG 和 AGCTTACCGT 基因序列有多相似?")
>>> [{'type': 'GeneSequenceAnalyzer',
      'args': {'sequence_a': 'AGCTGACCTG', 'sequence_b': 'AGCTTACCGT'},
      'output': '给定DNA序列的相似度为 70.00%.'}]

> chain.invoke("如果我向当前50毫升溶液中加100毫升,溶液会被稀释多少?")
>>> [{'type': 'calculate_dilution',
      'args': {'aliquot_volume_ml': 50, 'final_volume_ml': 150},
      'output': '稀释倍数为 3.00'}]

> chain.invoke("我手上有40毫升溶液,想配制1升溶液,稀释倍数是多少?")
>>> [{'type': 'calculate_dilution',
      'args': {'aliquot_volume_ml': 40, 'final_volume_ml': 1000},
      'output': '稀释倍数为 25.00'}]

> chain.invoke("药效和效力有什么区别?")
>>> []

LangChain 内置许多有用的预构建工具。从科研角度看,Google Scholar、Wolfram Alpha 和 PubMed 集成非常有前景。后续章节我们将定义多个自定义工具,调用外部 Python 包和科学相关 API。

工具的一个重要特点是它们的可组合性。语言模型可以灵活地将多个工具组合起来,完成更复杂的多步骤任务。例如,一个模型可以先使用搜索工具查找相关信息,再用转换工具将信息统一格式,最后用计算工具执行所需的计算。另一个例子是在 RAG 中使用文档加载器和拆分工具。这种组合能力为开发复杂的处理管道释放了巨大潜力。

提示
LangChain 提供了便捷的工具包(toolkits),这些工具包是一组为特定任务设计、可一起使用的工具,并且已经具备易用的加载方式。你可以查看 LangChain 文档中的“Tools/Toolkits”部分,了解包含 CSV、JSON 文件处理、pandas 等的完整工具包列表。

从开发者角度来看,工具的模块化设计也便于测试和调试。由于工具是独立组件,它们的行为可以被隔离并独立验证。这有助于确保整个系统按预期运行,并减轻语言模型的认知负担。

代理(Agents)

LangChain 代理是一个强大的概念,使得创建动态的、目标导向且可交互的系统成为可能。代理是一种自主的软件实体,能够进行推理并决定采取何种行动以完成特定目标。

代理与链(chains)非常相似,但存在关键区别。此前搭建链时,我们明确指定了具体步骤和顺序;而代理则使用大型语言模型(LLM)作为推理引擎,动态决定使用哪些工具以及使用顺序。代理通过协调和利用链来执行以目标为驱动的动作。链定义了可复用的逻辑组件序列,而代理则将链视作工具,观察环境、决定执行哪个链、采取相应动作,并重复该过程。

代理执行器运行时管理循环流程,包括查询代理、执行工具动作、返回观察结果。运行时处理错误管理、日志记录和解析等底层复杂性,使代理开发者能专注于高层逻辑。代理具有可组合性,能结合复用组件链,构建强大灵活的系统架构。

提示
可以将 LangChain 代理视为一个独立的 AI 实例,你可以通过设置模型、提示和工具来编程它。

代理和链的一个主要限制是无状态性——每次执行相互独立,不保留之前的上下文。这正是 LangChain 中“记忆”概念的重要性所在。记忆指的是跨链和代理执行持久保存信息,以实现有状态处理和支持多步骤工作流。

示例3-12展示了一个能够找到最近 DNA 序列对的代理,使用稍作修改的 GeneSequenceAnalyzer 工具。我们将结合本章学到的知识,定义 calculate_dna_similarity 工具,并从 LangChain Hub 拉取提示,创建一个 ReAct 代理。

提示
随着 LangChain 持续发展,未来可能支持更多类型代理。请关注官方文档及 LangChain4LifeSciencesHealthcare 仓库获取最新信息。

LangChain 视 AgentExecutor 为过时组件,适合初学者学习基础,但高级用户若需更多定制和精准控制,可能受限。本书后续章节将使用 LangGraph 探讨更复杂的代理实现。

ReAct 代理设计用来基于处理的信息进行推理和行动(ReAct 即 Reason + Act 的缩写)。它将推理(思考与规划)与行动(执行步骤或任务)结合,实现特定目标。代理不遵循固定指令集,而是动态评估形势、做决策并采取行动,能适应新信息和环境变化,处理复杂任务时更灵活智能。我们将用 create_react_agent() 方法创建 ReAct 代理,并用 AgentExecutor 调用代理。

示例3-12要求代理从列表 AGCTA、CTTAC、AGCTG、AGAGA 中找出两个最相近的基因序列,基于 LLM 拥有的工具和知识执行。

# 示例3-12. 代理基因序列比较
from pydantic import BaseModel, Field
from langchain import hub
from langchain.agents import AgentExecutor, create_react_agent
from langchain_openai import OpenAI

# 修改工具以接受单一输入
class GeneSequenceSingleInput(BaseModel):
    pair: str = Field(..., description="待比较的DNA序列对")

@tool(args_schema=GeneSequenceSingleInput)
def calculate_dna_similarity(pair: str) -> float:
    sequence_a, sequence_b = pair.split(", ")
    matches = sum(1 for a, b in zip(sequence_a, sequence_b) if a == b)
    length = max(len(sequence_a), len(sequence_b))
    return (matches / length) * 100 if length > 0 else 0

tools = [calculate_dna_similarity]
prompt = hub.pull("hwchase17/react")
model = OpenAI(temperature=0)

agent = create_react_agent(model, tools, prompt)
agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=True)

> agent_executor.invoke({"input": """Find two closest genes from the given list:
                                AGCTA, CTTAC, AGCTG, AGAGA"""})
# AI生成内容节选:
> Entering new AgentExecutor chain...
I should use calculate_dna_similarity to compare each pair of genes
Action: calculate_dna_similarity
Action Input: AGCTA, CTTAC 0.0 means no similarity
Action: calculate_dna_similarity
Action Input: AGCTA, AGCTG 80.0 means high similarity
...  
Thought: Two closest genes are AGCTA and AGCTG with similarity 80.0%
Final Answer: Two closest genes are AGCTA and AGCTG with similarity 80.0%.

> Finished chain.
>>> {'input': 'Find two closest genes from the given list: AGCTA, CTTAC, AGCTG, AGAGA',
     'output': 'Two closest genes are AGCTA and AGCTG with a similarity of 80.0%.'}

代理通过多次调用 calculate_dna_similarity 工具,利用穷举搜索成功找出最接近的两条序列。

LangChain 提供多种内置代理类型,针对不同用例优化:

  • OpenAI Tools agent
    设计用于聊天历史和多输入工具,支持并行函数调用,适合需要工具集成的对话式 AI 应用,自然融入多工具会话流程并保持上下文。
  • OpenAI Functions agent
    专为支持 OpenAI 函数调用接口的模型设计,实现语言模型与外部工具间的结构化交互,可靠解析语言模型的结构化输出。
  • XML agent 与 JSON Chat agent
    专注处理 XML 和 JSON 格式的输入输出,适合对数据格式要求严格的系统,保证不同系统组件间解析一致。
  • Structured Chat agent
    支持多输入工具,适用复杂任务执行,能处理涉及多个参数的复杂工作流,增强现实应用灵活性。
  • Self Ask With Search agent
    用于推理过程中需要检索或信息获取的场景,能提出问题并利用搜索工具寻找答案,特别适合开放域问答和科研任务。
  • RunnableAgent
    提供灵活的代理实现框架,高度可定制,接口统一,良好集成于 LangChain 生态。
  • ReAct agent
    即前文描述的基于推理与行动结合的代理。

本书后续多个场景将通过定义提示、提供相关工具并利用语言模型,创建自定义代理。示例包括动态决定搜索哪些数据库、阅读哪些论文、如何总结关键发现以回答研究问题。实验设计代理可根据实验室设备和历史结果,建议验证假设的实验序列。医疗诊断代理能询问患者、安排检测、提出诊断建议。治疗规划代理考虑病史、现状及可选方案,推荐个性化护理计划。监控与报警代理持续跟踪患者数据,必要时触发干预。

使用 LangChain 创建应用程序

既然我们已经了解了所有主要的 LangChain 组件,接下来就看看 LangChain 是如何将它们组合起来的。这里我们要演示的管道,旨在识别 2023 年诺贝尔化学奖背后的研究细节,包括诺贝尔奖授予的原因以及该领域最新成果的相关问题。完整代码托管在 LangChain4LifeSciencesHealthcare 仓库中。

首先,我们导入所有必要的包,按 LangChain 组件架构分成逻辑类别:

# 模型
from langchain_openai import ChatOpenAI, OpenAIEmbeddings

# 索引和向量存储
from langchain_community.vectorstores import Chroma

# 链
from langchain_core.runnables import RunnablePassthrough, RunnableLambda
from operator import itemgetter

# 记忆
# 虽然自0.3.1版本起已弃用,但如果不使用 langgraph 仍可正常
from langchain.memory import ConversationSummaryBufferMemory, ChatMessageHistory

# 提示
from langchain_core.prompts import MessagesPlaceholder, ChatPromptTemplate

# 工具
from langchain_community.document_loaders import PyPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_core.utils.function_calling import format_tool_to_openai_function
from langchain_core.tools.retriever import create_retriever_tool
from langchain_community.tools.pubmed.tool import PubmedQueryRun

# 代理
from langchain.agents import AgentExecutor
from langchain.agents.format_scratchpad import format_to_openai_function_messages
from langchain.agents.output_parsers.openai_functions import OpenAIFunctionsAgentOutputParser

记忆设置部分创建了带有热身对话的会话历史,用以初始化对话上下文——这里包括简单的“你好—你好”开场对白。热身消息为模型预置了初始交互模式,有助于建立后续对话的语气和期望行为。此设置用 ChatMessageHistory 存储实际对话轮次,再通过 ConversationSummaryBufferMemory 包装,实现对增长中的对话自动摘要。

这种记忆实现解决了会话 AI 系统的一大挑战:如何随时间保持上下文。缓冲记忆策略十分巧妙,不只是简单保存原始对话(这会变得难以管理),而是主动总结对话早期内容,在控制令牌使用量的同时保留重要上下文。常量 MEMORY_KEY(chat_history)作为引用点,供其他组件访问此对话记忆。此方法确保 AI 助手始终维护上下文,使回复更连贯、相关。

MEMORY_KEY = "chat_history"

def setup_conversation_memory(model_name):
    """初始化带有热身对话的会话记忆"""
    history = ChatMessageHistory()
    history.add_user_message("Hi, I want you to help me to answer some questions and complete a couple of tasks")
    history.add_ai_message("Hello! I can help you. Can you specify your task?")
    
    memory = ConversationSummaryBufferMemory(
        llm=ChatOpenAI(temperature=0, model_name=model_name),
        return_messages=True,
        memory_key=MEMORY_KEY,
        chat_memory=history
    )
    
    return memory, history

提示模板定义了发送给 AI 模型的信息结构,包括系统指令、对话历史、用户输入和代理推理。系统消息确定助手角色为科学问题解答者,指示其使用可用工具、正确理解问题,若不知答案则回复“抱歉,我不知道”。MessagesPlaceholder 与专门的 agent_scratchpad(代理推理空间)实现上下文感知和透明推理。注意 MEMORY_KEY 用于访问聊天记忆。

def create_prompt_template(memory_key):
    """创建包含系统消息和占位符的提示模板"""
    return ChatPromptTemplate.from_messages([
        (
            "system",
            """
            You are an assistant who helps answer scientific questions.
            Use tools you have if required.
            Be sure to understand the question correctly.
            If you don't know the answer - respond, "Sorry, I don't know."
            """
        ),
        MessagesPlaceholder(variable_name=memory_key),
        ("user", "{input}"),
        MessagesPlaceholder(variable_name="agent_scratchpad"),
    ])

组件准备完毕后,我们构建一个简单的 RAG(检索增强生成)管道:加载 2023 年诺贝尔化学奖声明(用 PyPDFLoader),将其拆分成可搜索的小段落(RecursiveCharacterTextSplitter),用 OpenAIEmbeddings 嵌入这些段落,并存储进支持相似度搜索的 Chroma 向量数据库。这里段落大小(500字符)和重叠(200字符)参数经过精心选择,确保分段既足够精细精准检索,又包含丰富有效信息。通过重叠段落维持跨段上下文,对理解可能跨多段落的复杂科学概念至关重要。

使用 OpenAI 嵌入模型对段落进行向量化,并存入支持相似搜索的 Chroma 数据库。创建了两个工具:一个用于搜索诺贝尔奖文档的自定义检索工具(search_through_pdf_text),另一个是访问科学文献的 PubMed 查询工具(PubmedQueryRun)。这些工具扩展了模型能力,提供了访问特定知识源的接口。

link = ".../advanced-chemistryprize2023.pdf"
pdf_loader = PyPDFLoader(link)
pdf_doc = pdf_loader.load()

text_splitter = RecursiveCharacterTextSplitter(
    separators=["\n"],
    chunk_size=500,
    chunk_overlap=200,
    keep_separator=False
)
chunks = text_splitter.split_documents(pdf_doc)

vectorstore = Chroma.from_documents(
    documents=chunks,
    embedding=OpenAIEmbeddings()
)
retriever = vectorstore.as_retriever()

retrive_tool = create_retriever_tool(
    retriever,
    "search_through_pdf_text",
    """This function searches and returns data from pdf text regarding
    Nobel prize winners and their work""",
)

tools = [PubmedQueryRun(), retrive_tool]
formatted_functions = [format_tool_to_openai_function(t) for t in tools]

我们将以 gpt-4o-mini 作为示例模型,无需专门训练。示例流程与前述一般模型类似。如“工具”部分所示,我们通过 bind() 方法将语言模型与工具绑定。绑定过程利用 OpenAI 函数调用能力,将语言模型与工具连接,使模型能基于用户查询决定何时及如何使用各工具。这构建了一个集成系统,语言模型能推理何时检索信息,何时直接回答。

最后,我们将启动一个代理,将支持工具的语言模型、提示和记忆组合起来,并指定一个链来使用。通过 format_to_openai_function_messages 函数处理中间步骤,系统会创建一个透明的代理思考过程记录,并反馈给模型。这形成了一种递归推理模式,模型在决定下一步行动时能反思之前的思路。记忆集成确保这种推理发生在对话历史的上下文中。设置 verbose 标志可在执行过程中显示此过程,有助于调试并通过展示代理如何得出结论来增强用户信任。最终的 QA 代理构建了一个完整系统,能够在文档检索、PubMed 查询和直接回答间切换,提供关于诺贝尔奖研究及相关科学进展的全面信息。

model_name = "gpt-4o-mini"
llm = ChatOpenAI(temperature=0, model_name=model_name)
llm_with_tools = llm.bind(functions=formatted_functions)

agent = (
    {
        "input": lambda x: x["input"],
        "agent_scratchpad": lambda x: format_to_openai_function_messages(
            x["intermediate_steps"]
        ),
    }
    | RunnablePassthrough.assign(
        **{MEMORY_KEY: RunnableLambda(memory.load_memory_variables) | itemgetter(MEMORY_KEY)}
    )
    | prompt
    | llm_with_tools
    | OpenAIFunctionsAgentOutputParser()
)

qa = AgentExecutor(agent=agent, tools=tools, memory=memory, verbose=True)

图3-8 展示了该管道的可视化。我们将文档上传至向量存储,使用 QA 代理从中检索信息并回答用户问题。同时设置 PubMed 工具用于从 PubMed API 获取数据。

现在是测试我们的问答代理的时候了。首先问一个问题,确认应用配置正确、提示正常工作,且模型是否会访问网络。这里查询 2023 年诺贝尔文学奖得主及获奖原因,显然这不是文档中提供的信息:

query = """Who won the Nobel prize in Literature in 2023 and
           what did they win it for?"""
> result = qa.invoke({"input": query})

AI 生成内容节选:

Entering new AgentExecutor chain...
Invoking: search_through_pdf_text with {'query': 'Nobel Prize in Literature 2023'}
Nobel Prize ® and the Nobel Prize ® medal design mark are registrated trademarks of the Nobel Foundation
4 OCTOBER 2023 Scientific Background to the Nobel Prize in Chemistry 2023 QUANTUM DOTS – SEEDS OF NANOSCIENCE The Nobel Committee for Chemistry
...
Finished chain.

>>> Sorry, I don't know.

结果符合预期。文档中确实未提及其他领域的诺贝尔奖信息。

image.png

提示
另一种理解这个管道的方式是:基于查询,哪些文本片段对提供必要上下文最为重要?
找到的相关段落会被插入提示中,使得新的查询看起来像“基于我刚找到的这些具体事实 {文本片段},现在让我回答原始问题:{查询}。”这种方式通过利用存储的相关信息,帮助系统给出更准确、更有依据的回答,而不是仅凭一般常识猜测。

现在让我们来问个关于化学的问题:

query = "Sorry, I meant chem"
> result = qa.invoke({"input": query})

AI 生成内容节选:

进入新的 AgentExecutor 链... 调用 search_through_pdf_text,参数为 {'query': 'Nobel Prize in Chemistry 2023'}
... 2023 年诺贝尔化学奖的科学背景:量子点——纳米科学的种子,瑞典皇家科学院决定将2023年诺贝尔化学奖授予 Moungi G. Bawendi、Louis E. Brus 和 Aleksey I. Yekimov,表彰他们对量子点的发现与合成。
...
2023 年诺贝尔化学奖授予 Moungi G. Bawendi、Louis E. Brus 和 Aleksey I. Yekimov,表彰他们对量子点的发现与合成。

>>> 2023年诺贝尔化学奖授予 Moungi G. Bawendi、Louis E. Brus 和 Aleksey I. Yekimov,表彰他们对量子点的发现与合成。这些量子点是纳米级的半导体晶体,其性质由量子尺寸效应决定。

注意记忆的作用,执行链调用了“诺贝尔化学奖2023”而我们查询中未明确指定年份。系统从文档中提取了两个相关段落,基于这些上下文给出回答。现在让我们问问论文中用于制备纳米粒子的晶体化合物:

query = "What crystals were used by the authors to create nanoparticles?"
> result = qa.invoke({"input": query})

AI 生成内容节选:

进入新的 AgentExecutor 链...
调用 search_through_pdf_text,参数为 {'query': 'Nobel Prize in Chemistry 2023 Bawendi Brus Yekimov quantum dots nanoparticles crystals used'}
... 2023 年诺贝尔化学奖颁给了 Moungi G. Bawendi、Louis E. Brus 和 Aleksey I. Yekimov,表彰他们对量子点的发现与合成。

>>> 2023年诺贝尔化学奖获奖者合成了纳米级半导体晶体,称为量子点。但检索到的文本未详细说明用于制备这些纳米粒子的具体晶体类型。通常,量子点可由多种半导体材料制成,如硒化镉(CdSe)、硫化铅(PbS)、磷化铟(InP)等。

结果可能让你感到意外,因为回答未返回具体结果。可以看到代理使用了 search_through_pdf_text 工具进行查询,最相似的文本块是从向量存储中提取的,语言模型基于检索到的上下文生成最终回答。第10章将部分讲解 LangChain 应用的调试,以更好地理解管道哪个步骤出错。

注意
你认为代理为什么没有检索到关于晶体的信息?如何改进才能提取所有关于晶体的提及?试从向量嵌入角度思考。如果选用更专业的化学模型,结果会改变吗?

分析构造的查询 "Nobel Prize in Chemistry 2023 Bawendi Brus Yekimov quantum dots nanoparticles crystals used",我们会发现最相似文本更偏向“诺贝尔化学奖”相关内容,而不是我们真正想问的“量子点纳米粒子晶体”部分。

接下来比较几种可提升结果质量的检索技术。标准检索器设置 k=3,返回与查询向量最相似的前三个文档。在此示例中,检索器返回了 PDF 第16、15 和 5 页的文档。参数 k 决定返回多少文档,算法只选取相似度最高的 top-k 文档,不考虑结果多样性:

retriever = vectorstore.as_retriever(
    search_kwargs={"k": 3}
)
> retriever.invoke(query)
>>> [Document(metadata={... 'page': 16, 'page_label': '17', ... ,
    page_content='153 (3), 989-&. DOI: DOI 10.1103/PhysRev.153.989. \n(50)
    Berry, C. R. Structure and Optical Absorption of Agi Microcrystals...,

    Document(metadata={... 'page': 15, 'page_label': '16', ... ,
    page_content='microcrystals in glass matrix. Sov. Glass Phys. Chem. 1980,
    6, 511-512.\n(29) Golubkov,  V. V.; Yekimov, A. I.; Onushchenko, A. A.;
    Tsekhomskii, V. A. Growth kinetics of ...,

    Document(metadata={... 'page': 5, 'page_label': '6', ... ,
    page_content='Institute.27 He aimed to understand the chemical composition
    and structure of colloidal particles \nin coloured glasses, as well as the
    mechanism of their growth...')]

最大边缘相关性(MMR)检索器平衡了相关性与多样性,通过同时考虑与查询的相似度和已选文档间的差异。参数包括 k=3(最终返回数)、fetch_k=10(初始候选池大小)和 lambda_mult=0.25(相关性与多样性的权衡系数)。该方法返回第16页一篇文档和第5页两篇文档,更强调结果的多样性。较低的 lambda 值(0.25)使多样性远重于纯相关性,这解释了为何它选了第5页不同部分,而非与第16页顶级结果内容相近的第15页部分(两者均含文献引用):

retriever = vectorstore.as_retriever(
    search_type="mmr", search_kwargs={"k": 3, "fetch_k": 10, "lambda_mult": 0.25}
)
> retriever.invoke(query)
>>> [Document(metadata={... 'page': 16, 'page_label': '17', ... ,
    page_content='153 (3), 989-&. DOI: DOI 10.1103/PhysRev.153.989. \n(50)
    Berry, C. R. Structure and Optical Absorption of Agi Microcrystals...,

    Document(metadata={... 'page': 5, 'page_label': '6', ... ,
    page_content='Institute.27 He aimed to understand the chemical composition
    and structure of colloidal particles \nin coloured glasses, as well as the
    mechanism  of their growth...),

    Document(metadata={... 'page': 5, 'page_label': '6', ... , page_content='
    varied with details of the heat treatment. 28 The researchers attributed
    this observation to the \nformation of a crystalline phase of CuCl in the
    glass matrix as a result of phase decomposition of \na supersaturated
    solution during heat treatment . \nFurthermore, by varying temperature and
    \nduration of the heat treatment , they were able to \ncontrol the average
    size of CuCl crystals...')]

过滤检索器 对结果加了元数据限制,只返回文档的前12页内容,因为第13页以后全是参考文献。三个返回的文档均来自第5页,说明查询最相关内容集中在这一页。过滤参数 {"page": {"$lte": 12}} 表示对页码设置“少于等于12”的条件,有效排除了后续页数,无论其相关性如何:

retriever = vectorstore.as_retriever(
    search_kwargs={"k": 3, "filter": {"page": {"$lte": 12}}}
)
> retriever.invoke(query)
>>> [Document(metadata={... 'page': 5, 'page_label': '6', ... ,
    page_content='Institute.27 He aimed to understand the chemical composition
    and structure of colloidal particles \nin coloured glasses, as well as the
    mechanism of their growth...),

    Document(metadata={..., 'page': 5, 'page_label': '6', ... ,
    page_content='duration of the heat treatment , they were able to \ncontrol
    the average size of CuCl crystals forming \nin the glass melt.29 Using small
    -angle X-ray \nscattering, ...) ,

    Document(metadata={..., 'page': 5, 'page_label': '6', ... ,
    page_content='varied with details of the heat treatment. 28 The researchers
    attributed this observation to the \nformation of a crystalline phase of CuCl
    in the glass matrix as a result of phase decomposition of \na supersaturated
    solution during heat treatment. \nFurthermore, by varying temperature and
    \nduration of the heat treatment, they were able to \ncontrol the average
    size of CuCl crystals...)]

下面表3-2对比了前述检索技术及其结果,注意可组合不同参数优化结果。

特性标准检索MMR 检索过滤检索
参数k=3search_type="mmr", k=3, fetch_k=10, lambda_mult=0.25k=3, filter={"page": {"$lte": 12}}
返回页码16, 15, 516, 55
用例纯相关度,找最相似内容相关度与多样性平衡,减少结果冗余相关度内的约束,限定检索范围
结果多样性低(仅基于相似度)高(显式优化多样性)低(仅基于相似度且受限于过滤)
提及化学晶体CuCl(一篇文档)CuCl(两篇文档)

另一种简单方法是通过更明确的问句修改来影响嵌入查询。例如,我们可以让查询忽略“诺贝尔化学奖2023”,重点关注化学化合物:

query = """What crystals were used in the paper? exclude mentioning Nobel Prize
           in Chemistry 2023 in the query, focus on the chemical compounds"""
> result = qa.invoke({"input": query})

AI 生成内容节选:

进入新的 AgentExecutor 链... 调用 search_through_pdf_text,参数为 {'query': 'quantum dots chemical compounds'}
量子点是一类既非分子也非块体的新材料。它们与块体材料的结构和原子组成相同,但性质可通过粒径单一参数调控。例如,CdSe的光吸收和发射...

>>> 论文中提到了CdSe(硒化镉)量子点,这是一种半导体晶体。

如你所见,我们修改后的查询转为“quantum dots chemical compounds”,使嵌入查询更贴近“晶体”而非“诺贝尔奖”,因而得到更准确结果。

最后,我们用 PubMed 集成检索论文中提及晶体的最新发表文献。请再次留意记忆的使用以及 PubMed 工具的查询重构:

query = """What are the titles of the 3 most recent publications for each of the
           crystals mentioned in the paper?"""
> result = qa.invoke({"input": query})

AI 生成内容节选:

进入新的 AgentExecutor 链...
调用 pub_med,参数为 {'query': 'CdSe quantum dots'}
...
调用 pub_med,参数为 {'query': 'CdS quantum dots'}
...
完成链。

>>> ### CdSe 量子点
1. **标题:** 水溶性CdSe量子点的水力空化合成:文丘里管参数对尺寸分布和荧光性能的影响及其在铁检测中的应用 - **发表时间:** 2025-02-20

### CdS 量子点
1. **标题:** 基于硼酸功能化Eu-MOF的智能手机比率荧光传感器,用于辣根过氧化物酶检测 - **发表时间:** 2025-03-14
2. **标题:** 利用D-肽的叶酸比率荧光定量 - **发表时间:** 2025-03-13
...

我们将在第5章开始构建更多应用,深入构建和查询各种科学资源 API。由于内置工具的默认限制,无法获取所有想要的数据,但结果仍然令人期待。

提示
多次无间隔调用 PubmedQueryRun 会导致“请求过多,等待X秒…”错误。重试间隔默认 0.2 秒,最大重试次数为5。调整这些参数以实现更高效的 PubMed 查询。

提升代理规划效果的若干要点:

  • 使用更强的模型,通常规划能力更佳。
  • 编写详尽的系统提示并附带示例,明确指导规划行为。
  • 制作清晰的工具描述,提升工具与参数的理解度。
  • 将复杂函数拆解为更简单的多个函数。
  • 采用分层规划,从高层策略到细节执行分层管理。
  • 实施链式思维推理,引导代理逐步思考再求解。
  • 添加反思机制,使代理分析表现并修正策略。
  • 加强错误处理设计,优雅应对异常和意外情况。

总结

本章介绍了 LangChain,这是一个用于构建大型语言模型(LLM)应用的开发框架。LangChain 通过提供预构建组件(如聊天机器人和问答系统),简化了开发流程,使开发者更容易创建先进的 AI 应用。

本章重点讲解了 LangChain 的核心组件:模型、索引、链、提示、记忆、工具和代理,说明了这些组件如何协同工作,以克服 LLM 存在的幻觉(hallucination)和信息过时等限制。其中简要介绍了检索增强生成(RAG)技术,通过访问外部知识库来补充 LLM 的训练数据。还解释了文本数据的索引原理。

LangChain 的强大之处在于能够创建链接各组件的链条。章节介绍了 LangChain 表达语言(LCEL),它提供了一种简单的方式组合语言组件。同时介绍了 LangGraph,作为链的扩展,支持环路结构,适合需要决策循环的代理行为。章节还说明了提示如何引导 AI 模型,以及记忆组件如何帮助维持跨次交互的对话上下文。

下一章将更详细地讲述如何构建 RAG 系统,以及是否能够利用 LLM 的幻觉优势。内容涵盖处理 RAG 流水线的最佳实践,包括数据准备和索引、用户问题理解、精准定位知识源、构建高效查询、检索最相关信息,最终基于检索信息生成优质答案。