阅读说明:这是一份照抄就能跑的教程。每一步都保证你按顺序粘贴代码能看到预期输出。不要跳步;前一步跑通了再进下一步,不然你会在第 8 步卡住。
怎么用这份教程
每一步结构固定:
🎯 本步目标 → 📚 前置 → 🧠 概念(白话)
→ 💻 完整代码 → ✅ 验证 → ⚠️ 常见报错
→ 🧪 5 分钟小练习 → 📍 检查点最重要的原则:每一步都要亲手跑通。光看不动手,5 小时后你会发现"我好像懂又好像不懂"——那就是没学会。
准备:建一个干净的项目
在终端里跑一次,之后所有代码都在这个目录下写:
mkdir langchain-practice && cd langchain-practice
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install --upgrade pip
pip install \
"langchain>=0.3" \
"langchain-openai>=0.2" \
"langchain-community>=0.3" \
"langgraph>=0.2" \
"python-dotenv"新建 .env 文件:
OPENAI_API_KEY=sk-xxxxxxxxxxxxxx
# 如果用 DeepSeek:
# OPENAI_API_KEY=sk-xxxxxxxxxxxxxx
# OPENAI_BASE_URL=https://api.deepseek.com/v1为什么这样做:虚拟环境让这个项目的依赖不污染全局;.env 让你的 key 不进 git。
Step 1 · 第一次调 LLM(15 分钟)
🎯 本步目标
能用 3 行代码让模型回答一个问题,并看到它流式地吐字。
📚 前置
- 你有
OPENAI_API_KEY。 - 你会在终端里跑
python xxx.py。
🧠 白话概念
LangChain 最底层的东西叫 ChatModel。它就是一个对象,你传消息进去,它回消息给你。就这么简单。
把它想成一个函数:
llm(messages) -> message。后面所有花哨的东西都围绕"怎么准备 messages、怎么处理 message 出来"转。
💻 完整代码
新建 step01_hello.py:
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
load_dotenv() # 从 .env 读 OPENAI_API_KEY
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
# 如果用 DeepSeek:model="deepseek-chat"
resp = llm.invoke("用一句话介绍 LangChain")
print(resp.content)跑:
python step01_hello.py✅ 验证
你应该看到类似这样的输出(具体文字会不同):
LangChain 是一个用于构建基于大语言模型的应用程序的框架...如果看到了,恭喜,你已经会用 LLM 了。剩下的都是怎么组织 messages、怎么处理输出。
⚠️ 常见报错
| 错误 | 原因 | 解法 |
|---|---|---|
openai.AuthenticationError | API Key 不对 | 检查 .env 和 echo $OPENAI_API_KEY |
ModuleNotFoundError: langchain_openai | 没装依赖或没进虚拟环境 | source .venv/bin/activate 再 pip install |
openai.APIConnectionError | 网络问题 | 走代理或换 DeepSeek 等国内镜像 |
🧪 5 分钟小练习
改代码让模型流式输出(一个字一个字吐):
for chunk in llm.stream("讲个冷笑话"):
print(chunk.content, end="", flush=True)📍 检查点
你应该能回答:
- ChatModel 的两个最基本方法是什么?(
invoke/stream) temperature=0意味着什么?(输出更确定,适合做判断题;改大更有创意)
Step 2 · 理解 LCEL 的管道 |(30 分钟)
🎯 本步目标
看懂 prompt | llm | parser 这三根竖线是什么意思,并能自己组一条。
📚 前置
- Step 1 已跑通。
🧠 白话概念
LangChain Expression Language(LCEL)就一句话:用 | 把几个"可调用对象"串起来,左边的输出就是右边的输入。
prompt | llm | parser
↓
(字典) → 提示词 → AI 消息 → 字符串类比 Linux 管道 cat file | grep foo | wc -l,一模一样的心智模型。
💻 完整代码
新建 step02_lcel.py:
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
load_dotenv()
# 1. Prompt:把字典变成一条消息
prompt = ChatPromptTemplate.from_messages([
("system", "你是简洁的助手,只用一句话回答。"),
("user", "{question}"),
])
# 2. LLM:把消息变成 AI 消息
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
# 3. Parser:把 AI 消息变成字符串
parser = StrOutputParser()
# 4. 串起来
chain = prompt | llm | parser
# 5. 用字典喂入
print(chain.invoke({"question": "什么是 RAG?"}))
print("-" * 40)
print(chain.invoke({"question": "Python 的装饰器是什么?"}))✅ 验证
两个问题各给一行简洁的回答。
⚠️ 常见报错
KeyError: 'question'→ prompt 里写的占位符名和 invoke 的 key 不一致。- 输出是
AIMessage(content=...)而不是字符串 → 你忘了加StrOutputParser()。
🧪 5 分钟小练习
把 parser 换成 JsonOutputParser,让模型返回 {"title": ..., "tags": [...]}:
from langchain_core.output_parsers import JsonOutputParser
prompt = ChatPromptTemplate.from_messages([
("system", "你把问题分类。必须严格返回 JSON:{{\"title\":\"...\",\"tags\":[\"...\"]}}"),
("user", "{question}"),
])
chain = prompt | llm | JsonOutputParser()
print(chain.invoke({"question": "怎么学好 Python?"}))(注意 system 里的 {{ 和 }} 是为了在模板里转义真实的花括号。)
📍 检查点
|左右两边的类型必须怎么对接?(左边输出 = 右边输入)- Prompt 接受的输入是什么?(dict)
- 最常用的两个 parser 是什么?(StrOutputParser / JsonOutputParser)
Step 3 · 结构化输出(30 分钟)
🎯 本步目标
让模型强制返回指定结构,不是靠"求"它返回 JSON,而是协议层面约束。
📚 前置
- Step 2 跑通。
- 会写简单的 Pydantic
BaseModel。
🧠 白话概念
让模型自由 JSON 会偶尔翻车。.with_structured_output(Schema) 会走 OpenAI 的 tool calling 协议,模型层保证返回值结构合法。生产环境几乎必用。
💻 完整代码
新建 step03_structured.py:
from dotenv import load_dotenv
from pydantic import BaseModel, Field
from langchain_openai import ChatOpenAI
load_dotenv()
class IssueTicket(BaseModel):
"""把用户抱怨抽成工单。"""
title: str = Field(description="问题标题,不超过 20 字")
severity: str = Field(description="low / medium / high")
tags: list[str] = Field(description="相关标签,2-5 个")
summary: str = Field(description="一句话摘要")
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
structured_llm = llm.with_structured_output(IssueTicket)
complaint = "我买的耳机才用了一个月左声道就没声音了,花了 800 块,你们客服还不回我消息!"
ticket = structured_llm.invoke(complaint)
print(type(ticket))
print(ticket.title)
print(ticket.severity)
print(ticket.tags)
print(ticket.summary)✅ 验证
打印出一个 IssueTicket 对象,字段都填对了。
⚠️ 常见报错
- 返回 None → 你的模型不支持 tool calling(比如某些老模型)。换
gpt-4o-mini/deepseek-chat/qwen-max。 - 字段缺失 → description 写清楚,Pydantic 字段加合理默认值。
🧪 5 分钟小练习
加一个 priority: int = Field(ge=1, le=5) 字段,看模型是否正确限制在 1-5。
📍 检查点
- 为什么结构化输出比"让模型吐 JSON"更可靠?(走 tool calling 协议,模型端校验)
- 什么时候不用它?(需要自由创作文本,比如写文章)
Step 4 · 带记忆的对话(45 分钟)
🎯 本步目标
让 AI 在一次会话里记住你之前说过什么。
📚 前置
- Step 2 跑通。
🧠 白话概念
LLM 本身没有记忆。你每次调用它只给一句话,它当然不知道前面。所谓"记忆"就是把历史消息列表再塞一次给它。
第 1 轮:[user: "我叫张三"] → AI: "你好张三"
第 2 轮:[user: "我叫张三", AI: "你好张三", user: "我叫什么?"] → AI: "你叫张三"
↑ 全部历史重塞这个"历史列表"叫 ChatHistory,有多种实现(内存 / Redis / Postgres)。
💻 完整代码
新建 step04_memory.py:
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.messages import HumanMessage, AIMessage
from langchain_core.chat_history import InMemoryChatMessageHistory
from langchain_core.runnables.history import RunnableWithMessageHistory
load_dotenv()
prompt = ChatPromptTemplate.from_messages([
("system", "你是友好的中文助手。"),
MessagesPlaceholder("history"),
("user", "{input}"),
])
llm = ChatOpenAI(model="gpt-4o-mini")
chain = prompt | llm
# 每个 session_id 对应一份独立的历史
store: dict[str, InMemoryChatMessageHistory] = {}
def get_history(session_id: str):
if session_id not in store:
store[session_id] = InMemoryChatMessageHistory()
return store[session_id]
with_memory = RunnableWithMessageHistory(
chain,
get_history,
input_messages_key="input",
history_messages_key="history",
)
cfg = {"configurable": {"session_id": "u-001"}}
print(with_memory.invoke({"input": "我叫张三,28 岁。"}, cfg).content)
print(with_memory.invoke({"input": "我多大?"}, cfg).content)
print(with_memory.invoke({"input": "我叫什么?"}, cfg).content)
# 换一个 session,应该"不认识"你
cfg2 = {"configurable": {"session_id": "u-002"}}
print(with_memory.invoke({"input": "我叫什么?"}, cfg2).content)✅ 验证
- 前三条对话 AI 能记住"张三 28 岁"。
- 第四条(换 session)AI 说不知道。
⚠️ 常见报错
- 每次都忘 → 检查
session_id是否每次一致;store是不是每次重新建了。 - 历史越来越长 token 爆炸 → Step 10 会讲
trim_messages,这里先别管。
🧪 5 分钟小练习
在 get_history 里打印 session_id 和当前历史长度,观察每轮的增长。
📍 检查点
- LLM 本身有没有记忆?(没有,靠外部存)
- 为什么需要
session_id?(区分不同用户 / 不同对话线) - 为什么不能无限加历史?(token 限制 + 费用)
Step 5 · 你的第一个 RAG(60 分钟)
🎯 本步目标
给 AI 一份它不知道的文档,让它基于文档回答问题。
📚 前置
- Step 2、3 跑通。
- 装一下向量库和文档解析依赖:
pip install "langchain-chroma>=0.1" "langchain-text-splitters>=0.3" pypdf🧠 白话概念
RAG = 检索 + 生成。
步骤:
- 把你的文档切成小块。
- 每块用 embedding 模型变成向量,存进向量库。
- 用户问问题时,把问题也变向量,在库里找 top-k 相似块。
- 把这些块拼进 prompt,让 LLM "参考这些内容回答"。
本质:给 LLM 一份"开卷考试的小抄"。
问题 ──embed──► 向量 ──检索──► Top-K 相似文档块 ──拼进 prompt──► LLM 回答💻 完整代码
先建一份假的知识库 kb.md:
# 公司请假制度
- 普通病假:提前 1 小时通知直属上级,填写 OA 单据。
- 年假:员工入职满 1 年后每年 10 天。
- 婚假:法定 3 天 + 公司额外 5 天,共 8 天。
- 产假:按国家标准 158 天。
- 调休:加班满 8 小时可兑换 1 天调休,3 个月内有效。新建 step05_rag.py:
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_chroma import Chroma
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough
load_dotenv()
# 1. 加载 & 切分
loader = TextLoader("kb.md", encoding="utf-8")
docs = loader.load()
splitter = RecursiveCharacterTextSplitter(chunk_size=200, chunk_overlap=20)
chunks = splitter.split_documents(docs)
print(f"切成了 {len(chunks)} 块")
# 2. 存进向量库
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vectorstore = Chroma.from_documents(chunks, embeddings)
retriever = vectorstore.as_retriever(search_kwargs={"k": 3})
# 3. 组 RAG chain
def format_docs(docs):
return "\n\n".join(d.page_content for d in docs)
prompt = ChatPromptTemplate.from_template("""
只能基于下面【参考资料】回答问题。如果资料里没有就说"资料中未提到"。
【参考资料】
{context}
【问题】
{question}
""")
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
rag_chain = (
{"context": retriever | format_docs, "question": RunnablePassthrough()}
| prompt
| llm
| StrOutputParser()
)
# 4. 测试
for q in ["婚假有几天?", "产假多少天?", "午休时间是多久?"]:
print(f"\nQ: {q}")
print(f"A: {rag_chain.invoke(q)}")✅ 验证
Q: 婚假有几天?
A: 婚假共 8 天(法定 3 天 + 公司额外 5 天)。
Q: 产假多少天?
A: 产假 158 天。
Q: 午休时间是多久?
A: 资料中未提到。第三个问题如果 AI 没说"未提到"而是自己编了一个时间,说明你的 prompt 约束不够强——这是幻觉,正是 RAG 要解决的核心问题。
⚠️ 常见报错
- Embedding 调用失败 → 检查网络 / API Key 对应的 endpoint 是否支持 embedding。
- 中文切块太碎 → 加大
chunk_size到 500。 - AI 乱编 → prompt 再强一点:"严格基于资料、不要推测、不要编造"。
🧪 5 分钟小练习
在输出时同时返回检索到的原始片段,这样用户能看到 AI 的"依据":
from langchain_core.runnables import RunnableParallel
rag_with_source = RunnableParallel(
answer=rag_chain,
source=retriever,
)
result = rag_with_source.invoke("产假多少天?")
print(result["answer"])
print("--- 依据 ---")
for d in result["source"]:
print(d.page_content[:60])📍 检查点
- RAG 三个步骤是什么?(切块 / 存向量 / 检索+生成)
- 为什么要 overlap?(防止句子被切断丢上下文)
- 怎么防幻觉?(prompt 约束 + 检索质量 + 引用展示)
Step 6 · 工具调用:让模型能"做事"(45 分钟)
🎯 本步目标
让模型能决定"这个问题要用计算器"或"要查天气",然后自动调你的 Python 函数。
📚 前置
- Step 2、3 跑通。
🧠 白话概念
LLM 自己不会做数学、查数据库、发邮件。Tool Calling 是一个协议:
- 你给模型一份"工具清单"(函数名、参数、描述)。
- 模型收到问题后,判断"要不要调工具",如果要,返回
{"tool": "calc", "args": {...}}。 - 你的代码真正执行这个函数,把结果塞回去。
- 模型基于结果组织最终回答。
关键点:模型不真的执行,它只是"开处方";执行是你的代码干的。
💻 完整代码
新建 step06_tools.py:
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage
load_dotenv()
@tool
def add(a: float, b: float) -> float:
"""两数相加。"""
return a + b
@tool
def multiply(a: float, b: float) -> float:
"""两数相乘。"""
return a * b
@tool
def get_weather(city: str) -> str:
"""查询指定城市的天气。返回一句话描述。"""
# 真实项目这里调外部 API;这里假造
return f"{city} 今天多云,22 度。"
tools = [add, multiply, get_weather]
tool_map = {t.name: t for t in tools}
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
llm_with_tools = llm.bind_tools(tools)
def run(user_input: str):
messages = [HumanMessage(user_input)]
# 第一步:问 LLM
ai_msg = llm_with_tools.invoke(messages)
messages.append(ai_msg)
# 第二步:如果 LLM 想调工具,就执行
while ai_msg.tool_calls:
for call in ai_msg.tool_calls:
tool_fn = tool_map[call["name"]]
result = tool_fn.invoke(call["args"])
print(f" [调用 {call['name']}({call['args']}) → {result}]")
messages.append({
"role": "tool",
"tool_call_id": call["id"],
"content": str(result),
})
# 第三步:把结果再塞给 LLM,让它基于结果回答
ai_msg = llm_with_tools.invoke(messages)
messages.append(ai_msg)
return ai_msg.content
for q in [
"3.14 乘以 2.5 等于多少?",
"上海今天天气怎么样?",
"先算 100 加 200,再乘以 3,告诉我结果。",
]:
print(f"\nQ: {q}")
print(f"A: {run(q)}")✅ 验证
Q: 3.14 乘以 2.5 等于多少?
[调用 multiply({'a': 3.14, 'b': 2.5}) → 7.85]
A: 3.14 乘以 2.5 等于 7.85。
Q: 上海今天天气怎么样?
[调用 get_weather({'city': '上海'}) → 上海 今天多云,22 度。]
A: 上海今天多云,22 度。
Q: 先算 100 加 200,再乘以 3,告诉我结果。
[调用 add({'a': 100, 'b': 200}) → 300]
[调用 multiply({'a': 300, 'b': 3}) → 900]
A: 结果是 900。重点观察:第三题模型连续调了两次工具,中间结果会传递。这就是 Agent 的雏形。
⚠️ 常见报错
AttributeError: 'AIMessage' object has no attribute 'tool_calls'→ 换新版langchain-openai(0.2+)。- 模型总不肯调工具 → 工具 description 写不清,或用了不支持 tool calling 的模型。
- 死循环 → 加
max_iterations=5的保护。
🧪 5 分钟小练习
加一个需要参数校验的工具,比如:
@tool
def transfer_money(from_account: str, to_account: str, amount: float) -> str:
"""转账。注意 amount 必须大于 0。"""
if amount <= 0:
return "错误:金额必须大于 0"
if amount > 10000:
return "错误:单笔限额 10000"
return f"已从 {from_account} 向 {to_account} 转 {amount} 元"问它"帮我转 -100 到 123 账户",看它怎么处理。
📍 检查点
- 模型执行工具吗?(不,你的代码执行)
- 一轮对话里可以调几次工具?(多次,链式)
- 工具 description 写给谁看?(模型看——直接影响它选不选)
🎉 中途小结(Step 1-6 已完成)
你现在会的东西:
✅ 调 LLM
✅ 用 LCEL 组 chain
✅ 结构化输出
✅ 带记忆的对话
✅ RAG 问文档
✅ 工具调用
以上就是"LangChain"的全部核心。但这些还不够做一个真正的 Agent——因为:
- 多步决策后,你不知道现在到哪一步了。
- 要加"如果用户生气就转人工"这种条件分支,硬编码
if/else会乱。 - 流程中间要暂停等人审,LangChain 做不到。
这就是为什么需要 LangGraph——它把上面这些能力组织成状态机。继续往下。
(Step 7-12 继续:StateGraph、条件边、Checkpointer、interrupt、完整项目。)
Step 7 · 从 LangChain 到 LangGraph 的心智转换(30 分钟)
🎯 本步目标
在写一行 LangGraph 代码之前,先在脑子里建立正确的心智模型。这一步没代码,但最重要。
🧠 白话概念
对比一下两个世界:
| 对比 | LangChain(LCEL chain) | LangGraph(StateGraph) |
|---|---|---|
| 心智模型 | 流水线:一头进,一头出 | 状态机:节点操作共享状态 |
| 数据流 | 上一步的输出 = 下一步的输入 | 所有节点读写同一个 State 字典 |
| 分支 | 难写,一旦有 if 就乱 | 天然支持条件边 |
| 回头 | 不行,单向的 | 可以,节点 A → B → A |
| 暂停 | 不行 | interrupt() 原生支持 |
| 记忆 | ChatHistory 手动管 | Checkpointer 自动存 |
三个核心概念
State(状态):一个字典,整张图共享
↓
Node(节点):一个函数,读 State,返回对 State 的修改
↓
Edge(边):规定节点之间怎么跳转,可以是直连也可以是条件类比:State 是共享黑板,Node 是老师,每个老师都往黑板上写东西。Edge 是课程表,决定下一节谁来上课。
一张图就能懂的例子
START ──► [load_user] ──► [answer] ──► END
↓ 读 state["user_id"]
↓ 写 state["profile"]
↓ 读 state["profile"] + state["question"]
↓ 写 state["answer"]load_user读user_id,写profile。answer读profile+question,写answer。- 两个节点都通过 State 解耦,互不直接调用。
📍 检查点
- LangGraph 的三个核心概念是什么?
- 为什么 State 比直接传参数更灵活?(所有节点都能读写,解耦)
Step 8 · 第一个 StateGraph(60 分钟)
🎯 本步目标
亲手搭一个有 3 个节点的 StateGraph,跑通。
📚 前置
- Step 7 看完。
langgraph已装(开头装过了)。
💻 完整代码
新建 step08_first_graph.py:
from typing import TypedDict, Annotated
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, START, END
import operator
load_dotenv()
# 1. 定义 State
class ChatState(TypedDict):
question: str
classification: str
answer: str
log: Annotated[list[str], operator.add] # 每个节点追加,自动合并
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
# 2. 定义节点(每个节点是函数)
def classify(state: ChatState) -> dict:
prompt = f"把下面问题归类为 'tech' / 'life' / 'other' 之一,只输出类别词:\n{state['question']}"
cat = llm.invoke(prompt).content.strip().lower()
if cat not in {"tech", "life", "other"}:
cat = "other"
return {"classification": cat, "log": [f"classify -> {cat}"]}
def answer(state: ChatState) -> dict:
style = {
"tech": "用工程师的口吻回答",
"life": "用亲切的口吻回答",
"other": "用简洁的口吻回答",
}[state["classification"]]
prompt = f"{style}:{state['question']}"
ans = llm.invoke(prompt).content
return {"answer": ans, "log": ["answer done"]}
def done(state: ChatState) -> dict:
return {"log": ["done"]}
# 3. 组装图
graph = StateGraph(ChatState)
graph.add_node("classify", classify)
graph.add_node("answer", answer)
graph.add_node("done", done)
graph.add_edge(START, "classify")
graph.add_edge("classify", "answer")
graph.add_edge("answer", "done")
graph.add_edge("done", END)
app = graph.compile()
# 4. 跑
for q in ["Python 的 GIL 是什么?", "今天午饭吃什么好?"]:
result = app.invoke({"question": q, "classification": "", "answer": "", "log": []})
print(f"\nQ: {q}")
print(f" 分类: {result['classification']}")
print(f" 回答: {result['answer'][:60]}...")
print(f" 日志: {result['log']}")✅ 验证
Q: Python 的 GIL 是什么?
分类: tech
回答: GIL(Global Interpreter Lock)是 CPython...
日志: ['classify -> tech', 'answer done', 'done']
Q: 今天午饭吃什么好?
分类: life
回答: 今天天气不错,推荐试试...
日志: ['classify -> life', 'answer done', 'done']🧠 关键点详解
TypedDict定义 State 的 schema。Annotated[list[str], operator.add]是reducer:多个节点返回的log会被自动拼起来(默认行为是覆盖)。- 节点函数签名:
(state) -> dict,返回值里只写要更新的字段。 add_edge("A", "B")= 执行完 A 就执行 B。
⚠️ 常见报错
ValueError: Cannot update "xxx" without a reducer→ 多个分支同时写同一字段时必须加 reducer(operator.add/operator.or_或自定义)。- State 少字段 → 首次 invoke 把所有字段初始化好,否则读不到。
🧪 5 分钟小练习
加一个 rewrite 节点:放在 classify 之后,如果分类是 tech,把问题改写得更专业再交给 answer。
📍 检查点
- State、Node、Edge 分别是什么?
- 节点函数的返回值怎么合并进 State?(字段覆盖 or reducer)
- START / END 是什么?(图的入口出口标识)
Step 9 · 条件边与路由(45 分钟)
🎯 本步目标
做一个"如果用户情绪负面就转人工,否则 AI 回答"的图。
💻 完整代码
新建 step09_conditional.py:
from typing import TypedDict
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, START, END
from pydantic import BaseModel, Field
load_dotenv()
class State(TypedDict):
message: str
sentiment: str # positive / neutral / negative
answer: str
handoff_to_human: bool
class Sentiment(BaseModel):
label: str = Field(description="positive / neutral / negative")
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
sentiment_llm = llm.with_structured_output(Sentiment)
def detect_sentiment(state: State) -> dict:
out = sentiment_llm.invoke(f"判断情绪,只返回 positive/neutral/negative:{state['message']}")
label = out.label.strip().lower()
if label not in {"positive", "neutral", "negative"}:
label = "neutral"
return {"sentiment": label}
def ai_answer(state: State) -> dict:
ans = llm.invoke(f"友好回答:{state['message']}").content
return {"answer": ans, "handoff_to_human": False}
def human_handoff(state: State) -> dict:
return {
"answer": "非常抱歉让您不满意,我已为您转接人工客服,请稍候。",
"handoff_to_human": True,
}
# 条件边的"路由函数":返回下一个节点名
def route_by_sentiment(state: State) -> str:
return "handoff" if state["sentiment"] == "negative" else "ai_answer"
graph = StateGraph(State)
graph.add_node("detect", detect_sentiment)
graph.add_node("ai_answer", ai_answer)
graph.add_node("handoff", human_handoff)
graph.add_edge(START, "detect")
graph.add_conditional_edges(
"detect",
route_by_sentiment,
{"ai_answer": "ai_answer", "handoff": "handoff"},
)
graph.add_edge("ai_answer", END)
graph.add_edge("handoff", END)
app = graph.compile()
for msg in [
"你们的产品真的不错!",
"这什么垃圾系统,用了三次崩三次,退钱!",
"请问怎么导出数据?",
]:
r = app.invoke({"message": msg, "sentiment": "", "answer": "", "handoff_to_human": False})
print(f"\n{msg}")
print(f" 情绪: {r['sentiment']}")
print(f" 回答: {r['answer'][:60]}")
print(f" 转人工: {r['handoff_to_human']}")✅ 验证
- 第一句 positive → AI 回答。
- 第二句 negative → 转人工,
handoff_to_human=True。 - 第三句 neutral → AI 回答。
⚠️ 常见报错
KeyError→add_conditional_edges的字典 key 必须和路由函数返回值一致。- 一直走同一分支 → 结构化输出没生效,先单独 print 看 sentiment。
🧪 5 分钟小练习
加第三个分支:如果问题包含"退款 / 退货"字样,走 refund_flow 节点,无论情绪。
📍 检查点
- 条件边靠什么决定走哪里?(路由函数的返回值 + 字典映射)
- 一个节点可以有几条出边?(多条:一条默认边 or 多条条件边)
Step 10 · Checkpointer:让 Agent 有持久记忆(60 分钟)
🎯 本步目标
让同一个 thread_id 的对话跨多次 invoke 保留状态——相当于数据库里存了个"进度"。
📚 前置
- Step 8 跑通。
🧠 白话概念
Step 4 我们用 InMemoryChatMessageHistory 做了对话记忆。LangGraph 的 Checkpointer 是升级版:它不光存消息历史,存整个 State,且每一步之后都会存一次。
这带来三个能力:
- 持久化:进程重启、换机器,还能从
thread_id继续。 - 时间旅行:可以回退到任意历史 State 重跑。
- interrupt 基础:中途暂停等人审,稍后恢复。
实现有 3 种:
InMemorySaver— 玩具,进程退出就丢。SqliteSaver— 单机长期存。PostgresSaver— 生产用。
💻 完整代码
新建 step10_checkpoint.py:
from typing import TypedDict, Annotated
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, AIMessage, BaseMessage
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.checkpoint.memory import InMemorySaver
load_dotenv()
class State(TypedDict):
messages: Annotated[list[BaseMessage], add_messages] # 官方 reducer,自动去重合并
llm = ChatOpenAI(model="gpt-4o-mini")
def chat(state: State) -> dict:
resp = llm.invoke(state["messages"])
return {"messages": [resp]}
graph = StateGraph(State)
graph.add_node("chat", chat)
graph.add_edge(START, "chat")
graph.add_edge("chat", END)
# 关键:compile 时传入 checkpointer
checkpointer = InMemorySaver()
app = graph.compile(checkpointer=checkpointer)
# 第一次对话
cfg = {"configurable": {"thread_id": "u-001"}}
r1 = app.invoke({"messages": [HumanMessage("我叫王小明,学 Python 3 个月了。")]}, cfg)
print("AI:", r1["messages"][-1].content)
# 再问(同一 thread)
r2 = app.invoke({"messages": [HumanMessage("我叫什么?学了多久?")]}, cfg)
print("AI:", r2["messages"][-1].content)
# 看看完整 State
snapshot = app.get_state(cfg)
print(f"\n[thread u-001 共有 {len(snapshot.values['messages'])} 条消息]")
# 换个 thread,不认识
cfg2 = {"configurable": {"thread_id": "u-002"}}
r3 = app.invoke({"messages": [HumanMessage("我叫什么?")]}, cfg2)
print("\n不同 thread AI:", r3["messages"][-1].content)✅ 验证
- 前两次同 thread → AI 记得"王小明,3 个月"。
- 第三次换 thread → AI 说不知道。
- snapshot 里 messages 至少 4 条(2 问 2 答)。
🧠 升级到 Sqlite(可选)
from langgraph.checkpoint.sqlite import SqliteSaver
import sqlite3
conn = sqlite3.connect("chat.db", check_same_thread=False)
checkpointer = SqliteSaver(conn)
# 剩下一模一样重启程序都还记得。生产用 Postgres,接口一致。
⚠️ 常见报错
ValueError: Checkpointer requires configurable thread_id→ 忘传cfg。- State 里消息重复 → 没用
add_messagesreducer,改成默认覆盖了。
🧪 5 分钟小练习
用 app.get_state_history(cfg) 列出这个 thread 所有历史快照,并挑一个快照重新走(时间旅行)。
📍 检查点
- Checkpointer 保存的是单条消息还是整个 State?(整个 State)
thread_id的作用?(区分会话线,类比 chat session)- 三种 Checkpointer 的取舍?(玩具 / 单机 / 生产)
Step 11 · interrupt:让流程暂停等人审(60 分钟)
🎯 本步目标
做一个"AI 决定要退款时,先暂停等人类点'同意/拒绝'"的流程。
📚 前置
- Step 10 跑通。
🧠 白话概念
interrupt() 在节点里调用后,整张图立即停在这里,把 State 写进 checkpoint,返回给调用者一个"等待中"的状态。同一个 thread 用 Command(resume=xxx) 再次 invoke,图会从断点继续,interrupt 的返回值就是你 resume 进去的值。
app.invoke({...}) → 跑到 interrupt → 返回,thread 状态 = 等待中
↓ 人类审批
app.invoke(Command(resume={"approved": True})) → 从断点继续💻 完整代码
新建 step11_hitl.py:
from typing import TypedDict
from dotenv import load_dotenv
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import interrupt, Command
load_dotenv()
class State(TypedDict):
order_id: str
amount: float
reason: str
decision: str
status: str
def propose_refund(state: State) -> dict:
# AI 决定发起退款(这里简化为直接来到这步)
print(f" [AI 判断:订单 {state['order_id']} 需退款 {state['amount']} 元,理由:{state['reason']}]")
return {}
def human_approval(state: State) -> dict:
# 在这里暂停
decision = interrupt({
"action": "refund",
"order_id": state["order_id"],
"amount": state["amount"],
"reason": state["reason"],
"prompt": "请审批:是否同意退款?",
})
# decision 是人类 resume 进来的值
return {"decision": decision.get("verdict", "reject"),
"status": "approved" if decision.get("verdict") == "approve" else "rejected"}
def execute(state: State) -> dict:
if state["status"] == "approved":
print(f" [执行退款:{state['amount']} 元已退]")
else:
print(f" [退款已拒绝]")
return {}
graph = StateGraph(State)
graph.add_node("propose", propose_refund)
graph.add_node("approval", human_approval)
graph.add_node("execute", execute)
graph.add_edge(START, "propose")
graph.add_edge("propose", "approval")
graph.add_edge("approval", "execute")
graph.add_edge("execute", END)
checkpointer = InMemorySaver()
app = graph.compile(checkpointer=checkpointer)
cfg = {"configurable": {"thread_id": "order-123"}}
# --- 第一轮:跑到 interrupt 停住 ---
print("== 第一轮:AI 发起 ==")
state = app.invoke(
{"order_id": "ORD-001", "amount": 580.0, "reason": "商品破损", "decision": "", "status": ""},
cfg,
)
print(f"当前节点状态:{app.get_state(cfg).next}") # 会显示 ('approval',)
print(f"interrupt payload: {app.get_state(cfg).tasks[0].interrupts[0].value}")
# --- 第二轮:人类审批 ---
print("\n== 人类审批中... 模拟同意 ==")
state = app.invoke(Command(resume={"verdict": "approve"}), cfg)
print(f"最终 status: {state['status']}")
# --- 另一个订单:模拟拒绝 ---
print("\n== 新订单,拒绝路径 ==")
cfg2 = {"configurable": {"thread_id": "order-124"}}
app.invoke(
{"order_id": "ORD-002", "amount": 9999.0, "reason": "不喜欢", "decision": "", "status": ""},
cfg2,
)
app.invoke(Command(resume={"verdict": "reject"}), cfg2)✅ 验证
== 第一轮:AI 发起 ==
[AI 判断:订单 ORD-001 需退款 580.0 元,理由:商品破损]
当前节点状态:('approval',)
interrupt payload: {'action': 'refund', ...}
== 人类审批中... 模拟同意 ==
[执行退款:580.0 元已退]
最终 status: approved
== 新订单,拒绝路径 ==
[AI 判断:订单 ORD-002 需退款 9999.0 元,理由:不喜欢]
[退款已拒绝]⚠️ 常见报错
NodeInterrupt报错 → 版本太老,升级langgraph >= 0.2.28。- resume 后没继续 →
thread_id不一致;interrupt 必须在同 thread 上 resume。 - interrupt 直接返回 None → 忘了加
checkpointer,interrupt 依赖 checkpoint。
🧪 5 分钟小练习
改造成:如果 amount < 100,跳过审批直接执行;否则走 interrupt。提示:用 Step 9 的条件边。
📍 检查点
- interrupt 为什么必须配 checkpointer?(State 要写进 checkpoint 才能等人)
- resume 怎么把人类的决定传回去?(
Command(resume=...)) - 真实系统里,人类审批是怎么 resume 的?(通常是审批完成后触发回调,后台 API 再 invoke 一次)
Step 12 · 实战:完整的客服 Agent(90 分钟)
🎯 本步目标
把 Step 1-11 全部串起来,做一个能:查订单(工具) + 查知识库(RAG) + 情绪检测路由 + 退款走人审 + 持久记忆 的 Agent。
🧠 架构图
START
│
▼
[intent_router] ◄── 把用户消息分类:order_query / policy_ask / refund / complaint
│
├── order_query ──► [call_tools(get_order)] ──► [reply] ──► END
│
├── policy_ask ───► [rag_search] ──► [reply] ──► END
│
├── refund ───────► [propose_refund] ──► [approval(interrupt)] ──► [execute_refund] ──► [reply] ──► END
│
└── complaint ────► [handoff_human] ──► END
全程共享 State;全程走 SqliteSaver 持久化。💻 完整代码
新建 step12_agent.py:
from typing import TypedDict, Annotated, Literal
import sqlite3
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage
from langchain_core.tools import tool
from langchain_core.prompts import ChatPromptTemplate
from langchain_chroma import Chroma
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import TextLoader
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.types import interrupt, Command
from pydantic import BaseModel, Field
load_dotenv()
# ---------- State ----------
class State(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
intent: str
order_info: str
kb_answer: str
refund_amount: float
refund_status: str
# ---------- 工具 ----------
FAKE_ORDERS = {
"ORD-001": {"product": "无线耳机", "amount": 580, "status": "已发货"},
"ORD-002": {"product": "蓝牙音箱", "amount": 399, "status": "已签收"},
}
@tool
def get_order(order_id: str) -> str:
"""根据订单号查订单详情。"""
o = FAKE_ORDERS.get(order_id)
if not o:
return "订单不存在"
return f"{order_id} - {o['product']},{o['amount']} 元,{o['status']}"
# ---------- RAG ----------
with open("kb.md", "w", encoding="utf-8") as f:
f.write("""
# 退换货政策
- 7 天无理由退货。
- 商品破损须提供照片。
- 退款 1-3 个工作日到账。
# 配送政策
- 江浙沪 48 小时达。
- 其它地区 3-5 天。
""")
loader = TextLoader("kb.md", encoding="utf-8")
chunks = RecursiveCharacterTextSplitter(chunk_size=200, chunk_overlap=20).split_documents(loader.load())
vs = Chroma.from_documents(chunks, OpenAIEmbeddings(model="text-embedding-3-small"))
retriever = vs.as_retriever(search_kwargs={"k": 3})
# ---------- LLM ----------
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
class Intent(BaseModel):
label: Literal["order_query", "policy_ask", "refund", "complaint"] = Field(
description="分类结果"
)
intent_llm = llm.with_structured_output(Intent)
# ---------- 节点 ----------
def intent_router(state: State) -> dict:
last = state["messages"][-1].content
out = intent_llm.invoke(f"""把用户消息分类到以下之一并只输出字段:
- order_query: 问订单状态、物流(带订单号 ORD-)
- policy_ask: 问政策、规则(退货、配送、价保)
- refund: 明确要求退款退货
- complaint: 愤怒、投诉、辱骂
消息:{last}""")
return {"intent": out.label}
def call_tool_node(state: State) -> dict:
last = state["messages"][-1].content
llm_with_tools = llm.bind_tools([get_order])
ai = llm_with_tools.invoke([HumanMessage(last)])
info = ""
if ai.tool_calls:
for c in ai.tool_calls:
info = get_order.invoke(c["args"])
return {"order_info": info}
def rag_node(state: State) -> dict:
last = state["messages"][-1].content
docs = retriever.invoke(last)
context = "\n".join(d.page_content for d in docs)
ans = llm.invoke(f"基于资料回答,资料外不要编造。\n资料:{context}\n问题:{last}").content
return {"kb_answer": ans}
def propose_refund(state: State) -> dict:
last = state["messages"][-1].content
# 假设从消息中抽金额(简化,真实用结构化输出)
return {"refund_amount": 580.0}
def approval_node(state: State) -> dict:
verdict = interrupt({
"action": "refund",
"amount": state["refund_amount"],
"prompt": "请审批退款。",
})
return {"refund_status": "approved" if verdict.get("ok") else "rejected"}
def execute_refund(state: State) -> dict:
return {}
def handoff_node(state: State) -> dict:
return {}
def reply_node(state: State) -> dict:
# 根据 intent 组织最终回复
if state["intent"] == "order_query":
text = f"您的订单信息:{state['order_info']}"
elif state["intent"] == "policy_ask":
text = state["kb_answer"]
elif state["intent"] == "refund":
text = (f"退款 {state['refund_amount']} 元已批准,预计 1-3 个工作日到账。"
if state["refund_status"] == "approved"
else f"退款申请已拒绝。")
else:
text = "已为您转接人工客服,请稍候。"
return {"messages": [AIMessage(text)]}
# ---------- 路由 ----------
def route(state: State) -> str:
return {
"order_query": "call_tool",
"policy_ask": "rag",
"refund": "propose_refund",
"complaint": "handoff",
}[state["intent"]]
# ---------- 组装 ----------
g = StateGraph(State)
g.add_node("intent", intent_router)
g.add_node("call_tool", call_tool_node)
g.add_node("rag", rag_node)
g.add_node("propose_refund", propose_refund)
g.add_node("approval", approval_node)
g.add_node("execute_refund", execute_refund)
g.add_node("handoff", handoff_node)
g.add_node("reply", reply_node)
g.add_edge(START, "intent")
g.add_conditional_edges("intent", route, {
"call_tool": "call_tool",
"rag": "rag",
"propose_refund": "propose_refund",
"handoff": "handoff",
})
g.add_edge("call_tool", "reply")
g.add_edge("rag", "reply")
g.add_edge("propose_refund", "approval")
g.add_edge("approval", "execute_refund")
g.add_edge("execute_refund", "reply")
g.add_edge("handoff", "reply")
g.add_edge("reply", END)
conn = sqlite3.connect("agent.db", check_same_thread=False)
app = g.compile(checkpointer=SqliteSaver(conn))
# ---------- 跑几个场景 ----------
def run(thread: str, msg: str, resume=None):
cfg = {"configurable": {"thread_id": thread}}
if resume is not None:
out = app.invoke(Command(resume=resume), cfg)
else:
out = app.invoke({
"messages": [HumanMessage(msg)],
"intent": "", "order_info": "", "kb_answer": "",
"refund_amount": 0.0, "refund_status": "",
}, cfg)
if out.get("messages"):
print(f" AI: {out['messages'][-1].content}")
state = app.get_state(cfg)
if state.next:
print(f" [暂停中,等待节点: {state.next}]")
return state
print("\n=== 场景 1:查订单 ===")
run("t1", "帮我查一下 ORD-001 的订单状态")
print("\n=== 场景 2:问政策 ===")
run("t2", "你们多久能退款到账?")
print("\n=== 场景 3:投诉 ===")
run("t3", "你们这什么破玩意,赶紧给我退钱!")
print("\n=== 场景 4:退款(走人审)===")
run("t4", "我要退 580 元")
print(" [人类点击同意]")
run("t4", "", resume={"ok": True})✅ 验证
预期输出:
=== 场景 1:查订单 ===
AI: 您的订单信息:ORD-001 - 无线耳机,580 元,已发货
=== 场景 2:问政策 ===
AI: 退款 1-3 个工作日到账。
=== 场景 3:投诉 ===
AI: 已为您转接人工客服,请稍候。
=== 场景 4:退款(走人审)===
[暂停中,等待节点: ('approval',)]
[人类点击同意]
AI: 退款 580.0 元已批准,预计 1-3 个工作日到账。🎉 恭喜
你现在拥有了一个真正的多功能 Agent:它能分流、能调工具、能查知识库、能走人审、能持久化。这就是面试里说"我做过 LangGraph 的 Agent"时要能白板画出来的东西。
🧪 进阶练习(建议花 2 小时做)
- 把
get_order改成真实数据库查询(SQLite 就行)。 - 加对话历史:当前版本每次都是独立 intent 分流,让它能记住上下文(把
messages加进每个节点的 prompt)。 - 把
InMemorySaver / SqliteSaver换成PostgresSaver,体验生产配置。 - 加一个
escalate分支:如果 AI 连续失败 2 次,自动 handoff。 - 把这个 Agent 包成 FastAPI 流式 SSE 接口,对接前端(联动
ai-frontend-streaming-uxtopic)。
📍 检查点(总结你能回答)
- LangChain 和 LangGraph 的心智模型差异?
- State / Node / Edge / Reducer 四件套是什么?
- 条件边是怎么实现的?
- Checkpointer 解决什么问题?三种实现区别?
- interrupt 是怎么让流程暂停的?依赖什么?
- 你刚搭的客服 Agent 有 8 个节点,画出它们之间的边。
- 为什么生产环境不能用
InMemorySaver?
能顺畅回答以上 7 个问题,就可以把"LangGraph Agent 工程经验"写进简历了。
🎯 学完怎么继续
- 走完 练习与自测:把每个 Step 的检查点过一遍。
- 读 topic 正文(讲义版):现在你有手感了,回头看原文会觉得"全都看懂了"。
- 扩展阅读:agent-state-persistence-hitl 把 Step 10-11 再深化。
- 下一步项目:把这个客服 Agent 接 rag-engineering 的真实知识库,或加 ai-application-security 的 guard。
- 对标面试:参考 interview-question-bank-ai-python 里的 LangGraph 题,现在每道题你都能白板写代码回答。
有问题记下来,下一轮我们做 topic 2 的手把手版时一起改进。