Add multiple LangGraph examples including basic agents, chatbots, conditional edges, checkpointers, tool calls, and human-in-the-loop approval processes. Update .gitignore to include local environment files.

This commit is contained in:
Eric Wang
2026-07-25 17:31:26 +08:00
parent e5d701a045
commit f0b0eb13fe
8 changed files with 709 additions and 0 deletions

315
06_human_in_the_loop.py Normal file
View File

@@ -0,0 +1,315 @@
"""LangGraph 人工审批示例:普通工具直接执行,敏感工具先暂停审批。
图结构:
START -> model
| 无 tool_calls ------------------------> END
| 普通工具调用 -> tools ----------------> model
| 敏感工具调用 -> approval
| 批准 -> tools ----> model
| 拒绝 -> model
运行:
uv run --with langgraph --with langchain-openai --with python-dotenv \
python 06_human_in_the_loop.py
说明:send_email 是模拟工具,不会真正发送邮件。
"""
import os
import uuid
from typing import Literal
from dotenv import load_dotenv
from langchain_core.messages import ToolMessage
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode
from langgraph.types import Command, interrupt
load_dotenv()
# -----------------------------------------------------------------------------
# 1. 定义工具
# -----------------------------------------------------------------------------
@tool
def get_weather(city: str) -> str:
"""查询指定城市当前的天气。城市名称使用中文,例如北京、上海。"""
weather_data = {
"北京": "晴,25°C,北风 2 级",
"上海": "多云,28°C,东南风 3 级",
"广州": "雷阵雨,30°C,南风 2 级",
"深圳": "阴,29°C,东风 2 级",
}
return weather_data.get(city, f"暂时没有 {city} 的天气数据")
@tool
def calculator(
a: float,
b: float,
operation: Literal["add", "subtract", "multiply", "divide"],
) -> float:
"""对两个数字进行加、减、乘、除运算。
Args:
a: 第一个数字。
b: 第二个数字。
operation: 运算类型:add、subtract、multiply 或 divide。
"""
if operation == "add":
return a + b
if operation == "subtract":
return a - b
if operation == "multiply":
return a * b
if b == 0:
raise ValueError("除数不能为 0")
return a / b
@tool
def send_email(recipient: str, subject: str, body: str) -> str:
"""向指定邮箱发送邮件。这是有外部副作用的敏感操作,执行前必须审批。
Args:
recipient: 收件人的邮箱地址。
subject: 邮件主题。
body: 邮件正文。
"""
# 教学示例:这里只模拟发送,不连接真实邮件服务。
print("\n[模拟发送邮件]")
print(f"收件人:{recipient}")
print(f"主题:{subject}")
print(f"正文:{body}")
return f"邮件已成功发送给 {recipient}"
TOOLS = [get_weather, calculator, send_email]
SENSITIVE_TOOL_NAMES = {"send_email"}
# -----------------------------------------------------------------------------
# 2. 创建模型并绑定工具
# -----------------------------------------------------------------------------
model = ChatOpenAI(
model=os.getenv("OPENAI_MODEL", "openai/gpt-5.4"),
api_key=os.getenv("OPENAI_API_KEY"),
base_url=os.getenv("OPENAI_BASE_URL", "https://api.qnaigc.com/v1"),
temperature=0,
max_retries=2,
)
model_with_tools = model.bind_tools(TOOLS)
# -----------------------------------------------------------------------------
# 3. 定义节点与路由
# -----------------------------------------------------------------------------
def call_model(state: MessagesState) -> dict:
"""调用模型;模型可以直接回答,也可以生成一个或多个 tool_calls。"""
response = model_with_tools.invoke(state["messages"])
return {"messages": [response]}
def route_after_model(
state: MessagesState,
) -> Literal["tools", "approval", "__end__"]:
"""无工具调用则结束;敏感工具去审批;其余工具直接执行。"""
tool_calls = state["messages"][-1].tool_calls
if not tool_calls:
return END
if any(call["name"] in SENSITIVE_TOOL_NAMES for call in tool_calls):
return "approval"
return "tools"
def approval_node(
state: MessagesState,
) -> Command[Literal["tools", "model"]]:
"""暂停图执行,等待用户批准或拒绝当前批次的工具调用。"""
tool_calls = state["messages"][-1].tool_calls
# interrupt() 应放在副作用之前。恢复时节点可能从函数开头重新执行。
decision = interrupt(
{
"type": "tool_approval",
"message": "模型请求执行敏感工具,是否批准?",
"tool_calls": tool_calls,
}
)
approved = (
bool(decision.get("approved"))
if isinstance(decision, dict)
else bool(decision)
)
if approved:
# ToolNode 将执行这一批工具调用,并生成对应的 ToolMessage。
return Command(goto="tools")
# 每个 tool_call 都必须得到对应的 ToolMessage,否则消息协议不完整,
# 部分模型服务会拒绝下一次请求。整个批次在审批拒绝后均不执行。
rejection_messages = [
ToolMessage(
content="用户拒绝了本批次工具调用,工具未执行。请告知用户并询问下一步。",
tool_call_id=call["id"],
name=call["name"],
)
for call in tool_calls
]
return Command(
update={"messages": rejection_messages},
goto="model",
)
# ToolNode 自动查找并执行工具,再把结果包装为 ToolMessage。
tool_node = ToolNode(TOOLS)
# -----------------------------------------------------------------------------
# 4. 构建图并启用 Checkpointer
# -----------------------------------------------------------------------------
builder = StateGraph(MessagesState)
builder.add_node("model", call_model)
builder.add_node("approval", approval_node)
builder.add_node("tools", tool_node)
builder.add_edge(START, "model")
builder.add_conditional_edges(
"model",
route_after_model,
{
"tools": "tools",
"approval": "approval",
END: END,
},
)
builder.add_edge("tools", "model")
# interrupt() 必须配合 checkpointer 使用。InMemorySaver 只适合本地学习,
# 进程退出后 checkpoint 会消失。
checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)
# -----------------------------------------------------------------------------
# 5. 命令行交互
# -----------------------------------------------------------------------------
def print_update(event: dict) -> None:
"""打印 model/tools 节点产生的消息更新。"""
for node_name, update in event.items():
if node_name == "__interrupt__" or not isinstance(update, dict):
continue
messages = update.get("messages", [])
if not messages:
continue
print(f"\n[{node_name}]")
for message in messages:
message.pretty_print()
def run_until_pause_or_end(graph_input: dict | Command, config: dict):
"""运行图,返回本轮产生的 interrupt;正常结束则返回 None。"""
pending_interrupt = None
for event in graph.stream(
graph_input,
config=config,
stream_mode="updates",
):
print_update(event)
interrupts = event.get("__interrupt__", ())
if interrupts:
pending_interrupt = interrupts[0]
return pending_interrupt
def ask_for_approval(interrupt_value) -> bool:
"""在命令行展示待执行工具,并读取审批结果。"""
payload = interrupt_value.value
print("\n=== 需要人工审批 ===")
print(payload.get("message", "是否批准工具调用?"))
for index, call in enumerate(payload.get("tool_calls", []), start=1):
print(f"{index}. 工具:{call['name']}")
print(f" 参数:{call['args']}")
while True:
answer = input("批准执行吗?[y/n]:").strip().lower()
if answer in {"y", "yes"}:
return True
if answer in {"n", "no"}:
return False
print("请输入 y 或 n。")
def run_turn(question: str, config: dict) -> None:
"""执行一轮对话;如果暂停,则审批后使用 Command(resume=...) 恢复。"""
pending = run_until_pause_or_end(
{"messages": [{"role": "user", "content": question}]},
config,
)
# 理论上一次模型响应可能导致多次暂停,因此使用循环处理。
while pending is not None:
approved = ask_for_approval(pending)
pending = run_until_pause_or_end(
Command(resume={"approved": approved}),
config,
)
def main() -> None:
print("=== LangGraph 工具助手(带人工审批)===")
print("天气和计算器直接执行;发送邮件需要人工批准。")
print("输入 /new 创建新会话,输入 quit 退出。")
# 同一 thread_id 的多轮输入共享 checkpoint 和消息历史。
thread_id = str(uuid.uuid4())
while True:
question = input(f"\n用户[{thread_id[:8]}]:").strip()
if question.lower() in {"quit", "exit", "q"}:
break
if question == "/new":
thread_id = str(uuid.uuid4())
print(f"已创建新会话:{thread_id}")
continue
if not question:
continue
config = {
"configurable": {"thread_id": thread_id},
"recursion_limit": 20,
}
try:
run_turn(question, config)
except Exception as exc:
print(f"\n执行失败:{type(exc).__name__}: {exc}")
if __name__ == "__main__":
main()