Files
langgraph-learning-examples/06_human_in_the_loop.py

316 lines
9.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()