From 825bcf47fad2223cf07f8f0713c4975253eeec66 Mon Sep 17 00:00:00 2001 From: AaronXu <718827633@qq.com> Date: Wed, 8 Jul 2026 10:09:42 +0800 Subject: [PATCH] =?UTF-8?q?07-08-=E5=91=A8=E4=B8=89=5F10-09-42?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 14 + 01_开发环境搭建.ipynb | 213 ++++++++ 02_Python环境配置.ipynb | 293 +++++++++++ 03_大模型API调用.ipynb | 356 ++++++++++++++ 04_LangChain概述.ipynb | 303 ++++++++++++ 05_LLM链.ipynb | 393 +++++++++++++++ 06_Prompt模板.ipynb | 579 ++++++++++++++++++++++ 07_输出解析器.ipynb | 560 +++++++++++++++++++++ 08_链式组合.ipynb | 558 +++++++++++++++++++++ 09_工具定义.ipynb | 470 ++++++++++++++++++ 10_工具调用.ipynb | 468 ++++++++++++++++++ 11_检索增强.ipynb | 505 +++++++++++++++++++ 12_向量数据库.ipynb | 398 +++++++++++++++ 13_RAG构建.ipynb | 465 ++++++++++++++++++ 14_LangGraph概述.ipynb | 299 +++++++++++ 15_图结构.ipynb | 656 ++++++++++++++++++++++++ 16_状态管理.ipynb | 635 ++++++++++++++++++++++++ 17_节点与边.ipynb | 586 ++++++++++++++++++++++ 18_条件边.ipynb | 594 ++++++++++++++++++++++ 19_循环与记忆.ipynb | 831 +++++++++++++++++++++++++++++++ 20_多智能体架构.ipynb | 966 ++++++++++++++++++++++++++++++++++++ 21_对话代理.ipynb | 836 +++++++++++++++++++++++++++++++ 22_任务调度.ipynb | 604 +++++++++++++++++++++++ 23_人机协作.ipynb | 834 +++++++++++++++++++++++++++++++ 24_项目实战一.ipynb | 662 +++++++++++++++++++++++++ 25_项目实战二.ipynb | 772 +++++++++++++++++++++++++++++ 26_项目实战三.ipynb | 1039 +++++++++++++++++++++++++++++++++++++++ 27_部署与评估.ipynb | 687 ++++++++++++++++++++++++++ 28_课程总结.ipynb | 557 +++++++++++++++++++++ README.md | 355 +++++++++++++ 30 files changed, 16488 insertions(+) create mode 100644 .gitignore create mode 100644 01_开发环境搭建.ipynb create mode 100644 02_Python环境配置.ipynb create mode 100644 03_大模型API调用.ipynb create mode 100644 04_LangChain概述.ipynb create mode 100644 05_LLM链.ipynb create mode 100644 06_Prompt模板.ipynb create mode 100644 07_输出解析器.ipynb create mode 100644 08_链式组合.ipynb create mode 100644 09_工具定义.ipynb create mode 100644 10_工具调用.ipynb create mode 100644 11_检索增强.ipynb create mode 100644 12_向量数据库.ipynb create mode 100644 13_RAG构建.ipynb create mode 100644 14_LangGraph概述.ipynb create mode 100644 15_图结构.ipynb create mode 100644 16_状态管理.ipynb create mode 100644 17_节点与边.ipynb create mode 100644 18_条件边.ipynb create mode 100644 19_循环与记忆.ipynb create mode 100644 20_多智能体架构.ipynb create mode 100644 21_对话代理.ipynb create mode 100644 22_任务调度.ipynb create mode 100644 23_人机协作.ipynb create mode 100644 24_项目实战一.ipynb create mode 100644 25_项目实战二.ipynb create mode 100644 26_项目实战三.ipynb create mode 100644 27_部署与评估.ipynb create mode 100644 28_课程总结.ipynb create mode 100644 README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..825f695 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# 忽略虚拟环境 +.venv/ + +# 忽略所有文件 +* + +# 但保留 Jupyter Notebook 课件 +!*.ipynb + +# 保留 README +!README.md + +# 保留本文件本身 +!.gitignore diff --git a/01_开发环境搭建.ipynb b/01_开发环境搭建.ipynb new file mode 100644 index 0000000..a1846d5 --- /dev/null +++ b/01_开发环境搭建.ipynb @@ -0,0 +1,213 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 01 开发环境搭建\n", + "\n", + "## 学习目标\n", + "1. 了解课程整体安排与学习目标\n", + "2. 明确 LangChain 和 LangGraph 在 AI 智能体开发中的定位\n", + "3. 熟悉本课程使用的技术栈和工具链(VS Code + Python 3.12 + 虚拟环境)\n", + "4. 学会使用 venv 创建虚拟环境并使用清华镜像源加速 pip 安装" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. 课程概述与技术栈\n", + "\n", + "本课程面向具备 Python 基础语法的大学生,系统讲解 **LangChain** 和 **LangGraph** 两大框架,帮助你从零构建 AI 智能体应用。\n", + "\n", + "### 核心技术栈\n", + "- **开发工具**:VS Code(Visual Studio Code)\n", + "- **编程语言**:Python 3.12.11\n", + "- **虚拟环境**:venv(Python 内置)\n", + "- **主要框架**:LangChain、LangGraph\n", + "- **镜像源**:清华大学 TUNA 镜像(加速 pip 安装)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. VS Code 安装与配置\n", + "\n", + "### 步骤 1:下载并安装 VS Code\n", + "1. 访问官网:https://code.visualstudio.com/\n", + "2. 下载 Windows 版本安装包\n", + "3. 运行安装程序,建议勾选:\n", + " - **添加到 PATH**(添加到系统环境变量)\n", + " - **右键菜单集成**(在资源管理器中打开)\n", + "\n", + "### 步骤 2:安装 Python 扩展\n", + "打开 VS Code,按 `Ctrl+Shift+X` 进入扩展市场,搜索并安装:\n", + "- **Python**(Microsoft 官方扩展)\n", + "- **Jupyter**(用于运行 .ipynb 文件)\n", + "\n", + "### 步骤 3:选择 Python 解释器\n", + "按 `Ctrl+Shift+P` 打开命令面板,输入 `Python: Select Interpreter`,选择后续步骤中创建的虚拟环境。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. Python 3.12.11 环境准备\n", + "\n", + "本课程统一使用 **Python 3.12.11**,确保版本一致可避免兼容性问题。\n", + "\n", + "### 检查当前 Python 版本\n", + "在 VS Code 终端(`Ctrl+ ``)或 PowerShell 中执行:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import sys\n", + "print(f\"Python 版本:{sys.version}\")\n", + "print(f\"Python 路径:{sys.executable}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "如果你的系统中没有 Python 3.12.11,请前往 [Python 官网](https://www.python.org/downloads/release/python-31211/) 下载安装,或使用 `pyenv-win` 管理多版本。\n", + "\n", + "> ⚠️ **注意**:安装时请务必勾选 **Add Python to PATH**。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. 创建虚拟环境(venv)\n", + "\n", + "使用虚拟环境可以隔离项目依赖,避免不同项目之间的包冲突。Python 3.12 内置了 `venv` 模块,无需额外安装。\n", + "\n", + "### 在项目目录下创建虚拟环境\n", + "\n", + "在 VS Code 终端(确保终端为 PowerShell 或 CMD)中执行:\n", + "\n", + "```powershell\n", + "# 创建名为 .venv 的虚拟环境\n", + "python -m venv .venv\n", + "```\n", + "\n", + "执行后,你会在当前目录下看到 `.venv` 文件夹。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. 激活虚拟环境\n", + "\n", + "### Windows 系统(PowerShell)\n", + "```powershell\n", + ".venv\\Scripts\\Activate.ps1\n", + "```\n", + "\n", + "### Windows 系统(CMD)\n", + "```cmd\n", + ".venv\\Scripts\\activate.bat\n", + "```\n", + "\n", + "> ⚠️ **PowerShell 执行策略提示**:如果提示无法执行脚本,请以管理员身份运行 PowerShell 并执行:\n", + "> ```powershell\n", + "> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser\n", + "> ```\n", + "\n", + "激活成功后,终端提示符前会显示 `(.venv)`。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. 使用清华镜像源安装依赖\n", + "\n", + "为了加快 pip 下载速度,我们使用清华大学 TUNA 镜像源。\n", + "\n", + "### 临时使用清华源(推荐)\n", + "在 VS Code 终端中执行以下命令(确保虚拟环境已激活):\n", + "\n", + "```powershell\n", + "pip install langchain langgraph langchain-openai -i https://pypi.tuna.tsinghua.edu.cn/simple\n", + "```\n", + "\n", + "### 配置全局默认镜像源(可选)\n", + "如果你想让所有 pip 安装默认使用清华源:\n", + "\n", + "```powershell\n", + "pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7. 验证环境安装\n", + "\n", + "运行以下代码,确认核心库已正确安装:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import sys\n", + "from importlib.metadata import version\n", + "\n", + "print(f\"Python 版本:{sys.version}\")\n", + "print(f\"LangChain 版本:{version('langchain')}\")\n", + "print(f\"LangGraph 版本:{version('langgraph')}\")\n", + "print(\"✅ 环境安装成功!\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 8. VS Code 关联虚拟环境\n", + "\n", + "1. 按 `Ctrl+Shift+P` 打开命令面板\n", + "2. 输入 `Python: Select Interpreter`\n", + "3. 选择 `./.venv/Scripts/python.exe`\n", + "4. 重新启动 Jupyter 内核(点击右上角内核选择器,选择 `.venv` 环境)\n", + "\n", + "现在你可以在 VS Code 中直接运行本课程的所有 `.ipynb` 文件了!" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.4" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/02_Python环境配置.ipynb b/02_Python环境配置.ipynb new file mode 100644 index 0000000..828ca59 --- /dev/null +++ b/02_Python环境配置.ipynb @@ -0,0 +1,293 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 02 Python 环境配置\n", + "\n", + "## 学习目标\n", + "1. 理解 Python 虚拟环境的作用,掌握 venv 的进阶用法\n", + "2. 学会使用 `requirements.txt` 管理项目依赖\n", + "3. 掌握 Jupyter Notebook / JupyterLab 在 VS Code 中的配置\n", + "4. 学会为 Jupyter 注册虚拟环境内核并排查常见环境问题" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. 为什么需要虚拟环境\n", + "\n", + "在 Python 开发中,不同项目可能依赖不同版本的库。如果不加隔离,全局安装的库会相互冲突。\n", + "\n", + "### 虚拟环境的好处\n", + "- **依赖隔离**:每个项目有独立的包集合\n", + "- **版本可控**:避免全局包版本混乱\n", + "- **便于迁移**:通过 `requirements.txt` 快速复现环境\n", + "- **与生产一致**:开发、测试、生产环境使用相同依赖" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. venv 进阶用法\n", + "\n", + "Python 3.12 内置 `venv` 模块,无需额外安装。除了创建环境,你还可以指定 Python 解释器、选择是否包含系统站点包等。\n", + "\n", + "### 常用命令\n", + "\n", + "```powershell\n", + "# 创建虚拟环境(默认当前目录下 .venv 文件夹)\n", + "python -m venv .venv\n", + "\n", + "# 指定 Python 版本创建环境(需该版本在 PATH 中)\n", + "python3.12 -m venv .venv\n", + "\n", + "# 创建时继承系统已安装的包(谨慎使用)\n", + "python -m venv .venv --system-site-packages\n", + "\n", + "# 删除虚拟环境(直接删除文件夹即可)\n", + "Remove-Item -Recurse -Force .venv\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 虚拟环境目录结构\n", + "\n", + "```text\n", + ".venv/\n", + "├── Scripts/ # Windows 可执行文件(python.exe、pip.exe、activate)\n", + "├── Lib/ # 安装的第三方库\n", + "├── include/ # C 扩展头文件\n", + "└── pyvenv.cfg # 环境配置信息\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 查看当前解释器所在环境信息\n", + "import sys\n", + "import os\n", + "\n", + "print(f\"当前 Python:{sys.executable}\")\n", + "print(f\"是否处于虚拟环境:{hasattr(sys, 'real_prefix') or sys.base_prefix != sys.prefix}\")\n", + "print(f\"虚拟环境路径:{sys.prefix}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. 使用 requirements.txt 管理依赖\n", + "\n", + "`requirements.txt` 是 Python 项目的依赖清单,便于团队共享和部署复现。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 导出当前环境依赖\n", + "\n", + "在已激活的虚拟环境中执行:\n", + "\n", + "```powershell\n", + "pip freeze > requirements.txt\n", + "```\n", + "\n", + "### 从 requirements.txt 安装依赖\n", + "\n", + "```powershell\n", + "pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 一份适合本课程的 requirements.txt 示例\n", + "\n", + "你可以将以下内容保存为项目根目录下的 `requirements.txt`:\n", + "\n", + "```text\n", + "# 核心框架\n", + "langchain>=0.3.0\n", + "langgraph>=0.2.0\n", + "langchain-openai>=0.2.0\n", + "\n", + "# 向量数据库与 Embedding\n", + "langchain-chroma>=0.1.0\n", + "sentence-transformers>=3.0.0\n", + "\n", + "# Jupyter 与工具\n", + "jupyter>=1.0.0\n", + "ipykernel>=6.29.0\n", + "python-dotenv>=1.0.0\n", + "\n", + "# 可选:搜索/网络请求工具\n", + "requests>=2.32.0\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 示例:查看当前环境已安装的核心包\n", + "import subprocess\n", + "result = subprocess.run(['pip', 'list'], capture_output=True, text=True)\n", + "print(result.stdout[:2000]) # 只打印前 2000 字符" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. Jupyter Notebook 配置\n", + "\n", + "Jupyter Notebook 是交互式运行 Python 代码的理想工具,本课程所有课件都以 `.ipynb` 格式提供。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 安装 Jupyter\n", + "\n", + "在虚拟环境中执行:\n", + "\n", + "```powershell\n", + "pip install jupyter ipykernel -i https://pypi.tuna.tsinghua.edu.cn/simple\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 为 Jupyter 注册当前虚拟环境内核\n", + "\n", + "这样 VS Code 的 Jupyter 面板就能识别并使用 `.venv` 中的解释器:\n", + "\n", + "```powershell\n", + "python -m ipykernel install --user --name=langchain-env --display-name=\"LangChain课程环境\"\n", + "```\n", + "\n", + "参数说明:\n", + "- `--user`:安装到用户目录,无需管理员权限\n", + "- `--name`:内核内部名称\n", + "- `--display-name`:在 VS Code 中显示的名称" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 在 VS Code 中切换 Jupyter 内核\n", + "\n", + "1. 打开任意 `.ipynb` 文件\n", + "2. 点击右上角内核选择器(默认显示为 Python 版本号)\n", + "3. 选择 `LangChain课程环境` 或 `.venv` 对应的解释器\n", + "4. 重新运行单元格即可" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 测试 Jupyter 运行是否正常\n", + "print(\"Hello, LangChain + LangGraph!\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. pip 常用命令速查\n", + "\n", + "| 命令 | 作用 |\n", + "| --- | --- |\n", + "| `pip install 包名` | 安装指定包 |\n", + "| `pip install 包名==1.0.0` | 安装指定版本 |\n", + "| `pip install -U 包名` | 升级指定包 |\n", + "| `pip uninstall 包名` | 卸载指定包 |\n", + "| `pip list` | 列出已安装包 |\n", + "| `pip show 包名` | 查看包详细信息 |\n", + "| `pip freeze > requirements.txt` | 导出依赖清单 |\n", + "| `pip install -r requirements.txt` | 按清单安装依赖 |" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. 常见环境问题排查\n", + "\n", + "### 问题 1:终端显示未激活虚拟环境\n", + "- 检查终端是否从 VS Code 正确启动\n", + "- 手动执行 `.venv\\Scripts\\Activate.ps1`\n", + "\n", + "### 问题 2:pip 安装速度慢或超时\n", + "- 使用清华镜像源:`-i https://pypi.tuna.tsinghua.edu.cn/simple`\n", + "- 升级 pip:`python -m pip install --upgrade pip -i https://pypi.tuna.tsinghua.edu.cn/simple`\n", + "\n", + "### 问题 3:Jupyter 内核无法导入虚拟环境中的包\n", + "- 确认内核选择的是 `.venv` 对应的解释器\n", + "- 重新注册内核:`python -m ipykernel install --user --name=langchain-env --force`\n", + "\n", + "### 问题 4:安装包时报依赖冲突\n", + "- 优先在干净虚拟环境中安装\n", + "- 使用 `pip install 包名 --no-deps` 跳过依赖检查(谨慎)\n", + "- 使用 `pip install -r requirements.txt` 统一安装" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7. 本节课练习\n", + "\n", + "1. 在当前项目目录下创建一个名为 `.venv` 的虚拟环境(如果尚未创建)\n", + "2. 激活虚拟环境后,使用清华镜像源安装 `jupyter`、`ipykernel`、`langchain`、`langgraph`\n", + "3. 注册当前环境为 Jupyter 内核,名称为 `langchain-env`\n", + "4. 在 VS Code 中切换到这个内核,并成功运行本 notebook 中的所有代码单元格\n", + "5. 使用 `pip freeze > requirements.txt` 导出依赖清单,查看文件内容" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.4" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/03_大模型API调用.ipynb b/03_大模型API调用.ipynb new file mode 100644 index 0000000..d1ee89c --- /dev/null +++ b/03_大模型API调用.ipynb @@ -0,0 +1,356 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 03 大模型 API 调用\n", + "\n", + "## 学习目标\n", + "1. 理解大模型 API 的基本概念(OpenAI、Anthropic 等)\n", + "2. 学会使用 Python 代码调用大模型 API 进行对话\n", + "3. 掌握 API 密钥的安全管理和环境变量配置\n", + "4. 能够区分 OpenAI 格式与 Anthropic 格式的 API 调用方式" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. 大模型 API 概述\n", + "\n", + "大模型(Large Language Model, LLM)通常通过 **HTTP API** 提供服务。你发送一段文本(Prompt),模型返回生成的回复。\n", + "\n", + "### 主流 API 提供商\n", + "\n", + "| 提供商 | 代表模型 | 特点 |\n", + "| --- | --- | --- |\n", + "| **OpenAI** | GPT-4o、GPT-4o-mini | 生态最成熟,很多第三方平台兼容其格式 |\n", + "| **Anthropic** | Claude 3.5 Sonnet、Claude 3 Opus | 长文本处理能力强,安全性设计突出 |\n", + "| **国内厂商** | 文心一言、通义千问、智谱 GLM | 中文优化好,无需翻墙 |\n", + "\n", + "> 💡 **注意**:很多国内平台(如硅基流动、智谱 AI、DashScope)提供兼容 OpenAI 格式的 API,可以直接使用 OpenAI SDK 调用。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. API 调用核心概念\n", + "\n", + "调用大模型 API 时,你需要了解以下几个核心要素:\n", + "\n", + "- **Base URL**:API 服务的基础地址,例如 `https://api.openai.com/v1` 或 `https://api.anthropic.com`\n", + "- **API Key**:身份验证密钥,类似于密码,**绝对不能泄露**\n", + "- **Model**:模型名称,本课程统一使用 `qwen3.6-35b-A3b`\n", + "- **Message / Prompt**:发送给模型的输入内容\n", + "- **Temperature**:控制输出随机性(0~2,越低越确定,越高越有创意)\n", + "- **Max Tokens**:限制模型输出的最大长度" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. API 密钥安全管理\n", + "\n", + "**切勿将 API Key 直接写入代码并上传到 GitHub!** 推荐做法是将密钥存放在环境变量中。\n", + "\n", + "### 方法一:使用 .env 文件(推荐)\n", + "\n", + "1. 在项目根目录创建 `.env` 文件(注意文件名以点开头)\n", + "2. 添加以下内容(填入老师提供的实际值):\n", + "\n", + "```env\n", + "# 请使用老师提供的 base_url 和 api_key\n", + "OPENAI_BASE_URL=https://your-openai-compatible-base-url.com/v1\n", + "OPENAI_API_KEY=sk-your-openai-api-key\n", + "\n", + "ANTHROPIC_BASE_URL=https://api.anthropic.com\n", + "ANTHROPIC_API_KEY=sk-ant-your-anthropic-api-key\n", + "```\n", + "\n", + "> ⚠️ **重要**:`.env` 文件已加入 `.gitignore`,不会上传到 Git,切勿手动删除该忽略配置。\n", + "\n", + "### 方法二:手动设置系统环境变量(Windows)\n", + "\n", + "```powershell\n", + "# 当前终端会话有效\n", + "$env:OPENAI_API_KEY=\"sk-your-api-key\"\n", + "$env:OPENAI_BASE_URL=\"https://your-base-url.com/v1\"\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 加载 .env 文件中的环境变量\n", + "from dotenv import load_dotenv\n", + "import os\n", + "\n", + "load_dotenv() # 默认加载当前目录下的 .env 文件\n", + "\n", + "# 验证环境变量是否加载成功\n", + "openai_base_url = os.getenv(\"OPENAI_BASE_URL\")\n", + "openai_api_key = os.getenv(\"OPENAI_API_KEY\")\n", + "anthropic_base_url = os.getenv(\"ANTHROPIC_BASE_URL\")\n", + "anthropic_api_key = os.getenv(\"ANTHROPIC_API_KEY\")\n", + "\n", + "print(\"✅ OPENAI_BASE_URL 已设置\" if openai_base_url else \"❌ OPENAI_BASE_URL 未设置\")\n", + "print(\"✅ OPENAI_API_KEY 已设置\" if openai_api_key else \"❌ OPENAI_API_KEY 未设置\")\n", + "print(\"✅ ANTHROPIC_BASE_URL 已设置\" if anthropic_base_url else \"❌ ANTHROPIC_BASE_URL 未设置\")\n", + "print(\"✅ ANTHROPIC_API_KEY 已设置\" if anthropic_api_key else \"❌ ANTHROPIC_API_KEY 未设置\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. 方式一:OpenAI 格式调用(兼容 OpenAI SDK)\n", + "\n", + "OpenAI 格式的 API 是目前最通用的标准。很多国内厂商(如硅基流动、智谱、DeepSeek)也提供兼容此格式的接口。\n", + "\n", + "### 安装依赖\n", + "\n", + "```powershell\n", + "pip install openai python-dotenv -i https://pypi.tuna.tsinghua.edu.cn/simple\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from openai import OpenAI\n", + "import os\n", + "\n", + "# 初始化客户端(使用环境变量中的配置)\n", + "client = OpenAI(\n", + " base_url=os.getenv(\"OPENAI_BASE_URL\"),\n", + " api_key=os.getenv(\"OPENAI_API_KEY\")\n", + ")\n", + "\n", + "# 发送对话请求\n", + "response = client.chat.completions.create(\n", + " model=\"qwen3.6-35b-A3b\", # 请根据老师提供的实际模型名称修改\n", + " messages=[\n", + " {\"role\": \"system\", \"content\": \"你是一个 helpful 的 AI 助手。\"},\n", + " {\"role\": \"user\", \"content\": \"请用一句话解释什么是大语言模型。\"}\n", + " ],\n", + " temperature=0.7,\n", + " max_tokens=200\n", + ")\n", + "\n", + "# 输出模型回复\n", + "print(\"🤖 AI 回复:\")\n", + "print(response.choices[0].message.content)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### OpenAI 格式调用要点\n", + "\n", + "| 参数 | 说明 |\n", + "| --- | --- |\n", + "| `model` | 模型名称,本课程默认使用 `qwen3.6-35b-A3b` |\n", + "| `messages` | 消息列表,每条消息包含 `role`(system/user/assistant)和 `content` |\n", + "| `temperature` | 随机性(0~2),0 最确定,2 最有创意 |\n", + "| `max_tokens` | 最大输出 token 数 |\n", + "| `response.choices[0].message.content` | 获取模型回复的文本内容 |" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. 扩展阅读:Anthropic 格式调用(Claude API)\n", + "\n", + "Anthropic 的 Claude 系列模型使用独立的 API 格式,与 OpenAI 格式不同。需要安装 `anthropic` Python SDK。\n", + "\n", + "> 课程主线统一使用 OpenAI 兼容格式调用 `qwen3.6-35b-A3b`。以下 Anthropic/Claude 内容仅用于了解不同 API 格式,不作为默认运行示例。\n", + "\n", + "### 安装依赖\n", + "\n", + "```powershell\n", + "pip install anthropic python-dotenv -i https://pypi.tuna.tsinghua.edu.cn/simple\n", + "```\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Anthropic/Claude 调用格式示意(扩展阅读)\n", + "\n", + "课程默认不执行 Claude 调用,主线统一使用 OpenAI 兼容格式调用 `qwen3.6-35b-A3b`。如需了解 Anthropic SDK 的写法,可参考下面的非执行示例:\n", + "\n", + "```python\n", + "from anthropic import Anthropic\n", + "import os\n", + "\n", + "client = Anthropic(\n", + " base_url=os.getenv(\"ANTHROPIC_BASE_URL\"),\n", + " api_key=os.getenv(\"ANTHROPIC_API_KEY\"),\n", + ")\n", + "\n", + "response = client.messages.create(\n", + " model=\"\",\n", + " max_tokens=200,\n", + " temperature=0.7,\n", + " system=\"你是一个 helpful 的 AI 助手。\",\n", + " messages=[\n", + " {\"role\": \"user\", \"content\": \"请用一句话解释什么是大语言模型。\"}\n", + " ],\n", + ")\n", + "\n", + "print(response.content[0].text)\n", + "```\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Anthropic 格式与 OpenAI 格式的区别\n", + "\n", + "| 特性 | OpenAI 格式 | Anthropic 格式 |\n", + "| --- | --- | --- |\n", + "| 系统提示 | 放入 `messages` 列表,role=\"system\" | 独立的 `system` 参数 |\n", + "| 回复获取 | `response.choices[0].message.content` | `response.content[0].text` |\n", + "| SDK 包名 | `openai` | `anthropic` |\n", + "| 客户端类 | `OpenAI()` | `Anthropic()` |\n", + "| 方法名 | `chat.completions.create()` | `messages.create()` |" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. 多轮对话示例\n", + "\n", + "多轮对话需要保留历史消息,让模型理解上下文。以下以 OpenAI 格式为例:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from openai import OpenAI\n", + "import os\n", + "\n", + "client = OpenAI(\n", + " base_url=os.getenv(\"OPENAI_BASE_URL\"),\n", + " api_key=os.getenv(\"OPENAI_API_KEY\")\n", + ")\n", + "\n", + "# 维护对话历史\n", + "messages = [\n", + " {\"role\": \"system\", \"content\": \"你是一个 helpful 的 AI 助手。\"}\n", + "]\n", + "\n", + "# 第一轮对话\n", + "messages.append({\"role\": \"user\", \"content\": \"你好,我叫小明。\"})\n", + "response = client.chat.completions.create(\n", + " model=\"qwen3.6-35b-A3b\",\n", + " messages=messages,\n", + " temperature=0.7\n", + ")\n", + "reply = response.choices[0].message.content\n", + "print(f\"🤖: {reply}\")\n", + "\n", + "# 将模型回复加入历史\n", + "messages.append({\"role\": \"assistant\", \"content\": reply})\n", + "\n", + "# 第二轮对话(模型应该记得我叫小明)\n", + "messages.append({\"role\": \"user\", \"content\": \"你还记得我的名字吗?\"})\n", + "response = client.chat.completions.create(\n", + " model=\"qwen3.6-35b-A3b\",\n", + " messages=messages,\n", + " temperature=0.7\n", + ")\n", + "reply = response.choices[0].message.content\n", + "print(f\"🤖: {reply}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7. 流式输出(Streaming)\n", + "\n", + "流式输出让模型内容逐字返回,提升用户体验:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from openai import OpenAI\n", + "import os\n", + "\n", + "client = OpenAI(\n", + " base_url=os.getenv(\"OPENAI_BASE_URL\"),\n", + " api_key=os.getenv(\"OPENAI_API_KEY\")\n", + ")\n", + "\n", + "stream = client.chat.completions.create(\n", + " model=\"qwen3.6-35b-A3b\",\n", + " messages=[{\"role\": \"user\", \"content\": \"写一首关于春天的短诗。\"}],\n", + " stream=True # 开启流式输出\n", + ")\n", + "\n", + "print(\"🤖: \", end=\"\")\n", + "for chunk in stream:\n", + " # 某些 chunk 的 choices 为空,需要先判断\n", + " if chunk.choices and chunk.choices[0].delta.content is not None:\n", + " print(chunk.choices[0].delta.content, end=\"\")\n", + "print()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 8. 本节课练习\n", + "\n", + "1. 根据老师提供的 `base_url` 和 `api_key`,在 `.env` 文件中正确配置 OpenAI 和 Anthropic 两种环境变量\n", + "2. 运行 OpenAI 格式的示例代码,成功获取模型回复\n", + "3. 阅读 Anthropic 格式的扩展说明,对比两种 API 的差异\n", + "4. 尝试修改 `temperature` 和 `max_tokens` 参数,观察输出变化\n", + "5. 使用流式输出模式,让模型生成一段关于人工智能的短文" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.4" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/04_LangChain概述.ipynb b/04_LangChain概述.ipynb new file mode 100644 index 0000000..bcc29d4 --- /dev/null +++ b/04_LangChain概述.ipynb @@ -0,0 +1,303 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 04 LangChain 概述\n", + "\n", + "## 学习目标\n", + "1. 理解 LangChain 产生的背景和解决的问题\n", + "2. 掌握 LangChain 的核心架构与组件组成\n", + "3. 能够运行第一个 LangChain 程序,体验链式调用的便利性\n", + "4. 了解 LangChain 生态中的相关工具(LangServe、LangSmith 等)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. 为什么需要 LangChain\n", + "\n", + "前两节课中,我们直接通过 openai 或 anthropic SDK 调用大模型 API。这种方式简单直接,但当应用变得复杂时,会遇到以下问题:\n", + "\n", + "- **Prompt 管理混乱**:硬编码在代码中,难以维护和复用\n", + "- **代码重复**:每次调用都要写重复的 API 初始化、参数配置\n", + "- **模型切换困难**:换一个模型提供商需要重写大量代码\n", + "- **缺乏标准流程**:没有统一的输入处理、输出解析规范\n", + "\n", + "**LangChain** 就是为了解决这些问题而生的框架。它将大模型应用的开发流程抽象为标准化组件,让开发者可以像搭积木一样构建复杂的 AI 应用。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. LangChain 是什么\n", + "\n", + "LangChain 是一个用于构建基于大语言模型(LLM)应用的 Python/JS 框架,由 Harrison Chase 于 2022 年创建。\n", + "\n", + "### 核心设计思想\n", + "\n", + "- **组件化**:将大模型应用拆解为可复用的标准组件\n", + "- **链式组合**:通过管道(|)将多个组件串联成处理流程\n", + "- **模型无关**:同一套代码可以切换不同厂商的模型\n", + "- **生态丰富**:提供大量预集成工具(向量库、搜索引擎、数据库等)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. LangChain 核心架构\n", + "\n", + "LangChain 将大模型应用抽象为四大核心模块:\n", + "\n", + "### 四大核心模块\n", + "\n", + "| 模块 | 作用 | 本课程对应章节 |\n", + "| --- | --- | --- |\n", + "| **Model I/O** | 模型输入输出管理(Prompt、Model、Output Parser) | 05-07 节 |\n", + "| **Retrieval** | 检索增强生成(RAG):文档加载、向量化、检索 | 11-13 节 |\n", + "| **Chains** | 链式组合,将多个组件串联成工作流 | 05、08 节 |\n", + "| **Agents** | 智能体:让模型自主决策、调用工具完成任务 | 09-10 节 |\n", + "\n", + "### 组件关系图\n", + "\n", + "用户输入 -> Prompt模板 -> LLM模型 -> 输出解析 -> 最终结果\n", + "\n", + "例如:\n", + "\n", + "chain = prompt | llm | parser\n", + "result = chain.invoke({'input': '用户问题'})" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. 第一个 LangChain 程序\n", + "\n", + "我们先看一个对比示例:直接调用 API vs 使用 LangChain。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 方式一:直接调用 API(传统方式)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from openai import OpenAI\n", + "import os\n", + "from dotenv import load_dotenv\n", + "\n", + "load_dotenv()\n", + "\n", + "\n", + "client = OpenAI(\n", + " base_url=os.getenv('OPENAI_BASE_URL'),\n", + " api_key=os.getenv('OPENAI_API_KEY')\n", + ")\n", + "\n", + "response = client.chat.completions.create(\n", + " model='qwen3.6-35b-A3b',\n", + " messages=[\n", + " {'role': 'system', 'content': '你是一个翻译助手,将中文翻译成英文。'},\n", + " {'role': 'user', 'content': '你好,世界'}\n", + " ]\n", + ")\n", + "print(response.choices[0].message.content)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 方式二:使用 LangChain(推荐方式)\n", + "\n", + "LangChain 将 Prompt、模型调用、输出解析封装成标准组件,代码更简洁、更易维护。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "import os\n", + "\n", + "# 1. 创建模型(自动读取环境变量 OPENAI_BASE_URL 和 OPENAI_API_KEY)\n", + "llm = ChatOpenAI(\n", + " model='qwen3.6-35b-A3b',\n", + " temperature=0.7\n", + ")\n", + "\n", + "# 2. 创建 Prompt 模板\n", + "prompt = ChatPromptTemplate.from_messages([\n", + " ('system', '你是一个翻译助手,将中文翻译成英文。'),\n", + " ('user', '{input}')\n", + "])\n", + "\n", + "# 3. 构建链:Prompt -> LLM\n", + "chain = prompt | llm\n", + "\n", + "# 4. 运行链\n", + "result = chain.invoke({'input': '你好,世界'})\n", + "print(result.content)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 对比总结\n", + "\n", + "| 特性 | 直接 API | LangChain |\n", + "| --- | --- | --- |\n", + "| Prompt 管理 | 硬编码在 messages 中 | 模板化,支持变量复用 |\n", + "| 代码可读性 | 每次都要写完整 API 调用 | 语义清晰:prompt \\| llm |\n", + "| 模型切换 | 重写 SDK 初始化代码 | 换一行 model= 即可 |\n", + "| 输出处理 | 手动解析 choices[0] | 自动解析,支持结构化输出 |\n", + "| 扩展性 | 难以添加后续处理步骤 | 可继续追加 \\| parser 等组件 |" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. LangChain 核心组件速览\n", + "\n", + "本课程后续章节会逐一深入讲解,这里先建立整体印象。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 5.1 Model(模型)\n", + "\n", + "LangChain 封装了各大厂商的模型,统一接口:\n", + "\n", + "```python\n", + "from langchain_openai import ChatOpenAI # OpenAI 兼容格式\n", + "from langchain_anthropic import ChatAnthropic # Anthropic Claude\n", + "from langchain_community.llms import Tongyi # 阿里通义千问\n", + "\n", + "llm = ChatOpenAI(model='qwen3.6-35b-A3b')\n", + "```\n", + "\n", + "所有模型都支持 .invoke()、.stream()、.batch() 等统一方法。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 5.2 Prompt(提示模板)\n", + "\n", + "将 Prompt 模板化,支持变量插入和复用:\n", + "\n", + "```python\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "\n", + "prompt = ChatPromptTemplate.from_messages([\n", + " ('system', '你是一个{role}。'),\n", + " ('user', '请{task}:{content}')\n", + "])\n", + "\n", + "prompt.format(role='翻译助手', task='翻译', content='Hello')\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 5.3 Output Parser(输出解析器)\n", + "\n", + "将模型输出的文本解析为结构化数据(JSON、列表、Pydantic 对象等):\n", + "\n", + "```python\n", + "from langchain_core.output_parsers import JsonOutputParser\n", + "\n", + "parser = JsonOutputParser()\n", + "chain = prompt | llm | parser # 模型输出自动解析为 JSON\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 5.4 Chain(链)\n", + "\n", + "链是 LangChain 的核心概念,表示数据的处理流程。使用 | 运算符组合:\n", + "\n", + "```python\n", + "chain = prompt | llm | parser\n", + "result = chain.invoke({'input': '用户输入'})\n", + "```\n", + "\n", + "数据流:用户输入 -> Prompt模板 -> LLM -> 输出解析 -> 最终结果" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. LangChain 生态工具\n", + "\n", + "| 工具 | 作用 |\n", + "| --- | --- |\n", + "| **LangGraph** | 构建复杂状态图和多智能体工作流(本课程核心内容) |\n", + "| **LangServe** | 将 LangChain 链部署为 REST API 服务 |\n", + "| **LangSmith** | 调试、监控和评估 LangChain 应用的可观测性平台 |\n", + "| **LangChain Hub** | 社区共享的 Prompt 模板市场 |\n", + "\n", + "本课程重点讲解 LangChain 核心组件 + LangGraph 智能体,LangSmith 会在最后的部署评估环节介绍。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7. 本节课练习\n", + "\n", + "1. 对比直接调用 API 和 LangChain 的代码,体会 LangChain 的便利性\n", + "2. 修改上面的 LangChain 示例,将 system 提示改为「你是一个诗人」,将输入改为「写一句关于月亮的诗」\n", + "3. 尝试在 ChatOpenAI 中切换 temperature(0.0、1.0、2.0),观察输出变化\n", + "4. 尝试将 prompt | llm 链保存为一个变量,多次调用 invoke() 传入不同输入" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.4" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/05_LLM链.ipynb b/05_LLM链.ipynb new file mode 100644 index 0000000..9d2654d --- /dev/null +++ b/05_LLM链.ipynb @@ -0,0 +1,393 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 05 LLM 链\n", + "\n", + "## 学习目标\n", + "1. 理解 LLMChain 的本质:Prompt 模板 + 模型 + 输出解析的组合\n", + "2. 掌握使用 LCEL(LangChain Expression Language)管道符构建链\n", + "3. 理解数据在链中的流动过程:输入 -> Prompt -> LLM -> 输出\n", + "4. 学会使用链进行单条调用、批量调用和流式输出" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. 什么是 LLMChain\n", + "\n", + "LLMChain 是 LangChain 中最基础、最常用的链类型。它表示一个简单的处理流程:\n", + "\n", + "```\n", + "用户输入 -> Prompt模板 -> 大模型 -> 输出结果\n", + "```\n", + "\n", + "在 LangChain 的新版(LCEL)中,我们不再使用专门的 `LLMChain` 类,而是直接用 `|` 管道符将组件串联起来:\n", + "\n", + "```python\n", + "chain = prompt | llm\n", + "```\n", + "\n", + "这种方式更直观、更灵活,也是本课程推荐的标准写法。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. 构建第一个 LLMChain\n", + "\n", + "我们需要三个基本组件:\n", + "1. **Prompt 模板**:定义输入格式和系统提示\n", + "2. **LLM 模型**:实际调用的大模型\n", + "3. **链**:将两者用 `|` 连接" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "from dotenv import load_dotenv\n", + "\n", + "load_dotenv()\n", + "\n", + "\n", + "# 1. 创建模型\n", + "llm = ChatOpenAI(\n", + " model='qwen3.6-35b-A3b',\n", + " temperature=0.7\n", + ")\n", + "\n", + "# 2. 创建 Prompt 模板\n", + "prompt = ChatPromptTemplate.from_messages([\n", + " ('system', '你是一个专业的翻译助手,只输出翻译结果,不做解释。'),\n", + " ('user', '请将以下文本翻译成英文:{text}')\n", + "])\n", + "\n", + "# 3. 构建链\n", + "chain = prompt | llm\n", + "\n", + "# 4. 运行链\n", + "result = chain.invoke({'text': '人工智能正在改变世界'})\n", + "print(result.content)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解析\n", + "\n", + "| 步骤 | 代码 | 作用 |\n", + "| --- | --- | --- |\n", + "| 1 | `ChatOpenAI(...)` | 创建模型实例,指定模型名称和温度 |\n", + "| 2 | `ChatPromptTemplate.from_messages(...)` | 创建提示模板,包含 system 和 user 消息 |\n", + "| 3 | `prompt \\| llm` | 用管道符连接,形成处理链 |\n", + "| 4 | `chain.invoke({'text': '...'})` | 传入变量,执行链并获取结果 |\n", + "\n", + "`invoke()` 返回的是 `AIMessage` 对象,用 `.content` 获取文本内容。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. 数据流详解\n", + "\n", + "当调用 `chain.invoke({'text': '人工智能正在改变世界'})` 时,数据在链中是这样流动的:\n", + "\n", + "```text\n", + "输入: {'text': '人工智能正在改变世界'}\n", + " |\n", + " v\n", + "prompt: 将变量填入模板\n", + " system: 你是一个专业的翻译助手,只输出翻译结果,不做解释。\n", + " user: 请将以下文本翻译成英文:人工智能正在改变世界\n", + " |\n", + " v\n", + "llm: 调用大模型 API\n", + " |\n", + " v\n", + "输出: AIMessage(content='AI is changing the world.')\n", + "```\n", + "\n", + "链的每个环节都是 **Runnable**(可运行对象),它们都实现了统一的接口:\n", + "- `.invoke(input)` — 单条调用\n", + "- `.batch(inputs)` — 批量调用\n", + "- `.stream(input)` — 流式输出\n", + "- `.ainvoke(input)` — 异步调用" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. 多变量输入\n", + "\n", + "Prompt 模板中可以定义多个变量,invoke 时传入字典即可:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "\n", + "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.7)\n", + "\n", + "# 多变量 Prompt 模板\n", + "prompt = ChatPromptTemplate.from_messages([\n", + " ('system', '你是一位{style}作家。'),\n", + " ('user', '请用{style}的风格写一段关于{topic}的{format},约{length}字。')\n", + "])\n", + "\n", + "chain = prompt | llm\n", + "\n", + "result = chain.invoke({\n", + " 'style': '浪漫主义',\n", + " 'topic': '星空',\n", + " 'format': '散文',\n", + " 'length': '100'\n", + "})\n", + "print(result.content)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. 批量调用(Batch)\n", + "\n", + "当需要处理多条输入时,使用 `.batch()` 可以一次性发送多个请求,效率更高:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "\n", + "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.7)\n", + "\n", + "prompt = ChatPromptTemplate.from_messages([\n", + " ('system', '你是一个简洁的助手,只输出结果。'),\n", + " ('user', '将以下中文翻译成英文:{text}')\n", + "])\n", + "\n", + "chain = prompt | llm\n", + "\n", + "# 批量输入\n", + "inputs = [\n", + " {'text': '你好'},\n", + " {'text': '谢谢'},\n", + " {'text': '再见'},\n", + " {'text': '人工智能'}\n", + "]\n", + "\n", + "results = chain.batch(inputs)\n", + "for i, result in enumerate(results):\n", + " print(f'{i+1}. {result.content}')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. 流式输出(Stream)\n", + "\n", + "链也支持流式输出,模型会逐字返回结果:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "\n", + "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.7)\n", + "\n", + "prompt = ChatPromptTemplate.from_messages([\n", + " ('system', '你是一个诗人。'),\n", + " ('user', '写一首关于{topic}的短诗。')\n", + "])\n", + "\n", + "chain = prompt | llm\n", + "\n", + "print('生成中:', end='')\n", + "for chunk in chain.stream({'topic': '秋天'}):\n", + " print(chunk.content, end='')\n", + "print()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7. 链的重用与组合\n", + "\n", + "定义好的链可以像函数一样多次复用,也可以作为更大链的一部分继续组合。本节演示三个核心概念:\n", + "\n", + "### 核心概念\n", + "\n", + "1. **StrOutputParser()**:输出解析器,自动把模型的 AIMessage 对象转换成纯字符串\n", + "2. **链的复用**:同一个 chain 变量可以多次调用 invoke(),传入不同输入\n", + "3. **链的组合**:把多个链用 `|` 连接起来,前一个链的输出作为后一个链的输入\n", + "\n", + "### 组合链的数据流\n", + "\n", + "```\n", + "输入: {'text': '大语言模型正在改变软件开发的方式'}\n", + " |\n", + " v\n", + "translate_chain: 中文 -> 英文\n", + " 输出: 'Large language models are changing the way software is developed.'\n", + " |\n", + " v\n", + "lambda x: {'text': x}: 把字符串包装成 summarize_chain 需要的字典格式\n", + " 输出: {'text': 'Large language models are changing...'}\n", + " |\n", + " v\n", + "summarize_chain: 总结成一句话\n", + " 输出: '大型语言模型正在改变软件开发的方式。'\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "from langchain_core.output_parsers import StrOutputParser\n", + "\n", + "# 创建共享的 LLM 实例\n", + "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.7)\n", + "\n", + "# ========== 1. 定义可复用的翻译链 ==========\n", + "# 先创建翻译用的 Prompt 模板\n", + "translate_prompt = ChatPromptTemplate.from_messages([\n", + " ('system', '你是一个翻译助手,将中文翻译成英文。'),\n", + " ('user', '{text}') # {text} 是输入变量\n", + "])\n", + "\n", + "# 构建翻译链:Prompt -> LLM -> 字符串输出解析器\n", + "# StrOutputParser() 的作用:把模型返回的 AIMessage 对象直接转成 str\n", + "translate_chain = translate_prompt | llm | StrOutputParser()\n", + "\n", + "# ========== 2. 链的复用 ==========\n", + "# 同一个 translate_chain 可以像函数一样多次调用\n", + "# 每次 invoke() 传入不同的 {'text': '...'},得到对应的英文翻译\n", + "print(translate_chain.invoke({'text': '苹果'}))\n", + "print(translate_chain.invoke({'text': '香蕉'}))\n", + "print(translate_chain.invoke({'text': '桃子'}))\n", + "\n", + "# ========== 3. 链的组合 ==========\n", + "# 再定义一个总结链,同样接收 {text} 变量\n", + "summarize_prompt = ChatPromptTemplate.from_messages([\n", + " ('system', '请用一句话总结以下内容。'),\n", + " ('user', '{text}')\n", + "])\n", + "summarize_chain = summarize_prompt | llm | StrOutputParser()\n", + "\n", + "# 组合链:翻译后的英文传给总结链\n", + "# translate_chain 的输出是字符串,但 summarize_chain 需要字典 {'text': ...}\n", + "# 所以中间加一个 lambda 函数做格式转换\n", + "full_chain = translate_chain | (lambda x: {'text': x}) | summarize_chain\n", + "\n", + "# 运行组合链:输入中文 -> 翻译成英文 -> 总结成中文\n", + "result = full_chain.invoke({'text': '大语言模型写代码的能力越来越强。使用ai开发代码让传统的软件开发方式被改变。'})\n", + "print(f'\\n翻译+总结:{result}')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 8. 链的调试与查看中间结果\n", + "\n", + "使用 `StrOutputParser()` 可以自动将模型输出转换为字符串,省去手动 `.content` 的步骤。\n", + "\n", + "如果想查看链运行过程中的中间状态(比如格式化后的 Prompt 是什么样的),可以使用回调或调试工具:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "\n", + "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.7)\n", + "\n", + "prompt = ChatPromptTemplate.from_messages([\n", + " ('system', '你是一个助手。'),\n", + " ('user', '你好,我叫{name}')\n", + "])\n", + "\n", + "# 查看格式化后的 Prompt\n", + "formatted = prompt.format_messages(name='小明')\n", + "print('格式化后的 Prompt:')\n", + "for msg in formatted:\n", + " print(f' {msg.type}: {msg.content}')\n", + "\n", + "chain = prompt | llm\n", + "result = chain.invoke({'name': '小明'})\n", + "print(f'\\n模型回复:{result.content}')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 9. 本节课练习\n", + "\n", + "1. 创建一个 LLMChain,将用户的输入翻译成法文(修改 system 提示和 user 模板)\n", + "2. 使用多变量输入,创建一个可以根据「风格」和「主题」生成不同文章的链\n", + "3. 使用 `.batch()` 一次性翻译 5 个不同的中文句子\n", + "4. 使用 `.stream()` 让模型逐字生成一段关于「未来科技」的短文\n", + "5. 尝试在链末尾加上 `| StrOutputParser()`,观察返回结果类型的变化" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.3" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/06_Prompt模板.ipynb b/06_Prompt模板.ipynb new file mode 100644 index 0000000..e57e1a7 --- /dev/null +++ b/06_Prompt模板.ipynb @@ -0,0 +1,579 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 06 Prompt 模板\n", + "\n", + "## 学习目标\n", + "1. 理解 Prompt 模板的作用:将 Prompt 从硬编码变为可复用、可配置的组件\n", + "2. 掌握两种最常用的模板类型:PromptTemplate 和 ChatPromptTemplate\n", + "3. 学会使用变量占位符 {name} 动态填充内容\n", + "4. 理解 Few-Shot Prompt 少样本提示的构建方法\n", + "5. 了解常见的 Prompt Engineering 技巧及其对模型输出的影响" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. 为什么需要 Prompt 模板\n", + "\n", + "直接拼接字符串来构造 Prompt 会带来几个问题:\n", + "\n", + "- **可读性差**:Python 字符串拼接难以看清最终给模型的提示长什么样\n", + "- **容易出错**:变量多的时候容易遗漏、顺序错乱\n", + "- **难以复用**:同样的提示结构无法直接复用到不同输入\n", + "- **难以管理**:无法集中管理和版本化 Prompt\n", + "\n", + "**Prompt 模板** 就是解决这些问题的方案。它允许你:\n", + "\n", + "- 把 Prompt 写成一个带占位符的模板\n", + "- 运行时传入变量自动填充\n", + "- 复用同一个结构处理不同输入\n", + "- 把提示逻辑与业务逻辑分离" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. PromptTemplate:基础字符串模板\n", + "\n", + "`PromptTemplate` 是最基础的模板类,适用于构造单条文本提示。它只有一个 `template` 字符串和若干 {变量}。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "from dotenv import load_dotenv\n", + "\n", + "load_dotenv()\n", + "\n", + "\n", + "# 创建模型\n", + "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.7)\n", + "\n", + "# 使用元组列表定义消息模板\n", + "prompt = ChatPromptTemplate.from_messages([\n", + " ('system', '你是一位{role},擅长用{style}风格回答问题。'),\n", + " ('user', '{question}')\n", + "])\n", + "\n", + "# 查看组装后的 Prompt\n", + "input_vars = {\n", + " 'role': '计算机科学家',\n", + " 'style': '通俗易懂',\n", + " 'question': '什么是递归?'\n", + "}\n", + "print('===== 组装后的 Prompt =====')\n", + "for msg in prompt.format_messages(**input_vars):\n", + " print(f'{msg.type}: {msg.content}')\n", + "\n", + "# 构建链并调用\n", + "chain = prompt | llm\n", + "print('\\n===== 大模型输出 =====')\n", + "result = chain.invoke(input_vars)\n", + "print(result.content)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 2.1 使用 from_template 快速创建\n", + "\n", + "如果你习惯简单写法,可以使用 `from_template` 类方法,自动识别模板中的变量。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "from langchain_core.prompts import SystemMessagePromptTemplate, HumanMessagePromptTemplate\n", + "\n", + "# 创建模型\n", + "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.7)\n", + "\n", + "prompt = ChatPromptTemplate.from_messages([\n", + " SystemMessagePromptTemplate.from_template('你是一位耐心的老师。'),\n", + " HumanMessagePromptTemplate.from_template('请解释{concept},要求{requirement}。')\n", + "])\n", + "\n", + "# 查看组装后的 Prompt\n", + "input_vars = {\n", + " 'concept': '神经网络',\n", + " 'requirement': '用小学生能听懂的语言'\n", + "}\n", + "print('===== 组装后的 Prompt =====')\n", + "for msg in prompt.format_messages(**input_vars):\n", + " print(f'{msg.type}: {msg.content}')\n", + "\n", + "# 构建链并调用\n", + "chain = prompt | llm\n", + "print('\\n===== 大模型输出 =====')\n", + "result = chain.invoke(input_vars)\n", + "print(result.content)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. ChatPromptTemplate:聊天消息模板\n", + "\n", + "现代大模型通常采用对话格式,每条消息都有角色(role)。`ChatPromptTemplate` 就是为这种场景设计的。\n", + "\n", + "常见的消息类型:\n", + "\n", + "| 消息类型 | 作用 |\n", + "| --- | --- |\n", + "| system | 设定模型的身份、能力和行为规则 |\n", + "| user / human | 用户的输入问题 |\n", + "| assistant / ai | 模型的回复,可用于 Few-Shot 示例 |\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "\n", + "# 创建模型\n", + "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.7)\n", + "\n", + "prompt = ChatPromptTemplate.from_messages([\n", + " ('system', '你是一位{role}。'),\n", + " ('user', '请{action}:{content}')\n", + "])\n", + "\n", + "# 先固定 role 变量,得到一个新的模板\n", + "translator_prompt = prompt.partial(role='专业翻译')\n", + "\n", + "# 查看组装后的 Prompt\n", + "input_vars = {\n", + " 'action': '把以下中文翻译成英文',\n", + " 'content': '今天天气很好'\n", + "}\n", + "print('===== 组装后的 Prompt =====')\n", + "for msg in translator_prompt.format_messages(**input_vars):\n", + " print(f'{msg.type}: {msg.content}')\n", + "\n", + "# 构建链并调用\n", + "chain = translator_prompt | llm\n", + "print('\\n===== 大模型输出 =====')\n", + "result = chain.invoke(input_vars)\n", + "print(result.content)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 3.1 使用消息模板对象构建模板\n", + "\n", + "除了元组,也可以使用 LangChain 提供的**消息模板类**来构建模板。这种方式语义更清晰,而且同样支持变量替换。\n", + "\n", + "**注意区分两类对象**:\n", + "- `SystemMessage` / `HumanMessage`:普通消息对象,内容固定,不会解析 `{变量}`\n", + "- `SystemMessagePromptTemplate` / `HumanMessagePromptTemplate`:消息模板对象,内容中的 `{变量}` 会被替换" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "\n", + "# 创建模型\n", + "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.1)\n", + "\n", + "prompt = ChatPromptTemplate.from_messages([\n", + " ('system', '你是一个情绪分析助手,只输出正面或负面。'),\n", + " ('human', '这个产品太差了'),\n", + " ('ai', '负面'),\n", + " ('human', '这次的体验非常愉快'),\n", + " ('ai', '正面'),\n", + " ('human', '{text}')\n", + "])\n", + "\n", + "# 查看组装后的 Prompt\n", + "input_vars = {'text': '物流速度还可以,但包装破损了'}\n", + "print('===== 组装后的 Prompt =====')\n", + "for msg in prompt.format_messages(**input_vars):\n", + " print(f'{msg.type}: {msg.content}')\n", + "\n", + "# 构建链并调用\n", + "chain = prompt | llm\n", + "print('\\n===== 大模型输出 =====')\n", + "result = chain.invoke(input_vars)\n", + "print(result.content)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. 模板变量的高级用法\n", + "\n", + "### 4.1 默认值与 partial\n", + "\n", + "你可以先填充部分变量,得到一个新的模板,后续再填充剩余变量。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "from langchain_core.prompts import ChatPromptTemplate, FewShotChatMessagePromptTemplate\n", + "\n", + "# 创建模型\n", + "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.1)\n", + "\n", + "# 准备示例数据\n", + "examples = [\n", + " {'input': '苹果', 'output': 'apple'},\n", + " {'input': '香蕉', 'output': 'banana'},\n", + " {'input': '人工智能', 'output': 'artificial intelligence'},\n", + "]\n", + "\n", + "# 定义每个示例的格式\n", + "example_prompt = ChatPromptTemplate.from_messages([\n", + " ('human', '{input}'),\n", + " ('ai', '{output}')\n", + "])\n", + "\n", + "# 构建 Few-Shot 提示模板\n", + "few_shot_prompt = FewShotChatMessagePromptTemplate(\n", + " example_prompt=example_prompt,\n", + " examples=examples\n", + ")\n", + "\n", + "# 组合成完整提示\n", + "final_prompt = ChatPromptTemplate.from_messages([\n", + " ('system', '你是一个中英文翻译助手,请参考示例回答问题。'),\n", + " few_shot_prompt,\n", + " ('human', '{input}')\n", + "])\n", + "\n", + "# 查看组装后的 Prompt\n", + "input_vars = {'input': '机器学习'}\n", + "print('===== 组装后的 Prompt =====')\n", + "for msg in final_prompt.format_messages(**input_vars):\n", + " print(f'{msg.type}: {msg.content}')\n", + "\n", + "# 构建链并调用\n", + "chain = final_prompt | llm\n", + "print('\\n===== 大模型输出 =====')\n", + "result = chain.invoke(input_vars)\n", + "print(result.content)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 4.2 多轮对话模板\n", + "\n", + "模板中可以预设多轮对话历史,常用于构建 Few-Shot 示例或保持上下文:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_core.prompts import ChatPromptTemplate\n", + "\n", + "prompt = ChatPromptTemplate.from_messages([\n", + " ('system', '你是一个情绪分析助手,只输出正面或负面。'),\n", + " ('human', '这个产品太差了'),\n", + " ('ai', '负面'),\n", + " ('human', '这次的体验非常愉快'),\n", + " ('ai', '正面'),\n", + " ('human', '{text}')\n", + "])\n", + "\n", + "messages = prompt.format_messages(text='物流速度还可以,但包装破损了')\n", + "for msg in messages:\n", + " print(f'{msg.type}: {msg.content}')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. Few-Shot Prompt:少样本提示\n", + "\n", + "Few-Shot Prompt 是 Prompt Engineering 中最有效的技巧之一。它通过在输入前给出若干「示例-答案」对,让模型学习期望的输出格式和风格。\n", + "\n", + "LangChain 提供了 `FewShotPromptTemplate` 和 `FewShotChatMessagePromptTemplate` 两种主要方式。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_core.prompts import ChatPromptTemplate, FewShotChatMessagePromptTemplate\n", + "\n", + "# 准备示例数据\n", + "examples = [\n", + " {'input': '苹果', 'output': 'apple'},\n", + " {'input': '香蕉', 'output': 'banana'},\n", + " {'input': '人工智能', 'output': 'artificial intelligence'},\n", + "]\n", + "\n", + "# 定义每个示例的格式\n", + "example_prompt = ChatPromptTemplate.from_messages([\n", + " ('human', '{input}'),\n", + " ('ai', '{output}')\n", + "])\n", + "\n", + "# 构建 Few-Shot 提示模板\n", + "few_shot_prompt = FewShotChatMessagePromptTemplate(\n", + " example_prompt=example_prompt,\n", + " examples=examples\n", + ")\n", + "\n", + "# 组合成完整提示\n", + "final_prompt = ChatPromptTemplate.from_messages([\n", + " ('system', '你是一个中英文翻译助手,请参考示例回答问题。'),\n", + " few_shot_prompt,\n", + " ('human', '{input}')\n", + "])\n", + "\n", + "messages = final_prompt.format_messages(input='机器学习')\n", + "for msg in messages:\n", + " print(f'{msg.type}: {msg.content}')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. Prompt Engineering 技巧\n", + "\n", + "Prompt Engineering 是指通过设计和优化提示词,让模型输出更准确、更符合预期。下面介绍几个最常用、最有效的技巧。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 6.1 角色扮演(Role Prompting)\n", + "\n", + "给模型设定一个清晰的角色,可以显著提升回答质量。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "\n", + "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.7)\n", + "\n", + "# 无角色设定\n", + "prompt1 = ChatPromptTemplate.from_messages([\n", + " ('user', '解释什么是区块链')\n", + "])\n", + "\n", + "# 有角色设定\n", + "prompt2 = ChatPromptTemplate.from_messages([\n", + " ('system', '你是一位资深技术讲师,擅长用比喻和例子解释复杂概念。'),\n", + " ('user', '解释什么是区块链')\n", + "])\n", + "\n", + "chain1 = prompt1 | llm\n", + "chain2 = prompt2 | llm\n", + "\n", + "print('=== 无角色 ===')\n", + "print(chain1.invoke({}).content[:250])\n", + "print('\\n=== 有角色 ===')\n", + "print(chain2.invoke({}).content[:250])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 6.2 明确输出格式(Output Formatting)\n", + "\n", + "如果你希望模型按特定格式输出,要在 Prompt 中明确说明。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "from langchain_core.output_parsers import JsonOutputParser\n", + "\n", + "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.7)\n", + "\n", + "# 把 JSON 格式示例放在 system 消息中\n", + "# 注意:{ 和 } 是 LangChain 的变量占位符,如果要显示字面量大括号,必须写成 {{ 和 }}\n", + "system_template = '''你是一个信息提取助手。请只输出 JSON 格式,不要包含任何解释。\n", + "\n", + "输出格式如下:\n", + "{{\n", + " \"names\": [...],\n", + " \"locations\": [...],\n", + " \"time\": \"\"\n", + "}}'''\n", + "\n", + "prompt = ChatPromptTemplate.from_messages([\n", + " ('system', system_template),\n", + " ('user', '文本:{text}')\n", + "])\n", + "\n", + "chain = prompt | llm | JsonOutputParser()\n", + "\n", + "input_vars = {\n", + " 'text': '2024年5月1日,李明和王芳一起去了北京故宫参观。'\n", + "}\n", + "\n", + "print('===== 组装后的 Prompt =====')\n", + "for msg in prompt.format_messages(**input_vars):\n", + " print(f'{msg.type}: {msg.content}')\n", + "\n", + "print('\\n===== 大模型输出 =====')\n", + "result = chain.invoke(input_vars)\n", + "print(result)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 6.3 分步骤思考(Chain-of-Thought)\n", + "\n", + "对于推理类问题,让模型「一步步思考」往往能显著提升准确率。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "\n", + "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.1)\n", + "\n", + "# 直接提问\n", + "prompt1 = ChatPromptTemplate.from_messages([\n", + " ('user', '问题:一个农场有鸡和兔共35只,脚共94只。鸡和兔各几只?请直接给出答案。')\n", + "])\n", + "\n", + "# 分步思考\n", + "prompt2 = ChatPromptTemplate.from_messages([\n", + " ('user', '问题:一个农场有鸡和兔共35只,脚共94只。鸡和兔各几只?请一步步思考并给出答案。')\n", + "])\n", + "\n", + "print('=== 直接提问 ===')\n", + "print((prompt1 | llm).invoke({}).content)\n", + "print('\\n=== 分步思考 ===')\n", + "print((prompt2 | llm).invoke({}).content)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7. 完整示例:Prompt 模板与链结合\n", + "\n", + "下面是一个综合示例:使用模板构建一个「学习助手」,根据学生年级和知识点生成适合难度的解释。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "from langchain_core.output_parsers import StrOutputParser\n", + "\n", + "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.7)\n", + "\n", + "prompt = ChatPromptTemplate.from_messages([\n", + " ('system', '你是一位{grade}水平的优秀教师。'),\n", + " ('user', '请用适合{grade}学生理解的方式解释 {topic},并举一个生活中的例子。')\n", + "])\n", + "\n", + "chain = prompt | llm | StrOutputParser()\n", + "\n", + "result = chain.invoke({\n", + " 'grade': '小学三年级',\n", + " 'topic': '浮力'\n", + "})\n", + "print(result)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 8. 本节课练习\n", + "\n", + "1. 使用 `PromptTemplate` 创建一个邮件生成模板,变量包含收件人姓名、主题、正文要点,调用 `format` 输出完整邮件\n", + "2. 使用 `ChatPromptTemplate` 创建一个「代码审查助手」模板,system 设定角色,user 传入代码片段\n", + "3. 使用 `.partial()` 固定 system 角色为「技术文档写手」,只传入 user 变量运行\n", + "4. 使用 `FewShotChatMessagePromptTemplate` 创建一个三示例的情绪分类器,然后输入新句子测试\n", + "5. 对比实验:分别用「直接提问」和「分步骤思考」两种方式向模型提问同一道数学题,观察输出差异" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.3" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/07_输出解析器.ipynb b/07_输出解析器.ipynb new file mode 100644 index 0000000..b180b83 --- /dev/null +++ b/07_输出解析器.ipynb @@ -0,0 +1,560 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 07 输出解析器\n", + "\n", + "## 学习目标\n", + "1. 理解输出解析器(Output Parsers)在 LangChain 链中的作用\n", + "2. 掌握常见解析器的用法:StrOutputParser、JsonOutputParser、PydanticOutputParser、CommaSeparatedListOutputParser\n", + "3. 学会使用 `手动 try-except + LLM 修复` 处理模型输出格式异常\n", + "4. 了解如何编写自定义输出解析器\n", + "5. 理解「Prompt 中说明格式 + 解析器约束」配合的重要性" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. 为什么需要输出解析器\n", + "\n", + "大模型返回的通常是**自由文本**,但很多时候我们需要:\n", + "\n", + "- 提取结构化的 JSON 数据\n", + "- 把文本转换成 Python 列表、字典或对象\n", + "- 验证输出是否符合预期的数据格式\n", + "- 过滤掉模型多余的解释,只保留关键信息\n", + "\n", + "**输出解析器**就是链中负责把模型输出转换为结构化数据的组件。\n", + "\n", + "在 LCEL 中,链的常见结构是:\n", + "\n", + "```\n", + "chain = prompt | llm | output_parser\n", + "```\n", + "\n", + "其中 `output_parser` 接收 `AIMessage` 对象,输出我们需要的 Python 数据结构。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. StrOutputParser:最简单的字符串解析器\n", + "\n", + "`StrOutputParser` 会把模型返回的 `AIMessage` 对象直接转换成纯字符串。\n", + "\n", + "它适合的场景:\n", + "- 只需要文本回答\n", + "- 不想每次手动写 `.content`\n", + "- 作为链的最后一步统一输出类型" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "from langchain_core.output_parsers import StrOutputParser\n", + "from dotenv import load_dotenv\n", + "\n", + "load_dotenv()\n", + "\n", + "\n", + "# 创建模型\n", + "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.7)\n", + "\n", + "# 创建 Prompt 模板\n", + "prompt = ChatPromptTemplate.from_messages([\n", + " ('system', '你是一个简洁的助手。'),\n", + " ('user', '用一句话介绍{topic}')\n", + "])\n", + "\n", + "# 构建链:prompt -> llm -> 字符串解析器\n", + "chain = prompt | llm | StrOutputParser()\n", + "\n", + "# 调用链,直接得到字符串\n", + "result = chain.invoke({'topic': '机器学习'})\n", + "\n", + "print('返回类型:', type(result))\n", + "print('返回内容:', result)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "| 代码 | 作用 |\n", + "| --- | --- |\n", + "| `prompt \\| llm \\| StrOutputParser()` | 用管道符串联三个组件 |\n", + "| `StrOutputParser()` | 自动提取 `.content` 并返回字符串 |\n", + "| `type(result)` | 验证返回结果是否为 `str` |\n", + "\n", + "不加 `StrOutputParser()` 时,`chain.invoke()` 返回的是 `AIMessage` 对象;加了之后返回的是 `str`。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. JsonOutputParser:解析 JSON 输出\n", + "\n", + "`JsonOutputParser` 会自动把模型输出的 JSON 字符串解析成 Python 字典。\n", + "\n", + "使用要点:\n", + "1. 在 Prompt 中明确要求模型输出 JSON\n", + "2. JSON 中的大括号 `{` 在 Prompt 模板中需要写成 `{{` 进行转义\n", + "3. 模型输出必须能被 `json.loads()` 解析" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "from langchain_core.output_parsers import JsonOutputParser\n", + "\n", + "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.1)\n", + "\n", + "# 注意:模板中的 {{ 和 }} 会被 LangChain 渲染成单个 { 和 }\n", + "system_template = '''你是一个信息提取助手。请只输出 JSON 格式,不要包含任何解释。\n", + "\n", + "输出格式如下:\n", + "{{\\n\n", + " \"names\": [...],\\n\n", + " \"locations\": [...],\\n\n", + " \"time\": \"\"\\n\n", + "}}'''\n", + "\n", + "prompt = ChatPromptTemplate.from_messages([\n", + " ('system', system_template),\n", + " ('user', '文本:{text}')\n", + "])\n", + "\n", + "chain = prompt | llm | JsonOutputParser()\n", + "\n", + "result = chain.invoke({\n", + " 'text': '2024年5月1日,李明和王芳一起去了北京故宫参观。'\n", + "})\n", + "\n", + "print('返回类型:', type(result))\n", + "print('返回内容:', result)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "- `system_template` 中的 `{{` 和 `}}` 是**LangChain 模板转义语法**,表示最终 Prompt 中显示为单个 `{` 和 `}`\n", + "- `JsonOutputParser()` 会调用 `json.loads()` 解析模型输出\n", + "- 如果模型输出不是合法 JSON,会抛出异常\n", + "\n", + "返回类型是 `dict`,可以直接像 `result['names']` 这样访问字段。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. PydanticOutputParser:解析为 Pydantic 对象\n", + "\n", + "如果你需要更严格的类型校验和结构化数据,可以使用 `PydanticOutputParser`。\n", + "\n", + "Pydantic 是 Python 中非常流行的数据验证库。通过定义数据模型,可以:\n", + "- 明确每个字段的类型\n", + "- 自动验证数据格式\n", + "- 把模型输出转换成可操作的 Python 对象" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "from langchain_core.output_parsers import PydanticOutputParser\n", + "from pydantic import BaseModel, Field\n", + "\n", + "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.1)\n", + "\n", + "# 第一步:定义 Pydantic 数据模型\n", + "class PersonInfo(BaseModel):\n", + " name: str = Field(description='人物姓名')\n", + " age: int = Field(description='人物年龄')\n", + " city: str = Field(description='所在城市')\n", + " hobbies: list[str] = Field(description='兴趣爱好列表')\n", + "\n", + "# 第二步:创建解析器\n", + "parser = PydanticOutputParser(pydantic_object=PersonInfo)\n", + "\n", + "# 第三步:在 Prompt 中嵌入格式说明\n", + "prompt = ChatPromptTemplate.from_messages([\n", + " ('system', '''你是一个信息提取助手。请从文本中提取人物信息,按指定格式输出。\\n\n", + "{format_instructions}'''),\n", + " ('user', '文本:{text}')\n", + "])\n", + "\n", + "# format_instructions 会自动生成 JSON Schema 说明\n", + "prompt_with_format = prompt.partial(format_instructions=parser.get_format_instructions())\n", + "\n", + "# 构建链\n", + "chain = prompt_with_format | llm | parser\n", + "\n", + "result = chain.invoke({\n", + " 'text': '张三今年25岁,住在杭州,喜欢打篮球和编程。'\n", + "})\n", + "\n", + "print('返回类型:', type(result))\n", + "print('返回对象:', result)\n", + "print('姓名:', result.name)\n", + "print('年龄:', result.age)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "| 步骤 | 代码 | 说明 |\n", + "| --- | --- | --- |\n", + "| 1 | `class PersonInfo(BaseModel)` | 定义数据结构,每个字段都有类型和描述 |\n", + "| 2 | `PydanticOutputParser(pydantic_object=PersonInfo)` | 创建解析器,指定要解析的模型 |\n", + "| 3 | `parser.get_format_instructions()` | 自动生成格式说明,告诉模型如何输出 |\n", + "| 4 | `prompt.partial(format_instructions=...)` | 把格式说明预先填充到 Prompt 中 |\n", + "| 5 | `chain.invoke(...)` | 返回的是 `PersonInfo` 对象 |\n", + "\n", + "Pydantic 会自动校验类型,比如 `age` 必须是整数,否则报错。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. CommaSeparatedListOutputParser:解析逗号分隔列表\n", + "\n", + "当你需要模型返回一个列表时,可以使用 `CommaSeparatedListOutputParser`。\n", + "\n", + "它适合的场景:\n", + "- 提取关键词\n", + "- 生成待办事项\n", + "- 输出多个选项" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "from langchain_core.output_parsers import CommaSeparatedListOutputParser\n", + "\n", + "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.7)\n", + "\n", + "# 创建解析器\n", + "parser = CommaSeparatedListOutputParser()\n", + "\n", + "# 获取格式说明\n", + "format_instructions = parser.get_format_instructions()\n", + "\n", + "prompt = ChatPromptTemplate.from_messages([\n", + " ('system', '你是一个关键词提取助手。{format_instructions}'),\n", + " ('user', '请从以下文本中提取3-5个关键词:{text}')\n", + "])\n", + "\n", + "# 预填充格式说明\n", + "prompt_with_format = prompt.partial(format_instructions=format_instructions)\n", + "\n", + "chain = prompt_with_format | llm | parser\n", + "\n", + "result = chain.invoke({\n", + " 'text': '人工智能、机器学习和深度学习正在改变各个行业的运作方式。'\n", + "})\n", + "\n", + "print('返回类型:', type(result))\n", + "print('关键词列表:', result)\n", + "print('第一个关键词:', result[0])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "- `CommaSeparatedListOutputParser()` 会自动生成格式说明:「用逗号分隔各项,不要带编号和多余解释」\n", + "- `parser.get_format_instructions()` 返回一段英文说明文本\n", + "- 返回结果是 Python 列表 `list[str]`\n", + "- 可以直接用索引访问,比如 `result[0]`" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. 处理格式异常:手动 try-except + LLM 修复\n", + "\n", + "大模型有时不会严格按 JSON 格式输出,可能包含多余文字、缺失引号或注释。\n", + "\n", + "`手动 try-except + LLM 修复` 可以包装另一个解析器,当第一次解析失败时,自动调用 LLM 修复输出格式。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "from langchain_core.output_parsers import JsonOutputParser\n", + "import json\n", + "\n", + "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.1)\n", + "\n", + "# 定义一个 JSON 解析器\n", + "parser = JsonOutputParser()\n", + "\n", + "# 提示模型输出 JSON,但不强制约束格式\n", + "prompt = ChatPromptTemplate.from_messages([\n", + " ('system', '请把用户信息转换成 JSON 格式输出。'),\n", + " ('user', '内容:{text}')\n", + "])\n", + "\n", + "# 先获取模型的原始文本输出\n", + "raw_chain = prompt | llm\n", + "raw_output = raw_chain.invoke({'text': '姓名:李四,年龄:30,城市:上海'})\n", + "\n", + "print('===== 原始模型输出 =====')\n", + "print(raw_output.content)\n", + "\n", + "# 尝试解析 JSON\n", + "try:\n", + " result = parser.invoke(raw_output)\n", + " print('\\n===== 直接解析成功 =====')\n", + " print(result)\n", + "except Exception as e:\n", + " print('\\n===== 解析失败,尝试用 LLM 修复 =====')\n", + " print('错误信息:', e)\n", + "\n", + " # 构造修复 Prompt,让模型把错误输出修正为合法 JSON\n", + " fix_prompt = ChatPromptTemplate.from_messages([\n", + " ('system', '你是一个 JSON 修复专家。请把用户提供的内容修正为合法 JSON,只输出 JSON 字符串,不要任何解释。'),\n", + " ('user', '原始内容:\\n{raw_output}\\n\\n错误信息:{error}')\n", + " ])\n", + "\n", + " fix_chain = fix_prompt | llm\n", + " fixed_output = fix_chain.invoke({\n", + " 'raw_output': raw_output.content,\n", + " 'error': str(e)\n", + " })\n", + "\n", + " # 手动用 json.loads 解析修复后的内容\n", + " result = json.loads(fixed_output.content)\n", + " print('\\n===== 修复后的结果 =====')\n", + " print(result)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "- `手动 try-except + LLM 修复.from_llm(parser=parser, llm=llm)` 用同一个 LLM 修复格式错误\n", + "- 修复逻辑:第一次解析失败 → 把原始输出和错误信息传给 LLM → 请求模型修正为合法格式 → 再次解析\n", + "- 适合用于对稳定性要求较高的场景\n", + "- 注意:修复不一定 100% 成功,极端情况下仍会报错" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7. 自定义输出解析器\n", + "\n", + "如果内置解析器不能满足需求,你可以继承 `BaseOutputParser` 自己实现。\n", + "\n", + "下面是一个简单的自定义解析器:把模型输出按指定分隔符切分成列表。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "from langchain_core.output_parsers import BaseOutputParser\n", + "\n", + "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.7)\n", + "\n", + "# 自定义解析器:按换行符切分,并去除空行和序号\n", + "class NumberedListParser(BaseOutputParser[list[str]]):\n", + " \"\"\"把模型输出的编号列表解析成字符串列表。\"\"\"\n", + "\n", + " def parse(self, text: str) -> list[str]:\n", + " # 按行切分\n", + " lines = text.strip().split('\\n')\n", + " # 去除空行,并去掉每行开头的 1. 2. 3. 等序号\n", + " items = []\n", + " for line in lines:\n", + " line = line.strip()\n", + " if not line:\n", + " continue\n", + " # 去掉行首的数字和点,例如 \"1. \"\n", + " if '. ' in line[:4]:\n", + " line = line.split('. ', 1)[1]\n", + " items.append(line)\n", + " return items\n", + "\n", + "prompt = ChatPromptTemplate.from_messages([\n", + " ('system', '你是一个清单生成助手。请输出编号列表,每行一个项目。'),\n", + " ('user', '请列出学习{topic}的5个步骤')\n", + "])\n", + "\n", + "chain = prompt | llm | NumberedListParser()\n", + "\n", + "result = chain.invoke({'topic': 'Python 编程'})\n", + "\n", + "print('返回类型:', type(result))\n", + "print('返回内容:', result)\n", + "print('步骤数量:', len(result))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "- 继承 `BaseOutputParser[T]`,泛型 `T` 表示返回类型\n", + "- 必须实现 `parse(self, text: str) -> T` 方法\n", + "- `text` 是模型输出的字符串(已经过 `StrOutputParser` 提取 `.content`)\n", + "- 可以在 `parse` 中做任何字符串处理:切分、清洗、正则匹配、校验等\n", + "\n", + "自定义解析器的优势是完全可控,适合处理模型输出不规范的情况。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 8. 完整示例:结构化数据提取\n", + "\n", + "下面是一个综合示例:从一段商品评价中同时提取评分、优点、缺点和关键词。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "from langchain_core.output_parsers import PydanticOutputParser\n", + "from pydantic import BaseModel, Field\n", + "\n", + "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.1)\n", + "\n", + "class Review(BaseModel):\n", + " sentiment: str = Field(description='整体情感,正面/负面/中性')\n", + " pros: list[str] = Field(description='优点列表')\n", + " cons: list[str] = Field(description='缺点列表')\n", + " keywords: list[str] = Field(description='关键词列表')\n", + "\n", + "parser = PydanticOutputParser(pydantic_object=Review)\n", + "\n", + "prompt = ChatPromptTemplate.from_messages([\n", + " ('system', '''你是一个商品评价分析助手。请从评价中提取结构化信息。\\n\n", + "{format_instructions}\\n\n", + "只输出 JSON,不要其他内容。'''),\n", + " ('user', '商品评价:{review}')\n", + "])\n", + "\n", + "prompt_with_format = prompt.partial(format_instructions=parser.get_format_instructions())\n", + "\n", + "chain = prompt_with_format | llm | parser\n", + "\n", + "review_text = '''这款手机外观很漂亮,拍照效果也不错。\n", + "但是电池续航一般,充电速度有点慢。\n", + "总体来说性价比还可以。'''\n", + "\n", + "result = chain.invoke({'review': review_text})\n", + "\n", + "print('情感:', result.sentiment)\n", + "print('优点:', result.pros)\n", + "print('缺点:', result.cons)\n", + "print('关键词:', result.keywords)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 9. 常见解析器对比\n", + "\n", + "| 解析器 | 返回类型 | 适用场景 |\n", + "| --- | --- | --- |\n", + "| `StrOutputParser` | `str` | 只需要文本内容 |\n", + "| `JsonOutputParser` | `dict` | 需要 JSON 字典 |\n", + "| `PydanticOutputParser` | Pydantic 对象 | 需要类型校验和结构化对象 |\n", + "| `CommaSeparatedListOutputParser` | `list[str]` | 逗号分隔的列表 |\n", + "| `手动 try-except + LLM 修复` | 依赖被包装的解析器 | 自动修复格式错误 |\n", + "| 自定义 `BaseOutputParser` | 任意类型 | 特殊格式或复杂后处理 |" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 10. 本节课练习\n", + "\n", + "1. 使用 `StrOutputParser()` 构建一个链,让模型介绍一门编程语言,验证返回类型是否为 `str`\n", + "2. 使用 `JsonOutputParser()` 从一段地址文本中提取省、市、区、街道,输出为 JSON 字典\n", + "3. 定义一个 Pydantic 模型 `BookInfo`(书名、作者、出版年份、类型),用 `PydanticOutputParser` 从文本中提取图书信息\n", + "4. 使用 `CommaSeparatedListOutputParser()` 让模型列出学习 LangChain 的 5 个关键知识点\n", + "5. 尝试让模型故意输出不规范的 JSON,然后用 `手动 try-except + LLM 修复` 包装 `JsonOutputParser` 观察是否能修复" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.3" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/08_链式组合.ipynb b/08_链式组合.ipynb new file mode 100644 index 0000000..354fe11 --- /dev/null +++ b/08_链式组合.ipynb @@ -0,0 +1,558 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 08 链式组合\n", + "\n", + "## 学习目标\n", + "1. 深入理解 LCEL(LangChain Expression Language)管道符 `|` 的组合原理\n", + "2. 掌握 `Runnable` 接口的核心方法:`invoke`、`batch`、`stream`、`ainvoke`\n", + "3. 学会在链中使用 `RunnableLambda` 进行数据格式转换\n", + "4. 理解 `RunnableParallel` 并行执行多个子链\n", + "5. 能够构建复杂的多步骤处理链" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. 什么是 LCEL 链式组合\n", + "\n", + "LCEL 是 LangChain 推荐的新一代链式组合语法。它用管道符 `|` 把各个组件串联起来,就像 Linux 命令行一样直观。\n", + "\n", + "```\n", + "chain = component_a | component_b | component_c\n", + "```\n", + "\n", + "数据流:\n", + "```\n", + "输入 -> component_a -> 中间结果 -> component_b -> 中间结果 -> component_c -> 输出\n", + "```\n", + "\n", + "LCEL 中的每个组件都实现了 `Runnable` 接口,它们都有统一的方法签名。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Runnable 接口的核心方法\n", + "\n", + "所有 LCEL 组件都实现了以下方法:\n", + "\n", + "| 方法 | 作用 |\n", + "| --- | --- |\n", + "| `invoke(input)` | 单条同步调用 |\n", + "| `batch(inputs)` | 批量同步调用 |\n", + "| `stream(input)` | 流式输出 |\n", + "| `ainvoke(input)` | 异步单条调用 |\n", + "| `abatch(inputs)` | 异步批量调用 |\n", + "\n", + "这些方法不仅单个组件有,组合后的链也有。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "from langchain_core.output_parsers import StrOutputParser\n", + "from dotenv import load_dotenv\n", + "\n", + "load_dotenv()\n", + "\n", + "\n", + "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.7)\n", + "\n", + "# 构建一个简单链\n", + "prompt = ChatPromptTemplate.from_messages([\n", + " ('system', '你是一个翻译助手。'),\n", + " ('user', '把以下中文翻译成英文:{text}')\n", + "])\n", + "\n", + "chain = prompt | llm | StrOutputParser()\n", + "\n", + "# 使用 invoke 单条调用\n", + "result = chain.invoke({'text': '人工智能'})\n", + "print('invoke 结果:', result)\n", + "\n", + "# 使用 batch 批量调用\n", + "results = chain.batch([\n", + " {'text': '苹果'},\n", + " {'text': '香蕉'},\n", + " {'text': '机器学习'}\n", + "])\n", + "print('\\nbatch 结果:', results)\n", + "\n", + "# 使用 stream 流式输出\n", + "print('\\nstream 结果:')\n", + "for chunk in chain.stream({'text': '未来科技'}):\n", + " print(chunk, end='')\n", + "print()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "- `prompt` 接收字典,输出格式化后的消息列表\n", + "- `llm` 接收消息列表,输出 `AIMessage`\n", + "- `StrOutputParser()` 接收 `AIMessage`,输出字符串\n", + "- 组合后的 `chain` 继承了三个组件的能力,可以直接调用 `invoke`、`batch`、`stream`" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. 使用 RunnableLambda 转换数据\n", + "\n", + "在组合链时,前一个组件的输出格式可能和后一个组件的输入格式不匹配。\n", + "\n", + "例如:前一个链输出字符串,后一个链需要字典。这时可以用 `RunnableLambda` 或普通 lambda 函数做转换。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "from langchain_core.output_parsers import StrOutputParser\n", + "from langchain_core.runnables import RunnableLambda\n", + "\n", + "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.7)\n", + "\n", + "# 第一步:把中文翻译成英文\n", + "translate_prompt = ChatPromptTemplate.from_messages([\n", + " ('system', '你是一个翻译助手。'),\n", + " ('user', '把以下中文翻译成英文:{text}')\n", + "])\n", + "translate_chain = translate_prompt | llm | StrOutputParser()\n", + "\n", + "# 第二步:把英文总结成一句话\n", + "summarize_prompt = ChatPromptTemplate.from_messages([\n", + " ('system', '你是一个总结助手。'),\n", + " ('user', '请用一句话总结以下内容:{text}')\n", + "])\n", + "summarize_chain = summarize_prompt | llm | StrOutputParser()\n", + "\n", + "# 翻译链输出字符串,但总结链需要字典 {text: ...}\n", + "# 使用 RunnableLambda 做格式转换\n", + "def to_dict(text):\n", + " return {'text': text}\n", + "\n", + "# 组合链:翻译 -> 转字典 -> 总结\n", + "full_chain = translate_chain | RunnableLambda(to_dict) | summarize_chain\n", + "\n", + "result = full_chain.invoke({'text': '大语言模型开发效率比较高,而且比较聪明,而且成本比较低,大家都在学习怎么使用,正在改变软件的开发方式。'})\n", + "print('最终结果:', result)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "| 组件 | 输入 | 输出 |\n", + "| --- | --- | --- |\n", + "| `translate_chain` | `{'text': '...'}` | 英文字符串 |\n", + "| `RunnableLambda(to_dict)` | 英文字符串 | `{'text': '...'}` |\n", + "| `summarize_chain` | `{'text': '...'}` | 总结字符串 |\n", + "\n", + "`RunnableLambda` 把一个普通函数包装成 Runnable,可以无缝接入链中。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. 直接传递多个变量:RunnableParallel\n", + "\n", + "`RunnableParallel` 允许你同时运行多个子链,并把多个结果合并成一个字典。\n", + "\n", + "它适合的场景:\n", + "- 同时让模型做翻译和总结\n", + "- 同时提取不同类型的信息\n", + "- 一个输入需要生成多个输出" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "from langchain_core.output_parsers import StrOutputParser\n", + "from langchain_core.runnables import RunnableParallel\n", + "\n", + "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.7)\n", + "\n", + "# 定义多个子链\n", + "translate_prompt = ChatPromptTemplate.from_messages([\n", + " ('system', '你是翻译助手。'),\n", + " ('user', '把以下中文翻译成英文:{text}')\n", + "])\n", + "translate_chain = translate_prompt | llm | StrOutputParser()\n", + "\n", + "summary_prompt = ChatPromptTemplate.from_messages([\n", + " ('system', '你是总结助手。'),\n", + " ('user', '用一句话总结:{text}')\n", + "])\n", + "summary_chain = summary_prompt | llm | StrOutputParser()\n", + "\n", + "keywords_prompt = ChatPromptTemplate.from_messages([\n", + " ('system', '你是关键词提取助手。'),\n", + " ('user', '提取3个关键词,用逗号分隔:{text}')\n", + "])\n", + "keywords_chain = keywords_prompt | llm | StrOutputParser()\n", + "\n", + "# 并行执行三个子链,输入都是 {'text': '...'}\n", + "parallel_chain = RunnableParallel(\n", + " translation=translate_chain,\n", + " summary=summary_chain,\n", + " keywords=keywords_chain\n", + ")\n", + "\n", + "result = parallel_chain.invoke({'text': '人工智能正在改变教育、医疗和交通行业'})\n", + "\n", + "print('返回类型:', type(result))\n", + "print('翻译结果:', result['translation'])\n", + "print('总结结果:', result['summary'])\n", + "print('关键词结果:', result['keywords'])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "- `RunnableParallel(...)` 接收一个字典,键是输出字段名,值是子链\n", + "- 所有子链会同时接收同一个输入 `{'text': '...'}`\n", + "- 返回结果是一个字典,包含每个子链的输出\n", + "- 适合需要「一次输入、多种处理」的场景" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. 组合并行与串行\n", + "\n", + "`RunnableParallel` 的输出是一个字典,可以继续传给下一个组件使用。\n", + "\n", + "例如:先并行做翻译和总结,再把结果合并成一个报告。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "from langchain_core.output_parsers import StrOutputParser\n", + "from langchain_core.runnables import RunnableParallel\n", + "\n", + "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.7)\n", + "\n", + "# 并行生成翻译和总结\n", + "parallel_chain = RunnableParallel(\n", + " translation=(\n", + " ChatPromptTemplate.from_messages([\n", + " ('system', '翻译助手'),\n", + " ('user', '把中文翻译成英文:{text}')\n", + " ]) | llm | StrOutputParser()\n", + " ),\n", + " summary=(\n", + " ChatPromptTemplate.from_messages([\n", + " ('system', '总结助手'),\n", + " ('user', '用一句话总结:{text}')\n", + " ]) | llm | StrOutputParser()\n", + " )\n", + ")\n", + "\n", + "# 把并行结果合并成最终报告\n", + "report_prompt = ChatPromptTemplate.from_messages([\n", + " ('system', '你是一个报告生成助手。'),\n", + " ('user', '''请根据以下翻译和总结生成一份简短报告:\\n\\n翻译:{translation}\\n总结:{summary}\\n\\n请输出包含「原文概要」和「英文翻译」两部分的报告。''')\n", + "])\n", + "\n", + "full_chain = parallel_chain | report_prompt | llm | StrOutputParser()\n", + "\n", + "result = full_chain.invoke({'text': '大语言模型开发效率比较高,而且比较聪明,而且成本比较低,大家都在学习怎么使用,正在改变软件的开发方式。'})\n", + "print(result)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "| 阶段 | 说明 |\n", + "| --- | --- |\n", + "| `parallel_chain` | 同时生成翻译和总结,输出 `{'translation': ..., 'summary': ...}` |\n", + "| `report_prompt` | 接收字典,用 `{translation}` 和 `{summary}` 填充模板 |\n", + "| `llm \\| StrOutputParser()` | 生成最终报告 |\n", + "\n", + "这种「先并行、再串行」的模式在实际应用中非常常见。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. 条件分支:RunnableBranch\n", + "\n", + "`RunnableBranch` 可以根据条件选择不同的子链执行。\n", + "\n", + "例如:根据输入语言类型选择不同的处理链。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "from langchain_core.output_parsers import StrOutputParser\n", + "from langchain_core.runnables import RunnableBranch, RunnableLambda\n", + "\n", + "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.1)\n", + "\n", + "# 定义两个处理链\n", + "chinese_chain = (\n", + " ChatPromptTemplate.from_messages([\n", + " ('system', '你是中文助手。'),\n", + " ('user', '请用中文解释:{text}')\n", + " ]) | llm | StrOutputParser()\n", + ")\n", + "\n", + "english_chain = (\n", + " ChatPromptTemplate.from_messages([\n", + " ('system', 'You are an English assistant.'),\n", + " ('user', 'Please explain in English: {text}')\n", + " ]) | llm | StrOutputParser()\n", + ")\n", + "\n", + "# 判断输入文本是否包含中文字符\n", + "def is_chinese(input_dict):\n", + " text = input_dict['text']\n", + " return any('\\u4e00' <= char <= '\\u9fff' for char in text)\n", + "\n", + "# 构建分支链\n", + "branch_chain = RunnableBranch(\n", + " (is_chinese, chinese_chain),\n", + " english_chain # 默认分支\n", + ")\n", + "\n", + "print('中文输入结果:')\n", + "print(branch_chain.invoke({'text': '神经网络'})[:250])\n", + "\n", + "print('\\n英文输入结果:')\n", + "print(branch_chain.invoke({'text': 'neural network'})[:250])\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "- `RunnableBranch` 接收多个 `(条件函数, 子链)` 元组,最后是默认子链\n", + "- 条件函数接收链的输入,返回 `True` 或 `False`\n", + "- 从上往下匹配,第一个条件为 `True` 的分支会被执行\n", + "- 如果没有匹配,执行默认分支\n", + "\n", + "注意:条件函数处理的输入是整个链的输入(这里是字典),不是前一个组件的输出。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7. 查看链的结构\n", + "\n", + "复杂的链可以通过 `get_graph().print_ascii()` 方法查看结构。它会用 ASCII 字符打印出链中的节点和连接关系,非常直观。\n", + "\n", + "> 需要安装依赖:`pip install grandalf`\n", + "> 安装后请**重启 Jupyter 内核**再运行本单元格。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "from langchain_core.output_parsers import StrOutputParser\n", + "\n", + "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.7)\n", + "\n", + "chain = (\n", + " ChatPromptTemplate.from_messages([\n", + " ('user', '把以下内容翻译成英文:{text}')\n", + " ])\n", + " | llm\n", + " | StrOutputParser()\n", + " | (lambda x: {'text': x})\n", + " | ChatPromptTemplate.from_messages([\n", + " ('user', '用一句话总结:{text}')\n", + " ])\n", + " | llm\n", + " | StrOutputParser()\n", + ")\n", + "\n", + "# 打印链的 ASCII 结构图\n", + "chain.get_graph().print_ascii()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "- `get_graph()` 获取链的图结构对象\n", + "- `print_ascii()` 用 ASCII 字符画出节点和边\n", + "- 可以清楚地看到数据从输入到输出经过了哪些步骤\n", + "- 如果提示缺少 `grandalf`,请先安装:`pip install grandalf`,然后重启 Jupyter 内核" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 8. 完整示例:智能客服工单处理\n", + "\n", + "下面是一个综合示例:用户输入一条投诉或咨询,系统同时完成情绪分析、分类、生成回复建议。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "from langchain_core.output_parsers import StrOutputParser\n", + "from langchain_core.runnables import RunnableParallel\n", + "\n", + "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.3)\n", + "\n", + "# 并行分析:情绪 + 分类 + 摘要\n", + "# 注意:reply_prompt 中还需要原始输入 text,所以这里用 lambda 把 text 一起传下去\n", + "analysis_chain = RunnableParallel(\n", + " text=lambda x: x['text'],\n", + " sentiment=(\n", + " ChatPromptTemplate.from_messages([\n", + " ('system', '情绪分析助手,只输出:正面/负面/中性'),\n", + " ('user', '{text}')\n", + " ]) | llm | StrOutputParser()\n", + " ),\n", + " category=(\n", + " ChatPromptTemplate.from_messages([\n", + " ('system', '分类助手,只输出类别:售后/产品/物流/其他'),\n", + " ('user', '{text}')\n", + " ]) | llm | StrOutputParser()\n", + " ),\n", + " summary=(\n", + " ChatPromptTemplate.from_messages([\n", + " ('system', '摘要助手,用一句话概括'),\n", + " ('user', '{text}')\n", + " ]) | llm | StrOutputParser()\n", + " )\n", + ")\n", + "\n", + "# 根据分析结果生成回复建议\n", + "reply_prompt = ChatPromptTemplate.from_messages([\n", + " ('system', '你是客服主管,请根据分析结果生成回复建议。'),\n", + " ('user', '''用户反馈:{text}\n", + "\n", + "分析结果:\n", + "- 情绪:{sentiment}\n", + "- 类别:{category}\n", + "- 摘要:{summary}\n", + "\n", + "请生成一段礼貌、专业的回复建议。''')\n", + "])\n", + "\n", + "full_chain = analysis_chain | reply_prompt | llm | StrOutputParser()\n", + "\n", + "result = full_chain.invoke({\n", + " 'text': '你们的发货速度太慢了,说好三天到,结果等了一周,非常失望!'\n", + "})\n", + "print(result)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "| 步骤 | 说明 |\n", + "| --- | --- |\n", + "| `analysis_chain` | 并行执行三个分析任务,输出字典 |\n", + "| `reply_prompt` | 把原始输入和分析结果一起填充到回复模板 |\n", + "| `llm \\| StrOutputParser()` | 生成最终回复建议 |\n", + "\n", + "注意:`reply_prompt` 中既用到了原始输入 `{text}`,也用到了并行分析结果 `{sentiment}`、`{category}`、`{summary}`。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 9. 本节课练习\n", + "\n", + "1. 用 `|` 构建一个链:输入中文 -> 翻译为英文 -> 再翻译回中文,观察「回译」后的变化\n", + "2. 使用 `RunnableLambda` 把字符串输出转换为字典,再接第二个 Prompt 模板\n", + "3. 使用 `RunnableParallel` 让一个输入同时生成「正式版」和「口语版」两种翻译\n", + "4. 使用 `RunnableBranch` 根据输入长度选择不同模型:短文本用轻量提示,长文本用详细提示\n", + "5. 用 `chain.get_graph().print_ascii()` 查看你自己构建的链的结构\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.3" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/09_工具定义.ipynb b/09_工具定义.ipynb new file mode 100644 index 0000000..acfb096 --- /dev/null +++ b/09_工具定义.ipynb @@ -0,0 +1,470 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 09 工具定义\n", + "\n", + "## 学习目标\n", + "1. 理解工具(Tools)在智能体开发中的核心作用\n", + "2. 掌握使用 `@tool` 装饰器定义自定义工具\n", + "3. 学会为工具编写清晰的参数说明和文档字符串\n", + "4. 理解工具描述如何影响模型选择工具的能力\n", + "5. 能够定义计算类、查询类和文件处理类工具" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. 为什么需要工具\n", + "\n", + "大语言模型本身只能处理文本,它的知识截止于训练数据,也无法直接访问外部世界。\n", + "\n", + "**工具(Tools)** 让模型能够:\n", + "\n", + "- 执行数学计算(模型不擅长精确计算)\n", + "- 查询数据库、API、搜索引擎\n", + "- 读写文件、操作数据库\n", + "- 获取实时信息(天气、股价、新闻)\n", + "\n", + "智能体(Agent)的核心工作就是:**根据用户问题选择合适的工具,调用工具,再根据结果回答问题**。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. 使用 @tool 装饰器定义第一个工具\n", + "\n", + "LangChain 提供了 `@tool` 装饰器,可以把一个普通 Python 函数变成智能体可调用的工具。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain.tools import tool\n", + "\n", + "# 使用 @tool 装饰器定义一个加法工具\n", + "@tool\n", + "def add(a: int, b: int) -> int:\n", + " \"\"\"计算两个整数的和。\"\"\"\n", + " return a + b\n", + "\n", + "# 查看工具信息\n", + "print('工具名称:', add.name)\n", + "print('工具描述:', add.description)\n", + "print('参数Schema:', add.args)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "- `@tool`:把 `add` 函数注册为 LangChain 工具\n", + "- `a: int, b: int`:参数类型会被自动解析为工具的输入参数格式\n", + "- 函数文档字符串 `\"\"\"...\"\"\"`:会被自动提取为工具描述(description)\n", + "- `add.name`:工具名,默认使用函数名\n", + "- `add.description`:工具描述,来自函数的 docstring\n", + "- `add.args`:参数的 JSON Schema,模型根据它决定如何传参" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. 手动调用工具\n", + "\n", + "工具定义好后,可以通过 `invoke` 方法或 `run` 方法调用。\n", + "\n", + "注意:`@tool` 装饰后的对象是一个 `StructuredTool` 对象,**不能直接像普通函数那样加括号调用**(如 `multiply(3, 4)`),需要传入字典参数。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain.tools import tool\n", + "\n", + "@tool\n", + "def multiply(a: int, b: int) -> int:\n", + " \"\"\"计算两个整数的乘积。\"\"\"\n", + " return a * b\n", + "\n", + "# 通过 invoke 调用,传入字典参数\n", + "print('invoke 调用:', multiply.invoke({'a': 3, 'b': 4}))\n", + "\n", + "# 通过 run 调用(LangChain 工具的传统调用方式)\n", + "print('run 调用:', multiply.run({'a': 5, 'b': 6}))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "- `@tool` 装饰后的对象是一个 `StructuredTool`,不是普通函数\n", + "- `multiply.invoke({'a': 3, 'b': 4})`:LangChain 风格调用,智能体实际使用这种方式\n", + "- `multiply.run({'a': 5, 'b': 6})`:传统工具调用方式,与 `invoke` 效果相同\n", + "- 注意:不能写成 `multiply(3, 4)`,否则会报 `StructuredTool object is not callable`" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. 自定义工具名称和描述\n", + "\n", + "默认情况下,工具名就是函数名,描述来自 docstring。你也可以通过 `@tool` 参数自定义。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain.tools import tool\n", + "\n", + "@tool('weather-query', description='查询指定城市的当前天气情况')\n", + "def get_weather(city: str) -> str:\n", + " \"\"\"\n", + " 查询天气。\n", + " 参数:\n", + " city: 城市名称,例如 \"北京\"、\"上海\"\n", + " \"\"\"\n", + " # 这里用模拟数据,实际可接入天气 API\n", + " weather_data = {\n", + " '北京': '晴,25°C',\n", + " '上海': '多云,28°C',\n", + " '广州': '小雨,30°C'\n", + " }\n", + " return weather_data.get(city, f'未找到 {city} 的天气信息')\n", + "\n", + "print('工具名称:', get_weather.name)\n", + "print('工具描述:', get_weather.description)\n", + "print('调用结果:', get_weather.invoke({'city': '北京'}))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "- `@tool('weather-query', description='...')`:自定义工具名和描述\n", + "- 工具描述非常重要,模型主要依赖它判断什么时候使用该工具\n", + "- 描述要清晰、具体,包含「工具能做什么」和「参数含义」\n", + "- 函数 docstring 可以继续补充参数细节" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. 定义带多个参数的工具\n", + "\n", + "工具可以有多个参数,LangChain 会自动根据类型注解生成参数 Schema。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain.tools import tool\n", + "\n", + "@tool\n", + "def calculate_bmi(height: float, weight: float) -> str:\n", + " \"\"\"\n", + " 根据身高和体重计算 BMI 指数,并返回健康建议。\n", + " 参数:\n", + " height: 身高,单位米\n", + " weight: 体重,单位千克\n", + " \"\"\"\n", + " bmi = weight / (height ** 2)\n", + " if bmi < 18.5:\n", + " suggestion = '体重过轻,建议加强营养'\n", + " elif bmi < 24:\n", + " suggestion = '体重正常,请保持'\n", + " elif bmi < 28:\n", + " suggestion = '超重,建议适当运动'\n", + " else:\n", + " suggestion = '肥胖,建议咨询医生'\n", + " return f'BMI: {bmi:.2f},{suggestion}'\n", + "\n", + "print(calculate_bmi.invoke({'height': 1.75, 'weight': 70}))\n", + "print('\\n参数 Schema:')\n", + "print(calculate_bmi.args)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "- `height: float, weight: float`:类型注解会被 LangChain 自动识别\n", + "- 生成的 `args` 中会包含参数名、类型、是否必填等信息\n", + "- 模型会根据这个 Schema 构造正确的 JSON 参数调用工具" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. 定义模拟 API 查询工具\n", + "\n", + "实际应用中,工具通常会调用外部 API。下面我们用一个模拟的「产品库存查询」工具来演示。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain.tools import tool\n", + "\n", + "@tool\n", + "def query_product_stock(product_name: str) -> str:\n", + " \"\"\"\n", + " 查询指定商品的库存数量。\n", + " 参数:\n", + " product_name: 商品名称\n", + " \"\"\"\n", + " # 模拟数据库查询\n", + " stock_db = {\n", + " '手机': 150,\n", + " '笔记本电脑': 45,\n", + " '耳机': 200,\n", + " '充电宝': 80\n", + " }\n", + " stock = stock_db.get(product_name)\n", + " if stock is None:\n", + " return f'未找到商品 \"{product_name}\" 的库存信息'\n", + " return f'商品 \"{product_name}\" 当前库存:{stock} 件'\n", + "\n", + "# 测试工具\n", + "print(query_product_stock.invoke({'product_name': '手机'}))\n", + "print(query_product_stock.invoke({'product_name': '相机'}))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "- 这个工具模拟了查询数据库或 API 的场景\n", + "- 返回的字符串会作为观察结果(Observation)交给模型继续处理\n", + "- 工具内部可以替换为真实的数据库查询、HTTP 请求等" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7. 定义文件处理工具\n", + "\n", + "工具也可以读写文件。下面是读取文本文件和写入文本文件的工具示例。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain.tools import tool\n", + "\n", + "@tool\n", + "def read_text_file(file_path: str) -> str:\n", + " \"\"\"\n", + " 读取指定文本文件的内容。\n", + " 参数:\n", + " file_path: 文件路径\n", + " \"\"\"\n", + " try:\n", + " with open(file_path, 'r', encoding='utf-8') as f:\n", + " return f.read()\n", + " except FileNotFoundError:\n", + " return f'文件不存在:{file_path}'\n", + " except Exception as e:\n", + " return f'读取失败:{str(e)}'\n", + "\n", + "@tool\n", + "def write_text_file(file_path: str, content: str) -> str:\n", + " \"\"\"\n", + " 将内容写入指定文本文件。\n", + " 参数:\n", + " file_path: 文件路径\n", + " content: 要写入的内容\n", + " \"\"\"\n", + " try:\n", + " with open(file_path, 'w', encoding='utf-8') as f:\n", + " f.write(content)\n", + " return f'已成功写入文件:{file_path}'\n", + " except Exception as e:\n", + " return f'写入失败:{str(e)}'\n", + "\n", + "# 测试写入和读取\n", + "write_text_file.invoke({'file_path': 'test_note.txt', 'content': '这是测试内容'})\n", + "print(read_text_file.invoke({'file_path': 'test_note.txt'}))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "- `read_text_file`:读取指定文件内容\n", + "- `write_text_file`:把字符串写入指定文件\n", + "- 文件路径使用相对路径时,会以当前工作目录为基准\n", + "- 工具内部要做好异常处理,避免程序崩溃" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 8. 工具列表与工具选择\n", + "\n", + "在构建智能体时,通常会把多个工具放在一个列表里,交给模型选择使用。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain.tools import tool\n", + "\n", + "@tool\n", + "def add(a: int, b: int) -> int:\n", + " \"\"\"计算两个整数的和。\"\"\"\n", + " return a + b\n", + "\n", + "@tool\n", + "def multiply(a: int, b: int) -> int:\n", + " \"\"\"计算两个整数的乘积。\"\"\"\n", + " return a * b\n", + "\n", + "@tool\n", + "def power(base: int, exponent: int) -> int:\n", + " \"\"\"计算 base 的 exponent 次方。\"\"\"\n", + " return base ** exponent\n", + "\n", + "# 把工具放入列表\n", + "tools = [add, multiply, power]\n", + "\n", + "# 查看工具列表信息\n", + "for t in tools:\n", + " print(f'名称:{t.name},描述:{t.description}')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "- 智能体通过工具列表了解有哪些工具可用\n", + "- 模型会根据用户问题和工具描述,决定调用哪个工具、传什么参数\n", + "- 工具名称和描述的清晰度直接影响模型选择工具的准确率" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 9. 完整示例:多功能计算器工具集\n", + "\n", + "下面把多个数学工具组合成一个工具集,模拟智能体可使用的工具环境。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain.tools import tool\n", + "\n", + "@tool\n", + "def add(a: float, b: float) -> float:\n", + " \"\"\"计算两个数的和。\"\"\"\n", + " return a + b\n", + "\n", + "@tool\n", + "def subtract(a: float, b: float) -> float:\n", + " \"\"\"计算两个数的差。\"\"\"\n", + " return a - b\n", + "\n", + "@tool\n", + "def multiply(a: float, b: float) -> float:\n", + " \"\"\"计算两个数的乘积。\"\"\"\n", + " return a * b\n", + "\n", + "@tool\n", + "def divide(a: float, b: float) -> float:\n", + " \"\"\"计算两个数的商,如果除数为0则返回错误信息。\"\"\"\n", + " if b == 0:\n", + " return '错误:除数不能为0'\n", + " return a / b\n", + "\n", + "math_tools = [add, subtract, multiply, divide]\n", + "\n", + "# 测试每个工具\n", + "for t in math_tools:\n", + " print(f'{t.name}: {t.description}')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 10. 本节课练习\n", + "\n", + "1. 使用 `@tool` 定义一个摄氏度转华氏度的工具,参数和返回值都使用 float 类型\n", + "2. 定义一个「查询课程信息」工具,用字典模拟数据库,根据课程名返回上课时间\n", + "3. 定义一个「写入学习笔记」工具,接收文件名和内容,把内容写入指定文件\n", + "4. 把上面三个工具放入一个列表,打印每个工具的名称、描述和参数 Schema\n", + "5. 尝试修改一个工具的描述,观察参数 Schema 是否会变化" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.4" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/10_工具调用.ipynb b/10_工具调用.ipynb new file mode 100644 index 0000000..6e4ae78 --- /dev/null +++ b/10_工具调用.ipynb @@ -0,0 +1,468 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 10 工具调用\n", + "\n", + "## 学习目标\n", + "1. 理解工具调用(Tool Calling)的基本机制和流程\n", + "2. 掌握手动解析模型返回的工具调用请求\n", + "3. 学会使用 `bind_tools` 让模型选择工具\n", + "4. 掌握 `create_tool_calling_agent` 和 `AgentExecutor` 构建智能体\n", + "5. 能够处理工具调用中的常见错误" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. 工具调用的基本流程\n", + "\n", + "工具调用不是由模型直接执行代码,而是模型「决定」要调用哪个工具、传什么参数,然后由程序代为执行。\n", + "\n", + "完整流程如下:\n", + "\n", + "```\n", + "用户提问 -> 模型分析 -> 输出工具调用请求 -> 程序执行工具 -> 结果返回模型 -> 模型生成最终回答\n", + "```\n", + "\n", + "模型输出的是结构化的调用请求,包含:\n", + "- 工具名称(name)\n", + "- 工具参数(arguments)\n", + "\n", + "程序根据请求执行对应函数,再把结果返回给模型。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. 准备工具集\n", + "\n", + "首先定义几个简单的工具,用于后续演示。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain.tools import tool\n", + "\n", + "@tool\n", + "def add(a: float, b: float) -> float:\n", + " \"\"\"计算两个数的和。\"\"\"\n", + " return a + b\n", + "\n", + "@tool\n", + "def multiply(a: float, b: float) -> float:\n", + " \"\"\"计算两个数的乘积。\"\"\"\n", + " return a * b\n", + "\n", + "@tool\n", + "def query_weather(city: str) -> str:\n", + " \"\"\"\n", + " 查询指定城市的天气。\n", + " 参数:\n", + " city: 城市名称,如北京、上海\n", + " \"\"\"\n", + " weather_db = {'北京': '晴 25°C', '上海': '多云 28°C', '广州': '小雨 30°C'}\n", + " return weather_db.get(city, f'未找到 {city} 的天气信息')\n", + "\n", + "tools = [add, multiply, query_weather]\n", + "\n", + "for t in tools:\n", + " print(f'工具:{t.name},描述:{t.description}')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. 手动执行工具调用\n", + "\n", + "在使用智能体之前,先手动走一遍工具调用流程,理解底层机制。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "from langchain_core.messages import HumanMessage\n", + "from dotenv import load_dotenv\n", + "\n", + "load_dotenv()\n", + "\n", + "\n", + "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.1)\n", + "\n", + "# 把工具绑定到模型上\n", + "llm_with_tools = llm.bind_tools(tools)\n", + "\n", + "# 用户提问\n", + "question = '北京和上海的气温相差多少度?'\n", + "messages = [HumanMessage(content=question)]\n", + "\n", + "# 模型输出,可能包含工具调用请求\n", + "response = llm_with_tools.invoke(messages)\n", + "\n", + "print('模型回复类型:', type(response))\n", + "print('工具调用请求:', response.tool_calls)\n", + "print('文本内容:', response.content)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "- `llm.bind_tools(tools)`:把工具列表绑定到模型上\n", + "- 模型看到问题后,如果觉得需要工具,会在 `response.tool_calls` 中返回调用请求\n", + "- 每个 `tool_call` 包含 `name`(工具名)和 `args`(参数)\n", + "- 如果不需要工具,`response.content` 中直接包含文本回答" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. 手动解析并执行工具调用\n", + "\n", + "模型只负责「决定」调用什么工具,真正的执行需要程序完成。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "from langchain_core.messages import HumanMessage, ToolMessage\n", + "\n", + "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.1)\n", + "llm_with_tools = llm.bind_tools(tools)\n", + "\n", + "# 使用一个只需一次工具调用的简单问题\n", + "question = '北京今天的天气怎么样?'\n", + "messages = [HumanMessage(content=question)]\n", + "\n", + "# 第一步:获取模型的工具调用请求\n", + "response = llm_with_tools.invoke(messages)\n", + "messages.append(response)\n", + "\n", + "print('第一次模型输出:')\n", + "print('tool_calls:', response.tool_calls)\n", + "print('content:', response.content)\n", + "\n", + "# 第二步:执行工具调用\n", + "for tool_call in response.tool_calls:\n", + " tool_name = tool_call['name']\n", + " tool_args = tool_call['args']\n", + " \n", + " # 找到对应的工具并执行\n", + " selected_tool = {t.name: t for t in tools}[tool_name]\n", + " tool_result = selected_tool.invoke(tool_args)\n", + " \n", + " # 把工具执行结果加入对话历史\n", + " messages.append(ToolMessage(content=str(tool_result), tool_call_id=tool_call['id']))\n", + " \n", + " print(f'\\n执行工具:{tool_name},参数:{tool_args},结果:{tool_result}')\n", + "\n", + "# 第三步:把工具结果返回模型,生成最终回答\n", + "final_response = llm_with_tools.invoke(messages)\n", + "print('\\n最终回答:', final_response.content)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "- `ToolMessage`:专门用于承载工具执行结果的消息类型\n", + "- `tool_call_id`:必须和模型请求的 `id` 对应,这是 LangChain 的要求\n", + "- 工具执行后,需要把结果追加到 `messages` 中,再次调用模型\n", + "- 这种手动流程帮助我们理解智能体的底层工作原理" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. 使用 create_agent 构建智能体\n", + "\n", + "LangChain 1.x 推荐使用 `create_agent` 构建工具调用智能体。它会自动处理「模型决定调用工具 -> 执行工具 -> 返回结果 -> 再次调用模型」的循环,并返回一个可执行的状态图。\n", + "\n", + "`create_agent` 的核心参数:\n", + "\n", + "| 参数 | 说明 |\n", + "| --- | --- |\n", + "| `model` | 语言模型实例 |\n", + "| `tools` | 工具列表 |\n", + "| `system_prompt` | 系统提示词 |\n", + "| `checkpointer` | 可选,用于保存对话历史 |" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "from langchain.agents import create_agent\n", + "\n", + "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.1)\n", + "\n", + "# 创建工具调用智能体\n", + "# create_agent 是 LangChain 1.x 推荐的智能体构建方式,返回一个可执行的状态图\n", + "agent = create_agent(\n", + " model=llm,\n", + " tools=tools,\n", + " system_prompt='你是一个 helpful 的数学和天气助手,可以使用工具帮助用户。'\n", + ")\n", + "\n", + "# 运行智能体\n", + "result = agent.invoke({'messages': [('user', '3 加 5 乘以 2 等于多少?')]})\n", + "print('\\n最终输出:', result['messages'][-1].content)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "- `create_agent(model=llm, tools=tools, system_prompt='...')`:创建工具调用智能体\n", + "- `create_agent` 会自动处理工具调用循环:模型选择工具 -> 执行工具 -> 返回结果 -> 再次调用模型\n", + "- 返回的 `agent` 是一个 LangGraph 编译后的状态图(CompiledStateGraph),可以直接调用 `invoke`\n", + "- 调用时传入 `{'messages': [('user', '...')]}`,表示用户消息\n", + "- `result['messages'][-1].content`:获取最后一条消息,即智能体的最终回答" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. 观察智能体的执行过程\n", + "\n", + "`create_agent` 返回的是一个 LangGraph 状态图。运行后,`result['messages']` 会包含完整的对话历史,包括模型的思考过程、工具调用请求、工具执行结果和最终回答。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 同样的智能体,换个问题运行\n", + "result = agent.invoke({'messages': [('user', '北京今天天气怎么样?广州呢?')]})\n", + "for i in result['messages']:\n", + " print(i)\n", + "print('\\n最终输出:', result['messages'][-1].content)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7. 多轮对话:带记忆的智能体\n", + "\n", + "如果希望智能体能记住上下文,可以在 `create_agent` 中传入 `checkpointer`。这里使用 `MemorySaver` 作为内存型的 checkpointer。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.checkpoint.memory import MemorySaver\n", + "\n", + "# 创建带记忆的智能体\n", + "# checkpointer 会自动保存和加载对话历史\n", + "memory = MemorySaver()\n", + "agent_with_history = create_agent(\n", + " model=llm,\n", + " tools=tools,\n", + " system_prompt='你是一个 helpful 的数学和天气助手,可以使用工具帮助用户。',\n", + " checkpointer=memory\n", + ")\n", + "\n", + "# 第一轮对话\n", + "config = {'configurable': {'thread_id': 'user_001'}}\n", + "result1 = agent_with_history.invoke(\n", + " {'messages': [('user', '我叫张三')]},\n", + " config=config\n", + ")\n", + "print('第一轮:', result1['messages'][-1].content)\n", + "\n", + "# 第二轮对话,测试是否记得名字\n", + "result2 = agent_with_history.invoke(\n", + " {'messages': [('user', '我叫什么名字?')]},\n", + " config=config\n", + ")\n", + "print('第二轮:', result2['messages'][-1].content)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "- `MemorySaver()`:内存型 checkpointer,用于保存对话历史\n", + "- `checkpointer=memory`:把 checkpointer 传给 `create_agent`,让智能体具备记忆能力\n", + "- `config={'configurable': {'thread_id': 'user_001'}}`:通过 thread_id 区分不同会话\n", + "- 同一个 `thread_id` 下的历史消息会被保留并传给模型" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 8. 处理工具调用错误\n", + "\n", + "工具执行过程中可能出现异常,比如参数错误、网络超时、文件不存在等。好的智能体需要能优雅处理这些错误。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain.tools import tool\n", + "\n", + "@tool\n", + "def safe_divide(a: float, b: float) -> str:\n", + " \"\"\"安全除法,除数为0时返回友好提示。\"\"\"\n", + " try:\n", + " if b == 0:\n", + " return '错误:除数不能为0'\n", + " return str(a / b)\n", + " except Exception as e:\n", + " return f'计算出错:{str(e)}'\n", + "\n", + "safe_tools = [safe_divide, add, multiply]\n", + "\n", + "safe_agent = create_agent(\n", + " model=llm,\n", + " tools=safe_tools,\n", + " system_prompt='你是一个 helpful 的数学助手,可以使用工具帮助用户。'\n", + ")\n", + "\n", + "result = safe_agent.invoke({'messages': [('user', '10 除以 0 等于多少?')]})\n", + "print('\\n最终输出:', result['messages'][-1].content)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "- 工具内部用 try-except 捕获异常,返回错误信息而不是抛出异常\n", + "- 这样模型能看到错误原因,并据此调整或告知用户\n", + "- 不要把异常直接抛给 AgentExecutor,否则会导致智能体运行中断" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 9. 完整示例:智能客服助手\n", + "\n", + "下面把工具调用应用到智能客服场景:根据用户问题查询订单、库存或计算退款金额。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain.tools import tool\n", + "from langchain_openai import ChatOpenAI\n", + "from langchain.agents import create_agent\n", + "\n", + "@tool\n", + "def query_order(order_id: str) -> str:\n", + " \"\"\"根据订单号查询订单信息。\"\"\"\n", + " orders = {\n", + " '1001': '已发货,预计明天送达',\n", + " '1002': '已签收',\n", + " '1003': '处理中'\n", + " }\n", + " return orders.get(order_id, '未找到该订单')\n", + "\n", + "@tool\n", + "def query_stock(product_name: str) -> str:\n", + " \"\"\"查询商品库存。\"\"\"\n", + " stock = {'手机': 100, '耳机': 50, '充电器': 200}\n", + " return f'{product_name} 库存:{stock.get(product_name, 0)} 件'\n", + "\n", + "@tool\n", + "def calculate_refund(price: float, days: int) -> str:\n", + " \"\"\"计算退款金额,每天折旧 1%。\"\"\"\n", + " refund = price * max(0, 1 - days * 0.01)\n", + " return f'预计退款金额:{refund:.2f} 元'\n", + "\n", + "cs_tools = [query_order, query_stock, calculate_refund]\n", + "\n", + "cs_agent = create_agent(\n", + " model=llm,\n", + " tools=cs_tools,\n", + " system_prompt='你是电商客服助手,可以通过工具查询订单、库存和计算退款。'\n", + ")\n", + "\n", + "cs_result = cs_agent.invoke({\n", + " 'messages': [('user', '我的订单 1001 现在状态怎样?手机还有库存吗?如果手机 3000 元买了 10 天,退款多少?')]\n", + "})\n", + "print('\\n最终输出:', cs_result['messages'][-1].content)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 10. 本节课练习\n", + "\n", + "1. 手动执行一次工具调用:让模型选择 `add` 或 `multiply`,然后自己解析 `tool_calls` 并执行\n", + "2. 使用 `create_tool_calling_agent` 和 `AgentExecutor` 构建一个能计算 BMI 的工具智能体\n", + "3. 给智能体添加 `verbose=True`,观察它执行工具调用的完整过程\n", + "4. 修改一个工具,让它在参数错误时返回友好提示,而不是抛出异常\n", + "5. 使用 `RunnableWithMessageHistory` 让智能体记住用户的连续提问" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.3" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/11_检索增强.ipynb b/11_检索增强.ipynb new file mode 100644 index 0000000..8f4f861 --- /dev/null +++ b/11_检索增强.ipynb @@ -0,0 +1,505 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 11 检索增强\n", + "\n", + "## 学习目标\n", + "1. 理解 RAG(Retrieval-Augmented Generation,检索增强生成)的核心原理\n", + "2. 掌握文档加载、文本分割、向量化的基本流程\n", + "3. 学会使用 LangChain 构建简单的 RAG 问答链\n", + "4. 理解检索器(Retriever)在智能体中的作用\n", + "5. 能够处理本地文本数据并基于其回答问题" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. 为什么需要 RAG\n", + "\n", + "大语言模型有两个明显局限:\n", + "\n", + "- **知识过时**:模型训练数据有截止日期,不知道最新信息\n", + "- **容易产生幻觉**:对未训练过的问题可能编造答案\n", + "\n", + "**RAG** 通过在回答前先从外部知识库中检索相关信息,把检索到的内容作为上下文喂给模型,从而:\n", + "\n", + "- 让模型基于最新、准确的资料回答\n", + "- 减少幻觉\n", + "- 实现私有知识库问答(如公司文档、个人笔记)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. RAG 的核心流程\n", + "\n", + "一个完整的 RAG 系统通常包含以下步骤:\n", + "\n", + "```\n", + "1. 加载文档 -> DocumentLoader\n", + "2. 分割文档 -> TextSplitter\n", + "3. 文本向量化 -> Embeddings\n", + "4. 存入向量库 -> VectorStore\n", + "5. 用户提问 -> Query\n", + "6. 检索相关片段 -> Retriever\n", + "7. 拼接上下文 -> Context\n", + "8. 生成回答 -> LLM\n", + "```\n", + "\n", + "其中第 1-4 步是「离线」的索引阶段,第 5-8 步是「在线」的查询阶段。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. 环境准备\n", + "\n", + "本节课会用到文档加载、文本分割组件。如果你的虚拟环境中还没有安装,请先执行:\n", + "\n", + "```powershell\n", + "pip install langchain-community langchain-text-splitters -i https://pypi.tuna.tsinghua.edu.cn/simple\n", + "```\n", + "\n", + "> 如果暂时不想安装,本课件也提供了用 Python 原生代码实现的备用方案。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. 准备示例文档\n", + "\n", + "先创建一个示例文本文件,后面会用它来演示 RAG 流程。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "sample_text = \"\"\"\n", + "人工智能(Artificial Intelligence,简称 AI)是计算机科学的一个分支,旨在创建能够执行通常需要人类智能才能完成的任务的系统。\n", + "\n", + "机器学习是人工智能的一个重要子领域。它通过数据训练模型,使计算机能够从经验中学习,而无需进行明确的编程。\n", + "\n", + "深度学习是机器学习的一个分支,使用多层神经网络来模拟人脑的工作方式。深度学习在图像识别、自然语言处理和语音识别等领域取得了显著成果。\n", + "\n", + "自然语言处理(Natural Language Processing,简称 NLP)是人工智能和语言学的交叉领域,研究如何让计算机理解、解释和生成人类语言。\n", + "\n", + "大语言模型(Large Language Model,简称 LLM)是基于深度学习的自然语言处理模型。它们通过在海量文本数据上进行预训练,能够生成连贯的文本、回答问题并完成多种语言任务。\n", + "\"\"\"\n", + "\n", + "with open('sample_rag.txt', 'w', encoding='utf-8') as f:\n", + " f.write(sample_text.strip())\n", + "\n", + "print('示例文档已创建:sample_rag.txt')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 5.1 使用 LangChain 的 TextLoader(推荐)\n", + "\n", + "`TextLoader` 可以自动处理编码、元数据等问题。\n", + "\n", + "> 注意:`langchain-community` 在 LangChain 1.x 中已被标记为弃用,但仍然可用。代码中用 `warnings.filterwarnings('ignore', category=DeprecationWarning)` 过滤了警告。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import warnings\n", + "\n", + "# langchain-community 在 LangChain 1.x 中已被标记为弃用,但仍然可用\n", + "# 这里过滤掉 DeprecationWarning,避免输出中显示警告信息\n", + "warnings.filterwarnings('ignore', category=DeprecationWarning)\n", + "\n", + "from langchain_community.document_loaders import TextLoader\n", + "\n", + "loader = TextLoader('sample_rag.txt', encoding='utf-8')\n", + "documents = loader.load()\n", + "\n", + "print(f'加载了 {len(documents)} 个文档')\n", + "print(f'第一个文档长度:{len(documents[0].page_content)} 字符')\n", + "print(f'元数据:{documents[0].metadata}')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 5.2 原生 Python 加载方式(备用)\n", + "\n", + "如果暂时不想安装 `langchain-community`,可以直接用 Python 读取文件。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_core.documents import Document\n", + "\n", + "with open('sample_rag.txt', 'r', encoding='utf-8') as f:\n", + " text = f.read()\n", + "\n", + "documents = [Document(page_content=text, metadata={'source': 'sample_rag.txt'})]\n", + "print(f'加载了 {len(documents)} 个文档,长度:{len(documents[0].page_content)} 字符')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. 文档分割\n", + "\n", + "大模型一次能处理的文本长度有限,而且过长的文档会影响检索精度。因此需要把长文档切分成小块。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_text_splitters import RecursiveCharacterTextSplitter\n", + "\n", + "# 创建一个文本分割器\n", + "# chunk_size: 每个块的最大字符数\n", + "# chunk_overlap: 相邻块之间的重叠字符数,用于保持上下文连贯\n", + "splitter = RecursiveCharacterTextSplitter(\n", + " chunk_size=100,\n", + " chunk_overlap=20,\n", + " separators=['\\n\\n', '\\n', '。', ',', ' ', '']\n", + ")\n", + "\n", + "chunks = splitter.split_documents(documents)\n", + "\n", + "print(f'分割成 {len(chunks)} 个文本块')\n", + "for i, chunk in enumerate(chunks[:3]):\n", + " print(f'\\n--- 第 {i+1} 块 ---')\n", + " print(chunk.page_content[:80] + '...')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "- `chunk_size`:每个文本块的最大长度\n", + "- `chunk_overlap`:相邻块重叠的字符数,避免关键信息被切分断掉\n", + "- `separators`:切分优先级,先按段落切,再按句子切,最后按字符切\n", + "- `RecursiveCharacterTextSplitter` 会尽量在语义边界处切分" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7. 文本向量化\n", + "\n", + "向量化是把文本转换成数值向量的过程。语义相近的文本,向量距离也更近。\n", + "\n", + "本课件使用 `OpenAIEmbeddings`。如果你的 API 不支持 `qwen3-embedding`,请把 `model` 参数改成你实际可用的 embedding 模型名。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import OpenAIEmbeddings\n", + "from dotenv import load_dotenv\n", + "\n", + "load_dotenv()\n", + "\n", + "\n", + "# 创建 embedding 模型\n", + "# 如果你的 API 提供的是其他 embedding 模型名,请修改 model 参数\n", + "embeddings = OpenAIEmbeddings(model='qwen3-embedding')\n", + "\n", + "# 测试向量化\n", + "test_text = '人工智能是计算机科学的分支'\n", + "vector = embeddings.embed_query(test_text)\n", + "\n", + "print(f'文本:{test_text}')\n", + "print(f'向量维度:{len(vector)}')\n", + "print(f'向量前5个值:{vector[:5]}')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 备用方案:使用 FakeEmbeddings 测试流程\n", + "\n", + "如果你暂时没有可用的 embedding 接口,可以用 `FakeEmbeddings` 仅测试 RAG 流程。注意:FakeEmbeddings 生成的向量是随机的,检索结果没有实际语义意义。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_core.embeddings import FakeEmbeddings\n", + "\n", + "# 仅用于测试流程,向量维度设为 384\n", + "fake_embeddings = FakeEmbeddings(size=384)\n", + "\n", + "fake_vector = fake_embeddings.embed_query('测试文本')\n", + "print(f'Fake 向量维度:{len(fake_vector)}')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 8. 向量存储与检索\n", + "\n", + "把分割后的文本块存入向量数据库。这里使用 `InMemoryVectorStore`,它是一个内存型向量存储,适合学习和测试。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_core.vectorstores import InMemoryVectorStore\n", + "\n", + "# 创建内存向量库\n", + "vectorstore = InMemoryVectorStore(embeddings)\n", + "\n", + "# 把文本块加入向量库\n", + "vectorstore.add_documents(chunks)\n", + "\n", + "# 创建检索器,k=2 表示返回最相关的 2 个片段\n", + "retriever = vectorstore.as_retriever(search_kwargs={'k': 2})\n", + "\n", + "# 测试检索\n", + "query = '什么是深度学习?'\n", + "retrieved_docs = retriever.invoke(query)\n", + "\n", + "print(f'检索到 {len(retrieved_docs)} 个相关片段:\\n')\n", + "for i, doc in enumerate(retrieved_docs):\n", + " print(f'--- 片段 {i+1} ---')\n", + " print(doc.page_content)\n", + " print(f'来源:{doc.metadata}\\n')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "- `InMemoryVectorStore(embeddings)`:创建内存向量库,使用指定的 embedding 模型\n", + "- `add_documents(chunks)`:把文本块存入向量库\n", + "- `as_retriever(search_kwargs={'k': 2})`:把向量库转成检索器,每次返回前 2 个最相关结果\n", + "- `retriever.invoke(query)`:根据用户问题检索相关文本块" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 9. 构建 RAG 问答链\n", + "\n", + "把检索器和 LLM 组合起来,构建一个完整的 RAG 链。流程是:\n", + "\n", + "1. 接收用户问题\n", + "2. 用检索器找到相关文档\n", + "3. 把文档内容拼接到 Prompt 中\n", + "4. 让 LLM 基于上下文回答" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "from langchain_core.output_parsers import StrOutputParser\n", + "from langchain_core.runnables import RunnablePassthrough\n", + "\n", + "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.1)\n", + "\n", + "# 定义 RAG Prompt 模板\n", + "rag_prompt = ChatPromptTemplate.from_messages([\n", + " ('system', '你是一个知识库问答助手。请根据下面的上下文回答问题,如果上下文没有相关信息,请说「我不知道」。'),\n", + " ('user', '''上下文:\\n{context}\\n\\n问题:{question}''')\n", + "])\n", + "\n", + "# 把检索到的文档拼接成字符串\n", + "def format_docs(docs):\n", + " return '\\n\\n'.join([doc.page_content for doc in docs])\n", + "\n", + "# 构建 RAG 链\n", + "rag_chain = (\n", + " {'context': retriever | format_docs, 'question': RunnablePassthrough()}\n", + " | rag_prompt\n", + " | llm\n", + " | StrOutputParser()\n", + ")\n", + "\n", + "# 测试\n", + "question = '深度学习和大语言模型有什么关系?'\n", + "answer = rag_chain.invoke(question)\n", + "print('问题:', question)\n", + "print('\\n回答:', answer)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "- `{'context': retriever | format_docs, 'question': RunnablePassthrough()}`:\n", + " - `context` 分支:用检索器找到相关文档,再用 `format_docs` 拼接成字符串\n", + " - `question` 分支:用 `RunnablePassthrough()` 把原始问题传递下去\n", + "- `rag_prompt`:把 context 和 question 填充到模板中\n", + "- `llm`:让模型基于上下文生成回答\n", + "- `StrOutputParser()`:把 AIMessage 转成字符串" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 10. 完整示例:基于私有文档的问答系统\n", + "\n", + "下面把前面的步骤整合成一个完整的 RAG 系统。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_community.document_loaders import TextLoader\n", + "from langchain_text_splitters import RecursiveCharacterTextSplitter\n", + "from langchain_core.vectorstores import InMemoryVectorStore\n", + "from langchain_openai import ChatOpenAI, OpenAIEmbeddings\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "from langchain_core.output_parsers import StrOutputParser\n", + "from langchain_core.runnables import RunnablePassthrough\n", + "\n", + "# 1. 加载文档\n", + "loader = TextLoader('sample_rag.txt', encoding='utf-8')\n", + "documents = loader.load()\n", + "\n", + "# 2. 分割文档\n", + "splitter = RecursiveCharacterTextSplitter(chunk_size=100, chunk_overlap=20)\n", + "chunks = splitter.split_documents(documents)\n", + "\n", + "# 3. 创建 embedding 和向量库\n", + "embeddings = OpenAIEmbeddings(model='qwen3-embedding')\n", + "vectorstore = InMemoryVectorStore(embeddings)\n", + "vectorstore.add_documents(chunks)\n", + "\n", + "# 4. 创建检索器\n", + "retriever = vectorstore.as_retriever(search_kwargs={'k': 2})\n", + "\n", + "# 5. 创建 LLM 和 Prompt\n", + "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.1)\n", + "prompt = ChatPromptTemplate.from_messages([\n", + " ('system', '你是知识库问答助手,请严格根据上下文回答。'),\n", + " ('user', '''上下文:\\n{context}\\n\\n问题:{question}''')\n", + "])\n", + "\n", + "def format_docs(docs):\n", + " return '\\n\\n'.join([doc.page_content for doc in docs])\n", + "\n", + "# 6. 构建 RAG 链\n", + "qa_chain = (\n", + " {'context': retriever | format_docs, 'question': RunnablePassthrough()}\n", + " | prompt\n", + " | llm\n", + " | StrOutputParser()\n", + ")\n", + "\n", + "# 7. 提问\n", + "questions = [\n", + " '机器学习是什么?',\n", + " '自然语言处理和大语言模型有什么关系?',\n", + " '苹果是什么颜色的?' # 这个问题不在文档中,观察模型是否说不知道\n", + "]\n", + "\n", + "for q in questions:\n", + " print(f'Q: {q}')\n", + " print(f'A: {qa_chain.invoke(q)}\\n')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 11. RAG 系统的优化方向\n", + "\n", + "实际应用中,RAG 系统还有很多优化空间:\n", + "\n", + "| 优化方向 | 说明 |\n", + "| --- | --- |\n", + "| **更好的分块策略** | 按语义、按段落、按标题切分,避免切断关键信息 |\n", + "| **更优的 Embedding 模型** | 使用针对中文优化的模型,如 BGE、M3E 等 |\n", + "| **重排序(Rerank)** | 先检索多个候选,再用更精确的模型排序 |\n", + "| **查询改写** | 把用户问题改写成更适合检索的形式 |\n", + "| **持久化向量库** | 使用 Chroma、FAISS、Milvus 等持久化存储 |\n", + "| **元数据过滤** | 根据来源、时间、类别等元数据进行筛选 |" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 12. 本节课练习\n", + "\n", + "1. 创建一个新的文本文件,写入关于你感兴趣的主题(如 Python、篮球、电影),用 TextLoader 加载并分割\n", + "2. 调整 `chunk_size` 和 `chunk_overlap`,观察分割结果的变化\n", + "3. 用 `OpenAIEmbeddings` 或 `FakeEmbeddings` 把文本块存入 `InMemoryVectorStore`\n", + "4. 针对你的文档提出 3 个问题,观察 RAG 链的回答质量\n", + "5. 尝试问一个文档中没有的问题,观察模型是否按 Prompt 要求回答「我不知道」" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.3" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/12_向量数据库.ipynb b/12_向量数据库.ipynb new file mode 100644 index 0000000..ae7a93f --- /dev/null +++ b/12_向量数据库.ipynb @@ -0,0 +1,398 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 12 向量数据库\n", + "\n", + "## 学习目标\n", + "1. 理解向量数据库与传统数据库的区别\n", + "2. 掌握 ChromaDB 的基本使用方法\n", + "3. 学会向量数据库的持久化存储和加载\n", + "4. 理解元数据过滤在检索中的作用\n", + "5. 能够选择合适的向量数据库方案" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. 为什么需要向量数据库\n", + "\n", + "上一节课我们用 `InMemoryVectorStore` 把向量存在内存中,它适合学习和测试,但有几个明显缺点:\n", + "\n", + "- **数据无法持久化**:程序关闭后向量就消失了\n", + "- **无法增量更新**:不能方便地添加、删除文档\n", + "- **不支持复杂查询**:无法按元数据过滤\n", + "- **不适合大规模数据**:内存容量有限\n", + "\n", + "**向量数据库** 就是为解决这些问题而生的专用数据库。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. 向量数据库 vs 传统数据库\n", + "\n", + "| 特性 | 传统数据库(MySQL) | 向量数据库(ChromaDB) |\n", + "| --- | --- | --- |\n", + "| 存储内容 | 结构化数据 | 向量 + 原始文本 + 元数据 |\n", + "| 查询方式 | 精确匹配、范围查询 | 相似度搜索 |\n", + "| 索引类型 | B+ 树 | HNSW、IVF 等近似最近邻索引 |\n", + "| 典型应用 | 订单、用户管理 | 语义搜索、RAG、推荐 |\n", + "\n", + "向量数据库的核心能力:**根据语义相似度快速找到最相关的向量**。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. 核心概念\n", + "\n", + "### 3.1 向量(Vector)\n", + "文本经过 Embedding 模型编码后变成的高维数组。例如一个 2560 维的浮点数数组。\n", + "\n", + "### 3.2 相似度(Similarity)\n", + "两个向量之间的距离。常用度量方式:\n", + "- **余弦相似度(Cosine Similarity)**:衡量方向是否一致,范围 -1 到 1\n", + "- **欧氏距离(Euclidean Distance)**:衡量向量空间中的直线距离\n", + "\n", + "### 3.3 索引(Index)\n", + "为了加速海量向量的相似度搜索,向量数据库会构建专门的索引结构,如 HNSW。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. 常见向量数据库\n", + "\n", + "| 数据库 | 特点 | 适用场景 |\n", + "| --- | --- | --- |\n", + "| **ChromaDB** | 轻量、易用、支持持久化 | 本地开发、中小规模 |\n", + "| **FAISS** | Meta 开源、高性能 | 大规模向量检索 |\n", + "| **Milvus** | 企业级、分布式 | 生产环境、海量数据 |\n", + "| **Pinecone** | 云端托管、无需运维 | 快速上线、Serverless |\n", + "| **Weaviate** | 支持多模态、GraphQL | 复杂查询、多模态 |\n", + "\n", + "本节课以 **ChromaDB** 为例,因为它安装简单、接口友好,非常适合学习。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. 安装 ChromaDB\n", + "\n", + "在虚拟环境中执行:\n", + "\n", + "```powershell\n", + "pip install chromadb -i https://pypi.tuna.tsinghua.edu.cn/simple\n", + "```\n", + "\n", + "安装完成后需要**重启 Jupyter 内核**。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. 使用 ChromaDB 创建向量库\n", + "\n", + "下面演示如何把文档存入 ChromaDB 并进行检索。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import warnings\n", + "from dotenv import load_dotenv\n", + "\n", + "load_dotenv()\n", + "\n", + "warnings.filterwarnings('ignore', category=DeprecationWarning)\n", + "\n", + "from langchain_community.vectorstores import Chroma\n", + "from langchain_core.documents import Document\n", + "from langchain_openai import OpenAIEmbeddings\n", + "import os\n", + "\n", + "# 创建 embedding 模型\n", + "embeddings = OpenAIEmbeddings(\n", + " model='qwen3-embedding',\n", + " openai_api_base=os.environ.get('OPENAI_BASE_URL'),\n", + " openai_api_key=os.environ.get('OPENAI_API_KEY')\n", + ")\n", + "\n", + "# 准备文档\n", + "documents = [\n", + " Document(page_content='人工智能是计算机科学的一个分支。', metadata={'source': 'ai_book', 'category': 'AI'}),\n", + " Document(page_content='深度学习使用多层神经网络。', metadata={'source': 'ai_book', 'category': 'AI'}),\n", + " Document(page_content='机器学习让计算机从数据中学习。', metadata={'source': 'ai_book', 'category': 'AI'}),\n", + " Document(page_content='香蕉是一种黄色的水果。', metadata={'source': 'food_book', 'category': 'food'}),\n", + " Document(page_content='苹果通常是红色或绿色的。', metadata={'source': 'food_book', 'category': 'food'})\n", + "]\n", + "\n", + "# 从文档创建 ChromaDB 向量库\n", + "# persist_directory 指定数据保存目录\n", + "vectorstore = Chroma.from_documents(\n", + " documents=documents,\n", + " embedding=embeddings,\n", + " persist_directory='./chroma_db_demo'\n", + ")\n", + "\n", + "print('向量库创建成功!')\n", + "print(f'文档数量:{vectorstore._collection.count()}')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "- `Chroma.from_documents()`:从 Document 列表直接创建向量库\n", + "- `embedding=embeddings`:指定向量化模型\n", + "- `persist_directory`:数据持久化目录,程序关闭后数据仍存在" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7. 相似度检索\n", + "\n", + "创建检索器后,就可以根据用户问题进行语义搜索。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 创建检索器\n", + "retriever = vectorstore.as_retriever(search_kwargs={'k': 3})\n", + "\n", + "# 检索与问题最相关的文档\n", + "query = '什么是人工智能?'\n", + "results = retriever.invoke(query)\n", + "\n", + "print(f'问题:{query}\\n')\n", + "for i, doc in enumerate(results):\n", + " print(f'--- 结果 {i+1} ---')\n", + " print(f'内容:{doc.page_content}')\n", + " print(f'元数据:{doc.metadata}\\n')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 8. 持久化加载\n", + "\n", + "ChromaDB 的数据已经保存在 `./chroma_db_demo` 目录中。下次启动程序时,可以直接加载,不需要重新向量化。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 从已有目录加载向量库\n", + "loaded_vectorstore = Chroma(\n", + " persist_directory='./chroma_db_demo',\n", + " embedding_function=embeddings\n", + ")\n", + "\n", + "loaded_retriever = loaded_vectorstore.as_retriever(search_kwargs={'k': 2})\n", + "results = loaded_retriever.invoke('深度学习和神经网络的关系')\n", + "\n", + "print('从持久化数据库加载后检索:')\n", + "for doc in results:\n", + " print(f'- {doc.page_content}')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 9. 元数据过滤\n", + "\n", + "元数据过滤可以让我们在检索时只搜索特定类别的文档。例如只搜索 `category='AI'` 的文档。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 只检索 category 为 AI 的文档\n", + "filtered_results = loaded_vectorstore.similarity_search(\n", + " '水果的颜色',\n", + " filter={'category': 'AI'},\n", + " k=3\n", + ")\n", + "\n", + "print('过滤后只搜索 AI 类文档:')\n", + "for doc in filtered_results:\n", + " print(f'- [{doc.metadata[\"category\"]}] {doc.page_content}')\n", + "\n", + "# 只检索 category 为 food 的文档\n", + "food_results = loaded_vectorstore.similarity_search(\n", + " '人工智能',\n", + " filter={'category': 'food'},\n", + " k=3\n", + ")\n", + "\n", + "print('\\n过滤后只搜索 food 类文档:')\n", + "for doc in food_results:\n", + " print(f'- [{doc.metadata[\"category\"]}] {doc.page_content}')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "- `filter={'category': 'AI'}`:只返回 category 字段等于 AI 的文档\n", + "- 即使查询词是「水果的颜色」,由于过滤条件限制,也只会返回 AI 类文档\n", + "- 元数据过滤常用于多租户、多知识库场景" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 10. 增量更新:添加和删除文档\n", + "\n", + "向量数据库支持动态增删文档,不需要每次重新构建整个库。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 添加新文档\n", + "new_docs = [\n", + " Document(page_content='自然语言处理让计算机理解人类语言。', metadata={'source': 'ai_book', 'category': 'AI'})\n", + "]\n", + "vectorstore.add_documents(new_docs)\n", + "print(f'添加后文档数量:{vectorstore._collection.count()}')\n", + "\n", + "# 查询刚添加的文档\n", + "results = vectorstore.similarity_search('NLP 是什么', k=2)\n", + "for doc in results:\n", + " print(f'- {doc.page_content}')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 11. 完整示例:基于 ChromaDB 的本地知识库问答\n", + "\n", + "把第 11 节课的 RAG 链和 ChromaDB 结合起来,构建一个可持久化的本地知识库。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "from langchain_core.output_parsers import StrOutputParser\n", + "from langchain_core.runnables import RunnablePassthrough\n", + "\n", + "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.1)\n", + "\n", + "prompt = ChatPromptTemplate.from_messages([\n", + " ('system', '你是知识库问答助手,请根据上下文回答问题。如果不知道,请说「我不知道」。'),\n", + " ('user', '''上下文:\\n{context}\\n\\n问题:{question}''')\n", + "])\n", + "\n", + "def format_docs(docs):\n", + " return '\\n\\n'.join([doc.page_content for doc in docs])\n", + "\n", + "# 使用 ChromaDB 检索器\n", + "chroma_retriever = loaded_vectorstore.as_retriever(search_kwargs={'k': 2})\n", + "\n", + "qa_chain = (\n", + " {'context': chroma_retriever | format_docs, 'question': RunnablePassthrough()}\n", + " | prompt\n", + " | llm\n", + " | StrOutputParser()\n", + ")\n", + "\n", + "question = '深度学习是什么?'\n", + "answer = qa_chain.invoke(question)\n", + "print(f'Q: {question}')\n", + "print(f'A: {answer}')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 12. 向量数据库选型建议\n", + "\n", + "| 场景 | 推荐方案 |\n", + "| --- | --- |\n", + "| 本地学习、原型开发 | ChromaDB / InMemoryVectorStore |\n", + "| 大规模生产环境 | Milvus / FAISS |\n", + "| 不想自己运维 | Pinecone / Weaviate Cloud |\n", + "| 需要多模态检索 | Weaviate |\n", + "| 已有 Elasticsearch 集群 | Elasticsearch 向量检索 |\n", + "\n", + "选择向量数据库时主要考虑:数据规模、查询性能、运维成本、是否支持元数据过滤。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 13. 本节课练习\n", + "\n", + "1. 创建 5 条以上不同类别的文档,存入 ChromaDB,并指定 source 和 category 元数据\n", + "2. 分别用无过滤和有过滤两种方式检索,对比结果差异\n", + "3. 关闭 Jupyter 后重新打开,加载已持久化的 ChromaDB,验证数据没有丢失\n", + "4. 尝试添加一条新文档,然后检索这条新文档相关的内容\n", + "5. 用 ChromaDB 替换第 11 节课中的 InMemoryVectorStore,重新构建 RAG 链" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.4" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/13_RAG构建.ipynb b/13_RAG构建.ipynb new file mode 100644 index 0000000..5557b3f --- /dev/null +++ b/13_RAG构建.ipynb @@ -0,0 +1,465 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 13 RAG 构建\n", + "\n", + "## 学习目标\n", + "1. 掌握从零构建完整 RAG 应用的整体流程\n", + "2. 学会将文档加载、分割、向量化、存储、检索、生成串联成系统\n", + "3. 理解 RAG 系统的评估方法和常见优化策略\n", + "4. 能够将 RAG 代码模块化封装,便于复用\n", + "5. 完成一个可运行的多文档问答系统" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. RAG 应用整体架构\n", + "\n", + "一个可落地的 RAG 应用通常包含以下模块:\n", + "\n", + "```\n", + "数据准备阶段:\n", + " 原始文档 -> 文档加载器 -> 文本分割器 -> 文本块\n", + "\n", + "索引阶段:\n", + " 文本块 -> Embedding 模型 -> 向量 -> 向量数据库\n", + "\n", + "查询阶段:\n", + " 用户问题 -> Embedding 模型 -> 向量检索 -> 相关文本块\n", + " 相关文本块 + 用户问题 -> Prompt -> LLM -> 最终回答\n", + "```\n", + "\n", + "本节课将以「公司产品手册问答系统」为例,完整演示上述流程。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. 准备知识库文档\n", + "\n", + "先创建两份示例文档,模拟真实的产品手册。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "\n", + "# 创建产品手册目录\n", + "os.makedirs('product_docs', exist_ok=True)\n", + "\n", + "# 文档 1:智能手表使用手册\n", + "watch_manual = \"\"\"\n", + "智能手表使用手册\n", + "\n", + "1. 开机与配对\n", + "长按右侧电源键 3 秒开机。首次使用需在手机上下载 HealthApp,打开蓝牙后搜索设备并完成配对。\n", + "\n", + "2. 心率监测\n", + "手表背面配备 PPG 心率传感器。在表盘界面下滑进入功能菜单,点击「心率」即可开始测量。测量时请保持手腕静止。\n", + "\n", + "3. 睡眠监测\n", + "佩戴手表入睡后,系统会自动记录睡眠数据。次日可在 HealthApp 中查看深睡、浅睡和 REM 睡眠时长。\n", + "\n", + "4. 防水说明\n", + "本手表支持 5ATM 防水,可佩戴游泳,但不适用于潜水、热水淋浴或桑拿。\n", + "\"\"\"\n", + "\n", + "# 文档 2:无线耳机使用手册\n", + "earphone_manual = \"\"\"\n", + "无线耳机使用手册\n", + "\n", + "1. 开机与配对\n", + "打开充电盒,耳机自动开机并进入配对模式。在手机蓝牙设置中选择 SoundPod 即可完成配对。\n", + "\n", + "2. 触控操作\n", + "单击左耳或右耳可播放/暂停音乐。双击右耳切换下一首,双击左耳切换上一首。长按 2 秒唤醒语音助手。\n", + "\n", + "3. 充电说明\n", + "耳机单次续航 6 小时,充电盒可提供额外 24 小时续航。使用 Type-C 线缆为充电盒充电,约 1.5 小时充满。\n", + "\n", + "4. 降噪功能\n", + "耳机支持主动降噪和环境音模式。在连接状态下,长按右耳 3 秒可在两种模式间切换。\n", + "\"\"\"\n", + "\n", + "with open('product_docs/watch_manual.txt', 'w', encoding='utf-8') as f:\n", + " f.write(watch_manual.strip())\n", + "\n", + "with open('product_docs/earphone_manual.txt', 'w', encoding='utf-8') as f:\n", + " f.write(earphone_manual.strip())\n", + "\n", + "print('产品手册已创建:')\n", + "for f in os.listdir('product_docs'):\n", + " print(f' - {f}')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. 完整 RAG 构建流程\n", + "\n", + "下面按照「加载 -> 分割 -> 向量化 -> 存储 -> 检索 -> 生成」的顺序,一步步构建系统。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import warnings\n", + "from dotenv import load_dotenv\n", + "\n", + "load_dotenv()\n", + "\n", + "warnings.filterwarnings('ignore', category=DeprecationWarning)\n", + "\n", + "from langchain_community.document_loaders import DirectoryLoader, TextLoader\n", + "from langchain_text_splitters import RecursiveCharacterTextSplitter\n", + "from langchain_openai import OpenAIEmbeddings\n", + "from langchain_community.vectorstores import Chroma\n", + "\n", + "# 1. 加载目录下所有 txt 文件\n", + "loader = DirectoryLoader(\n", + " 'product_docs',\n", + " glob='*.txt',\n", + " loader_cls=TextLoader,\n", + " loader_kwargs={'encoding': 'utf-8'}\n", + ")\n", + "documents = loader.load()\n", + "print(f'加载文档数:{len(documents)}')\n", + "\n", + "# 2. 分割文档\n", + "splitter = RecursiveCharacterTextSplitter(\n", + " chunk_size=150,\n", + " chunk_overlap=30,\n", + " separators=['\\n\\n', '\\n', '。', ' ']\n", + ")\n", + "chunks = splitter.split_documents(documents)\n", + "print(f'分割后文本块数:{len(chunks)}')\n", + "\n", + "# 3. 创建 embedding 模型\n", + "embeddings = OpenAIEmbeddings(\n", + " model='qwen3-embedding',\n", + " openai_api_base=os.environ.get('OPENAI_BASE_URL'),\n", + " openai_api_key=os.environ.get('OPENAI_API_KEY')\n", + ")\n", + "\n", + "# 4. 存入 ChromaDB\n", + "vectorstore = Chroma.from_documents(\n", + " documents=chunks,\n", + " embedding=embeddings,\n", + " persist_directory='./product_knowledge_db'\n", + ")\n", + "\n", + "print('向量数据库创建成功!')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "- `DirectoryLoader`:批量加载目录下所有匹配文件\n", + "- `glob='*.txt'`:只加载 txt 文件\n", + "- `loader_cls=TextLoader`:指定使用 TextLoader 加载每个文件\n", + "- `RecursiveCharacterTextSplitter`:按语义边界切分文档\n", + "- `Chroma.from_documents`:一次性完成向量化和存储" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. 构建问答链\n", + "\n", + "使用 LCEL 把检索器和 LLM 组合起来。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "from langchain_core.output_parsers import StrOutputParser\n", + "from langchain_core.runnables import RunnablePassthrough\n", + "\n", + "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.1)\n", + "\n", + "prompt = ChatPromptTemplate.from_messages([\n", + " ('system', '''你是公司产品客服助手。请严格根据提供的产品手册内容回答用户问题。\n", + "如果手册中没有相关信息,请明确告知「根据现有手册,我无法回答这个问题」,不要编造。'''),\n", + " ('user', '''相关手册内容:\\n{context}\\n\\n用户问题:{question}''')\n", + "])\n", + "\n", + "def format_docs(docs):\n", + " return '\\n\\n'.join([f'[来自 {doc.metadata[\"source\"]}] {doc.page_content}' for doc in docs])\n", + "\n", + "retriever = vectorstore.as_retriever(search_kwargs={'k': 3})\n", + "\n", + "qa_chain = (\n", + " {'context': retriever | format_docs, 'question': RunnablePassthrough()}\n", + " | prompt\n", + " | llm\n", + " | StrOutputParser()\n", + ")\n", + "\n", + "# 测试\n", + "questions = [\n", + " '智能手表怎么测心率?',\n", + " '耳机的降噪功能怎么用?',\n", + " '这款手表支持潜水吗?',\n", + " '手机怎么连接打印机?' # 不在手册中\n", + "]\n", + "\n", + "for q in questions:\n", + " print(f'Q: {q}')\n", + " print(f'A: {qa_chain.invoke(q)}\\n')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. 模块化封装\n", + "\n", + "把 RAG 系统封装成一个类,便于在项目中复用。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "class ProductQASystem:\n", + " \"\"\"基于 ChromaDB 的产品手册问答系统。\"\"\"\n", + "\n", + " def __init__(self, persist_dir, llm_model='qwen3.6-35b-A3b', embedding_model='qwen3-embedding'):\n", + " self.embeddings = OpenAIEmbeddings(\n", + " model=embedding_model,\n", + " openai_api_base=os.environ.get('OPENAI_BASE_URL'),\n", + " openai_api_key=os.environ.get('OPENAI_API_KEY')\n", + " )\n", + " self.vectorstore = Chroma(\n", + " persist_directory=persist_dir,\n", + " embedding_function=self.embeddings\n", + " )\n", + " self.llm = ChatOpenAI(model=llm_model, temperature=0.1)\n", + " self.chain = self._build_chain()\n", + "\n", + " def _format_docs(self, docs):\n", + " return '\\n\\n'.join([doc.page_content for doc in docs])\n", + "\n", + " def _build_chain(self):\n", + " prompt = ChatPromptTemplate.from_messages([\n", + " ('system', '你是产品客服助手,请根据手册内容回答问题。'),\n", + " ('user', '''手册内容:\\n{context}\\n\\n问题:{question}''')\n", + " ])\n", + " retriever = self.vectorstore.as_retriever(search_kwargs={'k': 3})\n", + " return (\n", + " {'context': retriever | self._format_docs, 'question': RunnablePassthrough()}\n", + " | prompt\n", + " | self.llm\n", + " | StrOutputParser()\n", + " )\n", + "\n", + " def ask(self, question):\n", + " \"\"\"回答用户问题。\"\"\"\n", + " return self.chain.invoke(question)\n", + "\n", + " def add_documents(self, file_paths):\n", + " \"\"\"增量添加新文档。\"\"\"\n", + " loaders = [TextLoader(path, encoding='utf-8') for path in file_paths]\n", + " docs = []\n", + " for loader in loaders:\n", + " docs.extend(loader.load())\n", + " splitter = RecursiveCharacterTextSplitter(chunk_size=150, chunk_overlap=30)\n", + " chunks = splitter.split_documents(docs)\n", + " self.vectorstore.add_documents(chunks)\n", + " return f'已添加 {len(chunks)} 个文本块'\n", + "\n", + "# 使用封装类\n", + "qa_system = ProductQASystem('./product_knowledge_db')\n", + "print(qa_system.ask('耳机续航多久?'))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. 评估检索效果\n", + "\n", + "RAG 系统的效果取决于两个环节:检索是否找到相关文档、生成是否基于文档准确回答。下面通过打印检索结果来评估检索环节。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 评估检索效果\n", + "test_queries = [\n", + " '智能手表怎么测心率?',\n", + " '耳机怎么切换下一首歌?',\n", + " '手表防水吗?',\n", + " '耳机充电要多久?'\n", + "]\n", + "\n", + "for q in test_queries:\n", + " print(f'\\n问题:{q}')\n", + " docs = vectorstore.as_retriever(search_kwargs={'k': 2}).invoke(q)\n", + " for i, doc in enumerate(docs):\n", + " print(f' {i+1}. [{doc.metadata[\"source\"]}] {doc.page_content[:60]}...')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7. 常见问题与优化策略\n", + "\n", + "### 7.1 检索不到相关内容\n", + "- 检查 embedding 模型是否正常工作\n", + "- 调整 `chunk_size` 和 `chunk_overlap`\n", + "- 尝试增加 `k` 值,返回更多候选\n", + "\n", + "### 7.2 回答包含错误信息\n", + "- 加强 Prompt 中的约束,如「不知道就说不知道」\n", + "- 检查检索片段是否包含答案\n", + "- 使用更大的语言模型\n", + "\n", + "### 7.3 回答过长或偏离问题\n", + "- 在 Prompt 中明确要求简洁回答\n", + "- 限制 `max_tokens`\n", + "\n", + "### 7.4 多文档冲突\n", + "- 在 `format_docs` 中标注每个片段的来源\n", + "- 使用元数据过滤区分不同产品或版本" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 8. 完整项目:多文档知识库问答系统\n", + "\n", + "下面把前面的代码整合成一个可复用的完整项目结构。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 完整流程整合\n", + "import warnings\n", + "warnings.filterwarnings('ignore', category=DeprecationWarning)\n", + "\n", + "from langchain_community.document_loaders import DirectoryLoader, TextLoader\n", + "from langchain_text_splitters import RecursiveCharacterTextSplitter\n", + "from langchain_openai import ChatOpenAI, OpenAIEmbeddings\n", + "from langchain_community.vectorstores import Chroma\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "from langchain_core.output_parsers import StrOutputParser\n", + "from langchain_core.runnables import RunnablePassthrough\n", + "\n", + "def build_knowledge_base(docs_dir, db_dir):\n", + " \"\"\"构建知识库:加载、分割、向量化、持久化。\"\"\"\n", + " loader = DirectoryLoader(docs_dir, glob='*.txt', loader_cls=TextLoader, loader_kwargs={'encoding': 'utf-8'})\n", + " documents = loader.load()\n", + " splitter = RecursiveCharacterTextSplitter(chunk_size=150, chunk_overlap=30)\n", + " chunks = splitter.split_documents(documents)\n", + " embeddings = OpenAIEmbeddings(\n", + " model='qwen3-embedding',\n", + " openai_api_base=os.environ.get('OPENAI_BASE_URL'),\n", + " openai_api_key=os.environ.get('OPENAI_API_KEY')\n", + " )\n", + " vectorstore = Chroma.from_documents(chunks, embeddings, persist_directory=db_dir)\n", + " return vectorstore\n", + "\n", + "def build_qa_chain(vectorstore, k=3):\n", + " \"\"\"构建 RAG 问答链。\"\"\"\n", + " llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.1)\n", + " prompt = ChatPromptTemplate.from_messages([\n", + " ('system', '你是产品客服助手,请根据手册内容准确回答,不知道就说不知道。'),\n", + " ('user', '''参考内容:\\n{context}\\n\\n问题:{question}''')\n", + " ])\n", + " retriever = vectorstore.as_retriever(search_kwargs={'k': k})\n", + " def format_docs(docs):\n", + " return '\\n\\n'.join([doc.page_content for doc in docs])\n", + " return (\n", + " {'context': retriever | format_docs, 'question': RunnablePassthrough()}\n", + " | prompt\n", + " | llm\n", + " | StrOutputParser()\n", + " )\n", + "\n", + "# 构建或加载知识库\n", + "if not os.path.exists('product_knowledge_db'):\n", + " vectorstore = build_knowledge_base('product_docs', 'product_knowledge_db')\n", + "else:\n", + " embeddings = OpenAIEmbeddings(\n", + " model='qwen3-embedding',\n", + " openai_api_base=os.environ.get('OPENAI_BASE_URL'),\n", + " openai_api_key=os.environ.get('OPENAI_API_KEY')\n", + " )\n", + " vectorstore = Chroma(persist_directory='product_knowledge_db', embedding_function=embeddings)\n", + "\n", + "# 构建问答链并提问\n", + "chain = build_qa_chain(vectorstore)\n", + "print(chain.invoke('无线耳机怎么配对?'))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 9. 本节课练习\n", + "\n", + "1. 在 `product_docs` 目录下添加第三份产品手册(如「智能音箱使用手册」),重新构建知识库\n", + "2. 修改 `format_docs` 函数,在 Prompt 中显示每个片段来自哪个文件\n", + "3. 分别测试 `k=1`、`k=3`、`k=5` 时的回答质量差异\n", + "4. 设计 5 个产品手册中的问题和 2 个手册外的问题,评估系统表现\n", + "5. 把 `ProductQASystem` 类封装保存到单独的 `qa_system.py` 文件中,实现命令行问答" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.3" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/14_LangGraph概述.ipynb b/14_LangGraph概述.ipynb new file mode 100644 index 0000000..5043d07 --- /dev/null +++ b/14_LangGraph概述.ipynb @@ -0,0 +1,299 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 14 LangGraph 概述\n", + "\n", + "## 学习目标\n", + "1. 理解为什么需要 LangGraph:复杂智能体流程的控制需求\n", + "2. 掌握 LangGraph 的核心概念:StateGraph、State、Node、Edge\n", + "3. 理解 LangGraph 与 LangChain 的关系和分工\n", + "4. 能够编写一个最简单的 LangGraph 状态图\n", + "5. 了解 LangGraph 的典型应用场景" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. 为什么需要 LangGraph\n", + "\n", + "前面我们用 LangChain 构建了链式应用和简单的工具调用智能体。但当智能体流程变得复杂时,会遇到一些问题:\n", + "\n", + "- **流程不可控**:标准 Agent 的黑盒决策难以调试和约束\n", + "- **循环和分支困难**:难以实现多轮循环、条件分支、人机协作\n", + "- **状态管理复杂**:多步骤之间的状态传递不清晰\n", + "- **缺乏持久化**:无法暂停、恢复或检查执行历史\n", + "\n", + "**LangGraph** 就是为了解决这些问题而设计的。它把智能体流程建模为一个**状态图(StateGraph)**,让开发者精确控制每个步骤。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. LangGraph 是什么\n", + "\n", + "LangGraph 是 LangChain 生态中的一个库,用于构建基于**状态图**的智能体应用。它的核心思想是:\n", + "\n", + "- 把应用看作一个图(Graph)\n", + "- 图中的每个节点(Node)是一个函数或操作\n", + "- 边(Edge)控制流程走向\n", + "- 状态(State)在节点之间传递\n", + "\n", + "LangGraph 基于 LangChain 的 Runnable 接口构建,因此可以无缝使用 LangChain 的模型、工具、Prompt 等组件。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. LangGraph 与 LangChain 的关系\n", + "\n", + "| 对比项 | LangChain | LangGraph |\n", + "| --- | --- | --- |\n", + "| 核心抽象 | Chain / Agent | StateGraph |\n", + "| 流程控制 | 线性或简单循环 | 任意图结构、条件分支、循环 |\n", + "| 状态管理 | 隐式传递 | 显式 State 对象 |\n", + "| 适用场景 | 简单流水线、单次调用 | 复杂多智能体、多轮交互 |\n", + "| 关系 | 基础组件库 | 基于 LangChain 构建的流程编排层 |\n", + "\n", + "可以把 LangChain 理解为「零件库」,LangGraph 理解为「装配线控制系统」。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. 核心概念\n", + "\n", + "### 4.1 State(状态)\n", + "状态是整个图共享的数据结构。每个节点读取状态、修改状态,然后把状态传递给下一个节点。\n", + "\n", + "### 4.2 StateGraph(状态图)\n", + "状态图是 LangGraph 最核心的类。它负责定义节点、边和状态类型。\n", + "\n", + "### 4.3 Node(节点)\n", + "节点是图中的执行单元,通常是一个 Python 函数。节点接收当前状态,返回对状态的更新。\n", + "\n", + "### 4.4 Edge(边)\n", + "边定义节点之间的连接关系。LangGraph 支持:\n", + "- 普通边:固定从一个节点到另一个节点\n", + "- 条件边:根据状态决定下一个节点\n", + "- START / END:图的起点和终点" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. 第一个 LangGraph 程序\n", + "\n", + "下面构建一个最简单的图:两个节点 `node_a` 和 `node_b`,`node_a` 给状态加 1,`node_b` 再给状态乘 2。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from typing_extensions import TypedDict\n", + "from langgraph.graph import StateGraph, START, END\n", + "\n", + "# 1. 定义状态类型\n", + "class MyState(TypedDict):\n", + " \"\"\"图中的共享状态。\"\"\"\n", + " value: int\n", + "\n", + "# 2. 定义节点函数\n", + "def node_a(state: MyState):\n", + " \"\"\"把 value 加 1。\"\"\"\n", + " print(f'Node A 接收:{state[\"value\"]}')\n", + " return {'value': state['value'] + 1}\n", + "\n", + "def node_b(state: MyState):\n", + " \"\"\"把 value 乘 2。\"\"\"\n", + " print(f'Node B 接收:{state[\"value\"]}')\n", + " return {'value': state['value'] * 2}\n", + "\n", + "# 3. 构建状态图\n", + "builder = StateGraph(MyState)\n", + "builder.add_node('node_a', node_a)\n", + "builder.add_node('node_b', node_b)\n", + "\n", + "# 4. 添加边\n", + "builder.add_edge(START, 'node_a')\n", + "builder.add_edge('node_a', 'node_b')\n", + "builder.add_edge('node_b', END)\n", + "\n", + "# 5. 编译图\n", + "graph = builder.compile()\n", + "\n", + "# 6. 运行图\n", + "result = graph.invoke({'value': 3})\n", + "print(f'\\n最终结果:{result}')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "- `TypedDict` 定义状态结构,告诉 LangGraph 图中有哪些字段\n", + "- `StateGraph(MyState)` 创建状态图实例\n", + "- `add_node(name, func)` 添加节点\n", + "- `add_edge(START, 'node_a')` 设置从图起点到 node_a 的边\n", + "- `compile()` 编译图,生成可执行的 Runnable\n", + "- `invoke({'value': 3})` 运行图,传入初始状态\n", + "\n", + "这个例子的执行流程是:`START -> node_a(value=3 -> 4) -> node_b(value=4 -> 8) -> END`,最终结果是 8。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. LangGraph 执行流程可视化\n", + "\n", + "LangGraph 提供了 `get_graph()` 方法,可以查看图的结构。\n", + "\n", + "在线查看Mermaid 图:https://mermaid.live/edit\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 打印图的节点和边\n", + "print(graph.get_graph().nodes)\n", + "print(graph.get_graph().edges)\n", + "\n", + "# 使用 mermaid 格式可视化(在支持 mermaid 的 Markdown 查看器中可显示)\n", + "print('\\nMermaid 图:')\n", + "print(graph.get_graph().draw_mermaid())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7. 在 LangGraph 中使用 LangChain 组件\n", + "\n", + "LangGraph 的节点可以使用任何 LangChain 组件。下面是一个使用 LLM 和 Prompt 的节点示例。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "from langgraph.graph.message import add_messages\n", + "from dotenv import load_dotenv\n", + "\n", + "load_dotenv()\n", + "\n", + "\n", + "# 定义带消息历史的状态\n", + "class ChatState(TypedDict):\n", + " messages: list # 对话历史\n", + "\n", + "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0.7)\n", + "\n", + "def chat_node(state: ChatState):\n", + " \"\"\"调用 LLM 回复用户。\"\"\"\n", + " prompt = ChatPromptTemplate.from_messages([\n", + " ('system', '你是友好的 AI 助手。'),\n", + " *state['messages']\n", + " ])\n", + " response = (prompt | llm).invoke({})\n", + " return {'messages': [response]}\n", + "\n", + "builder = StateGraph(ChatState)\n", + "builder.add_node('chat', chat_node)\n", + "builder.add_edge(START, 'chat')\n", + "builder.add_edge('chat', END)\n", + "\n", + "chat_graph = builder.compile()\n", + "\n", + "result = chat_graph.invoke({\n", + " 'messages': [('user', '请用一句话介绍 LangGraph')]\n", + "})\n", + "print(result['messages'][-1].content)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "- `messages` 是列表类型,每个元素是 `(role, content)` 元组\n", + "- `ChatPromptTemplate.from_messages([...])` 中的 `*state['messages']` 把历史消息展开\n", + "- 节点返回 `{'messages': [response]}`,LangGraph 会自动合并到状态中\n", + "- 这里只是一个单节点图,后续课程会扩展到多轮对话和循环" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 8. LangGraph 的典型应用场景\n", + "\n", + "LangGraph 特别适合以下场景:\n", + "\n", + "| 场景 | 说明 |\n", + "| --- | --- |\n", + "| **多轮工具调用** | 在循环中反复调用工具,直到获得足够信息 |\n", + "| **人机协作(Human-in-the-loop)** | 在关键节点暂停,等待人类确认 |\n", + "| **多智能体系统** | 多个 Agent 通过图结构协作完成任务 |\n", + "| **复杂审批流程** | 根据条件分支决定流程走向 |\n", + "| **长期记忆** | 通过持久化状态实现跨会话记忆 |\n", + "\n", + "这些场景在后面几节课会逐一展开。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 9. 本节课练习\n", + "\n", + "1. 修改第一个 LangGraph 示例,增加第三个节点 `node_c`,实现 value = value - 2\n", + "2. 改变三个节点的连接顺序,观察最终结果如何变化\n", + "3. 在 ChatState 中增加一个 `topic` 字段,让 system prompt 根据 topic 动态变化\n", + "4. 尝试画出本节课第一个示例的状态图(START -> node_a -> node_b -> END)\n", + "5. 思考:LangGraph 与你之前学过的 LangChain Chain 相比,最大的优势是什么?" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.4" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/15_图结构.ipynb b/15_图结构.ipynb new file mode 100644 index 0000000..06a51d4 --- /dev/null +++ b/15_图结构.ipynb @@ -0,0 +1,656 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "1801d2fc", + "metadata": {}, + "source": [ + "# 15 图结构\n", + "\n", + "## 学习目标\n", + "1. 理解为什么要用图结构来组织复杂流程\n", + "2. 掌握 LangGraph 中节点、边、状态的配合方式\n", + "3. 学会构建顺序执行、条件分支和循环三种常见图结构\n", + "4. 能够看懂图结构的执行路径,并根据需求修改流程\n", + "5. 为后续多智能体、工作流和复杂 Agent 打下基础" + ] + }, + { + "cell_type": "markdown", + "id": "1f4e8fef", + "metadata": {}, + "source": [ + "## 1. 为什么需要图结构\n", + "\n", + "在前面的课程中,我们已经学过链(Chain):\n", + "\n", + "```\n", + "输入 -> Prompt -> 模型 -> 输出\n", + "```\n", + "\n", + "这种结构很适合**线性流程**,也就是每一步都按固定顺序往下走。\n", + "\n", + "但是在真实应用中,流程往往没有这么简单。例如:\n", + "\n", + "- 如果用户的问题不完整,要先追问再继续\n", + "- 如果模型判断需要调用工具,要进入工具节点\n", + "- 如果工具返回的信息还不够,要再循环一次\n", + "- 如果满足某个条件,要走 A 路线,否则走 B 路线\n", + "\n", + "这时,单纯的链就不够用了。我们需要一种更灵活的结构,让流程能够:\n", + "\n", + "- **分支**:根据条件走不同路线\n", + "- **回路**:重复执行直到满足条件\n", + "- **汇合**:多条路径最后回到同一个结果\n", + "\n", + "这就是**图结构(Graph)**的价值。" + ] + }, + { + "cell_type": "markdown", + "id": "761e8840", + "metadata": {}, + "source": [ + "## 2. 图结构的核心组成\n", + "\n", + "在 LangGraph 中,一个图通常由三部分组成:\n", + "\n", + "### 2.1 State(状态)\n", + "状态可以理解为‘流程执行时共享的一份数据’。\n", + "\n", + "例如一个状态可能长这样:\n", + "\n", + "```python\n", + "{'value': 3, 'result': '通过', 'step': 2}\n", + "```\n", + "\n", + "图中的每个节点都可以读取这份状态,也可以返回对状态的更新。\n", + "\n", + "### 2.2 Node(节点)\n", + "节点就是图中的一个处理步骤。\n", + "\n", + "你可以把节点理解成‘流水线上的一个工位’。每个工位做一件事,例如:\n", + "\n", + "- 给数字加 1\n", + "- 判断成绩是否及格\n", + "- 调用大模型生成回复\n", + "- 调用搜索工具获取资料\n", + "\n", + "### 2.3 Edge(边)\n", + "边表示‘从哪个节点走到哪个节点’。\n", + "\n", + "边决定了执行顺序。常见有两种:\n", + "\n", + "- **普通边**:固定走向下一个节点\n", + "- **条件边**:根据状态判断下一步走哪条路\n", + "\n", + "可以把图结构想象成一张地铁路线图:\n", + "\n", + "- 状态 = 你当前身上的信息\n", + "- 节点 = 你经过的站点\n", + "- 边 = 站点之间的路线" + ] + }, + { + "cell_type": "markdown", + "id": "bf0b4be0", + "metadata": {}, + "source": [ + "## 3. 第一个图:顺序执行\n", + "\n", + "先从最简单的情况开始:\n", + "\n", + "```\n", + "START -> 加一 -> 乘二 -> END\n", + "```\n", + "\n", + "也就是说:\n", + "\n", + "1. 初始值是 `value=3`\n", + "2. 第一个节点先把它加 1,变成 4\n", + "3. 第二个节点再把它乘 2,变成 8\n", + "4. 最后输出结果" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "527c7a13", + "metadata": {}, + "outputs": [], + "source": [ + "from typing_extensions import TypedDict\n", + "from langgraph.graph import StateGraph, START, END\n", + "\n", + "# 1. 定义状态结构\n", + "class NumberState(TypedDict):\n", + " value: int\n", + "\n", + "# 2. 定义节点函数\n", + "def add_one(state: NumberState):\n", + " print(f'进入 add_one 前,value = {state[\"value\"]}')\n", + " return {'value': state['value'] + 1}\n", + "\n", + "def multiply_two(state: NumberState):\n", + " print(f'进入 multiply_two 前,value = {state[\"value\"]}')\n", + " return {'value': state['value'] * 2}\n", + "\n", + "# 3. 创建图\n", + "builder = StateGraph(NumberState)\n", + "\n", + "# 4. 添加节点\n", + "builder.add_node('add_one', add_one)\n", + "builder.add_node('multiply_two', multiply_two)\n", + "\n", + "# 5. 添加边\n", + "builder.add_edge(START, 'add_one')\n", + "builder.add_edge('add_one', 'multiply_two')\n", + "builder.add_edge('multiply_two', END)\n", + "\n", + "# 6. 编译图\n", + "graph = builder.compile()\n", + "\n", + "# 7. 执行图\n", + "result = graph.invoke({'value': 3})\n", + "print('最终结果:', result)" + ] + }, + { + "cell_type": "markdown", + "id": "62406ccb", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码虽然不长,但非常关键,值得逐行理解。\n", + "\n", + "#### 第 1 步:定义状态\n", + "`NumberState` 规定了图里共享的数据结构,这里只有一个字段:`value`。\n", + "\n", + "也就是说,整张图传来传去的核心数据就是一个整数。\n", + "\n", + "#### 第 2 步:定义节点函数\n", + "`add_one(state)` 和 `multiply_two(state)` 都接收当前状态。\n", + "\n", + "例如当状态是:\n", + "\n", + "```python\n", + "{'value': 3}\n", + "```\n", + "\n", + "那么 `add_one` 返回:\n", + "\n", + "```python\n", + "{'value': 4}\n", + "```\n", + "\n", + "节点函数**不需要返回完整状态**,只要返回要更新的字段即可。LangGraph 会把更新结果合并回状态中。\n", + "\n", + "#### 第 3 步:创建图\n", + "`StateGraph(NumberState)` 表示:我们要创建一张图,并且这张图中的状态结构由 `NumberState` 定义。\n", + "\n", + "#### 第 4 步:添加节点\n", + "`builder.add_node('add_one', add_one)` 的意思是:\n", + "\n", + "- 节点名字叫 `add_one`\n", + "- 这个节点真正执行的函数是 Python 函数 `add_one`\n", + "\n", + "节点名字更像是图里的‘标签’,函数才是‘真正干活的人’。\n", + "\n", + "#### 第 5 步:添加边\n", + "这三行定义了执行路径:\n", + "\n", + "- 从 `START` 进入 `add_one`\n", + "- 从 `add_one` 进入 `multiply_two`\n", + "- 从 `multiply_two` 进入 `END`\n", + "\n", + "因此执行顺序是固定的,没有分支,也没有循环。\n", + "\n", + "#### 第 6 步:编译图\n", + "`compile()` 会把‘图的定义’变成一个真正可运行的对象。\n", + "\n", + "你可以把它理解为:前面是在画流程图,这一步是把流程图变成程序。\n", + "\n", + "#### 第 7 步:运行图\n", + "`graph.invoke({'value': 3})` 表示用初始状态 `{'value': 3}` 启动整张图。\n", + "\n", + "执行过程是:\n", + "\n", + "1. `value=3` 进入 `add_one`\n", + "2. 更新为 `value=4`\n", + "3. `value=4` 进入 `multiply_two`\n", + "4. 更新为 `value=8`\n", + "5. 图结束,输出 `{'value': 8}`" + ] + }, + { + "cell_type": "markdown", + "id": "e86fbc2b", + "metadata": {}, + "source": [ + "## 4. 查看图结构\n", + "\n", + "除了运行图,我们还可以把图的结构打印出来,帮助自己检查流程是否连对了。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "029c26ae", + "metadata": {}, + "outputs": [], + "source": [ + "print('节点:')\n", + "print(graph.get_graph().nodes)\n", + "\n", + "print('\\n边:')\n", + "print(graph.get_graph().edges)\n", + "\n", + "print('\\nMermaid 图:')\n", + "print(graph.get_graph().draw_mermaid())" + ] + }, + { + "cell_type": "markdown", + "id": "996a24d3", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这一段代码的重点不在‘计算’,而在‘观察图’。\n", + "\n", + "#### `graph.get_graph().nodes`\n", + "它会返回当前图中有哪些节点。你可以把它理解为‘流程图里有哪些方框’。\n", + "\n", + "#### `graph.get_graph().edges`\n", + "它会返回节点之间的连接关系,也就是‘箭头从哪里指向哪里’。\n", + "\n", + "#### `draw_mermaid()`\n", + "它会生成 Mermaid 格式的流程图文本。\n", + "\n", + "Mermaid 是一种用文本描述流程图的语法,例如:\n", + "\n", + "```\n", + "START --> add_one --> multiply_two --> END\n", + "```\n", + "\n", + "你可以把输出复制到支持 Mermaid 的工具中查看图形化效果。\n", + "\n", + "这在图结构变复杂之后尤其有用,因为:\n", + "\n", + "- 可以快速确认自己有没有连错边\n", + "- 可以帮助团队成员理解流程\n", + "- 可以作为调试流程的一部分" + ] + }, + { + "cell_type": "markdown", + "id": "9117ea6b", + "metadata": {}, + "source": [ + "## 5. 条件分支图\n", + "\n", + "前面的图是固定顺序执行。现在我们来做一个更像真实业务的例子:**根据分数决定是否通过**。\n", + "\n", + "流程如下:\n", + "\n", + "```\n", + "START -> 判断分数 -> 合格 / 不合格 -> END\n", + "```\n", + "\n", + "如果分数大于等于 60,就走‘通过’节点;否则走‘补考’节点。\n", + "\n", + "这就是典型的**条件分支**。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ed38a5d4", + "metadata": {}, + "outputs": [], + "source": [ + "from typing_extensions import TypedDict\n", + "from langgraph.graph import StateGraph, START, END\n", + "\n", + "class ScoreState(TypedDict):\n", + " score: int\n", + " result: str\n", + "\n", + "def check_score(state: ScoreState):\n", + " print(f'当前分数:{state[\"score\"]}')\n", + " return {}\n", + "\n", + "def pass_node(state: ScoreState):\n", + " return {'result': '成绩合格,顺利通过'}\n", + "\n", + "def fail_node(state: ScoreState):\n", + " return {'result': '成绩不合格,需要补考'}\n", + "\n", + "def route_by_score(state: ScoreState):\n", + " if state['score'] >= 60:\n", + " return 'pass_node'\n", + " return 'fail_node'\n", + "\n", + "builder = StateGraph(ScoreState)\n", + "builder.add_node('check_score', check_score)\n", + "builder.add_node('pass_node', pass_node)\n", + "builder.add_node('fail_node', fail_node)\n", + "\n", + "builder.add_edge(START, 'check_score')\n", + "builder.add_conditional_edges(\n", + " 'check_score',\n", + " route_by_score,\n", + " {\n", + " 'pass_node': 'pass_node',\n", + " 'fail_node': 'fail_node'\n", + " }\n", + ")\n", + "builder.add_edge('pass_node', END)\n", + "builder.add_edge('fail_node', END)\n", + "\n", + "score_graph = builder.compile()\n", + "\n", + "print('分数 85 的结果:')\n", + "print(score_graph.invoke({'score': 85, 'result': ''}))\n", + "\n", + "print('\\n分数 45 的结果:')\n", + "print(score_graph.invoke({'score': 45, 'result': ''}))" + ] + }, + { + "cell_type": "markdown", + "id": "a8a9080b", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这个例子第一次出现了**条件边**,所以需要重点掌握。\n", + "\n", + "#### 状态结构\n", + "这里的状态包含两个字段:\n", + "\n", + "- `score`:输入分数\n", + "- `result`:最终判断结果\n", + "\n", + "也就是说,这张图的目标是:根据 `score` 计算出 `result`。\n", + "\n", + "#### `check_score` 节点\n", + "这个节点本身没有修改状态,只是把当前分数打印出来。\n", + "\n", + "它返回 `{}`,表示‘这个节点不更新任何字段’。\n", + "\n", + "这说明一个节点不一定非要做数据修改,它也可以只承担‘中转’或‘判断前准备’的作用。\n", + "\n", + "#### `pass_node` 和 `fail_node`\n", + "这两个节点分别代表两条不同路径的处理结果:\n", + "\n", + "- 如果通过,更新 `result='成绩合格,顺利通过'`\n", + "- 如果不通过,更新 `result='成绩不合格,需要补考'`\n", + "\n", + "这两个节点就像流程图里分叉后的两条支路。\n", + "\n", + "#### `route_by_score`:路由函数\n", + "这是本例最核心的部分。\n", + "\n", + "它不是真正的业务节点,而是一个‘路线选择器’。它根据当前状态判断:\n", + "\n", + "- `score >= 60` 返回 `'pass_node'`\n", + "- 否则返回 `'fail_node'`\n", + "\n", + "返回值必须和后面条件边映射表中的 key 对应上。\n", + "\n", + "#### `add_conditional_edges(...)`\n", + "这段代码的意思是:\n", + "\n", + "从 `check_score` 节点出来后,不是固定走下一步,而是调用 `route_by_score(state)`。\n", + "\n", + "如果返回 `'pass_node'`,就走到 `pass_node`;如果返回 `'fail_node'`,就走到 `fail_node`。\n", + "\n", + "你可以把它理解成:\n", + "\n", + "- 普通边 = 固定导航\n", + "- 条件边 = 智能导航\n", + "\n", + "#### 最终执行效果\n", + "当输入 `score=85` 时,流程是:\n", + "\n", + "```\n", + "START -> check_score -> pass_node -> END\n", + "```\n", + "\n", + "当输入 `score=45` 时,流程是:\n", + "\n", + "```\n", + "START -> check_score -> fail_node -> END\n", + "```\n", + "\n", + "这就是图结构中‘根据条件走不同路线’的最基本模式。" + ] + }, + { + "cell_type": "markdown", + "id": "3cf69b96", + "metadata": {}, + "source": [ + "## 6. 循环图\n", + "\n", + "除了分支,图结构还有一个非常重要的能力:**循环**。\n", + "\n", + "在很多智能体任务中,我们都需要重复执行某些步骤,比如:\n", + "\n", + "- 检索不到足够信息就继续搜索\n", + "- 模型输出格式不对就重新生成\n", + "- 工具调用结果不完整就继续补充\n", + "\n", + "下面我们用一个简单例子说明循环:\n", + "\n", + "让 `count` 从 0 开始,每次加 1,直到加到 3 为止。\n", + "\n", + "流程如下:\n", + "\n", + "```\n", + "START -> increment -> 判断是否结束\n", + " ↘ 未结束,回到 increment\n", + " ↘ 已结束,进入 END\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d33d64ec", + "metadata": {}, + "outputs": [], + "source": [ + "from typing_extensions import TypedDict\n", + "from langgraph.graph import StateGraph, START, END\n", + "\n", + "class LoopState(TypedDict):\n", + " count: int\n", + "\n", + "def increment(state: LoopState):\n", + " new_count = state['count'] + 1\n", + " print(f'当前 count 从 {state[\"count\"]} 变成 {new_count}')\n", + " return {'count': new_count}\n", + "\n", + "def should_continue(state: LoopState):\n", + " if state['count'] < 3:\n", + " return 'continue'\n", + " return 'stop'\n", + "\n", + "builder = StateGraph(LoopState)\n", + "builder.add_node('increment', increment)\n", + "\n", + "builder.add_edge(START, 'increment')\n", + "builder.add_conditional_edges(\n", + " 'increment',\n", + " should_continue,\n", + " {\n", + " 'continue': 'increment',\n", + " 'stop': END\n", + " }\n", + ")\n", + "\n", + "loop_graph = builder.compile()\n", + "result = loop_graph.invoke({'count': 0})\n", + "print('最终状态:', result)" + ] + }, + { + "cell_type": "markdown", + "id": "26274c73", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这个例子看起来简单,但它已经具备了很多 Agent 工作流的核心思想。\n", + "\n", + "#### `increment` 节点\n", + "它的职责非常明确:每次把 `count` 加 1。\n", + "\n", + "例如:\n", + "\n", + "- 输入 `count=0`,输出 `count=1`\n", + "- 输入 `count=1`,输出 `count=2`\n", + "- 输入 `count=2`,输出 `count=3`\n", + "\n", + "#### `should_continue` 路由函数\n", + "它负责判断是继续循环,还是结束流程。\n", + "\n", + "规则很简单:\n", + "\n", + "- 如果 `count < 3`,返回 `'continue'`\n", + "- 否则返回 `'stop'`\n", + "\n", + "#### 条件边的映射\n", + "这里最关键的一点是:\n", + "\n", + "- `'continue'` 映射到 `'increment'`\n", + "- `'stop'` 映射到 `END`\n", + "\n", + "这就意味着:\n", + "\n", + "- 只要还没达到条件,就重新回到同一个节点\n", + "- 达到条件后,就退出图\n", + "\n", + "这就是循环结构的本质。\n", + "\n", + "#### 执行过程\n", + "如果初始状态是 `{'count': 0}`,执行路径如下:\n", + "\n", + "1. 进入 `increment`,变成 `count=1`\n", + "2. 判断 `1 < 3`,继续\n", + "3. 再次进入 `increment`,变成 `count=2`\n", + "4. 判断 `2 < 3`,继续\n", + "5. 再次进入 `increment`,变成 `count=3`\n", + "6. 判断 `3 < 3` 不成立,停止\n", + "7. 进入 `END`\n", + "\n", + "也就是说,图结构中的循环并不是 `for` 或 `while` 语法写出来的,而是通过‘节点 + 条件边回跳’实现的。\n", + "\n", + "这也是 LangGraph 很强大的地方:它把‘流程控制’变成了图本身的一部分。" + ] + }, + { + "cell_type": "markdown", + "id": "8c2c4ae1", + "metadata": {}, + "source": [ + "## 7. 图结构和普通 Python 流程控制有什么区别\n", + "\n", + "你可能会问:\n", + "\n", + "‘这些分支和循环,我直接用 `if`、`while` 不也能写吗?’\n", + "\n", + "当然可以。但图结构有几个明显优势:\n", + "\n", + "| 对比点 | 普通 Python 代码 | 图结构 |\n", + "| --- | --- | --- |\n", + "| 流程表达方式 | 写在代码逻辑里 | 显式画成节点和边 |\n", + "| 可视化能力 | 弱 | 强 |\n", + "| 分支/回路可读性 | 复杂时容易乱 | 更直观 |\n", + "| 与 Agent / 工作流结合 | 需要手动组织 | 天然适合 |\n", + "| 状态传递 | 自己管理变量 | 统一用 State 管理 |\n", + "\n", + "最核心的一点是:\n", + "\n", + "**图结构不是为了替代 Python,而是为了让复杂流程更容易设计、理解、调试和扩展。**" + ] + }, + { + "cell_type": "markdown", + "id": "f82467cf", + "metadata": {}, + "source": [ + "## 8. 一个更贴近 Agent 的理解方式\n", + "\n", + "如果把图结构放到 AI Agent 场景中,可以这样理解:\n", + "\n", + "- 一个节点可以是‘调用大模型’\n", + "- 一个节点可以是‘调用工具’\n", + "- 一个节点可以是‘检查结果是否满足要求’\n", + "- 条件边可以决定‘是继续调用工具,还是给用户最终答案’\n", + "\n", + "也就是说,后面你学到的复杂 Agent,本质上大多都可以拆成:\n", + "\n", + "```\n", + "理解问题 -> 决策 -> 调工具 -> 检查结果 -> 继续/结束\n", + "```\n", + "\n", + "而图结构正是把这条路线显式表达出来的最好方式。" + ] + }, + { + "cell_type": "markdown", + "id": "f8ced671", + "metadata": {}, + "source": [ + "## 9. 本节小结\n", + "\n", + "本节课你需要记住三个最重要的结论:\n", + "\n", + "1. **State 是共享数据**:节点之间通过状态传递信息\n", + "2. **Node 是处理步骤**:每个节点负责完成一个明确任务\n", + "3. **Edge 决定执行路径**:普通边用于顺序执行,条件边用于分支和循环\n", + "\n", + "掌握了这三点,你就已经理解了图结构的核心。后面无论是多轮工作流、工具调用循环,还是多智能体协作,本质上都只是图结构的进一步扩展。" + ] + }, + { + "cell_type": "markdown", + "id": "3ec471d7", + "metadata": {}, + "source": [ + "## 10. 本节练习\n", + "\n", + "1. 修改顺序执行示例,在 `multiply_two` 后面再加一个节点 `minus_three`,让最终结果再减 3\n", + "2. 修改条件分支示例,把及格线从 60 改成 80,观察结果变化\n", + "3. 修改循环示例,把终止条件从 `count == 3` 改成 `count == 5`\n", + "4. 尝试自己画出三个示例的流程图\n", + "5. 思考:如果要做一个‘查询资料直到找到答案为止’的 Agent,它更像本节中的分支图,还是循环图?为什么?" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.4" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/16_状态管理.ipynb b/16_状态管理.ipynb new file mode 100644 index 0000000..8c3bf69 --- /dev/null +++ b/16_状态管理.ipynb @@ -0,0 +1,635 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cc615887", + "metadata": {}, + "source": [ + "# 16 状态管理\n", + "\n", + "## 学习目标\n", + "1. 理解 LangGraph 中状态(State)的设计与作用\n", + "2. 掌握使用 TypedDict 定义图状态\n", + "3. 学会在节点间传递、读取和更新状态\n", + "4. 理解多字段状态在实际流程中的用法\n", + "5. 避免状态设计中的常见问题" + ] + }, + { + "cell_type": "markdown", + "id": "dfebe943", + "metadata": {}, + "source": [ + "## 1. 什么是状态管理\n", + "\n", + "在 LangGraph 中,**状态(State)** 可以理解为“流程运行时一直随身携带的一份数据”。\n", + "\n", + "前一节我们已经接触过状态,例如:\n", + "\n", + "```python\n", + "{'value': 3}\n", + "```\n", + "\n", + "或者:\n", + "\n", + "```python\n", + "{'score': 85, 'result': '成绩合格,顺利通过'}\n", + "```\n", + "\n", + "这些数据会在图的不同节点之间传来传去。\n", + "\n", + "状态管理要解决的问题,其实很简单:\n", + "\n", + "- 当前流程已经走到哪一步了?\n", + "- 前面节点产出的结果,后面节点怎么接着用?\n", + "- 某个节点更新了数据,后续节点如何读取最新值?\n", + "\n", + "你可以把状态想象成一张‘流程记录卡’:\n", + "\n", + "- 每经过一个节点,就在卡上补充信息\n", + "- 后面的节点只需要看这张卡,就知道前面发生了什么\n", + "\n", + "所以,**状态管理的本质,就是管理这张共享记录卡。**" + ] + }, + { + "cell_type": "markdown", + "id": "a40150ac", + "metadata": {}, + "source": [ + "## 2. 为什么状态很重要\n", + "\n", + "如果没有状态,节点之间就很难协作。\n", + "\n", + "例如一个流程要完成下面几步:\n", + "\n", + "1. 接收用户输入\n", + "2. 提取关键词\n", + "3. 根据关键词生成结论\n", + "\n", + "那么问题来了:\n", + "\n", + "- 第二步提取出来的关键词,怎么交给第三步?\n", + "- 第一节点记录的原始问题,第三步还能不能访问?\n", + "- 如果流程还要继续加步骤,数据还能不能统一管理?\n", + "\n", + "答案就是:都放进状态里。\n", + "\n", + "状态的几个核心价值:\n", + "\n", + "- **统一传递数据**:所有节点都从同一个地方读取信息\n", + "- **减少参数混乱**:不用手动给每个函数传很多单独变量\n", + "- **便于扩展流程**:后面增加新节点时,只要继续读写状态即可\n", + "- **便于调试**:看状态就能知道流程执行到了什么程度" + ] + }, + { + "cell_type": "markdown", + "id": "644129aa", + "metadata": {}, + "source": [ + "## 3. 用 TypedDict 定义状态\n", + "\n", + "在 LangGraph 中,我们通常用 `TypedDict` 来定义状态结构。\n", + "\n", + "这样做有两个好处:\n", + "\n", + "1. 让自己清楚状态里有哪些字段\n", + "2. 让代码更容易维护和理解\n", + "\n", + "下面先看一个最简单的例子。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "08a43781", + "metadata": {}, + "outputs": [], + "source": [ + "from typing_extensions import TypedDict\n", + "\n", + "class UserState(TypedDict):\n", + " name: str\n", + " age: int\n", + " city: str\n", + "\n", + "example_state: UserState = {\n", + " 'name': '张三',\n", + " 'age': 20,\n", + " 'city': '北京'\n", + "}\n", + "\n", + "print(example_state)" + ] + }, + { + "cell_type": "markdown", + "id": "4cdd8784", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码本身还没有进入 LangGraph,只是在说明状态应该怎样定义。\n", + "\n", + "#### `TypedDict` 是什么\n", + "`TypedDict` 可以理解为‘带字段说明的字典’。\n", + "\n", + "普通字典也能写成:\n", + "\n", + "```python\n", + "{'name': '张三', 'age': 20, 'city': '北京'}\n", + "```\n", + "\n", + "但如果项目变复杂,单纯靠大家记忆“这个字典里应该有哪些字段”会很容易出错。\n", + "\n", + "`TypedDict` 的作用就是把这种约定明确写出来。\n", + "\n", + "#### `class UserState(TypedDict)`\n", + "这表示我们定义了一种状态结构,名字叫 `UserState`。\n", + "\n", + "它要求状态中有三个字段:\n", + "\n", + "- `name`:字符串\n", + "- `age`:整数\n", + "- `city`:字符串\n", + "\n", + "#### `example_state: UserState = {...}`\n", + "这表示我们创建了一个符合 `UserState` 结构的状态对象。\n", + "\n", + "从教学角度看,这一步很重要,因为它让你意识到:\n", + "\n", + "- 状态本质上仍然是字典\n", + "- `TypedDict` 只是帮助我们把字典结构写清楚\n", + "\n", + "在 LangGraph 中,节点之间传递的就是这种结构化字典。" + ] + }, + { + "cell_type": "markdown", + "id": "dceebf80", + "metadata": {}, + "source": [ + "## 4. 读取状态:节点如何使用已有数据\n", + "\n", + "定义完状态之后,下一步就是在节点中读取它。\n", + "\n", + "下面这个例子演示:节点如何从状态里取出数据并生成一句自我介绍。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cb6536ba", + "metadata": {}, + "outputs": [], + "source": [ + "from typing_extensions import TypedDict\n", + "from langgraph.graph import StateGraph, START, END\n", + "\n", + "class ProfileState(TypedDict):\n", + " name: str\n", + " age: int\n", + " city: str\n", + " intro: str\n", + "\n", + "def create_intro(state: ProfileState):\n", + " intro_text = f'大家好,我叫{state[\"name\"]},今年{state[\"age\"]}岁,来自{state[\"city\"]}。'\n", + " return {'intro': intro_text}\n", + "\n", + "builder = StateGraph(ProfileState)\n", + "builder.add_node('create_intro', create_intro)\n", + "builder.add_edge(START, 'create_intro')\n", + "builder.add_edge('create_intro', END)\n", + "\n", + "graph = builder.compile()\n", + "result = graph.invoke({\n", + " 'name': '李雷',\n", + " 'age': 18,\n", + " 'city': '上海',\n", + " 'intro': ''\n", + "})\n", + "\n", + "print(result)\n", + "print(result['intro'])" + ] + }, + { + "cell_type": "markdown", + "id": "120b0a74", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这个例子重点不是图结构本身,而是“节点如何读取状态中的已有字段”。\n", + "\n", + "#### 状态结构\n", + "这里的状态有四个字段:\n", + "\n", + "- `name`\n", + "- `age`\n", + "- `city`\n", + "- `intro`\n", + "\n", + "前三个字段是输入信息,最后一个字段是我们希望在流程中生成的新结果。\n", + "\n", + "#### `create_intro(state)`\n", + "这个节点做的事情很直观:\n", + "\n", + "1. 从状态中读取 `name`\n", + "2. 从状态中读取 `age`\n", + "3. 从状态中读取 `city`\n", + "4. 组装成一句完整介绍\n", + "5. 把结果写回 `intro` 字段\n", + "\n", + "也就是说,这个节点没有改变原来的 `name`、`age`、`city`,只是新增或更新了 `intro`。\n", + "\n", + "#### 节点返回值为什么只写 `{'intro': intro_text}`\n", + "这是 LangGraph 状态管理里非常重要的一点:\n", + "\n", + "**节点只需要返回自己负责修改的字段。**\n", + "\n", + "原来的字段不会凭空消失,而是继续保留在状态里。\n", + "\n", + "所以最终 `result` 中既有原始输入,也有新生成的 `intro`。\n", + "\n", + "#### 这个例子的意义\n", + "它展示了状态管理的最基础工作方式:\n", + "\n", + "- 输入状态中先有一部分信息\n", + "- 节点读取这些信息进行加工\n", + "- 再把加工结果写回状态\n", + "\n", + "这就是后面所有复杂流程的基础。" + ] + }, + { + "cell_type": "markdown", + "id": "28600cec", + "metadata": {}, + "source": [ + "## 5. 更新状态:后面的节点继续接着用\n", + "\n", + "状态管理真正强大的地方,不在于‘一个节点能读写状态’,而在于**前一个节点更新的内容,后一个节点可以继续使用**。\n", + "\n", + "下面这个例子演示两个节点接力处理状态:\n", + "\n", + "1. 第一个节点生成问候语\n", + "2. 第二个节点在问候语后面再补一句欢迎词" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "98e52d28", + "metadata": {}, + "outputs": [], + "source": [ + "from typing_extensions import TypedDict\n", + "from langgraph.graph import StateGraph, START, END\n", + "\n", + "class GreetingState(TypedDict):\n", + " name: str\n", + " greeting: str\n", + " final_message: str\n", + "\n", + "def make_greeting(state: GreetingState):\n", + " return {'greeting': f'你好,{state[\"name\"]}!'}\n", + "\n", + "def make_final_message(state: GreetingState):\n", + " final_text = state['greeting'] + ' 欢迎来到状态管理课程。'\n", + " return {'final_message': final_text}\n", + "\n", + "builder = StateGraph(GreetingState)\n", + "builder.add_node('make_greeting', make_greeting)\n", + "builder.add_node('make_final_message', make_final_message)\n", + "\n", + "builder.add_edge(START, 'make_greeting')\n", + "builder.add_edge('make_greeting', 'make_final_message')\n", + "builder.add_edge('make_final_message', END)\n", + "\n", + "graph = builder.compile()\n", + "result = graph.invoke({\n", + " 'name': '小王',\n", + " 'greeting': '',\n", + " 'final_message': ''\n", + "})\n", + "\n", + "print(result)\n", + "print(result['final_message'])" + ] + }, + { + "cell_type": "markdown", + "id": "70fa1f92", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这个例子体现了状态在多个节点之间的‘接力传递’。\n", + "\n", + "#### 第一个节点 `make_greeting`\n", + "它只做一件事:根据 `name` 生成一句问候语,写入 `greeting`。\n", + "\n", + "例如:\n", + "\n", + "```python\n", + "{'name': '小王'}\n", + "```\n", + "\n", + "会生成:\n", + "\n", + "```python\n", + "{'greeting': '你好,小王!'}\n", + "```\n", + "\n", + "#### 第二个节点 `make_final_message`\n", + "它并不重新根据 `name` 生成问候,而是直接读取上一个节点已经写入状态的 `greeting`。\n", + "\n", + "然后在它后面拼上一句:\n", + "\n", + "```text\n", + "欢迎来到状态管理课程。\n", + "```\n", + "\n", + "最后写入 `final_message`。\n", + "\n", + "#### 这说明了什么\n", + "这说明状态不仅能存放“输入数据”,还能存放“中间结果”。\n", + "\n", + "这是状态管理特别重要的一点:\n", + "\n", + "- 输入数据:用户最初给的信息\n", + "- 中间结果:某个节点处理出来的阶段性结果\n", + "- 最终结果:流程结束时输出的内容\n", + "\n", + "如果没有状态,你就得手动把这些内容一级一级传下去;有了状态,流程自然就串起来了。" + ] + }, + { + "cell_type": "markdown", + "id": "f28536c4", + "metadata": {}, + "source": [ + "## 6. 多字段状态:让流程更接近真实业务\n", + "\n", + "真实项目里的状态,通常不会只有 1 个或 2 个字段。\n", + "\n", + "例如一个订单处理流程,可能需要同时记录:\n", + "\n", + "- 用户姓名\n", + "- 商品名称\n", + "- 数量\n", + "- 总价\n", + "- 订单状态\n", + "\n", + "下面我们用一个简单的订单示例,演示多字段状态的管理方式。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b3d1077b", + "metadata": {}, + "outputs": [], + "source": [ + "from typing_extensions import TypedDict\n", + "from langgraph.graph import StateGraph, START, END\n", + "\n", + "class OrderState(TypedDict):\n", + " customer_name: str\n", + " product_name: str\n", + " price: float\n", + " quantity: int\n", + " total_price: float\n", + " order_status: str\n", + "\n", + "def calculate_total(state: OrderState):\n", + " total = state['price'] * state['quantity']\n", + " return {'total_price': total}\n", + "\n", + "def confirm_order(state: OrderState):\n", + " status = f'订单已确认:{state[\"customer_name\"]} 购买了 {state[\"quantity\"]} 件 {state[\"product_name\"]},总价 {state[\"total_price\"]} 元。'\n", + " return {'order_status': status}\n", + "\n", + "builder = StateGraph(OrderState)\n", + "builder.add_node('calculate_total', calculate_total)\n", + "builder.add_node('confirm_order', confirm_order)\n", + "\n", + "builder.add_edge(START, 'calculate_total')\n", + "builder.add_edge('calculate_total', 'confirm_order')\n", + "builder.add_edge('confirm_order', END)\n", + "\n", + "graph = builder.compile()\n", + "result = graph.invoke({\n", + " 'customer_name': '王芳',\n", + " 'product_name': '机械键盘',\n", + " 'price': 299.0,\n", + " 'quantity': 2,\n", + " 'total_price': 0.0,\n", + " 'order_status': ''\n", + "})\n", + "\n", + "print(result)\n", + "print(result['order_status'])" + ] + }, + { + "cell_type": "markdown", + "id": "ad35700b", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这个例子很适合用来理解‘多字段状态为什么有必要’。\n", + "\n", + "#### 状态里有哪些信息\n", + "这里的状态已经不只是一个简单变量,而是一组完整业务数据:\n", + "\n", + "- 谁在下单:`customer_name`\n", + "- 买的是什么:`product_name`\n", + "- 单价是多少:`price`\n", + "- 买了几件:`quantity`\n", + "- 总价是多少:`total_price`\n", + "- 当前订单描述:`order_status`\n", + "\n", + "#### `calculate_total` 节点\n", + "这个节点只负责计算总价:\n", + "\n", + "```python\n", + "总价 = 单价 * 数量\n", + "```\n", + "\n", + "它返回 `{'total_price': total}`,把结果写回状态。\n", + "\n", + "#### `confirm_order` 节点\n", + "这个节点读取多个字段:\n", + "\n", + "- `customer_name`\n", + "- `quantity`\n", + "- `product_name`\n", + "- `total_price`\n", + "\n", + "然后把这些信息拼成完整订单说明,再写入 `order_status`。\n", + "\n", + "#### 这个例子传达的核心思想\n", + "状态不是只给“一个节点”准备的,而是给“整个流程”准备的。\n", + "\n", + "每个节点只关心自己需要的部分字段,但所有字段加起来,构成了完整的业务上下文。\n", + "\n", + "这也是为什么状态设计要尽量清晰:因为它决定了整个流程的数据组织方式。" + ] + }, + { + "cell_type": "markdown", + "id": "4858b6f3", + "metadata": {}, + "source": [ + "## 7. 状态设计的常见原则\n", + "\n", + "写状态时,建议遵循下面几个原则。\n", + "\n", + "### 7.1 字段名要清楚\n", + "不要用太模糊的名字,例如:\n", + "\n", + "- `data`\n", + "- `info`\n", + "- `result1`\n", + "\n", + "更好的方式是:\n", + "\n", + "- `user_question`\n", + "- `retrieved_docs`\n", + "- `final_answer`\n", + "\n", + "字段名越清楚,后面越不容易乱。\n", + "\n", + "### 7.2 一个字段只表达一种含义\n", + "例如不要今天把 `result` 用来存字符串,明天又用来存字典。\n", + "\n", + "字段含义最好稳定,不要随流程变化得太厉害。\n", + "\n", + "### 7.3 把中间结果保留下来\n", + "有些同学会只保留最终结果,把中间结果全部覆盖掉。这样虽然表面简洁,但调试时会非常痛苦。\n", + "\n", + "如果中间结果后面可能还会用到,或者你希望排查流程问题,就应该保留。\n", + "\n", + "### 7.4 不要把无关信息全塞进去\n", + "状态不是越大越好。\n", + "\n", + "只保留这个流程真正需要的数据,避免状态越来越臃肿。" + ] + }, + { + "cell_type": "markdown", + "id": "d701714f", + "metadata": {}, + "source": [ + "## 8. 一个常见误区:以为节点要返回完整状态\n", + "\n", + "很多初学者会误以为:每个节点都必须返回整份状态,例如:\n", + "\n", + "```python\n", + "return {\n", + " 'name': state['name'],\n", + " 'age': state['age'],\n", + " 'city': state['city'],\n", + " 'intro': intro_text\n", + "}\n", + "```\n", + "\n", + "其实在很多情况下,这么写没有必要。\n", + "\n", + "更简洁的方式通常是:\n", + "\n", + "```python\n", + "return {'intro': intro_text}\n", + "```\n", + "\n", + "原因是:LangGraph 会自动把更新结果合并回原状态。\n", + "\n", + "所以,除非你确实要同时改很多字段,否则通常只返回你真正改动的那部分。\n", + "\n", + "这会让代码更短,也更不容易出错。" + ] + }, + { + "cell_type": "markdown", + "id": "a28700b1", + "metadata": {}, + "source": [ + "## 9. 状态管理和后续 Agent 的关系\n", + "\n", + "后面学 Agent、RAG、多轮工具调用时,你会越来越频繁地用到状态。\n", + "\n", + "例如一个复杂 Agent 的状态里,可能会放这些字段:\n", + "\n", + "- 用户问题 `user_query`\n", + "- 历史消息 `messages`\n", + "- 工具调用结果 `tool_result`\n", + "- 检索到的资料 `retrieved_docs`\n", + "- 当前决策 `decision`\n", + "- 最终回答 `final_answer`\n", + "\n", + "所以可以说:\n", + "\n", + "**图结构解决的是“流程怎么走”,状态管理解决的是“数据怎么跟着流程走”。**\n", + "\n", + "这两者是 LangGraph 最核心的两根主线。" + ] + }, + { + "cell_type": "markdown", + "id": "2a08da16", + "metadata": {}, + "source": [ + "## 10. 本节小结\n", + "\n", + "本节你需要重点记住以下几点:\n", + "\n", + "1. **状态本质上是共享字典**,只是通常用 `TypedDict` 把结构定义清楚\n", + "2. **节点通过读取状态拿到已有数据**\n", + "3. **节点通过返回字典更新状态**\n", + "4. **后面的节点可以继续读取前面节点写入的中间结果**\n", + "5. **好的状态设计,会让整个图流程更清晰、更稳定、更容易调试**\n", + "\n", + "理解了状态管理,后面再看复杂流程时,你就不会只盯着‘节点怎么连’,而会同时关注‘数据怎么流动’。" + ] + }, + { + "cell_type": "markdown", + "id": "caa87287", + "metadata": {}, + "source": [ + "## 11. 本节练习\n", + "\n", + "1. 修改 `ProfileState` 示例,在状态中增加 `hobby` 字段,并把爱好加入自我介绍\n", + "2. 修改 `GreetingState` 示例,让第二个节点在最终消息后面再追加一句祝福\n", + "3. 修改 `OrderState` 示例,增加一个 `discount` 字段,并让总价计算支持折扣\n", + "4. 思考:哪些字段属于输入数据,哪些字段属于中间结果,哪些字段属于最终输出?\n", + "5. 思考:如果一个 Agent 需要反复调用工具,你觉得状态里至少应该保留哪些字段?" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.4" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/17_节点与边.ipynb b/17_节点与边.ipynb new file mode 100644 index 0000000..53900f7 --- /dev/null +++ b/17_节点与边.ipynb @@ -0,0 +1,586 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "5acad58d", + "metadata": {}, + "source": [ + "# 17 节点与边\n", + "\n", + "## 学习目标\n", + "1. 掌握添加节点(`add_node`)和添加边(`add_edge`)的方法\n", + "2. 理解 `START` 和 `END` 在图中的含义\n", + "3. 能够构建包含多个节点的顺序执行图\n", + "4. 理解节点负责做事、边负责决定顺序的分工\n", + "5. 能够看懂并修改一个简单的图流程" + ] + }, + { + "cell_type": "markdown", + "id": "0be17af5", + "metadata": {}, + "source": [ + "## 1. 为什么要单独讲节点与边\n", + "\n", + "在前两节中,我们已经接触过图结构和状态管理。现在要进一步回答一个更基础的问题:\n", + "\n", + "**一张图到底是怎么拼出来的?**\n", + "\n", + "答案就是:\n", + "\n", + "- 用 **节点(Node)** 表示每一步做什么\n", + "- 用 **边(Edge)** 表示下一步往哪里走\n", + "\n", + "也就是说:\n", + "\n", + "- 节点负责‘干活’\n", + "- 边负责‘指路’\n", + "\n", + "你可以把它想象成一张旅游路线图:\n", + "\n", + "- 景点 = 节点\n", + "- 景点之间的道路 = 边\n", + "- 你游览的先后顺序 = 图的执行顺序\n", + "\n", + "如果没有节点,图里就没有真正执行的步骤;如果没有边,这些步骤之间就连不起来。" + ] + }, + { + "cell_type": "markdown", + "id": "e7e11691", + "metadata": {}, + "source": [ + "## 2. 节点是什么\n", + "\n", + "在 LangGraph 中,节点通常就是一个 Python 函数。\n", + "\n", + "这个函数会:\n", + "\n", + "1. 接收当前状态 `state`\n", + "2. 处理数据\n", + "3. 返回要更新的状态字段\n", + "\n", + "例如一个节点可以做这些事:\n", + "\n", + "- 给数字加 1\n", + "- 拼接一段字符串\n", + "- 计算总价\n", + "- 调用大模型\n", + "- 调用工具\n", + "\n", + "所以,节点本质上就是流程中的‘处理步骤’。\n", + "\n", + "一个图里可以有一个节点,也可以有很多节点。节点越多,流程通常越细。" + ] + }, + { + "cell_type": "markdown", + "id": "9599df9d", + "metadata": {}, + "source": [ + "## 3. 边是什么\n", + "\n", + "边表示节点和节点之间的连接关系。\n", + "\n", + "如果说节点回答的是:\n", + "\n", + "> 这一步做什么?\n", + "\n", + "那么边回答的就是:\n", + "\n", + "> 做完这一步以后,下一步去哪?\n", + "\n", + "例如:\n", + "\n", + "```\n", + "START -> node_a -> node_b -> END\n", + "```\n", + "\n", + "这里就有三条边:\n", + "\n", + "- `START -> node_a`\n", + "- `node_a -> node_b`\n", + "- `node_b -> END`\n", + "\n", + "所以,边并不负责处理数据,它只负责规定执行顺序。" + ] + }, + { + "cell_type": "markdown", + "id": "707d8841", + "metadata": {}, + "source": [ + "## 4. `START` 和 `END` 是什么\n", + "\n", + "在 LangGraph 中,我们通常会看到两个特殊标记:\n", + "\n", + "- `START`:图的起点\n", + "- `END`:图的终点\n", + "\n", + "它们不是普通业务节点,而是系统提供的特殊位置。\n", + "\n", + "你可以把它们理解成:\n", + "\n", + "- `START` = 流程从哪里进入\n", + "- `END` = 流程在哪里结束\n", + "\n", + "注意:\n", + "\n", + "- `START` 不负责处理数据\n", + "- `END` 也不负责处理数据\n", + "- 它们的作用只是帮助我们把流程的起点和终点标出来\n", + "\n", + "因此,很多图都长这样:\n", + "\n", + "```\n", + "START -> 第一个节点 -> 第二个节点 -> END\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "1ddf8a66", + "metadata": {}, + "source": [ + "## 5. 第一个例子:一个节点的最小图\n", + "\n", + "先从最简单的图开始,只放一个节点。\n", + "\n", + "流程如下:\n", + "\n", + "```\n", + "START -> greet -> END\n", + "```\n", + "\n", + "也就是说:\n", + "\n", + "1. 流程从 `START` 进入\n", + "2. 执行 `greet` 节点\n", + "3. 执行完后直接结束" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d23c2d17", + "metadata": {}, + "outputs": [], + "source": [ + "from typing_extensions import TypedDict\n", + "from langgraph.graph import StateGraph, START, END\n", + "\n", + "class HelloState(TypedDict):\n", + " name: str\n", + " message: str\n", + "\n", + "def greet(state: HelloState):\n", + " return {'message': f'你好,{state[\"name\"]}!'}\n", + "\n", + "builder = StateGraph(HelloState)\n", + "builder.add_node('greet', greet)\n", + "builder.add_edge(START, 'greet')\n", + "builder.add_edge('greet', END)\n", + "\n", + "graph = builder.compile()\n", + "result = graph.invoke({'name': '小明', 'message': ''})\n", + "print(result)" + ] + }, + { + "cell_type": "markdown", + "id": "93b3f4bc", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这个例子非常短,但已经完整包含了节点与边的核心写法。\n", + "\n", + "#### `class HelloState(TypedDict)`\n", + "这里定义了图中的状态结构,包含两个字段:\n", + "\n", + "- `name`:输入的人名\n", + "- `message`:节点生成的问候语\n", + "\n", + "#### `def greet(state)`\n", + "这就是一个节点函数。它读取 `name`,然后生成一句问候语,写入 `message`。\n", + "\n", + "也就是说,这个节点真正做的事情是:\n", + "\n", + "```text\n", + "读取名字 -> 生成问候 -> 写回状态\n", + "```\n", + "\n", + "#### `builder.add_node('greet', greet)`\n", + "这是把节点加入图中的关键代码。\n", + "\n", + "它的意思是:\n", + "\n", + "- 节点名字叫 `greet`\n", + "- 节点实际执行的函数是 `greet`\n", + "\n", + "你可以把前面的字符串 `'greet'` 理解为节点标签,把后面的函数 `greet` 理解为节点的工作内容。\n", + "\n", + "#### `builder.add_edge(START, 'greet')`\n", + "表示流程从起点进入 `greet` 节点。\n", + "\n", + "#### `builder.add_edge('greet', END)`\n", + "表示执行完 `greet` 节点之后,流程直接结束。\n", + "\n", + "#### 整体流程\n", + "整个图的执行路径就是:\n", + "\n", + "```\n", + "START -> greet -> END\n", + "```\n", + "\n", + "这是最小的完整图,也是理解节点与边的最好起点。" + ] + }, + { + "cell_type": "markdown", + "id": "926caf0b", + "metadata": {}, + "source": [ + "## 6. 两个节点的顺序图\n", + "\n", + "一个节点的图很简单,但很多实际流程至少会有两个步骤。\n", + "\n", + "下面我们做一个两步流程:\n", + "\n", + "1. 先生成问候语\n", + "2. 再给问候语后面补一句欢迎信息\n", + "\n", + "流程如下:\n", + "\n", + "```\n", + "START -> make_greeting -> add_welcome -> END\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0a529a83", + "metadata": {}, + "outputs": [], + "source": [ + "from typing_extensions import TypedDict\n", + "from langgraph.graph import StateGraph, START, END\n", + "\n", + "class WelcomeState(TypedDict):\n", + " name: str\n", + " greeting: str\n", + " final_text: str\n", + "\n", + "def make_greeting(state: WelcomeState):\n", + " return {'greeting': f'你好,{state[\"name\"]}!'}\n", + "\n", + "def add_welcome(state: WelcomeState):\n", + " return {'final_text': state['greeting'] + ' 欢迎学习 LangGraph。'}\n", + "\n", + "builder = StateGraph(WelcomeState)\n", + "builder.add_node('make_greeting', make_greeting)\n", + "builder.add_node('add_welcome', add_welcome)\n", + "\n", + "builder.add_edge(START, 'make_greeting')\n", + "builder.add_edge('make_greeting', 'add_welcome')\n", + "builder.add_edge('add_welcome', END)\n", + "\n", + "graph = builder.compile()\n", + "result = graph.invoke({\n", + " 'name': '小红',\n", + " 'greeting': '',\n", + " 'final_text': ''\n", + "})\n", + "\n", + "print(result)\n", + "print(result['final_text'])" + ] + }, + { + "cell_type": "markdown", + "id": "a2dd0d07", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这个例子比前一个例子多了一个节点,因此也多了一条边。\n", + "\n", + "#### 两个节点分别做什么\n", + "- `make_greeting`:生成问候语\n", + "- `add_welcome`:在问候语后面追加欢迎信息\n", + "\n", + "这两个节点就像工厂里的两个工位:\n", + "\n", + "- 第一个工位先加工出半成品\n", + "- 第二个工位接着加工成完整成品\n", + "\n", + "#### 为什么这里要有三条边\n", + "因为整个流程要从头走到尾,所以必须把三段路径都写出来:\n", + "\n", + "1. `START -> make_greeting`\n", + "2. `make_greeting -> add_welcome`\n", + "3. `add_welcome -> END`\n", + "\n", + "少写任何一条,图的路径都会不完整。\n", + "\n", + "#### `add_edge('make_greeting', 'add_welcome')` 的意义\n", + "这行代码非常关键,它表达的是:\n", + "\n", + "> 第一个节点做完以后,不是结束,而是继续把结果交给第二个节点。\n", + "\n", + "所以我们可以说:\n", + "\n", + "- 节点负责定义每一步做什么\n", + "- 边负责把这些步骤串成一条完整路线\n", + "\n", + "#### 整个执行过程\n", + "执行时,状态会这样流动:\n", + "\n", + "1. 初始状态进入 `make_greeting`\n", + "2. 生成 `greeting='你好,小红!'`\n", + "3. 再进入 `add_welcome`\n", + "4. 生成 `final_text='你好,小红! 欢迎学习 LangGraph。'`\n", + "5. 最后进入 `END`\n", + "\n", + "这就是最典型的多节点顺序图。" + ] + }, + { + "cell_type": "markdown", + "id": "41a9b843", + "metadata": {}, + "source": [ + "## 7. 三个节点的顺序图\n", + "\n", + "为了进一步理解边的作用,我们再看一个三个节点的例子。\n", + "\n", + "流程目标:\n", + "\n", + "1. 输入一个数字\n", + "2. 先加 2\n", + "3. 再乘 3\n", + "4. 最后减 1\n", + "\n", + "流程图如下:\n", + "\n", + "```\n", + "START -> add_two -> multiply_three -> minus_one -> END\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a95e97f2", + "metadata": {}, + "outputs": [], + "source": [ + "from typing_extensions import TypedDict\n", + "from langgraph.graph import StateGraph, START, END\n", + "\n", + "class NumberState(TypedDict):\n", + " value: int\n", + "\n", + "def add_two(state: NumberState):\n", + " return {'value': state['value'] + 2}\n", + "\n", + "def multiply_three(state: NumberState):\n", + " return {'value': state['value'] * 3}\n", + "\n", + "def minus_one(state: NumberState):\n", + " return {'value': state['value'] - 1}\n", + "\n", + "builder = StateGraph(NumberState)\n", + "builder.add_node('add_two', add_two)\n", + "builder.add_node('multiply_three', multiply_three)\n", + "builder.add_node('minus_one', minus_one)\n", + "\n", + "builder.add_edge(START, 'add_two')\n", + "builder.add_edge('add_two', 'multiply_three')\n", + "builder.add_edge('multiply_three', 'minus_one')\n", + "builder.add_edge('minus_one', END)\n", + "\n", + "graph = builder.compile()\n", + "result = graph.invoke({'value': 4})\n", + "print(result)" + ] + }, + { + "cell_type": "markdown", + "id": "e095e193", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这个例子的重点是:**节点一多,边就必须更清楚地把顺序连接起来。**\n", + "\n", + "#### 三个节点的分工\n", + "- `add_two`:把数字加 2\n", + "- `multiply_three`:把结果乘 3\n", + "- `minus_one`:把结果减 1\n", + "\n", + "每个节点都只做一件非常明确的事情。\n", + "\n", + "#### 四条边的作用\n", + "这里总共有四条边:\n", + "\n", + "- `START -> add_two`\n", + "- `add_two -> multiply_three`\n", + "- `multiply_three -> minus_one`\n", + "- `minus_one -> END`\n", + "\n", + "从这个例子你能很明显地看出:\n", + "\n", + "- 节点数量增加,边的数量通常也会跟着增加\n", + "- 边并不是可有可无的,它是图的骨架\n", + "\n", + "#### 为什么顺序会影响结果\n", + "如果初始值是 `4`,按当前顺序执行:\n", + "\n", + "1. `4 + 2 = 6`\n", + "2. `6 * 3 = 18`\n", + "3. `18 - 1 = 17`\n", + "\n", + "最终结果是:\n", + "\n", + "```python\n", + "{'value': 17}\n", + "```\n", + "\n", + "如果你把边的顺序改掉,例如先乘再加,结果就会完全不同。\n", + "\n", + "这说明一件事:\n", + "\n", + "**边不仅是在连线,它其实是在定义业务流程的顺序逻辑。**" + ] + }, + { + "cell_type": "markdown", + "id": "f8b86cad", + "metadata": {}, + "source": [ + "## 8. 常见错误:只加节点,不连边\n", + "\n", + "初学者最常见的错误之一,就是把节点都加进去了,但忘了把它们连起来。\n", + "\n", + "例如你可能写了:\n", + "\n", + "- `add_node('a', node_a)`\n", + "- `add_node('b', node_b)`\n", + "- `add_node('c', node_c)`\n", + "\n", + "但如果没有边,图并不知道该怎么走。\n", + "\n", + "所以一定要记住:\n", + "\n", + "- 节点 = 把工位摆出来\n", + "- 边 = 把工位之间的传送带接起来\n", + "\n", + "只有节点没有边,就像车间里摆了很多机器,但它们之间没有流水线,产品根本流不过去。" + ] + }, + { + "cell_type": "markdown", + "id": "2653a1ac", + "metadata": {}, + "source": [ + "## 9. 常见错误:边的顺序写错\n", + "\n", + "另一个常见错误,是边都写了,但顺序写错了。\n", + "\n", + "比如本来你想要:\n", + "\n", + "```\n", + "START -> add_two -> multiply_three -> minus_one -> END\n", + "```\n", + "\n", + "结果却写成:\n", + "\n", + "```\n", + "START -> multiply_three -> add_two -> minus_one -> END\n", + "```\n", + "\n", + "这样虽然图也能运行,但业务逻辑已经变了。\n", + "\n", + "所以在写边的时候,不要只关心‘能不能连上’,更要关心‘是不是按我想要的顺序连上’。" + ] + }, + { + "cell_type": "markdown", + "id": "c519d51c", + "metadata": {}, + "source": [ + "## 10. 节点与边的分工总结\n", + "\n", + "到这里,你可以把节点与边的关系总结成一句话:\n", + "\n", + "**节点定义处理步骤,边定义执行路径。**\n", + "\n", + "更具体一点:\n", + "\n", + "| 组件 | 作用 | 你可以怎么理解 |\n", + "| --- | --- | --- |\n", + "| Node(节点) | 做具体工作 | 流程中的工位 |\n", + "| Edge(边) | 决定下一步去哪 | 工位之间的路线 |\n", + "| START | 流程入口 | 从哪里开始 |\n", + "| END | 流程出口 | 在哪里结束 |\n", + "\n", + "只要把这四个角色分清楚,写简单的图就不会乱。" + ] + }, + { + "cell_type": "markdown", + "id": "31760dde", + "metadata": {}, + "source": [ + "## 11. 本节小结\n", + "\n", + "本节最重要的内容有五点:\n", + "\n", + "1. **节点就是处理步骤,通常由函数表示**\n", + "2. **边就是连接关系,负责决定执行顺序**\n", + "3. **`START` 是起点,`END` 是终点**\n", + "4. **节点越多,越要靠边把流程顺序明确写出来**\n", + "5. **图能不能正确运行,不仅取决于节点有没有写对,也取决于边有没有连对**\n", + "\n", + "理解了节点与边,后面再学条件边、分支流程、循环流程时,你会更容易看懂整张图是怎么运转的。" + ] + }, + { + "cell_type": "markdown", + "id": "2de9c0d2", + "metadata": {}, + "source": [ + "## 12. 本节练习\n", + "\n", + "1. 修改第一个示例,把 `greet` 节点输出改成‘欢迎你,某某同学!’\n", + "2. 在第二个示例中再增加一个节点 `add_goodbye`,让最后再追加一句‘祝你学习顺利!’\n", + "3. 修改第三个示例的边顺序,观察最终计算结果如何变化\n", + "4. 尝试自己画出三个示例的流程图\n", + "5. 思考:如果一个图有 5 个节点,它至少需要几条边才能形成一条完整顺序路径?" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.4" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/18_条件边.ipynb b/18_条件边.ipynb new file mode 100644 index 0000000..6e49329 --- /dev/null +++ b/18_条件边.ipynb @@ -0,0 +1,594 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 18 条件边\n", + "\n", + "## 学习目标\n", + "1. 理解条件边(Conditional Edges)的作用和使用场景\n", + "2. 掌握 `add_conditional_edges` 实现分支流程\n", + "3. 能够根据状态值动态决定图的执行路径\n", + "4. 理解路由函数在图中的作用\n", + "5. 能够读懂并修改一个带分支的图流程" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. 为什么需要条件边\n", + "\n", + "前一节我们学习了普通边。普通边的特点是:**下一步固定不变**。\n", + "\n", + "例如:\n", + "\n", + "```\n", + "START -> node_a -> node_b -> END\n", + "```\n", + "\n", + "这条流程里,不管输入是什么,`node_a` 做完以后都会进入 `node_b`。\n", + "\n", + "但真实业务中,流程往往不是固定直走的,而是会根据情况分支。比如:\n", + "\n", + "- 分数及格,就进入‘通过’节点\n", + "- 分数不及格,就进入‘补考’节点\n", + "- 用户问题完整,就直接回答\n", + "- 用户问题不完整,就先追问\n", + "- 检索结果足够,就结束\n", + "- 检索结果不够,就继续查\n", + "\n", + "这类‘根据当前状态决定下一步去哪’的场景,就要用到**条件边**。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. 条件边和普通边的区别\n", + "\n", + "可以把两种边对比着理解:\n", + "\n", + "| 类型 | 决定方式 | 典型场景 |\n", + "| --- | --- | --- |\n", + "| 普通边 `add_edge` | 固定写死 | 顺序执行流程 |\n", + "| 条件边 `add_conditional_edges` | 根据状态动态判断 | 分支、回路、路由控制 |\n", + "\n", + "最核心的区别只有一句话:\n", + "\n", + "- 普通边:下一步是提前写死的\n", + "- 条件边:下一步是运行时判断出来的\n", + "\n", + "也就是说,条件边让图开始‘有判断力’。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. 条件边的基本思路\n", + "\n", + "使用条件边时,通常有三样东西要配合起来:\n", + "\n", + "1. **一个普通节点**:先处理当前状态\n", + "2. **一个路由函数**:根据状态判断下一步去哪\n", + "3. **一个分支映射表**:告诉图不同返回值分别对应哪个节点\n", + "\n", + "写法通常像这样:\n", + "\n", + "```python\n", + "builder.add_conditional_edges(\n", + " '某个节点',\n", + " route_function,\n", + " {\n", + " '路径A': 'node_a',\n", + " '路径B': 'node_b'\n", + " }\n", + ")\n", + "```\n", + "\n", + "它的意思是:\n", + "\n", + "- 先执行‘某个节点’\n", + "- 执行完以后,不直接写死下一步\n", + "- 而是调用 `route_function(state)` 看返回什么\n", + "- 如果返回 `'路径A'`,就走到 `node_a`\n", + "- 如果返回 `'路径B'`,就走到 `node_b`" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. 第一个例子:根据分数决定是否通过\n", + "\n", + "先从最直观的例子开始。\n", + "\n", + "流程目标:\n", + "\n", + "- 如果分数大于等于 60,进入通过节点\n", + "- 如果分数小于 60,进入补考节点\n", + "\n", + "流程图如下:\n", + "\n", + "```\n", + "START -> check_score -> pass_node / fail_node -> END\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from typing_extensions import TypedDict\n", + "from langgraph.graph import StateGraph, START, END\n", + "\n", + "class ScoreState(TypedDict):\n", + " score: int\n", + " result: str\n", + "\n", + "def check_score(state: ScoreState):\n", + " print(f'当前分数:{state[\"score\"]}')\n", + " return {}\n", + "\n", + "def pass_node(state: ScoreState):\n", + " return {'result': '成绩合格,顺利通过'}\n", + "\n", + "def fail_node(state: ScoreState):\n", + " return {'result': '成绩不合格,需要补考'}\n", + "\n", + "def route_by_score(state: ScoreState):\n", + " if state['score'] >= 60:\n", + " return 'pass'\n", + " return 'fail'\n", + "\n", + "builder = StateGraph(ScoreState)\n", + "builder.add_node('check_score', check_score)\n", + "builder.add_node('pass_node', pass_node)\n", + "builder.add_node('fail_node', fail_node)\n", + "\n", + "builder.add_edge(START, 'check_score')\n", + "builder.add_conditional_edges(\n", + " 'check_score',\n", + " route_by_score,\n", + " {\n", + " 'pass': 'pass_node',\n", + " 'fail': 'fail_node'\n", + " }\n", + ")\n", + "builder.add_edge('pass_node', END)\n", + "builder.add_edge('fail_node', END)\n", + "\n", + "graph = builder.compile()\n", + "\n", + "print('分数 85:')\n", + "print(graph.invoke({'score': 85, 'result': ''}))\n", + "\n", + "print('\\n分数 45:')\n", + "print(graph.invoke({'score': 45, 'result': ''}))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这个例子非常典型,几乎把条件边最核心的要点都展示出来了。\n", + "\n", + "#### 状态结构\n", + "状态里有两个字段:\n", + "\n", + "- `score`:输入分数\n", + "- `result`:最终判断结果\n", + "\n", + "#### `check_score` 节点\n", + "这个节点本身没有修改状态,只是打印当前分数。\n", + "\n", + "它返回 `{}`,表示‘这个节点不更新任何字段’。\n", + "\n", + "这说明一个节点不一定非要负责生成结果,它也可以只是一个‘分支前的准备步骤’。\n", + "\n", + "#### `pass_node` 和 `fail_node`\n", + "这两个节点分别代表分支后的两条路径:\n", + "\n", + "- 通过路径:写入‘成绩合格,顺利通过’\n", + "- 失败路径:写入‘成绩不合格,需要补考’\n", + "\n", + "#### `route_by_score(state)`\n", + "这是本例的路由函数。它的任务不是修改状态,而是‘判断下一步去哪’。\n", + "\n", + "判断逻辑很简单:\n", + "\n", + "- 如果 `score >= 60`,返回 `'pass'`\n", + "- 否则返回 `'fail'`\n", + "\n", + "注意:这里返回的不是节点函数本身,而是一个**分支标记**。\n", + "\n", + "#### `add_conditional_edges(...)`\n", + "这一段可以拆成三层意思:\n", + "\n", + "1. 从 `check_score` 节点出来以后,不直接写死下一步\n", + "2. 调用 `route_by_score(state)` 得到一个返回值\n", + "3. 根据映射表决定真正跳去哪个节点\n", + "\n", + "也就是说:\n", + "\n", + "- 返回 `'pass'` -> 去 `pass_node`\n", + "- 返回 `'fail'` -> 去 `fail_node`\n", + "\n", + "#### 执行路径是怎么变化的\n", + "当输入 `score=85` 时:\n", + "\n", + "```\n", + "START -> check_score -> pass_node -> END\n", + "```\n", + "\n", + "当输入 `score=45` 时:\n", + "\n", + "```\n", + "START -> check_score -> fail_node -> END\n", + "```\n", + "\n", + "这就是条件边最基础的价值:**同一张图,面对不同状态时,可以自动走不同路线。**" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. 第二个例子:问题完整就直接回答,不完整就先追问\n", + "\n", + "下面我们做一个更贴近 AI 助手的例子。\n", + "\n", + "假设用户提问时,有的问题已经很清楚,有的问题信息还不够。\n", + "\n", + "流程目标:\n", + "\n", + "- 如果问题完整,直接进入回答节点\n", + "- 如果问题不完整,先进入追问节点\n", + "\n", + "这个例子可以帮助你理解:条件边不仅能处理分数、数字,也能处理业务判断。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from typing_extensions import TypedDict\n", + "from langgraph.graph import StateGraph, START, END\n", + "\n", + "class QuestionState(TypedDict):\n", + " user_question: str\n", + " is_complete: bool\n", + " response: str\n", + "\n", + "def analyze_question(state: QuestionState):\n", + " print(f'收到问题:{state[\"user_question\"]}')\n", + " return {}\n", + "\n", + "def ask_more(state: QuestionState):\n", + " return {'response': '你的问题还不够完整,请补充更多背景信息。'}\n", + "\n", + "def answer_directly(state: QuestionState):\n", + " return {'response': '问题信息已经足够,我现在直接为你回答。'}\n", + "\n", + "def route_question(state: QuestionState):\n", + " if state['is_complete']:\n", + " return 'answer'\n", + " return 'ask_more'\n", + "\n", + "builder = StateGraph(QuestionState)\n", + "builder.add_node('analyze_question', analyze_question)\n", + "builder.add_node('ask_more', ask_more)\n", + "builder.add_node('answer_directly', answer_directly)\n", + "\n", + "builder.add_edge(START, 'analyze_question')\n", + "builder.add_conditional_edges(\n", + " 'analyze_question',\n", + " route_question,\n", + " {\n", + " 'answer': 'answer_directly',\n", + " 'ask_more': 'ask_more'\n", + " }\n", + ")\n", + "builder.add_edge('ask_more', END)\n", + "builder.add_edge('answer_directly', END)\n", + "\n", + "graph = builder.compile()\n", + "\n", + "print('完整问题:')\n", + "print(graph.invoke({\n", + " 'user_question': '请解释什么是条件边,并举一个例子',\n", + " 'is_complete': True,\n", + " 'response': ''\n", + "}))\n", + "\n", + "print('\\n不完整问题:')\n", + "print(graph.invoke({\n", + " 'user_question': '帮我看看这个',\n", + " 'is_complete': False,\n", + " 'response': ''\n", + "}))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这个例子和前面的‘分数判断’相比,更接近真实 AI 产品中的流程控制。\n", + "\n", + "#### 状态字段的意义\n", + "这里的状态有三个字段:\n", + "\n", + "- `user_question`:用户输入的问题\n", + "- `is_complete`:问题是否完整\n", + "- `response`:最终回复\n", + "\n", + "#### `analyze_question` 节点\n", + "这个节点先接收问题并打印出来,相当于‘进入分析阶段’。\n", + "\n", + "它没有直接修改状态,而是把路由判断交给后面的路由函数。\n", + "\n", + "#### `route_question(state)`\n", + "这个函数根据 `is_complete` 的真假做判断:\n", + "\n", + "- `True` -> 返回 `'answer'`\n", + "- `False` -> 返回 `'ask_more'`\n", + "\n", + "这意味着流程下一步是动态的,而不是提前写死的。\n", + "\n", + "#### 两条分支的业务含义\n", + "- `answer_directly`:说明信息足够,可以直接处理\n", + "- `ask_more`:说明信息不足,需要先追问\n", + "\n", + "#### 为什么这个例子很重要\n", + "它让你看到:条件边并不只是‘程序里的 if 判断’,更像是业务流程里的‘路由器’。\n", + "\n", + "很多 Agent 系统都会有类似逻辑:\n", + "\n", + "- 信息够不够?\n", + "- 要不要调用工具?\n", + "- 要不要继续搜索?\n", + "- 能不能直接输出答案?\n", + "\n", + "而这些判断,往往都可以通过条件边表达出来。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. 第三个例子:条件边也可以用于继续或结束\n", + "\n", + "条件边不仅可以做左右分支,还经常用来做‘继续/停止’的判断。\n", + "\n", + "这类场景非常常见,例如:\n", + "\n", + "- 检索结果不够,就继续检索\n", + "- 输出格式不对,就继续生成\n", + "- 数量还没到要求,就继续累加\n", + "\n", + "下面用一个简单例子说明:如果 `count < 3`,就继续执行;否则结束。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from typing_extensions import TypedDict\n", + "from langgraph.graph import StateGraph, START, END\n", + "\n", + "class LoopState(TypedDict):\n", + " count: int\n", + "\n", + "def add_one(state: LoopState):\n", + " new_count = state['count'] + 1\n", + " print(f'当前 count 从 {state[\"count\"]} 变成 {new_count}')\n", + " return {'count': new_count}\n", + "\n", + "def route_loop(state: LoopState):\n", + " if state['count'] < 3:\n", + " return 'continue'\n", + " return 'stop'\n", + "\n", + "builder = StateGraph(LoopState)\n", + "builder.add_node('add_one', add_one)\n", + "\n", + "builder.add_edge(START, 'add_one')\n", + "builder.add_conditional_edges(\n", + " 'add_one',\n", + " route_loop,\n", + " {\n", + " 'continue': 'add_one',\n", + " 'stop': END\n", + " }\n", + ")\n", + "\n", + "graph = builder.compile()\n", + "result = graph.invoke({'count': 0})\n", + "print('最终结果:', result)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这个例子非常重要,因为它说明:**条件边不仅能分叉,也能回跳。**\n", + "\n", + "#### `add_one` 节点\n", + "这个节点每次把 `count` 加 1。\n", + "\n", + "也就是说,它是一个不断重复执行的工作步骤。\n", + "\n", + "#### `route_loop(state)`\n", + "这个路由函数负责判断接下来是继续,还是停止:\n", + "\n", + "- 如果 `count < 3`,返回 `'continue'`\n", + "- 否则返回 `'stop'`\n", + "\n", + "#### 条件边映射\n", + "这里映射关系很关键:\n", + "\n", + "- `'continue'` -> `'add_one'`\n", + "- `'stop'` -> `END`\n", + "\n", + "也就是说:\n", + "\n", + "- 还没满足条件,就重新回到原节点\n", + "- 满足条件后,就离开流程\n", + "\n", + "#### 执行过程\n", + "如果初始值是 `count=0`,路径会是:\n", + "\n", + "1. `0 -> 1`,继续\n", + "2. `1 -> 2`,继续\n", + "3. `2 -> 3`,停止\n", + "4. 进入 `END`\n", + "\n", + "从这个例子你可以看到,条件边不只是“二选一分支”,它还是循环流程的基础。\n", + "\n", + "后面很多 Agent 的‘反复尝试直到成功’流程,本质上都是这种写法。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7. 路由函数应该怎么理解\n", + "\n", + "很多初学者最容易混淆的地方,就是把‘节点函数’和‘路由函数’混在一起。\n", + "\n", + "你可以这样区分:\n", + "\n", + "| 类型 | 主要职责 | 是否负责业务处理 |\n", + "| --- | --- | --- |\n", + "| 节点函数 | 读写状态、执行处理逻辑 | 是 |\n", + "| 路由函数 | 判断下一步走哪条路 | 通常不是重点 |\n", + "\n", + "换句话说:\n", + "\n", + "- 节点函数更像‘工人’\n", + "- 路由函数更像‘调度员’\n", + "\n", + "工人负责真正干活,调度员负责决定下一站去哪。\n", + "\n", + "理解这一点以后,条件边的结构就会非常清晰。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 8. 使用条件边时的常见错误\n", + "\n", + "### 8.1 路由函数返回值和映射表对不上\n", + "例如路由函数返回 `'pass'`,但映射表里只有 `'ok'` 和 `'fail'`。\n", + "\n", + "这样图就不知道该往哪里走。\n", + "\n", + "### 8.2 把节点名字和路由标记混为一谈\n", + "路由函数返回的可以是一个标记,例如 `'continue'`、`'stop'`、`'pass'`。\n", + "\n", + "这些标记不一定要和节点名完全相同,但必须和映射表中的 key 对应。\n", + "\n", + "### 8.3 以为条件边只能做两条分支\n", + "其实不止两条。\n", + "\n", + "你完全可以写三条、四条甚至更多路线,只要业务场景需要。\n", + "\n", + "### 8.4 忘记给分支节点连到 `END` 或后续节点\n", + "条件边只负责从当前节点分出去,不代表后面的路径已经自动补全。\n", + "\n", + "分支出去以后,该怎么结束、怎么汇合,仍然要继续写边。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 9. 条件边在 Agent 场景中的意义\n", + "\n", + "到了这里,你可以把条件边理解成 LangGraph 里的‘流程决策器’。\n", + "\n", + "在 Agent 场景中,它特别常见,因为 Agent 天生就不是固定直线流程。\n", + "\n", + "例如:\n", + "\n", + "- 如果用户问题明确,直接回答\n", + "- 如果用户问题不明确,先追问\n", + "- 如果需要外部信息,调用工具\n", + "- 如果工具结果不够,再查一次\n", + "- 如果结果足够,就进入最终回答\n", + "\n", + "你会发现,这些逻辑本质上都属于:\n", + "\n", + "**根据当前状态,动态决定下一步去哪。**\n", + "\n", + "这正是条件边最核心的价值。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 10. 本节小结\n", + "\n", + "本节最重要的内容有五点:\n", + "\n", + "1. **条件边用于动态决定流程路径**\n", + "2. **`add_conditional_edges` 是实现分支和回路的关键方法**\n", + "3. **路由函数负责返回分支标记,映射表负责把标记映射到节点**\n", + "4. **条件边不仅能做左右分支,也能做继续/停止这样的循环控制**\n", + "5. **很多 Agent 工作流,本质上都是条件边在控制路径**\n", + "\n", + "理解了条件边,你就真正进入了‘动态流程控制’这一层。后面的复杂图,大多都离不开它。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 11. 本节练习\n", + "\n", + "1. 修改第一个示例,把及格线从 60 改成 80,观察分支结果变化\n", + "2. 修改第二个示例,再增加一种情况:如果问题为空,就返回一个专门的提示节点\n", + "3. 修改第三个示例,把停止条件从 `count < 3` 改成 `count < 5`\n", + "4. 思考:如果一个流程有 3 条不同分支,路由函数和映射表应该怎么设计?\n", + "5. 思考:条件边和普通边最大的本质区别是什么?" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.4" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/19_循环与记忆.ipynb b/19_循环与记忆.ipynb new file mode 100644 index 0000000..8cb773a --- /dev/null +++ b/19_循环与记忆.ipynb @@ -0,0 +1,831 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 19 循环与记忆\n", + "\n", + "## 学习目标\n", + "1. 理解LangGraph中循环和持久化记忆的实现方式\n", + "2. 掌握MemorySaver的使用,实现对话状态持久化\n", + "3. 能够构建支持多轮交互的智能体流程" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. 为什么需要循环和记忆\n", + "\n", + "在之前的课程中,我们构建的图流程都是**单次执行**的:\n", + "\n", + "```\n", + "START -> node_a -> node_b -> END\n", + "```\n", + "\n", + "这种流程跑一次就结束了。但真实场景中,很多需求需要**多次循环**和**记忆状态**:\n", + "\n", + "- **聊天机器人**:需要记住之前说过的话,才能进行多轮对话\n", + "- **任务分解**:可能需要反复调用工具,直到任务完成\n", + "- **信息收集**:需要逐步收集用户的信息,直到完整\n", + "- **多用户系统**:每个用户的对话历史需要独立保存\n", + "\n", + "简单来说:\n", + "\n", + "- **循环**:让流程可以重复执行某些节点\n", + "- **记忆**:让状态可以在多次执行之间保持" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. 循环的实现方式\n", + "\n", + "在LangGraph中,循环主要通过**条件边**来实现。关键思路是:\n", + "\n", + "1. 执行某个节点\n", + "2. 判断是否需要继续循环\n", + "3. 如果需要,回到之前的节点重新执行\n", + "4. 如果不需要,走向结束\n", + "\n", + "流程图如下:\n", + "\n", + "```\n", + "START -> work_node -> 判断节点\n", + " |\n", + " ┌─────────┴─────────┐\n", + " ▼ ▼\n", + " 继续循环 结束\n", + " | |\n", + " └───────► work_node ◄──┘\n", + "```\n", + "\n", + "用条件边实现就是:`判断节点` 根据状态返回 `'loop'` 或 `'end'`,然后:\n", + "\n", + "- `'loop'` → 回到 `work_node`\n", + "- `'end'` → 走到 `END`" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. 第一个例子:简单的计数器循环\n", + "\n", + "我们用一个计数器来演示循环的基本实现。\n", + "\n", + "流程目标:\n", + "- 从0开始计数\n", + "- 每次加1\n", + "- 当计数达到5时停止\n", + "- 每次循环都打印当前计数值" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from typing_extensions import TypedDict\n", + "from langgraph.graph import StateGraph, START, END\n", + "\n", + "class CounterState(TypedDict):\n", + " count: int\n", + "\n", + "def increment_node(state: CounterState):\n", + " new_count = state['count'] + 1\n", + " print(f'当前计数:{new_count}')\n", + " return {'count': new_count}\n", + "\n", + "def should_continue(state: CounterState):\n", + " if state['count'] < 5:\n", + " return 'loop'\n", + " return 'end'\n", + "\n", + "builder = StateGraph(CounterState)\n", + "builder.add_node('increment', increment_node)\n", + "\n", + "builder.add_edge(START, 'increment')\n", + "builder.add_conditional_edges(\n", + " 'increment',\n", + " should_continue,\n", + " {\n", + " 'loop': 'increment',\n", + " 'end': END\n", + " }\n", + ")\n", + "\n", + "graph = builder.compile()\n", + "result = graph.invoke({'count': 0})\n", + "print(result)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "1. **State 定义**:`CounterState` 只包含一个字段 `count`,用于存储当前计数值。\n", + "\n", + "2. **increment_node**:每次执行时,从状态中取出 `count`,加1后放回状态。同时打印当前计数值。\n", + "\n", + "3. **should_continue**:路由函数,判断是否继续循环。如果 `count < 5` 返回 `'loop'`,否则返回 `'end'`。\n", + "\n", + "4. **条件边设置**:\n", + " - 来源节点是 `'increment'`\n", + " - 路由函数是 `should_continue`\n", + " - 如果返回 `'loop'`,就回到 `'increment'` 节点(形成循环)\n", + " - 如果返回 `'end'`,就走到 `END`\n", + "\n", + "5. **执行**:初始状态是 `{'count': 0}`,图会自动循环执行,直到 `count` 达到5。\n", + "\n", + "运行结果显示计数从1到5,说明循环成功执行了5次。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. 状态累积:消息历史的保存\n", + "\n", + "循环的核心价值在于**状态累积**。最常见的场景是保存对话历史。\n", + "\n", + "在聊天机器人中,每次用户输入和AI回复都需要保存下来,这样AI才能理解上下文。\n", + "\n", + "我们来构建一个简单的对话系统,每次循环都会把消息追加到历史中。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from typing_extensions import TypedDict\n", + "from langgraph.graph import StateGraph, START, END\n", + "\n", + "class ChatState(TypedDict):\n", + " messages: list\n", + " current_user_input: str\n", + "\n", + "def process_message(state: ChatState):\n", + " user_input = state['current_user_input']\n", + " \n", + " responses = {\n", + " '你好': '你好!我是一个AI助手。',\n", + " '我叫张三': '你好张三!很高兴认识你。',\n", + " '我今天心情很好': '太好了!祝你有美好的一天!'\n", + " }\n", + " \n", + " assistant_reply = responses.get(user_input, '抱歉,我不太理解。')\n", + "\n", + " new_message = {'user': user_input, 'assistant': assistant_reply}\n", + " \n", + " return {\n", + " 'messages': state['messages'] + [new_message],\n", + " 'current_user_input': ''\n", + " }\n", + "\n", + "def route(state: ChatState):\n", + " if state['current_user_input']:\n", + " return 'process'\n", + " return END\n", + "\n", + "builder = StateGraph(ChatState)\n", + "builder.add_node('process', process_message)\n", + "\n", + "builder.add_edge(START, 'process')\n", + "builder.add_conditional_edges(\n", + " 'process',\n", + " route,\n", + " {\n", + " 'process': 'process',\n", + " END: END\n", + " }\n", + ")\n", + "\n", + "graph = builder.compile()\n", + "\n", + "state = {'messages': [], 'current_user_input': '你好'}\n", + "state = graph.invoke(state)\n", + "\n", + "state['current_user_input'] = '我叫张三'\n", + "state = graph.invoke(state)\n", + "\n", + "state['current_user_input'] = '我今天心情很好'\n", + "state = graph.invoke(state)\n", + "\n", + "print(state['messages'])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "1. **State 定义**:`ChatState` 包含两个字段:\n", + " - `messages`:保存所有对话历史的列表\n", + " - `current_user_input`:当前用户输入\n", + "\n", + "2. **process_message**:处理消息的核心逻辑:\n", + " - 从状态中获取当前用户输入\n", + " - 根据预设的规则生成助手回复\n", + " - 将新消息追加到 `messages` 列表中(关键!)\n", + " - 清空 `current_user_input`\n", + "\n", + "3. **route**:路由函数判断是否有新输入需要处理。\n", + "\n", + "4. **执行流程**:\n", + " - 第一次调用:输入'你好',消息列表变成 `[{'user': '你好', ...}]`\n", + " - 第二次调用:输入'我叫张三',消息列表变成两条记录\n", + " - 第三次调用:输入'我今天心情很好',消息列表变成三条记录\n", + "\n", + "运行结果显示消息历史被正确累积,这就是状态持久化的基础。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. MemorySaver:持久化状态存储\n", + "\n", + "上面的例子中,我们手动传递状态。但在真实应用中,状态需要:\n", + "\n", + "- 在多次请求之间保持\n", + "- 支持多个用户同时使用(会话隔离)\n", + "- 重启后不丢失\n", + "\n", + "LangGraph 提供了 `MemorySaver` 来解决这个问题。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from typing_extensions import TypedDict\n", + "from langgraph.graph import StateGraph, START, END\n", + "from langgraph.checkpoint.memory import MemorySaver\n", + "\n", + "class ChatState(TypedDict):\n", + " messages: list\n", + " current_user_input: str\n", + "\n", + "def process_message(state: ChatState):\n", + " user_input = state['current_user_input']\n", + " \n", + " responses = {\n", + " '你好': '你好!我是一个AI助手。',\n", + " '我叫张三': '你好张三!很高兴认识你。',\n", + " '我今天心情很好': '太好了!祝你有美好的一天!'\n", + " }\n", + " \n", + " assistant_reply = responses.get(user_input, '抱歉,我不太理解。')\n", + "\n", + " new_message = {'user': user_input, 'assistant': assistant_reply}\n", + " \n", + " return {\n", + " 'messages': state['messages'] + [new_message],\n", + " 'current_user_input': ''\n", + " }\n", + "\n", + "def route(state: ChatState):\n", + " if state['current_user_input']:\n", + " return 'process'\n", + " return END\n", + "\n", + "builder = StateGraph(ChatState)\n", + "builder.add_node('process', process_message)\n", + "\n", + "builder.add_edge(START, 'process')\n", + "builder.add_conditional_edges(\n", + " 'process',\n", + " route,\n", + " {\n", + " 'process': 'process',\n", + " END: END\n", + " }\n", + ")\n", + "\n", + "memory = MemorySaver()\n", + "graph = builder.compile(checkpointer=memory)\n", + "\n", + "config = {'configurable': {'thread_id': 'session_1'}}\n", + "\n", + "graph.invoke({'messages': [], 'current_user_input': '你好'}, config)\n", + "graph.invoke({'current_user_input': '我叫张三'}, config)\n", + "graph.invoke({'current_user_input': '我今天心情很好'}, config)\n", + "\n", + "final_state = graph.get_state(config)\n", + "print(final_state.values['messages'])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "1. **导入 MemorySaver**:从 `langgraph.checkpoint.memory` 导入。\n", + "\n", + "2. **创建 MemorySaver 实例**:`memory = MemorySaver()`\n", + "\n", + "3. **编译时传入 checkpointer**:`graph = builder.compile(checkpointer=memory)`\n", + "\n", + "4. **配置会话ID**:\n", + " ```python\n", + " config = {'configurable': {'thread_id': 'session_1'}}\n", + " ```\n", + " `thread_id` 是会话的唯一标识,不同用户用不同的 `thread_id`。\n", + "\n", + "5. **多次调用**:每次调用只传入新的输入,不需要手动传递状态。\n", + "\n", + "6. **获取状态**:`graph.get_state(config)` 可以获取指定会话的当前状态。\n", + "\n", + "关键区别:之前需要手动传递 `state`,现在只需传入 `config`,MemorySaver 会自动保存和恢复状态。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. 多会话隔离\n", + "\n", + "MemorySaver 的一个重要特性是**会话隔离**。不同的 `thread_id` 会维护独立的状态。\n", + "\n", + "下面的例子演示两个用户同时使用同一个图,但各自的对话历史完全独立。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from typing_extensions import TypedDict\n", + "from langgraph.graph import StateGraph, START, END\n", + "from langgraph.checkpoint.memory import MemorySaver\n", + "\n", + "class ChatState(TypedDict):\n", + " messages: list\n", + " current_user_input: str\n", + "\n", + "def process_message(state: ChatState):\n", + " user_input = state['current_user_input']\n", + " \n", + " responses = {\n", + " '你好': '你好!我是一个AI助手。',\n", + " '我是Alice': '你好Alice!很高兴认识你。',\n", + " '我是Bob': '你好Bob!很高兴认识你。'\n", + " }\n", + " \n", + " assistant_reply = responses.get(user_input, '抱歉,我不太理解。')\n", + "\n", + " new_message = {'user': user_input, 'assistant': assistant_reply}\n", + " \n", + " return {\n", + " 'messages': state['messages'] + [new_message],\n", + " 'current_user_input': ''\n", + " }\n", + "\n", + "def route(state: ChatState):\n", + " if state['current_user_input']:\n", + " return 'process'\n", + " return END\n", + "\n", + "builder = StateGraph(ChatState)\n", + "builder.add_node('process', process_message)\n", + "\n", + "builder.add_edge(START, 'process')\n", + "builder.add_conditional_edges(\n", + " 'process',\n", + " route,\n", + " {\n", + " 'process': 'process',\n", + " END: END\n", + " }\n", + ")\n", + "\n", + "memory = MemorySaver()\n", + "graph = builder.compile(checkpointer=memory)\n", + "\n", + "config_a = {'configurable': {'thread_id': 'user_a'}}\n", + "config_b = {'configurable': {'thread_id': 'user_b'}}\n", + "\n", + "graph.invoke({'messages': [], 'current_user_input': '你好'}, config_a)\n", + "graph.invoke({'current_user_input': '我是Alice'}, config_a)\n", + "\n", + "graph.invoke({'messages': [], 'current_user_input': 'Hi'}, config_b)\n", + "graph.invoke({'current_user_input': '我是Bob'}, config_b)\n", + "\n", + "state_a = graph.get_state(config_a)\n", + "state_b = graph.get_state(config_b)\n", + "\n", + "print('用户A的对话历史:')\n", + "print(state_a.values['messages'])\n", + "print()\n", + "print('用户B的对话历史:')\n", + "print(state_b.values['messages'])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "1. **创建两个配置**:\n", + " - `config_a` 使用 `thread_id: 'user_a'`\n", + " - `config_b` 使用 `thread_id: 'user_b'`\n", + "\n", + "2. **分别调用**:\n", + " - 用户A发送了'你好'和'我是Alice'\n", + " - 用户B发送了'Hi'和'我是Bob'\n", + "\n", + "3. **获取各自状态**:\n", + " - 用户A的消息历史只有自己的两条消息\n", + " - 用户B的消息历史只有自己的两条消息\n", + "\n", + "运行结果清楚地展示了会话隔离:两个用户的对话历史完全独立,互不干扰。\n", + "\n", + "这就是多用户聊天系统的核心原理。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7. 使用标准消息类型管理对话\n", + "\n", + "在实际应用中,我们通常使用 LangChain 的标准消息类型(如 `HumanMessage`、`AIMessage`)来管理对话。\n", + "\n", + "为了让 MemorySaver 能够自动追加消息而不是覆盖,我们需要使用 `add_messages` reducer。\n", + "\n", + "这种方式的特点:\n", + "- 使用标准的消息类型,便于与LangChain组件集成\n", + "- 自动追加消息,避免手动拼接\n", + "- 方便与 LLM 集成" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from typing_extensions import TypedDict\n", + "from typing import Annotated\n", + "from langgraph.graph import StateGraph, START, END\n", + "from langgraph.graph.message import add_messages\n", + "from langgraph.checkpoint.memory import MemorySaver\n", + "from langchain_core.messages import HumanMessage, AIMessage\n", + "\n", + "class ChatState(TypedDict):\n", + " messages: Annotated[list, add_messages]\n", + "\n", + "def process_message(state: ChatState):\n", + " last_message = state['messages'][-1]\n", + " \n", + " if isinstance(last_message, HumanMessage):\n", + " user_input = last_message.content\n", + " \n", + " responses = {\n", + " '你好': '你好!我是一个AI助手。',\n", + " '我叫张三': '你好张三!很高兴认识你。',\n", + " }\n", + " \n", + " assistant_reply = responses.get(user_input, '抱歉,我不太理解。')\n", + " \n", + " return {'messages': [AIMessage(content=assistant_reply)]}\n", + " \n", + " return state\n", + "\n", + "def route(state: ChatState):\n", + " last_message = state['messages'][-1]\n", + " if isinstance(last_message, HumanMessage):\n", + " return 'process'\n", + " return END\n", + "\n", + "builder = StateGraph(ChatState)\n", + "builder.add_node('process', process_message)\n", + "\n", + "builder.add_edge(START, 'process')\n", + "builder.add_conditional_edges(\n", + " 'process',\n", + " route,\n", + " {\n", + " 'process': 'process',\n", + " END: END\n", + " }\n", + ")\n", + "\n", + "memory = MemorySaver()\n", + "graph = builder.compile(checkpointer=memory)\n", + "\n", + "config = {'configurable': {'thread_id': 'session_with_messages'}}\n", + "\n", + "graph.invoke({'messages': [HumanMessage(content='你好')]}, config)\n", + "graph.invoke({'messages': [HumanMessage(content='我叫张三')]}, config)\n", + "graph.invoke({'messages': [HumanMessage(content='今天天气怎么样')]}, config)\n", + "\n", + "final_state = graph.get_state(config)\n", + "for msg in final_state.values['messages']:\n", + " if isinstance(msg, HumanMessage):\n", + " print(f'Human: {msg.content}')\n", + " elif isinstance(msg, AIMessage):\n", + " print(f'AI: {msg.content}')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "1. **导入 add_messages reducer**:`from langgraph.graph.message import add_messages`\n", + " - reducer 用于控制状态字段的更新方式\n", + " - `add_messages` 会自动将新消息追加到列表中,而不是覆盖\n", + "\n", + "2. **定义 ChatState**:使用 `Annotated[list, add_messages]` 定义消息字段\n", + " - `Annotated` 是 Python 的类型提示工具,用于添加元数据\n", + " - 这里告诉 LangGraph 对 `messages` 字段使用 `add_messages` reducer\n", + "\n", + "3. **导入消息类型**:\n", + " - `HumanMessage`:用户消息\n", + " - `AIMessage`:AI回复\n", + "\n", + "4. **process_message**:\n", + " - 获取最后一条消息\n", + " - 判断是否是用户消息\n", + " - 如果是,生成回复并返回 `{'messages': [AIMessage(...)]}`\n", + " - 由于使用了 `add_messages`,返回的新消息会自动追加到历史中\n", + "\n", + "5. **route**:\n", + " - 如果最后一条是用户消息,继续处理\n", + " - 如果是AI消息,说明已经处理过了,结束\n", + "\n", + "6. **调用方式**:每次传入 `{'messages': [HumanMessage(content=...)]}`\n", + " - MemorySaver 会自动保存状态\n", + " - `add_messages` reducer 会自动追加新消息\n", + "\n", + "这种方式的优点:\n", + "- 使用标准的消息类型,便于与LangChain组件集成\n", + "- 自动追加消息,避免手动拼接\n", + "- 后续可以直接将消息列表传给LLM" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 8. 综合案例:带工具调用的循环智能体\n", + "\n", + "现在我们把循环、记忆和工具调用结合起来,构建一个完整的智能体。\n", + "\n", + "流程目标:\n", + "- 用户询问关于天气或时间的问题\n", + "- 智能体判断是否需要调用工具\n", + "- 如果需要,调用工具获取信息\n", + "- 如果不需要,直接回答\n", + "- 循环直到任务完成\n", + "- 使用MemorySaver保存对话历史" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from typing_extensions import TypedDict\n", + "from langgraph.graph import StateGraph, START, END\n", + "from langgraph.checkpoint.memory import MemorySaver\n", + "from langchain_core.messages import HumanMessage, AIMessage, ToolMessage\n", + "\n", + "class AgentState(TypedDict):\n", + " messages: list\n", + " next_tool: str\n", + "\n", + "def decide_tool(state: AgentState):\n", + " last_message = state['messages'][-1]\n", + " user_input = last_message.content\n", + " \n", + " print(f'用户问了:{user_input}')\n", + " \n", + " if '几点' in user_input or '时间' in user_input:\n", + " print('需要调用工具:get_time')\n", + " return {'next_tool': 'get_time'}\n", + " elif '天气' in user_input:\n", + " print('需要调用工具:get_weather')\n", + " return {'next_tool': 'get_weather'}\n", + " else:\n", + " print('不需要调用工具,直接回答')\n", + " return {'next_tool': None}\n", + "\n", + "def call_tool(state: AgentState):\n", + " tool_name = state['next_tool']\n", + " \n", + " if tool_name == 'get_time':\n", + " result = '2024-01-15 14:30:00'\n", + " elif tool_name == 'get_weather':\n", + " result = '晴天,25度'\n", + " else:\n", + " result = '未知工具'\n", + " \n", + " print(f'调用工具 {tool_name},结果:{result}')\n", + " \n", + " return {\n", + " 'messages': state['messages'] + [ToolMessage(content=result, tool_call_id='1')],\n", + " 'next_tool': None\n", + " }\n", + "\n", + "def summarize(state: AgentState):\n", + " last_message = state['messages'][-1]\n", + " \n", + " if isinstance(last_message, ToolMessage):\n", + " tool_result = last_message.content\n", + " reply = f'总结回答:{tool_result}'\n", + " else:\n", + " reply = '抱歉,我不太理解你的问题。'\n", + " \n", + " print(reply)\n", + " \n", + " return {\n", + " 'messages': state['messages'] + [AIMessage(content=reply)]\n", + " }\n", + "\n", + "def route_after_decide(state: AgentState):\n", + " if state['next_tool']:\n", + " return 'call_tool'\n", + " return 'summarize'\n", + "\n", + "def route_after_summarize(state: AgentState):\n", + " last_message = state['messages'][-1]\n", + " if isinstance(last_message, HumanMessage):\n", + " return 'decide_tool'\n", + " return END\n", + "\n", + "builder = StateGraph(AgentState)\n", + "builder.add_node('decide_tool', decide_tool)\n", + "builder.add_node('call_tool', call_tool)\n", + "builder.add_node('summarize', summarize)\n", + "\n", + "builder.add_edge(START, 'decide_tool')\n", + "builder.add_conditional_edges(\n", + " 'decide_tool',\n", + " route_after_decide,\n", + " {\n", + " 'call_tool': 'call_tool',\n", + " 'summarize': 'summarize'\n", + " }\n", + ")\n", + "builder.add_edge('call_tool', 'summarize')\n", + "builder.add_conditional_edges(\n", + " 'summarize',\n", + " route_after_summarize,\n", + " {\n", + " 'decide_tool': 'decide_tool',\n", + " END: END\n", + " }\n", + ")\n", + "\n", + "memory = MemorySaver()\n", + "graph = builder.compile(checkpointer=memory)\n", + "\n", + "config = {'configurable': {'thread_id': 'tool_agent_session'}}\n", + "\n", + "graph.invoke({'messages': [HumanMessage(content='现在几点了')], 'next_tool': None}, config)\n", + "\n", + "current_state = graph.get_state(config)\n", + "new_messages = current_state.values['messages'] + [HumanMessage(content='今天天气怎么样')]\n", + "graph.invoke({'messages': new_messages, 'next_tool': None}, config)\n", + "\n", + "final_state = graph.get_state(config)\n", + "print()\n", + "print('完整对话历史:')\n", + "for msg in final_state.values['messages']:\n", + " if isinstance(msg, HumanMessage):\n", + " print(f'Human: {msg.content}')\n", + " elif isinstance(msg, AIMessage):\n", + " print(f'AI: {msg.content}')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这个案例展示了一个完整的智能体流程:\n", + "\n", + "1. **AgentState**:使用 TypedDict 定义状态,包含:\n", + " - `messages`:消息列表\n", + " - `next_tool`:下一个要调用的工具\n", + "\n", + "2. **decide_tool**:判断是否需要调用工具\n", + " - 如果用户问时间,返回 `{'next_tool': 'get_time'}`\n", + " - 如果用户问天气,返回 `{'next_tool': 'get_weather'}`\n", + " - 如果不需要工具,返回 `{'next_tool': None}`\n", + "\n", + "3. **call_tool**:执行工具调用\n", + " - 根据 `next_tool` 调用相应工具\n", + " - 将结果追加到 `messages` 列表\n", + "\n", + "4. **summarize**:总结回答\n", + " - 如果最后是工具消息,提取结果并生成回答\n", + " - 将回答追加到 `messages` 列表\n", + "\n", + "5. **route_after_decide**:decide_tool 之后的路由\n", + " - 如果 `next_tool` 有值,进入 `call_tool`\n", + " - 如果 `next_tool` 为 None,进入 `summarize`\n", + "\n", + "6. **route_after_summarize**:summarize 之后的路由\n", + " - 如果最后是用户消息,回到 `decide_tool`\n", + " - 否则结束\n", + "\n", + "流程图:\n", + "\n", + "```\n", + "START -> decide_tool -> [条件边] -> call_tool -> summarize\n", + " ^ |\n", + " | |\n", + " +--------------+\n", + "```\n", + "\n", + "这个流程可以处理多轮对话,每轮都可以调用工具并保存历史。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 9. 总结\n", + "\n", + "### 核心知识点\n", + "\n", + "1. **循环实现**:通过条件边让流程回到之前的节点,形成循环\n", + "2. **状态累积**:在节点中不断追加数据到状态字段(如消息列表)\n", + "3. **MemorySaver**:自动保存和恢复状态,无需手动传递\n", + "4. **会话隔离**:通过 `thread_id` 区分不同用户的会话\n", + "5. **add_messages reducer**:使用 `Annotated[list, add_messages]` 实现消息自动追加\n", + "\n", + "### 关键API\n", + "\n", + "| API | 作用 |\n", + "| --- | --- |\n", + "| `MemorySaver()` | 创建内存状态存储 |\n", + "| `builder.compile(checkpointer=memory)` | 编译时启用状态持久化 |\n", + "| `config = {'configurable': {'thread_id': ...}}` | 设置会话ID |\n", + "| `graph.get_state(config)` | 获取指定会话的状态 |\n", + "| `add_messages` | 自动追加消息的 reducer |\n", + "\n", + "### 实践要点\n", + "\n", + "- 循环的关键是路由函数返回某个已存在的节点名\n", + "- 状态累积需要在节点中返回新的状态值\n", + "- 使用 `Annotated[list, add_messages]` 可以让 MemorySaver 自动追加消息\n", + "- MemorySaver 适合开发和测试,生产环境可使用其他存储后端\n", + "- 会话隔离通过 `thread_id` 实现,必须为每个用户分配唯一ID" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 练习\n", + "\n", + "1. 修改计数器示例,让它从10递减到0\n", + "2. 扩展对话示例,添加更多回复规则\n", + "3. 创建一个多轮问答系统,支持用户追问\n", + "4. 尝试使用不同的 `thread_id` 模拟三个用户同时对话" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.4" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/20_多智能体架构.ipynb b/20_多智能体架构.ipynb new file mode 100644 index 0000000..07f4ddf --- /dev/null +++ b/20_多智能体架构.ipynb @@ -0,0 +1,966 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 20 多智能体架构\n", + "\n", + "## 学习目标\n", + "1. 理解多智能体系统的典型架构(如监督者、协作、竞争)\n", + "2. 掌握使用LangGraph设计多节点多智能体工作流\n", + "3. 能够实现多个智能体节点之间的消息传递与协作" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. 为什么需要多智能体\n", + "\n", + "在之前的课程中,我们构建的智能体都是**单智能体**:一个智能体完成所有任务。\n", + "\n", + "但真实场景中,很多任务需要**多个智能体协作**才能完成:\n", + "\n", + "- **复杂任务分解**:一个任务太大,需要分给多个专业智能体\n", + "- **专业分工**:不同智能体有不同的专业知识(如代码专家、写作专家、数据分析专家)\n", + "- **协作决策**:需要多个智能体共同讨论才能做出最佳决策\n", + "- **竞争博弈**:多个智能体竞争完成同一任务\n", + "\n", + "简单来说:\n", + "\n", + "- **单智能体**:一个人做所有事\n", + "- **多智能体**:一个团队分工合作" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. 多智能体的典型架构模式\n", + "\n", + "多智能体系统有几种经典的架构模式:\n", + "\n", + "### 2.1 监督者模式(Supervisor Pattern)\n", + "\n", + "一个监督者智能体负责协调多个工作智能体:\n", + "\n", + "```\n", + " 监督者\n", + " │\n", + " ┌──────┼──────┬──────┐\n", + " ▼ ▼ ▼ ▼\n", + " 智能体A 智能体B 智能体C 智能体D\n", + "```\n", + "\n", + "**适用场景**:任务需要多种专业能力,监督者分配任务并汇总结果。\n", + "\n", + "### 2.2 协作模式(Collaborative Pattern)\n", + "\n", + "多个智能体平等协作,共同完成任务:\n", + "\n", + "```\n", + " 智能体A ←→ 智能体B\n", + " ↑ ↑\n", + " │ │\n", + " 智能体C ←→ 智能体D\n", + "```\n", + "\n", + "**适用场景**:任务需要多个智能体之间频繁交流和协同。\n", + "\n", + "### 2.3 竞争模式(Competitive Pattern)\n", + "\n", + "多个智能体竞争完成同一任务,胜者获得奖励:\n", + "\n", + "```\n", + " 智能体A ─┐\n", + " ├──→ 比较器 → 最佳结果\n", + " 智能体B ─┘\n", + "```\n", + "\n", + "**适用场景**:需要从多个方案中选择最优解。\n", + "\n", + "### 2.4 流水线模式(Pipeline Pattern)\n", + "\n", + "多个智能体按顺序处理任务,像流水线一样:\n", + "\n", + "```\n", + " 智能体A → 智能体B → 智能体C → 智能体D\n", + "```\n", + "\n", + "**适用场景**:任务可以分解为多个顺序步骤。\n", + "\n", + "### 2.5 辩论模式(Debate Pattern)\n", + "\n", + "多个智能体就某个问题展开辩论,最终达成共识:\n", + "\n", + "```\n", + " 正方智能体 ←→ 反方智能体\n", + " │ │\n", + " └────┬──────┘\n", + " ▼\n", + " 裁判智能体\n", + "```\n", + "\n", + "**适用场景**:需要深入分析某个问题的正反两面。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. 第一个例子:简单的协作智能体\n", + "\n", + "我们用一个简单的例子来演示多智能体协作。\n", + "\n", + "场景:\n", + "- 用户提出一个问题\n", + "- 研究智能体负责收集信息\n", + "- 写作智能体负责整理成报告\n", + "- 监督者智能体负责协调整个流程" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from typing_extensions import TypedDict\n", + "from langgraph.graph import StateGraph, START, END\n", + "from openai import OpenAI\n", + "import os\n", + "from dotenv import load_dotenv\n", + "\n", + "load_dotenv()\n", + "\n", + "client = OpenAI(\n", + " base_url=os.getenv(\"OPENAI_BASE_URL\"),\n", + " api_key=os.getenv(\"OPENAI_API_KEY\")\n", + ")\n", + "\n", + "def call_llm(system_prompt: str, user_prompt: str) -> str:\n", + " response = client.chat.completions.create(\n", + " model=\"qwen3.6-35b-A3b\",\n", + " messages=[\n", + " {\"role\": \"system\", \"content\": system_prompt},\n", + " {\"role\": \"user\", \"content\": user_prompt}\n", + " ],\n", + " temperature=0.7,\n", + " max_tokens=500\n", + " )\n", + " return response.choices[0].message.content\n", + "\n", + "class AgentState(TypedDict):\n", + " user_question: str\n", + " research_info: str\n", + " report: str\n", + " current_agent: str\n", + "\n", + "def supervisor(state: AgentState):\n", + " print(f'监督者:收到用户问题:{state[\"user_question\"]}')\n", + " if not state['research_info']:\n", + " print('监督者:分配给研究智能体')\n", + " return {'current_agent': 'research'}\n", + " elif not state['report']:\n", + " print('监督者:分配给写作智能体')\n", + " return {'current_agent': 'writer'}\n", + " else:\n", + " print('监督者:任务完成')\n", + " return {'current_agent': 'finish'}\n", + "\n", + "def research_agent(state: AgentState):\n", + " print(f'研究智能体:正在研究 \"{state[\"user_question\"]}\"')\n", + " system_prompt = '你是一个专业的研究助手,请针对用户问题提供详细的研究信息和分析。'\n", + " user_prompt = f'请研究并总结关于\"{state[\"user_question\"]}\"的最新信息,包括主要趋势、关键技术和未来展望。'\n", + " research_info = call_llm(system_prompt, user_prompt)\n", + " print(f'研究智能体:完成研究,收集到信息')\n", + " return {'research_info': research_info}\n", + "\n", + "def writer_agent(state: AgentState):\n", + " print(f'写作智能体:正在撰写报告')\n", + " system_prompt = '你是一个专业的报告撰写者,请根据提供的研究信息撰写结构化的报告。'\n", + " user_prompt = f'请根据以下研究信息撰写一份完整的报告:\\n\\n研究信息:{state[\"research_info\"]}'\n", + " report = call_llm(system_prompt, user_prompt)\n", + " print(f'写作智能体:报告完成')\n", + " return {'report': report}\n", + "\n", + "def route(state: AgentState):\n", + " if state['current_agent'] == 'research':\n", + " return 'research'\n", + " elif state['current_agent'] == 'writer':\n", + " return 'writer'\n", + " elif state['current_agent'] == 'finish':\n", + " return END\n", + " return 'supervisor'\n", + "\n", + "builder = StateGraph(AgentState)\n", + "builder.add_node('supervisor', supervisor)\n", + "builder.add_node('research', research_agent)\n", + "builder.add_node('writer', writer_agent)\n", + "\n", + "builder.add_edge(START, 'supervisor')\n", + "builder.add_edge('research', 'supervisor')\n", + "builder.add_edge('writer', 'supervisor')\n", + "builder.add_conditional_edges(\n", + " 'supervisor',\n", + " route,\n", + " {\n", + " 'research': 'research',\n", + " 'writer': 'writer',\n", + " END: END\n", + " }\n", + ")\n", + "\n", + "graph = builder.compile()\n", + "\n", + "result = graph.invoke({\n", + " 'user_question': '人工智能的发展趋势',\n", + " 'research_info': '',\n", + " 'report': '',\n", + " 'current_agent': ''\n", + "})\n", + "\n", + "print()\n", + "print('最终报告:')\n", + "print(result['report'])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "1. **环境配置**:\n", + " - 加载 `.env` 文件中的 API 密钥\n", + " - 创建 OpenAI 客户端\n", + " - 定义 `call_llm` 函数,封装大模型调用逻辑\n", + "\n", + "2. **AgentState**:定义状态包含四个字段:\n", + " - `user_question`:用户的问题\n", + " - `research_info`:研究智能体收集的信息\n", + " - `report`:写作智能体生成的报告\n", + " - `current_agent`:当前应该执行的智能体\n", + "\n", + "3. **supervisor**:监督者智能体\n", + " - 检查当前状态,决定下一步分配给哪个智能体\n", + " - 如果没有研究信息,分配给研究智能体\n", + " - 如果有研究信息但没有报告,分配给写作智能体\n", + " - 如果报告已完成,标记任务结束\n", + "\n", + "4. **research_agent**:研究智能体(使用真实大模型)\n", + " - 构建系统提示词和用户提示词\n", + " - 调用大模型获取研究结果\n", + " - 将结果保存到 `research_info`\n", + "\n", + "5. **writer_agent**:写作智能体(使用真实大模型)\n", + " - 构建系统提示词和用户提示词\n", + " - 调用大模型根据研究信息生成报告\n", + " - 将结果保存到 `report`\n", + "\n", + "6. **route**:路由函数\n", + " - 根据 `current_agent` 字段决定下一步执行哪个节点\n", + "\n", + "7. **图结构**:\n", + " - START -> supervisor\n", + " - supervisor 根据条件边分配给 research 或 writer\n", + " - research 和 writer 执行完后都回到 supervisor\n", + " - supervisor 判断完成后走到 END\n", + "\n", + "流程图:\n", + "\n", + "```\n", + "START -> supervisor\n", + " │\n", + " ┌──────┴──────┐\n", + " ▼ ▼\n", + "research writer\n", + " │ │\n", + " └──────┬──────┘\n", + " ▼\n", + " supervisor -> END\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. 流水线模式:多智能体顺序处理\n", + "\n", + "流水线模式是最直观的多智能体架构,智能体按顺序处理任务。\n", + "\n", + "场景:\n", + "- 用户输入一段原始文本\n", + "- 翻译智能体将其翻译成英文\n", + "- 摘要智能体生成摘要\n", + "- 情感分析智能体分析情感倾向" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from typing_extensions import TypedDict\n", + "from langgraph.graph import StateGraph, START, END\n", + "\n", + "class PipelineState(TypedDict):\n", + " original_text: str\n", + " translated_text: str\n", + " summary: str\n", + " sentiment: str\n", + "\n", + "def translate_agent(state: PipelineState):\n", + " print(f'翻译智能体:正在翻译')\n", + " text = state['original_text']\n", + " translated = f'[Translated] {text}'\n", + " print(f'翻译智能体:{translated}')\n", + " return {'translated_text': translated}\n", + "\n", + "def summarize_agent(state: PipelineState):\n", + " print(f'摘要智能体:正在生成摘要')\n", + " text = state['translated_text']\n", + " summary = f'[Summary] {text[:20]}...'\n", + " print(f'摘要智能体:{summary}')\n", + " return {'summary': summary}\n", + "\n", + "def sentiment_agent(state: PipelineState):\n", + " print(f'情感分析智能体:正在分析情感')\n", + " text = state['translated_text']\n", + " if '好' in text or '高兴' in text:\n", + " sentiment = '积极'\n", + " elif '坏' in text or '难过' in text:\n", + " sentiment = '消极'\n", + " else:\n", + " sentiment = '中性'\n", + " print(f'情感分析智能体:情感倾向为 {sentiment}')\n", + " return {'sentiment': sentiment}\n", + "\n", + "builder = StateGraph(PipelineState)\n", + "builder.add_node('translate', translate_agent)\n", + "builder.add_node('summarize', summarize_agent)\n", + "builder.add_node('sentiment', sentiment_agent)\n", + "\n", + "builder.add_edge(START, 'translate')\n", + "builder.add_edge('translate', 'summarize')\n", + "builder.add_edge('summarize', 'sentiment')\n", + "builder.add_edge('sentiment', END)\n", + "\n", + "graph = builder.compile()\n", + "\n", + "result = graph.invoke({\n", + " 'original_text': '今天天气很好,我很高兴。',\n", + " 'translated_text': '',\n", + " 'summary': '',\n", + " 'sentiment': ''\n", + "})\n", + "\n", + "print()\n", + "print('处理结果:')\n", + "print(f'原文:{result[\"original_text\"]}')\n", + "print(f'翻译:{result[\"translated_text\"]}')\n", + "print(f'摘要:{result[\"summary\"]}')\n", + "print(f'情感:{result[\"sentiment\"]}')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "1. **PipelineState**:定义流水线的状态,包含四个字段,分别对应每个智能体的输入/输出。\n", + "\n", + "2. **translate_agent**:翻译智能体\n", + " - 接收原始文本\n", + " - 输出翻译结果\n", + "\n", + "3. **summarize_agent**:摘要智能体\n", + " - 接收翻译后的文本\n", + " - 输出摘要\n", + "\n", + "4. **sentiment_agent**:情感分析智能体\n", + " - 接收翻译后的文本\n", + " - 分析情感倾向\n", + "\n", + "5. **图结构**:简单的顺序流程\n", + " - START -> translate -> summarize -> sentiment -> END\n", + "\n", + "流程图:\n", + "\n", + "```\n", + "START -> translate -> summarize -> sentiment -> END\n", + "```\n", + "\n", + "这种模式的优点:\n", + "- 结构简单,易于理解\n", + "- 每个智能体只关注自己的任务\n", + "- 便于扩展,可以随时添加新的智能体" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. 竞争模式:多个智能体竞争\n", + "\n", + "竞争模式中,多个智能体同时处理同一个任务,然后比较结果选择最优解。\n", + "\n", + "场景:\n", + "- 用户提出一个问题\n", + "- 多个回答智能体给出不同的答案\n", + "- 评估智能体选择最佳答案" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from typing_extensions import TypedDict\n", + "from langgraph.graph import StateGraph, START, END\n", + "\n", + "class CompetitionState(TypedDict):\n", + " question: str\n", + " answers: list\n", + " best_answer: str\n", + "\n", + "def answer_agent_a(state: CompetitionState):\n", + " print(f'回答智能体A:正在回答问题')\n", + " answer = f'A的回答:关于\"{state[\"question\"]}\",这是一个详细的回答。'\n", + " print(f'回答智能体A:{answer}')\n", + " return {'answers': state['answers'] + [{'agent': 'A', 'content': answer, 'score': 85}]}\n", + "\n", + "def answer_agent_b(state: CompetitionState):\n", + " print(f'回答智能体B:正在回答问题')\n", + " answer = f'B的回答:对于\"{state[\"question\"]}\",我的见解是...'\n", + " print(f'回答智能体B:{answer}')\n", + " return {'answers': state['answers'] + [{'agent': 'B', 'content': answer, 'score': 92}]}\n", + "\n", + "def answer_agent_c(state: CompetitionState):\n", + " print(f'回答智能体C:正在回答问题')\n", + " answer = f'C的回答:\"{state[\"question\"]}\"的答案如下...'\n", + " print(f'回答智能体C:{answer}')\n", + " return {'answers': state['answers'] + [{'agent': 'C', 'content': answer, 'score': 88}]}\n", + "\n", + "def evaluator_agent(state: CompetitionState):\n", + " print(f'评估智能体:正在评估所有回答')\n", + " if not state['answers']:\n", + " return {'best_answer': '没有回答'}\n", + "\n", + " best = max(state['answers'], key=lambda x: x['score'])\n", + " print(f'评估智能体:最佳回答来自智能体{best[\"agent\"]},得分{best[\"score\"]}')\n", + " return {'best_answer': best['content']}\n", + "\n", + "builder = StateGraph(CompetitionState)\n", + "builder.add_node('answer_a', answer_agent_a)\n", + "builder.add_node('answer_b', answer_agent_b)\n", + "builder.add_node('answer_c', answer_agent_c)\n", + "builder.add_node('evaluator', evaluator_agent)\n", + "\n", + "builder.add_edge(START, 'answer_a')\n", + "builder.add_edge('answer_a', 'answer_b')\n", + "builder.add_edge('answer_b', 'answer_c')\n", + "builder.add_edge('answer_c', 'evaluator')\n", + "builder.add_edge('evaluator', END)\n", + "\n", + "graph = builder.compile()\n", + "\n", + "result = graph.invoke({\n", + " 'question': '什么是人工智能?',\n", + " 'answers': [],\n", + " 'best_answer': ''\n", + "})\n", + "\n", + "print()\n", + "print('最佳回答:')\n", + "print(result['best_answer'])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "1. **CompetitionState**:定义竞争状态,包含:\n", + " - `question`:用户问题\n", + " - `answers`:所有智能体的回答列表\n", + " - `best_answer`:评估后的最佳回答\n", + "\n", + "2. **answer_agent_a/b/c**:三个回答智能体\n", + " - 每个智能体给出自己的回答\n", + " - 回答包含智能体标识、内容和得分\n", + " - 将回答追加到 `answers` 列表\n", + "\n", + "3. **evaluator_agent**:评估智能体\n", + " - 比较所有回答的得分\n", + " - 选择得分最高的回答作为最佳答案\n", + "\n", + "4. **图结构**:顺序执行三个回答智能体,然后评估\n", + " - START -> answer_a -> answer_b -> answer_c -> evaluator -> END\n", + "\n", + "流程图:\n", + "\n", + "```\n", + "START -> answer_a -> answer_b -> answer_c -> evaluator -> END\n", + "```\n", + "\n", + "这种模式的优点:\n", + "- 可以从多个角度解决问题\n", + "- 通过竞争提高回答质量\n", + "- 易于扩展,随时可以添加新的智能体" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. 辩论模式:智能体之间的辩论\n", + "\n", + "辩论模式中,多个智能体就某个问题展开辩论,最终达成共识或由裁判做出裁决。\n", + "\n", + "场景:\n", + "- 用户提出一个有争议的问题\n", + "- 正方智能体支持某个观点\n", + "- 反方智能体反对该观点\n", + "- 裁判智能体总结辩论结果" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from typing_extensions import TypedDict\n", + "from langgraph.graph import StateGraph, START, END\n", + "\n", + "class DebateState(TypedDict):\n", + " question: str\n", + " debate_history: list\n", + " round: int\n", + " conclusion: str\n", + "\n", + "def pro_agent(state: DebateState):\n", + " print(f'正方智能体(第{state[\"round\"]}轮):')\n", + " arguments = [\n", + " f'支持\"{state[\"question\"]}\"的理由:它可以提高效率。',\n", + " f'进一步论证:从长远来看,这是必然趋势。'\n", + " ]\n", + " arg = arguments[state['round'] - 1]\n", + " print(f' {arg}')\n", + " return {\n", + " 'debate_history': state['debate_history'] + [{'side': '正方', 'argument': arg}],\n", + " 'round': state['round']\n", + " }\n", + "\n", + "def con_agent(state: DebateState):\n", + " print(f'反方智能体(第{state[\"round\"]}轮):')\n", + " arguments = [\n", + " f'反对\"{state[\"question\"]}\"的理由:它可能带来风险。',\n", + " f'进一步反驳:我们需要更谨慎地对待。'\n", + " ]\n", + " arg = arguments[state['round'] - 1]\n", + " print(f' {arg}')\n", + " new_round = state['round'] + 1\n", + " return {\n", + " 'debate_history': state['debate_history'] + [{'side': '反方', 'argument': arg}],\n", + " 'round': new_round\n", + " }\n", + "\n", + "def judge_agent(state: DebateState):\n", + " print(f'裁判智能体:总结辩论')\n", + " conclusion = f'关于\"{state[\"question\"]}\"的辩论总结:\\n'\n", + " for entry in state['debate_history']:\n", + " conclusion += f'- {entry[\"side\"]}:{entry[\"argument\"]}\\n'\n", + " conclusion += '\\n结论:双方观点都有道理,需要权衡利弊。'\n", + " print(f'裁判智能体:{conclusion}')\n", + " return {'conclusion': conclusion}\n", + "\n", + "def after_con_route(state: DebateState):\n", + " if state['round'] <= 2:\n", + " return 'pro'\n", + " return 'judge'\n", + "\n", + "builder = StateGraph(DebateState)\n", + "builder.add_node('pro', pro_agent)\n", + "builder.add_node('con', con_agent)\n", + "builder.add_node('judge', judge_agent)\n", + "\n", + "builder.add_edge(START, 'pro')\n", + "builder.add_edge('pro', 'con')\n", + "builder.add_conditional_edges(\n", + " 'con',\n", + " after_con_route,\n", + " {\n", + " 'pro': 'pro',\n", + " 'judge': 'judge'\n", + " }\n", + ")\n", + "builder.add_edge('judge', END)\n", + "\n", + "graph = builder.compile()\n", + "\n", + "result = graph.invoke({\n", + " 'question': '人工智能是否应该取代人类工作?',\n", + " 'debate_history': [],\n", + " 'round': 1,\n", + " 'conclusion': ''\n", + "})\n", + "\n", + "print()\n", + "print('辩论总结:')\n", + "print(result['conclusion'])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "1. **DebateState**:定义辩论状态,包含:\n", + " - `question`:辩论的问题\n", + " - `debate_history`:辩论历史记录\n", + " - `round`:当前辩论轮次\n", + " - `conclusion`:最终结论\n", + "\n", + "2. **pro_agent**:正方智能体\n", + " - 根据当前轮次给出支持观点\n", + " - 将论点添加到辩论历史\n", + "\n", + "3. **con_agent**:反方智能体\n", + " - 根据当前轮次给出反对观点\n", + " - 将论点添加到辩论历史\n", + "\n", + "4. **judge_agent**:裁判智能体\n", + " - 总结所有辩论内容\n", + " - 给出最终结论\n", + "\n", + "5. **after_con_route**:反方发言后的路由\n", + " - 如果还没到第2轮,回到正方开始下一轮\n", + " - 如果已经第2轮结束,进入裁判\n", + "\n", + "6. **图结构**:\n", + " - START -> pro -> con\n", + " - con 根据轮次决定回到 pro 还是进入 judge\n", + " - judge -> END\n", + "\n", + "流程图:\n", + "\n", + "```\n", + "START -> pro -> con\n", + " │\n", + " ┌────────┴────────┐\n", + " ▼ ▼\n", + " pro → con judge -> END\n", + "```\n", + "\n", + "这种模式的优点:\n", + "- 可以全面分析问题的正反两面\n", + "- 通过辩论深入探讨复杂问题\n", + "- 最终结论更加客观全面" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7. 综合案例:多智能体协作完成复杂任务\n", + "\n", + "现在我们将多种模式结合起来,构建一个复杂的多智能体系统。\n", + "\n", + "场景:\n", + "- 用户提出一个数据分析需求\n", + "- 监督者智能体负责协调\n", + "- 数据收集智能体收集数据\n", + "- 数据处理智能体处理数据\n", + "- 数据分析智能体分析数据\n", + "- 报告生成智能体生成报告\n", + "- 使用MemorySaver保存状态" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from typing_extensions import TypedDict\n", + "from langgraph.graph import StateGraph, START, END\n", + "from langgraph.checkpoint.memory import MemorySaver\n", + "from openai import OpenAI\n", + "import os\n", + "from dotenv import load_dotenv\n", + "\n", + "load_dotenv()\n", + "\n", + "client = OpenAI(\n", + " base_url=os.getenv(\"OPENAI_BASE_URL\"),\n", + " api_key=os.getenv(\"OPENAI_API_KEY\")\n", + ")\n", + "\n", + "def call_llm(system_prompt: str, user_prompt: str) -> str:\n", + " response = client.chat.completions.create(\n", + " model=\"qwen3.6-35b-A3b\",\n", + " messages=[\n", + " {\"role\": \"system\", \"content\": system_prompt},\n", + " {\"role\": \"user\", \"content\": user_prompt}\n", + " ],\n", + " temperature=0.7,\n", + " max_tokens=500\n", + " )\n", + " return response.choices[0].message.content\n", + "\n", + "class DataAgentState(TypedDict):\n", + " user_request: str\n", + " raw_data: str\n", + " processed_data: str\n", + " analysis_result: str\n", + " report: str\n", + " next_agent: str\n", + "\n", + "def supervisor(state: DataAgentState):\n", + " print(f'监督者:收到请求:{state[\"user_request\"]}')\n", + " \n", + " if not state['raw_data']:\n", + " print('监督者:分配给数据收集智能体')\n", + " return {'next_agent': 'collect'}\n", + " elif not state['processed_data']:\n", + " print('监督者:分配给数据处理智能体')\n", + " return {'next_agent': 'process'}\n", + " elif not state['analysis_result']:\n", + " print('监督者:分配给数据分析智能体')\n", + " return {'next_agent': 'analyze'}\n", + " elif not state['report']:\n", + " print('监督者:分配给报告生成智能体')\n", + " return {'next_agent': 'report'}\n", + " else:\n", + " print('监督者:任务完成')\n", + " return {'next_agent': 'finish'}\n", + "\n", + "def collect_agent(state: DataAgentState):\n", + " print(f'数据收集智能体:正在收集数据')\n", + " raw_data = f'原始数据:关于\"{state[\"user_request\"]}\"的统计数据、趋势数据、对比数据。'\n", + " print(f'数据收集智能体:完成数据收集')\n", + " return {'raw_data': raw_data}\n", + "\n", + "def process_agent(state: DataAgentState):\n", + " print(f'数据处理智能体:正在处理数据')\n", + " processed_data = f'处理后数据:清洗、转换、标准化后的\"{state[\"user_request\"]}\"数据。'\n", + " print(f'数据处理智能体:完成数据处理')\n", + " return {'processed_data': processed_data}\n", + "\n", + "def analyze_agent(state: DataAgentState):\n", + " print(f'数据分析智能体:正在分析数据')\n", + " \n", + " system_prompt = '你是一个专业的数据分析师,请根据提供的数据进行深入分析。'\n", + " user_prompt = f'请分析以下数据,找出关键发现和趋势:\\n\\n原始数据:{state[\"raw_data\"]}\\n\\n处理后数据:{state[\"processed_data\"]}'\n", + " \n", + " analysis_result = call_llm(system_prompt, user_prompt)\n", + " \n", + " print(f'数据分析智能体:完成数据分析')\n", + " return {'analysis_result': analysis_result}\n", + "\n", + "def report_agent(state: DataAgentState):\n", + " print(f'报告生成智能体:正在生成报告')\n", + " \n", + " system_prompt = '你是一个专业的报告撰写者,请根据数据分析结果撰写结构化的报告。'\n", + " user_prompt = f'请根据以下信息撰写一份完整的数据分析报告:\\n\\n请求:{state[\"user_request\"]}\\n\\n分析结果:{state[\"analysis_result\"]}'\n", + " \n", + " report = call_llm(system_prompt, user_prompt)\n", + " print(f'报告生成智能体:完成报告生成')\n", + " return {'report': report}\n", + "\n", + "def route(state: DataAgentState):\n", + " if state['next_agent'] == 'collect':\n", + " return 'collect'\n", + " elif state['next_agent'] == 'process':\n", + " return 'process'\n", + " elif state['next_agent'] == 'analyze':\n", + " return 'analyze'\n", + " elif state['next_agent'] == 'report':\n", + " return 'report'\n", + " elif state['next_agent'] == 'finish':\n", + " return END\n", + " return 'supervisor'\n", + "\n", + "builder = StateGraph(DataAgentState)\n", + "builder.add_node('supervisor', supervisor)\n", + "builder.add_node('collect', collect_agent)\n", + "builder.add_node('process', process_agent)\n", + "builder.add_node('analyze', analyze_agent)\n", + "builder.add_node('report', report_agent)\n", + "\n", + "builder.add_edge(START, 'supervisor')\n", + "builder.add_edge('collect', 'supervisor')\n", + "builder.add_edge('process', 'supervisor')\n", + "builder.add_edge('analyze', 'supervisor')\n", + "builder.add_edge('report', 'supervisor')\n", + "builder.add_conditional_edges(\n", + " 'supervisor',\n", + " route,\n", + " {\n", + " 'collect': 'collect',\n", + " 'process': 'process',\n", + " 'analyze': 'analyze',\n", + " 'report': 'report',\n", + " END: END\n", + " }\n", + ")\n", + "\n", + "memory = MemorySaver()\n", + "graph = builder.compile(checkpointer=memory)\n", + "\n", + "config = {'configurable': {'thread_id': 'data_analysis_session'}}\n", + "\n", + "result = graph.invoke({\n", + " 'user_request': '分析过去一年的销售趋势',\n", + " 'raw_data': '',\n", + " 'processed_data': '',\n", + " 'analysis_result': '',\n", + " 'report': '',\n", + " 'next_agent': ''\n", + "}, config)\n", + "\n", + "print()\n", + "print('最终报告:')\n", + "print(result['report'])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这个案例展示了一个完整的多智能体协作系统:\n", + "\n", + "1. **环境配置**:\n", + " - 加载 `.env` 文件中的 API 密钥\n", + " - 创建 OpenAI 客户端\n", + " - 定义 `call_llm` 函数,封装大模型调用逻辑\n", + "\n", + "2. **DataAgentState**:定义状态,包含六个字段,分别对应每个阶段的数据。\n", + "\n", + "3. **supervisor**:监督者智能体\n", + " - 根据当前状态决定下一步分配给哪个智能体\n", + " - 按照数据收集→数据处理→数据分析→报告生成的顺序分配\n", + "\n", + "4. **collect_agent**:数据收集智能体\n", + " - 收集原始数据\n", + " - 保存到 `raw_data`\n", + "\n", + "5. **process_agent**:数据处理智能体\n", + " - 处理原始数据\n", + " - 保存到 `processed_data`\n", + "\n", + "6. **analyze_agent**:数据分析智能体(使用真实大模型)\n", + " - 构建系统提示词和用户提示词\n", + " - 调用大模型分析数据\n", + " - 保存到 `analysis_result`\n", + "\n", + "7. **report_agent**:报告生成智能体(使用真实大模型)\n", + " - 构建系统提示词和用户提示词\n", + " - 调用大模型生成报告\n", + " - 保存到 `report`\n", + "\n", + "8. **MemorySaver**:保存状态\n", + " - 使用 `thread_id` 隔离不同会话\n", + " - 支持多轮交互\n", + "\n", + "流程图:\n", + "\n", + "```\n", + "START -> supervisor\n", + " │\n", + " ┌──────┼──────┬──────┬──────┐\n", + " ▼ ▼ ▼ ▼ ▼\n", + " collect process analyze report finish\n", + " │ │ │ │\n", + " └──────┴──────┴──────┘\n", + " │\n", + " ▼\n", + " supervisor -> END\n", + "```\n", + "\n", + "这个系统结合了监督者模式和流水线模式,展示了多智能体协作的强大能力。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 8. 总结\n", + "\n", + "### 核心知识点\n", + "\n", + "1. **监督者模式**:一个监督者智能体协调多个工作智能体\n", + "2. **协作模式**:多个智能体平等协作,共同完成任务\n", + "3. **竞争模式**:多个智能体竞争,选择最优解\n", + "4. **流水线模式**:多个智能体按顺序处理任务\n", + "5. **辩论模式**:多个智能体展开辩论,最终达成共识\n", + "\n", + "### 架构选择指南\n", + "\n", + "| 模式 | 适用场景 | 特点 |\n", + "| --- | --- | --- |\n", + "| 监督者 | 需要多种专业能力 | 集中控制,易于管理 |\n", + "| 协作 | 需要频繁交流 | 平等协作,灵活适应 |\n", + "| 竞争 | 需要最优解 | 多方竞争,提高质量 |\n", + "| 流水线 | 可分解为顺序步骤 | 结构清晰,易于扩展 |\n", + "| 辩论 | 需要深入分析 | 全面探讨,结论客观 |\n", + "\n", + "### 实践要点\n", + "\n", + "- 根据任务特点选择合适的架构模式\n", + "- 智能体之间通过共享状态传递信息\n", + "- 使用监督者模式时,确保监督者逻辑清晰\n", + "- 使用MemorySaver保存状态,支持多轮交互\n", + "- 可以组合多种模式构建复杂系统\n", + "- 智能体可以调用真实大模型(如GPT、Qwen等)来生成内容\n", + "- 使用环境变量管理API密钥,确保安全性\n", + "- 通过封装call_llm函数统一管理大模型调用" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 练习\n", + "\n", + "1. 修改监督者模式示例,添加一个\"质量检查智能体\"\n", + "2. 修改流水线模式示例,添加一个\"校对智能体\"\n", + "3. 修改竞争模式示例,让智能体可以互相评价\n", + "4. 创建一个新的辩论模式示例,增加到3轮辩论\n", + "5. 设计一个综合多智能体系统,包含监督者、协作和竞争模式" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.4" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/21_对话代理.ipynb b/21_对话代理.ipynb new file mode 100644 index 0000000..a1943d4 --- /dev/null +++ b/21_对话代理.ipynb @@ -0,0 +1,836 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "a24e74a7", + "metadata": {}, + "source": [ + "# 21 对话代理\n", + "\n", + "## 学习目标\n", + "1. 掌握基于 LangGraph 构建对话代理的方法\n", + "2. 理解消息列表(`messages`)在对话状态中的作用\n", + "3. 能够实现具备历史记忆和上下文理解的对话机器人\n", + "4. 理解 `HumanMessage`、`AIMessage`、`SystemMessage` 的区别\n", + "5. 学会用检查点机制保存多轮对话状态" + ] + }, + { + "cell_type": "markdown", + "id": "04d2a1aa", + "metadata": {}, + "source": [ + "## 1. 什么是对话代理\n", + "\n", + "对话代理可以理解为一个能够和用户连续交流的 AI 程序。\n", + "\n", + "普通的大模型调用通常是这样的:\n", + "\n", + "```\n", + "用户输入 -> 模型回答 -> 结束\n", + "```\n", + "\n", + "而对话代理更像这样:\n", + "\n", + "```\n", + "用户第 1 轮输入 -> 模型回答\n", + "用户第 2 轮输入 -> 模型结合上一轮继续回答\n", + "用户第 3 轮输入 -> 模型继续理解上下文\n", + "```\n", + "\n", + "它和一次性问答最大的区别是:**对话代理需要记住前面说过什么。**\n", + "\n", + "例如用户先说:\n", + "\n", + "```text\n", + "我叫小王。\n", + "```\n", + "\n", + "下一轮再问:\n", + "\n", + "```text\n", + "我叫什么?\n", + "```\n", + "\n", + "如果代理能回答‘你叫小王’,说明它具备了基本的对话记忆。" + ] + }, + { + "cell_type": "markdown", + "id": "2c678a5b", + "metadata": {}, + "source": [ + "## 2. 为什么对话代理要用 `messages`\n", + "\n", + "在 LangChain 和 LangGraph 中,对话通常不是用一个普通字符串保存,而是用消息列表 `messages` 保存。\n", + "\n", + "一个简单的消息列表可能长这样:\n", + "\n", + "```python\n", + "[\n", + " HumanMessage(content='我叫小王'),\n", + " AIMessage(content='你好,小王!'),\n", + " HumanMessage(content='我叫什么?')\n", + "]\n", + "```\n", + "\n", + "这个列表的好处是:\n", + "\n", + "- 能保存多轮对话历史\n", + "- 能区分每句话是谁说的\n", + "- 能直接传给聊天模型\n", + "- 很适合放进 LangGraph 的状态里\n", + "\n", + "可以把 `messages` 理解成一份‘聊天记录本’。每轮对话都会往记录本里追加新内容。" + ] + }, + { + "cell_type": "markdown", + "id": "9b9665f1", + "metadata": {}, + "source": [ + "## 3. 三种常见消息类型\n", + "\n", + "对话代理中最常见的消息类型有三种:\n", + "\n", + "| 消息类型 | 含义 | 常见用途 |\n", + "| --- | --- | --- |\n", + "| `SystemMessage` | 系统消息 | 规定 AI 的角色、规则、风格 |\n", + "| `HumanMessage` | 用户消息 | 保存用户输入 |\n", + "| `AIMessage` | AI 消息 | 保存模型回复 |\n", + "\n", + "举个例子:\n", + "\n", + "- `SystemMessage`:你是一个耐心的 Python 老师\n", + "- `HumanMessage`:请解释什么是列表\n", + "- `AIMessage`:列表是 Python 中用于存放多个元素的数据结构\n", + "\n", + "这三类消息合在一起,就能表达一段完整对话。" + ] + }, + { + "cell_type": "markdown", + "id": "cd8ce90c", + "metadata": {}, + "source": [ + "## 4. 第一个例子:手动维护消息列表\n", + "\n", + "在进入 LangGraph 之前,先用一个最简单的例子理解 `messages`。\n", + "\n", + "这个例子不调用大模型,只演示消息列表如何保存对话历史。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2e02c643", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_core.messages import HumanMessage, AIMessage, SystemMessage\n", + "\n", + "messages = [\n", + " SystemMessage(content='你是一个耐心的课程助教。'),\n", + " HumanMessage(content='我叫小王。'),\n", + " AIMessage(content='你好,小王,很高兴认识你。'),\n", + " HumanMessage(content='我叫什么?')\n", + "]\n", + "\n", + "for message in messages:\n", + " print(type(message).__name__, ':', message.content)" + ] + }, + { + "cell_type": "markdown", + "id": "d3d58143", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这个例子没有使用 LangGraph,也没有调用大模型,目的只是先把消息列表讲清楚。\n", + "\n", + "#### `SystemMessage`\n", + "第一条消息是系统消息:\n", + "\n", + "```python\n", + "SystemMessage(content='你是一个耐心的课程助教。')\n", + "```\n", + "\n", + "它不是用户说的话,也不是模型回答的话,而是给模型设置角色和行为规则。\n", + "\n", + "#### `HumanMessage`\n", + "用户说的话用 `HumanMessage` 表示。\n", + "\n", + "例如:\n", + "\n", + "```python\n", + "HumanMessage(content='我叫小王。')\n", + "```\n", + "\n", + "这表示用户告诉代理:自己的名字是小王。\n", + "\n", + "#### `AIMessage`\n", + "AI 的回复用 `AIMessage` 表示。\n", + "\n", + "例如:\n", + "\n", + "```python\n", + "AIMessage(content='你好,小王,很高兴认识你。')\n", + "```\n", + "\n", + "它记录了上一轮模型回答过什么。\n", + "\n", + "#### 为什么要保存完整列表\n", + "最后一条用户消息是:\n", + "\n", + "```python\n", + "HumanMessage(content='我叫什么?')\n", + "```\n", + "\n", + "如果只看这一句话,模型不知道‘我’是谁。\n", + "\n", + "但如果把完整 `messages` 传给模型,模型就能从前面的聊天记录里看到:用户之前说过自己叫小王。\n", + "\n", + "这就是对话历史的作用。" + ] + }, + { + "cell_type": "markdown", + "id": "fed8eded", + "metadata": {}, + "source": [ + "## 5. 在 LangGraph 状态中保存 messages\n", + "\n", + "对话代理的核心状态通常就是 `messages`。\n", + "\n", + "但是这里有一个重要细节:多轮对话不是每次覆盖旧消息,而是要把新消息追加到旧消息后面。\n", + "\n", + "LangGraph 提供了 `add_messages`,可以帮助我们把消息列表自动追加合并。\n", + "\n", + "下面先看一个不调用模型的 LangGraph 示例。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d199158f", + "metadata": {}, + "outputs": [], + "source": [ + "from typing import Annotated\n", + "from typing_extensions import TypedDict\n", + "from langchain_core.messages import HumanMessage, AIMessage\n", + "from langgraph.graph import StateGraph, START, END\n", + "from langgraph.graph.message import add_messages\n", + "\n", + "class ChatState(TypedDict):\n", + " messages: Annotated[list, add_messages]\n", + "\n", + "def simple_reply(state: ChatState):\n", + " last_message = state['messages'][-1]\n", + " reply = AIMessage(content=f'我收到了你的消息:{last_message.content}')\n", + " return {'messages': [reply]}\n", + "\n", + "builder = StateGraph(ChatState)\n", + "builder.add_node('simple_reply', simple_reply)\n", + "builder.add_edge(START, 'simple_reply')\n", + "builder.add_edge('simple_reply', END)\n", + "\n", + "graph = builder.compile()\n", + "result = graph.invoke({\n", + " 'messages': [HumanMessage(content='你好,请介绍一下你自己')]\n", + "})\n", + "\n", + "for message in result['messages']:\n", + " print(type(message).__name__, ':', message.content)" + ] + }, + { + "cell_type": "markdown", + "id": "76db6c52", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这个例子已经进入 LangGraph,但为了降低理解难度,暂时没有调用真正的大模型。\n", + "\n", + "#### `Annotated[list, add_messages]`\n", + "这是本节最关键的一行:\n", + "\n", + "```python\n", + "messages: Annotated[list, add_messages]\n", + "```\n", + "\n", + "它表示:`messages` 是一个列表,并且当节点返回新的 `messages` 时,不是直接覆盖旧列表,而是把新消息追加进去。\n", + "\n", + "如果没有 `add_messages`,你返回:\n", + "\n", + "```python\n", + "{'messages': [reply]}\n", + "```\n", + "\n", + "可能会覆盖原来的消息。\n", + "\n", + "有了 `add_messages`,它的效果更像:\n", + "\n", + "```python\n", + "旧消息列表 + 新消息列表\n", + "```\n", + "\n", + "这正是多轮对话需要的行为。\n", + "\n", + "#### `simple_reply` 节点\n", + "这个节点做了三件事:\n", + "\n", + "1. 从 `state['messages']` 中取出最后一条消息\n", + "2. 根据最后一条用户消息生成一个简单回复\n", + "3. 把回复包装成 `AIMessage` 返回\n", + "\n", + "这里的回复不是模型生成的,而是我们手写的。这样做是为了先看懂对话状态怎么流动。\n", + "\n", + "#### 为什么返回 `{'messages': [reply]}`\n", + "节点返回的是一个只包含新回复的列表。\n", + "\n", + "因为状态字段配置了 `add_messages`,所以 LangGraph 会把这个新回复追加到原来的消息列表后面。\n", + "\n", + "最终结果中会同时包含:\n", + "\n", + "- 用户原始消息\n", + "- AI 新回复\n", + "\n", + "这就是对话状态的最小工作方式。" + ] + }, + { + "cell_type": "markdown", + "id": "1a3b6c97", + "metadata": {}, + "source": [ + "## 6. 使用大模型构建对话节点\n", + "\n", + "现在把手写回复换成真正的大模型调用。\n", + "\n", + "注意:下面代码会读取项目 `.env` 中的环境变量,因此第一步要先执行 `load_dotenv()`。\n", + "\n", + "你需要在项目根目录配置:\n", + "\n", + "```env\n", + "OPENAI_BASE_URL=你的接口地址\n", + "OPENAI_API_KEY=你的 API Key\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7f7639be", + "metadata": {}, + "outputs": [], + "source": [ + "from dotenv import load_dotenv\n", + "\n", + "load_dotenv()" + ] + }, + { + "cell_type": "markdown", + "id": "347ee1fe", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码只有两行,但很重要。\n", + "\n", + "#### `from dotenv import load_dotenv`\n", + "它从 `python-dotenv` 中导入 `load_dotenv` 函数。\n", + "\n", + "#### `load_dotenv()`\n", + "它会尝试读取当前项目目录下的 `.env` 文件,并把里面的变量加载到当前 Python 进程中。\n", + "\n", + "后面的 `ChatOpenAI` 会依赖 `OPENAI_API_KEY` 和 `OPENAI_BASE_URL`。\n", + "\n", + "如果没有先执行这一步,Jupyter 很可能读不到环境变量,从而出现缺少 API Key 的报错。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "74ef82dd", + "metadata": {}, + "outputs": [], + "source": [ + "from typing import Annotated\n", + "from typing_extensions import TypedDict\n", + "from langchain_core.messages import HumanMessage, SystemMessage\n", + "from langchain_openai import ChatOpenAI\n", + "from langgraph.graph import StateGraph, START, END\n", + "from langgraph.graph.message import add_messages\n", + "\n", + "class AgentState(TypedDict):\n", + " messages: Annotated[list, add_messages]\n", + "\n", + "llm = ChatOpenAI(model='qwen3.6-35b-A3b', temperature=0)\n", + "\n", + "def chatbot(state: AgentState):\n", + " response = llm.invoke(state['messages'])\n", + " return {'messages': [response]}\n", + "\n", + "builder = StateGraph(AgentState)\n", + "builder.add_node('chatbot', chatbot)\n", + "builder.add_edge(START, 'chatbot')\n", + "builder.add_edge('chatbot', END)\n", + "\n", + "chat_graph = builder.compile()\n", + "\n", + "result = chat_graph.invoke({\n", + " 'messages': [\n", + " SystemMessage(content='你是一个通俗易懂的 Python 课程助教。'),\n", + " HumanMessage(content='请用一句话解释什么是函数。')\n", + " ]\n", + "})\n", + "\n", + "print(result['messages'][-1].content)" + ] + }, + { + "cell_type": "markdown", + "id": "ed5dc0e5", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这是本节第一个真正调用大模型的对话代理。\n", + "\n", + "#### `AgentState`\n", + "状态中只有一个字段:\n", + "\n", + "```python\n", + "messages: Annotated[list, add_messages]\n", + "```\n", + "\n", + "这表示整张图最重要的数据就是聊天记录。\n", + "\n", + "#### `llm = ChatOpenAI(...)`\n", + "这里创建了一个聊天模型对象。\n", + "\n", + "- `model='qwen3.6-35b-A3b'` 表示使用的模型名称\n", + "- `temperature=0` 表示回答尽量稳定,不要太随机\n", + "\n", + "只要 `.env` 中配置了 `OPENAI_BASE_URL` 和 `OPENAI_API_KEY`,这里就可以正常使用对应接口。\n", + "\n", + "#### `chatbot(state)` 节点\n", + "这是图里的核心节点。它做的事情是:\n", + "\n", + "1. 读取当前状态中的 `messages`\n", + "2. 把完整消息列表传给大模型\n", + "3. 得到模型回复 `response`\n", + "4. 把回复追加回 `messages`\n", + "\n", + "关键代码是:\n", + "\n", + "```python\n", + "response = llm.invoke(state['messages'])\n", + "```\n", + "\n", + "这不是只传最后一句用户问题,而是把整个消息列表传给模型。\n", + "\n", + "#### 为什么返回 `[response]`\n", + "`response` 本身就是一个 AI 消息对象。\n", + "\n", + "返回:\n", + "\n", + "```python\n", + "{'messages': [response]}\n", + "```\n", + "\n", + "表示把模型刚刚生成的回复追加到历史记录中。\n", + "\n", + "#### 这张图的流程\n", + "整张图非常简单:\n", + "\n", + "```\n", + "START -> chatbot -> END\n", + "```\n", + "\n", + "虽然流程简单,但它已经具备对话代理的核心结构:用 `messages` 作为状态,用模型节点生成回复。" + ] + }, + { + "cell_type": "markdown", + "id": "023bdf1a", + "metadata": {}, + "source": [ + "## 7. 多轮对话:手动传入历史消息\n", + "\n", + "刚才的示例只运行了一轮。\n", + "\n", + "如果想让模型记住前面说过什么,最直接的方法是:第二轮调用时,把第一轮返回的 `messages` 继续传进去。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "621a179c", + "metadata": {}, + "outputs": [], + "source": [ + "messages = [\n", + " SystemMessage(content='你是一个简洁的中文助手。'),\n", + " HumanMessage(content='我叫小王,正在学习 LangGraph。')\n", + "]\n", + "\n", + "first_result = chat_graph.invoke({'messages': messages})\n", + "print('第一轮回答:')\n", + "print(first_result['messages'][-1].content)\n", + "\n", + "second_messages = first_result['messages'] + [\n", + " HumanMessage(content='我叫什么?我正在学什么?')\n", + "]\n", + "\n", + "second_result = chat_graph.invoke({'messages': second_messages})\n", + "print('\\n第二轮回答:')\n", + "print(second_result['messages'][-1].content)" + ] + }, + { + "cell_type": "markdown", + "id": "c8ce506f", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这个例子演示了最直接的多轮对话方式:手动保存并传递历史消息。\n", + "\n", + "#### 第一轮对话\n", + "第一轮消息包括:\n", + "\n", + "- 一条系统消息:规定助手要简洁\n", + "- 一条用户消息:告诉模型‘我叫小王,正在学习 LangGraph’\n", + "\n", + "调用后,`first_result['messages']` 中会包含:\n", + "\n", + "- 原来的系统消息\n", + "- 原来的用户消息\n", + "- 模型第一轮回复\n", + "\n", + "#### 第二轮为什么要用 `first_result['messages']`\n", + "第二轮用户问:\n", + "\n", + "```text\n", + "我叫什么?我正在学什么?\n", + "```\n", + "\n", + "如果只把这句话传给模型,模型不知道答案。\n", + "\n", + "所以代码这样写:\n", + "\n", + "```python\n", + "second_messages = first_result['messages'] + [HumanMessage(...)]\n", + "```\n", + "\n", + "意思是:把上一轮完整聊天记录拿过来,再追加新的用户问题。\n", + "\n", + "#### 这个例子的局限\n", + "这种方式能工作,但需要我们自己手动管理 `messages`。\n", + "\n", + "如果对话轮数很多,手动传来传去会比较麻烦。\n", + "\n", + "所以 LangGraph 还提供了检查点机制,可以帮我们保存对话状态。" + ] + }, + { + "cell_type": "markdown", + "id": "40a885e7", + "metadata": {}, + "source": [ + "## 8. 使用检查点保存对话记忆\n", + "\n", + "如果希望图自动保存多轮对话状态,可以使用检查点(checkpoint)。\n", + "\n", + "最常见的入门方式是使用 `MemorySaver`。\n", + "\n", + "它会根据 `thread_id` 保存不同会话的状态。\n", + "\n", + "你可以把 `thread_id` 理解成一个聊天窗口 ID:\n", + "\n", + "- 同一个 `thread_id`:共享同一段对话历史\n", + "- 不同 `thread_id`:互相独立,互不影响" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c2880d4c", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.checkpoint.memory import MemorySaver\n", + "\n", + "memory = MemorySaver()\n", + "chat_graph_with_memory = builder.compile(checkpointer=memory)\n", + "\n", + "config = {'configurable': {'thread_id': 'student-001'}}\n", + "\n", + "first_result = chat_graph_with_memory.invoke(\n", + " {'messages': [HumanMessage(content='我叫小王,正在学习 LangGraph。')]},\n", + " config=config\n", + ")\n", + "print('第一轮回答:')\n", + "print(first_result['messages'][-1].content)\n", + "\n", + "second_result = chat_graph_with_memory.invoke(\n", + " {'messages': [HumanMessage(content='我叫什么?我正在学什么?')]},\n", + " config=config\n", + ")\n", + "print('\\n第二轮回答:')\n", + "print(second_result['messages'][-1].content)" + ] + }, + { + "cell_type": "markdown", + "id": "a50620b3", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这个例子是本节最接近真实对话机器人的写法。\n", + "\n", + "#### `MemorySaver()`\n", + "`MemorySaver` 是一个内存版检查点保存器。\n", + "\n", + "它会把图运行过程中的状态保存下来。\n", + "\n", + "这里的状态主要就是 `messages`。\n", + "\n", + "#### `builder.compile(checkpointer=memory)`\n", + "普通的 `compile()` 只是把图编译成可运行对象。\n", + "\n", + "加上 `checkpointer=memory` 后,图在运行时会自动保存和读取状态。\n", + "\n", + "#### `thread_id`\n", + "配置中最关键的是:\n", + "\n", + "```python\n", + "config = {'configurable': {'thread_id': 'student-001'}}\n", + "```\n", + "\n", + "它表示当前对话属于 `student-001` 这个会话。\n", + "\n", + "只要第二次调用继续使用同一个 `thread_id`,LangGraph 就会自动接上之前的消息历史。\n", + "\n", + "#### 为什么第二轮只传新消息也能记住上下文\n", + "第二轮调用时,我们只传了新的用户消息:\n", + "\n", + "```python\n", + "{'messages': [HumanMessage(content='我叫什么?我正在学什么?')]}\n", + "```\n", + "\n", + "但是因为 `thread_id` 没变,检查点里已经保存了上一轮的历史。\n", + "\n", + "所以图会把旧消息和新消息合并起来,再交给模型。\n", + "\n", + "这就是它能回答出‘你叫小王,正在学习 LangGraph’的原因。\n", + "\n", + "#### 这个机制的价值\n", + "有了检查点机制,你不需要每次手动传完整历史。\n", + "\n", + "这对真实聊天机器人非常重要,因为真实系统中会有很多用户、很多会话,每个会话都需要独立保存上下文。" + ] + }, + { + "cell_type": "markdown", + "id": "7df1fdbb", + "metadata": {}, + "source": [ + "## 9. 不同 thread_id 表示不同对话\n", + "\n", + "为了理解 `thread_id`,可以再看一个例子。\n", + "\n", + "同一个图,如果使用不同的 `thread_id`,就相当于打开了两个不同的聊天窗口。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "db8a613d", + "metadata": {}, + "outputs": [], + "source": [ + "config_a = {'configurable': {'thread_id': 'chat-a'}}\n", + "config_b = {'configurable': {'thread_id': 'chat-b'}}\n", + "\n", + "chat_graph_with_memory.invoke(\n", + " {'messages': [HumanMessage(content='我叫小李。')]},\n", + " config=config_a\n", + ")\n", + "\n", + "chat_graph_with_memory.invoke(\n", + " {'messages': [HumanMessage(content='我叫小张。')]},\n", + " config=config_b\n", + ")\n", + "\n", + "result_a = chat_graph_with_memory.invoke(\n", + " {'messages': [HumanMessage(content='我叫什么?')]},\n", + " config=config_a\n", + ")\n", + "\n", + "result_b = chat_graph_with_memory.invoke(\n", + " {'messages': [HumanMessage(content='我叫什么?')]},\n", + " config=config_b\n", + ")\n", + "\n", + "print('chat-a:', result_a['messages'][-1].content)\n", + "print('chat-b:', result_b['messages'][-1].content)" + ] + }, + { + "cell_type": "markdown", + "id": "296052e3", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这个例子用来说明:记忆不是全局混在一起的,而是按 `thread_id` 分开的。\n", + "\n", + "#### `config_a` 和 `config_b`\n", + "这两个配置分别代表两个会话:\n", + "\n", + "- `chat-a`\n", + "- `chat-b`\n", + "\n", + "它们使用同一张图、同一个模型,但是历史记录互相独立。\n", + "\n", + "#### 第一轮分别告诉不同名字\n", + "在 `chat-a` 中,用户说自己叫小李。\n", + "\n", + "在 `chat-b` 中,用户说自己叫小张。\n", + "\n", + "#### 第二轮都问‘我叫什么?’\n", + "虽然第二轮问题完全一样,但因为 `thread_id` 不同,模型看到的历史也不同。\n", + "\n", + "所以理想情况下:\n", + "\n", + "- `chat-a` 会回答小李\n", + "- `chat-b` 会回答小张\n", + "\n", + "这就是多会话对话机器人的基础。\n", + "\n", + "真实产品中,每个用户、每个聊天窗口、每个任务线程,都可以用不同的 `thread_id` 管理。" + ] + }, + { + "cell_type": "markdown", + "id": "9353884b", + "metadata": {}, + "source": [ + "## 10. 对话代理的整体结构\n", + "\n", + "到这里,我们可以把对话代理总结成下面这张流程图:\n", + "\n", + "```\n", + "用户输入\n", + " ↓\n", + "追加到 messages\n", + " ↓\n", + "chatbot 节点读取 messages\n", + " ↓\n", + "大模型基于完整上下文生成回复\n", + " ↓\n", + "AI 回复追加到 messages\n", + " ↓\n", + "检查点保存当前状态\n", + "```\n", + "\n", + "其中最重要的两件事是:\n", + "\n", + "- `messages` 负责保存对话内容\n", + "- `thread_id` 负责区分不同对话线程\n", + "\n", + "理解这两点,就能看懂大多数 LangGraph 对话代理的基础写法。" + ] + }, + { + "cell_type": "markdown", + "id": "b720b8d0", + "metadata": {}, + "source": [ + "## 11. 常见问题\n", + "\n", + "### 11.1 为什么模型还是不记得前面说过的话\n", + "通常有几个原因:\n", + "\n", + "- 没有把旧 `messages` 传入下一轮\n", + "- 没有使用 `add_messages`\n", + "- 使用检查点时,第二轮换了不同的 `thread_id`\n", + "- 每次都重新编译图并重新创建内存保存器\n", + "\n", + "### 11.2 `messages` 会不会越来越长\n", + "会。\n", + "\n", + "真实项目中通常需要做历史裁剪、摘要记忆或长期记忆存储。\n", + "\n", + "本节先学习基础机制,后面再考虑复杂记忆管理。\n", + "\n", + "### 11.3 `SystemMessage` 每轮都要传吗\n", + "如果你手动维护 `messages`,通常第一轮放进去后,后续继续传完整历史即可。\n", + "\n", + "如果使用检查点,只要第一轮已经进入同一个 `thread_id`,后续通常不需要重复传同一条系统消息。\n", + "\n", + "但在真实项目中,也有人会在每次调用前固定补充系统提示,保证行为稳定。" + ] + }, + { + "cell_type": "markdown", + "id": "2bdf3988", + "metadata": {}, + "source": [ + "## 12. 本节小结\n", + "\n", + "本节最重要的内容有五点:\n", + "\n", + "1. **对话代理和一次性问答最大的区别是要保存历史上下文**\n", + "2. **`messages` 是对话状态的核心,负责保存多轮聊天记录**\n", + "3. **`add_messages` 可以让新消息追加到旧消息后面,而不是覆盖旧消息**\n", + "4. **对话节点通常会把完整 `messages` 传给聊天模型,再把模型回复追加回来**\n", + "5. **检查点和 `thread_id` 可以帮助我们自动保存不同会话的对话记忆**\n", + "\n", + "掌握了本节内容,你就已经具备了构建基础聊天机器人的能力。后面再加入工具调用、条件边和长期记忆,就可以逐步扩展成更完整的智能体。" + ] + }, + { + "cell_type": "markdown", + "id": "9ea83fb3", + "metadata": {}, + "source": [ + "## 13. 本节练习\n", + "\n", + "1. 修改 `SystemMessage`,让对话代理变成一个英语学习助手\n", + "2. 修改第一轮用户输入,让代理记住你的名字和学习目标\n", + "3. 用同一个 `thread_id` 连续提问三轮,观察它是否能记住前文\n", + "4. 换一个新的 `thread_id`,观察新会话是否还记得旧会话内容\n", + "5. 思考:如果 `messages` 很长,可能会带来哪些问题?应该如何处理?" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.4" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/22_任务调度.ipynb b/22_任务调度.ipynb new file mode 100644 index 0000000..1c172c8 --- /dev/null +++ b/22_任务调度.ipynb @@ -0,0 +1,604 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 22_任务调度型智能体\n", + "\n", + "## 学习目标\n", + "1. 理解任务调度型智能体的设计思路\n", + "2. 掌握使用 LangGraph 实现任务分解、执行与结果汇总\n", + "3. 能够构建简单的自动化任务处理智能体\n", + "\n", + "本节课会用一个通俗例子来理解任务调度:**把一个大任务拆成多个小任务,再安排合适的节点依次完成,最后汇总结果**。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. 什么是任务调度型智能体\n", + "\n", + "任务调度型智能体可以理解为一个“项目经理型”智能体。用户只提出一个目标,它不会马上盲目执行,而是先思考:\n", + "\n", + "1. 这个目标可以拆成哪些步骤?\n", + "2. 哪些步骤需要先做,哪些步骤可以后做?\n", + "3. 每一步应该交给哪个工具、函数或智能体执行?\n", + "4. 每一步完成后,如何把结果合并成最终答案?\n", + "\n", + "例如用户说:\n", + "\n", + "> 帮我分析一个产品的用户反馈,并给出改进建议。\n", + "\n", + "任务调度型智能体可能会拆成:\n", + "\n", + "1. 收集用户反馈\n", + "2. 对反馈进行分类\n", + "3. 提取高频问题\n", + "4. 生成改进建议\n", + "5. 输出结构化报告\n", + "\n", + "这类智能体的核心不是“单次回答”,而是“组织多个步骤完成复杂任务”。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. 任务调度的基本流程\n", + "\n", + "一个典型任务调度型智能体通常包含 4 个环节:\n", + "\n", + "| 环节 | 作用 | 通俗理解 |\n", + "| --- | --- | --- |\n", + "| 任务输入 | 接收用户目标 | 用户告诉智能体要做什么 |\n", + "| 任务分解 | 把大任务拆成小任务 | 项目经理制定待办清单 |\n", + "| 任务执行 | 按顺序执行每个小任务 | 不同同事完成各自工作 |\n", + "| 结果汇总 | 整合所有执行结果 | 写成最终报告交给用户 |\n", + "\n", + "在 LangGraph 中,我们可以把每个环节看成一个 **节点**,节点之间通过 **边** 连接起来,形成一个工作流。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. 先不用 LangGraph:用普通 Python 理解任务调度\n", + "\n", + "在学习 LangGraph 前,先用普通 Python 模拟一个最小任务调度流程。这个例子不依赖任何外部库,更容易理解“任务分解、执行、汇总”的本质。\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def split_task(user_goal):\n", + " \"\"\"把用户的大目标拆成多个小任务。\"\"\"\n", + " tasks = [\n", + " f\"理解用户目标:{user_goal}\",\n", + " \"收集与目标相关的信息\",\n", + " \"分析关键信息\",\n", + " \"生成最终建议\",\n", + " ]\n", + " return tasks\n", + "\n", + "\n", + "def execute_task(task):\n", + " \"\"\"模拟执行一个小任务。\"\"\"\n", + " return f\"已完成:{task}\"\n", + "\n", + "\n", + "def summarize_results(results):\n", + " \"\"\"把多个小任务的执行结果汇总成最终输出。\"\"\"\n", + " report = \"任务执行报告:\\n\"\n", + " for index, result in enumerate(results, start=1):\n", + " report += f\"{index}. {result}\\n\"\n", + " return report\n", + "\n", + "\n", + "user_goal = \"为一家咖啡店设计会员运营方案\"\n", + "tasks = split_task(user_goal)\n", + "results = [execute_task(task) for task in tasks]\n", + "final_report = summarize_results(results)\n", + "\n", + "print(final_report)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码展示了任务调度的最小模型:\n", + "\n", + "1. `split_task(user_goal)` 负责拆解任务。它接收用户的大目标,返回一个任务列表。\n", + "2. `execute_task(task)` 负责执行单个小任务。这里为了演示,只返回“已完成”的文字。真实项目中,这里可以调用搜索工具、数据库、LLM 或其他业务函数。\n", + "3. `summarize_results(results)` 负责汇总结果。它把每个小任务的输出整理成最终报告。\n", + "4. `results = [execute_task(task) for task in tasks]` 是任务调度的执行阶段,会逐个执行任务列表里的每一项。\n", + "\n", + "这个例子虽然简单,但已经包含任务调度型智能体最重要的 3 个动作:**拆、做、汇总**。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. LangGraph 的核心概念\n", + "\n", + "LangGraph 是一个用“图”组织智能体流程的框架。\n", + "\n", + "可以把它想象成一张流程图:\n", + "\n", + "```text\n", + "开始 -> 任务分解 -> 任务执行 -> 结果汇总 -> 结束\n", + "```\n", + "\n", + "在 LangGraph 中常见概念如下:\n", + "\n", + "| 概念 | 含义 | 通俗理解 |\n", + "| --- | --- | --- |\n", + "| State | 工作流中流动的数据 | 一个不断更新的任务档案袋 |\n", + "| Node | 处理数据的函数 | 流程图中的一个步骤 |\n", + "| Edge | 节点之间的连接 | 告诉程序下一步去哪里 |\n", + "| Graph | 完整工作流 | 整张流程图 |\n", + "\n", + "接下来我们用 LangGraph 实现同样的“拆、做、汇总”流程。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. 安装依赖\n", + "\n", + "如果当前环境没有安装 LangGraph,可以先运行下面的命令。已经安装过的环境可以跳过。\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 如果没有安装 LangGraph,取消下一行注释后运行\n", + "# %pip install langgraph\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "`%pip install langgraph` 是 Jupyter Notebook 中安装 Python 包的写法。\n", + "\n", + "这里默认把安装命令注释掉,是为了避免重复安装。如果运行后提示找不到 `langgraph`,再去掉前面的 `#` 执行即可。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. 定义工作流状态 State\n", + "\n", + "State 是 LangGraph 中非常重要的概念。它表示工作流运行过程中一直携带和更新的数据。\n", + "\n", + "本例中,我们希望 State 保存 4 类信息:\n", + "\n", + "1. `goal`:用户输入的大目标\n", + "2. `tasks`:拆分后的小任务列表\n", + "3. `results`:每个小任务的执行结果\n", + "4. `final_answer`:最终汇总答案\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from typing import List, TypedDict\n", + "\n", + "\n", + "class TaskState(TypedDict):\n", + " goal: str\n", + " tasks: List[str]\n", + " results: List[str]\n", + " final_answer: str\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码定义了工作流中的数据结构:\n", + "\n", + "1. `TypedDict` 用来描述字典里应该有哪些字段。它不会改变程序运行方式,但能让代码更清晰。\n", + "2. `TaskState` 表示整个任务调度过程中共享的数据。每个节点都会读取它,也可以返回新的字段值更新它。\n", + "3. `goal: str` 表示用户目标是字符串。\n", + "4. `tasks: List[str]` 表示任务列表,里面每一项都是字符串。\n", + "5. `results: List[str]` 表示执行结果列表。\n", + "6. `final_answer: str` 表示最终回答。\n", + "\n", + "可以把 `TaskState` 理解为一张任务表,工作流每走一步,就在这张表上补充一些内容。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7. 编写节点函数\n", + "\n", + "节点函数就是工作流中的一个步骤。每个节点接收当前 `state`,处理后返回要更新的字段。\n", + "\n", + "下面定义 3 个节点:\n", + "\n", + "1. `planner_node`:任务分解节点\n", + "2. `worker_node`:任务执行节点\n", + "3. `summary_node`:结果汇总节点\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def planner_node(state: TaskState):\n", + " goal = state[\"goal\"]\n", + " tasks = [\n", + " f\"明确目标:{goal}\",\n", + " \"列出目标用户和使用场景\",\n", + " \"设计可执行的行动步骤\",\n", + " \"整理成清晰的建议清单\",\n", + " ]\n", + " return {\"tasks\": tasks}\n", + "\n", + "\n", + "def worker_node(state: TaskState):\n", + " results = []\n", + " for task in state[\"tasks\"]:\n", + " result = f\"完成任务:{task}\"\n", + " results.append(result)\n", + " return {\"results\": results}\n", + "\n", + "\n", + "def summary_node(state: TaskState):\n", + " lines = [\"最终任务调度结果:\"]\n", + " for index, result in enumerate(state[\"results\"], start=1):\n", + " lines.append(f\"{index}. {result}\")\n", + " final_answer = \"\\n\".join(lines)\n", + " return {\"final_answer\": final_answer}\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码定义了 3 个节点函数:\n", + "\n", + "1. `planner_node(state)`:从 `state[\"goal\"]` 读取用户目标,然后生成 `tasks` 任务列表。返回 `{\"tasks\": tasks}` 表示把任务列表写回 State。\n", + "2. `worker_node(state)`:读取 `state[\"tasks\"]`,循环执行每个任务。这里用字符串模拟执行结果,真实项目中可以替换为调用工具、调用模型、查询数据库等操作。\n", + "3. `summary_node(state)`:读取 `state[\"results\"]`,把多个结果拼接成一段最终答案。\n", + "\n", + "需要注意:节点函数通常不需要返回完整 State,只返回自己负责更新的字段即可。LangGraph 会把这些字段合并回当前 State。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 8. 用 LangGraph 连接节点\n", + "\n", + "现在有了节点函数,下一步就是把这些节点连成一张图。\n", + "\n", + "流程如下:\n", + "\n", + "```text\n", + "START -> planner -> worker -> summary -> END\n", + "```\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.graph import END, START, StateGraph\n", + "\n", + "\n", + "workflow = StateGraph(TaskState)\n", + "\n", + "workflow.add_node(\"planner\", planner_node)\n", + "workflow.add_node(\"worker\", worker_node)\n", + "workflow.add_node(\"summary\", summary_node)\n", + "\n", + "workflow.add_edge(START, \"planner\")\n", + "workflow.add_edge(\"planner\", \"worker\")\n", + "workflow.add_edge(\"worker\", \"summary\")\n", + "workflow.add_edge(\"summary\", END)\n", + "\n", + "app = workflow.compile()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码把节点组织成可运行的工作流:\n", + "\n", + "1. `StateGraph(TaskState)` 创建一张图,并说明这张图中的数据结构是 `TaskState`。\n", + "2. `add_node(\"planner\", planner_node)` 把 `planner_node` 注册成名为 `planner` 的节点。\n", + "3. `add_edge(START, \"planner\")` 表示工作流从 `planner` 节点开始。\n", + "4. `add_edge(\"planner\", \"worker\")` 表示任务分解完成后,进入任务执行节点。\n", + "5. `add_edge(\"worker\", \"summary\")` 表示任务执行完成后,进入结果汇总节点。\n", + "6. `add_edge(\"summary\", END)` 表示汇总完成后,工作流结束。\n", + "7. `workflow.compile()` 会把流程图编译成一个可以调用的应用对象 `app`。\n", + "\n", + "这一步相当于把流程图画好,并告诉程序每一步应该怎么走。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 9. 运行任务调度智能体\n", + "\n", + "工作流编译完成后,就可以传入用户目标并运行了。\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "initial_state = {\n", + " \"goal\": \"为一家咖啡店设计会员运营方案\",\n", + " \"tasks\": [],\n", + " \"results\": [],\n", + " \"final_answer\": \"\",\n", + "}\n", + "\n", + "final_state = app.invoke(initial_state)\n", + "\n", + "print(final_state[\"final_answer\"])\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码负责真正运行工作流:\n", + "\n", + "1. `initial_state` 是初始状态,里面放入用户目标 `goal`,其余字段先给空值。\n", + "2. `app.invoke(initial_state)` 会启动 LangGraph 工作流。数据会依次经过 `planner`、`worker`、`summary` 三个节点。\n", + "3. 运行结束后返回 `final_state`,它包含整个流程运行后的完整状态。\n", + "4. `final_state[\"final_answer\"]` 取出最终汇总结果并打印。\n", + "\n", + "从这个例子可以看到,LangGraph 的优势是把复杂流程拆成清晰节点,每个节点只负责一件事。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 10. 加入条件判断:根据任务复杂度选择路径\n", + "\n", + "真实任务中,不是所有问题都需要复杂调度。\n", + "\n", + "例如:\n", + "\n", + "- 简单问题:直接回答即可\n", + "- 复杂问题:先拆分,再执行,再汇总\n", + "\n", + "LangGraph 支持条件边,可以根据 State 的内容决定下一步走哪条路径。\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from typing import Literal\n", + "\n", + "\n", + "def judge_complexity(state: TaskState) -> Literal[\"simple\", \"complex\"]:\n", + " goal = state[\"goal\"]\n", + " if len(goal) <= 12:\n", + " return \"simple\"\n", + " return \"complex\"\n", + "\n", + "\n", + "def direct_answer_node(state: TaskState):\n", + " answer = f\"这是一个简单任务,可以直接处理:{state['goal']}\"\n", + " return {\"final_answer\": answer}\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码新增了两个函数:\n", + "\n", + "1. `judge_complexity(state)` 用来判断任务复杂度。这里为了教学简单,用目标文字长度作为判断标准:短任务走 `simple`,长任务走 `complex`。真实项目中可以让大模型判断复杂度。\n", + "2. `Literal[\"simple\", \"complex\"]` 表示这个函数只会返回两个固定值之一,方便我们理解后续条件分支。\n", + "3. `direct_answer_node(state)` 是简单任务的处理节点,不做任务分解,直接生成最终答案。\n", + "\n", + "这个例子说明:任务调度不一定永远走同一条流程,可以根据任务情况动态选择路径。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 11. 构建带条件分支的工作流\n", + "\n", + "下面把简单路径和复杂路径组合到同一张图里。\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "branch_workflow = StateGraph(TaskState)\n", + "\n", + "branch_workflow.add_node(\"direct_answer\", direct_answer_node)\n", + "branch_workflow.add_node(\"planner\", planner_node)\n", + "branch_workflow.add_node(\"worker\", worker_node)\n", + "branch_workflow.add_node(\"summary\", summary_node)\n", + "\n", + "branch_workflow.add_conditional_edges(\n", + " START,\n", + " judge_complexity,\n", + " {\n", + " \"simple\": \"direct_answer\",\n", + " \"complex\": \"planner\",\n", + " },\n", + ")\n", + "\n", + "branch_workflow.add_edge(\"direct_answer\", END)\n", + "branch_workflow.add_edge(\"planner\", \"worker\")\n", + "branch_workflow.add_edge(\"worker\", \"summary\")\n", + "branch_workflow.add_edge(\"summary\", END)\n", + "\n", + "branch_app = branch_workflow.compile()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码构建了一个带分支的任务调度工作流:\n", + "\n", + "1. `add_conditional_edges` 表示添加条件边。它会先调用 `judge_complexity`,再根据返回值选择下一步。\n", + "2. 如果返回 `simple`,流程进入 `direct_answer` 节点,然后直接结束。\n", + "3. 如果返回 `complex`,流程进入 `planner` 节点,再继续执行 `worker` 和 `summary`。\n", + "4. 这张图有两条路径:一条适合简单任务,一条适合复杂任务。\n", + "\n", + "这就是任务调度型智能体的一个关键能力:**不是固定执行所有步骤,而是根据任务情况选择合适流程**。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 12. 测试条件分支效果\n", + "\n", + "分别输入一个简单任务和一个复杂任务,观察工作流走向的差异。\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "simple_state = {\n", + " \"goal\": \"写标题\",\n", + " \"tasks\": [],\n", + " \"results\": [],\n", + " \"final_answer\": \"\",\n", + "}\n", + "\n", + "complex_state = {\n", + " \"goal\": \"为一家咖啡店设计会员运营方案,并给出执行步骤\",\n", + " \"tasks\": [],\n", + " \"results\": [],\n", + " \"final_answer\": \"\",\n", + "}\n", + "\n", + "print(\"简单任务结果:\")\n", + "print(branch_app.invoke(simple_state)[\"final_answer\"])\n", + "\n", + "print(\"\\n复杂任务结果:\")\n", + "print(branch_app.invoke(complex_state)[\"final_answer\"])\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码测试了条件分支:\n", + "\n", + "1. `simple_state` 的目标是“写标题”,文字较短,所以 `judge_complexity` 返回 `simple`,流程会直接进入 `direct_answer`。\n", + "2. `complex_state` 的目标更长,所以返回 `complex`,流程会进入 `planner -> worker -> summary`。\n", + "3. 两次都调用 `branch_app.invoke(...)`,但由于输入不同,工作流自动选择了不同执行路径。\n", + "\n", + "这说明任务调度型智能体可以做到“简单任务快速处理,复杂任务分步处理”。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 13. 小结\n", + "\n", + "本节课学习了任务调度型智能体的基本设计方法:\n", + "\n", + "1. 任务调度型智能体适合处理多步骤、复杂目标。\n", + "2. 它的核心流程是:任务输入、任务分解、任务执行、结果汇总。\n", + "3. LangGraph 可以把每个步骤定义成节点,并用边连接成工作流。\n", + "4. State 是工作流中流动的数据,节点通过读取和更新 State 协同完成任务。\n", + "5. 条件边可以让智能体根据任务情况选择不同执行路径。\n", + "\n", + "掌握这些内容后,就可以继续扩展:把 `worker_node` 替换成真实工具调用、搜索引擎调用、数据库查询或大模型调用,从而构建更实用的自动化任务处理智能体。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 14. 练习题\n", + "\n", + "1. 修改 `planner_node`,让它针对“写一篇公众号文章”生成任务列表。\n", + "2. 修改 `worker_node`,让不同任务返回不同格式的结果。\n", + "3. 修改 `judge_complexity`,不要用文字长度判断,而是根据是否包含“方案”“分析”“计划”等关键词判断复杂度。\n", + "4. 尝试新增一个 `review_node`,在最终输出前检查结果是否完整。\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.4" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/23_人机协作.ipynb b/23_人机协作.ipynb new file mode 100644 index 0000000..40293cd --- /dev/null +++ b/23_人机协作.ipynb @@ -0,0 +1,834 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 23 人机协作\n", + "\n", + "## 学习目标\n", + "1. 理解人机协作(Human-in-the-loop)在智能体中的重要性\n", + "2. 掌握 LangGraph 中暂停(`interrupt`)和恢复(`resume`)机制\n", + "3. 能够设计需要人工确认或补充输入的智能体流程\n", + "4. 理解检查点在暂停与恢复中的作用\n", + "5. 能够构建带人工审批环节的基础智能体工作流" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. 什么是人机协作\n", + "\n", + "人机协作,也叫 Human-in-the-loop,意思是:**智能体不是从头到尾完全自动执行,而是在关键步骤暂停下来,让人参与判断、确认或补充信息。**\n", + "\n", + "普通自动流程可能是这样:\n", + "\n", + "```\n", + "输入 -> 智能体处理 -> 自动执行 -> 输出结果\n", + "```\n", + "\n", + "人机协作流程更像这样:\n", + "\n", + "```\n", + "输入 -> 智能体处理 -> 暂停等待人工确认 -> 继续执行 -> 输出结果\n", + "```\n", + "\n", + "也就是说,人不是在流程外面旁观,而是成为流程中的一个关键环节。\n", + "\n", + "在人机协作中,AI 适合做:\n", + "\n", + "- 整理信息\n", + "- 生成草稿\n", + "- 给出建议\n", + "- 自动处理低风险步骤\n", + "\n", + "人更适合做:\n", + "\n", + "- 最终确认\n", + "- 风险判断\n", + "- 补充缺失信息\n", + "- 决定是否继续执行\n", + "\n", + "这就是人机协作的核心思想:**让 AI 提高效率,让人把控关键决策。**" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. 为什么智能体需要人工介入\n", + "\n", + "很多智能体任务不能完全交给模型自动完成。原因主要有三类。\n", + "\n", + "### 2.1 有些操作风险较高\n", + "例如:\n", + "\n", + "- 发送正式邮件\n", + "- 删除数据\n", + "- 提交订单\n", + "- 执行付款\n", + "- 修改线上配置\n", + "\n", + "这些操作一旦执行,可能造成真实影响,所以最好先让人确认。\n", + "\n", + "### 2.2 有些信息 AI 不知道\n", + "例如用户说:\n", + "\n", + "```text\n", + "帮我给客户写一封邮件。\n", + "```\n", + "\n", + "但 AI 可能不知道客户姓名、邮件目的、语气要求、是否要附带报价。\n", + "\n", + "这时流程应该暂停,让用户补充信息。\n", + "\n", + "### 2.3 有些判断需要人负责\n", + "例如 AI 生成了一个方案,但最终是否采用,需要人做业务判断。\n", + "\n", + "所以,人机协作不是因为 AI 不够强,而是因为很多场景天然需要人工负责。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. LangGraph 中的人机协作机制\n", + "\n", + "在 LangGraph 中,人机协作通常依赖两个核心动作:\n", + "\n", + "| 动作 | 含义 | 作用 |\n", + "| --- | --- | --- |\n", + "| `interrupt(...)` | 暂停流程 | 把需要人工处理的信息抛出来 |\n", + "| `Command(resume=...)` | 恢复流程 | 把人工输入传回图中,继续执行 |\n", + "\n", + "可以这样理解:\n", + "\n", + "- `interrupt` 像是智能体举手说:‘这里需要人来决定一下’\n", + "- `Command(resume=...)` 像是人回复:‘我决定好了,你继续吧’\n", + "\n", + "不过要注意:暂停和恢复必须依赖检查点(checkpoint)。\n", + "\n", + "因为图暂停后,需要记住自己停在了哪里。等人给出答案后,才能从正确的位置继续执行。\n", + "\n", + "所以,人机协作一般会和 `MemorySaver` 一起使用。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. 第一个例子:人工确认后再继续\n", + "\n", + "先从最简单的例子开始:智能体准备执行一个操作,但在执行前需要人工确认。\n", + "\n", + "流程如下:\n", + "\n", + "```\n", + "START -> request_approval -> perform_action -> END\n", + "```\n", + "\n", + "其中 `request_approval` 会暂停,等待用户输入 `yes` 或 `no`。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7085b2d5", + "metadata": {}, + "outputs": [], + "source": [ + "from typing_extensions import TypedDict\n", + "from langgraph.graph import StateGraph, START, END\n", + "from langgraph.types import interrupt, Command\n", + "from langgraph.checkpoint.memory import MemorySaver\n", + "\n", + "class ApprovalState(TypedDict):\n", + " task: str\n", + " approved: bool\n", + " result: str\n", + "\n", + "def request_approval(state: ApprovalState):\n", + " answer = interrupt({\n", + " 'question': '是否允许执行这个任务?',\n", + " 'task': state['task'],\n", + " 'options': ['yes', 'no']\n", + " })\n", + " return {'approved': answer == 'yes'}\n", + "\n", + "def perform_action(state: ApprovalState):\n", + " if state['approved']:\n", + " return {'result': f'任务已执行:{state[\"task\"]}'}\n", + " return {'result': f'任务已取消:{state[\"task\"]}'}\n", + "\n", + "builder = StateGraph(ApprovalState)\n", + "builder.add_node('request_approval', request_approval)\n", + "builder.add_node('perform_action', perform_action)\n", + "\n", + "builder.add_edge(START, 'request_approval')\n", + "builder.add_edge('request_approval', 'perform_action')\n", + "builder.add_edge('perform_action', END)\n", + "\n", + "memory = MemorySaver()\n", + "graph = builder.compile(checkpointer=memory)\n", + "\n", + "config = {'configurable': {'thread_id': 'approval-demo'}}\n", + "\n", + "first_result = graph.invoke(\n", + " {'task': '发送项目周报邮件', 'approved': False, 'result': ''},\n", + " config=config\n", + ")\n", + "\n", + "print(first_result)" + ] + }, + { + "cell_type": "markdown", + "id": "07b99ca8", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这个例子第一次使用了 `interrupt`,所以需要重点理解。\n", + "\n", + "#### `ApprovalState`\n", + "状态里有三个字段:\n", + "\n", + "- `task`:准备执行的任务\n", + "- `approved`:人工是否批准\n", + "- `result`:最终执行结果\n", + "\n", + "也就是说,整张图围绕一个任务展开,先确认,再执行或取消。\n", + "\n", + "#### `request_approval` 节点\n", + "这个节点的核心代码是:\n", + "\n", + "```python\n", + "answer = interrupt({...})\n", + "```\n", + "\n", + "当代码执行到这里时,图不会继续往下走,而是暂停下来。\n", + "\n", + "`interrupt` 里面传入的是要展示给人的信息。这里包括:\n", + "\n", + "- 问题:是否允许执行这个任务\n", + "- 任务内容:`state['task']`\n", + "- 可选项:`yes` 或 `no`\n", + "\n", + "#### 为什么 `interrupt` 后面还有 return\n", + "这行代码:\n", + "\n", + "```python\n", + "return {'approved': answer == 'yes'}\n", + "```\n", + "\n", + "不是第一次暂停时立刻执行完成的。\n", + "\n", + "第一次运行到 `interrupt` 时,图会停住。等我们用 `Command(resume='yes')` 恢复时,`interrupt` 才会把 `'yes'` 作为返回值交给 `answer`,然后继续执行后面的 `return`。\n", + "\n", + "#### `perform_action` 节点\n", + "这个节点根据 `approved` 决定结果:\n", + "\n", + "- 如果 `approved=True`,说明人工批准,任务执行\n", + "- 如果 `approved=False`,说明人工拒绝,任务取消\n", + "\n", + "#### 为什么需要 `MemorySaver`\n", + "因为图会暂停。暂停之后,系统必须记住:\n", + "\n", + "- 当前执行到哪个节点\n", + "- 当前状态是什么\n", + "- 后面应该从哪里继续\n", + "\n", + "这些信息都需要检查点保存。`MemorySaver` 就是一个内存版检查点保存器。\n", + "\n", + "#### 第一次运行会得到什么\n", + "第一次 `invoke` 不会直接得到最终结果,而是会得到一个包含中断信息的结果。\n", + "\n", + "这表示图已经暂停,正在等待人工输入。" + ] + }, + { + "cell_type": "markdown", + "id": "185c4df9", + "metadata": {}, + "source": [ + "## 5. 恢复流程:用 `Command(resume=...)` 传回人工决定\n", + "\n", + "上一步图已经暂停。现在我们模拟人工选择 `yes`,让流程继续执行。\n", + "\n", + "恢复时要使用同一个 `config`,尤其是同一个 `thread_id`。\n", + "\n", + "因为 LangGraph 需要根据 `thread_id` 找回刚才暂停的那一次运行。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0c18357d", + "metadata": {}, + "outputs": [], + "source": [ + "second_result = graph.invoke(\n", + " Command(resume='yes'),\n", + " config=config\n", + ")\n", + "\n", + "print(second_result)" + ] + }, + { + "cell_type": "markdown", + "id": "31abd5f8", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这一段代码负责恢复刚才暂停的流程。\n", + "\n", + "#### `Command(resume='yes')`\n", + "它的意思是:把人工输入 `'yes'` 传回给刚才的 `interrupt`。\n", + "\n", + "也就是说,前面这行代码:\n", + "\n", + "```python\n", + "answer = interrupt(...)\n", + "```\n", + "\n", + "会在恢复后得到:\n", + "\n", + "```python\n", + "answer = 'yes'\n", + "```\n", + "\n", + "然后节点继续执行:\n", + "\n", + "```python\n", + "return {'approved': answer == 'yes'}\n", + "```\n", + "\n", + "于是 `approved` 会变成 `True`。\n", + "\n", + "#### 为什么还要传 `config=config`\n", + "因为恢复流程必须知道要恢复哪一次暂停。\n", + "\n", + "`thread_id='approval-demo'` 就像这次流程的编号。\n", + "\n", + "如果换了另一个 `thread_id`,LangGraph 就找不到刚才暂停的位置。\n", + "\n", + "#### 恢复后的执行路径\n", + "恢复后流程会继续:\n", + "\n", + "```\n", + "request_approval -> perform_action -> END\n", + "```\n", + "\n", + "最终会得到:\n", + "\n", + "```text\n", + "任务已执行:发送项目周报邮件\n", + "```\n", + "\n", + "这就是最基本的人机协作流程。" + ] + }, + { + "cell_type": "markdown", + "id": "1fd00b84", + "metadata": {}, + "source": [ + "## 6. 第二个例子:人工补充缺失信息\n", + "\n", + "人机协作不只是审批,也可以用来补充信息。\n", + "\n", + "例如用户想让智能体写邮件,但没有提供收件人。\n", + "\n", + "流程可以这样设计:\n", + "\n", + "```\n", + "START -> check_info -> draft_email -> END\n", + "```\n", + "\n", + "如果缺少收件人,`check_info` 就暂停,让用户补充。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4000512e", + "metadata": {}, + "outputs": [], + "source": [ + "from typing_extensions import TypedDict\n", + "from langgraph.graph import StateGraph, START, END\n", + "from langgraph.types import interrupt, Command\n", + "from langgraph.checkpoint.memory import MemorySaver\n", + "\n", + "class EmailState(TypedDict):\n", + " topic: str\n", + " recipient: str\n", + " email_draft: str\n", + "\n", + "def check_info(state: EmailState):\n", + " if state['recipient']:\n", + " return {}\n", + "\n", + " recipient = interrupt({\n", + " 'question': '请补充邮件收件人',\n", + " 'current_topic': state['topic']\n", + " })\n", + " return {'recipient': recipient}\n", + "\n", + "def draft_email(state: EmailState):\n", + " draft = f'''收件人:{state[\"recipient\"]}\n", + "主题:{state[\"topic\"]}\n", + "\n", + "你好,\n", + "这里是一封关于“{state[\"topic\"]}”的邮件草稿。\n", + "请根据具体情况补充正文细节。\n", + "'''\n", + " return {'email_draft': draft}\n", + "\n", + "builder = StateGraph(EmailState)\n", + "builder.add_node('check_info', check_info)\n", + "builder.add_node('draft_email', draft_email)\n", + "\n", + "builder.add_edge(START, 'check_info')\n", + "builder.add_edge('check_info', 'draft_email')\n", + "builder.add_edge('draft_email', END)\n", + "\n", + "email_graph = builder.compile(checkpointer=MemorySaver())\n", + "email_config = {'configurable': {'thread_id': 'email-demo'}}\n", + "\n", + "first_result = email_graph.invoke(\n", + " {'topic': '下周项目会议安排', 'recipient': '', 'email_draft': ''},\n", + " config=email_config\n", + ")\n", + "\n", + "print(first_result)" + ] + }, + { + "cell_type": "markdown", + "id": "84056ad6", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这个例子展示了人机协作的另一种常见用途:补充缺失信息。\n", + "\n", + "#### `EmailState`\n", + "状态包含三个字段:\n", + "\n", + "- `topic`:邮件主题\n", + "- `recipient`:邮件收件人\n", + "- `email_draft`:生成的邮件草稿\n", + "\n", + "#### `check_info` 节点\n", + "这个节点负责检查信息是否完整。\n", + "\n", + "如果 `recipient` 已经有值,就返回 `{}`,表示不需要修改状态。\n", + "\n", + "如果 `recipient` 为空,就执行:\n", + "\n", + "```python\n", + "recipient = interrupt({...})\n", + "```\n", + "\n", + "这会暂停流程,并向用户请求收件人信息。\n", + "\n", + "#### 为什么这里不直接报错\n", + "在真实智能体中,缺少信息不一定是错误。\n", + "\n", + "更好的做法是:停下来问人。\n", + "\n", + "这就是人机协作比普通异常处理更适合智能体流程的地方。\n", + "\n", + "#### `draft_email` 节点\n", + "这个节点依赖完整信息生成邮件草稿。\n", + "\n", + "它会读取:\n", + "\n", + "- `state['recipient']`\n", + "- `state['topic']`\n", + "\n", + "然后写入 `email_draft`。\n", + "\n", + "#### 第一次运行为什么会暂停\n", + "因为初始状态中:\n", + "\n", + "```python\n", + "'recipient': ''\n", + "```\n", + "\n", + "所以流程会停在 `check_info`,等待人补充收件人。" + ] + }, + { + "cell_type": "markdown", + "id": "cf5e9b4b", + "metadata": {}, + "source": [ + "## 7. 恢复并补充收件人\n", + "\n", + "现在模拟人工输入收件人,例如:`张经理`。\n", + "\n", + "恢复后,流程会继续进入 `draft_email`,并生成邮件草稿。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "081941b0", + "metadata": {}, + "outputs": [], + "source": [ + "final_result = email_graph.invoke(\n", + " Command(resume='张经理'),\n", + " config=email_config\n", + ")\n", + "\n", + "print(final_result['email_draft'])" + ] + }, + { + "cell_type": "markdown", + "id": "8c7dade8", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这一段代码把人工补充的信息传回给图。\n", + "\n", + "#### `Command(resume='张经理')`\n", + "它会让刚才暂停的 `interrupt` 返回 `'张经理'`。\n", + "\n", + "于是这行代码:\n", + "\n", + "```python\n", + "recipient = interrupt(...)\n", + "```\n", + "\n", + "恢复后就相当于:\n", + "\n", + "```python\n", + "recipient = '张经理'\n", + "```\n", + "\n", + "然后节点返回:\n", + "\n", + "```python\n", + "return {'recipient': recipient}\n", + "```\n", + "\n", + "状态中的收件人就被补上了。\n", + "\n", + "#### 后续为什么能生成邮件草稿\n", + "恢复后,图会继续执行 `draft_email`。\n", + "\n", + "这时状态已经包含:\n", + "\n", + "- `topic='下周项目会议安排'`\n", + "- `recipient='张经理'`\n", + "\n", + "所以就可以生成完整的邮件草稿。\n", + "\n", + "#### 这个例子的重点\n", + "人机协作不是只能做‘同意/不同意’。\n", + "\n", + "它也可以用来让人补充缺失信息,然后让图继续完成后续任务。" + ] + }, + { + "cell_type": "markdown", + "id": "6090d74f", + "metadata": {}, + "source": [ + "## 8. 第三个例子:人工修改 AI 草稿\n", + "\n", + "在很多真实场景中,AI 不一定直接执行最终结果,而是先生成一个草稿,让人修改或确认。\n", + "\n", + "例如:\n", + "\n", + "- AI 生成邮件草稿,人修改后再发送\n", + "- AI 生成计划,人确认后再执行\n", + "- AI 生成 SQL,人审核后再运行\n", + "\n", + "下面我们用一个简单的任务计划示例。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "88a81e02", + "metadata": {}, + "outputs": [], + "source": [ + "from typing_extensions import TypedDict\n", + "from langgraph.graph import StateGraph, START, END\n", + "from langgraph.types import interrupt, Command\n", + "from langgraph.checkpoint.memory import MemorySaver\n", + "\n", + "class PlanState(TypedDict):\n", + " goal: str\n", + " draft_plan: str\n", + " final_plan: str\n", + "\n", + "def create_draft_plan(state: PlanState):\n", + " draft = f'''目标:{state[\"goal\"]}\n", + "步骤 1:明确当前需求\n", + "步骤 2:拆分任务并安排执行顺序\n", + "步骤 3:检查结果并总结经验\n", + "'''\n", + " return {'draft_plan': draft}\n", + "\n", + "def review_plan(state: PlanState):\n", + " revised_plan = interrupt({\n", + " 'question': '请审核并修改计划草稿',\n", + " 'draft_plan': state['draft_plan']\n", + " })\n", + " return {'final_plan': revised_plan}\n", + "\n", + "builder = StateGraph(PlanState)\n", + "builder.add_node('create_draft_plan', create_draft_plan)\n", + "builder.add_node('review_plan', review_plan)\n", + "\n", + "builder.add_edge(START, 'create_draft_plan')\n", + "builder.add_edge('create_draft_plan', 'review_plan')\n", + "builder.add_edge('review_plan', END)\n", + "\n", + "plan_graph = builder.compile(checkpointer=MemorySaver())\n", + "plan_config = {'configurable': {'thread_id': 'plan-demo'}}\n", + "\n", + "review_result = plan_graph.invoke(\n", + " {'goal': '准备一次 LangGraph 分享', 'draft_plan': '', 'final_plan': ''},\n", + " config=plan_config\n", + ")\n", + "\n", + "print(review_result)" + ] + }, + { + "cell_type": "markdown", + "id": "88b97a79", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这个例子展示的是‘AI 先生成草稿,人再修改确认’的模式。\n", + "\n", + "#### `PlanState`\n", + "状态中有三个字段:\n", + "\n", + "- `goal`:目标\n", + "- `draft_plan`:AI 或程序生成的草稿计划\n", + "- `final_plan`:人工确认后的最终计划\n", + "\n", + "#### `create_draft_plan` 节点\n", + "这个节点根据目标生成一个初步计划。\n", + "\n", + "这里为了教学简单,计划是用模板字符串生成的。\n", + "\n", + "在真实项目中,这一步也可以换成大模型生成。\n", + "\n", + "#### `review_plan` 节点\n", + "这个节点调用 `interrupt`,把草稿交给人审核。\n", + "\n", + "传给人的信息包括:\n", + "\n", + "- 问题:请审核并修改计划草稿\n", + "- 当前草稿:`state['draft_plan']`\n", + "\n", + "人可以直接接受,也可以修改后再返回。\n", + "\n", + "#### 为什么返回 `final_plan`\n", + "AI 生成的是草稿,不一定等于最终结果。\n", + "\n", + "人工修改后的内容才写入 `final_plan`。\n", + "\n", + "这能清楚地区分:\n", + "\n", + "- AI 草稿是什么\n", + "- 人工最终确认的版本是什么\n", + "\n", + "这在真实工作流中非常重要,因为最终责任通常属于人工确认后的结果。" + ] + }, + { + "cell_type": "markdown", + "id": "f0da01c5", + "metadata": {}, + "source": [ + "## 9. 恢复并提交人工修改后的计划\n", + "\n", + "现在模拟人工审核后,提交一个修改版计划。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "31d862b2", + "metadata": {}, + "outputs": [], + "source": [ + "human_revised_plan = '''目标:准备一次 LangGraph 分享\n", + "步骤 1:先介绍图结构、状态和边的概念\n", + "步骤 2:演示一个最小可运行的 LangGraph 示例\n", + "步骤 3:重点讲解人机协作中的 interrupt 和 resume\n", + "步骤 4:最后总结适合人机协作的真实业务场景\n", + "'''\n", + "\n", + "final_plan_result = plan_graph.invoke(\n", + " Command(resume=human_revised_plan),\n", + " config=plan_config\n", + ")\n", + "\n", + "print(final_plan_result['final_plan'])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码模拟人工修改草稿后,把最终版本交回给图。\n", + "\n", + "#### `human_revised_plan`\n", + "这是人工修改后的计划。\n", + "\n", + "它和原始草稿相比,更具体,也更贴近分享主题。\n", + "\n", + "#### `Command(resume=human_revised_plan)`\n", + "这表示把人工修改后的计划传回给 `interrupt`。\n", + "\n", + "恢复后,`review_plan` 节点会继续执行:\n", + "\n", + "```python\n", + "return {'final_plan': revised_plan}\n", + "```\n", + "\n", + "于是最终状态中就有了人工确认后的 `final_plan`。\n", + "\n", + "#### 这个模式有什么用\n", + "这类流程非常适合高风险或高要求任务。\n", + "\n", + "例如:\n", + "\n", + "- AI 生成合同条款,人审核后确认\n", + "- AI 生成客户邮件,人修改后发送\n", + "- AI 生成执行计划,人确认后落地\n", + "\n", + "核心思想是:AI 负责初稿,人负责把关。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 10. 人机协作流程的关键点\n", + "\n", + "设计人机协作流程时,要特别注意下面几点。\n", + "\n", + "### 10.1 暂停点要放在关键位置\n", + "不是每一步都需要人确认。\n", + "\n", + "一般只在这些地方暂停:\n", + "\n", + "- 信息缺失\n", + "- 风险较高\n", + "- 需要业务判断\n", + "- 执行前需要最终确认\n", + "\n", + "### 10.2 给人的信息要清楚\n", + "`interrupt` 传出的内容应该让人一眼看懂:\n", + "\n", + "- 当前要做什么\n", + "- 为什么要暂停\n", + "- 希望人提供什么\n", + "- 可选项有哪些\n", + "\n", + "### 10.3 恢复时必须使用同一个线程\n", + "恢复流程时,必须使用同一个 `thread_id`。\n", + "\n", + "否则图找不到之前暂停的位置。\n", + "\n", + "### 10.4 不要滥用人工确认\n", + "如果每一步都让人确认,智能体就会变得很繁琐。\n", + "\n", + "合理做法是:低风险步骤自动执行,高风险步骤人工把关。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 11. 人机协作和普通 input() 的区别\n", + "\n", + "你可能会问:为什么不直接用 Python 的 `input()`?\n", + "\n", + "因为 LangGraph 的 `interrupt` 不只是简单输入,它更适合智能体工作流。\n", + "\n", + "| 对比项 | `input()` | `interrupt` |\n", + "| --- | --- | --- |\n", + "| 是否适合 notebook / 服务端流程 | 不太适合 | 更适合 |\n", + "| 是否能保存暂停状态 | 不能 | 可以,依赖检查点 |\n", + "| 是否能恢复到图中原位置 | 不能 | 可以 |\n", + "| 是否适合多用户多会话 | 弱 | 强,配合 `thread_id` |\n", + "| 是否和 LangGraph 状态集成 | 否 | 是 |\n", + "\n", + "所以在人机协作智能体中,推荐使用 `interrupt`,而不是普通 `input()`。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 12. 本节小结\n", + "\n", + "本节最重要的内容有五点:\n", + "\n", + "1. **人机协作是让人在关键节点参与智能体流程**\n", + "2. **`interrupt` 用于暂停流程,并把需要人工处理的信息交出去**\n", + "3. **`Command(resume=...)` 用于把人工输入传回图中,并恢复执行**\n", + "4. **暂停和恢复必须依赖检查点,通常会配合 `MemorySaver` 使用**\n", + "5. **人工介入适合审批、补充信息、修改草稿和高风险操作确认**\n", + "\n", + "掌握人机协作后,智能体就不再只是自动执行脚本,而可以变成一个真正能和人配合工作的流程系统。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 13. 本节练习\n", + "\n", + "1. 修改第一个示例,把任务改成‘删除临时文件’,观察批准和拒绝的结果\n", + "2. 修改第二个示例,让缺少邮件主题时也触发一次人工补充\n", + "3. 修改第三个示例,让人工可以输入 `accept` 表示直接接受草稿\n", + "4. 思考:哪些智能体操作必须经过人工确认?\n", + "5. 思考:如果一个系统有多个用户同时使用,为什么 `thread_id` 很重要?" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.4" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/24_项目实战一.ipynb b/24_项目实战一.ipynb new file mode 100644 index 0000000..6304329 --- /dev/null +++ b/24_项目实战一.ipynb @@ -0,0 +1,662 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 24_项目实战一:个人知识库问答助手\n", + "\n", + "## 学习目标\n", + "1. 综合运用 LangChain 和 LangGraph 知识解决实际问题\n", + "2. 掌握项目需求分析、模块划分和技术选型\n", + "3. 开始构建第一个综合项目:个人知识库问答助手\n", + "\n", + "本节课会从项目角度出发,带大家一步步设计一个“个人知识库问答助手”。它的核心能力是:**用户上传或准备一些资料,助手根据资料内容回答问题,而不是凭空编答案**。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. 项目背景:为什么需要个人知识库问答助手\n", + "\n", + "日常学习和工作中,我们经常会有很多资料,例如:\n", + "\n", + "- 课程笔记\n", + "- 项目文档\n", + "- 公司制度\n", + "- 技术文章\n", + "- 会议记录\n", + "\n", + "如果资料很多,直接人工查找会很慢。个人知识库问答助手要解决的问题就是:\n", + "\n", + "> 用户提出问题,系统先从资料中找到相关内容,再基于这些内容生成回答。\n", + "\n", + "这类系统通常使用 RAG 技术。RAG 的全称是 Retrieval-Augmented Generation,中文可以理解为“检索增强生成”。\n", + "\n", + "通俗地说:\n", + "\n", + "1. 先查资料\n", + "2. 再带着资料回答\n", + "\n", + "这样可以减少大模型胡编乱造的问题。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. 需求分析\n", + "\n", + "做项目之前,不要急着写代码。第一步应该先想清楚:这个项目到底要解决什么问题。\n", + "\n", + "本项目的基础需求如下:\n", + "\n", + "| 需求 | 说明 |\n", + "| --- | --- |\n", + "| 导入资料 | 准备一批文本资料,作为知识库内容 |\n", + "| 文本切分 | 把长文档切成小段,方便检索 |\n", + "| 内容检索 | 根据用户问题找到最相关的资料片段 |\n", + "| 生成回答 | 根据检索到的资料组织答案 |\n", + "| 返回依据 | 告诉用户答案主要参考了哪些内容 |\n", + "\n", + "第一版项目先做最小可用版本,也就是 MVP。我们先不处理复杂文件上传,也不接入真实数据库,而是用少量文本模拟知识库。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. 项目整体流程\n", + "\n", + "个人知识库问答助手的流程可以画成下面这样:\n", + "\n", + "```text\n", + "准备文档 -> 切分文档 -> 建立索引 -> 用户提问 -> 检索相关片段 -> 生成答案\n", + "```\n", + "\n", + "如果用生活中的例子理解:\n", + "\n", + "- 准备文档:把书放进书架\n", + "- 切分文档:给书按章节拆开\n", + "- 建立索引:做目录和标签\n", + "- 用户提问:用户问“某个知识点是什么”\n", + "- 检索相关片段:先翻目录找到相关章节\n", + "- 生成答案:根据章节内容整理成自然语言回答\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. 技术选型\n", + "\n", + "在真实项目中,可以使用下面这些技术:\n", + "\n", + "| 模块 | 可选技术 | 作用 |\n", + "| --- | --- | --- |\n", + "| 文档加载 | LangChain DocumentLoader | 读取 PDF、Markdown、网页等资料 |\n", + "| 文本切分 | RecursiveCharacterTextSplitter | 把长文本切成小块 |\n", + "| 向量模型 | Embeddings | 把文本转成向量 |\n", + "| 向量数据库 | FAISS、Chroma | 存储并检索相似文本 |\n", + "| 大模型 | ChatOpenAI 或其他模型 | 根据资料生成答案 |\n", + "| 流程编排 | LangGraph | 把检索、回答、检查等步骤组织成工作流 |\n", + "\n", + "为了让代码更容易运行,本节先用纯 Python 实现一个简化版 RAG。理解原理后,再过渡到 LangChain 和 LangGraph。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. 第一步:准备模拟知识库\n", + "\n", + "我们先不用读取外部文件,而是在代码中准备几段文本,模拟个人知识库中的资料。\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "documents = [\n", + " \"LangChain 是一个用于开发大模型应用的框架,它提供模型调用、提示词模板、文档加载、检索问答等能力。\",\n", + " \"LangGraph 是 LangChain 生态中的流程编排框架,适合构建多步骤、有状态、可分支的智能体应用。\",\n", + " \"RAG 是检索增强生成技术,核心流程是先从知识库中检索相关资料,再让大模型基于资料生成答案。\",\n", + " \"个人知识库问答助手可以帮助用户从自己的笔记、文档和资料中快速找到答案,减少人工查找成本。\",\n", + " \"向量数据库用于存储文本向量,并根据相似度快速找到与用户问题最相关的文本片段。\",\n", + "]\n", + "\n", + "print(f\"知识库中共有 {len(documents)} 条资料\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码完成了知识库的准备工作:\n", + "\n", + "1. `documents` 是一个列表,列表里的每个字符串代表一条资料。\n", + "2. 这里的资料围绕 LangChain、LangGraph、RAG、个人知识库和向量数据库。\n", + "3. `len(documents)` 用来统计资料数量。\n", + "4. 在真实项目中,`documents` 通常不是手写的,而是从 PDF、Markdown、Word、网页或数据库中读取出来的。\n", + "\n", + "这一步对应项目流程中的“准备文档”。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. 第二步:文本切分\n", + "\n", + "真实文档可能很长,如果直接把整篇文档拿去检索,效果通常不好。\n", + "\n", + "所以我们需要把长文本切成较小的片段。这里用一个简单函数演示文本切分。\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def split_text(text, chunk_size=30):\n", + " chunks = []\n", + " for start in range(0, len(text), chunk_size):\n", + " chunk = text[start:start + chunk_size]\n", + " chunks.append(chunk)\n", + " return chunks\n", + "\n", + "\n", + "all_chunks = []\n", + "for doc in documents:\n", + " chunks = split_text(doc, chunk_size=30)\n", + " all_chunks.extend(chunks)\n", + "\n", + "for index, chunk in enumerate(all_chunks, start=1):\n", + " print(f\"片段 {index}: {chunk}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码实现了一个简化版文本切分器:\n", + "\n", + "1. `split_text(text, chunk_size=30)` 表示把文本按固定长度切分,每段最多 30 个字符。\n", + "2. `range(0, len(text), chunk_size)` 会生成每个片段的起始位置。\n", + "3. `text[start:start + chunk_size]` 使用字符串切片取出一小段文本。\n", + "4. `all_chunks.extend(chunks)` 把每篇文档切出来的片段合并到总列表中。\n", + "5. 最后的循环会打印所有文本片段,方便观察切分效果。\n", + "\n", + "真实项目中,通常会使用 LangChain 的 `RecursiveCharacterTextSplitter`,它比这里的固定长度切分更智能,会尽量按段落、句子等边界切分。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7. 第三步:实现一个简单检索器\n", + "\n", + "标准 RAG 通常会用向量相似度检索。为了让初学者更容易理解,我们先用“关键词重合数量”模拟检索。\n", + "\n", + "规则很简单:用户问题和某个资料片段中重复出现的字越多,就认为它越相关。\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def simple_score(question, chunk):\n", + " question_chars = set(question)\n", + " chunk_chars = set(chunk)\n", + " common_chars = question_chars & chunk_chars\n", + " return len(common_chars)\n", + "\n", + "\n", + "def retrieve(question, chunks, top_k=2):\n", + " scored_chunks = []\n", + " for chunk in chunks:\n", + " score = simple_score(question, chunk)\n", + " scored_chunks.append((score, chunk))\n", + "\n", + " scored_chunks.sort(reverse=True, key=lambda item: item[0])\n", + " return scored_chunks[:top_k]\n", + "\n", + "\n", + "question = \"什么是 RAG?\"\n", + "retrieved_chunks = retrieve(question, all_chunks, top_k=3)\n", + "\n", + "for score, chunk in retrieved_chunks:\n", + " print(f\"相关度分数:{score},内容:{chunk}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码实现了一个简单检索器:\n", + "\n", + "1. `simple_score(question, chunk)` 用来计算问题和资料片段的相关度。\n", + "2. `set(question)` 会把问题中的字符去重后放入集合。\n", + "3. `question_chars & chunk_chars` 表示取两个集合的交集,也就是问题和片段中共同出现的字符。\n", + "4. `len(common_chars)` 用共同字符数量作为相关度分数。\n", + "5. `retrieve(question, chunks, top_k=2)` 会给所有片段打分,并返回分数最高的前 `top_k` 个。\n", + "6. `scored_chunks.sort(reverse=True, key=lambda item: item[0])` 表示按照分数从高到低排序。\n", + "\n", + "这个检索器很简单,但它帮助我们理解了检索阶段的本质:**从大量资料中找出最可能有用的内容**。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 8. 第四步:根据检索结果生成答案\n", + "\n", + "在真实 RAG 系统中,生成答案通常由大模型完成。\n", + "\n", + "这里为了避免依赖外部 API,我们先用一个函数模拟回答生成:把检索到的资料整理成答案。\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def generate_answer(question, retrieved_chunks):\n", + " context = \"\".join([chunk for score, chunk in retrieved_chunks])\n", + " answer = f\"问题:{question}\\n\\n\"\n", + " answer += \"根据知识库中检索到的内容,可以参考以下信息回答:\\n\"\n", + " answer += context\n", + " answer += \"\\n\\n总结:以上内容是从个人知识库中找到的相关资料,回答时应优先依据这些资料。\"\n", + " return answer\n", + "\n", + "\n", + "answer = generate_answer(question, retrieved_chunks)\n", + "print(answer)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码模拟了回答生成过程:\n", + "\n", + "1. `generate_answer(question, retrieved_chunks)` 接收用户问题和检索结果。\n", + "2. `[chunk for score, chunk in retrieved_chunks]` 从检索结果中取出文本片段,忽略分数。\n", + "3. `'\\n'.join(...)` 把多个片段用换行符拼接成上下文。\n", + "4. `answer += ...` 逐步拼接最终回答内容。\n", + "5. 真实项目中,这一步通常会把 `question` 和 `context` 放进提示词,然后交给大模型生成更自然、更完整的回答。\n", + "\n", + "这一阶段的关键原则是:**回答要基于检索到的资料,而不是凭空生成**。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 9. 封装成一个完整问答函数\n", + "\n", + "现在我们已经有了文档片段、检索器和回答生成函数。下一步把它们封装成一个完整的 `ask_knowledge_base` 函数。\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def ask_knowledge_base(question, chunks, top_k=3):\n", + " retrieved = retrieve(question, chunks, top_k=top_k)\n", + " answer = generate_answer(question, retrieved)\n", + " return {\n", + " \"question\": question,\n", + " \"retrieved\": retrieved,\n", + " \"answer\": answer,\n", + " }\n", + "\n", + "\n", + "result = ask_knowledge_base(\"LangGraph 适合做什么?\", all_chunks)\n", + "print(result[\"answer\"])\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码把前面的步骤组合成一个完整功能:\n", + "\n", + "1. `ask_knowledge_base` 是问答助手的主入口。用户只需要传入问题。\n", + "2. `retrieve(question, chunks, top_k=top_k)` 先从知识库片段中找相关内容。\n", + "3. `generate_answer(question, retrieved)` 再根据检索结果生成答案。\n", + "4. 函数返回一个字典,里面包含原问题、检索结果和最终答案。\n", + "5. 返回检索结果的好处是:后续可以展示“参考来源”,让用户知道答案从哪里来。\n", + "\n", + "到这里,我们已经完成了一个最小版本的个人知识库问答助手。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 10. 用 LangChain 思路改造项目结构\n", + "\n", + "虽然上面的代码是纯 Python,但它已经对应了 LangChain RAG 的主要模块。\n", + "\n", + "| 当前示例 | LangChain 中常见模块 |\n", + "| --- | --- |\n", + "| `documents` | DocumentLoader 加载出的文档 |\n", + "| `split_text` | TextSplitter |\n", + "| `retrieve` | Retriever |\n", + "| `generate_answer` | LLMChain 或 LCEL 链 |\n", + "| `ask_knowledge_base` | 完整 RAG Chain |\n", + "\n", + "所以学习项目时不要只记 API,更重要的是理解每个模块承担什么职责。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 11. LangChain 版伪代码\n", + "\n", + "下面给出一个接近真实项目的 LangChain 写法。由于不同环境中的模型和 API Key 不一定相同,这段代码作为结构参考,不要求直接运行。\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 下面是 LangChain RAG 的典型结构示例,作为项目结构参考\n", + "\n", + "# from langchain_text_splitters import RecursiveCharacterTextSplitter\n", + "# from langchain_community.vectorstores import FAISS\n", + "# from langchain_openai import OpenAIEmbeddings, ChatOpenAI\n", + "# from langchain_core.prompts import ChatPromptTemplate\n", + "\n", + "# text_splitter = RecursiveCharacterTextSplitter(\n", + "# chunk_size=500,\n", + "# chunk_overlap=50,\n", + "# )\n", + "# docs = text_splitter.create_documents(documents)\n", + "\n", + "# embeddings = OpenAIEmbeddings(model=\"qwen3-embedding\")\n", + "# vectorstore = FAISS.from_documents(docs, embeddings)\n", + "# retriever = vectorstore.as_retriever(search_kwargs={\"k\": 3})\n", + "\n", + "# prompt = ChatPromptTemplate.from_template(\"\"\"\n", + "# 请根据下面的资料回答用户问题。\n", + "# 如果资料中没有答案,请说明不知道,不要编造。\n", + "\n", + "# 资料:\n", + "# {context}\n", + "\n", + "# 问题:\n", + "# {question}\n", + "# \"\"\")\n", + "\n", + "# llm = ChatOpenAI(model=\"qwen3.6-35b-A3b\")\n", + "# chain = prompt | llm\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码展示了真实 LangChain 项目的常见结构:\n", + "\n", + "1. `RecursiveCharacterTextSplitter` 用于文本切分,`chunk_size` 控制每块大小,`chunk_overlap` 控制相邻片段之间的重叠内容。\n", + "2. `FAISS.from_documents(docs, embeddings)` 会把文档转换成向量并存入 FAISS 向量库。\n", + "3. `vectorstore.as_retriever(search_kwargs={\"k\": 3})` 会创建检索器,每次返回最相关的 3 个片段。\n", + "4. `ChatPromptTemplate.from_template(...)` 定义提示词模板,要求模型必须根据资料回答。\n", + "5. `prompt | llm` 是 LangChain 表达式语言 LCEL 的写法,表示把提示词输出交给大模型。\n", + "\n", + "这段代码被注释掉,是因为它依赖真实模型、Embedding 服务和相关包。课程中先理解结构,后续再接入真实模型即可。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 12. 用 LangGraph 设计项目工作流\n", + "\n", + "如果项目流程变复杂,例如要增加问题改写、检索质量检查、答案审核,就可以使用 LangGraph 编排流程。\n", + "\n", + "一个基础工作流可以设计为:\n", + "\n", + "```text\n", + "用户问题 -> 检索节点 -> 回答生成节点 -> 输出结果\n", + "```\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from typing import List, Tuple, TypedDict\n", + "\n", + "\n", + "class QAState(TypedDict):\n", + " question: str\n", + " chunks: List[str]\n", + " retrieved: List[Tuple[int, str]]\n", + " answer: str\n", + "\n", + "\n", + "def retrieve_node(state: QAState):\n", + " retrieved = retrieve(state[\"question\"], state[\"chunks\"], top_k=3)\n", + " return {\"retrieved\": retrieved}\n", + "\n", + "\n", + "def answer_node(state: QAState):\n", + " answer = generate_answer(state[\"question\"], state[\"retrieved\"])\n", + " return {\"answer\": answer}\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码先定义 LangGraph 工作流需要的状态和节点:\n", + "\n", + "1. `QAState` 表示问答流程中的共享数据。\n", + "2. `question` 保存用户问题。\n", + "3. `chunks` 保存知识库切分后的文本片段。\n", + "4. `retrieved` 保存检索结果,每个结果包含分数和文本片段。\n", + "5. `answer` 保存最终答案。\n", + "6. `retrieve_node` 是检索节点,负责调用前面写好的 `retrieve` 函数。\n", + "7. `answer_node` 是回答节点,负责调用 `generate_answer` 生成答案。\n", + "\n", + "可以看到,LangGraph 节点并不神秘,本质上就是接收 State、处理数据、返回更新字段的普通函数。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 13. 构建 LangGraph 工作流\n", + "\n", + "下面把检索节点和回答节点连接起来。\n", + "\n", + "如果当前环境没有安装 LangGraph,可以先运行:`%pip install langgraph`。\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 如果没有安装 LangGraph,取消下一行注释后运行\n", + "# %pip install langgraph\n", + "\n", + "from langgraph.graph import END, START, StateGraph\n", + "\n", + "\n", + "qa_workflow = StateGraph(QAState)\n", + "\n", + "qa_workflow.add_node(\"retrieve\", retrieve_node)\n", + "qa_workflow.add_node(\"answer\", answer_node)\n", + "\n", + "qa_workflow.add_edge(START, \"retrieve\")\n", + "qa_workflow.add_edge(\"retrieve\", \"answer\")\n", + "qa_workflow.add_edge(\"answer\", END)\n", + "\n", + "qa_app = qa_workflow.compile()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码把问答流程编排成 LangGraph 工作流:\n", + "\n", + "1. `StateGraph(QAState)` 创建一张基于 `QAState` 的流程图。\n", + "2. `add_node(\"retrieve\", retrieve_node)` 添加检索节点。\n", + "3. `add_node(\"answer\", answer_node)` 添加回答生成节点。\n", + "4. `add_edge(START, \"retrieve\")` 表示从检索节点开始。\n", + "5. `add_edge(\"retrieve\", \"answer\")` 表示检索完成后进入回答节点。\n", + "6. `add_edge(\"answer\", END)` 表示回答生成后流程结束。\n", + "7. `compile()` 把流程图编译成可以调用的 `qa_app`。\n", + "\n", + "这就是一个最小 LangGraph 版个人知识库问答流程。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 14. 运行 LangGraph 问答助手\n", + "\n", + "现在传入问题和知识库片段,运行整个工作流。\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "initial_state = {\n", + " \"question\": \"个人知识库问答助手有什么作用?\",\n", + " \"chunks\": all_chunks,\n", + " \"retrieved\": [],\n", + " \"answer\": \"\",\n", + "}\n", + "\n", + "final_state = qa_app.invoke(initial_state)\n", + "print(final_state[\"answer\"])\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码运行了 LangGraph 工作流:\n", + "\n", + "1. `initial_state` 是初始状态,包含用户问题、知识库片段、空的检索结果和空答案。\n", + "2. `qa_app.invoke(initial_state)` 启动工作流。\n", + "3. 数据会先进入 `retrieve_node`,生成 `retrieved`。\n", + "4. 然后进入 `answer_node`,生成 `answer`。\n", + "5. 最终返回 `final_state`,我们打印其中的 `answer`。\n", + "\n", + "这个例子说明:LangGraph 适合把多个处理步骤组织成清晰、可维护的项目流程。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 15. 项目可以如何继续升级\n", + "\n", + "当前版本只是教学版 MVP,后续可以从这些方向升级:\n", + "\n", + "1. 支持读取 PDF、Markdown、Word 等真实文件。\n", + "2. 使用真实 Embedding 模型,把关键词检索升级为向量检索。\n", + "3. 使用 Chroma 或 FAISS 保存向量索引。\n", + "4. 接入真实大模型,生成更自然的回答。\n", + "5. 增加“无法回答”判断,避免资料不足时强行回答。\n", + "6. 增加来源展示,让用户看到答案引用了哪些资料。\n", + "7. 使用 LangGraph 增加问题改写、答案审核、多轮追问等节点。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 16. 小结\n", + "\n", + "本节课完成了个人知识库问答助手的第一版项目设计和实现。\n", + "\n", + "你需要重点掌握:\n", + "\n", + "1. 项目开发要先做需求分析,再写代码。\n", + "2. RAG 的核心流程是:准备资料、切分资料、检索资料、生成答案。\n", + "3. LangChain 更适合提供文档加载、切分、检索、模型调用等组件。\n", + "4. LangGraph 更适合把多个步骤编排成清晰工作流。\n", + "5. 初学项目时,可以先用纯 Python 理解原理,再逐步替换成成熟框架。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 17. 练习题\n", + "\n", + "1. 往 `documents` 中新增 3 条自己的学习笔记,然后重新运行问答流程。\n", + "2. 修改 `top_k`,观察返回 1 条、3 条、5 条资料时答案有什么变化。\n", + "3. 修改 `simple_score`,让它按词语而不是按字符计算相关度。\n", + "4. 在 `generate_answer` 中增加“参考资料”部分,把检索到的片段编号展示出来。\n", + "5. 给 LangGraph 工作流新增一个 `check_node`,判断是否检索到了相关资料。\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.4" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/25_项目实战二.ipynb b/25_项目实战二.ipynb new file mode 100644 index 0000000..b91c9da --- /dev/null +++ b/25_项目实战二.ipynb @@ -0,0 +1,772 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 25_项目实战二:多工具协作型智能体\n", + "\n", + "## 学习目标\n", + "1. 掌握多工具协作型智能体的项目开发方法\n", + "2. 实现结合搜索引擎、计算工具和文件读取的复合型智能体\n", + "3. 学会调试和优化智能体的执行流程\n", + "\n", + "本节课会完成一个小型项目:构建一个可以根据用户问题自动选择工具的智能体。它可以模拟搜索资料、执行计算、读取文件,并把结果整理成最终回答。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. 什么是多工具协作型智能体\n", + "\n", + "普通问答智能体主要依靠大模型自身回答问题,但大模型并不适合完成所有事情。\n", + "\n", + "例如:\n", + "\n", + "- 想知道最新信息,需要搜索工具\n", + "- 想做精确数学计算,需要计算工具\n", + "- 想分析本地资料,需要文件读取工具\n", + "- 想保存结果,需要写文件工具\n", + "\n", + "多工具协作型智能体就像一个会安排工作的助理:它先理解用户问题,再判断该用哪个工具,最后把工具结果整理成答案。\n", + "\n", + "通俗地说,它的核心流程是:\n", + "\n", + "```text\n", + "用户问题 -> 判断需要什么工具 -> 调用工具 -> 整理工具结果 -> 返回答案\n", + "```\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. 项目需求分析\n", + "\n", + "本项目要实现一个教学版多工具智能体。为了让代码容易运行,我们先不用真实搜索引擎和真实文件系统,而是用 Python 函数模拟工具。\n", + "\n", + "项目需求如下:\n", + "\n", + "| 需求 | 说明 |\n", + "| --- | --- |\n", + "| 搜索工具 | 根据关键词返回模拟搜索结果 |\n", + "| 计算工具 | 执行简单数学表达式 |\n", + "| 文件读取工具 | 从模拟文件中读取内容 |\n", + "| 工具选择 | 根据用户问题判断应该调用哪个工具 |\n", + "| 结果汇总 | 把工具返回结果整理为自然语言答案 |\n", + "\n", + "第一版项目重点不是做得复杂,而是把“工具定义、工具选择、工具调用、结果汇总”的完整流程跑通。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. 项目模块划分\n", + "\n", + "我们把项目拆成 5 个模块:\n", + "\n", + "1. 定义工具:准备搜索、计算、文件读取函数\n", + "2. 注册工具:把工具放入统一的工具表\n", + "3. 选择工具:根据用户问题决定用哪个工具\n", + "4. 执行工具:调用对应函数并拿到结果\n", + "5. 汇总答案:把工具执行结果整理给用户\n", + "\n", + "这种拆法的好处是每个模块只负责一件事,后续调试和扩展都更方便。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. 第一步:定义搜索工具\n", + "\n", + "真实项目中,搜索工具可能会调用搜索引擎 API。这里为了教学简单,用一个字典模拟搜索数据库。\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "search_database = {\n", + " \"LangChain\": \"LangChain 是用于开发大模型应用的框架,常用于提示词、链、工具和 RAG 应用开发。\",\n", + " \"LangGraph\": \"LangGraph 是用于构建有状态、多步骤、可分支智能体流程的框架。\",\n", + " \"RAG\": \"RAG 是检索增强生成技术,先检索资料,再基于资料生成答案。\",\n", + "}\n", + "\n", + "\n", + "def search_tool(query):\n", + " for keyword, result in search_database.items():\n", + " if keyword.lower() in query.lower():\n", + " return result\n", + " return \"没有找到相关搜索结果。\"\n", + "\n", + "\n", + "print(search_tool(\"请介绍 LangGraph\"))\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码定义了一个模拟搜索工具:\n", + "\n", + "1. `search_database` 是一个字典,用来模拟搜索引擎中的资料。\n", + "2. 字典的 key 是关键词,例如 `LangChain`、`LangGraph`、`RAG`。\n", + "3. 字典的 value 是搜索结果,也就是工具返回给智能体的信息。\n", + "4. `search_tool(query)` 接收用户查询内容。\n", + "5. `keyword.lower() in query.lower()` 用来做不区分大小写的关键词匹配。\n", + "6. 如果命中关键词,就返回对应搜索结果;如果没有命中,就返回“没有找到相关搜索结果”。\n", + "\n", + "这个工具虽然简单,但已经体现了搜索工具的基本思想:输入查询,返回相关资料。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. 第二步:定义计算工具\n", + "\n", + "大模型有时会算错数,所以精确计算应该交给计算工具。\n", + "\n", + "下面实现一个简单计算器,只允许安全的数学字符,避免执行危险代码。\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def calculator_tool(expression):\n", + " allowed_chars = set(\"0123456789+-*/(). \" )\n", + " if not set(expression) <= allowed_chars:\n", + " return \"表达式中包含不支持的字符。\"\n", + "\n", + " try:\n", + " result = eval(expression)\n", + " return f\"计算结果是:{result}\"\n", + " except Exception as error:\n", + " return f\"计算失败:{error}\"\n", + "\n", + "\n", + "print(calculator_tool(\"128 * 36 + 50\"))\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码定义了一个计算工具:\n", + "\n", + "1. `calculator_tool(expression)` 接收一个数学表达式字符串。\n", + "2. `allowed_chars` 定义允许出现的字符,包括数字、加减乘除、小括号、小数点和空格。\n", + "3. `set(expression) <= allowed_chars` 用来检查表达式中的字符是否都在允许范围内。\n", + "4. `eval(expression)` 会执行数学表达式并得到结果。\n", + "5. `try...except` 用来捕获计算错误,例如表达式格式不正确。\n", + "\n", + "注意:真实项目中要谨慎使用 `eval`。这里已经做了简单字符限制,但生产环境通常会使用更安全的数学表达式解析库。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. 第三步:定义文件读取工具\n", + "\n", + "文件读取工具适合回答和本地资料相关的问题。这里用字典模拟文件内容。\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "fake_files = {\n", + " \"project_plan.txt\": \"项目计划:第一周完成需求分析,第二周完成原型开发,第三周完成测试和优化。\",\n", + " \"meeting_notes.txt\": \"会议纪要:团队决定优先开发知识库问答功能,然后再增加多工具调用能力。\",\n", + "}\n", + "\n", + "\n", + "def file_reader_tool(file_name):\n", + " if file_name in fake_files:\n", + " return fake_files[file_name]\n", + " return \"没有找到这个文件。\"\n", + "\n", + "\n", + "print(file_reader_tool(\"project_plan.txt\"))\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码定义了一个模拟文件读取工具:\n", + "\n", + "1. `fake_files` 是模拟文件系统,key 是文件名,value 是文件内容。\n", + "2. `file_reader_tool(file_name)` 接收文件名。\n", + "3. 如果文件名存在于 `fake_files` 中,就返回对应文件内容。\n", + "4. 如果文件名不存在,就返回提示信息。\n", + "\n", + "真实项目中,这个工具可以改成读取本地文件、数据库记录、对象存储文件或企业文档系统。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7. 第四步:注册工具\n", + "\n", + "如果工具越来越多,不能每次都手动写大量 if 语句。更好的方式是把工具统一注册到一个工具表中。\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "tools = {\n", + " \"search\": {\n", + " \"name\": \"搜索工具\",\n", + " \"description\": \"用于查询 LangChain、LangGraph、RAG 等知识。\",\n", + " \"function\": search_tool,\n", + " },\n", + " \"calculator\": {\n", + " \"name\": \"计算工具\",\n", + " \"description\": \"用于执行数学表达式计算。\",\n", + " \"function\": calculator_tool,\n", + " },\n", + " \"file_reader\": {\n", + " \"name\": \"文件读取工具\",\n", + " \"description\": \"用于读取模拟文件内容。\",\n", + " \"function\": file_reader_tool,\n", + " },\n", + "}\n", + "\n", + "print(tools.keys())\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码把所有工具放入统一的 `tools` 字典:\n", + "\n", + "1. `search`、`calculator`、`file_reader` 是工具编号,后续智能体会用编号选择工具。\n", + "2. `name` 是工具的中文名称,方便展示给用户或调试时阅读。\n", + "3. `description` 是工具说明,真实智能体可以根据说明判断工具用途。\n", + "4. `function` 保存真正要调用的 Python 函数。\n", + "5. `tools.keys()` 可以查看当前注册了哪些工具。\n", + "\n", + "工具注册表的好处是:新增工具时,只需要往表里加一项,不需要大幅修改主流程。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 8. 第五步:实现工具选择器\n", + "\n", + "工具选择器负责判断用户问题应该交给哪个工具。\n", + "\n", + "真实项目中,工具选择可以由大模型完成。这里为了便于理解,先用关键词规则实现。\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def select_tool(user_input):\n", + " if any(word in user_input for word in [\"计算\", \"加\", \"减\", \"乘\", \"除\", \"+\", \"-\", \"*\", \"/\"]):\n", + " return \"calculator\"\n", + "\n", + " if any(word in user_input for word in [\"文件\", \"计划\", \"会议\", \"project_plan\", \"meeting_notes\"]):\n", + " return \"file_reader\"\n", + "\n", + " return \"search\"\n", + "\n", + "\n", + "print(select_tool(\"帮我计算 12 * 8\"))\n", + "print(select_tool(\"读取项目计划文件\"))\n", + "print(select_tool(\"LangChain 是什么\"))\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码实现了一个规则版工具选择器:\n", + "\n", + "1. `select_tool(user_input)` 接收用户输入。\n", + "2. 如果问题中包含计算相关词语或数学符号,就选择 `calculator`。\n", + "3. 如果问题中包含文件、计划、会议等词语,就选择 `file_reader`。\n", + "4. 如果前面规则都没有命中,默认选择 `search`。\n", + "\n", + "这种规则方法简单直观,适合教学和小项目。但当问题复杂时,可以让大模型根据工具描述自动选择工具。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 9. 第六步:为不同工具提取参数\n", + "\n", + "选择工具之后,还需要给工具传入合适的参数。\n", + "\n", + "例如计算工具需要数学表达式,文件读取工具需要文件名,搜索工具需要搜索关键词。\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import re\n", + "\n", + "\n", + "def extract_tool_input(user_input, tool_name):\n", + " if tool_name == \"calculator\":\n", + " match = re.search(r\"[0-9+\\-*/(). ]+\", user_input)\n", + " return match.group().strip() if match else user_input\n", + "\n", + " if tool_name == \"file_reader\":\n", + " if \"meeting\" in user_input or \"会议\" in user_input:\n", + " return \"meeting_notes.txt\"\n", + " return \"project_plan.txt\"\n", + "\n", + " return user_input\n", + "\n", + "\n", + "print(extract_tool_input(\"帮我计算 128 * 36 + 50\", \"calculator\"))\n", + "print(extract_tool_input(\"读取会议纪要\", \"file_reader\"))\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码负责从用户输入中提取工具参数:\n", + "\n", + "1. `import re` 引入正则表达式模块。\n", + "2. `extract_tool_input(user_input, tool_name)` 根据工具类型提取不同参数。\n", + "3. 对计算工具,`re.search(r\"[0-9+\\-*/(). ]+\", user_input)` 会从文本中找出数学表达式。\n", + "4. `match.group().strip()` 取出匹配结果并去掉前后空格。\n", + "5. 对文件读取工具,如果用户提到会议,就读取 `meeting_notes.txt`;否则默认读取 `project_plan.txt`。\n", + "6. 对搜索工具,直接把用户输入作为查询内容。\n", + "\n", + "参数提取是工具调用中很重要的一步。工具选对了,但参数给错了,结果也会不准确。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 10. 第七步:实现智能体主流程\n", + "\n", + "现在把工具选择、参数提取、工具调用和答案汇总连接起来,形成完整智能体。\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def multi_tool_agent(user_input):\n", + " tool_name = select_tool(user_input)\n", + " tool_info = tools[tool_name]\n", + " tool_input = extract_tool_input(user_input, tool_name)\n", + " tool_result = tool_info[\"function\"](tool_input)\n", + "\n", + " final_answer = f\"用户问题:{user_input}\\n\"\n", + " final_answer += f\"选择工具:{tool_info['name']}\\n\"\n", + " final_answer += f\"工具输入:{tool_input}\\n\"\n", + " final_answer += f\"工具结果:{tool_result}\"\n", + " return final_answer\n", + "\n", + "\n", + "print(multi_tool_agent(\"帮我计算 128 * 36 + 50\"))\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码实现了智能体主流程:\n", + "\n", + "1. `multi_tool_agent(user_input)` 是智能体入口函数。\n", + "2. `select_tool(user_input)` 判断应该使用哪个工具。\n", + "3. `tools[tool_name]` 从工具注册表中取出工具信息。\n", + "4. `extract_tool_input(user_input, tool_name)` 为工具准备输入参数。\n", + "5. `tool_info[\"function\"](tool_input)` 调用真正的工具函数。\n", + "6. 最后把用户问题、选择的工具、工具输入和工具结果拼接成最终答案。\n", + "\n", + "这就是多工具协作型智能体的最小可用版本。它的重点不是某个工具有多复杂,而是能自动完成“选工具、调工具、汇总结果”。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 11. 测试多个问题\n", + "\n", + "下面用不同类型的问题测试智能体,观察它是否能选择正确工具。\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "test_questions = [\n", + " \"LangChain 是什么?\",\n", + " \"帮我计算 25 * 4 + 18\",\n", + " \"读取项目计划文件\",\n", + " \"会议纪要里说了什么?\",\n", + "]\n", + "\n", + "for question in test_questions:\n", + " print(\"=\" * 40)\n", + " print(multi_tool_agent(question))\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码用于批量测试:\n", + "\n", + "1. `test_questions` 保存多个测试问题。\n", + "2. 第一个问题适合搜索工具。\n", + "3. 第二个问题适合计算工具。\n", + "4. 第三个和第四个问题适合文件读取工具。\n", + "5. `for question in test_questions` 逐个运行智能体。\n", + "6. `print(\"=\" * 40)` 用分隔线区分不同测试结果。\n", + "\n", + "测试时要重点观察两点:工具是否选对,工具输入是否提取正确。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 12. 加入调试日志\n", + "\n", + "智能体项目经常需要调试,因为错误可能发生在工具选择、参数提取或工具执行阶段。\n", + "\n", + "下面给主流程增加调试日志,方便定位问题。\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def debug_multi_tool_agent(user_input, verbose=True):\n", + " if verbose:\n", + " print(f\"[调试] 用户输入:{user_input}\")\n", + "\n", + " tool_name = select_tool(user_input)\n", + " if verbose:\n", + " print(f\"[调试] 选择工具:{tool_name}\")\n", + "\n", + " tool_input = extract_tool_input(user_input, tool_name)\n", + " if verbose:\n", + " print(f\"[调试] 工具输入:{tool_input}\")\n", + "\n", + " tool_result = tools[tool_name][\"function\"](tool_input)\n", + " if verbose:\n", + " print(f\"[调试] 工具结果:{tool_result}\")\n", + "\n", + " return f\"最终回答:{tool_result}\"\n", + "\n", + "\n", + "print(debug_multi_tool_agent(\"帮我计算 10 + 20 * 3\"))\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码增加了调试能力:\n", + "\n", + "1. `verbose=True` 表示默认打印调试信息。\n", + "2. 第一条日志显示用户原始输入。\n", + "3. 第二条日志显示智能体选择了哪个工具。\n", + "4. 第三条日志显示传给工具的输入参数。\n", + "5. 第四条日志显示工具返回结果。\n", + "6. 如果不想看日志,可以调用 `debug_multi_tool_agent(question, verbose=False)`。\n", + "\n", + "调试日志能帮助我们判断问题到底出在哪里:是工具没选对,还是参数没提对,还是工具本身执行失败。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 13. 用 LangGraph 表达多工具流程\n", + "\n", + "当前流程是普通 Python 函数。实际项目中,如果流程变复杂,可以用 LangGraph 把步骤拆成节点。\n", + "\n", + "工作流可以设计为:\n", + "\n", + "```text\n", + "START -> 选择工具 -> 执行工具 -> 汇总答案 -> END\n", + "```\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from typing import TypedDict\n", + "\n", + "\n", + "class AgentState(TypedDict):\n", + " user_input: str\n", + " tool_name: str\n", + " tool_input: str\n", + " tool_result: str\n", + " final_answer: str\n", + "\n", + "\n", + "def choose_tool_node(state: AgentState):\n", + " tool_name = select_tool(state[\"user_input\"])\n", + " tool_input = extract_tool_input(state[\"user_input\"], tool_name)\n", + " return {\"tool_name\": tool_name, \"tool_input\": tool_input}\n", + "\n", + "\n", + "def execute_tool_node(state: AgentState):\n", + " tool_result = tools[state[\"tool_name\"]][\"function\"](state[\"tool_input\"])\n", + " return {\"tool_result\": tool_result}\n", + "\n", + "\n", + "def summarize_node(state: AgentState):\n", + " tool_label = tools[state[\"tool_name\"]][\"name\"]\n", + " final_answer = f\"我使用了{tool_label},得到结果:{state['tool_result']}\"\n", + " return {\"final_answer\": final_answer}\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码定义了 LangGraph 版本需要的 State 和节点:\n", + "\n", + "1. `AgentState` 表示工作流中流动的数据。\n", + "2. `user_input` 保存用户问题。\n", + "3. `tool_name` 保存选择出的工具编号。\n", + "4. `tool_input` 保存传给工具的参数。\n", + "5. `tool_result` 保存工具执行结果。\n", + "6. `final_answer` 保存最终回答。\n", + "7. `choose_tool_node` 负责工具选择和参数提取。\n", + "8. `execute_tool_node` 负责调用工具。\n", + "9. `summarize_node` 负责把工具结果整理成用户能看懂的回答。\n", + "\n", + "用 LangGraph 后,每个步骤变成独立节点,流程更清晰,也更容易扩展。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 14. 构建 LangGraph 工作流\n", + "\n", + "如果当前环境没有安装 LangGraph,可以先运行:`%pip install langgraph`。\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 如果没有安装 LangGraph,取消下一行注释后运行\n", + "# %pip install langgraph\n", + "\n", + "from langgraph.graph import END, START, StateGraph\n", + "\n", + "\n", + "workflow = StateGraph(AgentState)\n", + "\n", + "workflow.add_node(\"choose_tool\", choose_tool_node)\n", + "workflow.add_node(\"execute_tool\", execute_tool_node)\n", + "workflow.add_node(\"summarize\", summarize_node)\n", + "\n", + "workflow.add_edge(START, \"choose_tool\")\n", + "workflow.add_edge(\"choose_tool\", \"execute_tool\")\n", + "workflow.add_edge(\"execute_tool\", \"summarize\")\n", + "workflow.add_edge(\"summarize\", END)\n", + "\n", + "agent_app = workflow.compile()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码把节点连接成完整工作流:\n", + "\n", + "1. `StateGraph(AgentState)` 创建一张流程图,并指定状态结构。\n", + "2. `add_node` 注册节点函数。\n", + "3. `START -> choose_tool` 表示从工具选择节点开始。\n", + "4. `choose_tool -> execute_tool` 表示选好工具后执行工具。\n", + "5. `execute_tool -> summarize` 表示拿到工具结果后进行汇总。\n", + "6. `summarize -> END` 表示汇总完成后结束。\n", + "7. `compile()` 把流程图编译成可运行对象。\n", + "\n", + "这和前面的普通 Python 主流程做的是同一件事,只是 LangGraph 更适合管理复杂流程。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 15. 运行 LangGraph 版智能体\n", + "\n", + "现在用一个问题测试 LangGraph 版多工具智能体。\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "initial_state = {\n", + " \"user_input\": \"会议纪要里说了什么?\",\n", + " \"tool_name\": \"\",\n", + " \"tool_input\": \"\",\n", + " \"tool_result\": \"\",\n", + " \"final_answer\": \"\",\n", + "}\n", + "\n", + "final_state = agent_app.invoke(initial_state)\n", + "print(final_state[\"final_answer\"])\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码运行 LangGraph 工作流:\n", + "\n", + "1. `initial_state` 是初始状态,只有 `user_input` 有真实内容,其他字段先留空。\n", + "2. `agent_app.invoke(initial_state)` 启动工作流。\n", + "3. 工作流会依次执行工具选择、工具调用和结果汇总。\n", + "4. `final_state` 是运行结束后的完整状态。\n", + "5. `final_state[\"final_answer\"]` 取出最终答案。\n", + "\n", + "这个例子说明:当一个智能体需要多个步骤协作时,LangGraph 可以让流程更有结构。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 16. 调试和优化建议\n", + "\n", + "多工具智能体常见问题包括:\n", + "\n", + "| 问题 | 可能原因 | 优化方法 |\n", + "| --- | --- | --- |\n", + "| 工具选错 | 工具描述不清或规则太简单 | 优化工具描述,增加分类规则或让模型判断 |\n", + "| 参数提错 | 用户表达复杂 | 增加参数抽取逻辑或使用结构化输出 |\n", + "| 工具报错 | 输入格式不符合要求 | 在调用前做参数校验 |\n", + "| 答案不清晰 | 只返回原始工具结果 | 增加结果解释和格式化输出 |\n", + "| 流程难排查 | 缺少中间状态 | 增加调试日志或保存执行轨迹 |\n", + "\n", + "开发智能体时,不要只看最终答案,也要观察每一步的中间状态。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 17. 小结\n", + "\n", + "本节课完成了一个多工具协作型智能体项目。\n", + "\n", + "你需要重点掌握:\n", + "\n", + "1. 多工具智能体的核心是根据任务选择合适工具。\n", + "2. 工具通常包含名称、描述和函数。\n", + "3. 工具调用前要先选择工具,再提取参数。\n", + "4. 调试时要观察工具选择、工具输入和工具结果。\n", + "5. LangGraph 可以把复杂工具调用流程拆成清晰节点。\n", + "\n", + "掌握这个项目后,就可以继续接入真实搜索 API、真实文件读取、大模型工具调用和更复杂的工作流。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 18. 练习题\n", + "\n", + "1. 新增一个 `weather_tool`,模拟查询天气。\n", + "2. 修改 `select_tool`,让它可以根据“天气”关键词选择天气工具。\n", + "3. 修改 `calculator_tool`,支持平方和取余计算。\n", + "4. 给 `multi_tool_agent` 增加错误处理,当工具不存在时返回友好提示。\n", + "5. 在 LangGraph 工作流中新增一个 `debug_node`,专门输出当前 State。\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.4" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/26_项目实战三.ipynb b/26_项目实战三.ipynb new file mode 100644 index 0000000..b0434a9 --- /dev/null +++ b/26_项目实战三.ipynb @@ -0,0 +1,1039 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 26 项目实战三:带交互界面的智能体项目\n", + "\n", + "## 学习目标\n", + "1. 完成一个具备完整交互界面的智能体项目\n", + "2. 掌握将 LangGraph 智能体与简单命令行界面集成\n", + "3. 能够进行项目测试、迭代和成果展示\n", + "4. 理解项目从需求分析到最终演示的完整开发流程\n", + "5. 学会把前面学过的状态、节点、边、条件分支组合成一个可用项目\n", + "\n", + "本节课会完成一个小型项目:**学习任务助手**。\n", + "\n", + "它可以根据用户输入,自动识别用户想做什么,并给出不同类型的回复。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. 项目背景\n", + "\n", + "前面的项目实战中,我们已经学习了知识库问答、多工具智能体等内容。\n", + "\n", + "但是一个项目如果只能在代码单元里调用函数,体验还不够完整。\n", + "\n", + "真实项目通常还需要一个交互入口,例如:\n", + "\n", + "- 命令行界面\n", + "- Web 页面\n", + "- 桌面应用\n", + "- 聊天窗口\n", + "- 企业微信、飞书、钉钉机器人\n", + "\n", + "本节我们先从最简单、最容易理解的命令行界面开始。\n", + "\n", + "项目目标是做一个学习任务助手,用户可以输入自然语言,例如:\n", + "\n", + "```text\n", + "帮我制定 LangGraph 学习计划\n", + "```\n", + "\n", + "或者:\n", + "\n", + "```text\n", + "解释一下条件边是什么\n", + "```\n", + "\n", + "系统会根据输入内容判断用户意图,并给出对应回复。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. 项目需求分析\n", + "\n", + "做项目之前,先不要急着写代码。\n", + "\n", + "我们先分析这个项目要具备哪些能力。\n", + "\n", + "| 需求 | 说明 |\n", + "| --- | --- |\n", + "| 接收用户输入 | 用户可以输入一句自然语言请求 |\n", + "| 判断用户意图 | 判断用户是想学习计划、概念解释,还是普通聊天 |\n", + "| 生成对应回复 | 根据不同意图生成不同内容 |\n", + "| 保存执行状态 | 记录用户输入、意图、回复和历史记录 |\n", + "| 提供交互界面 | 让用户可以连续输入问题 |\n", + "| 支持测试和展示 | 能用示例输入验证系统是否正常 |\n", + "\n", + "为了让项目容易运行,本节不依赖外部大模型 API,而是用规则模拟智能体的判断和回复。\n", + "\n", + "这样做的好处是:\n", + "\n", + "- 不需要 API Key\n", + "- 不受网络影响\n", + "- 更容易看清楚 LangGraph 的工作流结构\n", + "- 适合课堂演示和初学者练习\n", + "\n", + "等理解项目结构后,再把规则回复替换成真实大模型也很容易。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. 项目整体流程\n", + "\n", + "学习任务助手的整体流程如下:\n", + "\n", + "```text\n", + "用户输入\n", + " ↓\n", + "分析意图\n", + " ↓\n", + "根据意图选择处理节点\n", + " ↓\n", + "生成回复\n", + " ↓\n", + "记录历史\n", + " ↓\n", + "返回结果\n", + "```\n", + "\n", + "如果用 LangGraph 表达,可以拆成几个节点:\n", + "\n", + "| 节点 | 作用 |\n", + "| --- | --- |\n", + "| `analyze_intent` | 分析用户意图 |\n", + "| `make_plan` | 生成学习计划 |\n", + "| `explain_concept` | 解释概念 |\n", + "| `chat` | 普通聊天回复 |\n", + "| `save_history` | 保存本轮对话历史 |\n", + "\n", + "其中 `analyze_intent` 后面会接条件边,根据不同意图走到不同节点。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. 第一步:定义项目状态\n", + "\n", + "LangGraph 项目的第一步通常是定义状态。\n", + "\n", + "状态可以理解为整个流程共享的数据记录。\n", + "\n", + "本项目需要记录:\n", + "\n", + "- 用户输入了什么\n", + "- 系统判断出的意图是什么\n", + "- 系统生成的回复是什么\n", + "- 历史对话有哪些\n", + "\n", + "下面先定义状态结构。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11927b6e", + "metadata": {}, + "outputs": [], + "source": [ + "from typing_extensions import TypedDict\n", + "\n", + "class AssistantState(TypedDict):\n", + " user_input: str\n", + " intent: str\n", + " response: str\n", + " history: list[str]\n", + "\n", + "initial_state: AssistantState = {\n", + " 'user_input': '帮我制定 LangGraph 学习计划',\n", + " 'intent': '',\n", + " 'response': '',\n", + " 'history': []\n", + "}\n", + "\n", + "print(initial_state)" + ] + }, + { + "cell_type": "markdown", + "id": "b1272a7f", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码完成了项目状态设计。\n", + "\n", + "#### `AssistantState`\n", + "这是一个 `TypedDict`,用来规定状态中有哪些字段。\n", + "\n", + "字段含义如下:\n", + "\n", + "- `user_input`:用户当前输入的内容\n", + "- `intent`:系统分析出的用户意图\n", + "- `response`:系统生成的回复\n", + "- `history`:保存历史交互记录\n", + "\n", + "#### 为什么需要 `intent`\n", + "因为项目不是简单地收到输入就回复,而是要先判断用户想做什么。\n", + "\n", + "例如:\n", + "\n", + "- 用户说‘制定学习计划’,意图就是 `plan`\n", + "- 用户说‘解释条件边’,意图就是 `explain`\n", + "- 用户说‘你好’,意图就是 `chat`\n", + "\n", + "后面条件边会根据 `intent` 决定走哪个处理节点。\n", + "\n", + "#### 为什么需要 `history`\n", + "`history` 用来保存交互记录。\n", + "\n", + "虽然本项目只是简单命令行助手,但保留历史可以帮助我们展示项目结果,也为后续扩展多轮对话打基础。" + ] + }, + { + "cell_type": "markdown", + "id": "67b2006f", + "metadata": {}, + "source": [ + "## 5. 第二步:编写意图识别节点\n", + "\n", + "意图识别节点负责判断用户想做什么。\n", + "\n", + "真实项目中,可以用大模型判断意图。\n", + "\n", + "本节为了方便运行,先用关键词规则实现。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7dc3e04c", + "metadata": {}, + "outputs": [], + "source": [ + "def analyze_intent(state: AssistantState):\n", + " text = state['user_input']\n", + "\n", + " if '计划' in text or '安排' in text or '学习路线' in text:\n", + " intent = 'plan'\n", + " elif '解释' in text or '什么是' in text or '概念' in text:\n", + " intent = 'explain'\n", + " else:\n", + " intent = 'chat'\n", + "\n", + " return {'intent': intent}\n", + "\n", + "test_state = {\n", + " 'user_input': '请解释一下 LangGraph 的条件边',\n", + " 'intent': '',\n", + " 'response': '',\n", + " 'history': []\n", + "}\n", + "\n", + "print(analyze_intent(test_state))" + ] + }, + { + "cell_type": "markdown", + "id": "91aa9945", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这个节点是项目中的第一个处理节点。\n", + "\n", + "#### `text = state['user_input']`\n", + "从状态中取出用户输入。\n", + "\n", + "后面的判断都基于这句话进行。\n", + "\n", + "#### 关键词规则\n", + "代码中用了三类规则:\n", + "\n", + "1. 如果包含 `计划`、`安排`、`学习路线`,判断为学习计划类需求\n", + "2. 如果包含 `解释`、`什么是`、`概念`,判断为概念解释类需求\n", + "3. 其他情况都归为普通聊天\n", + "\n", + "#### 返回 `{'intent': intent}`\n", + "节点只返回要更新的字段。\n", + "\n", + "这里不需要返回完整状态,因为 LangGraph 会把这个更新合并回原状态。\n", + "\n", + "#### 为什么这里不用大模型\n", + "规则判断虽然简单,但非常适合教学。\n", + "\n", + "它让我们先专注理解项目结构:\n", + "\n", + "```text\n", + "输入 -> 判断意图 -> 条件分支\n", + "```\n", + "\n", + "后续如果想升级,只需要把这个函数替换成大模型判断即可。" + ] + }, + { + "cell_type": "markdown", + "id": "f1684879", + "metadata": {}, + "source": [ + "## 6. 第三步:编写不同意图的处理节点\n", + "\n", + "识别出意图后,需要根据不同意图生成不同回复。\n", + "\n", + "本项目设计三个处理节点:\n", + "\n", + "- `make_plan`:生成学习计划\n", + "- `explain_concept`:解释概念\n", + "- `chat`:普通聊天\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ea3867af", + "metadata": {}, + "outputs": [], + "source": [ + "def make_plan(state: AssistantState):\n", + " response = '''这是一个建议的学习计划:\n", + "1. 先复习 LangGraph 的 State、Node、Edge\n", + "2. 再重点理解条件边和循环结构\n", + "3. 然后练习对话代理和工具调用\n", + "4. 最后完成一个小项目进行综合应用\n", + "'''\n", + " return {'response': response}\n", + "\n", + "def explain_concept(state: AssistantState):\n", + " response = '''可以这样理解:\n", + "LangGraph 是一个用“图”来组织智能体流程的框架。\n", + "节点负责执行具体任务,边负责决定执行顺序,状态负责在节点之间传递数据。\n", + "'''\n", + " return {'response': response}\n", + "\n", + "def chat(state: AssistantState):\n", + " response = '我可以帮你制定学习计划、解释 AI 智能体概念,或者协助你梳理项目思路。'\n", + " return {'response': response}\n", + "\n", + "print(make_plan(initial_state)['response'])" + ] + }, + { + "cell_type": "markdown", + "id": "048b0e96", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码定义了三个处理节点。\n", + "\n", + "#### `make_plan`\n", + "当用户想要学习计划时,会进入这个节点。\n", + "\n", + "它返回一个分步骤学习建议。\n", + "\n", + "#### `explain_concept`\n", + "当用户想要解释概念时,会进入这个节点。\n", + "\n", + "它用通俗语言解释 LangGraph 的核心思想。\n", + "\n", + "#### `chat`\n", + "当系统没有识别到明确需求时,会进入普通聊天节点。\n", + "\n", + "这个节点告诉用户当前助手能做什么。\n", + "\n", + "#### 三个节点的共同点\n", + "它们都返回:\n", + "\n", + "```python\n", + "{'response': response}\n", + "```\n", + "\n", + "也就是说,它们都只负责生成回复,不负责保存历史,也不负责判断下一步。\n", + "\n", + "这体现了项目开发中的一个好习惯:**每个节点只负责一件事。**" + ] + }, + { + "cell_type": "markdown", + "id": "a48bdefd", + "metadata": {}, + "source": [ + "## 7. 第四步:保存历史记录\n", + "\n", + "每轮交互结束前,我们希望把用户输入和系统回复保存下来。\n", + "\n", + "这样后面展示项目成果时,就能看到完整历史。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "93d75eec", + "metadata": {}, + "outputs": [], + "source": [ + "def save_history(state: AssistantState):\n", + " new_record = f'用户:{state[\"user_input\"]}\\n助手:{state[\"response\"]}'\n", + " history = state['history'] + [new_record]\n", + " return {'history': history}\n", + "\n", + "sample_state = {\n", + " 'user_input': '你好',\n", + " 'intent': 'chat',\n", + " 'response': '你好,我是学习任务助手。',\n", + " 'history': []\n", + "}\n", + "\n", + "print(save_history(sample_state))" + ] + }, + { + "cell_type": "markdown", + "id": "11773b90", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这个节点负责保存历史记录。\n", + "\n", + "#### `new_record`\n", + "这一行把当前用户输入和助手回复拼成一条记录。\n", + "\n", + "格式类似:\n", + "\n", + "```text\n", + "用户:你好\n", + "助手:你好,我是学习任务助手。\n", + "```\n", + "\n", + "#### `history = state['history'] + [new_record]`\n", + "这行代码不是直接修改原列表,而是创建一个新列表。\n", + "\n", + "新列表 = 原来的历史记录 + 本轮新记录。\n", + "\n", + "这样写更清晰,也更符合状态更新的思路。\n", + "\n", + "#### 返回 `{'history': history}`\n", + "节点只更新 `history` 字段。\n", + "\n", + "它不需要关心用户意图,也不需要重新生成回复。\n", + "\n", + "这同样体现了节点职责分离。" + ] + }, + { + "cell_type": "markdown", + "id": "be5775e0", + "metadata": {}, + "source": [ + "## 8. 第五步:构建 LangGraph 工作流\n", + "\n", + "现在我们已经有了所有节点,接下来把它们组装成一张图。\n", + "\n", + "这张图的关键是:`analyze_intent` 后面要使用条件边。\n", + "\n", + "不同意图会进入不同处理节点。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ce3f69f9", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.graph import StateGraph, START, END\n", + "\n", + "def route_by_intent(state: AssistantState):\n", + " if state['intent'] == 'plan':\n", + " return 'plan'\n", + " if state['intent'] == 'explain':\n", + " return 'explain'\n", + " return 'chat'\n", + "\n", + "builder = StateGraph(AssistantState)\n", + "\n", + "builder.add_node('analyze_intent', analyze_intent)\n", + "builder.add_node('make_plan', make_plan)\n", + "builder.add_node('explain_concept', explain_concept)\n", + "builder.add_node('chat', chat)\n", + "builder.add_node('save_history', save_history)\n", + "\n", + "builder.add_edge(START, 'analyze_intent')\n", + "builder.add_conditional_edges(\n", + " 'analyze_intent',\n", + " route_by_intent,\n", + " {\n", + " 'plan': 'make_plan',\n", + " 'explain': 'explain_concept',\n", + " 'chat': 'chat'\n", + " }\n", + ")\n", + "builder.add_edge('make_plan', 'save_history')\n", + "builder.add_edge('explain_concept', 'save_history')\n", + "builder.add_edge('chat', 'save_history')\n", + "builder.add_edge('save_history', END)\n", + "\n", + "assistant_graph = builder.compile()\n", + "\n", + "print('图构建完成')" + ] + }, + { + "cell_type": "markdown", + "id": "5059750c", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这一段是项目的核心,把前面所有函数组装成完整工作流。\n", + "\n", + "#### `route_by_intent`\n", + "这是路由函数,负责告诉条件边下一步去哪。\n", + "\n", + "它根据 `state['intent']` 返回不同标记:\n", + "\n", + "- `plan`\n", + "- `explain`\n", + "- `chat`\n", + "\n", + "#### `builder.add_node(...)`\n", + "这些代码把节点加入图中。\n", + "\n", + "节点本身只是 Python 函数,加入图之后才成为流程的一部分。\n", + "\n", + "#### 条件边\n", + "核心代码是:\n", + "\n", + "```python\n", + "builder.add_conditional_edges(...)\n", + "```\n", + "\n", + "它表示:`analyze_intent` 执行完以后,不是固定走某一个节点,而是调用 `route_by_intent` 判断下一步。\n", + "\n", + "映射关系是:\n", + "\n", + "- `plan` -> `make_plan`\n", + "- `explain` -> `explain_concept`\n", + "- `chat` -> `chat`\n", + "\n", + "#### 三个处理节点为什么都连到 `save_history`\n", + "不管用户意图是什么,最终都需要保存历史。\n", + "\n", + "所以三个分支最后都会汇合到 `save_history`。\n", + "\n", + "这就是图结构中的‘分支后汇合’。\n", + "\n", + "#### 最终流程\n", + "完整执行路径可能是:\n", + "\n", + "```text\n", + "START -> analyze_intent -> make_plan -> save_history -> END\n", + "```\n", + "\n", + "也可能是:\n", + "\n", + "```text\n", + "START -> analyze_intent -> explain_concept -> save_history -> END\n", + "```\n", + "\n", + "具体走哪条路,由用户输入决定。" + ] + }, + { + "cell_type": "markdown", + "id": "dbe8d633", + "metadata": {}, + "source": [ + "## 9. 第六步:测试单轮运行\n", + "\n", + "在做交互界面之前,先测试图本身是否能正常运行。\n", + "\n", + "这是项目开发中的好习惯:先测试核心逻辑,再做界面。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "179f2115", + "metadata": {}, + "outputs": [], + "source": [ + "state = {\n", + " 'user_input': '帮我安排一个 LangGraph 学习计划',\n", + " 'intent': '',\n", + " 'response': '',\n", + " 'history': []\n", + "}\n", + "\n", + "result = assistant_graph.invoke(state)\n", + "\n", + "print('识别意图:', result['intent'])\n", + "print('助手回复:')\n", + "print(result['response'])\n", + "print('历史记录:')\n", + "print(result['history'][0])" + ] + }, + { + "cell_type": "markdown", + "id": "ad1a385b", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码测试了一次完整运行。\n", + "\n", + "#### 初始状态\n", + "初始状态中,只有 `user_input` 是用户给出的。\n", + "\n", + "其他字段先设置为空:\n", + "\n", + "- `intent=''`\n", + "- `response=''`\n", + "- `history=[]`\n", + "\n", + "这些字段会在图运行过程中被节点逐步填充。\n", + "\n", + "#### `assistant_graph.invoke(state)`\n", + "这行代码启动整张图。\n", + "\n", + "执行过程是:\n", + "\n", + "1. `analyze_intent` 判断用户想要学习计划\n", + "2. 条件边把流程送到 `make_plan`\n", + "3. `make_plan` 生成学习计划\n", + "4. `save_history` 保存历史记录\n", + "5. 流程结束\n", + "\n", + "#### 打印结果\n", + "最后打印三个重要信息:\n", + "\n", + "- 系统识别出的意图\n", + "- 助手回复\n", + "- 历史记录\n", + "\n", + "如果这一步结果正常,说明核心工作流没有问题。" + ] + }, + { + "cell_type": "markdown", + "id": "5f5322a5", + "metadata": {}, + "source": [ + "## 10. 第七步:批量测试多个输入\n", + "\n", + "一个项目不能只测试一个例子。\n", + "\n", + "我们应该准备多种输入,看看不同分支是否都能正常工作。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d45a2a15", + "metadata": {}, + "outputs": [], + "source": [ + "test_inputs = [\n", + " '帮我制定 AI 智能体学习计划',\n", + " '解释一下什么是条件边',\n", + " '你好,你能做什么?'\n", + "]\n", + "\n", + "for user_input in test_inputs:\n", + " state = {\n", + " 'user_input': user_input,\n", + " 'intent': '',\n", + " 'response': '',\n", + " 'history': []\n", + " }\n", + " result = assistant_graph.invoke(state)\n", + " print('=' * 40)\n", + " print('用户输入:', user_input)\n", + " print('识别意图:', result['intent'])\n", + " print('助手回复:')\n", + " print(result['response'])" + ] + }, + { + "cell_type": "markdown", + "id": "e9ad272a", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码是批量测试。\n", + "\n", + "#### `test_inputs`\n", + "这里准备了三类输入:\n", + "\n", + "1. 学习计划类\n", + "2. 概念解释类\n", + "3. 普通聊天类\n", + "\n", + "这正好覆盖了项目中的三个分支。\n", + "\n", + "#### 每次循环都创建新状态\n", + "每个输入都使用一个新的初始状态。\n", + "\n", + "这样可以单独观察每个输入的结果,不受上一轮影响。\n", + "\n", + "#### 为什么要做批量测试\n", + "因为条件分支项目最容易出现的问题是:\n", + "\n", + "- 某个分支没有走到\n", + "- 路由返回值写错\n", + "- 某个节点没有正确返回结果\n", + "\n", + "批量测试可以帮助我们快速发现这些问题。" + ] + }, + { + "cell_type": "markdown", + "id": "6916f5ae", + "metadata": {}, + "source": [ + "## 11. 第八步:封装成一个函数\n", + "\n", + "为了后面接入交互界面,我们把调用图的逻辑封装成函数。\n", + "\n", + "这样界面部分只需要调用这个函数即可。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cd9e649c", + "metadata": {}, + "outputs": [], + "source": [ + "def run_assistant(user_input, history=None):\n", + " if history is None:\n", + " history = []\n", + "\n", + " state = {\n", + " 'user_input': user_input,\n", + " 'intent': '',\n", + " 'response': '',\n", + " 'history': history\n", + " }\n", + "\n", + " result = assistant_graph.invoke(state)\n", + " return result\n", + "\n", + "result = run_assistant('什么是 LangGraph?')\n", + "print(result['response'])" + ] + }, + { + "cell_type": "markdown", + "id": "74ad36ce", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这个函数是核心逻辑和交互界面之间的桥梁。\n", + "\n", + "#### `run_assistant(user_input, history=None)`\n", + "它接收两个参数:\n", + "\n", + "- `user_input`:用户输入\n", + "- `history`:历史记录,默认可以为空\n", + "\n", + "#### 为什么默认值用 `None`\n", + "函数参数不建议直接写 `history=[]`。\n", + "\n", + "因为列表是可变对象,作为默认参数可能带来意外共享问题。\n", + "\n", + "所以这里写成:\n", + "\n", + "```python\n", + "if history is None:\n", + " history = []\n", + "```\n", + "\n", + "这是 Python 中比较稳妥的写法。\n", + "\n", + "#### 函数内部做了什么\n", + "函数内部重新构造状态,然后调用:\n", + "\n", + "```python\n", + "assistant_graph.invoke(state)\n", + "```\n", + "\n", + "最后把结果返回。\n", + "\n", + "有了这个函数,后面无论是命令行界面还是 Web 界面,都可以复用同一套智能体逻辑。" + ] + }, + { + "cell_type": "markdown", + "id": "471f167c", + "metadata": {}, + "source": [ + "## 12. 第九步:构建命令行交互界面\n", + "\n", + "现在我们给项目加一个简单的命令行界面。\n", + "\n", + "用户可以连续输入内容,输入 `退出` 时结束。\n", + "\n", + "注意:在 Jupyter Notebook 中,`input()` 需要手动输入。如果你只是阅读课件,可以先不运行这一格。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5701141f", + "metadata": {}, + "outputs": [], + "source": [ + "def start_cli():\n", + " print('学习任务助手已启动')\n", + " print('你可以输入问题,例如:帮我制定学习计划 / 解释条件边 / 你好')\n", + " print('输入“退出”结束对话')\n", + "\n", + " history = []\n", + "\n", + " while True:\n", + " user_input = input('你:')\n", + "\n", + " if user_input.strip() == '退出':\n", + " print('助手:再见,祝你学习顺利!')\n", + " break\n", + "\n", + " result = run_assistant(user_input, history)\n", + " history = result['history']\n", + "\n", + " print('助手:')\n", + " print(result['response'])\n", + "\n", + "# 在 notebook 中如需体验交互,可以取消下一行注释\n", + "# start_cli()" + ] + }, + { + "cell_type": "markdown", + "id": "f1231bf7", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码实现了一个简单但完整的命令行交互界面。\n", + "\n", + "#### `start_cli()`\n", + "这是启动命令行界面的函数。\n", + "\n", + "运行后,用户可以不断输入问题。\n", + "\n", + "#### `history = []`\n", + "这里创建一个历史记录列表。\n", + "\n", + "每一轮调用助手后,都会更新历史。\n", + "\n", + "#### `while True`\n", + "这是一个循环,让程序可以一直等待用户输入。\n", + "\n", + "如果没有这个循环,程序只能回答一次。\n", + "\n", + "#### `input('你:')`\n", + "这是命令行输入入口。\n", + "\n", + "用户在这里输入自然语言请求。\n", + "\n", + "#### 退出逻辑\n", + "如果用户输入:\n", + "\n", + "```text\n", + "退出\n", + "```\n", + "\n", + "程序会打印告别语,并用 `break` 跳出循环。\n", + "\n", + "#### 调用智能体\n", + "核心代码是:\n", + "\n", + "```python\n", + "result = run_assistant(user_input, history)\n", + "```\n", + "\n", + "这说明界面层并不直接处理智能体逻辑。\n", + "\n", + "它只是把用户输入交给 `run_assistant`,再把结果展示出来。\n", + "\n", + "这种分层方式很重要:\n", + "\n", + "- LangGraph 负责智能体逻辑\n", + "- `run_assistant` 负责封装调用\n", + "- `start_cli` 负责用户交互\n", + "\n", + "这样项目结构更清晰,也更容易维护。" + ] + }, + { + "cell_type": "markdown", + "id": "1c2fde68", + "metadata": {}, + "source": [ + "## 13. 第十步:模拟一次完整展示\n", + "\n", + "课堂演示或项目展示时,不一定要真的手动输入。\n", + "\n", + "我们也可以用一组预设输入模拟完整对话。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8e478b1e", + "metadata": {}, + "outputs": [], + "source": [ + "demo_inputs = [\n", + " '你好,你能做什么?',\n", + " '帮我制定 LangGraph 学习计划',\n", + " '解释一下什么是节点和边'\n", + "]\n", + "\n", + "history = []\n", + "\n", + "for user_input in demo_inputs:\n", + " result = run_assistant(user_input, history)\n", + " history = result['history']\n", + "\n", + " print('用户:', user_input)\n", + " print('助手:')\n", + " print(result['response'])\n", + " print()\n", + "\n", + "print('最终历史记录数量:', len(history))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码模拟了一次完整项目演示。\n", + "\n", + "#### `demo_inputs`\n", + "这里准备了三轮输入:\n", + "\n", + "1. 普通聊天\n", + "2. 学习计划\n", + "3. 概念解释\n", + "\n", + "这样可以展示项目的三个主要能力。\n", + "\n", + "#### 共用一个 `history`\n", + "每一轮调用后都会更新历史:\n", + "\n", + "```python\n", + "history = result['history']\n", + "```\n", + "\n", + "这样三轮对话会保存在同一个历史列表中。\n", + "\n", + "#### 为什么适合成果展示\n", + "这种方式不用手动输入,运行一次就能展示完整效果。\n", + "\n", + "在课堂演示、录屏展示、项目答辩中都很方便。\n", + "\n", + "#### `len(history)`\n", + "最后打印历史记录数量。\n", + "\n", + "如果进行了三轮对话,历史记录数量应该是 3。\n", + "\n", + "这说明系统确实把每轮交互保存了下来。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 14. 项目可以如何继续迭代\n", + "\n", + "当前项目是一个最小可用版本。\n", + "\n", + "如果继续升级,可以从下面几个方向入手。\n", + "\n", + "| 迭代方向 | 说明 |\n", + "| --- | --- |\n", + "| 接入真实大模型 | 用 ChatOpenAI 替换规则回复 |\n", + "| 增加更多意图 | 支持总结、翻译、代码解释等能力 |\n", + "| 加入工具调用 | 让助手可以查询资料、计算、读取文件 |\n", + "| 使用检查点 | 保存多轮对话状态 |\n", + "| Web 界面 | 用 Gradio、Streamlit 或 FastAPI 做页面 |\n", + "| 持久化历史 | 把历史记录保存到文件或数据库 |\n", + "\n", + "项目开发通常不是一次写完,而是不断从 MVP 迭代。\n", + "\n", + "本节完成的是第一版:能运行、能交互、能展示。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 15. 本节小结\n", + "\n", + "本节完成了一个完整的交互式智能体小项目。\n", + "\n", + "你需要重点掌握以下几点:\n", + "\n", + "1. **项目开发要先做需求分析,再写代码**\n", + "2. **状态设计决定了智能体流程中能传递哪些信息**\n", + "3. **节点负责具体任务,条件边负责根据意图选择路径**\n", + "4. **核心逻辑要先测试,再接入交互界面**\n", + "5. **命令行界面虽然简单,但已经能体现完整项目闭环**\n", + "\n", + "到这里,我们已经把 LangGraph 的基础能力组合成了一个可演示、可测试、可继续迭代的项目。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 16. 本节练习\n", + "\n", + "1. 给 `analyze_intent` 增加一个 `summary` 意图,用来处理‘总结’类请求\n", + "2. 增加一个 `summarize` 节点,返回一段固定的总结回复\n", + "3. 修改命令行界面,让用户输入空内容时提示‘请输入有效问题’\n", + "4. 把历史记录打印得更美观,例如加上轮次编号\n", + "5. 思考:如果把这个命令行项目改成 Web 项目,需要保留哪些核心函数?" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.4" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/27_部署与评估.ipynb b/27_部署与评估.ipynb new file mode 100644 index 0000000..a5806d4 --- /dev/null +++ b/27_部署与评估.ipynb @@ -0,0 +1,687 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 27 部署与评估\n", + "\n", + "## 学习目标\n", + "1. 了解智能体应用的部署方式,例如本地函数、本地 API、Docker 和云平台\n", + "2. 掌握智能体效果评估的常见指标和方法\n", + "3. 学会用测试集评估智能体的稳定性和准确性\n", + "4. 理解日志、追踪和 LangSmith 在智能体项目中的作用\n", + "5. 能够为一个智能体项目设计简单的上线检查流程\n", + "\n", + "前面我们已经完成了一个带交互界面的智能体项目。本节课要解决的问题是:**项目写完以后,怎么交付给别人使用?怎么判断它运行得好不好?**" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. 为什么要学习部署与评估\n", + "\n", + "很多初学者做智能体项目时,会停留在 notebook 能运行的阶段。\n", + "\n", + "但真实项目还需要回答三个问题:\n", + "\n", + "1. **别人怎么使用它?**\n", + "2. **它回答得好不好?**\n", + "3. **出问题时怎么排查?**\n", + "\n", + "这三个问题分别对应:\n", + "\n", + "| 问题 | 对应能力 |\n", + "| --- | --- |\n", + "| 怎么使用 | 部署 |\n", + "| 好不好用 | 评估 |\n", + "| 怎么排查 | 追踪与日志 |\n", + "\n", + "所以,智能体开发不是写完代码就结束。真正完整的流程应该是:\n", + "\n", + "```text\n", + "开发 -> 测试 -> 评估 -> 部署 -> 监控 -> 迭代\n", + "```\n", + "\n", + "本节会用通俗方式把这条链路讲清楚。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. 智能体常见部署方式\n", + "\n", + "部署可以理解为:**把只能自己运行的代码,变成别人也能使用的服务。**\n", + "\n", + "常见部署方式有下面几种:\n", + "\n", + "| 部署方式 | 适合场景 | 特点 |\n", + "| --- | --- | --- |\n", + "| 本地函数 | 学习、测试、内部脚本 | 最简单,但只能在代码里调用 |\n", + "| 命令行程序 | 工具型项目、个人使用 | 可以通过终端交互 |\n", + "| 本地 API | 前后端分离、系统集成 | 其他程序可以通过 HTTP 调用 |\n", + "| Docker | 统一环境、方便迁移 | 避免“我电脑能跑,你电脑不能跑” |\n", + "| 云平台 | 正式上线、多用户访问 | 稳定性更高,但配置更复杂 |\n", + "\n", + "初学时不要一上来就追求复杂部署。\n", + "\n", + "推荐顺序是:\n", + "\n", + "```text\n", + "函数调用 -> 命令行 -> 本地 API -> Docker -> 云平台\n", + "```\n", + "\n", + "每一步都是在上一层基础上增加一点工程化能力。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. 准备一个可部署的智能体核心函数\n", + "\n", + "部署前最重要的一件事是:**把核心逻辑封装成一个清晰的函数。**\n", + "\n", + "不管后面是接命令行、Web API,还是云服务,最终都应该调用同一个核心函数。\n", + "\n", + "下面我们用一个简化版学习助手作为示例。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def run_learning_assistant(user_input):\n", + " if '计划' in user_input or '安排' in user_input:\n", + " intent = 'plan'\n", + " response = '建议先学习 State、Node、Edge,再学习条件边、工具调用和项目实战。'\n", + " elif '解释' in user_input or '什么是' in user_input:\n", + " intent = 'explain'\n", + " response = 'LangGraph 可以理解为用图结构组织智能体流程的框架。'\n", + " else:\n", + " intent = 'chat'\n", + " response = '我可以帮你制定学习计划、解释概念或梳理项目思路。'\n", + "\n", + " return {\n", + " 'input': user_input,\n", + " 'intent': intent,\n", + " 'response': response\n", + " }\n", + "\n", + "result = run_learning_assistant('帮我制定 LangGraph 学习计划')\n", + "print(result)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码是一个最小可部署单元。\n", + "\n", + "#### `run_learning_assistant(user_input)`\n", + "这个函数接收用户输入,然后返回一个结构化结果。\n", + "\n", + "它没有直接使用 `input()`,也没有直接打印最终答案。\n", + "\n", + "这样做是为了方便部署。因为不同界面拿到结果后,可以用自己的方式展示:\n", + "\n", + "- 命令行可以 `print`\n", + "- Web API 可以返回 JSON\n", + "- 前端页面可以渲染到聊天窗口\n", + "\n", + "#### 为什么返回字典\n", + "函数返回:\n", + "\n", + "```python\n", + "{\n", + " 'input': user_input,\n", + " 'intent': intent,\n", + " 'response': response\n", + "}\n", + "```\n", + "\n", + "这比只返回一个字符串更适合工程项目。\n", + "\n", + "因为调用方不仅能拿到回复,还能看到系统识别出的意图,方便调试和评估。\n", + "\n", + "#### 部署前的关键原则\n", + "无论项目内部多复杂,都建议对外提供一个简单入口。\n", + "\n", + "可以把它理解成智能体项目的“总开关”。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. 本地 API 部署的基本思路\n", + "\n", + "API 部署的意思是:让其他程序通过 HTTP 请求调用你的智能体。\n", + "\n", + "例如前端页面可以发送:\n", + "\n", + "```text\n", + "POST /chat\n", + "{\"message\": \"帮我制定学习计划\"}\n", + "```\n", + "\n", + "服务端返回:\n", + "\n", + "```text\n", + "{\"response\": \"建议先学习 State、Node、Edge...\"}\n", + "```\n", + "\n", + "下面先不用 FastAPI,直接用一个普通函数模拟 API 处理过程。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def chat_api_handler(request_json):\n", + " user_message = request_json.get('message', '')\n", + "\n", + " if not user_message.strip():\n", + " return {\n", + " 'ok': False,\n", + " 'error': 'message 不能为空'\n", + " }\n", + "\n", + " result = run_learning_assistant(user_message)\n", + "\n", + " return {\n", + " 'ok': True,\n", + " 'data': result\n", + " }\n", + "\n", + "request = {'message': '解释一下什么是 LangGraph'}\n", + "response = chat_api_handler(request)\n", + "print(response)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码模拟了一个 API 接口。\n", + "\n", + "#### `request_json`\n", + "它代表外部系统传来的请求数据。\n", + "\n", + "真实 API 中,这个数据通常来自 HTTP 请求体。\n", + "\n", + "#### `request_json.get('message', '')`\n", + "这行代码从请求中取出用户消息。\n", + "\n", + "如果请求里没有 `message`,就默认使用空字符串。\n", + "\n", + "#### 空输入检查\n", + "如果用户没有传有效内容,接口返回:\n", + "\n", + "```python\n", + "{'ok': False, 'error': 'message 不能为空'}\n", + "```\n", + "\n", + "这是部署时常见的边界检查。\n", + "\n", + "#### 调用核心函数\n", + "真正处理智能体逻辑的是:\n", + "\n", + "```python\n", + "result = run_learning_assistant(user_message)\n", + "```\n", + "\n", + "API 层不应该写太多智能体逻辑,它只负责接收请求、调用核心函数、返回结果。\n", + "\n", + "#### 为什么这样分层\n", + "分层后,项目会更容易维护:\n", + "\n", + "- 智能体逻辑改动,不影响 API 格式\n", + "- API 部署方式改动,不影响智能体核心\n", + "- 测试时可以单独测试核心函数,也可以测试接口函数" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. FastAPI 部署示意\n", + "\n", + "真实项目中,经常会用 FastAPI 把智能体包装成 HTTP 服务。\n", + "\n", + "下面是示意代码。这里放在 Markdown 中展示,不要求你现在必须运行。\n", + "\n", + "```python\n", + "from fastapi import FastAPI\n", + "from pydantic import BaseModel\n", + "\n", + "app = FastAPI()\n", + "\n", + "class ChatRequest(BaseModel):\n", + " message: str\n", + "\n", + "@app.post('/chat')\n", + "def chat(request: ChatRequest):\n", + " result = run_learning_assistant(request.message)\n", + " return result\n", + "```\n", + "\n", + "如果保存为 `app.py`,通常可以用下面命令启动:\n", + "\n", + "```bash\n", + "uvicorn app:app --host 0.0.0.0 --port 8000\n", + "```\n", + "\n", + "启动后,其他程序就可以通过接口调用智能体。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. Docker 和云平台部署怎么理解\n", + "\n", + "当项目只在自己电脑上运行时,问题通常不大。\n", + "\n", + "但交给别人运行时,经常会出现:\n", + "\n", + "- Python 版本不同\n", + "- 依赖包没安装\n", + "- 环境变量没配置\n", + "- 系统路径不一致\n", + "\n", + "Docker 的作用就是把运行环境打包起来。\n", + "\n", + "可以把 Docker 镜像理解成一个“装好环境的盒子”。别人只要运行这个盒子,就能得到接近一致的环境。\n", + "\n", + "云平台部署则是在远程服务器上运行这个应用,让更多用户可以访问。\n", + "\n", + "一个常见上线链路是:\n", + "\n", + "```text\n", + "本地开发 -> 写 requirements.txt -> 写 Dockerfile -> 构建镜像 -> 部署到云服务器\n", + "```\n", + "\n", + "本课程重点是智能体开发,所以这里只需要理解部署思路,不要求一次掌握所有运维细节。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7. 为什么要评估智能体\n", + "\n", + "部署解决的是“能不能给别人用”。\n", + "\n", + "评估解决的是“用得好不好”。\n", + "\n", + "智能体评估通常关注:\n", + "\n", + "| 指标 | 含义 | 示例 |\n", + "| --- | --- | --- |\n", + "| 正确性 | 回答是否正确 | 是否解释对了概念 |\n", + "| 相关性 | 是否回答了用户问题 | 问学习计划,不要回复天气 |\n", + "| 完整性 | 回答是否覆盖关键点 | 学习计划是否有步骤 |\n", + "| 稳定性 | 多次运行是否表现一致 | 同类问题是否走同类分支 |\n", + "| 成本 | 调用模型的费用和耗时 | token、延迟、API 成本 |\n", + "| 安全性 | 是否避免危险输出 | 不泄露密钥,不执行高风险操作 |\n", + "\n", + "初学阶段可以先做最简单的评估:准备一批测试问题,看系统是否识别出正确意图。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 8. 构建一个简单评估集\n", + "\n", + "评估集就是提前准备好的测试样例。\n", + "\n", + "每条样例通常包含:\n", + "\n", + "- 输入问题\n", + "- 期望结果\n", + "\n", + "下面我们先评估意图识别是否正确。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "eval_dataset = [\n", + " {'input': '帮我制定 LangGraph 学习计划', 'expected_intent': 'plan'},\n", + " {'input': '请安排一个 AI 学习路线', 'expected_intent': 'plan'},\n", + " {'input': '解释一下什么是条件边', 'expected_intent': 'explain'},\n", + " {'input': '什么是智能体状态', 'expected_intent': 'explain'},\n", + " {'input': '你好,你能做什么', 'expected_intent': 'chat'},\n", + "]\n", + "\n", + "print('评估集数量:', len(eval_dataset))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码定义了一个小型评估集。\n", + "\n", + "#### `eval_dataset`\n", + "它是一个列表,里面每个元素都是一个测试样例。\n", + "\n", + "每个样例有两个字段:\n", + "\n", + "- `input`:用户输入\n", + "- `expected_intent`:期望识别出的意图\n", + "\n", + "#### 为什么先评估意图\n", + "因为当前学习助手的核心流程是:\n", + "\n", + "```text\n", + "输入 -> 意图识别 -> 分支处理 -> 回复\n", + "```\n", + "\n", + "如果意图识别错了,后面的节点也会走错。\n", + "\n", + "所以意图识别是一个很适合入门评估的指标。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 9. 自动运行评估\n", + "\n", + "有了评估集后,就可以写代码自动跑一遍,并计算准确率。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def evaluate_intent(dataset):\n", + " records = []\n", + " correct_count = 0\n", + "\n", + " for item in dataset:\n", + " result = run_learning_assistant(item['input'])\n", + " predicted = result['intent']\n", + " expected = item['expected_intent']\n", + " is_correct = predicted == expected\n", + "\n", + " if is_correct:\n", + " correct_count += 1\n", + "\n", + " records.append({\n", + " 'input': item['input'],\n", + " 'expected': expected,\n", + " 'predicted': predicted,\n", + " 'correct': is_correct\n", + " })\n", + "\n", + " accuracy = correct_count / len(dataset)\n", + " return accuracy, records\n", + "\n", + "accuracy, records = evaluate_intent(eval_dataset)\n", + "print('准确率:', accuracy)\n", + "for record in records:\n", + " print(record)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码完成了一个最小自动评估流程。\n", + "\n", + "#### `evaluate_intent(dataset)`\n", + "这个函数接收评估集,然后逐条测试。\n", + "\n", + "#### `result = run_learning_assistant(item['input'])`\n", + "每条测试样例都会调用一次智能体核心函数。\n", + "\n", + "这和真实用户调用项目的方式是一致的。\n", + "\n", + "#### `predicted` 和 `expected`\n", + "- `predicted` 是系统实际识别出的意图\n", + "- `expected` 是我们提前标注的正确意图\n", + "\n", + "如果两者相等,就认为这条样例通过。\n", + "\n", + "#### `accuracy`\n", + "准确率计算公式是:\n", + "\n", + "```text\n", + "准确率 = 正确数量 / 总样例数量\n", + "```\n", + "\n", + "#### `records`\n", + "除了给出总分,还要保存每条样例的详细结果。\n", + "\n", + "因为只有总分不够。我们还需要知道到底哪些问题错了,才能继续优化。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 10. 记录运行日志\n", + "\n", + "部署后,用户的问题可能非常多。\n", + "\n", + "如果没有日志,出问题时就很难排查。\n", + "\n", + "日志至少应该记录:\n", + "\n", + "- 用户输入\n", + "- 系统输出\n", + "- 识别出的意图\n", + "- 是否成功\n", + "- 运行时间\n", + "\n", + "下面用一个简单包装函数演示如何记录日志。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import time\n", + "\n", + "run_logs = []\n", + "\n", + "def run_with_trace(user_input):\n", + " start_time = time.time()\n", + "\n", + " try:\n", + " result = run_learning_assistant(user_input)\n", + " success = True\n", + " error = ''\n", + " except Exception as exc:\n", + " result = {'input': user_input, 'intent': '', 'response': ''}\n", + " success = False\n", + " error = str(exc)\n", + "\n", + " latency = time.time() - start_time\n", + "\n", + " log = {\n", + " 'input': user_input,\n", + " 'intent': result['intent'],\n", + " 'response': result['response'],\n", + " 'success': success,\n", + " 'error': error,\n", + " 'latency_seconds': round(latency, 4)\n", + " }\n", + "\n", + " run_logs.append(log)\n", + " return result\n", + "\n", + "run_with_trace('解释一下什么是 LangGraph')\n", + "run_with_trace('帮我安排学习计划')\n", + "\n", + "for log in run_logs:\n", + " print(log)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码演示了最基础的追踪思路。\n", + "\n", + "#### `run_logs`\n", + "这是一个列表,用来保存运行日志。\n", + "\n", + "真实项目中,日志通常会写入文件、数据库或日志平台。\n", + "\n", + "#### `start_time = time.time()`\n", + "记录开始时间,用来计算本次调用耗时。\n", + "\n", + "#### `try ... except ...`\n", + "部署后的系统不能因为一次错误就直接崩溃。\n", + "\n", + "所以这里用 `try ... except` 捕获异常,并把错误信息写入日志。\n", + "\n", + "#### `latency_seconds`\n", + "表示运行耗时。\n", + "\n", + "对于真实大模型应用,耗时是非常重要的指标。\n", + "\n", + "用户不仅关心回答对不对,也关心等多久。\n", + "\n", + "#### 追踪的价值\n", + "日志可以帮助我们回答:\n", + "\n", + "- 用户都问了什么?\n", + "- 哪些问题经常失败?\n", + "- 哪类意图识别不准?\n", + "- 哪些请求耗时很长?\n", + "\n", + "这些信息是后续优化智能体的重要依据。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 11. LangSmith 是做什么的\n", + "\n", + "LangSmith 是 LangChain 生态中的调试、追踪和评估平台。\n", + "\n", + "你可以把它理解成智能体应用的“运行记录仪”。\n", + "\n", + "它可以帮助你查看:\n", + "\n", + "- 每次调用输入了什么\n", + "- 调用了哪些链、工具或模型\n", + "- 每一步输出是什么\n", + "- 哪一步出错\n", + "- 每次调用耗时多久\n", + "- token 和成本情况\n", + "\n", + "本节我们不强制接入 LangSmith,因为它需要账号和环境变量配置。\n", + "\n", + "但你需要理解它解决的问题:**让智能体运行过程可观察、可追踪、可评估。**\n", + "\n", + "在真实项目中,如果智能体流程比较复杂,建议尽早接入类似追踪工具。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 12. 上线前检查清单\n", + "\n", + "智能体项目上线前,建议检查下面这些内容。\n", + "\n", + "| 检查项 | 说明 |\n", + "| --- | --- |\n", + "| 环境变量 | API Key、Base URL 是否配置正确 |\n", + "| 依赖版本 | requirements 是否完整 |\n", + "| 基础测试 | 核心函数是否能正常运行 |\n", + "| 评估集 | 常见问题是否通过测试 |\n", + "| 错误处理 | 空输入、异常情况是否能返回友好提示 |\n", + "| 日志追踪 | 是否能记录输入、输出和错误 |\n", + "| 安全检查 | 是否避免泄露密钥和执行危险操作 |\n", + "| 成本控制 | 是否限制过长输入和频繁调用 |\n", + "\n", + "这个清单不复杂,但很实用。\n", + "\n", + "它能帮助你从“代码能跑”过渡到“项目能交付”。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 13. 本节小结\n", + "\n", + "本节最重要的内容有五点:\n", + "\n", + "1. **部署是把智能体从个人代码变成别人可使用的服务**\n", + "2. **核心逻辑应该先封装成稳定函数,再接命令行、API 或 Web 界面**\n", + "3. **评估集可以帮助我们判断智能体是否稳定、准确**\n", + "4. **日志和追踪可以帮助我们排查问题、分析用户行为、持续优化系统**\n", + "5. **LangSmith 这类工具可以让复杂智能体流程变得可观察、可评估**\n", + "\n", + "到这里,一个智能体项目就不只是能运行的 demo,而是开始具备工程化交付能力。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 14. 本节练习\n", + "\n", + "1. 给 `eval_dataset` 增加 5 条新的测试样例\n", + "2. 修改 `run_learning_assistant`,增加一个 `summary` 意图\n", + "3. 修改评估函数,同时评估 `intent` 和 `response` 中是否包含关键字\n", + "4. 给 `run_with_trace` 增加一个 `timestamp` 字段,记录调用时间\n", + "5. 思考:如果这个助手要部署给 100 个用户使用,还需要补充哪些能力?" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/28_课程总结.ipynb b/28_课程总结.ipynb new file mode 100644 index 0000000..fe12046 --- /dev/null +++ b/28_课程总结.ipynb @@ -0,0 +1,557 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 28_课程总结:从入门到实践的 AI 智能体学习路线\n", + "\n", + "## 学习目标\n", + "1. 回顾课程核心知识点,形成完整的知识体系\n", + "2. 了解 AI 智能体领域的前沿发展趋势和拓展方向\n", + "3. 规划后续自主学习路径和实践建议\n", + "\n", + "本节课不是学习一个全新的技术点,而是把前面学过的内容串起来,帮助大家形成一张完整的“AI 智能体知识地图”。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. 这门课到底学了什么\n", + "\n", + "如果用一句话总结这门课:\n", + "\n", + "> 我们学习了如何把大模型从“聊天机器人”升级为“能够使用工具、执行流程、完成任务的智能体”。\n", + "\n", + "普通大模型更像一个会回答问题的人,而智能体更像一个会办事的助手。它不只是生成文字,还可以:\n", + "\n", + "- 理解用户目标\n", + "- 拆解任务步骤\n", + "- 调用外部工具\n", + "- 读取和检索资料\n", + "- 根据中间结果继续决策\n", + "- 最终交付完整结果\n", + "\n", + "所以,智能体的核心不是“模型有多聪明”,而是“模型能否被组织进一个可靠的工作流程中”。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. AI 智能体的核心知识地图\n", + "\n", + "可以把整个课程内容分成 6 个层次:\n", + "\n", + "| 层次 | 核心内容 | 通俗理解 |\n", + "| --- | --- | --- |\n", + "| 大模型基础 | Prompt、模型调用、消息格式 | 让模型听懂我们的问题 |\n", + "| LangChain | 链、提示词、工具、RAG | 把模型能力组件化 |\n", + "| 工具调用 | 搜索、计算、文件读取、API | 让模型能使用外部能力 |\n", + "| RAG 知识库 | 文档切分、向量检索、基于资料回答 | 让模型先查资料再回答 |\n", + "| LangGraph | State、Node、Edge、条件分支 | 把智能体流程画成流程图 |\n", + "| 项目实战 | 知识库助手、多工具智能体 | 把知识点组合成完整应用 |\n", + "\n", + "学习时不要只记住某个库的 API,更重要的是理解这些模块分别解决什么问题。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. 回顾一:Prompt 是智能体的起点\n", + "\n", + "Prompt 可以理解为我们给大模型的任务说明书。\n", + "\n", + "一个好的 Prompt 通常会说明:\n", + "\n", + "1. 你是谁:模型要扮演什么角色\n", + "2. 你要做什么:具体任务是什么\n", + "3. 你依据什么:是否需要参考资料\n", + "4. 你怎么输出:输出格式有什么要求\n", + "\n", + "下面用一个简单函数模拟 Prompt 拼接。\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def build_prompt(role, task, context, output_format):\n", + " prompt = f\"角色:{role}\\n\"\n", + " prompt += f\"任务:{task}\\n\"\n", + " prompt += f\"参考资料:{context}\\n\"\n", + " prompt += f\"输出格式:{output_format}\"\n", + " return prompt\n", + "\n", + "\n", + "prompt = build_prompt(\n", + " role=\"AI 智能体课程助教\",\n", + " task=\"解释什么是工具调用\",\n", + " context=\"工具调用是指模型根据任务选择并使用外部函数或 API。\",\n", + " output_format=\"用三句话通俗解释\",\n", + ")\n", + "\n", + "print(prompt)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码用于复习 Prompt 的基本组成:\n", + "\n", + "1. `build_prompt` 是一个提示词构造函数。\n", + "2. `role` 表示模型扮演的角色,例如课程助教、数据分析师、代码助手。\n", + "3. `task` 表示模型要完成的具体任务。\n", + "4. `context` 表示参考资料,能够减少模型凭空发挥。\n", + "5. `output_format` 表示输出格式要求,可以让答案更稳定。\n", + "6. 函数内部通过字符串拼接生成完整 Prompt。\n", + "\n", + "真实项目中,Prompt 往往不是随便写一句话,而是要像写任务说明书一样清晰。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. 回顾二:工具调用让智能体拥有外部能力\n", + "\n", + "大模型擅长理解和生成语言,但不擅长所有事情。\n", + "\n", + "例如数学计算、实时搜索、文件读取、数据库查询等任务,更适合交给工具完成。\n", + "\n", + "工具调用的基本流程是:\n", + "\n", + "```text\n", + "用户问题 -> 判断是否需要工具 -> 选择工具 -> 执行工具 -> 整理结果\n", + "```\n", + "\n", + "下面用一个简化例子复习工具选择和调用。\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def calculate(expression):\n", + " return eval(expression)\n", + "\n", + "\n", + "def search_knowledge(keyword):\n", + " knowledge = {\n", + " \"LangChain\": \"LangChain 用于构建大模型应用。\",\n", + " \"LangGraph\": \"LangGraph 用于编排多步骤智能体流程。\",\n", + " }\n", + " return knowledge.get(keyword, \"没有找到相关知识。\")\n", + "\n", + "\n", + "def simple_tool_agent(question):\n", + " if \"计算\" in question:\n", + " expression = question.replace(\"计算\", \"\").strip()\n", + " result = calculate(expression)\n", + " return f\"我使用了计算工具,结果是:{result}\"\n", + "\n", + " if \"LangGraph\" in question:\n", + " result = search_knowledge(\"LangGraph\")\n", + " return f\"我使用了知识查询工具,结果是:{result}\"\n", + "\n", + " return \"这个问题暂时不需要调用工具。\"\n", + "\n", + "\n", + "print(simple_tool_agent(\"计算 25 * 4 + 8\"))\n", + "print(simple_tool_agent(\"LangGraph 是什么?\"))\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码复习了工具调用的基本思想:\n", + "\n", + "1. `calculate(expression)` 是计算工具,负责执行数学表达式。\n", + "2. `search_knowledge(keyword)` 是知识查询工具,负责从字典中查找资料。\n", + "3. `simple_tool_agent(question)` 是一个简单智能体入口。\n", + "4. 如果问题中包含“计算”,就提取表达式并调用计算工具。\n", + "5. 如果问题中包含 `LangGraph`,就调用知识查询工具。\n", + "6. 最终返回时,会说明使用了哪个工具以及工具结果。\n", + "\n", + "这里为了复习写得很简单。真实项目中,不建议直接对用户输入使用 `eval`,应使用更安全的计算库或严格校验。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. 回顾三:RAG 让模型基于资料回答\n", + "\n", + "RAG 是构建知识库问答系统时非常重要的技术。\n", + "\n", + "它解决的问题是:大模型不知道你的私有资料,也可能记错公开知识。\n", + "\n", + "RAG 的思路很简单:\n", + "\n", + "1. 先把资料放入知识库\n", + "2. 用户提问时先检索相关资料\n", + "3. 再让模型基于检索资料回答\n", + "\n", + "下面用纯 Python 复习一个最小 RAG 流程。\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "documents = [\n", + " \"RAG 的核心是先检索资料,再基于资料生成答案。\",\n", + " \"LangChain 提供文档加载、文本切分、检索器和链等能力。\",\n", + " \"LangGraph 适合构建有状态、多步骤、可分支的智能体流程。\",\n", + "]\n", + "\n", + "\n", + "def retrieve_by_keyword(question, documents):\n", + " scored_docs = []\n", + " for doc in documents:\n", + " score = len(set(question) & set(doc))\n", + " scored_docs.append((score, doc))\n", + "\n", + " scored_docs.sort(reverse=True, key=lambda item: item[0])\n", + " return scored_docs[0][1]\n", + "\n", + "\n", + "def rag_answer(question):\n", + " context = retrieve_by_keyword(question, documents)\n", + " return f\"问题:{question}\\n参考资料:{context}\\n回答:请优先根据参考资料作答。\"\n", + "\n", + "\n", + "print(rag_answer(\"RAG 的核心流程是什么?\"))\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码复习了 RAG 的最小流程:\n", + "\n", + "1. `documents` 模拟知识库中的资料。\n", + "2. `retrieve_by_keyword` 是简化检索器,会给每条资料计算相关度分数。\n", + "3. `set(question) & set(doc)` 表示问题和文档中共同出现的字符。\n", + "4. `scored_docs.sort(...)` 按分数从高到低排序。\n", + "5. `rag_answer(question)` 先检索最相关资料,再把资料作为参考内容组织成回答。\n", + "\n", + "真实 RAG 项目通常会把关键词检索换成向量检索,把简单回答拼接换成大模型生成。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. 回顾四:LangGraph 让流程更清晰\n", + "\n", + "当智能体只有一步时,普通函数就够了。\n", + "\n", + "但当智能体需要多步骤执行、条件判断、循环反思、工具协作时,就需要更清晰的流程编排。\n", + "\n", + "LangGraph 的核心概念可以简单理解为:\n", + "\n", + "| 概念 | 作用 | 通俗理解 |\n", + "| --- | --- | --- |\n", + "| State | 保存流程中的数据 | 任务档案袋 |\n", + "| Node | 一个处理步骤 | 流程图里的方框 |\n", + "| Edge | 节点之间的连接 | 流程图里的箭头 |\n", + "| Conditional Edge | 条件分支 | 根据情况走不同路线 |\n", + "\n", + "下面不用依赖 LangGraph,先用普通 Python 模拟这种状态流转思想。\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def plan_node(state):\n", + " goal = state[\"goal\"]\n", + " state[\"plan\"] = [f\"理解目标:{goal}\", \"执行任务\", \"汇总结果\"]\n", + " return state\n", + "\n", + "\n", + "def execute_node(state):\n", + " state[\"result\"] = [f\"已完成:{step}\" for step in state[\"plan\"]]\n", + " return state\n", + "\n", + "\n", + "def summary_node(state):\n", + " state[\"answer\"] = \"\\n\".join(state[\"result\"])\n", + " return state\n", + "\n", + "\n", + "state = {\"goal\": \"复习 AI 智能体课程\"}\n", + "state = plan_node(state)\n", + "state = execute_node(state)\n", + "state = summary_node(state)\n", + "\n", + "print(state[\"answer\"])\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这段代码用普通 Python 模拟 LangGraph 的状态流转:\n", + "\n", + "1. `state` 是一个字典,相当于工作流中的共享状态。\n", + "2. `plan_node(state)` 模拟规划节点,会根据目标生成计划。\n", + "3. `execute_node(state)` 模拟执行节点,会执行计划中的每个步骤。\n", + "4. `summary_node(state)` 模拟汇总节点,会把结果整理成最终答案。\n", + "5. 每个节点都接收 `state`,修改后再返回 `state`。\n", + "\n", + "LangGraph 做的事情更规范、更强大,但底层思想就是让数据沿着流程图一步步流动。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7. 一个智能体项目的标准开发流程\n", + "\n", + "开发智能体项目时,可以按照下面的步骤推进:\n", + "\n", + "1. 明确用户需求:用户到底要解决什么问题\n", + "2. 设计输入输出:用户输入什么,系统输出什么\n", + "3. 拆分功能模块:需要模型、工具、检索还是工作流\n", + "4. 先做最小版本:用简单规则和模拟数据跑通流程\n", + "5. 替换真实能力:接入真实模型、数据库、搜索 API 等\n", + "6. 增加调试日志:观察每一步中间结果\n", + "7. 优化稳定性:处理异常、补充测试、改进提示词\n", + "\n", + "初学者最容易犯的错误是:一上来就想做完整系统。更推荐先做 MVP,再逐步升级。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 8. 智能体常见项目类型\n", + "\n", + "学完本课程后,可以尝试这些项目方向:\n", + "\n", + "| 项目类型 | 能力重点 | 示例 |\n", + "| --- | --- | --- |\n", + "| 知识库问答助手 | RAG、文档检索 | 课程资料问答、企业制度问答 |\n", + "| 多工具助手 | 工具选择、工具调用 | 搜索 + 计算 + 文件读取助手 |\n", + "| 自动报告生成器 | 任务分解、结果汇总 | 周报生成、调研报告生成 |\n", + "| 数据分析智能体 | 代码执行、图表生成 | 自动分析表格并输出结论 |\n", + "| 工作流智能体 | LangGraph、条件分支 | 审核流程、客服分流、任务调度 |\n", + "| 多智能体协作 | 角色分工、结果协同 | 产品经理 + 开发 + 测试协作 |\n", + "\n", + "建议从知识库问答和多工具助手开始,因为它们最容易理解,也最接近真实应用。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 9. AI 智能体的发展趋势\n", + "\n", + "AI 智能体还在快速发展,值得关注的方向包括:\n", + "\n", + "1. **更强的工具调用能力**:模型会更准确地选择工具、填写参数、处理工具错误。\n", + "2. **更可靠的工作流编排**:复杂任务会越来越依赖 LangGraph 这类流程框架。\n", + "3. **多模态智能体**:智能体不仅处理文字,还能理解图片、语音、视频和表格。\n", + "4. **企业级知识库**:RAG 会和权限控制、审计、数据治理结合得更紧密。\n", + "5. **多智能体协作**:多个角色智能体分工合作,完成更复杂的任务。\n", + "6. **本地化和私有化部署**:越来越多企业会关注数据安全和私有模型部署。\n", + "\n", + "但无论技术怎么变,底层能力仍然离不开:需求分析、任务拆解、工具设计、流程控制和结果验证。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 10. 后续学习路线建议\n", + "\n", + "可以按照下面路线继续学习:\n", + "\n", + "### 第一阶段:打牢基础\n", + "- 熟悉 Python 函数、类、字典、列表\n", + "- 掌握 API 调用和 JSON 数据格式\n", + "- 熟悉 Prompt 编写和调试\n", + "\n", + "### 第二阶段:掌握框架\n", + "- 学习 LangChain 的 Prompt、Chain、Tool、Retriever\n", + "- 学习 LangGraph 的 State、Node、Edge、条件分支\n", + "- 理解 RAG 的文档加载、切分、向量化、检索、生成\n", + "\n", + "### 第三阶段:完成项目\n", + "- 做一个个人知识库问答助手\n", + "- 做一个多工具协作助手\n", + "- 做一个带 LangGraph 工作流的任务调度智能体\n", + "\n", + "### 第四阶段:工程化\n", + "- 增加日志、测试和异常处理\n", + "- 接入数据库、缓存和权限控制\n", + "- 部署为 Web 服务或内部工具\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 11. 自我检查清单\n", + "\n", + "学完课程后,可以用下面的问题检查自己是否真正掌握:\n", + "\n", + "1. 我能否解释普通大模型应用和智能体应用的区别?\n", + "2. 我能否写出一个清晰的 Prompt 模板?\n", + "3. 我能否封装一个简单工具并让智能体调用?\n", + "4. 我能否说明 RAG 的完整流程?\n", + "5. 我能否用 State、Node、Edge 解释 LangGraph?\n", + "6. 我能否把一个复杂任务拆成多个节点?\n", + "7. 我能否通过日志定位智能体执行错误?\n", + "8. 我能否独立完成一个小型智能体项目?\n", + "\n", + "如果这些问题大部分都能回答清楚,说明你已经具备继续做项目的基础。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 12. 最后一个综合复习示例\n", + "\n", + "下面用一个小例子把 Prompt、工具调用、RAG 和流程编排思想合在一起。\n", + "\n", + "这个示例仍然是教学版,目标是帮助大家看清完整结构。\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "course_docs = [\n", + " \"智能体可以调用工具完成搜索、计算、文件读取等任务。\",\n", + " \"RAG 可以让智能体基于知识库资料回答问题。\",\n", + " \"LangGraph 可以把智能体任务拆成多个节点并按流程执行。\",\n", + "]\n", + "\n", + "\n", + "def course_retriever(question):\n", + " best_doc = max(course_docs, key=lambda doc: len(set(question) & set(doc)))\n", + " return best_doc\n", + "\n", + "\n", + "def course_calculator(expression):\n", + " return eval(expression)\n", + "\n", + "\n", + "def final_review_agent(question):\n", + " if \"计算\" in question:\n", + " expression = question.replace(\"计算\", \"\").strip()\n", + " result = course_calculator(expression)\n", + " return f\"我判断这是计算任务,调用计算工具后得到:{result}\"\n", + "\n", + " context = course_retriever(question)\n", + " prompt = build_prompt(\n", + " role=\"AI 智能体课程总结助手\",\n", + " task=f\"回答问题:{question}\",\n", + " context=context,\n", + " output_format=\"先给结论,再给一句解释\",\n", + " )\n", + " return prompt\n", + "\n", + "\n", + "print(final_review_agent(\"RAG 有什么作用?\"))\n", + "print(\"-\" * 40)\n", + "print(final_review_agent(\"计算 18 * 6 + 2\"))\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 代码解释\n", + "\n", + "这个综合示例把多个知识点串联起来:\n", + "\n", + "1. `course_docs` 是一个小型课程知识库。\n", + "2. `course_retriever(question)` 根据问题从知识库中找最相关的资料。\n", + "3. `course_calculator(expression)` 是计算工具。\n", + "4. `final_review_agent(question)` 是综合复习智能体入口。\n", + "5. 如果问题中包含“计算”,智能体会调用计算工具。\n", + "6. 如果不是计算任务,智能体会先检索课程资料,再调用前面写过的 `build_prompt` 生成提示词。\n", + "7. 这个流程体现了智能体的基本思想:先判断任务类型,再选择合适能力,最后组织输出。\n", + "\n", + "真实项目中,可以把这里的 `return prompt` 换成真实大模型调用,让模型基于 Prompt 生成最终回答。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 13. 课程总结\n", + "\n", + "这门课的重点不是让大家记住所有 API,而是建立一套解决智能体问题的思维方式:\n", + "\n", + "1. 先理解用户目标,再决定系统流程。\n", + "2. 能用普通函数解决的,先用普通函数跑通。\n", + "3. 需要模型生成时,再接入大模型。\n", + "4. 需要外部能力时,封装成工具。\n", + "5. 需要资料依据时,使用 RAG。\n", + "6. 需要多步骤流程时,使用 LangGraph。\n", + "7. 需要稳定上线时,补充日志、测试、异常处理和监控。\n", + "\n", + "真正的智能体开发,不是把所有新技术堆在一起,而是根据问题选择最合适、最简单、最可靠的方案。\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 14. 课后实践建议\n", + "\n", + "建议完成以下实践:\n", + "\n", + "1. 整理自己的学习笔记,做一个个人知识库问答助手。\n", + "2. 给知识库助手增加“显示参考来源”的功能。\n", + "3. 做一个多工具助手,至少支持搜索、计算和文件读取。\n", + "4. 用 LangGraph 重构多工具助手,把每一步拆成节点。\n", + "5. 给项目增加调试日志,记录每次工具选择和工具结果。\n", + "6. 尝试把项目封装成一个简单 Web 应用或命令行工具。\n", + "\n", + "完成这些练习后,你就不只是理解了 AI 智能体,而是已经具备了独立构建智能体应用的基础能力。\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.10.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..acc6b3d --- /dev/null +++ b/README.md @@ -0,0 +1,355 @@ +# AI 智能体开发(LangChain + LangGraph) + +## 课程简介 + +本课程面向具备 Python 基础语法的大学生和 AI 应用开发初学者,系统讲解如何使用 **大语言模型 API、LangChain、RAG、向量数据库、LangGraph** 构建 AI 智能体应用。 + +课程不是停留在“调用大模型聊天”,而是围绕真实智能体开发流程,帮助学习者理解并实践:如何把大模型、Prompt、工具、知识库、状态管理、流程控制、人机协作、评估与部署组织成一个可运行、可测试、可交付的 AI 应用系统。 + +课程主线如下: + +```text +开发环境 +→ 大模型 API 调用 +→ LangChain 基础组件 +→ Prompt / Chain / Parser / Tool +→ RAG 与向量数据库 +→ LangGraph 状态图 +→ 对话代理、任务调度、多智能体、人机协作 +→ 综合项目实战 +→ 部署、评估与课程总结 +``` + +## 课程定位 + +本课程定位为 **AI 智能体开发入门到项目实战课程**,重点培养以下能力: + +- 能够独立搭建 Python + Jupyter + LangChain + LangGraph 开发环境; +- 能够通过 OpenAI 兼容 API 调用大语言模型; +- 能够使用 Prompt、链式组合、输出解析器构建稳定的大模型应用; +- 能够定义工具,并让模型根据任务调用工具; +- 能够使用 RAG 和向量数据库构建知识库问答系统; +- 能够使用 LangGraph 构建有状态、可分支、可循环、可记忆的智能体流程; +- 能够理解多智能体协作、任务调度和人机协作机制; +- 能够完成从需求分析、MVP 实现到部署评估的完整项目闭环。 + +## 适合人群 + +适合以下学习者: + +- 具备 Python 基础语法的大学生; +- 想从大模型 API 调用进入 AI 应用开发的初学者; +- 想系统学习 LangChain、RAG、LangGraph 的开发者; +- 希望完成 AI 智能体项目作品的学生或工程实践者; +- 对工具调用、多智能体、人机协作、任务自动化感兴趣的学习者。 + +## 先修要求 + +建议学习者具备: + +- Python 基础语法; +- 函数、列表、字典、类、异常处理等基础知识; +- 基本命令行使用能力; +- Jupyter Notebook 基础使用经验; +- 对 HTTP API、JSON、环境变量有初步了解更佳。 + +## 技术栈 + +- **开发工具**:VS Code +- **编程语言**:Python 3.12.11 +- **虚拟环境**:venv +- **Notebook 环境**:Jupyter Notebook / ipykernel +- **主要框架**:LangChain、LangGraph +- **向量数据库**:ChromaDB +- **大语言模型**:`qwen3.6-35b-A3b` +- **向量模型**:`qwen3-embedding` +- **API 方式**:OpenAI 兼容 API +- **包管理**:pip + 清华大学 TUNA 镜像源 +- **部署与评估**:FastAPI、Docker、日志、评估集、LangSmith 概念 + +## 环境要求 + +- Python 3.10 及以上版本,推荐 Python 3.12.11 +- 已安装 pip +- 已安装 VS Code 和 Jupyter 相关插件 +- 具备 OpenAI 兼容 API 访问权限 +- 已获取可用的 `OPENAI_BASE_URL` 和 `OPENAI_API_KEY` + +## 默认模型配置 + +本课程默认统一使用以下模型: + +| 类型 | 默认模型 | 用途 | +| --- | --- | --- | +| 大语言模型 | `qwen3.6-35b-A3b` | 对话、生成、推理、工具调用、智能体节点 | +| 向量模型 | `qwen3-embedding` | 文档向量化、相似度检索、RAG 知识库 | + +如实际 API 服务不支持上述模型,可根据服务提供方说明替换为等价模型,但建议在整门课程中保持模型名称统一,避免调试混乱。 + +## 快速开始 + +### 1. 克隆本仓库 + +```bash +git clone <仓库地址> +cd AI智能体开发 +``` + +### 2. 创建虚拟环境 + +```bash +python -m venv .venv +``` + +### 3. 激活虚拟环境 + +Windows PowerShell: + +```powershell +.venv\Scripts\Activate.ps1 +``` + +macOS / Linux: + +```bash +source .venv/bin/activate +``` + +### 4. 安装依赖 + +```bash +pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple +``` + +### 5. 配置环境变量 + +在项目根目录创建 `.env` 文件: + +```bash +OPENAI_BASE_URL=https://your-base-url.com/v1 +OPENAI_API_KEY=sk-your-api-key +``` + +### 6. 选择 Jupyter 内核 + +在 VS Code 中选择 `.venv` 对应的 Python 解释器或 Jupyter Kernel,然后按顺序打开 `.ipynb` 课件学习。 + +## 学习目标 + +完成课程后,学习者应能够: + +1. 搭建 Python、VS Code、venv、Jupyter 的 AI 应用开发环境; +2. 使用 OpenAI 兼容 API 调用 `qwen3.6-35b-A3b`; +3. 掌握 PromptTemplate、ChatPromptTemplate、Few-shot Prompt 等提示模板方法; +4. 使用 LangChain 构建 LLM 链、结构化输出链和复杂 Runnable 链; +5. 定义计算、查询、文件处理等工具,并实现模型驱动的工具调用; +6. 使用 `qwen3-embedding`、ChromaDB 构建向量检索系统; +7. 构建完整 RAG 知识库问答应用; +8. 使用 LangGraph 构建状态图、节点、边、条件边、循环与记忆; +9. 实现对话代理、任务调度、多智能体协作和人机协作流程; +10. 完成知识库问答助手、多工具协作智能体、交互式智能体项目; +11. 理解智能体项目的部署、评估、日志、追踪和上线检查方法。 + +## 推荐学习路径 + +```text +01-02:环境准备 +03-04:大模型 API 与 LangChain 入门 +05-08:Prompt、LLM 链、输出解析器、链式组合 +09-10:工具定义与工具调用 +11-13:RAG、Embedding、向量数据库、知识库问答 +14-19:LangGraph 状态图基础 +20-23:高级智能体编排 +24-26:综合项目实战 +27-28:部署评估与课程总结 +``` + +> 注:从认知顺序看,建议学习者在学习第 05 课 LLM 链时,同时回看第 06 课 Prompt 模板。后续课程维护时,可考虑将 Prompt 模板提前到 LLM 链之前。 + +## 详细课程大纲 + +### 模块一:开发环境与 Python 项目配置 + +| 序号 | 课件 | 主题 | 核心产出 | +| --- | --- | --- | --- | +| 01 | `01_开发环境搭建.ipynb` | 开发环境搭建 | 可运行的本地 Python 虚拟环境 | +| 02 | `02_Python环境配置.ipynb` | Python 环境配置 | 可复现的 Notebook 开发环境 | + +本模块帮助学习者完成 VS Code、Python、venv、pip、Jupyter、依赖管理等基础准备,为后续运行大模型和智能体课件打好环境基础。 + +### 模块二:大模型 API 与 LangChain 入门 + +| 序号 | 课件 | 主题 | 核心产出 | +| --- | --- | --- | --- | +| 03 | `03_大模型API调用.ipynb` | 大模型 API 调用 | 可调用 `qwen3.6-35b-A3b` 的 Python 示例 | +| 04 | `04_LangChain概述.ipynb` | LangChain 概述 | 第一个 LangChain 调用链 | + +本模块介绍 OpenAI 兼容 API、环境变量配置、单轮对话、多轮对话、流式输出,以及 LangChain 的框架定位和组件化开发思想。 + +### 模块三:LangChain 核心组件 + +| 序号 | 课件 | 主题 | 核心产出 | +| --- | --- | --- | --- | +| 05 | `05_LLM链.ipynb` | LLM 链 | Prompt + LLM + Parser 基础链 | +| 06 | `06_Prompt模板.ipynb` | Prompt 模板 | 可复用的 Prompt 模板 | +| 07 | `07_输出解析器.ipynb` | 输出解析器 | 稳定结构化输出链 | +| 08 | `08_链式组合.ipynb` | 链式组合 | 多步骤、多分支 Runnable 流程 | + +本模块学习 LangChain 应用开发的核心组件,包括 Prompt 模板、模型调用、输出解析、Runnable、并行链、分支链和复杂链式组合。 + +### 模块四:工具定义与工具调用 + +| 序号 | 课件 | 主题 | 核心产出 | +| --- | --- | --- | --- | +| 09 | `09_工具定义.ipynb` | 工具定义 | 自定义工具集合 | +| 10 | `10_工具调用.ipynb` | 工具调用 | 能自动选择并调用工具的智能体 | + +本模块从 `@tool` 工具定义开始,逐步学习计算工具、查询工具、文件工具、多工具注册、模型选择工具、工具执行与最终答案生成。 + +建议在学习本模块后重点理解 Agent 的基本循环: + +```text +观察任务 → 思考方案 → 选择工具 → 执行工具 → 观察结果 → 生成答案 +``` + +### 模块五:RAG 与向量数据库 + +| 序号 | 课件 | 主题 | 核心产出 | +| --- | --- | --- | --- | +| 11 | `11_检索增强.ipynb` | 检索增强 | 简单本地 RAG 问答链 | +| 12 | `12_向量数据库.ipynb` | 向量数据库 | 基于 ChromaDB 的本地知识库检索 | +| 13 | `13_RAG构建.ipynb` | RAG 构建 | 多文档知识库问答系统 | + +本模块学习 RAG 的完整流程:文档加载、文本切分、`qwen3-embedding` 向量化、向量数据库存储、相似度检索、上下文构造、答案生成和检索效果评估。 + +推荐理解路径: + +```text +为什么需要 RAG +→ 文档如何切分 +→ 文本如何变成向量 +→ 向量数据库如何检索 +→ 检索结果如何交给大模型回答 +→ 如何评估 RAG 效果 +``` + +### 模块六:LangGraph 状态图基础 + +| 序号 | 课件 | 主题 | 核心产出 | +| --- | --- | --- | --- | +| 14 | `14_LangGraph概述.ipynb` | LangGraph 概述 | 最小 LangGraph 状态图 | +| 15 | `15_图结构.ipynb` | 图结构 | StateGraph 总体结构示例 | +| 16 | `16_状态管理.ipynb` | 状态管理 | 多字段业务状态设计 | +| 17 | `17_节点与边.ipynb` | 节点与边 | 多节点顺序执行图 | +| 18 | `18_条件边.ipynb` | 条件边 | 带条件判断的状态图 | +| 19 | `19_循环与记忆.ipynb` | 循环与记忆 | 支持循环和持久化状态的智能体流程 | + +本模块学习 LangGraph 的核心思想:用状态图管理复杂智能体流程。学习者将掌握 State、Node、Edge、START、END、条件边、循环、MemorySaver 和 thread_id 等关键概念。 + +### 模块七:高级智能体编排 + +| 序号 | 课件 | 主题 | 核心产出 | +| --- | --- | --- | --- | +| 20 | `20_多智能体架构.ipynb` | 多智能体架构 | 多智能体协作工作流 | +| 21 | `21_对话代理.ipynb` | 对话代理 | 支持上下文记忆的对话机器人 | +| 22 | `22_任务调度.ipynb` | 任务调度 | 自动化任务调度智能体 | +| 23 | `23_人机协作.ipynb` | 人机协作 | 带人工审批环节的智能体工作流 | + +本模块从单智能体流程扩展到多智能体协作,学习监督者模式、流水线模式、辩论模式、对话代理、任务拆解、任务调度、人机确认与审批流程。 + +### 模块八:综合项目实战 + +| 序号 | 课件 | 项目 | 核心产出 | +| --- | --- | --- | --- | +| 24 | `24_项目实战一.ipynb` | 个人知识库问答助手 | 知识库问答助手 MVP | +| 25 | `25_项目实战二.ipynb` | 多工具协作型智能体 | 多工具智能体 | +| 26 | `26_项目实战三.ipynb` | 带交互界面的学习任务助手 | 可交互智能体项目 | + +三个项目形成如下递进: + +1. **项目一:个人知识库问答助手** + 聚焦 RAG 思想,完成文档准备、检索、答案生成和参考资料返回。 + +2. **项目二:多工具协作型智能体** + 聚焦工具调用,完成搜索、计算、文件读取、工具选择、工具执行和结果汇总。 + +3. **项目三:带交互界面的学习任务助手** + 聚焦项目封装和交互体验,完成意图识别、条件路由、历史记录和命令行交互。 + +后续可将项目三进一步升级为综合收官项目:整合 RAG、多工具、记忆、条件路由、CLI / Web 交互和自动评估用例。 + +### 模块九:部署、评估与课程总结 + +| 序号 | 课件 | 主题 | 核心产出 | +| --- | --- | --- | --- | +| 27 | `27_部署与评估.ipynb` | 部署与评估 | 智能体上线检查与评估流程 | +| 28 | `28_课程总结.ipynb` | 课程总结 | 后续学习路线与完整知识框架 | + +本模块帮助学习者理解智能体应用如何从 Notebook Demo 走向工程交付,包括函数封装、命令行运行、API 服务、Docker、云部署、评估集、日志、追踪和上线检查清单。 + +## 实践项目列表 + +| 项目 | 对应课件 | 项目类型 | 关键能力 | +| --- | --- | --- | --- | +| 第一个大模型 API 对话程序 | `03_大模型API调用.ipynb` | API 入门 | 环境变量、单轮/多轮/流式调用 | +| 第一个 LangChain 程序 | `04_LangChain概述.ipynb` | 框架入门 | ChatOpenAI、Prompt、LCEL | +| 结构化输出链 | `07_输出解析器.ipynb` | 输出解析 | JSON、Pydantic、格式约束 | +| 智能客服工单处理链 | `08_链式组合.ipynb` | 链式组合 | 分类、摘要、并行分析、回复建议 | +| 多功能工具集合 | `09_工具定义.ipynb` | 工具定义 | 计算、查询、文件处理工具 | +| 工具调用型助手 | `10_工具调用.ipynb` | 工具智能体 | 工具选择、工具执行、结果汇总 | +| 私有文档问答系统 | `11_检索增强.ipynb` | RAG 入门 | 文档加载、切分、检索、生成 | +| ChromaDB 知识库 | `12_向量数据库.ipynb` | 向量数据库 | 持久化、相似度检索、元数据过滤 | +| 多文档 RAG 系统 | `13_RAG构建.ipynb` | 完整 RAG | 多文档加载、检索评估、问答生成 | +| 多智能体协作流程 | `20_多智能体架构.ipynb` | 多智能体 | 监督者、流水线、辩论、协作 | +| 对话代理机器人 | `21_对话代理.ipynb` | 对话智能体 | messages、检查点、多轮记忆 | +| 任务调度智能体 | `22_任务调度.ipynb` | 工作流智能体 | 任务拆解、执行、汇总 | +| 人工审批工作流 | `23_人机协作.ipynb` | Human-in-the-loop | interrupt、resume、人工确认 | +| 个人知识库问答助手 | `24_项目实战一.ipynb` | 综合项目一 | RAG、检索、回答、依据展示 | +| 多工具协作型智能体 | `25_项目实战二.ipynb` | 综合项目二 | 搜索、计算、文件读取、工具选择 | +| 学习任务助手 | `26_项目实战三.ipynb` | 综合项目三 | 意图识别、条件路由、历史记录、交互 | +| 智能体部署与评估流程 | `27_部署与评估.ipynb` | 工程化项目 | API、评估集、日志、追踪、上线检查 | + +## 课程优化说明 + +本课程已按以下原则进行优化: + +- 统一课程主线模型:LLM 使用 `qwen3.6-35b-A3b`,Embedding 使用 `qwen3-embedding`; +- 清理 Notebook 已保存运行输出,避免残留报错、API Key 前缀和本地运行结果干扰学习; +- 补齐 `requirements.txt` 中课程实际使用的关键依赖; +- 将 Claude / Anthropic 调用作为扩展阅读,不作为课程主线默认运行内容; +- 修正项目实战中的模型注释示例,避免出现与课程默认模型不一致的示例; +- README 课程目录与实际项目内容保持一致。 + +## 学习建议 + +1. 先完成 01-02 环境配置,确保 `.venv` 和 Jupyter Kernel 正确; +2. 学习 03 时重点确认 `.env` 中的 API 配置是否可用; +3. 学习 05-08 时重点掌握 Prompt、链、解析器和 Runnable 的组合方式; +4. 学习 09-10 时重点理解“工具让模型获得外部能力”; +5. 学习 11-13 时重点理解 RAG 的每个环节,而不是只运行最终代码; +6. 学习 14-19 时建议画流程图辅助理解 LangGraph 的状态、节点和边; +7. 学习 20-23 时关注单智能体到多智能体、人机协作的演进; +8. 学习 24-26 时建议主动扩展项目数据和功能,形成自己的项目作品; +9. 学习 27-28 时重点整理项目部署、评估、日志和后续学习路线。 + +## 注意事项 + +- 每份课件都是独立的 `.ipynb` 文件,建议按顺序学习; +- 运行课件前请确保虚拟环境已激活,并且 Jupyter 内核选择为 `.venv`; +- 涉及大模型 API 的课件需要正确配置 `OPENAI_BASE_URL` 和 `OPENAI_API_KEY`; +- 涉及 RAG 和向量数据库的课件默认使用 `qwen3-embedding`; +- 如果 API 服务不支持工具调用、流式输出或指定模型,请根据服务商文档调整对应参数; +- 项目实战中的部分案例为了教学稳定性使用规则模拟,学习者可在掌握流程后替换为真实 API 或真实数据源。 + +## 后续拓展方向 + +完成本课程后,可以继续学习: + +- 更复杂的 ReAct Agent 和规划型 Agent; +- 多模态智能体; +- 企业知识库 RAG 优化; +- Agent 评估体系与自动化测试; +- LangGraph 生产级工作流; +- MCP 工具生态; +- Streamlit / Gradio / FastAPI 智能体 Web 应用; +- 智能体权限控制、审计、安全与成本优化。