"""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()