03 大模型核心开发框架

01 大模型基础能力构建 之后,继续阅读该篇。


9 LangChain概述与架构

如果是java选手,学习过langchain4j,上手这部分内容应该很快。

先详细看看课程目标

  • 建立对 LangChain 的整体认识,知道它是什么、解决什么问题、在 AI 应用开发里处于什么位置。
  • 理解 LangChain 在 1.x 时代的产品边界、包结构、版本演进,以及它与 LangGraph、LangSmith、Deep Agents 的关系。
  • 建立对 Model I/O、Chains、Memory、Retrieval、Tools/Agents、Callbacks 这六类教学视角的整体认知

看完第9章建立认知,第10章实践进行熟悉。

1 Langchain简介

LangChain 是一个面向 LLM 应用开发的开源框架。它本身不是大模型,也不是数据库,更不是知识库平台;它更像一层“应用编排层”,负责把模型、提示词、外部知识、工具、记忆、输出解析、调试追踪等能力组织成一个完整、可维护、可扩展的 AI 应用。

Langchain是 用代码把大模型和外部世界连接起来的应用开发框架

Langchain在于把原本零散的东西组织起来,

  • 不同厂商的大模型调用方式
  • Prompt 与多角色消息
  • 结构化输出与解析
  • 检索增强生成(RAG)
  • 工具调用、Agent、多步任务
  • 记忆、会话状态、持久化
  • 日志、追踪、调试、评估

直接调API可以,适合简单任务,但是做应用,框架就会越来越有价值。

如下图所示的对比,可以利用框架进一步抽象,隔离,进行统一编排,统一接入在这里插入图片描述
Coze/Dify等平台是低代码平台,受限于平台。

Langchain是代码框架或库,在 Python 或 JS 中以代码方式编排模型、工具、RAG、Agent

实际项目里可以先低代码验证,再代码化落地。

现在版本更稳定,1.x版本,学习入口更清晰,重点放在 create_agent、统一模型初始化、消息、工具、记忆、检索和可观测性上。

LangChain、LangGraph、LangSmith 的分工更明确:你更容易理解“什么时候用高层框架,什么时候用底层图编排,什么时候做调试与评估”

LangChain 的优势很明显,但它也不是银弹。学习时要同时看到它的“能做什么”和“不能替你做什么”。

优势:

  • 统一模型接口,降低模型切换和多模型共存成本。
  • 提供 Prompt、Message、Parser、Retriever、Tool、Agent 等常用抽象
  • 能把“单次调用”提升为“可维护流程”。
  • 与 LangGraph、LangSmith、MCP 等生态衔接紧密,利于走向复杂应用。
  • 资料多、案例多、社区活跃,适合建立体系化认知。

边界:

  • 它不会替你设计业务逻辑,只是帮你更好地实现。
  • 它不会替你训练模型,模型能力强弱仍取决于底层模型。
  • 它不会替你存业务数据,向量库、数据库、对象存储仍要你自己选型。
  • 它不会天然让效果变好,Prompt、知识质量、工具设计、评估流程依然是关键。

不足与槽点:

  • 版本变化快:老教程里的类名、导入路径、写法,很可能在新版本里已经迁移。
  • 抽象多,学习成本不低:初学时容易觉得“明明只是调个模型,为什么多了这么多概念”。
  • 历史包袱真实存在:很多文章还在讲 LLMChain、ConversationChain、旧版 AgentExecutor,但新项目未必应照搬。
  • 文档存在新旧并存现象:官方文档已经比早期稳定很多,但生态广,仍会碰到版本语境不一致的问题。

所以结论很明确:LangChain 适合做 AI 应用,但要用“工程框架”的心态去学,不要把它当成一个简单工具函数库。

2 LangChain定位

2.1 大模型开发体系分类

按照大模型相关工作,从底到顶的一个分类

  • 基础模型层
  • 模型定制层
  • 应用开发层

LangChain 主要服务的,就是第三层:应用开发层。负责如何把模型能力变成一个能工作的应用系统。显然LangChain,LangGraph是服务编排层。

数据流:请求 自上而下(用户 → 服务/链 → 模型 → 存储),结果 自下而上返回用户;LangChain 处在 服务/编排层,串联 UI、模型与存储,而不是替代其中任一层。

2.3 使用场景

真实项目里,LangChain往往会承担下面几件事:

  • 统一接模型
  • 统一处理输入输出:用PromptTemplate、消息对象、输出解析器来规范请求与结果
  • 组织固定流程
  • 接入工具与外部系统
  • 管理状态与记忆
  • 做调试与评估

学习框架,掌握这套思维,再迁移到其他框架会快很多。

这里的简历看一下,在这里插入图片描述

3 LangChain包与版本管理

3.1 版本演进:从链到Agent + 图 + 生态

  • 第一阶段:0.0.x/0.1早期版本,特点是链优先
  • 第二阶段:0.2,0.3阶段,开始重视生态拆分和工程边界
  • 第三阶段:1.x阶段,重点变成精简主包,强化Agent,与LangGraph深度融合
    2025年10月20日发布LangChainv1.0.0

3.2 当前官方产品线理解

官方讲一整套出产品线。

  • LangChain:高层应用框架,快速构建,屏蔽大量底层细节
  • LangGraph:低层图编排与运行时
  • LangSmith:可观测性、评估、调试、部署平台,用来追踪、调试、评估和上线应用
  • Deep Agents:开箱即用的复杂Agent方法,建立在Langchain/ LangGraph之上

3.3 0.x与1.x的核心差异

如下图所示:
在这里插入图片描述
1.x更像精简主包 + 统一入口 + Agent建立在LangGraph之上

下面有一个0.x和1.x版本对比:

对比项0.x写法1.x写法
Agent 创建常见要手动组装Agent + Executor以create_agent为主入口
模型初始化常见直接记忆具体类或旧导入路径更强调统一入口,如init_chat_model
主包内容langchain中内容很多,历史包袱重langchain主包被精简,只聚焦核心能力
旧能力去向LLMChain, 老Retriever等混在主包中迁到langchain-classic
底层运行时以前很多人只感受到链现在agent明前建立在langgraph之上
python版本老资料中常见3.8, 3.9官方1.x要求python3.10

3.4 LangChain常见包

按照官方主线重新梳理

  • langchain-core:核心抽象层,包含消息、Runnable、工具基础、Prompt 基础、模型接口等,是整个生态的底座
  • langchain:高级应用框架主包,1.x中聚焦核心高层能力,如Agent,统一模型初始化,消息与工具等
  • langchain-openai/ langchain-anthropic/langchain-allama:厂商/provider集成包,每个模型 厂商或平台通常有自己的独立集成包,便于独立版本管理。
  • langchain-community:社区集成包,放置大量社区维护的集成,例如某些工具,loader, 向量库等。
  • langchain-classic:旧版兼容包,
  • langgraph:图编排与运行时,用于构建更复杂、可控、可持久化的Agentic workflow
  • langsmith:调试、评估、Tracing SDK,对接LangSmith平台,常用语可观测性与实验评估
  • langchain-text-splitters:文本切分组件,RAG常用,负责把 长文本拆分为合适片段。
  • langchain-mcp-adpters:MCP适配包,用于在langchain/langgrpah应用中接入MCP工具

4 LangChain核心模块

4.1 全景图

下面这张图展示了完整知识体系,
在这里插入图片描述

从这个视角看,LangChain 的核心模块其实是在回答六个问题:

  • 怎么接模型:Model / Model I/O
  • 怎么组织固定流程:Chains / LCEL
  • 怎么记住上下文:Memory
  • 怎么接外部知识:Retrieval / RAG
  • 怎么让模型会做事:Tools / Agents
  • 怎么观测与调试:Callbacks / LangSmith

4.2 Model I/O 模型输入输出

该部分是有关模型调用本身的一圈能力,解决的是输入怎么组织、模型怎么调、输出怎么拿的稳。
在这里插入图片描述
主要包含:

  • Format:输入格式化,把原始输入组织成prompt或多角色消息
  • Predict:模型调用,用统一接口调用不同厂商模型
  • Parse:输出解析,把自然语言输出转成更稳定的结构化结果

4.3 Chains链:固定流程编排

Chain核心思想很简单:把多个步骤按固定顺序串起来。

链式思维仍然是LangChain的基础能力。

固定流程更适合Chain/LCEL。动态决策流程更适合Agent/Graph。

4.4 Memory记忆:记忆与上下文状态

Memory指的是应用如何“记住”过去发生过什么。注意,这里的记忆不是让模型变得更聪明,而是让系统在多轮交互中保留必要上下文。例如历史对话、用户偏好、会话状态、阶段性结果等。

在官方当前语境里,记忆通常会去分为Short-term Memory,和Long term memory。

4.5 Retrieval检索:检索与RAG

Retrieval模块解决的是模型不知道的知识从哪里来的问题。

是RAG的核心组成部分,负责从外部知识源中检索和当前问题相关的信息,再把检索结果交给模型辅助生成答案。

4.6 Tools/Agents:让模型不只是会说,还会做

4.6.1 Tool是什么

Tool本质上是一个可以被模型调用的外部能力

4.6.2 Agent是什么

则是在Tool之上再多一层,不是简单执行固定步骤,而是让模型根据当前任务自主决定下一步该做什么。

在1.x官方语境下,LangChain的Agent已经明确建立在LangGraph运行时之上,即使只是调用 create_agent,底层也不再只是一个松散的“工具循环”,而是带有状态、节点、流转、持久化能力的图式运行逻辑。

4.7 Callbacks回调:日志、调试与可观测性

一个可用的AI应用,不只是能跑,还要能看见他怎么跑。

现在官方更强调的是LangSmith + Tracing + Evaluation这一整套可观测性能力。

AI应用最难排查的问题,通常不是程序报错,而是为什么这次没检索到,为什么调了错误工具,为什么模型理解偏了,为什么这轮成本突然变高,为什么线上效果和本地不一致。

如果没有可观测性,很多问题只能猜。

4.8 六大模块总览

教学视图下的六大模块
在这里插入图片描述
官方 1.x 文档中,你会看到这些能力被拆到 Agents、Models、Messages、Tools、Memory、Retrieval、Streaming、Structured Output、Middleware、Runtime、LangSmith 等更细的栏目里

进一步展开模块:
在这里插入图片描述
根据图的理解,理解LangChain做的不是某一件事,而是把模型调用、知识接入、流程编排、状态管理、工具执行、调试追踪这些原本分散的环节串成一个工程系统。

4.9 怎么记这套框架

章节思考题

为什么学习LangChain时要同时知道1.x,provider, Langgraph,

A : 1.x是当前主线,provider包负责具体模型接入,LangGraph承担复杂流程和Agent的底层编排

10 LangChain快速上手与HelloWorld

langchain中文文档链接
中文文档都是翻译更新的,可能慢于官方文档的更新速度

这里需要在新电脑按照之前配置环境配置好python3.10的虚拟环境。

这里还是解释一下pip和uv,

  • pip:它是 Python 的官方包管理工具(全称 Pip Installs Packages)。开发者使用它来查找、下载、安装、升级和卸载 Python 第三方库和依赖包。例如,使用 pip install requests 就可以安装网络请求库13。
  • uv:它是一个用 Rust 编写的、极其快速的 Python 包和项目管理器。你可以把它理解为 pip 的“超级替代品”。它的核心优势是速度极快(比传统 pip 快 10 到 100 倍),并且兼容 pip 的常用命令(如 uv pip install),同时还集成了虚拟环境管理等高级功能

1 LangChain环境与约定

1.1 支持的大模型与课程选用

langchain官方提供了完整的provider列表,将provider理解为封装好各个大模型的api提供者,只需要安装对应包 -》设置模型名称就可以切换各个大模型。
在这里插入图片描述
本课程选择阿里云百炼、千问,关键是理解怎么用langchain接模型。

1.2 python版本

langchain1.x推荐python3.10

1.3 运行案例前置注意事项

  • 尽量在项目根目录运行案例
  • 先配置.env,

2 常见大模型服务平台介绍

2.1 调用三件套

  • API KEY
  • 模型名
  • Base URL

2.2 常见平台

3 安装依赖

将仓库拉到本地,安装依赖,之前已经弄好了。

3.3 验证安装

执行GetEnvInfo.py,检测环境,如图所示:
在这里插入图片描述
并下载了AI Agents Debugger插件,用于调试langgraph agent。

运行项目时要确保用的同一个python环境。

4 案例:基于百炼的HelloWorld

例如在百炼上找到:

  • 模型名:qwen3.6-35b-a3b
  • baseUrl:可以在对应模型下面找到OpenAI兼容,https://llm-55uiolr4hol9nlrx.cn-beijing.maas.aliyuncs.com/compatible-mode/v1
  • api-key:在.env中声明了

4.2 HelloWorld的最小调用链路

最小链路流程:

准备三件套 → 初始化模型 → invoke("问题") → 读取 response.content

其中关键词:

  • invoke:同步调用模型,返回一个消息对象
  • .content:取出消息对象里的正文文本

4.3 示例代码

通过阅读代码,掌握这样的习惯,不将api-key硬编码到代码里,而是通过os.getenv()获取

查看langchain1.x写法

  • 使用init_chat_model统一入口调用大模型,通过model_provider指定厂商
  • 接国内平台(阿里百炼、通义等)时需显式写 model_provider=“openai”,否则会报错无法推断 provider。
  • 调用三件套:API Key、模型名、Base URL;invoke(问题) 返回消息对象,.content 取正文。

其中invoke源码定义:

@override
def invoke(
    self,
    input: LanguageModelInput,
    config: RunnableConfig | None = None,
    *,
    stop: list[str] | None = None,
    **kwargs: Any,
) -> AIMessage:

invoke接收用户的输入和运行时配置,调用底层的大语言模型生成响应,并最终将其转换为标准的AIMessage对象返回。

  • @Override是python3.12+引入的装饰器,明确表示该方法是对父类中invoke方法的重写
  • input用户的输入,可以使字符串,消息列表等
  • config:LangChain的运行时配置对象,用于控制执行行为
  • stop:可选参数

核心执行与结果转换,嵌套的cast逻辑,从内向外执行以下操作:

# 1. 输入转换与生成请求
self.generate_prompt(
    [self._convert_input(input)],  # 将原始输入转换为模型能理解的格式
    stop=stop,                     # 传入停止词
    callbacks=config.get("callbacks"), # 提取配置中的回调处理器
    tags=config.get("tags"),           # 提取标签
    metadata=config.get("metadata"),   # 提取元数据
    run_name=config.get("run_name"),   # 提取运行名称
    run_id=config.pop("run_id", None), # 提取并移除运行ID
    **kwargs,                          # 其他额外参数
)
# generate_prompt 返回的是一个包含 generations 列表的 LLMResult 对象

# 2. 提取生成结果并转换类型
# .generations 获取了第一个批次中的第一个生成结果(ChatGeneration 对象)
cast("ChatGeneration", ...).message 
# 从 ChatGeneration 对象中提取出底层的 message 属性

# 3. 最终类型转换
cast("AIMessage", ...)
# 确保最终返回的对象被明确标记为 AIMessage(AI 消息)类型

这段代码本质上是一个“适配器”。它将 LangChain 标准的 invoke 调用,转化为底层 generate_prompt 的调用,并且通过 config 实现了任务追踪、超时控制、日志记录等运行时控制712。最后,它把大模型返回的复杂嵌套结果(LLMResult -> ChatGeneration -> Message),剥离并强转为标准的 AIMessage,以便在 LangChain 的对话链(Chain)或 Agent 中继续流转

在openAI中的格式调用就是:

# Please install OpenAI SDK first: `pip3 install openai`
import os
from openai import OpenAI

client = OpenAI(
    api_key=os.environ.get('DEEPSEEK_API_KEY'),
    base_url="https://api.deepseek.com")

response = client.chat.completions.create(
    model="deepseek-v4-pro",
    messages=[
        {"role": "system", "content": "You are a helpful assistant"},
        {"role": "user", "content": "Hello"},
    ],
    stream=False,
    reasoning_effort="high",
    extra_body={"thinking": {"type": "enabled"}}
)

print(response.choices[0].message.content)

可以发现langchain的invoke就是封装了的,可以理解为response.choices[0].message就是langchaininvoke得到的AIMessage,然后可以通过.content拿到正文,如下图所示:
在这里插入图片描述
通过init_chat_model统一入口,不需要再为每个模型厂商记一套不同的初始化方式,而是先记住同一套调用骨架,通过参数切换不同模型和provider

4.4 0.3与1.0写法差异

0.3 / 经典写法的思路是:

  • 直接从具体集成包导入类,例如 ChatOpenAI
  • 类名本身就带有“我是按哪种协议接入”的语义
  • 代码非常直观,但不同厂商、不同类名会让项目越写越散

1.x 的思路是:

  • 用 init_chat_model 作为统一入口
  • 通过 model、model_provider、api_key、base_url 等参数描述“我要接谁”
  • 同一套代码骨架更容易迁移、统一与维护

5 多模型共存

5.1 多模型共存场景

存在不同模型的需求

5.2 调用三件套

5.3 多模型共存示例代码

记住就是多个模型使用变量名进行区分。

6 实战:企业级封装与流式输出

6.1 从HelloWorld到项目写法

真实项目里还要那么些就有问题了,因此要了解企业级封装和流式输出。

6.2 invoke()与stream()的区别

invoke()就是一次性返回完整结果。适合简单问答,后台处理,不需要实时展示中间输出的场景。

stream()就是变生成边返回。适合命令行实时输出,聊天界面打字机效果,长文本生成,用户等待体验更敏感的场景。

示例:

for chunk in model.stream("请介绍一下LangGraph"):
	print(chunk.content, end="")

6.3 示例代码

下面是自己按照langchain1.0版本风格写的封装初始化代码

"""
【案例】标准/工程化写法,用LangChain调用大模型invoke或者stream

将初始化模型封装成函数便于复用
    用.env存密钥
    logging打日志
    try/except区分错误

运行前记得在项目根目录配置.env中的api_key
这里直接看LangChainV1.0风格的写法
"""
# 导入环境
import os
import logging
from unittest import loader

from dotenv import load_dotenv
from langchain.chat_models import init_chat_model
from langchain_core.exceptions import LangChainException

load_dotenv(encoding="utf-8")

# 日志配置
_log_level = os.getenv("LOG_LEVEL", "INFO").upper()
logging.basicConfig(
    level=getattr(logging, _log_level, logging.INFO),
    format="%(asctime)s - %(levelname)s - %(message)s",
)

logger = logging.getLogger(__name__)

# LLM客户端初始化,封装为函数,便于多处复用
def init_llm_client():
    """
    肯定是返回初始化好的模型实例
    """
    api_key = os.getenv("deepseek-api")
    if not api_key:
        raise ValueError("环境变量DEEPSEEK_API_KEY未配置,请检查.env文件")

    return init_chat_model(
        model="deepseek-v4-pro",
        model_provider="openai",
        api_key=api_key,
        base_url="https://api.deepseek.com",
    )


# 主逻辑:invoke或者stream两种调用方式
def main():
    """
    主函数:封装核心逻辑,符合python工程化规范
    """
    try:
        model = init_llm_client()
        logger.info("LLM客户端初始化成功")

        # invoke方式
        question = "你是谁"
        response = model.invoke(question)
        logger.info(f"问题:{question}")
        logger.info(f"回答:{response.content}")

        # stream流式,边生成边输出
        print("============下面是流式输出=============")
        print("*" * 50)

        for chunk in model.stream(question):
            print(chunk.content, end="")

        print()
    except ValueError as e:
        logger.error(f"配置错误:{str(e)}")
    except LangChainException as e:
        logger.error(f"模型调用失败: {str(e)}")
    except Exception as e:
        logger.error(f"未知错误:{str(e)}")


# 注意不要开代理,否则无法正常访问API端点
if __name__ == "__main__":
    main()

上述示例:

  • 把模型初始化封装为函数,避免到处重复写配置
  • 显式检查环境变量
  • 使用日志,这个很重要,
  • 区分异常类型,便于排查问题
  • 同时演示invoke和stream()

思考题

真实项目里还要处理配置校验、日志、异常、超时、重试、流式输出和敏感信息隐藏

接下来学习11,13,14,掌握完整的输入-》模型-》输出

第11章:Model I/O与模型接入

掌握Modell I/O模块中的输入提示Promot, 调用模型Model, 输出解析Parser三件套

掌握LangChain里最常见的模型分类,标准化参数,返回值结构

1 Model I/O 简介

1.1 定义

该模块关心的是模型的输入、调用和输出。

  • Format(输入格式化):把原始业务输入整理成模型更容易理解的形式,例如 Prompt 模板、多角色消息、变量填充等。
  • Predict(模型调用):通过 LangChain 的统一接口调用不同模型提供商,例如 OpenAI、DeepSeek、阿里百炼、Ollama 等。
  • Parse(输出解析):把模型返回的自然语言结果转成更稳定、程序更好处理的形式,例如字符串、JSON、结构化对象等。
    在这里插入图片描述

1.2 为什么需要Model I/O

真实项目里,还是需要Model I/O进行解决

2 LangChain模型分类、参数与返回

讲解模型调用Predict,弄清楚:

  • LangChain中到底有哪些模型类型
  • 调模型时常见参数是什么
  • 调完模型后,返回的到底是什么

2.1 先分清:模型本身,模型提供商,LangChain模型对象

这个很好理解
在这里插入图片描述

2.2 LangChain常见模型分类

这里再次回顾LangChain框架是什么,是为了开发LLM应用程序设计的开源框架。
所以LangChain不提供模型权重本身,提供的是如何接这些模型的统一抽象。

LangChain的核心价值与能力体现在:

  • 模块化的组件结构,这在第4章讲了一个概览,有一个印象
  • 解决原生API调用的痛点
  • 强大的生态与扩展能力

覆盖了AI应用从开发、测试到部署的完整生命周期。

LangChain中实际开发过程中最常用的是聊天对话模型,用于多轮对话、系统角色、用户消息等场景。

最常碰到的三类:

  • LLM
  • ChatModel
  • Embeddings

后面的Prompt, Message, Tools, Agent, Memory本质大多都建立在ChatModel的消息交互之上。

2.3 LLM和Chat Model区别

在LangChain中,LLM和ChatModel,接口思维不一样,

  • LLM更像给一段文本,让模型补全或生成下一段
  • CahtModel更像给一段对话上下文,让模型按角色回复。

LLM风格就是提供prompt = "这是什么"

而ChatModel风格如下:

messages = [
    {"role": "system", "content": "你是一个技术助教"},
    {"role": "user", "content": "请用一句话解释什么是 LangChain"},
]

2.4 为什么Embedding放在这里

是向量模型

2.5 常用模型参数

LangChain对聊天模型定义了一批标准化参数,名称在不用写发下基本一致

掌握参数:

  • model
  • model_provider
  • api_key
  • base_url
  • temperature
  • max_tokens最大生成长度
  • timeout超时时间,请求最长等多久
  • max_retries 失败重试次数
  • stop停止词,生成到某些位置时强制停止

在项目里最先调temperature 和max_tokens

2.6 Token, max_tokens与计费的关系

用量统计通常都以token数为单位

也有token可视化工具

2.7 参数的选择

根据业务场景选:

  • 客服问答 / 规则回答 / 翻译:temperature 往往设低一些,例如 0~0.3
  • 知识问答 / 通用助手:可设在 0.2~0.7
  • 文案、创意写作、头脑风暴:可适当更高,例如 0.8~1.2
    max_tokens 的使用也一样:

如果只是简短问答,可以限制小一点,避免无意义长回答
如果是总结、分析、长文输出,可以适当放大
但有一点要始终记住:

参数只是调味料,底层模型能力、Prompt 设计、上下文质量,通常比单纯调 temperature 更重要。

2.8 基本案例

temperature取值范围在0-2

"""

理解模型参数与返回对象结构
temperature影响输出随机性
max_tokens控制返回长度涉及到成本
使用api_key为deepseek_api


"""
import os
from langchain.chat_models import init_chat_model

from dotenv import load_dotenv

load_dotenv(encoding="utf-8")

# 实例化设置常用参数

model = init_chat_model(
    model="deepseek-v4-flash",
    model_provider="openai",
    api_key=os.getenv("deepseek-api"),
    base_url="https://api.deepseek.com",
    temperature=0.7,  # 0~1,越高越随机;此处略高便于看到多次输出差异
    # max_tokens=256,  # 可选:限制单次回复长度
)

print(model.invoke("写一句关于春天的词,14字以内"))
print(type(model))
print(type(model.invoke("写一个关于春天的词,14字以内").content))
print(type(model.invoke("写一句关于春天的词,14字以内")))



通过上述案例理解模型和返回的类型

2.9 调用后的返回信息

将init_chat_modle理解为模型客户端对象,invoke()返回的是<class 'langchain_core.messages.ai.AIMessage'>

2.10 AIMessage上常见字段

掌握字段:

  • content
  • response_metadata,厂商返回的原始元数据,用于看模型名、finish_reason, token_usage等
  • usage_metadata,这是langchain统一整理后的用量信息,看input/output/total tokens
  • tool_calls工具调用信息
  • additional_kwargs厂商扩展字段

所以可以看到一个典型的AiMessage:

AIMessage(
  content='《鹧鸪天·春》\n一篙绿涨江南岸,…',   # 用户可见回复
  response_metadata={
    'token_usage': {
      'prompt_tokens': 14,
      'completion_tokens': 109,
      'total_tokens': 123,
      ...
    },
    'model_name': 'deepseek-v4-flash',
    'finish_reason': 'stop',
    ...
  },
  usage_metadata={
    'input_tokens': 14,
    'output_tokens': 109,
    'total_tokens': 123,
    ...
  },
  tool_calls=[],
  ...
)

如图加深理解:
在这里插入图片描述
所以看content, response_metadata, usage_metadata

2.11 Messages的作用

ChatModel的输入输出,本质上都是消息Message

例如SystemMessage, HumanMessage, AIMessage, ToolMessage

Prompt只是输入的一种组织方式,Message才是更底层的统一表达

3 接入大模型

怎么把不同模型接进来

3.1 三种常见接入思路

从SDK, provider,到使用init_chat_model

3.2 接入OpenAI及兼容接口

3.2.2 openai.OpenAI与ChatOpenAI的区别

oepnai.OpenAI是官方的pythonSDK,langchain_openai.ChatOpenAI是LangChain生态

显然使用ChatOpenAI方便后续使用

3.2.6 真实项目建议

3.3 接入DeepSeek

3.3.1 常见接法
  • 通过OpenAI兼容接口接入
  • 通过langchain-deepseek原生provider接入

原生的更贴近deepseek自身集成,某些特性表达更自然

3.3.3 用init_chat_model接入

两种方法

  • 按照OpenAI兼容接口接
  • 使用deepseek provider包

3.4 接入通义千问

也是同样的方式,兼容接口或原生路线

3.7 模型对象拿到后还要关注什么

模型初始化之后,一个chat Model通常还会继续承担下面这些能力:

  • 普通调用
  • 流式输出
  • 结构化输出with_structured_output(schema)
  • 绑定工具model.bind_tools(tools)
  • 放入链路

这里的模型也是核心组件

3.8 调用配置与运行时信息

除了初始化时传入的模型参数,LangChain每次调用还需要传入config,这是给LangChain运行时看的信息。

常见配置项:

  • tags,给本次调用打标签,用于区分rag, agent, demo等不同链路
  • metadata,记录额外上下文
  • callback,挂接回调处理器
  • configurable,运行时切换配置

示例:

response = model.invoke(
    "用一句话解释 LangChain 的作用",
    config={
        "tags": ["model-io", "demo"],
        "metadata": {"course": "ai-agents-from-zero"},
    },
)

如果后面接入LangSmith,这些tags和metadata会很有用,因为可以在追踪记录里快速筛选某一类调用

还有一种更进阶的用法是运行时切换模型。例如先创建一个可配置的模型对象,再在调用时指定具体模型。

config更偏运行时管理和观测。

第12章 Ollama本地部署与调用

这一章先跳过

第13章 提示词语消息模板

掌握LangChain中与输入组织最相关的三块内容:消息类型Message,模型调用方式invoke, stream,提示词模板PromptTemplate, ChatPromptTemplate。

理解本章全部案例。从模型需要吃进什么,理解提示词模板。

1 Prompt简介

对应了Model I/O中的输入格式化format和模型调用predict

将之前了解的提示词工程,应用到本章就是代码实现版。

将之前提到的角色、任务、上下文、输入、输出和约束,理解在LangChain中以什么形态存在的。

1.1 定义

提示词,从自然语言到带有角色的,再到代码可复用、可维护、可协作,从而把输入写成模板,把会变化的部分改为占位符,这就是从随手提问走向工程化输入管理。

因此Prompt需要把模型输入组织清楚

随着项目复杂度提高,Prompt会从一个字符串逐步演化为多角色消息 + 模板 + 占位符 + 外部配置文件。

1.2 Prompt作用

将输入组织清楚

下面这些真实开发场景,都离不开本章内容:

  • 智能客服:需要系统提示词规定语气、身份和拒答策略。
  • 企业知识库问答:需要把“检索出来的上下文 + 用户问题”组合成一条清晰提示。
  • 多轮聊天:需要把历史对话插回当前输入,而不是每轮都从头问。
  • 结构化输出:需要提前在 Prompt 里写清楚输出格式要求,方便后面交给解析器处理(通过提示词就一定能保证按照结构化方式输出吗)。
  • 团队协作与 A/B 测试:需要把 Prompt 模板从代码里抽出来,放到 JSON / YAML 中做版本管理。(例如java项目中使用nacos进行统一配置——AI版天机学堂)

模型能力决定上限,Prompt 设计决定你能不能稳定接近这个上限。

1.3 本章在项目中的位置

分为4类:

  • invoke,模型调用方式,invoke, stream,batch及异步版本
  • prompt_templates,文本模板,
  • chat_prompt_tempalte,对话模板
  • load_external,外部文件加载

2 调用大模型的入参类型

调用模型可以有多种输入形态

2.1 入参形态总览

  • str
  • PromptTemplate.format()后的字符串
  • 消息对象列表
  • (role, content)元组列表
  • 字典列表

回顾python的基础知识,元组有序不可变,字典大括号。

示例:

"""

多种输入类型
invoke可以接受消息对象列表
(role, content)元组
{"role": "...", "content": "..."}字典

这样写都是表达这次输入由哪些角色、哪些内容组成,LangChain会在内部转成统一消息表示
可以使用Message类写法
"""

import asyncio
import os

from dotenv import load_dotenv
from langchain.chat_models import init_chat_model
from langchain_core.messages import HumanMessage, SystemMessage

load_dotenv()

model = init_chat_model(
    model="deepseek-v4-flash",
    model_provider="openai",
    api_key=os.getenv("deepseek-api"),
    base_url="https://api.deepseek.com",
)

def demo_message_objects():
    """推荐,显式Message对象,角色与字段最清晰"""
    message = [
        SystemMessage(content="你是一个专业的数学助手,回答要简短。"), # 列表
        HumanMessage(content="你好,你是谁?"),
    ]

    resp = model.invoke(message)

    print(type(resp), resp.content[:80] if resp.content else "")

def demo_tuple_list():
    """元组列表:与 ChatPromptTemplate.from_messages 的写法一致。"""
    messages = [
        ("system", "你是一个专业的数学助手,回答要简短。"),
        ("human", "你好,你是谁?"),
    ]
    resp = model.invoke(messages)
    print(type(resp), resp.content[:80] if resp.content else "")


def demo_dict_list():
    """字典列表:与 OpenAI Chat Completions 等 API 的请求体形状接近。"""
    messages = [
        {"role": "system", "content": "你是一个专业的数学助手,回答要简短。"},
        {"role": "user", "content": "你好,你是谁?"},
    ]
    resp = model.invoke(messages)
    print(type(resp), resp.content[:80] if resp.content else "")

async def demo_ainvoke_tuple():
    """异步调用同样支持元组简写。"""
    resp = await model.ainvoke([(
        "user", "用一句话说明什么是素数。"
    )])

if __name__ == "__main__":
    print("--- Message 对象列表 ---")
    demo_message_objects()
    print("--- 元组列表 ---")
    demo_tuple_list()
    print("--- 字典列表 ---")
    demo_dict_list()
    print("--- ainvoke + 元组 ---")
    asyncio.run(demo_ainvoke_tuple())


回顾python中的异步编程语法async与await

  • async def: 这是python定义异步函数协程的语法,告诉python解释器,这个函数内部包含需要等待的I/O操作,在等待期间可以释放控制权去执行其他任务。
  • await,用于挂起当前协程的执行,等待model.ainvoke()这个异步操作完成并获取返回结果。LangChain中,ainvoke是同步方法invoke的异步版本,专门用于异步环境

2.3 写法二:模板 + 占位符

示例:

from langchain_core.prompts import PromptTemplate

template = PromptTemplate.from_template(
    "用不超过 50 字介绍:{topic} 是什么?"
)
prompt_str = template.format(topic="LangChain")
resp = model.invoke(prompt_str)
print(resp.content)

2.4 写法三:多角色消息列表

构建消息类型列表,可以简化为列表,例如HumanMessage就是"user",SystemMessage可以为"system",Langchain会转换的。

from langchain_core.messages import SystemMessage, HumanMessage, AIMessage

messages = [
    SystemMessage(content="你是只回答技术问题的助手,回答要简短。"),
    HumanMessage(content="什么是 LangChain?"),
    # 多轮示例:
    # AIMessage(content="LangChain 是用于编排 LLM 应用的框架……"),
    # HumanMessage(content="它和直接调 API 有什么区别?"),
]
resp = model.invoke(messages)
print(resp.content)

在实际项目里,这种写法特别常见,

  • 系统提示词放在SystemMessage
  • 用户问题放在HumanMessage
  • 历史回复可放在AIMessage
  • 工具执行结果后续可用ToolMessage

后续做多轮对话,Agent, RAG,这种消息列表思维会反复用到。

还有就是ChatPromptValue,链式编排里更常见。

直接构造对象然后转换,ChatPromptValue ->调用to_message()

2.4 写法四:元组列表与字典列表

2.6 Java生态中的多角色

多角色消息并不是某个框架的语法技巧,而是现代聊天模型交互的一种通用抽象。

3 入参的消息类型

知道每种消息类型代表什么

3.1 四类核心消息

  • SystemMessage
  • HumanMessage
  • AIMessage,模型回复消息,保存上一轮回复,支持多轮上下文
  • ToolMessage,工具执行结果,

3.3 ToolMessage什么时候会出现

ToolMessage不会在普通问答里频繁出现,更常见于函数调用,工具调用,Agent编排场景

简单示例:

from langchain_core.messages import SystemMessage, HumanMessage, AIMessage, ToolMessage

messages = [
    SystemMessage(content="你是一位乐于助人的智能小助手"),
    HumanMessage(content="你好,请你介绍一下你自己"),
    AIMessage(content="我是一名人工智能助手,请问您有什么想问的吗?"),
    ToolMessage(
        content='{"population": 21540000, "area": "16410平方公里"}',
        tool_call_id="call_abc123",
    ),
]
print(messages)

4 调用大模型的调用方式

LangChain 聊天模型常见的调用方式有四类:普通调用、流式调用、批量调用,以及它们各自的异步版本

例如:

  • invoke/ainvoke:一次发一条
  • stream/astream:一边生成一边返回
  • batch/abatch:一次发很多条

4.1 普通调用invoke/ ainvoke

  • invoke:同步调用,最常用,适合单轮问答与脚本演示。
  • ainvoke:异步调用,适合异步 Web 服务、并发任务和高吞吐场景。

实际项目中选择:

  • 命令行脚本,教学示例,简单后台任务,优先invoke
  • FastAPI, 异步服务,并发请求,优先ainvoke
  • 本地开源模型也适用

这里了解ainvoke,

  • ainvokeinvoke 的异步版本,等待模型返回时不会阻塞事件循环。
  • 它适合异步 Web 服务、并发任务和需要同时处理多条请求的场景。
  • 返回结果类型通常仍是 AIMessage;只是调用方式从“直接返回”变成了“await 后返回”。

示例:

resp = await model.ainvoke("解释一下你是谁")

if __name__ == "__main__":
	asyncio.run(main())

4.2 流式调用

即:

  • stream,同步流式输出
  • astream,异步流式输出

流式的最大价值不是“更快算完”,而是更快把正在生成的内容展示给用户。在聊天机器人、报告生成、代码生成等场景里,用户体验会明显更好。

示例:

messages = [
    SystemMessage(content="你叫小问,是一个乐于助人的AI助手"),
    HumanMessage(content="你是谁")
]
for chunk in model.stream(messages):
    print(chunk.content, end="", flush=True)
print()

异步流式,返回的是异步生成器,因此必须用asynch for遍历,循环的每一块通常仍是AIMessageChunk

示例:

# ---------- 3. 异步流式调用(在 async 函数中)----------
async def async_stream_call():
    # astream(messages) 返回的是「异步生成器」,不是 await 一个整体结果
    response = model.astream(messages)
    print(f"响应类型:{type(response)}")  # <class 'async_generator'>

    # 必须用 async for 遍历异步生成器,不能用普通 for
    async for chunk in response:
        print(chunk.content, end="", flush=True)
    print("\n")

5 提示词模板概览

5.1 提示词简介

5.2 提示词模板

之前也提到一点提示词模板,例如PromptTemplate, ChatPromptTemplate,后面的两者可以了解FewShotPromptTemplate, PipelinePrompt

6 文本提示词模板

6.1 简介

适合把文本做成“固定骨架 + 动态变量”的形式,其结果还是字符串,

6.2 参数

  • template,模板字符串,可包含{变量名}占位符
  • imput_variables,调用时传入变量名列表
  • partial_variables,在模板创建阶段就预先固定一部分变量

6.3 常用方法

  • format(…)方法,返回str
  • invoke(…)方法
  • partial(…)方法
    示例:
from langchain_core.prompts import PromptTemplate

template = PromptTemplate.from_template(
    "你是一个专业的{role}工程师,请回答我的问题,我的问题是:{question}"
)

# 1)format:得到 str
prompt_str = template.format(role="python开发", question="二分查找怎么写?")

# 2)invoke:得到 PromptValue
prompt_value = template.invoke({"role": "python开发", "question": "冒泡排序怎么写?"})
prompt_value.to_string()
prompt_value.to_messages()

# 3)partial:固定 role,得到新模板
new_template = template.partial(role="python开发")
prompt_str = new_template.format(question="快速排序怎么写?")

format方法示例:

from langchain_core.prompts import PromptTemplate

# 创建模板
template = PromptTemplate.from_template(
    "你是一个专业的{role}工程师,请回答我的问题给出回答,我的问题是:{question}"
)

# format填入变量,得到【最终一条提示词字符串】
prompt = template.format(role="python开发", question="二分查找算法怎么写")

print(prompt)

print(type(prompt))

invoke方法返回PromptValue,更适合衔接LangChain的链式调用,例如后续的LCEL。

# invoke方法
prompt = template.invoke({"role": "python开发",
     "question" :"冒泡排序怎么写"})
print(prompt)
print(type(prompt))
print()

6.4 创建方式

上述示例可以看到PromptTemplate有两种创建方式

  • 构造函数,例如PromptTempalte()
  • from_template(),由langchain自动推断变量名

示例:

model.invoke(prompt)

7 对话提示词模板

理解为面向多角色消息的模板系统

7.2 常用参数

ChatPromptTemplate核心不是单个template字符串,而是一组消息模板,可以是

  • 元组
  • 字典
  • Message类
  • MessagesPlaceHolder

示例:
传入元组

from langchain_core.prompts import ChatPromptTemplate
from numpy import char

# 用[(role, content)]形式的列表定义对话
chatPromptTemplate = ChatPromptTemplate(
    [
        ("system", "你是一个AI开发工程师,你的名字是{name}。"),
        ("human", "你能帮我做什么?"),
        ("ai", "我能开发很多{thing}"),
        ("human", "{user_input}"),
    ]
)

# 传入占位符变量,得到消息列表
prompt = chatPromptTemplate.format_messages(
    name="小谷A", thing="AI", user_input="7 + 5等于多少"
)

print(prompt)

第14章 输出解析器

分清Parser, Structured Output, TypeDict, Pydantic, JSON Schema的边界,

1 输出解析器简介

1.1 定义

对应了Model I/O的输出解析Parse部分,把模型的文本输出转成程序易用的结构化数据。与之前的形成输入-模型-输出解析完整链路。
后续用LCEL管道符将三者串成一条链。

是在模型输出和程序最终要用的数据之间的一层转换器。核心任务时把模型返回的内容,从面向人阅读的文本,转换为面向程序处理的结构化结果。

OutputParser负责把结果转成字符串,JSON或对象,把这份回答整理成程序好用的样子。

输出解析器:

  • 格式转换
  • 结构约束
  • 结果校验
  • 工程衔接

1.2 输出解析器的作用

面向程序输出,则需要将大模型的输出转换为一个字符串,JSON,一个字段固定的对象,一个带校验规则的强类型数据结构,所以不能只靠单纯的split(),正则,字符串截取去拆模型输出,代码往往会变得脆弱。

1.3 常见输出解析器分类

  • StrOutputParser
  • JsonOutputParser,模型返回pytyon dict / list
  • PydanticOutputParser, Pydantic对象,需要强类型和运行时校验的时候有用,

1.4 常见结构化输出方案

就需要了解schema代表的含义,事前规定模型应该按什么结构输出。

结构化输出的本质,就是先定义schema,再让模型按这个schema输出结果。

常见的结构化输出方案:

  • TypeDict,通过python标准库定义,返回dict,不支持运行时校验,适用于只需要固定字段结构
  • Pydantic,通过BaseModel + Field定义,返回Pydantic对象,支持运行时校验,需要强类型、范围、长度、必填项校验
  • JSON Schema,标准JSONSchema字典,通常为dict,

可以通过with_sturctured_output(..)实现让模型按照这个结构输出并自动解析

1.5 输出解析器与结构化输出的关系

结构化输出是一种约束,输出解析是后处理

可以进行组合:

  • 只用输出解析器
  • 只用结构化输出
  • 结构化输出 + 额外校验/处理

2 输出解析器常用方法

2.1 动作一:解析输出

解析器的两种使用方式:

  • parser.invoke(…),偏向LangChain、Runnable风格
  • parser.parse(text),偏已经拿到一段文本了,现在只想解析这段文本

2.2 动作二:给模型“格式说明”

可以事前帮你约束模型输出
常见方法是:

parser.get_format_instructions()

返回一段格式说明文字,告诉模型:应该输出什么结构,有哪些字段,每个字段是什么类型,是否只能返回JSON,是否不能加额外解释文字。

3 常见解析器用法与案例

3.1 StrOutputParser

直接把模型返回内容取出来,当作字符串使用,不做结构化解析,也不关心字段、键名、数据类型,只拿到最终文本。

在LangChain框架中大模型返回拿到的是AIMessage等对象,如果要拿到字符串,就可以用到解析器,StrOutputParser可以从模型返回中拿到content等字段,转成纯字符串,不做JSON等结构解析

StrOutputParser示例:

# 加载环境变量
load_dotenv(encoding="utf-8")

# 构造对话模板
# ChatPromptTemplate构建对话模板,使用列表构建
chat_prompt = ChatPromptTemplate.from_messages(
    [
        ("system", "你是一个{role},请简单回答我提出的问题"),
        ("human", "请回答:{question}"),
    ]
)


# 填充占位符,得到消息列表,供模型使用
prompt = chat_prompt.invoke(
    {'role': "AI助手", "question": "什么是LangChain,简洁回答100字以内"}
)
logger.info(prompt)

# 初始化大模型
model = init_chat_model(
    model="qwen-plus",
    model_provider="openai",
    api_key=os.getenv("aliQwen-api"),
    base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
)

# 调用模型
result = model.invoke(prompt)

logger.info(f"模型原始输出:\n{result}")

# 创建字符串解析器,从result中拿到content转为str

parser = StrOutputParser()

# 使用parser.invoke
response = parser.invoke(result)
logger.info(f"解析后的结构化结果:\n{response}")
logger.info("\n")
logger.info(
    f"结构类型:{type(response)}"
)


输出如下:

2026-07-04 22:06:36.438 | INFO     | __main__:<module>:35 - messages=[SystemMessage(content='你是一个AI助手,请简单回答我提出的问题', additional_kwargs={}, response_metadata={}), HumanMessage(content='请回答:什么是LangChain,简洁回答100字以内', additional_kwargs={}, response_metadata={})]
2026-07-04 22:06:41.007 | INFO     | __main__:<module>:48 - 模型原始输出:
content='LangChain是一个开源框架,用于构建基于大语言模型(LLM)的应用程序。它提供模块化组件(如链、代理、记忆、工具等),支持提示工程、数据连接、多步推理和外部工具调用,简化LLM应用的开发与集成。' additional_kwargs={'refusal': None} response_metadata={'token_usage': {'completion_tokens': 60, 'prompt_tokens': 37, 'total_tokens': 97, 'completion_tokens_details': None, 'prompt_tokens_details': {'audio_tokens': None, 'cached_tokens': 0}}, 'model_provider': 'openai', 'model_name': 'qwen-plus', 'system_fingerprint': None, 'id': 'chatcmpl-6cb20948-2de3-99ca-a8dc-2b7d54eb684b', 'finish_reason': 'stop', 'logprobs': None} id='lc_run--019f2d73-eb63-71c2-b63c-be77a65f3aa3-0' tool_calls=[] invalid_tool_calls=[] usage_metadata={'input_tokens': 37, 'output_tokens': 60, 'total_tokens': 97, 'input_token_details': {'cache_read': 0}, 'output_token_details': {}}
2026-07-04 22:06:41.007 | INFO     | __main__:<module>:56 - 解析后的结构化结果:
LangChain是一个开源框架,用于构建基于大语言模型(LLM)的应用程序。它提供模块化组件(如链、代理、记忆、工具等),支持提示工程、数据连接、多步推理和外部工具调用,简化LLM应用的开发与集成。
2026-07-04 22:06:41.007 | INFO     | __main__:<module>:57 - 

2026-07-04 22:06:41.007 | INFO     | __main__:<module>:58 - 结构类型:<class 'langchain_core.messages.base.TextAccessor'>

和直接从result.content拿到content相比,使用解析器是,在后续链式调用中可以直接通过切换不同parser切换不同解析要求。

3.2 JsonOutputParser

JSON解析器,把模型输出中可解析的JSON内容,转换成程序可直接使用的结构化数据。在Python里就是Dict和List

不是任意一段自然语言都能稳定转成JSON的魔法,Prompt仍需要提前说明输出格式,如果模型输出完全偏离JSON,解析依然可能失败。

3.2.1 用法一:直接在提示词手写JSON要求

另外进阶用法就是使用get_format_instructions()生成格式说明再拼进提示词

将JSOn要求写入提示词中

from dotenv import load_dotenv
from langchain.chat_models import init_chat_model
from langchain_core.output_parsers import JsonOutputParser
from langchain_core.prompts import ChatPromptTemplate
from loguru import logger
import os
# 加载环境变量
load_dotenv(encoding="utf-8")


# 构建提示词模板,并在系统消息提示词中提出JSON格式要是
chat_prompt = ChatPromptTemplate.from_messages(
    [
        ("system", "你是一个{role},请简短回答我提出的问题,结果返回Json格式,q字段标识问题a字段表示答案。"),
        ("human", "请回答:{question}"),
    ]
)

# 传入内容拼接
prompt = chat_prompt.invoke(
    {"role": "AI助手", "question": "什么是LangChain,简洁回答100字以内"}
)

logger.info(prompt)

model = init_chat_model(
    model="qwen-plus",
    model_provider="openai",
    api_key=os.getenv("aliQwen-api"),
    base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
)

# 获取结果
result = model.invoke(prompt)
logger.info(f"模型原始输出:\n{result}")

print("*" * 60)

# 创建JSON解析器(不绑Pydantic, 解析结果为dict/list)
parser = JsonOutputParser()

response = parser.invoke(result)
logger.info(f"解析后的JSON结果: \n{response}")

logger.info("\n")
logger.info(f"结果类型:{type(response)}")


JSON输出结果为:

解析后的JSON结果: 
{'q': '什么是LangChain,简洁回答100字以内', 'a': 'LangChain是一个开源框架,用于构建基于大语言模型的应用程序,支持链式调用、记忆管理、工具集成和数据连接,简化提示工程与应用开发。'}

3.2.2 用法二:用get_format_instructions()自动生成格式说明

让解析器自己自动生成格式说明,在拼进prompt,示例:
get_format_instructions返回一段格式说明字符串,描述希望模型输出成什么样子,例如Json有哪键,类型是什么
将这段说明拼进prompt中的{format_instructions}占位符,模型更容易输出可被解析的JSON,减少格式错误。

流程:定义结构——》创建解析器——》创建提示词模板——》拼接提示词——》模型调用——》输出解析器

from dotenv import load_dotenv
from langchain.chat_models import init_chat_model
from langchain_core.output_parsers import JsonOutputParser
from langchain_core.prompts import ChatPromptTemplate
from pydantic import BaseModel, Field
from loguru import logger
import os

load_dotenv(encoding="utf-8")


# 定义结构
class Person(BaseModel):
    """
    定义一条新闻的结构:时间、人物、事件
    用于约束模型输出的JSON
    """
    time: str = Field(description="时间")
    person: str = Field(description="人物")
    event: str = Field(description="事件")


# 绑定pydantic模型,主要驱动get_format_instructions()的schema
# invoke后得到dict

parser = JsonOutputParser(pydantic_object=Person)

# 就可以从get_format_instructions()中获取到schema
format_instructions = parser.get_format_instructions()

# 在human消息里加入{format_instructions},就相当于给模型提供了提示词“请按如下格式输出JSON”
chat_prompt = ChatPromptTemplate.from_messages(
    [
        ("system", "你是一个AI助手,你只能输出结构化JSON数据。"),
        ("human", "请生成一个关于{topic}的新闻,{format_instructions}"),
    ]
)

# 往提示词模板中注入变量进行拼接
prompt = chat_prompt.format_messages(
    topic="小米su7跑车", format_instructions=format_instructions
)
logger.info(prompt)

# 构建大模型
model = init_chat_model(
    model="qwen-plus",
    model_provider="openai",
    api_key=os.getenv("aliQwen-api"),
    base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
)


# 模型进行调用
result = model.invoke(prompt)
logger.info(f"模型原始输出:\n{result}")

# 用同一解析器解析,得到符合Person结构的数据dict或可转成person实例
response = parser.invoke(result)
logger.info(f"解析后的结构化结果:\n{response}")
logger.info(f"结果类型:{type(response)}")

输出内容如下:可以看到使用pydantic定义schema,在提示词中实际上就是JSON格式要求

2026-07-05 11:52:29.166 | INFO     | __main__:<module>:50 - [SystemMessage(content='你是一个AI助手,你只能输出结构化JSON数据。', additional_kwargs={}, response_metadata={}), HumanMessage(content='请生成一个关于小米su7跑车的新闻,**STRICT OUTPUT FORMA**T:\n- Return only the JSON value that conforms to the schema. Do not include any additional text, explanations, headings, or separators.\n- Do not wrap the JSON in Markdown or code fences (no or json).\n- Do not prepend or append any text (e.g., do not write "Here is the JSON:").\n- The response must be a single top-level JSON value exactly as required by the schema (object/array/etc.), with no trailing commas or comments.\n\nThe output should be formatted as a JSON instance that conforms to the JSON schema below.\n\nAs an example, for the schema {"properties": {"foo": {"title": "Foo", "description": "a list of strings", "type": "array", "items": {"type": "string"}}}, "required": ["foo"]} the object {"foo": ["bar", "baz"]} is a well-formatted instance of the schema. The object {"properties": {"foo": ["bar", "baz"]}} is not well-formatted.\n\nHere is the output schema (shown in a code block for readability only — do not include any backticks or Markdown in your output):\n\n{"description": "定义一条新闻的结构:时间、人物、事件\\n用于约束模型输出的JSON", "properties": {"time": {"description": "时间", "title": "Time", "type": "string"}, "person": {"description": "人物", "title": "Person", "type": "string"}, "event": {"description": "事件", "title": "Event", "type": "string"}}, "required": ["time", "person", "event"]}\n', additional_kwargs={}, response_metadata={})]
2026-07-05 11:52:33.616 | INFO     | __main__:<module>:63 - 模型原始输出:
content='{"time": "2024年4月1日", "person": "雷军", "event": "小米正式发布SU7高性能纯电动轿车,定位中大型轿跑,综合续航达800公里,零百加速2.78秒"}' additional_kwargs={'refusal': None} response_metadata={'token_usage': {'completion_tokens': 57, 'prompt_tokens': 379, 'total_tokens': 436, 'completion_tokens_details': None, 'prompt_tokens_details': {'audio_tokens': None, 'cached_tokens': 0}}, 'model_provider': 'openai', 'model_name': 'qwen-plus', 'system_fingerprint': None, 'id': 'chatcmpl-5ce9e140-10d5-92e1-b9aa-651d0e9dbe9e', 'finish_reason': 'stop', 'logprobs': None} id='lc_run--019f3068-089d-71c3-b6fd-57c9f5a0d695-0' tool_calls=[] invalid_tool_calls=[] usage_metadata={'input_tokens': 379, 'output_tokens': 57, 'total_tokens': 436, 'input_token_details': {'cache_read': 0}, 'output_token_details': {}}
2026-07-05 11:52:33.617 | INFO     | __main__:<module>:67 - 解析后的结构化结果:
{'time': '2024年4月1日', 'person': '雷军', 'event': '小米正式发布SU7高性能纯电动轿车,定位中大型轿跑,综合续航达800公里,零百加速2.78秒'}
2026-07-05 11:52:33.617 | INFO     | __main__:<module>:68 - 结果类型:<class 'dict'>

可以看到解析器拿到的是dict,如果想要获取到pydantic实例,则看下一节

4 结构化输出

4.1 定义

结构化输出structured output,不满足模型输出一段json文本,而是进一步要求:

  • 输出必须符合某个明确schema
  • LangChain直接帮你解析成字典或对象
  • 必要时还能做字段验证

模型输出越靠近业务动作,越需要更强约束,现在很多模型也支持原生结构化输出。在支持的模型上优先使用model.with_sturctured_output()

4.2 常见方式

有了前面的实践,在langchain里,结构化输出常见有三种schema方式:

  • TypeDict
  • Pydantic
  • JSON Schema

给字段补充说明信息,使用该Annotated

5 TypedDict与Annotated

5.1 TypedDict描述这个字典长什么样

TypeDict来自python标准库typing,作用是描述一个字典应该有哪些键,每个键是什么类型,更像是一张结构说明书。用于引导模型输出固定结构,再解析成python字典。

TypeDict不负责真正的运行时校验,

5.2 Annotated:给字段加解释说明

用于在原有类型上附加一段元数据或说明。示例:Annotated[str, "动物名"]

5.3 案例:TypedDict版结构化输出

langchain支持使用TypedDict或者Pydantic定义结构,再由模型按该结构生成并解析

在模型调用with_sturctured_output则会返回一个带结构化输出能力的可调用对象

示例:

from typing import TypedDict, Annotated

from dotenv import load_dotenv
from langchain.chat_models import init_chat_model
import os

"""
使用TypeDict定义结构,并添加元信息
"""

load_dotenv(encoding="utf-8")

llm = init_chat_model(
    model="qwen-plus",
    model_provider="openai",
    api_key=os.getenv("aliQwen-api"),
    base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
)

# 使用TypeDict定义一个动物的结构,
# Annotated的字符串是给模型看的描述,便于生成合适内容
class Animal(TypedDict):
    animal: Annotated[str, "动物"]
    emoji: Annotated[str, "表情"]

# 定义动物列表
class AnimalList(TypedDict):
    animals: Annotated[list[Animal], "动物与表情列表"]

# 普通对话消息
messages = [{"role": "user", "content": "任意生成三种动物,以及他们的emoji表情"}]

# 给模型绑定结构化输出
llm_with_sturctured_output = llm.with_structured_output(AnimalList)
resp = llm_with_sturctured_output.invoke(messages)

print(
    resp
)

输出,应该是大模型自带的结构化输出,返回动物列表,

{'animals': [{'animal': '狮子', 'emoji': '🦁'}, {'animal': '海豚', 'emoji': '🐬'}, {'animal': '熊猫', 'emoji': '🐼'}]}

5.4 案例: Annotated只是描述,不是校验

Annotated提供的只是元数据,Python不会按描述做校验

示例:

from typing import Annotated, TypedDict

# 例如定义一个年龄描述的元数据
Age = Annotated[int, "年龄,范围0-150"]


class Person(TypedDict):
    name: str
    age: int
    age2: Age # 本质上还是int,元数据不参与运行时校验


# 实例化
p = Person(name="张三", age=18, age2=188)
print(p)

输出:

{'name': '张三', 'age': 18, 'age2': 188}

5.5 使用场景

TypeDict适合要稳定结构,但暂时不要求严格数据合法性校验

6 PydanTic: 从结构说明到校验

6.1 定义

TypedDict解决这个字典应该长什么样,pydantic解决的是长什么样并且合法

Pydantic是python生态里很常用的数据校验库,可以在创建对象时对字段做类型检查、范围检查、长度检查、自定义校验等。

6.2 案例: Annotated + Pydantic触发校验

为了进行校验,将Pydantic的Field放在Annotated中,可以进行解析和校验
示例:

from typing import Annotated

from pydantic import Field, BaseModel, ValidationError

# 定义Age
Age = Annotated[int, Field(ge=0, le=150, description="年龄,范围0-150")]

# 定义Person类型
class Person(BaseModel):
    name: str
    age: int
    age2: Age

try:
    # 实例化
    p = Person(name="张三", age=18, age2=188)
    print(p)
except ValidationError as e:
    print("数据校验失败: ")
    print(e)

上述代码中,真正发生校验的是Pydantic中的Field规则,其中Field里面包含了很多参数,例如ge就是greater than or equal,le就是less than or equal。另外还有gt和lt。

6.3 PydanticOutputParser的完整流程

PydanticOutputParser 和 JsonOutputParser 的区别?

  • JsonOutputParser:把模型文本解析成「任意」JSON(dict/list),或可绑一个 Pydantic 模型约束形状。
  • PydanticOutputParser:专门配合 Pydantic 模型,解析结果会转成** Pydantic 实例**,并可利用 Pydantic 的校验(如字段类型、validator),不合格会抛错。

示例:


from typing import Any

from langchain.chat_models import init_chat_model
from langchain_core.output_parsers import PydanticOutputParser
from langchain_core.prompts import ChatPromptTemplate
from typing_extensions import Self

from dotenv import load_dotenv
from pydantic import BaseModel, Field, field_validator
from loguru import logger
import os


load_dotenv(encoding="utf-8")

class Product(BaseModel):
    """
    产品信息:名称,类别,简介,
    简介长度 >= 10,通过validator校验
    """
    name: str = Field(description="产品名称")
    category: str = Field(description="产品类别")
    description: str = Field(description="产品简介")

    # 定义校验字段
    @field_validator("description")
    def validate(cls, value: Any) -> Self:
        """Pydantic校验器"""
        if len(value) < 10:
            raise ValueError("产品简介长度必须 >= 10")
        return value

# 创建Pydantic输出解析器,解析结果会转成Product实例并校验
parser = PydanticOutputParser(pydantic_object=Product)

# 生成格式说明字符串,拼进prompt,
format_instructions = parser.get_format_instructions()

# 在system里放入format_instructions, human里放topic
# 构造提示词模板
prompt_template = ChatPromptTemplate.from_messages(
    [
        ("system", "你是一个AI助手,你只能输出结构化的json数据\n{format_instructions}"),
        ("human", "请你输出标题为:{topic}的新闻内容"),
    ]
)


# 拼接提示词
prompt = prompt_template.format_messages(
    topic="小鹏最新车型", format_instructions=format_instructions
)
logger.info(prompt)

model = init_chat_model(
    model="qwen-plus",
    model_provider="openai",
    api_key=os.getenv("aliQwen-api"),
    base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
)


# 模型调用
result = model.invoke(prompt)
logger.info(f"模型原始输出:\n{result.content}")

# 解析,把result转为Product实例,同时会进行校验
response = parser.invoke(result)
logger.info(f"解析后的结构化结果:\n{response}")
logger.info(f"结果类型:{type(response)}")

其中使用field_validator指定校验字段,通过PydanticOutputParser返回的是Product对象

6.4 使用场景

当你遇到下面的场景:

  • 结果写到数据库,不容忍字段类型乱掉
  • 要把模型输出接到其他业务系统
  • 某些字段必须要求满足一些限制
  • 数据不合法就抛出异常

7 JsonSchema和外部协议对齐时很有用

7.1 定义

用于专门用来描述JSON结构和约束规则的标准,特点是语言无关前后端都能理解,别LangChain列表为结构化输出的三种主流方式之一。

7.2 场景

  • 后端和前端已经约定了一份 JSON 协议;
  • 你的 Python 服务要和 Java、Go、Node 等系统对接;
  • 想把数据结构标准化、文档化;
  • 不想强依赖 Pydantic 或 Python 类型系统;

那么 JSON Schema 就会比 TypedDict 更通用。

7.3 和TypedDict和Pydantic的区别

  • TypedDict:最像 Python 内部的“结构说明书”。
  • Pydantic:最像 Python 内部的“结构 + 校验模型”。
  • JSON Schema:最像系统之间共享的“协议文档”。

8.4 自定义解析器

继承BaseOutputParser, 实现最关键的parse方法

第15章:LCEL与链式调用

了解管道符背后的链式组合思想
了解顺序链、分支链、多步串行链、并行链、函数链

1 Runnable与统一调用方式

1.1 前置知识点:抽象基类

就是规定这一类对象应该具备哪些共同能力,不着急规定每个对象内部怎么做

这样无论这个对象到底是什么,是Prompt, Model, Parser还是整条Chain,使用者都可以按同一种方式理解和调用。

在LangChain里,把runnable理解为这样一种核心抽象接口,背后的思想就是: Prompt, Model, Parser, Chain是一种可执行组件,属于可执行组件这一大类,应该尽量遵循同一套调用协议,

LangChain 参考文档。Runnable 声明为 class Runnable(ABC, Generic[Input, Output]),描述的是“可被调用、批量处理、流式输出、变换与组合”的工作单元;invoke/ainvoke、batch/abatch、stream/astream 等成对出现,astream_log 还可流式透出部分中间结果。各方法均可传入 config(如标签、元数据)便于追踪与排障;输入/输出/config 的结构信息可通过 input_schema、output_schema、config_schema 等暴露给工具链与 IDE。

1.2 统一接口的意义

把这些能接受输入并产生输出的对象尽量抽象成统一接口,这个统一接口就是Runnable

为什么需要Runnable,这是因为在早期的LangChain中,调用不同组件的命令非常混乱,例如调用LLM用llm(),调用Chain用chain.run(),导致组件之间很难优雅拼接,Runnable协议解决了这个痛点:

  • 统一接口与无缝组合,因此可以形成链
  • 能力自动继承,只需要拼接成链后,整个链就自动拥有了流式输出、批量并发、异步执行等所有标准能力
  • 显式数据校验:每个Runnable组件都内置了输入和输出的类型规范,在组件组合时,系统会自动校验类型匹配性,提前暴露错误。

1.3 定义

Runnable是LangChain最核心的抽象之一,要能够正常区分:

  • Runnable 是什么:它表示“这一类对象是可执行组件”,是一种统一抽象标准
  • 统一调用方式是什么:它是 Runnable 带来的结果,也就是这些组件都可以尽量用同一套方式去调用,比如 invoke、batch、stream

Runnable是统一调用方式背后的抽象基础。

只要某个对象实现了Runnable接口,通常就能用统一方式来调用。

1.4 统一调用方式

要实现链式组合,如何才能成立,才能用的更好。

例如提示词模板、模型、解析器三者都可以调用invoke,统一成了同一种风格。

组件之间更容易替换,流程更容易串联,链本身也可以继续被当作一个组件使用;中间结果传递方式更统一,不需要每一步都手写很多适配代码。

1.5 常用方法

回顾之前,我在前面的LangChain的init_model中回顾了invoke方法,其中就是重写的invoke方法,

@override
    def invoke(
        self,
        input: LanguageModelInput,
        config: RunnableConfig | None = None,
        *,
        stop: list[str] | None = None,
        **kwargs: Any,
    ) -> AIMessage:
        config = ensure_config(config)
        return cast(
            "AIMessage",
            cast(
                "ChatGeneration",
                self.generate_prompt(
                    [self._convert_input(input)],
                    stop=stop,
                    callbacks=config.get("callbacks"),
                    tags=config.get("tags"),
                    metadata=config.get("metadata"),
                    run_name=config.get("run_name"),
                    run_id=config.pop("run_id", None),
                    **kwargs,
                ).generations[0][0],
            ).message,
        )

上述的RunnableConfig是控制Runnable组件运行时行为的关键配置字典,是整个LangChain运行时配置的中枢系统,用于在调用时传递各种控制参数。例如这里的model的 invoke,@override 装饰器正是表示该方法**重写了 Runnable 接口中定义的标准 invoke 方法。**通过实现这个标准方法,这个特定的类就正式成为了一个合格的 Runnable 组件,从而可以无缝接入到 LangChain 的 LCEL 组合链路中。

在LangChain中,任何可以被调用、执行、组合的东西,本质上都是一个接受输入经过处理、产生输出的独立任务单元。只要一个对象实现了Runnable协议,就获得了LangChain赋予的一系列开箱即用的强大能力。

Runnable 协议强制规定了所有组件必须统一实现以下核心调用方法,极大简化了代码和学习成本。

  • invoke(input)方法,同步调用。传入单个输入,等待并获取单个输出,这是最基础的单次请求方式。
  • ainvoke(input)方法,异步调用,用于在异步框架中提高并发性能
  • batch(inputs),批量处理。传入一个输入列表,内部自动利用线程池并发处理,返回一组输出,比顺序循环调用效率高得多
  • stream(input),流式传输,打字机效果
  • abatch/astream,批量处理和流式传输的异步版本

参考文章:

LangChain Runnable类型系统,模式检查与配置管理,从接口规范到运行时控制

文章中提到:
Runnable接口定义了组件交互的标准行为,还通过完善的类型系统和配置机制确保了复杂AI流程的可靠性

与传统面向对象接口不同,Runnable的类型可以是任意Python对象,由组件自身定义

1.6 在实际项目中的价值

在链式调用中各个组件就像是在拼积木,很多更复杂的高级能力都是在Runnable这层统一抽象之上继续往上搭。

2 LCEL简介

2.1 定义

LCEL, LangChain Expression Language,LangChain表达式语言,作用很直接,用一种声明式、可组合的方式,把多个Runnable连接起来。

示例:

chain = prompt | model | parser
result = chain.invoke({"question": "什么是LangChain?"})

用 | 管道符或其他组合方式,把多个Runnable连接起来的一套表达方法。能够做到:

  • 把前一步输出自动传到后一步
  • 尽量减少不同节点之间手写适配的样板代码

2.2 LCEL不只是语法糖

LCEL的价值远不止省代码,好处是:

  • 表达更清晰
  • 更容易组合
  • 统一支持同步、异步、批量、流式

2.3 LCEL的核心组合思想

理解几种核心组合思想:

  • 顺序组合
  • 条件路由
  • 并行组合

RunnableSequence和RunnableParallel是两种最核心的组合原语,很多其他Runnable结构,本质上都可以看做是在这两种基础组合能力上的扩展或变体。

LCEL则是LangChain开始让你用数据流编排的方式思考LLM应用。

2.4 LCEL和Chain的关系

  • LCEL是构建流程的一种表达方式,expression language
  • Chain是通过LCEL或其他组合方式构建出来的可执行流程,重点在于最后得到了什么

3 Chain结构

3.1 定义

Chain链:多个Runnable按照某种规则组合起来后,形成的一段可执行流程。
关键是链本身也是Runnable。这就意味着链不会成为终点对象,依然可以继续被组合。例如一条顺序链可以作为RunnableBranch的分支,一条顺序链可以放进RunnableParallel,并行结果还可以继续交给后面的节点处理。

3.2 典型结构

最典型的一条链就是:

  • Prompt
  • Model
  • Parser

也就是chain = prompt | model | parser,是LangChain里最经典,最基础的组合方式

4 链式调用基础用于与案例

4.1 几种链的选择与对比

  • 顺序链,典型写法RunnableSequence
  • 分支链,典型写法RunnableBranch(…)
  • 多步串行链,多条子链继续串联
  • 并行链,RunnableParallel({…})
  • 函数链,RunnableLambda(func)

4.2 RunnableSequence顺序链

核心规则:前一个Runnable的输出可以作为后一个Runnable的输入
所以chain = prompt | model | parser本质上就是一个RunnableSequence

也可以显式写成:

from langchain_core.runnables import RunnableSequence
chain = RunnableSequence(first=prompt, second=model, third=parser)

但是最常见的是管道符写法,因为可读性更好。

顺序链示例:

from langchain_classic.chat_models import init_chat_model
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from loguru import logger
import os


# 构建提示词模板
prompt_template = ChatPromptTemplate.from_messages(
    [
        ("system", "你是一个{role}, 请简短回答我提出的问题。"),
        ("human", "请回答:{question}"),
    ]
)

# 拼接提示词
# prompt = prompt_template.format_messages(role="AI助手", question="什么是LangChain,简洁回答100字以内")
# 要用管道,就用Runnable接口定义的规则
prompt = prompt_template.invoke({"role": "AI助手", "question": "什么是LangChain,简洁回答100字以内"})
logger.info(prompt)

# 初始化聊天模型
model = init_chat_model(
    model="qwen-plus",
    model_provider="openai",
    api_key=os.getenv("aliQwen-api"),
    base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
)

# 调用模型
result = model.invoke(prompt)
logger.info(f"****>模型原始输出:\n{result}")

# 这里使用字符串输出解析器
parser = StrOutputParser()
response = parser.invoke(result)
logger.info(f"解析后的结构化结果是:\n{response}")
logger.info(f"结果类型:{type(response)}")

print()
print("*" * 60)
print()

# 使用管道符|构建顺序链,chain才是最终得到的RunnableSequence对象
chain = prompt_template | model | parser
# 调用链
result_chain = chain.invoke(
    {"role": "AI助手", "question": "什么是LangChain,简洁回答100字以内"}
)
logger.info(f"Chain执行结果:\n{result_chain}")
logger.info(f"Chain执行结果类型: {type(result_chain)}")

print()
print(type(chain))

得到输出:

2026-07-05 21:07:21.227 | INFO     | __main__:<module>:29 - messages=[SystemMessage(content='你是一个AI助手, 请简短回答我提出的问题。', additional_kwargs={}, response_metadata={}), HumanMessage(content='请回答:什么是LangChain,简洁回答100字以内', additional_kwargs={}, response_metadata={})]
F:\mycodesF\pycharmprojects\ai-agents-from-zero\案例与源码-2-LangChain框架\06-lcel\study\LCEL_RunnablSequenceDemo.py:32: LangChainDeprecationWarning: The function `init_chat_model` was deprecated in LangChain 1.0.5 and will be removed in 2.0.0. Use `langchain.chat_models.init_chat_model` instead. Maintained in `langchain`; `langchain-classic` retains this entry point for import-compatibility only.
  model = init_chat_model(

************************************************************

2026-07-05 21:07:25.430 | INFO     | __main__:<module>:41 - ****>模型原始输出:
content='LangChain是一个开源框架,用于构建基于大语言模型(LLM)的应用程序。它提供模块化组件(如链、代理、记忆、工具),支持提示工程、数据检索增强(RAG)、多步推理和外部工具调用,简化LLM应用开发与集成。' additional_kwargs={'refusal': None} response_metadata={'token_usage': {'completion_tokens': 62, 'prompt_tokens': 41, 'total_tokens': 103, 'completion_tokens_details': None, 'prompt_tokens_details': {'audio_tokens': None, 'cached_tokens': 0}}, 'model_provider': 'openai', 'model_name': 'qwen-plus', 'system_fingerprint': None, 'id': 'chatcmpl-af16b125-d6c0-9162-9b08-874a37bd5532', 'finish_reason': 'stop', 'logprobs': None} id='lc_run--019f3264-065d-7f12-9479-14caf9c36959-0' tool_calls=[] invalid_tool_calls=[] usage_metadata={'input_tokens': 41, 'output_tokens': 62, 'total_tokens': 103, 'input_token_details': {'cache_read': 0}, 'output_token_details': {}}
2026-07-05 21:07:25.430 | INFO     | __main__:<module>:46 - 解析后的结构化结果是:
LangChain是一个开源框架,用于构建基于大语言模型(LLM)的应用程序。它提供模块化组件(如链、代理、记忆、工具),支持提示工程、数据检索增强(RAG)、多步推理和外部工具调用,简化LLM应用开发与集成。
2026-07-05 21:07:25.430 | INFO     | __main__:<module>:47 - 结果类型:<class 'langchain_core.messages.base.TextAccessor'>

<class 'langchain_core.runnables.base.RunnableSequence'>
2026-07-05 21:07:27.320 | INFO     | __main__:<module>:59 - Chain执行结果:
LangChain 是一个开源框架,用于构建基于大语言模型(LLM)的应用程序。它提供模块化组件(如链、代理、记忆、工具),支持提示管理、数据检索增强(RAG)、多步推理与外部系统集成,简化 LLM 应用的开发与编排。
2026-07-05 21:07:27.321 | INFO     | __main__:<module>:60 - Chain执行结果类型: <class 'langchain_core.messages.base.TextAccessor'>

顺序链从接受输入到最后通过字符串解析器得到字符串内容。

4.3 RunnableBranch分支链

RunnableBranch解决的就是根据输入内容,决定走哪条链。
典型结构:

RunnableBranch(
	(条件1, 链1),
	(条件2, 链2),
	默认链)

例如语言路由的实例:按照顺序判断条件,命中的第一条分支会被执行,最后一个未成对的Runnable就是默认的,分支链可以理解为 外层路由 + 内层子链。
示例:

from dotenv import load_dotenv
from langchain.chat_models import init_chat_model
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
import os

from langchain_core.runnables import RunnableBranch
from loguru import logger


load_dotenv(encoding="utf-8")

# 编写分支提示词模板
english_prompt = ChatPromptTemplate.from_messages(
    [
        ("system", "你是一个英语翻译专家,你叫小英"),
        ("human", "{query}")]
)

japanese_prompt = ChatPromptTemplate.from_messages(
    [("system", "你是一个日语翻译专家,你叫小日"), ("human", "{query}")]
)

korean_prompt = ChatPromptTemplate.from_messages(
    [("system", "你是一个韩语翻译专家,你叫小韩"), ("human", "{query}")]
)


model = init_chat_model(
    model="qwen-plus",
    model_provider="openai",
    api_key=os.getenv("aliQwen-api"),
    base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
)

def determine_language(inputs):
    """根据query中的关键词判断语言类型,供分支条件使用"""
    query = inputs["query"]
    if "日语" in query:
        return "japanese"
    elif "韩语" in query:
        return "korean"
    else:
        return "english"

# 解析器
parser = StrOutputParser()

# 构建RunnableBranch
chain = RunnableBranch(
    (lambda x : determine_language(x) == "japanese", japanese_prompt | model | parser),
    (lambda x : determine_language(x) == "korean", korean_prompt | model | parser),
(english_prompt | model | parser), # 默认分支
)

# 测试查询
test_queries = [
    {"query": '请你用韩语翻译这句话"见到你很高兴"'},
    {"query": '请你用日语翻译这句话"见到你很高兴"'},
    {"query": '请你用英语翻译这句话"见到你很高兴"'},
]

# 测试分支
for query_input in test_queries:
    # 语言类型
    lang = determine_language(query_input)
    logger.info(f"检测到语言类型:{lang}")

    # 执行分支
    result = chain.invoke(query_input)
    logger.info(f"输出结果:{result}\n")

4.4 多步串行链

把多条子链首尾串起来,形成多步串行步骤。典型的多步加工。

在多条子链首尾相接,前一步输出会直接流向后一步,如果前后输入输出结构不匹配,就需要插入一次映射,(有点像Java对象之间的封装)理解为进行数据匹配转换。

示例中的两条链,通过lambda进行数据转换,可以理解为数据格式转换器,将chain1给的字符串赋值给content,然后打包成chain2需要的字典格式{“input”: content}

"""
实现多步串行链

"""
from dotenv import load_dotenv
from langchain.chat_models import init_chat_model
import os
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from loguru import logger



# 加载系统环境
load_dotenv(encoding="utf-8")

# 定义模型
model = init_chat_model(
    model="deepseek-v4-flash",
    api_key=os.getenv("deepseek-api"),
    base_url="https://api.deepseek.com",
)

# 子链1 用中文介绍某主题,输出为str
# 构造提示词
prompt1 = ChatPromptTemplate.from_messages(
    [
        ("system", "你是一个知识渊博的计算机专家,请用中文简短回答"),
        ("human", "请简短介绍什么是{topic}"),
    ]
)

# 构造字符串解析器
parser1 = StrOutputParser()

# 构造串行链
chain1 = prompt1 | model | parser1

result1 = chain1.invoke({"topic": "langchain"})
logger.info(result1)



# 子链2 将用户输入翻译为英文,期望入参为{"input": 文本}
# 构造提示词
prompt2 = ChatPromptTemplate.from_messages(
    [
        ("system", "你是一个翻译助手,将用户输入内容翻译为英文"),
        ("human", "{input}"),
    ]
)

# 构造字符串解析器
parser2 = StrOutputParser()

# 构造串行链
chain2 = prompt2 | model | parser2

# 串行组合,利用第一条链的中文文本输出作为第二条链的输入
full_chain = chain1 | (lambda content: {"input": content}) | chain2

result = full_chain.invoke({"topic": "langchain"})
logger.info(result)


说明了前后两步的输入输出结构,不一定天然匹配,这也是为什么函数节点和RunnableLambda在LCEL中很实用。

4.5 RunnableParallel并行链

解决的是并行问题,例如同一个输入,要同时交给多条链处理

核心特点就是多条子链共享同一输入,同时执行,最后再把结果按键汇总成一个Dict。

显式调用写法:

parallel_chain = RunnableParallel ({
	"chinese": chain1,
	"english": chain2,
})

LCEL中,直接使用字典表达并行结构,例如:会先并行运行后把结果汇总为一个字典

parallel_then_summary = {
	"paragraph_1": chain1,
	"paragraph_2": chain2,
} | summary_chain

一个完整的示例:

"""
并行链,同时运行多条子链
并行后结果以字典形式汇总返回,键对应并行结构里的键,值对应输出
"""
import os
from dotenv import load_dotenv
from langchain.chat_models import init_chat_model
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnableParallel
from loguru import logger

load_dotenv(encoding="utf-8")

# 构造模型
model = init_chat_model(
    model="deepseek-v4-flash",
    api_key=os.getenv("deepseek-api"),
    base_url="https://api.deepseek.com",
)

# 子链1,中文简短介绍
prompt1 = ChatPromptTemplate.from_messages(
    [
        ("system", "你是一个知识渊博的计算机专家,请用中文简短回答"),
        ("human", "请简短介绍{topic}是什么"),
    ]
)

parser1 = StrOutputParser()
chain1 = prompt1 | model | parser1

# 子链2,英文简短介绍
prompt2 = ChatPromptTemplate.from_messages(
    [
        ("system", "你是一个知识渊博的计算机专家,请用英文简短回答"),
        ("human", "请简短介绍{topic}是什么"),
    ]
)

parser2 = StrOutputParser()
chain2 = prompt2 | model | parser2

# 构造并行链
parallel_chain = RunnableParallel({"chinese": chain1, "english": chain2})

# 一次invoke就并行完成后同时返回
result = parallel_chain.invoke({"topic": "langchain"})
logger.info(result)
logger.info("*" * 30)
logger.info(type(result)) # 返回是dict类型

4.6 RunnableLambda 函数链

RunnableLambda价值在于把普通python函数也变成Runnable。

在实际开发里非常实用,因为链式流程经常会遇到以下情况:

  • 上一步输出结构不符合下一步输出要求
  • 想打印中间结果做调试
  • 想做一次字段重命名
  • 想插入一点简单业务逻辑

有了函数节点,这些逻辑可以直接放回链内部,不用拆开链。

示例:

from langchain_core.runnable import RunnableLambda

def debug_print(x):
	print(x)
	return {"input": x}

chain = chain1 | RunnableLambda(debug_print) | chain2

或者直接把函数放在|中间即可,框架会自动包装成Runnable。例如chain1 | debug_print | chain2

示例:

"""
函数链
作用,使用RunnableLambda将普通python函数接入LCEL链
原理:将普通函数变成Runnable节点
适合轻量逻辑:例如打印中间结果、字段映射、输入输出结果适配,
"""
import os

from langchain_core.runnables import RunnableLambda
from loguru import logger
from dotenv import load_dotenv
from langchain.chat_models import init_chat_model
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser

load_dotenv(encoding="utf-8")

# 构建模型
model = init_chat_model(
    model="deepseek-v4-flash",
    api_key=os.getenv("deepseek-api"),
    base_url="https://api.deepseek.com",
)

# 定义一个函数
def debug_print(x):
    """
    打印中间结果,并把文本包装成chain2所需的{"input": 文本}结构

    """
    logger.info(f"中间结果{x}")
    # 包装为字典
    return {"input": x}

# 子链1 中文介绍某主题,输出str
prompt1 = ChatPromptTemplate.from_messages(
    [
        ("system", "你是一个知识渊博的计算机专家,请用中文简短回答"),
        ("human", "请简短介绍什么是{topic}")
    ]
)

parser1 = StrOutputParser()

chain1 = prompt1 | model | parser1

# 子链2 将input翻译成英文
prompt2 = ChatPromptTemplate.from_messages(
    [
        ("system", "你是一个翻译助手,将用户输入内容翻译为英文"),
        ("human", "{input}")
    ]
)

parser2 = StrOutputParser()

chain2 = prompt2 | model | parser2

# 函数链,两种方式,RunnableLambda或者自动包装
full_chain = chain1 | RunnableLambda(debug_print) | chain2
# 或者
# full_chain = chain1 | debug_print | chain2

result = full_chain.invoke({"topic": "langchain"})
logger.info(f"最终结果:{result}")

注意RunnableLambda是轻量的,如果里面塞了大量业务逻辑,外部副作用和复杂异常处理,就应该拆成明确函数、工具或服务,而不是藏在链里。

4.7 补充:其他常见Runable结构

还有RunnablePassThrough, RunnableWithFallbacks, RunnableBinding。

4.8 重试与兜底

真实项目里,链路不是每次都顺利跑完,LCEL的好处之一在于可以把这类稳定性处理挂在Runnable上,而不是散落在业务代码各处。

最常见的两个动作是:

  • with_retry(…),当前Runnable失败后自动重试
  • with_fallbacks([…]),主Runnable失败后切到备用Runnable

了解LCEL也负责把重试、兜底、配置这些工程能力放到链上

链路问题很多不是模型错,而是前后节点的数据结构没对上。

学习后续内容理解如何进一步扩展成带状态、带工具、带决策能力的LLM应用。

第16章 记忆与对话历史,含Redis基础

掌握读历史——》拼入提示——》调模型——》写回历史,这条核心实现主线,

1 记忆简介

1.1 为什么需要记忆

这个很好理解,LLM是一个盒子,请求一次调用一次,如果没有携带上次对话信息,就会忘记断片。

记忆是多轮对话系统最基础的能力之一,至少解决三类问题:

  • 上下文连续性
  • 会话个性化
  • 多步任务承接
1.2 定义

记忆,主要指短期记忆或对话历史Chat History

本质不是模型真的记住了,而是程序把之前的消息保存下来,并在下一轮调用之前,再把这些历史消息一并传给模型

1.3 不是训练模型

记忆只是:

  • 把历史消息保存在外部
  • 下次调用前重新读出来
  • 和当前问题一起发给模型

1.4 本章的记忆能做什么

  • 让多轮问答连续起来
  • 保存当前会话上下文
  • 为链式流程提供上下文:历史消息通过MessagePlaceHolder注入模板,搭配链组合在一起使用

1.5 本章不重点讨论

  • 本章不讨论长期知识记忆,例如用户永久画像,长期偏好管理
  • 不是RAG检索记忆
  • 不是Agent决策状态机

2 无记忆时的演示

一个简单的示例就是一个链,同时提问两次,第二次提问涉及到第一提问给的信息,可以看到没有记忆就不知道信息。

示例:

"""
演示无记忆时的两轮请求
每次Invoke都是相互独立的,模型看不到上次对话,
网页端LLM能够实现记忆是因为前端或者后端实现了历史记忆
本章后续会用RunnableMessageHistory + 记忆组件实现该能力
"""

from dotenv import load_dotenv
import os
from loguru import logger
from langchain.chat_models import init_chat_model
from langchain_core.prompts import PromptTemplate
from langchain_core.output_parsers import StrOutputParser


load_dotenv(encoding="utf-8")

model = init_chat_model(
    model="deepseek-v4-flash",
    api_key=os.getenv("deepseek-api"),
    base_url="https://api.deepseek.com",
)

# 构建提示词
prompt = PromptTemplate.from_template("请回答我的问题:{question}")
parser = StrOutputParser()

# 构建链路
chain = prompt | model | parser

# 第一轮
print(chain.invoke({"question": "我叫张三,你叫什么?"}))

print(chain.invoke({"question": "你知道我是谁吗?"}))



输出结果为:

你好,张三!我是DeepSeek,一个由深度求索公司创造的AI助手。很高兴认识你!有什么我可以帮你的吗?😊
我不知道你是谁。我是DeepSeek,一个由深度求索公司开发的AI助手。我没有访问你的个人身份信息、历史记录或任何能识别你身份的数据的能力。每次对话对我来说都是全新的开始,我只会根据你当前提供的信息来理解和回应你。

如果你愿意告诉我一些关于你自己的信息,我会很乐意根据你分享的内容来帮助你!😊

因此并不是模型天然记住多轮对话的,是程序决定能不能看见历史的。

3 实现原理

3.1 核心主线

理解记忆实现链路,实际上是一个循环的流程

  • 读历史
  • 将历史拼进当前提示
  • 调用模型
  • 把本轮输入和输出写回历史

3.2 工程化表达

  • 根据会话标识找到对应历史,例如session_id = “user_001”
  • 把历史消息插入Prompt
  • 把历史 + 当前问题一起交给模型
  • 把本轮消息写回历史存储

3.3 MessagePlaceholder与模板的关系

在模板里留一个“历史消息插槽”,运行时再把当前会话历史整块塞进去,

3.4 session_id的意义

标识会话,最常见的就是session_id,表示当前会话的编号。
在RunnableWithMessageHistory,session_id通常通过运行配置传入,例如config = {“configurable”: {“session_id”: “user-0001”}}

3.5 本章和LangGraph官方主线的关系

短期记忆越来越倾向于放在LangGraph persistence/checkpointer/thread这条体系里讲。

历史消息会占用模型上下文窗口,轮数过多时需要做截断、摘要或只保留最近k轮,否则可能触达token上限等。

4 记忆相关实现类

4.1 本章使用哪套写法

先用RunnableWithMessageHistory + BaseChatMessageHistory理解链级对话历史

复杂Agent的状态持久化,后续放到LangGraph的thread/checkpointer / persistence中

4.3 RunnableWithMessageHistory

作用:给一条已有的Runnable / Chain包上一层历史管理能力,也是在Runnable体系上继续工作。

4.4 BaseChatMessageHistory

历史存储接口,

RunnableWithMessageHistory解决的是历史读写时机
BaseChatMessageHistory解决的就是历史到底存在哪,怎么存。可以看作聊天消息历史的统一抽象接口。

4.5 常见实现类

提供多种聊天实现。

  • InMemoryChatMessageHistory,进程内内存
  • FileChatMessageHistory,本地文件
  • RedisChatMessageHistory,Redis
  • 其他后端实现

5 案例代码

5.1 内存版

内存版适合入门,把记忆的本质暴露的最清楚:

  • 历史就是一个消息列表
  • 每轮都从这份列表里读取
  • 每轮结束再把新消息写回去
5.1.1 最基础写法:RunnableWithMessageHistory

示例代码:

"""
内存版本的历史对话
RunnableWithMessageHistory + InMemoryChatMessageHistory

RunnableWithMessageHistory决定什么时候用,
    在每次invoke时,会先从get_session_history(session_id)取历史,
    拼prompt的MessagePlaceHolder,执行链后再把本轮输入与输出写回历史
InMemoryChatMessageHistory决定存哪里怎么存
    InMemory存在内存中

"""

from dotenv import load_dotenv
load_dotenv(encoding="utf-8")

from langchain.chat_models import init_chat_model
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnableWithMessageHistory, RunnableConfig
from langchain_core.chat_history import InMemoryChatMessageHistory
from loguru import logger
import os

# 构建模型
llm = init_chat_model(
    model="deepseek-v4-flash",
    api_key=os.getenv("deepseek-api"),
    base_url="https://api.deepseek.com",
)

# 构造提示词模板
# 其中history占位符用于注入历史消息
prompt = ChatPromptTemplate.from_messages(
    [
        MessagesPlaceholder(variable_name="history"),
        ("human", "{input}"),
    ]
)

parser = StrOutputParser()
chain = prompt | llm | parser

# 记忆组件,内存实现,进程内有效,重启后丢失
history = InMemoryChatMessageHistory()

# 然后将链包装为带历史版本,指定key匹配输入和输出
runnable = RunnableWithMessageHistory(
    chain,
    get_session_history=lambda session_id: history,
    input_messages_key="input",
    history_messages_key="history",
)

history.clear()

# 保留session_id配置,
config = RunnableConfig(configurable={"session_id": "user-001"})

# 第一轮,
logger.info(runnable.invoke({"input": "我叫张三,我爱学习"},config))
# 第二轮
logger.info(runnable.invoke({"input": "我叫什么,我的爱好是什么"}, config))

第一轮完成后在内存记忆中就会存储第一轮对话的用户消息和AI消息,如图所示:
在这里插入图片描述
上面的示例中,MessagePlaceholder(“history”)负责接收历史,RunnableWithMessageHistory负责包住整条链,session_id负责告诉系统当前读哪份历史。

5.1.2 多session写法:按照session_id维护多份历史

示例

"""
【案例】内存版带历史对话(多 session):用 store 按 session_id 维护多份 InMemoryChatMessageHistory

对应教程章节:第 16 章 - 记忆与对话历史 → 6、案例代码 → 6.1 内存版(进程内,重启即丢失)

知识点速览:
- 与 Memory_RunnableWithMessageHistory 的区别:本案例用 get_session_history(session_id) 从 store 中按 session 取不同 history,可支持多用户/多会话(每 session 独立历史)。
- MessagesPlaceholder("history") 与 prompt 中的变量名一致,RunnableWithMessageHistory 会把读到的历史注入此处;input_messages_key、history_messages_key 需与 prompt 占位符对应。
- 本案例会同时演示 user-001 与 user-002 两个 session,帮助你直观看到“同一套链逻辑,如何切换到不同历史”。
- 生产环境可将 store 换成 Redis、数据库等,get_session_history 返回 RedisChatMessageHistory(session_id=session_id, ...) 即可实现持久化(见 6.2)。
"""

from dotenv import load_dotenv

load_dotenv(encoding="utf-8")

from langchain.chat_models import init_chat_model
from langchain_core.chat_history import InMemoryChatMessageHistory
from langchain_core.runnables.history import RunnableWithMessageHistory
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.output_parsers import StrOutputParser
import os

llm = init_chat_model(
    model="deepseek-v4-flash",
    api_key=os.getenv("deepseek-api"),
    base_url="https://api.deepseek.com",
)

# 按 session_id 保存多份历史,便于多用户/多会话;生产可改为 Redis 等
store = {}


def get_session_history(session_id: str):
    """
    根据 session_id 获取对应的历史消息对象。
    如果不存在则创建一个新的 InMemoryChatMessageHistory。
    """
    if session_id not in store:
        store[session_id] = InMemoryChatMessageHistory()
    return store[session_id]


# 定义 Prompt 模板
#     - system: 给模型设定角色
#     - MessagesPlaceholder: 历史消息将注入这里
#     - human: 当前用户输入
prompt = ChatPromptTemplate.from_messages(
    [
        ("system", "你是一个友好的中文助理,会根据上下文回答问题。"),
        MessagesPlaceholder("history"),
        ("human", "{question}"),
    ]
)
# 构建基本链:Prompt → LLM → 输出解析
memory_chain = prompt | llm | StrOutputParser()

# 包装为带历史链:get_session_history 决定「当前 session 用哪份 history」
with_history = RunnableWithMessageHistory(
    memory_chain,
    get_session_history,
    input_messages_key="question",
    history_messages_key="history",
)

cfg_user_001 = {"configurable": {"session_id": "user-001"}}
cfg_user_002 = {"configurable": {"session_id": "user-002"}}

print("用户A(user-001):我叫张三。")
print("AI:", with_history.invoke({"question": "我叫张三。"}, cfg_user_001))

print("\n用户B(user-002):我叫李四。")
print("AI:", with_history.invoke({"question": "我叫李四。"}, cfg_user_002))

print("\n用户A(user-001):我叫什么?")
print("AI:", with_history.invoke({"question": "我叫什么?"}, cfg_user_001))

print("\n用户B(user-002):我叫什么?")
print("AI:", with_history.invoke({"question": "我叫什么?"}, cfg_user_002))

# ---------- 查看当前存储了哪些历史数据 ----------
# store 的 key 为 session_id,value 为该会话的 InMemoryChatMessageHistory
# 每个 history 的 .messages 为 List[BaseMessage],即该会话至今的全部消息(HumanMessage、AIMessage 等)
print("\n--- 当前 store 中的历史数据 ---")
for sid, history in store.items():
    print(f"[session_id={sid}] 共 {len(history.messages)} 条消息:")
    for i, msg in enumerate(history.messages):
        # msg 有 .type(如 human/ai)、.content(文本内容)
        content = str(msg.content)
        content_preview = (content[:50] + "…") if len(content) > 50 else content
        print(f"  {i+1}. [{msg.type}] {content_preview}")
print("--- 以上 ---\n")

"""
【输出示例】
用户A(user-001):我叫张三。
AI: 你好,张三!很高兴认识你~😊  
有什么我可以帮你的吗?

用户B(user-002):我叫李四。
AI: 你好,李四!很高兴认识你~😊  
有什么我可以帮你的吗?

用户A(user-001):我叫什么?
AI: 你叫张三!😄  
之前你已经告诉过我啦~需要我帮你做点什么吗?

用户B(user-002):我叫什么?
AI: 你叫李四!😄  
之前你已经告诉过我啦~需要我帮你做点什么吗?

--- 当前 store 中的历史数据 ---
[session_id=user-001] 共 4 条消息:
  1. [human] 我叫张三。
  2. [ai] 你好,张三!很高兴认识你~😊  
有什么我可以帮你的吗?
  3. [human] 我叫什么?
  4. [ai] 你叫张三!😄  
之前你已经告诉过我啦~需要我帮你做点什么吗?
[session_id=user-002] 共 4 条消息:
  1. [human] 我叫李四。
  2. [ai] 你好,李四!很高兴认识你~😊  
有什么我可以帮你的吗?
  3. [human] 我叫什么?
  4. [ai] 你叫李四!😄  
之前你已经告诉过我啦~需要我帮你做点什么吗?
--- 以上 ---

"""

5.1.3 直接操作InMemoryChatMessageHistory

示例:

"""
直接使用InMemoryChatMessageHistory的API,add_message, messages,手动拼历史后调用模型
不通过RunnableWithMessageHistory,手动维护history
手动维护时要自己决定什么时候写消息

"""
from dotenv import load_dotenv

load_dotenv(encoding="utf-8")

import os
from langchain.chat_models import init_chat_model
from langchain_core.chat_history import InMemoryChatMessageHistory
from loguru import logger

# 构建模型
model = init_chat_model(
    model="deepseek-v4-flash",
    api_key=os.getenv("deepseek-api"),
    base_url="https://api.deepseek.com",
)

# 构建内存版历史实例
history = InMemoryChatMessageHistory()

# 手动添加用户消息并调用模型,模型输入为当前全部messages
history.add_user_message("我叫张三,我的爱好是学习")
ai_message = model.invoke(history.messages) # messages方法获取消息
logger.info(f"第一次回答\n{ai_message.content}")
# 手动将ai回复写回history,否则下一轮只看到用户消息,达不到多轮记忆的效果。
history.add_message(ai_message)

# 再追加一轮
history.add_user_message("我叫什么?我的爱好是什么")
ai_message2 = model.invoke(history.messages)
logger.info(f"第二次回答\n{ai_message2.content}")
history.add_message(ai_message2)


可以看见History中存储的消息,
在这里插入图片描述
通过上述手动管理,可以理解,记忆就是在维护一份消息历史列表。

想快速搭多轮链,优先用RunnableWithMessageHistory
想完全掌控读写时机,可以直接操作InMemoryChatMessageHistory

5.2 持久化:Redis存储

这里使用Redis是因为适合持久化保存会话历史的存储后端。

5.2.1 设计要求与参考文档

将历史从内存换为持久化存储,这样程序重启后还能恢复历史。

python环境中已经安装redis

5.2.2 Redis与Redis Stack简介

RedisChatMessageHistory存储对话历史依赖redis基础数据结构能力即可。

Redis是高性能键值存储本体,Redis Stack则是在Redis基础上补上更多增强能力并带来更友好的工具链

5.2.3

然后编写一个docker-compose.yml进行部署安装,

5.2.4 Redis基本命令与查看对话历史

LangChain会按session_id写入不同的键,

5.2.5 环境验证

检查python中的redis和电脑redis环境是否能够连上,
例如就只启动docker-redis这个原生redis,

"""
这里只使用原生redis
localhost:6379
"""
import os

try:
    import redis
except ModuleNotFoundError:
    print("未找到redis包,请先执行pip install -r requirements.txt")
    raise SystemExit(1)

REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379")

print("redis包导入成功")
print(f"redis包版本:{redis.__version__}")
print(f"正在连接redis:{REDIS_URL}")

# 检查redis服务是否连接成功
client = None

try:
    client = redis.Redis.from_url(REDIS_URL, decode_responses=True)
    print(f"redis连接成功,PING -> {client.ping()}")
except (redis.ConnectionError, redis.TimeoutError, redis.ResponseError) as e:
    print("redis连接失败")
    print(f"REDIS_URL = {REDIS_URL}")
    raise SystemExit(1)
except Exception as e:
    print(f"redis环境校验异常:{e}")
    raise SystemExit(1)
finally:
    if client is not None:
        client.close()


5.2.6 案例:Redis对话历史

上述验证环境成功,就可以使用RedisChatMessageHistory存储历史了。


"""
get_session_history应该是从RedisChatMessageHistory中获取消息的
"""
from dotenv import load_dotenv

load_dotenv(encoding="utf-8")

from langchain.chat_models import init_chat_model
from langchain_core.chat_history import BaseChatMessageHistory
# 控制什么时候写
from langchain_core.runnables.history import RunnableWithMessageHistory
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.runnables import RunnableConfig
import os
import redis
from loguru import logger

try:
    from langchain_redis import RedisChatMessageHistory
    USE_LANGCHAIN_REDIS = True
except ModuleNotFoundError:
    from langchain_community.chat_message_histories import RedisChatMessageHistory
    USE_LANGCHAIN_REDIS = True

# 支持环境变量Redis_URL,
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379")
FORCE_SAVE = os.getenv("REDIS_FORCE_SAVE", "0") == "1"

def _check_redis():
    """启动时检查redis是否可达"""
    try:
        r = redis.Redis.from_url(REDIS_URL, decode_responses=True)
        r.ping()
        r.close()
    except (redis.ConnectionError, redis.ResponseError) as e:
        logger.error(
            "Redis连接失败({}),请先启动redis,"
        )
        raise SystemExit(e)

_check_redis()

# 构建客户端
redis_client = redis.Redis.from_url(REDIS_URL, decode_responses=True)
logger.info(
    "Redis客户端实现"
)

llm = init_chat_model(
    model="deepseek-v4-flash",
    api_key=os.getenv("deepseek-api"),
    base_url="https://api.deepseek.com",
)

# 构建提示词
prompt = ChatPromptTemplate.from_messages(
    [
        MessagesPlaceholder("history"),
        ("human", "{question}"),
    ]
)


# 获取历史会话
def get_session_history(session_id: str) -> BaseChatMessageHistory:
    """为每个session_id创建/返回对应的redis历史实例,实现持久还存储"""
    if USE_LANGCHAIN_REDIS:
        return RedisChatMessageHistory(
            session_id=session_id,
            redis_url=REDIS_URL,
        )
    return RedisChatMessageHistory(
        session_id=session_id,
        url=REDIS_URL,
    )


# 构建链
chain = RunnableWithMessageHistory(
    prompt | llm,
    get_session_history,
    input_messages_key="question",
    history_messages_key="history",
)

# 配置
config = RunnableConfig(configurable={"session_id":  "user-001"})

print("开始对话(输入quit退出)")

while True:
    question = input("\n输入问题:")
    if question.lower() in ["quit", "exit", "q"]:
        break
    response = chain.invoke({"question": question}, config)

    logger.info(f"AI回答:{response.content}")

    if FORCE_SAVE:
        redis_client.save()


理解这里while循环持续对话并存入redis中这个实现。

进一步理解RunnableWithMessageHistory负责什么时候读写,RedisChatMessageHistory负责历史存到哪里

通过可视化redis数据库,可以看到里面存储的键值就是
chat:user-001:xxxx,值为json格式,
在这里插入图片描述

查看RedisChatMessageHistory中的add_message方法,可以看到存储的内容为:

common_data_to_store: Dict[str, Any] = {
            "type": message.type,
            "message_id": message_id,
            "data": {
                "content": message.content,
                "additional_kwargs": message.additional_kwargs,
                "type": message.type,
            },
            "session_id": self.session_id,
            "timestamp": timestamp,
        }

所以存储的值就包含了上述格式:

{
    "type": "human",
    "message_id": "01KXZBP6E6K5Q24V43GVMXX800",
    "data": {
        "content": "你好你是谁",
        "additional_kwargs": {},
        "type": "human"
    },
    "session_id": "user-001",
    "timestamp": 1784537618.886067
}

第二条消息则是:

{
    "type": "ai",
    "message_id": "01KXZBP6EFGEB8DEZPAB08JX99",
    "data": {
        "content": "你好呀!👋 很高兴认识你!\n\n我是 **DeepSeek**,由深度求索公司创造的AI助手。我是一个纯文本模型,可以帮你解答各种问题、处理文字任务、分析信息等等。\n\n**关于我的一些特点:**\n- 📚 知识截止日期:2025年5月\n- 🆓 **完全免费**使用(没错,不收费!)\n- 📄 支持上传文件(图片、PDF、Word、Excel、PPT等)来读取其中的文字信息\n- 🔍 支持联网搜索(需要你在Web/App端手动开启)\n- 💬 上下文长度1M,可以一次性处理超长文本(比如《三体》三部曲那么多内容)\n- 🎙️ App端支持语音输入\n\n虽然我不能识别图片内容(比如看图识物),但可以读取图片里的文字信息哦!\n\n有什么我可以帮你的吗?无论是学习、工作还是日常问题,尽管问我!😊",
        "additional_kwargs": {
            "refusal": null,
            "reasoning_content": "嗯,用户问了一个很常见的开场问题:“你好你是谁”。这是一个简单的自我介绍请求。\n\n用户可能刚接触我,想了解我的身份、能力和基本特点。深层需求是确认我的可靠性和功能范围,以便后续有效互动。\n\n我需要给出清晰、友好、全面的自我介绍。可以包括名称、创造者、核心能力(比如文本处理、文件支持、上下文长度)、知识截止日期、免费属性等关键信息,并以热情开放的态度结尾,邀请用户提出进一步问题。\n\n想到了用热情问候开头,然后分点或段落说明我的身份和主要功能,最后表达乐于助人的态度。"
        },
        "type": "ai"
    },
    "session_id": "user-001",
    "timestamp": 1784537618.895081
}

然后用户再发送一条,redis中再存一条,AI回复一条,AI再存一条,是这样的逻辑

session_id应该和用户,会话,业务租户等隔离策略一起设计,而不是随便写一个字符串

第17章 Tools工具调用

  • 建立模型负责决策,程序负责执行这条最核心的分工认知。
  • 使用@Tool装饰器定义langchain工具,配合pydantic写参数schema

模型负责判断要不要调用工具和填参数,程序负责真正执行工具并把结果交回去。

1 Tools简介

1.1 定义

工具,给大模型准备的一项外部能力,

本质上就是一个可调用函数,只不过把它包装成模型能理解的形式,让模型知道:

  • 这个工具叫什么
  • 这个工具是干什么的
  • 这个工具接收哪些参数

也就是模型负责决策,要调用工具则会先输出结构化意图,例如“我想调用某个工具,并附带参数”。然后程序负责执行,执行后将结果返回模型。

因此Tool不是模型自己突然学会调用外部系统,而是把外部能力以受控方式开放给模型使用。

1.2 Tools的作用

真正有业务价值的LLM应用,最后都会走到Tools,

  • 智能客服要查订单,查物流,查售后状态
  • 企业助手要查知识库,查数据库,查工单系统
  • 数据分析助手要跑SQL,读报表,调统计接口
  • 生活类助手要查天气,查地图,查航班,查日程

1.3 Tool, ToolCalling, Agent三者关系

  • Tool,一个被封装好的外部能力,负责做事
  • Tool Calling,模型输出结构化调用意图的机制,负责发起调用请求
  • Agent,决策

2 工具调用的工作方式

2.1 核心主线

结合泳道图加以理解
在这里插入图片描述
ToolCalling不是脱离消息机制另起炉灶,本质上仍然发生在消息流之中。

2.2 模型看到了什么

这是因为给模型传递了工具信息bind_tools(…),这些信息至少包括了:

  • 工具名称
  • 工具描述
  • 参数schema

因此模型不是靠读函数体理解工具。

2.3 程序主要做了什么

模型输出调用意图,程序才是执行工具。

这一步通常包括:

  • 读取模型返回的tool_calls
  • 找到对应工具
  • 取出参数
  • 执行代码逻辑
  • 把结果重新回填给模型

2.4 tool_calls, AIMessage, ToolMessage三者工具

AIMessage.tool_calls是模型请求
ToolMessage是程序的回执

如果多个工具调用,在ToolMessage还需要通过tool_call_id对应回前面的某一个tool_call,

2.5 什么时候不需要工具

需要实时数据、需要访问外部系统、需要高确定性执行,需要把模型输出落到真实动作上。

3 自定义Tool,从最简单的工具开始

3.1 使用@tool装饰器

装饰器的作用是在不改动函数核心逻辑的前提下,给函数额外加上一层框架可识别的能力。

因此这里@tool装饰器就是:

  • 把一个普通python函数包装成LangchainTool
  • 让其具备name, description, args等元信息
  • 让其能够被模型或agent识别并调用

最简单的创建工具方式,就是使用@tool装饰器,默认情况下,函数docstring会成为工具描述

3.2 基础案例:加法工具

示例:

"""
基础加法工具,使用@tool装饰器将普通函数转换为LangChain Tool
使用@tool,将函数包装为LangChain Tool,使其具备name, description, args等元信息
函数中的docstring会默认成为工具的description,再结合arg_schema补充说明

"""
from langchain_core.tools import tool

# @tool,不写参数,
    # 工具名默认为函数名
    # description取自docstring
@tool
def add_number(a: int, b: int) -> int:
    """两个整数相加"""
    return a + b

# tool.invoke执行工具
# invoke 执行字典参数
result = add_number.invoke({"a": 1, "b": 2})
print(result)

print()

# 查看元信息
print(f"{add_number.name=} \n {add_number.description=} \n {add_number.args=}")

直接使用工具名.invoke表示执行工具,

程序输出结果如下:

3

add_number.name='add_number' 
 add_number.description='两个整数相加' 
 add_number.args={'a': {'title': 'A', 'type': 'integer'}, 'b': {'title': 'B', 'type': 'integer'}}

tool会自动暴露名称,描述,参数结构。

3.3 Tool常用属性

常见属性:

  • name,工具名,模型要调用准,首先看这个名字
  • description,工具说明,模型判断什么时候该用这个工具的依据
  • args,参数结构,模型知道因该传什么参数,参数是什么类型
  • return_direct,是否直接把结果返回给用户

在这里插入图片描述

3.4 工具描述的作用

好的工具描述docstring,应该让模型看懂三件事:

  • 这个工具是干什么的
  • 什么时候应该调用
  • 关键参数应该怎么填

Tool描述不是给别人随便看看,本身就是模型决策的重要输入

3.5 真实项目里的工具层

真实项目里,往往是下面这些能力的轻量封装:

  • 调第三方API
  • 调内部服务
  • 查数据库
  • 跑检索
  • 执行某个稳定的业务动作

所以架构上常见组织方式为:

  • Tool层,暴露给模型的工具定义
  • Service层,真正的业务实现或API调用封装
  • Model层

实现了模型能力和业务解耦

4 参数Schema,为什么要配合Pydantic

4.1 为什么只写函数参数还不够

真实项目里,参数类型和格式不够清晰;参数校验不够严格

除了告诉模型有几个参数,还希望表达:

  • 这个参数具体是什么意思
  • 有没有取值范围
  • 是否允许为空
  • 参数传错时怎样更清晰地报错

因此就需要args_schema和pydantic

4.2 Pydantic

是python里非常常用的数据校验库,本质是把“参数结构、参数类型、字段说明、校验规则”统一收敛到一个模型类里。

这里使用pydantic,一是给程序看用作运行时校验,而是给模型看,把参数schem描述清楚

适合用在Tool参数定义里

4.3 入门案例

示例:

"""
理解Pandantic = 类型声明 + 自动校验  + 参数说明
下理解Pandantic
基于类型注解在实例化时做校验与转换,合法则自动转,不合法则抛ValidationError

"""

from pydantic import BaseModel, ValidationError, StrictInt

# 例如定义一个用户模型
class User(BaseModel):
    # id: int # 普通int时,传入"41"也会被自动转成41
    id: StrictInt  # 严格整数,不接受字符串等,必须已是int,否则报错
    name: str
    age: int = 0  # 可选字段,默认0,传入值会被校验并转换

try:
    # 合法示例
    u = User(id=42, name="z3")
except ValidationError as e:
    print(e)

print(u.id, type(u.id))

print()
print()

# 非法示例
try:
    User(id="123", name="z4")
except ValidationError as e:
    print(e)


输出如下:

42 <class 'int'>


1 validation error for User
id
  Input should be a valid integer [type=int_type, input_value='123', input_type=str]
    For further information visit https://errors.pydantic.dev/2.13/v/int_type

由上述示例可以看出,Pydantic会在实例化时做校验,非法输入就会报错,严格类型可以避免模糊转换,放到Tool参数上很有用,避免工具传入参数看起来对,实际上不对

4.4 加法工具的Pydantic

"""
工具结合Pydantic,用args_schema绑定参数模型,让模型看到规范参数说明

arg_schema的价值:除了参数校验,还有能把工具参数的语义说明显式暴露给模型,提升参数生成正确率
tool.args,主要反应参数schema,工具的整体用户说明仍应优先写在函数docstring中
return_direct等参数更偏Agent场景
"""

from langchain_core.tools import tool
from loguru import logger
from pydantic import BaseModel, Field

# 定义Pydantic模型 字段description会进入工具参数schema
class FieldInfo(BaseModel):
    """定义加法运算所需的参数结构"""
    a: int = Field(description="第1个参数")
    b: int = Field(description="第2个参数")


# 利用args_schema将模型绑定到工具,模型会更清楚看到a,b的类型与说明
@tool(args_schema=FieldInfo)
def add_number(a: int, b: int) -> int:
    """计算两个整数之和"""
    return a + b

# 打印工具属性,带有args_schema,时,args中会包含Field的description
logger.info(f"name = {add_number.name}")
logger.info(f"args = {add_number.args}")
logger.info(f"description = {add_number.description}")
logger.info(f"return_direct = {add_number.return_direct}")

# 调用工具,传入字典,pydantic会做类型校验与转换
res = add_number.invoke({"a": 1, "b": 2})
logger.info(res)

可以看见数据模型中的description字段也放在工具参数说明中,
在这里插入图片描述
上述示例用到的:

  • 用BaseModel定义参数结构
  • 用Field(description= )给参数写说明
  • 用@tool(args_schema= )把参数模型绑定给工具

4.5 args_schema的实践价值

真实项目中养成写args_schema的习惯。因为能够:更稳定、更安全、更可维护。

与后端写DTO,写请求参数对象,是同一种工程思想,这样是为了让模型更容易调用对,代码更容易排错,团队更容易协作。

5 天气助手实战:把Tool跑成业务闭环

5.1 需求与准备

真正接近业务项目的是:模型 + 工具 + 外部API的组合。

现实里的Tool往往都不是本地纯函数,更像:

调第三方接口
接收JSON
做必要加工
回到消息流或链路中继续生成最终答案

这里申请一个openweather api key,并将key写入.env文件中,
OPENWEATHER_API_KEY= xxx。

5.2 定义天气查询工具

"""
使用@tool定义天气接口工具,请求openweather api并返回json

理解运用把第三方http api封装为一个可被模型理解和调用的tool
工具docstring要写清调用场景和关键参数规则
返回值使用json,是为了方便后续链路进行处理,
真实项目里也可以返回结构化对象
该示例聚焦tool封装,未展开重试,降级,异常兜底等工程细节,这些通常会在真实项目里的service/ client层补齐
"""

from langchain_core.tools import tool
import json
import os
import httpx
from dotenv import load_dotenv
import requests

load_dotenv(encoding="utf-8")

# 函数名作为工具名,docstring会成为模型理解工具的重要依据
@tool
def get_weather(loc: str) -> str:
    """
    查询指定城市的即时天气。

    参数:
        loc:城市名称字符串。为了提高调用成功率,建议优先传英文城市名。例如Beijing, Shanghai

    返回:
        Openweather 当前天气返回的JSON字符串,包含气温、体感温度、湿度、风速、天气描述等信息。
    """
    # url = "http://api.openweathermap.org/data/2.5/weather"
    #
    # # 设置查询参数
    # params = {
    #     "q": loc,
    #     "appid": os.getenv(
    #         "OPENWEATHER_API_KEY",
    #     ),
    #     "units": "metric", # 温度单位metric=摄氏度
    #     "lang": "zh_cn", # 天气语言描述,简体中文
    # }

    # 发送请求
    # response = httpx.get(url, params=params, timeout=30)

    # 换成另一个天气查询接口
    url = "https://uapis.cn/api/v1/misc/weather"

    params = {
        "city": "天津"
    }

    response = requests.get(url, params=params, timeout=10)
    # 解析转换
    data = response.json()
    return json.dumps(data)


# 本地测试
result = get_weather.invoke("tianjin")
print(result)

可以看出来,模型不会帮你发http请求,真正的请求逻辑仍然是工具里写好的。

Tool的意义不是替代业务实现,而是把业务实现包装成模型可调用的接口。

所以说把业务能力封装成一个可复用函数,用tool方式暴露给模型,让模型只决定何时调、怎么调。

5.3 模型绑定工具后,到底会发生什么

tool如何交给模型,通过bind_tools()实现,不是现在就执行工具,而是声明工具给模型。

只有先把工具绑定给模型,后续模型调用时才有机会返回tool_calls。

在这里插入图片描述

5.4 案例:天气助手完整链路

示例:

"""
定义好工具之后将工具绑定给模型,
模型发起请求-》解析tool_calls -》执行工具 -》结果回填 -》模型生成自然语言回复


"""

from dotenv import load_dotenv
from langchain.chat_models import init_chat_model

load_dotenv(encoding="utf-8")

import os
from langchain_core.tools import tool
from langchain_core.prompts import PromptTemplate
from langchain_core.output_parsers import JsonOutputKeyToolsParser, StrOutputParser
from langchain_openai import ChatOpenAI
from loguru import logger
from QueryWeatherTool import get_weather

# 初始化大模型
model = init_chat_model(
    model="deepseek-v4-flash",
    api_key=os.getenv("deepseek-api"),
    base_url="https://api.deepseek.com",
)

model_with_tools = model.bind_tools([get_weather])

# 解析器,从模型输出中提取命中的天气工具参数,得到可直接传给get_weather的入参
parser = JsonOutputKeyToolsParser(key_name=get_weather.name, first_tool_only=True)

# 天气查询链:用户问题-》模型,可能返回tool_calls -》解析出参数 -》执行get_weather -》得到天气Json字符串
get_weather_chain = model_with_tools | parser | get_weather

# 然后又将工具输出塞进提示词,让模型转换为自然语言描述

output_prompt = PromptTemplate.from_template(
    """你将收到一段 JSON 格式的天气数据{weather_json},请用简洁自然的方式将其转述给用户。
    以下是天气 JSON 数据:
    请将其转换为中文天气描述,例如:
    "北京现在天气:多云,气温 28℃,体感有点闷热(约 32℃),湿度 75%,微风(东南风 2 米/秒),
    能见度很好,大约 10 公里。建议穿短袖短裤。适合做户外运动。"
    """
)

output_parser = StrOutputParser()
output_chain = output_prompt | model | output_parser

full_chain = get_weather_chain | (lambda x : {"weather_json": x}) | output_chain

result = full_chain.invoke("请问北京今天的天气如何?")
logger.info(result)


输出为:

{"province": "\u5929\u6d25\u5e02", "city": "\u5929\u6d25", "adcode": "120000", "weather": "\u591a\u4e91", "weather_icon": "101", "temperature": 29, "wind_direction": "\u897f\u5357\u98ce", "wind_power": "3\u7ea7", "humidity": 55, "report_time": "9 \u5206\u949f\u524d\u53d1\u5e03"}
2026-07-17 15:09:11.626 | INFO     | __main__:<module>:53 - 北京现在天气:晴,气温 29℃,湿度 56%,南风 4 级。建议穿短袖短裤,适合户外活动。

其中JsonOutputKeyToolsParser用于解析模型输出中的工具调用请求中的参数,解析为可执行的python结构。

分成了两段:
前半段:用户问题-》模型判断-》解析工具参数-》调工具-》得到json
后半段:把工具json交给模型-》生成更自然的中文天气描述

5.5 课程案例写法与官方主线的关系

官方主线中,常见的讲解是:

模型返回AIMessage.tool_calls
程序执行工具
结果包装成ToolMessage
再把这组消息送回模型

这两种写法本质一样,模型发起工具调用意图-》程序执行工具-》结果回到模型上下文里

修改为官方讲法:

"""
官方主线写法

"""

from dotenv import load_dotenv
from langchain.chat_models import init_chat_model
load_dotenv(encoding="utf-8")

import os
from langchain_core.tools import tool
from langchain_core.messages import ToolMessage
from loguru import logger
from QueryWeatherTool import get_weather

# 初始化模型
model = init_chat_model(
    model="deepseek-v4-flash",
    api_key=os.getenv("deepseek-api"),
    base_url="https://api.deepseek.com",
)

# 模型绑定工具
model_with_tools = model.bind_tools([get_weather])

def execute_tool_calls(tool_calls):
    """执行工具调用,返回toolmessage列表"""
    tool_messages = []
    for tool_call in tool_calls:
        # tool_call是一个字典,包含name, args, id
        tool_name = tool_call["name"]
        tool_args = tool_call["args"]
        tool_call_id = tool_call["id"]

        # 根据工具名执行对应工具
        if tool_name == get_weather.name:
            result = get_weather.invoke(tool_args)
        else:
            result = f"未知工具: {tool_name}"

        # 包装为tool_message
        tool_messages.append(
            ToolMessage(
                content=str(result),
                tool_call_id=tool_call_id
            )
        )

    return tool_messages

# 主流程
def chat_with_tools(user_query):
    """
    完整的工具调用流程
    1 用户提问,模型生成,可能包含tool_calls
    2 如果有tool_calls,执行工具,生成ToolMessage
    3 将ToolMessage回填给模型
    4 模型生成最终自然语言回复
    """
    messages = [{"role": "user", "content": user_query}]

    # 第一步模型发起请求
    response = model_with_tools.invoke(messages)

    # 检查是否有工具调用意图
    if hasattr(response, "tool_calls") and response.tool_calls:
        logger.info(f"检测到工具调用:{response.tool_calls}")
        # 第二步,执行工具
        tool_messages = execute_tool_calls(response.tool_calls)

        # 第三步,将工具结果回填到消息上下文
        messages.append(response)
        messages.extend(tool_messages)

        # 第四步,将完整上下文再次传给模型,生成自然语言回复
        final_response = model.invoke(messages)

        return final_response.content
    else:
        return response.content

if __name__ == "__main__":
    result = chat_with_tools("请问上海今天的天气如何?")
    logger.info(f"最终回复: {result}")

输出如下:

{"province": "\u5929\u6d25\u5e02", "city": "\u5929\u6d25", "adcode": "120000", "weather": "\u591a\u4e91", "weather_icon": "101", "temperature": 30, "wind_direction": "\u5357\u98ce", "wind_power": "2\u7ea7", "humidity": 49, "report_time": "6 \u5206\u949f\u524d\u53d1\u5e03"}
2026-07-17 15:52:44.669 | INFO     | __main__:chat_with_tools:67 - 检测到工具调用:[{'name': 'get_weather', 'args': {'loc': 'Shanghai'}, 'id': 'call_00_iImHk2UhIa8K71MdjNNh8513', 'type': 'tool_call'}]
2026-07-17 15:52:46.808 | INFO     | __main__:<module>:84 - 最终回复: 根据最新气象数据,上海今天(目前)的天气情况如下:

- **天气状况**:多云 ☁️
- **气温**:**37°C**(非常热,需要注意防暑)
- **风向风力**:西南风,2级(微风)
- **湿度**:42%(相对干燥)

**温馨提示**:
- 今天气温较高,紫外线较强,建议减少户外活动时间,注意**防暑降温**,多补充水分。
- 如果外出,请做好防晒措施(帽子、太阳镜、防晒霜),并携带防暑药品。

希望这些信息对您有帮助!

6 从课程案例走向真实项目

6.1 Tool在项目里通常怎么落位

一种比较常见,也比较适合团队协作的组织方式是:

  • tool.py/tools,定义给模型看的工具入口
  • service.py/ services/,写真实业务逻辑
  • client.py/ adapters/,封装第三方API或内部接口访问
  • prompt / chain / agent层,决定什么时候调用工具

6.2 设计Tool时,最值得重视的工程原则

职责单一、输入明确、输出稳定、异常可控、幂等与副作用隔离、超时/重试/限流、权限受控、可观测

后续mcp解决的是如何用标准协议把外部能力开放给模型,可以看作是更通用、更标准化的工具接入方式

第18章 向量数据库与Embedding实战

1 向量与向量化

1.1 向量的定义

向量表示了什么,用一组有顺序的数字表示了某个对象在空间中的位置或特征

例如二维向量、三维向量、高维向量。向量可以表示对象的特征。

1.2 向量化的定义

向量化,嵌入。
LangChain中的文本Embedding:Embedding模型会把句子、段落等原始文本转换成固定长度的数字向量,并尽量让语义相近的文本在向量空间里靠的更近。

Embedding常被用在:用于搜索、推荐系统、文本聚类、去重与相似内容识别、RAG检索增强生成

1.3 语义相似与向量相近

嵌入是为了使得语义关系映射成空间关系。语义相近的向量通常也会更近。

1.4 向量维度的定义

不同Embedding模型输出维度可能不同。
即时维度相同,不同模型的向量空间通常也不能直接混用。

因此保证建库时用的Embedding模型,和查询时用的Embedding模型保持一致。

1.5 进一步建立直觉

高维Embedding可理解为更多维度的特征轴,维度与模型设计相关,检索结果依赖模型与索引质量,而非单看维数高低。

2 向量数据库

2.1 定义

可以把向量数据库理解为一种专门面向相似度检索的存储系统。

2.2 与传统数据库的定义

向量检索侧重语义相似,可以相似性搜索,最近邻检索。

2.3 向量数据库里存什么

向量数据库里通常包含了原始内容,向量值,元数据,索引结构。

这个对RAG非常关键,因为检索阶段不仅要拿到最像的向量,还要拿回对应的文本片段和元数据,最后才能交给大模型生成答案。

2.4 稠密向量、稀疏向量、标量字段定义

一个可检索的数据对象,往往同时包含稠密向量、标量字段,某些系统里还会额外使用稀疏向量。

标量字段Scalar Fields,例如source, author, category, created_at, doc_id这类普通字段,不参与语义向量计算,但常用于过滤,排序,权限控制等

以 BGE-M3 这类 Embedding 模型为例,它可以同时支持稠密向量、稀疏向量等多种表示方式。放到检索系统里,就可以形成这样的搭配:

  • 稠密向量:用户换一种说法提问时,仍然能按语义找到相关内容
  • 稀疏向量:产品型号、错误码、专有名词、精确关键词匹配
  • 标量字段:权限、分类、时间、来源、业务标签过滤

所以生产 RAG 里经常会看到“稠密检索 + 稀疏检索 + metadata filter + rerank”的组合。它不是为了把系统做复杂,而是因为单一路径很难同时兼顾语义、关键词和业务约束。

2.5 常见的向量数据库分类

FAISS, Chroma, Milvus, Pgvector, Redis/Redis Stack, ElasticSearch / OpenSearch

2.6 RAG与向量数据库的关系

RAG的原理就不用多说了。

2.7 Redis与Milvus怎么选

把Milvus作为生产级扩展方向

3 用Redis Stack作为向量存储

Redis Stack在Redis基础上集成了搜索与向量检索能力。

4 Embedding文本向量化

把文本变成向量

4.1 定义

距离越小相似度越高,相关性越高;距离越大,相关性越低;

Embeddings的常见应用:

  • 搜索
  • 聚类
  • 推荐
  • 异常检测
  • 多样性测量
  • 分类

LangChain官方进一步强调了两个API:

  • embed_query(text),将单条查询文本转成向量
  • embed_documents(texts),把多条文档文本批量转成向量

这一组正好对应真实项目里的两个阶段:

  • 索引阶段:把文档片段批量向量化,通常用embed_documents
  • 查询阶段:把用户问题向量化,通常用embed_query

4.2 重要实践规则

经验:

  • Embedding模型输出的是向量,不是自然语言
  • 不同Embedding模型的维度可能不同
  • 建索引和查询必须使用同一套Embedding模型
  • Embedding适合做语义相似度计算,但本身不负责回答问题
  • 本章主线是文本Embedding

4.3 案例:DashScope原生调用,先看到向量长什么样

这里就是演示一下文本转成向量是什么样子的。

4.4 案例:OpenAI兼容写法,理解同一能力,不同接法

很多平台有OpenAI兼容接口,使用OpenAI SDK的调用方式,

因此学习通用接入模式

"""
兼容OpenAI格式

"""

import os
from openai import OpenAI
from dotenv import load_dotenv

load_dotenv()

input_text = "衣服的质量杠杠的"

# 使用 OpenAI 兼容接口连接阿里百炼:调用方式仍是 OpenAI SDK,只是连接地址改成百炼的兼容网关
client = OpenAI(
    api_key=os.getenv("aliQwen-api"),
    base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
)

# 与 OpenAI Embedding 调用方式一致:model 为百炼模型名,input 为待向量化的文本
completion = client.embeddings.create(model="text-embedding-v4", input=input_text)

print(completion.model_dump_json())

4.5 使用LangChain接口做单条与批量向量化

前面的是原生使用OpenAI格式的,现在使用langchain框架封装的,
例如使用 :
embed_query(text)和embed_documents(texts),分别是单条查询文本向量化,多条文档文本批量向量化;

代码示例:

"""
embed_query(text)更偏查询阶段,常用于把用户问题转成向量
embed_documents(texts)更偏索引阶段,常用于把文档片段批量转成向量

返回值分别是单个向量和向量列表,
"""

import os
from langchain_community.embeddings import DashScopeEmbeddings
from dotenv import load_dotenv

load_dotenv()


# 使用项目统一的aliQwen-api,
embeddings = DashScopeEmbeddings(
    model="text-embedding-v4",
    dashscope_api_key=os.getenv("aliQwen-api"),
)

text = "This is a test document"

# 单条文本 -》 一个向量
query_result = embeddings.embed_query(text)

print("文本向量长度:", len(query_result), sep="")

# 多条文本 -》 多个向量
doc_results = embeddings.embed_documents(
    [
        "Hi there!",
        "Oh, hello!",
        "What's your name?",
        "My friends call me World",

    ]
)

print(doc_results)


print(
    "文本向量数量:", len(doc_results), ", 文本向量长度: ", len(doc_results[0]), sep= ""
)

注意的是单条返回的一个向量,批量返回的是向量列表。

4.6 进阶扩展,多模态Embedding

例如dashscope.MultiModalEmbedding.call()

5 通过向量计算语义相似度

得到向量之后就是比较两段文本在语义上接近程度

5.1 常用余弦相似度

余弦相似度的方式是点积 / 模,结果通常在[-1, 1]范围内,

  • 越接近1,表明越相似
  • 越接近0,表明相关性较弱
  • 越接近-1,表明方向相反

5.2 检索里常见的距离度量

  • COSINE,更关注向量方向是否接近,是文本语义检索里最常见,也最容易理解的一类度量
  • L2,欧式距离,关注两个点在空间里的直线距离,距离越小,通常表示越接近
  • IP,Inner Product,内积,

要确保Embedding模型的特性,索引建立时选择的度量方式,查询时传入的metric,三者必须一致

5.3 精确检索、近似检索与索引

不是所有相似检索,都会老老实实把查询向量和库里每个向量逐一比较。

  • 精确检索Exact KNN,FLAT, 把查询向量和库里所有向量都算一遍
  • 相似检索ANN, Approximate Nearest Neighbor,通过索引结构加速搜索,只近似的找到最可能接近的一批结果

HNSW,工程里常见的ANN索引,通常能在召回率和延迟之间取得较好平衡。

理解索引:索引的作用不是改变语义相似的定义,而是让找最相似内容这件事在大规模数据下也能跑得动。

5.4 案例:把多句话转成向量,再两两比较

使用numpy计算余弦相似度,两两比较结果。

可以看到如下示例:

"""
拿到向量了进行数学计算,计算相似度
cos(theta) = (A . B) / (|A || B|)
"""
import dashscope
import os
from http import HTTPStatus
import numpy as np
from dotenv import load_dotenv
from langchain_community.embeddings import DashScopeEmbeddings
load_dotenv()

# 准备多句文本,用于观察语义越相近,相似度通常越高
texts = ["我喜欢吃苹果", "苹果是我最喜欢吃的水果", "我喜欢用苹果手机"]

embeddings = []

# 针对每个文本进行向量化
embedding_model = DashScopeEmbeddings(
    model="text-embedding-v4",
    dashscope_api_key=os.getenv("aliQwen-api")
)

embeddings = embedding_model.embed_documents(texts)

print(len(embeddings))

# 计算两个向量的相似度
def cosine_similarity(vec1, vec2):
    """计算两个向量的余弦相似度"""
    dot_product = np.dot(vec1, vec2)
    norm_vec1 = np.linalg.norm(vec1)
    norm_vec2 = np.linalg.norm(vec2)
    return dot_product / (norm_vec1 * norm_vec2)


print("文本相似度比较结果:")
print("=" * 60)

for i in range(len(texts)):
    for j in range(i + 1, len(texts)):
        similarity = cosine_similarity(embeddings[i], embeddings[j])
        print(f"文本{i+1} vs 文本{j+1}: ")
        print(f"  文本{i+1}: {texts[i]}")
        print(f"  文本{j+1}: {texts[j]}")
        print(f"  余弦相似度: {similarity:.4f}")
        print("-" * 40)


输出内容为:

3
文本相似度比较结果:
============================================================
文本1 vs 文本2: 
  文本1: 我喜欢吃苹果
  文本2: 苹果是我最喜欢吃的水果
  余弦相似度: 0.8393
----------------------------------------
文本1 vs 文本3: 
  文本1: 我喜欢吃苹果
  文本3: 我喜欢用苹果手机
  余弦相似度: 0.7158
----------------------------------------
文本2 vs 文本3: 
  文本2: 苹果是我最喜欢吃的水果
  文本3: 我喜欢用苹果手机
  余弦相似度: 0.6866
----------------------------------------

说明了拿到向量如何做相似度计算,当两个主题更接近时,相似度通常会更高

5.5 相似度结果在项目里怎么用

相似度结果可用于:

  • 语义检索排序
  • 文本去重
  • 推荐
  • 聚类分析

6 向量库的写入与检索

RAG的底层能力,

需要把文本和向量写入Redis,然后按相似度做检索

这一步先建索引,做相似度检索

6.1 案例:把Document写入Redis,再用检索器取回结果

这里是假设准备好了若干document,然后用dashscopeEmbeddings把page_content向量化
然后使用Redis.from_documents写入redis,
通过as_retriever()生成检索器,对查询文本做相似度检索

"""

redis_url 和 index_name要与本地环境一致,如果要复用已有索引,查询端也必须使用同一个index_name

"""

import os
from langchain_community.embeddings import DashScopeEmbeddings
from langchain_community.vectorstores import Redis
from langchain_core.documents import Document
from dotenv import load_dotenv

load_dotenv()

# 初始化嵌入模型
embeddings = DashScopeEmbeddings(
    model="text-embedding-v3",
    dashscope_api_key=os.getenv("aliQwen-api")
)

# 构造Document列表,page_content是正文,metadata是附加信息
texts = [
    "通义千问是阿里巴巴研发的大语言模型",
    "Redis是一个高性能的键值存储系统",
    "LangChain可以轻松集成各种大模型和向量数据库"
]

documents = [
    Document(page_content=text, metadata={"source": "manual"}) for text in texts
]

# 一次性写入redis,内部会对每个Document的page_content向量化,并建立可检索索引
vector_store = Redis.from_documents(
    documents=documents,
    embedding=embeddings,
    redis_url="redis://localhost:26379",
    index_name="my_index11",
)

# 检索器,当invoke时,langchain会先把问题向量化,再在库中相似度检索
retriever = vector_store.as_retriever(search_kwargs={"k": 2})
results = retriever.invoke("Langchain和redis怎么结合")
for res in results:
    print(res.page_content)

输出为:

LangChain可以轻松集成各种大模型和向量数据库
Redis是一个高性能的键值存储系统

可以理解向量库不是孤零零的数字而是和Document结构结合起来的内容,检索出来的也不是向量本身,而是对应的Document,这正是RAG后面能把检索结果塞回Prompt的基础。

然后可以在redisinsight里看到如下结构:localhost:8001
在这里插入图片描述
可以看到每条内容里存储了content, source元数据, content_vector向量

也就是说redis在这里保存的是一份可检索的语义索引,而不是只存一组浮点数。

6.2 案例:使用langchain_redis的RedisVectorStore写入文本

还可以用更贴近Redis集成包的写法:

  • RedisConfig
  • RedisVectorStore
  • add_texts()

用于理解先创建一个向量库实例,再持续往里面追加文本

"""
示例:先创建RedisVectorStore,再通过add_texts()把字符串列表写入向量库
add_texts()实际上会在内部调用embed_documents,然后再把文本,向量,metadata写入redis

"""
from langchain_redis import RedisConfig, RedisVectorStore
from langchain_community.embeddings import DashScopeEmbeddings
import os
from dotenv import load_dotenv

load_dotenv()

# 初始化嵌入模型
embeddings = DashScopeEmbeddings(
    model="text-embedding-v3",
    dashscope_api_key=os.getenv("aliQwen-api")
)

# 待写入的文本
texts = [
    "我喜欢吃苹果",
    "苹果是我最喜欢吃的水果",
    "我喜欢用苹果手机"
]

# 定义每条文本对应的元数据信息,真实RAG中这些metadata来自Document.metadata,
metadata = [{"segment_id": str(i)} for i in range(1, len(texts) + 1)]

# Redis连接与索引名
config = RedisConfig(
    index_name="newsgroups",
    redis_url="redis://localhost:26379"
)

# 创建redis向量库实例
vector_store = RedisVectorStore(embeddings, config=config)

# 将文本与元数据写入向量库
ids = vector_store.add_texts(texts, metadata)

print(ids[0:5])


可以看见里面的元数据存的就是segment_id,

在这里插入图片描述

6.3 案例:连接已有索引,做相似性检索

从redis库中读取数据,进行检索相似的,

核心动作:

  • 连接已有的index_name
  • 查询文本向量化
  • 在redis中找最相近的若干条文本
  • 返回(Document, score)结果

6.4 元数据过滤,混合检索与重排序

不能简单理解检索就是把查询转成向量然后直接搜索top-k,这是主线,但是在真实项目里,还会包含:

  • 元数据过滤,缩小候选范围,再做向量检索
  • 混合检索:同时结合语义向量检索和关键词检索,或者结合不同类型的召回路径
  • 重排序:先召回一批候选结果,再用更精细的模型或策略重新排序

6.5 from_documents 和 add_texts怎么理解

from_documents适合手里已经有一批Document对象,想一次性建库,更像一步到位建索引

add_texts,是有字符串列表,或者想持续追加数据,是增量写入

6.6 本章与第19章的关系

第19章则会用加载器把PDF/ Word / Markdown等文档读成Document,

用分割器把长文档切成片段;

把检索结果和用户问题交给大模型生成答案

6.7 生产级扩展方向

上面主要是了解了Redis向量检索,后续可以提升方向:

  • 数据线:增量写,删除更新,索引重建,版本管理
  • 检索线:元数据过滤,混合检索,rerank,阈值策略
  • 平台线:从轻量的Redis, Chroma过渡到Milvus, OpenSearch等更专业的检索平台

相似度高是否代表答案可用?

参考:不一定,相似度只能说明语义接近,可能仍然答非所问,缺少关键条件或不是最新资料。真实RAG还要结合阈值、过滤、Rerank和答案生成约束。

如果查询结果总是不想管,会先排查哪些环节?

参考:查文档是否写入,Embedding模型是否一致,向量维度是否匹配,查询文本是否合理,metadata过滤是否过严,相似度计算和topk是否设置得当

第19章 RAG检索增强生成

从概念理解落地到代码实践

掌握LangChain中构建RAG最常见的几类组件:文档加载器、文本分割器、嵌入模型、向量数据库、检索器、提示词模板与聊天模型

1 RAG简介

1.1 定义

1.2 RAG的作用

主要是解决知识冻结、私有知识缺失、最新信息不可、回答缺少依据这类问题。

代码是哪些环节决定了回答质量:

  • 文档是否被正确加载
  • 文本是否被合理切块
  • Embedding是否稳定
  • 检索是否召回了真正相关的片段
  • Prompt是否把上下文用对了

RAG使用不是没有代价,可能会带来:

  • 响应时延更高
  • Token消耗更高
  • 效果依赖链路质量

1.3 RAG的标准流程

这里以LangChain代码的视角过一遍RAG的流程。

LangChain官网也把RAG拆成两大阶段:索引,检索与生成

离线阶段进行索引, 在线阶段检索生成。

在这里插入图片描述

1.3.1 索引阶段:先把知识库准备好

索引阶段面对的是原始文档,例如word, pdf, md, txt,等文件,目标不是回答问题,而是把这些文档处理成未来方便检索的心态。

通常包括:

  • 加载:把原始文件读成LangChain的Document对象
  • 分割:切分文档
  • 向量化:Embed,转成向量
  • 存储

容易忽略的是:

  • 索引通常是离线做的,不一定跟用户问答发生在同一时刻
  • 索引不只是存文本

理解metadata:是和文档片段绑定的附加信息,例如文件路径source,页码page,标题,作者,日期,分类等

真正参与向量化的是正文page_content,metadata更多用于来源展示,过滤条件,结果解释。

1.3.2 检索与生成阶段:每次提问时动态查资料
1.3.3 管道式RAG与Agent式RAG

本章是介绍的管道式RAG,先检索再生成,流程由代码固定,而非由模型临时决策。

如果要让模型自己决定要不要检索,什么时候检索,检索几次,就更接近Agent式RAG

1.3.4 一个更贴近生产环境的增强版流程

很多 RAG 系统会在“检索与生成”之间再补几步

  • 先召回候选片段,检索不一定只有向量检索,也可能是关键词,混合检索
  • 再做过滤或重排,按照metadata过滤返回,或用reranker对候选片段重新打分
  • 最后再进Prompt
  • 生成后保留来源
  • 持续做评测与观测:离线看召回命中率,答案质量,线上看检索链路和生成链路谁在调链子

2 RAG文本处理核心知识

2.1 LangChain组件与标准流程

第20章 MCP模型上下文协议

MCP的核心是把外部能力接入模型应用时变得更标准。
暴露能力:Tools / Resources / Prompts
通信角色:Host / Client / Server
传输方式: stdio / Streamable Http

1 为什么需要MCP

1.1 真实项目里的接入痛点

理解不同AI应用怎样用统一方式接入外部工具和上下文

没有MCP的时候:

  • 每个AI应用都要重复接一遍外部系统
  • 每个框架和宿主都有自己的接法
  • 工具很难复用成生态能力

所以会有大家都在写工具,但是缺少一套跨应用、跨框架、跨宿主都能复用的统一连接标准。

1.2 MCP的核心问题

MCP解决的问题是:让外部工具、资源、提示词模板等能力,能够按统一协议被不同AI应用发现和使用

如果一个AI应用需要多个工具能力,不是自己编写,而是支持MCP,就可以统一接入这些服务。

MCP的价值:一次暴露,多处复用,统一schema,降低适配成本,更容易形成工具生态。

1.3 直观类比:AI世界的统一插口

理解成AI世界的USB-C,也像大模型版的OpenFeign / gRPC协议层,本质上承担的是AI应用和外部能力之间的通用适配层

2 MCP简介

2.1 定义

MCP是一套开放的标准协议,用于规范AI应用 / Agent / IDE / 聊天客户端 如何与外部工具,资源和上下文提供方交互。

MCP的关键词是标准协议、统一接入、跨宿主复用。

2.2 和Tool, RAG, Agent有什么区别

3 MCP能做什么

3.1 统一接入与抽象

最直观的价值:把原本分散的外部能力,用统一方式暴露给AI应用。

所以MCP不是替代Tool,而是在Tool之上再向上抽象了一层协议层。

3.2 MCP服务器通常能暴露什么

MCP服务器最核心的三类能力是:

  • Tools,工具,模型可触发,
  • Resources,资源,可读取内容,例如文件、配置、数据库等,应用或宿主决定如何使用
  • Prompts,可复用的提示词模板或工作流模板,用户可显式选择

所以工具只是MCP的一部分, 不是全部。

3.3 容易忽略的另外几类能力

例如:

  • Sampling,服务器通过客户端向宿主侧的LLM请求一次生成
  • Elictication,服务器通过客户端向用户请求补充信息
  • Loggin:服务器向客户端发送结构化日志
  • Progress / Notifications:长任务过程中的进度和通知。

这里本次先掌握:

  • Tool如何暴露
  • 服务器和客户端怎么连
  • LangChain / Agent如何拿到MCP工具

3.4 在实际项目的常见用途

  • 给现成AI应用接能力
  • 给自研Agent平台做统一工具接入层
  • 让企业内部能力变成可复用的AI接口层:例如把查工单、查订单、查配置、发通知这些能力封装层MCP服务,供多个AI应用共享

安全与信任边界:MCP Server往往能以较高权限访问本机文件、内网API或密钥,生产环境应控制来源可信,仅安装审计过的服务,最小权限与网络隔离,并记录调用审计。

4 怎么用MCP

4.1 直接使用现成的MCP服务

很多能力无需一开始就写服务端,先学会怎么接,怎么配,怎么调试也很有必要。

4.2 本地自建MCP客户端

如果要接本地文件系统,内部数据库,企业私有API,自己的业务系统,就通常要自己写MCP Server,在这个过程中理解一个MCP服务是如何被暴露、被发现、再被Agent使用的。

4.2.1 什么是FastMCP

FastMCP是MCP官网Python生态里用来快速编写MCP Server的高层封装。可以更接近写Python函数的方式去暴露MCP能力。

FastMCP是Python里的服务端开发工具,解决怎么更方便的把能力按MCP标准暴露出去。

MCP是规则,FastMCP是实现这些规则的一种工具,可以更轻松的写出一个MCP Server

4.2.2 为什么讲FastMCP

自己实现一个MCP服务端,通常两条路线:

  • 自己直接按SDK 、协议细节去实现
  • 借助FastMCP这种更高层的封装来实现

4.3 在LangChain / Agent里使用MCP

LangChain官方提供了对MCP的适配支持,

  • 用MultiServerMCPClient连接一台或多台MCP服务器
  • 通过get_tools()取回MCP工具
  • 把这些工具交给create_tool_calling_agent或create_agent
  • 让Agent在对话中实际调用

MCP负责接入,LangChain则是将接进来的能力用起来。

5 MCP架构知识

5.1 主机、客户端、服务器定义

MCP采用典型的Host - Client - Server架构
在这里插入图片描述

Host是主机,Client是MCP客户端,用于将Host和某几个MCP Server建立协议连接的组件,Server则是对外暴露能力的服务,本地、远程资源则是服务器可访问的文件、数据库、API等

Host就是自己本身在用的应用,需要连接Server的应用 ,通过Client连接。所以一个Host可以连多台Server,一个具体Client通常对应一条到某台Server的直接连接。

5.2 MCP协议层面大致什么工作

MCP在协议层有自己的约定,

  • Host/ Client发起连接
  • Client和Server做初始化
  • 双方声明各自支持的capabilities
  • 客户端发现服务器提供的tools/ resources / prompts
  • 按需调用或读取
  • 结果返回给Host, 再由模型, UI使用

MCP底层消息格式基于JSON-RPC2.0,请求-响应-通知,

5.2.1 用5个动作理解一次完整MCP调用
  1. 握手与能力发现Handshake Discovery
    Host启动后,会根据配置连接MCP Server,并完成初始化。此时客户端就能知道,Server提供了哪些Tools, Resources, Prompts以及支持的capabilities

  2. 用户提问与上下文注入Context Injection
    用户提问,Host则会把用户问题和已发现的工具、资源等信息一并发给模型或应用逻辑

  3. 模型或应用做决策Reasoning Decision
    模型决定是否需要调用某个Tool,或者应用决定是否读取某个Resource,选用某个Prompt

  4. 路由与执行Routing Execution
    Host / Client按协议把请求发给Server,Server在自己的进程中或远端服务中执行,

  5. 结果回传与继续生成
    Server将结果返回给Client, Client再把结果交给Host,再让Host继续让模型生成最终回答。

在Agent场景里,MCP尝尝出现在Tool之前一层

5.3 两类常见传输:STDIO, HTTP系列

根据MCP官方传输规范,当前主线传输标准是:

  • stdio
  • Steamable HTTP:独立服务进程,通过HTTP通信,必要时可配合SSE流式返回

stdio,适合本地、轻量、由客户端拉起服务端进程,
SSE /HTTP兼容教学写法,适合理解历史资料、理解远程服务形态

5.3.1 传输之外,还要注意安全边界
  • 写操作要有人确认:删除、外呼、支付、批量修改这类Tool,不要默认让模型静默执行
  • HTTP服务要做鉴权和来源校验,要考token, 会话身份,origin / 来源校验,不是能连上就算接入成功
  • 本地服务尽量收口暴露范围
  • 日志与业务数据要分级处理:进度、错误、调试日志很有用,但不要把敏感配置、私有数据原样暴露给模型或不可信客户端

5.4 FastMCP的基本写法与常用API

5.4.1 创建服务实例
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("Demo")

创建一个MCP Server实例

5.4.2 注册Tool
@mcp.tool()
def add(a : int, b : int) -> int:
	return a + b

把普通python函数暴露成mcp tools。

5.3.4 注册Resource
@mcp.reource("greeting://default")
def get_greeting() -> str:
  return "Hello from static resource!"

这还是服务器对外暴露一个可读资源,客户端可以按URI读取

5.4.4 注册Prompt
@mcp.prompt()
def greet_user(name: str, style: str = "friendly") -> str:
  return f"为{name}生成问候语"

对外暴露一个可复用提示词模板

5.4.5 启动服务

mcp.run(transport=“stdio”)

或者mcp.run(transport=“streamable-http”)

5.4.6 从底层SDK视角看客户端

以官方SDK思路来看,一个MCP客户端的典型动作通常是:

  • 建立传输连接
  • 创建ClientSession
  • 调用initialize
  • lsit_tools(), list_resources(), list_prompts()
  • call_tools(), read_resources(), get_prompt()

例如Stdio的示例:

import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

async def main():
    server_params = StdioServerParameters(
        command="python",
        args=["mcp_server_stdio.py"],
    )
    async with stdio_client(server_params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()
            tools = await session.list_tools()
            result = await session.call_tool("add", {"a": 1, "b": 2})
            print(tools)
            print(result)

asyncio.run(main())

理解SDK原理实现过程,有哪些步骤。

5.5 完整调用过程理解

在LangChain中,就是:

  • MultiServerMCPClient,连接MCP服务
  • get_tools获取工具
  • Agent拿到工具列表
  • 用户提问
  • Agent选择工具
  • 工具返回结果
  • 模型基于结果继续生成最终回答

6 案例实战:本地MCP天气服务与客户端

6.1 本章案例在项目中的位置

6.2 服务端案例区分理解

6.2.1 极简教学版:McpServer.py
"""
FileName: 
Author: 
Version: 
Date: 2026/7/2614:07
Description: 
"""
"""
极简实现,无FastMCP依赖
MCP则是在Tool之上增加一层标准协议,让同一套能力更容易被不同宿主,不同AI应用复用

"""
import json
import os
import httpx
from loguru import logger
from dotenv import load_dotenv

load_dotenv()

# ---------极简版MPC服务类,无FastMCP依赖,手写-------------、
# 如果使用FastMCP,则不需要下面这一整段class,直接mcp = FastMCP("名")  + @mcp.tool() + mcp.run()
class MCPWeatherServer:
    """只保留了注册工具,维持进程这两个概念"""

    def __init__(self, name: str, host: str, port: int):
        self.name = name
        self.host = host
        self.port = port
        self._tools = {}

    def tool(self):
        """实现@mcp.tool()装饰器,把普通函数登记到工具注册表中"""
        def decorator(func):
            self._tools[func.__name__] = func # 注册工具函数,key为函数名
            return func

        return decorator

    def run(self, transport: str):
        """模拟 run() 入口;这里只打印监听信息并保持进程存活,不提供完整网络服务。"""
        if transport != "sse":
            logger.warning(f"不支持的传输协议 {transport},默认使用 SSE")
        logger.info(f"启动 MCP SSE 天气服务器,监听 http://{self.host}:{self.port}/sse")
        self._keep_alive()

    def _keep_alive(self):
        """简单保持进程运行,便于从日志层面观察服务端已启动的状态"""
        try:
            while True:
                pass
        except KeyboardInterrupt:
            logger.info("MCP 天气服务已停止")


#---------创建MCP实例并注册工具---------
mcp = MCPWeatherServer("WeatherServerSSE", host="127.0.0.1", port=8000)


# 将get_weather注册为MCP工具
@mcp.tool()
def get_weather(city: str) -> str:
    """
    查询指定城市的及时天气信息

    """
    url = "https://api.openweathermap.org/data/2.5/weather"
    params = {
        "q": city,
        "appid": os.getenv(
            "OPENWEATHER_API_KEY"
        ),
        "units": "metric",
        "lang": "zh_cn",
    }
    resp = httpx.get(url, params=params, timeout=10)
    data = resp.json()
    logger.info(f"查询{city} 天气结果: {data}")
    return json.dumps(data, ensure_ascii=False)


if __name__ == "__main__":
    logger.info("启动MCP SSE天气服务器,监听http://127.0.0.1:8000/sse")
    mcp.run(transport="sse")
    

理解服务端干了什么:

  • 维护一个_tools容器
  • 用@mcp.tool()把get_weather注册进去

所以能够理解mcp的思想,服务器如何暴露工具,

6.2.2 标准写法入门版:McpServerByFastMCP.py
"""
FileName: 
Author: 
Version: 
Date: 2026/7/2615:14
Description: 
"""
"""
FastMCP通过@mcp.tool, @mcp.resource(), @mcp.prompt()暴露工具,静态资源和提示词模板
本次示例使用transport = \"stdio"\,通过标准输入输出与客户端通信,最适合本地开发,命令行宿主

"""
from mcp.server.fastmcp import FastMCP

# 创建mcp实例,对应了MCP服务器这个角色
mcp = FastMCP("Demo")

# 为MCP实例添加工具,最典型的可执行动作
@mcp.tool()
def add(a: int, b: int) -> int:
    return a + b

# 为mcp实例添加资源,资源更像可读取内容,常由宿主决定是否拿来做上下文
@mcp.resource("greeting://default")
def get_greeting() -> str:
    return "Hello from static resource!"

# 为mcp添加提示词模板,
@mcp.prompt()
def get_user(name: str, style: str = "friendly") -> str:
    styles = {
        "friendly": "写一句友善的问候",
        "formal": "写一句正式的问候",
        "casual": "写一句轻松的问候",
    }
    return f"为{name}{styles.get(style, styles['friendly'])}"

if __name__ == "__main__":
    # 正确用法是由cursor/ Claude等MCP客户端启动本进程并接管stdin, stdout
    mcp.run(transport="stdio")
6.2.3 天气服务端:McpServerWeatherByFastMCP.py
"""
FileName: 
Author: 
Version: 
Date: 2026/7/2615:48
Description: 
"""
"""
网络化MCP Tool服务示例,只保留一个天气查询工具,配合同目录的mcp.json
正确写法:
应该先构建实例,mcp = FastMCP("服务名")
再运行,mcp.run(transport="sse", host="127.0.0.1", prot=8000)
"""

import json
import os

# pip install mcp httpx python-dotenv
from dotenv import load_dotenv
from mcp.server.fastmcp import FastMCP
import httpx

load_dotenv()

# 构造函数只接受「服务名」;网络绑定信息在 run() 时再指定
mcp = FastMCP(
    "WeatherServerSSE"
)  # "WeatherServerSSE" 就是你自己起的名,可改成 "MyWeather" 等


@mcp.tool()
def get_weather(city: str) -> str:
    """查询指定城市的即时天气信息。city 为城市英文名,如 Beijing、Shanghai。"""
    url = "https://api.openweathermap.org/data/2.5/weather"
    params = {
        "q": city,
        "appid": os.getenv("OPENWEATHER_API_KEY"),
        "units": "metric",
        "lang": "zh_cn",
    }
    resp = httpx.get(url, params=params, timeout=10)
    data = resp.json()
    return json.dumps(data, ensure_ascii=False)


if __name__ == "__main__":
    # host、port 在 run() 时传入,不是构造函数。
    # 这里启动后,mcp.json 中的 weather 服务就可以按约定地址连到它。
    mcp.run(transport="sse", host="127.0.0.1", port=8000)

6.3 mcp.json简介

这里的json文件结合上面的McpServerWeatherByFastMCP.py来看,

{
  "mcpServers": {
    "weather": {
      "url": "http://127.0.0.1:8000/sse",
      "transport": "sse"
    },
    "fetch": {
      "command": "uvx",
      "args": ["mcp-server-fetch"],
      "transport": "stdio"
    }
  }
}

理解为mcp.json是MCP host / client适配器常用的客户端连接配置文件,

例如Deerflow中的mcp.json配置就是采用这种契约,解决了:

  • 要连接哪几台服务器
  • 每台服务器用什么transport
  • URL, command , args是什么

而不是说明MCP本题如何定义的问题。

例如上面的json示例:

  • weather,走sse,指向本地天气服务
  • fetch,走stdio,通过命令启动一个本地mcp server

体现出了同一个客户端完全可以同时连接多台MCP Server,而且每台服务器可以用不同传输方式。

6.4 客户端案例怎么区分理解

6.4.1 同进程教学版客户端McpClient.py
"""
FileName: 
Author: 
Version: 
Date: 2026/7/2615:57
Description: 
"""
"""
本地MCP天气客户端,直接调用服务端已注册工具
客户端职责:连接服务器,发现能力,发起调用
本案例为:同机,同进程演示,
客户端可通过from McpServer import mcp拿到mcp实例,再调用mcp._tools调用已注册工具,
没有走真实的mcp通信,这样是因为先了解调用原理,后续再理解FastMCP和LangChain客户端案例

"""

import json
from loguru import logger
from McpServer import mcp

class MCPWeatherClient:
    """教学版客户端,直接访问服务端注册表"""
    def __init__(self, mcp_instance):
        self.mcp_instance = mcp_instance
        # 获取工具
        self.available_tools = mcp_instance._tools

    def check_tool_availability(self, tool_name: str) -> bool:
        """检查工具是否在服务端注册,避免调用不存在的工具"""
        is_available = tool_name in self.available_tools
        if is_available:
            logger.info(f"工具 '{tool_name}' 可用")
        else:
            logger.warning(f"工具 '{tool_name} 未在服务端注册'")
        return is_available

    def call_get_weather(self, city: str) -> str or None:
        """调用服务端工具"""
        tool_name = "get_weather"
        if not self.check_tool_availability(tool_name):
            return None
        try:
            # 直接调用工具函数
            weather_result = self.available_tools[tool_name](city)
            logger.info(
                f"成功获取{city} 天气数据,返回结果长度: {len(weather_result)}"
            )
            return weather_result
        except Exception as exc:
            logger.error(f"调用 {tool_name} 工具失败: {str(exc)}")
            return None



def run_client_demo():
    """客户端演示:初始化客户端,依次查询多城市天气并格式化输出"""
    logger.info("初始化 MCP 天气客户端...")
    client = MCPWeatherClient(mcp)

    # 调用天气查询工具(支持 Beijing、Shanghai、Guangzhou 等英文城市名)
    target_cities = ["Beijing", "Shanghai"]
    for city in target_cities:
        logger.info(f"\n========== 查询 {city} 天气 ==========")
        weather_data = client.call_get_weather(city)
        if weather_data:
            # 格式化输出结果(可选,方便阅读)
            formatted_data = json.dumps(
                json.loads(weather_data), indent=4, ensure_ascii=False
            )
            print(f"格式化天气结果:\n{formatted_data}")
        print("-" * 50)
if __name__ == "__main__":
    logger.info("启动 MCP 天气客户端...")
    run_client_demo()
6.4.2 更贴近真实项目的客户端McpClientAgent.py
"""
【案例】基于 mcp.json + LangChain Agent 的 MCP 客户端(LLM + MCP 工具)

对应教程章节:
- 第 20 章 - MCP 模型上下文协议 → 6、案例实战:本地 MCP 天气服务与客户端
- 第 21 章 - Agent 智能体 → 5、实操与案例(5.4 Agent + MCP)

知识点速览:
- 从同目录的 mcp.json 加载 MCP 服务配置,使用 langchain_mcp_adapters 的 MultiServerMCPClient 连接多台
  MCP 服务器并获取工具列表,再交给 LangChain 的 create_tool_calling_agent + AgentExecutor,形成
  「LLM + MCP 工具」的对话 Agent。这也是第 21 章里“外部工具接入 Agent”的代表案例。
- mcp.json 是“客户端侧的连接配置约定”,不是 MCP 协议本身。它描述的是“有哪些服务、分别怎么连”,
  例如本仓库里既有网络方式的 weather 服务,也有 stdio 方式的 fetch 服务。
- 流程:加载 mcp.json → 初始化 MultiServerMCPClient → 异步获取 MCP Tools → 创建 DeepSeek 模型与
  提示模板 → 组装 Agent 与 AgentExecutor → 启动命令行聊天循环(输入 quit 退出)。
- 本案例重点展示“把 MCP Tools 交给 LangChain Agent”;Resources 和 Prompts 虽然也是 MCP 能力,
  但这里没有作为主线展开。
- 这个文件延续了仓库里更容易教学的 classic Agent 路线;如果改走更偏 1.x 的直接路线,也常见
  `await client.get_tools()` 之后把工具交给 `create_agent`,再配合 `ainvoke()` / `astream()` 使用。
- 依赖:pip install langchain-mcp-adapters langchain-openai langchain-classic loguru;部分适配器要求 Python 3.12 及以下。需配置环境变量 deepseek-api(或改用其他兼容 OpenAI 的 api_key/base_url)。
"""

import asyncio
import json
import os
from pathlib import Path

from loguru import logger

# 默认 mcp.json 路径(与本文件同目录)
_MCP_JSON_PATH = Path(__file__).resolve().parent / "mcp.json"


def load_servers(file_path: str | Path | None = None) -> dict:
    """
    加载 MCP 服务器配置。
    :param file_path: 配置文件路径,默认使用同目录下的 mcp.json
    :return: 完整配置字典,如 {"mcpServers": {"weather": {...}, "fetch": {...}}}

    这里读取的是“客户端如何连接服务”的约定配置,而不是协议本体。
    """
    path = Path(file_path) if file_path else _MCP_JSON_PATH
    if not path.exists():
        logger.warning(f"未找到 mcp 配置文件: {path}")
        return {"mcpServers": {}}
    with open(path, "r", encoding="utf-8") as f:
        config = json.load(f)
    logger.info(
        f"已加载 mcp 配置: {path},共 {len(config.get('mcpServers', {}))} 个服务"
    )
    return config


async def run_chat_loop(config_path: str | Path | None = None) -> None:
    """
    启动并运行一个基于 MCP 工具的聊天 Agent 循环。
    该函数会:1)加载 MCP 服务器配置;2)初始化 MCP 客户端并获取工具;
    3)创建基于 DeepSeek 的语言模型和 Agent;4)启动命令行聊天循环;5)退出时清理资源。
    """
    try:
        from langchain_mcp_adapters.client import MultiServerMCPClient
    except ImportError as e:
        logger.error(
            "请先安装 langchain-mcp-adapters: pip install langchain-mcp-adapters(部分环境需 Python 3.12 及以下)"
        )
        raise e

    from langchain_openai import ChatOpenAI
    from langchain_classic.agents import AgentExecutor, create_tool_calling_agent
    from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder

    config = load_servers(config_path)
    servers = config.get("mcpServers", {})
    if not servers:
        logger.warning("mcp.json 中未配置任何服务,无法获取 MCP 工具")
        return

    # 初始化 MCP 客户端:connections 就是 mcp.json 中的 mcpServers 字典
    # 每个条目描述一台 MCP 服务该如何连接,例如 stdio 子进程或 HTTP/SSE 地址
    client = MultiServerMCPClient(connections=servers)

    # 按官方默认用法,MultiServerMCPClient 是无状态的;获取工具时使用异步接口即可
    tools = await client.get_tools()
    if not tools:
        logger.warning(
            "未从 MCP 服务获取到任何工具,请确认服务已启动且 mcp.json 配置正确"
        )
        return

    logger.info(f"已获取 {len(tools)} 个 MCP 工具: {[t.name for t in tools]}")

    # 语言模型(DeepSeek,与截图一致;可改为其他 OpenAI 兼容接口)
    llm = ChatOpenAI(
        model="deepseek-v4-flash",
        api_key=os.getenv("deepseek-api"),
        base_url="https://api.deepseek.com",
    )

    # 对话提示:系统提示要求使用工具完成用户请求,agent_scratchpad 供 Executor 填入中间步骤
    prompt = ChatPromptTemplate.from_messages(
        [
            ("system", "你是一个有用的助手,需要使用提供的工具来完成用户请求。"),
            ("human", "{input}"),
            MessagesPlaceholder(variable_name="agent_scratchpad"),
        ]
    )

    agent = create_tool_calling_agent(llm, tools, prompt)
    agent_executor = AgentExecutor(
        agent=agent,
        tools=tools,
        verbose=True,
        handle_parsing_errors="解析用户请求失败,请重新输入清晰的指令",
    )

    logger.info("\n MCP Agent 已启动,请先输入一个提问给(LLM+MCP),输入 'quit' 退出")

    while True:
        try:
            user_input = input("\n您: ").strip()
            if not user_input:
                continue
            if user_input.lower() == "quit":
                logger.info("已退出")
                break
            result = agent_executor.invoke({"input": user_input})
            output = result.get("output", result)
            print(f"\nAgent: {output}")
        except KeyboardInterrupt:
            logger.info("已退出")
            break


def main() -> None:
    asyncio.run(run_chat_loop())


if __name__ == "__main__":
    main()

第21章 Agent智能体

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

所谓远行Misnearch

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值