837 lines
26 KiB
Plaintext
837 lines
26 KiB
Plaintext
{
|
||
"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
|
||
}
|