8. 人工介入控制

8.人工介入控制

在构建复杂的 AI 工作流时,完全依赖自动化并不总是最佳选择。有些场景下,我们需要让人类参与进来,对关键决策进行审查、批准或修正。LangGraph 的人工介入控制机制,正是为了解决这一需求而设计的。它允许我们在图的执行过程中设置中断点,暂停工作流,等待人类输入,然后基于反馈继续执行。这种机制不仅提升了系统的可靠性,也为处理敏感操作提供了必要的安全保障。

中断点设置

中断点是人工介入控制的核心。LangGraph 提供了两种类型的中断:动态中断和静态中断。动态中断基于运行时的状态条件触发,而静态中断则在编译时或运行时指定在特定节点前后暂停。

动态中断

动态中断通过 interrupt 函数实现。这个函数可以在节点内部的任何位置调用,当执行到该位置时,图会暂停并等待人类输入。调用 interrupt 时,可以传递任意 JSON 可序列化的数据,这些数据会作为中断信息展示给人类审查者。

使用 interrupt 需要满足几个前提条件。首先,必须配置检查点(checkpointer)来持久化图状态。其次,在调用图时需要指定线程 ID,这样系统才能正确追踪和恢复执行上下文。

from langgraph.types import interrupt, Command
from langgraph.checkpoint.memory import InMemorySaver
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START

class State(TypedDict):
    text: str

def human_review_node(state: State):
    # 在这里暂停,等待人类输入
    value = interrupt({
        "text_to_revise": state["text"],
        "action": "请审查并修改这段文本"
    })
    return {"text": value}

# 构建图
graph_builder = StateGraph(State)
graph_builder.add_node("human_review", human_review_node)
graph_builder.add_edge(START, "human_review")

# 编译时必须指定检查点
checkpointer = InMemorySaver()
graph = graph_builder.compile(checkpointer=checkpointer)

# 使用线程 ID 运行图
config = {"configurable": {"thread_id": "review_session_1"}}
result = graph.invoke({"text": "原始文本"}, config=config)

# 输出中断信息
print(result['__interrupt__'])

代码执行到 interrupt 调用时会立即暂停。返回结果中的 __interrupt__ 字段包含了中断的详细信息,包括传递的值、中断 ID 和命名空间等元数据。此时,工作流的状态已经被保存到检查点中,可以安全地等待任意长的时间。

要恢复执行,需要使用 Command 对象,并通过 resume 参数提供人类输入:

# 恢复执行,提供修改后的文本
final_result = graph.invoke(
    Command(resume="修改后的人类文本"),
    config=config
)
print(final_result)  # {'text': '修改后的人类文本'}

恢复执行时,图会从 interrupt 所在的节点重新开始执行。这次,interrupt 函数不再暂停,而是直接返回 Command 中提供的 resume 值。需要注意的是,整个节点会重新执行一遍,因此建议将 interrupt 放在节点的开头,或者将节点设计为幂等的,避免副作用重复执行。

静态中断

静态中断在编译时或运行时指定,用于在特定节点执行前或执行后暂停。这种方式更适合调试和测试场景,因为中断点是固定的,不依赖于运行时状态。

在编译时设置静态中断:

graph = graph_builder.compile(
    interrupt_before=["node_a"],      # 在 node_a 执行前暂停
    interrupt_after=["node_b", "node_c"]  # 在 node_b 和 node_c 执行后暂停
)

也可以在运行时动态指定:

# 运行时指定中断点
result = await client.runs.wait(
    thread_id,
    assistant_id,
    input=inputs,
    interrupt_before=["node_a"],
    interrupt_after=["node_b"]
)

静态中断的使用场景相对有限,因为它缺乏动态中断的灵活性。在生产环境中,动态中断是更常见的选择,因为它可以根据实际状态决定是否需要人工介入。

人工审批工作流设计

人工审批是人工介入最常见的模式。在关键操作执行前,系统暂停并请求人类批准,根据批准结果决定后续执行路径。这种模式特别适用于敏感操作,如金融交易、数据删除或重要配置变更。

批准或拒绝模式

实现审批模式的核心是在中断后根据人类输入进行条件路由。人类可以批准操作,让工作流继续执行;也可以拒绝操作,引导工作流走向替代路径。

from typing import Literal
from langgraph.types import interrupt, Command

def approval_node(state: State) -> Command[Literal["execute_action", "alternative_path"]]:
    # 暂停并请求批准
    is_approved = interrupt({
        "question": "是否批准执行以下操作?",
        "operation": state["pending_operation"],
        "details": state["operation_details"]
    })
    
    # 根据批准结果路由到不同节点
    if is_approved:
        return Command(goto="execute_action")
    else:
        return Command(goto="alternative_path")

# 在图中使用审批节点
builder = StateGraph(State)
builder.add_node("approval", approval_node)
builder.add_node("execute_action", execute_node)
builder.add_node("alternative_path", alternative_node)
builder.add_edge("approval", "execute_action")
builder.add_edge("approval", "alternative_path")

这个模式的关键在于 Command 对象的 goto 参数,它允许我们在恢复执行时直接跳转到指定的节点,而不是按照常规的边连接顺序执行。这为实现复杂的审批逻辑提供了极大的灵活性。

状态审查与编辑

有时我们不仅需要批准或拒绝,还需要人类直接修改工作流的状态。这在纠正错误、补充信息或优化结果时非常有用。

def review_and_edit_node(state: State):
    # 展示当前状态供审查
    reviewed_data = interrupt({
        "task": "请审查并编辑以下生成的摘要",
        "generated_summary": state["summary"],
        "original_text": state["source_text"]
    })
    
    # 使用人类编辑后的内容更新状态
    return {
        "summary": reviewed_data["edited_summary"],
        "reviewed_by": "human",
        "review_timestamp": reviewed_data.get("timestamp")
    }

这种模式允许人类直接修改工作流中的任何数据,而不仅仅是提供简单的批准或拒绝。返回的数据结构可以根据具体需求灵活设计,系统会将人类提供的值整合回状态中。

状态手动编辑恢复

状态手动编辑是人工介入的深层应用。它不仅允许人类审查输出,还能直接修改图的内部状态,然后让工作流基于修改后的状态继续执行。这在调试和纠错场景中特别有价值。

在工具中实现状态编辑

在工具调用中集成状态编辑功能,可以让模型生成的参数经过人类验证后再执行实际操作。这种方式结合了自动化生成和人类验证的优势。

from langchain_core.tools import InjectedToolCallId, tool
from langchain_core.messages import ToolMessage
from typing_extensions import Annotated

@tool
def sensitive_operation(
    param1: str, 
    param2: int,
    tool_call_id: Annotated[str, InjectedToolCallId]
):
    """执行需要人工审查的敏感操作"""
    # 暂停并展示操作详情
    human_response = interrupt({
        "action_request": {
            "action": "sensitive_operation",
            "args": {"param1": param1, "param2": param2}
        },
        "config": {
            "allow_accept": True,
            "allow_edit": True,
            "allow_respond": True
        },
        "description": "请审查此敏感操作"
    })
    
    # 处理人类响应
    if human_response["type"] == "accept":
        # 执行原始操作
        result = execute_with_original_params(param1, param2)
        response_msg = "操作已执行"
    elif human_response["type"] == "edit":
        # 使用编辑后的参数
        edited_params = human_response["args"]["args"]
        result = execute_with_original_params(
            edited_params["param1"], 
            edited_params["param2"]
        )
        response_msg = f"已使用编辑后的参数执行: {edited_params}"
    else:
        # 拒绝执行
        result = None
        response_msg = "操作已被拒绝"
    
    # 返回包含状态更新的 Command
    return Command(
        update={
            "operation_result": result,
            "messages": [ToolMessage(content=response_msg, tool_call_id=tool_call_id)]
        }
    )

这个实现展示了三种可能的响应类型:接受、编辑和拒绝。每种类型都会导致不同的后续行为,最终通过 Command 对象更新图的状态。这种方式将人工审查深度集成到工作流中,确保关键操作得到适当监督。

批量处理多个中断

当图并行执行多个节点时,可能会同时触发多个中断。LangGraph 支持一次性恢复所有中断,通过映射中断 ID 到对应的恢复值来实现。

# 获取当前状态的所有中断
state = graph.get_state(config)
interrupts = state.interrupts

# 构建恢复映射
resume_map = {
    interrupt.id: f"编辑后的值 for {interrupt.value}" 
    for interrupt in interrupts
}

# 一次性恢复所有中断
result = graph.invoke(Command(resume=resume_map), config)

这种模式在处理并行任务时特别有用,可以避免多次往返通信,提高整体效率。

工具调用人工审查

工具调用是 AI 代理与外部系统交互的主要方式,也是人工介入最关键的控制点。审查工具调用可以确保模型生成的参数正确、安全且符合业务规则。

基础工具审查

最简单的工具审查模式是在工具执行前插入中断,让人类确认或修改调用参数。

from langgraph.prebuilt import create_react_agent
from langgraph.types import interrupt

def book_hotel(hotel_name: str, nights: int):
    """预订酒店"""
    # 在真正执行前暂停
    response = interrupt(
        f"准备调用 book_hotel,参数: hotel_name={hotel_name}, nights={nights}。 "
        "请批准或建议修改。"
    )
    
    if response["type"] == "accept":
        # 执行原始调用
        return f"成功预订 {hotel_name},{nights} 晚。"
    elif response["type"] == "edit":
        # 使用修改后的参数
        new_name = response["args"]["hotel_name"]
        new_nights = response["args"]["nights"]
        return f"成功预订 {new_name},{new_nights} 晚。"
    else:
        raise ValueError("操作被拒绝")

# 创建带有审查功能的代理
agent = create_react_agent(
    model="anthropic:claude-3-5-sonnet-latest",
    tools=[book_hotel],
    checkpointer=InMemorySaver()
)

运行代理时,当模型尝试调用 book_hotel 工具,执行会在 interrupt 处暂停。人类审查者可以看到完整的调用详情,并决定接受、修改或拒绝。

通用工具审查包装器

为了避免为每个工具重复实现审查逻辑,可以创建一个通用的包装器,为任意工具添加审查功能。

from typing import Callable
from langchain_core.tools import BaseTool, tool as create_tool
from langgraph.prebuilt.interrupt import HumanInterruptConfig

def add_human_review(
    tool: Callable | BaseTool,
    *,
    interrupt_config: HumanInterruptConfig = None
) -> BaseTool:
    """为任意工具添加人工审查功能"""
    if not isinstance(tool, BaseTool):
        tool = create_tool(tool)
    
    if interrupt_config is None:
        interrupt_config = {
            "allow_accept": True,
            "allow_edit": True,
            "allow_respond": True
        }
    
    @create_tool(
        tool.name,
        description=tool.description,
        args_schema=tool.args_schema
    )
    def reviewed_tool(**tool_input):
        # 构建审查请求
        request = {
            "action_request": {
                "action": tool.name,
                "args": tool_input
            },
            "config": interrupt_config,
            "description": f"请审查工具调用: {tool.name}"
        }
        
        # 等待人类响应
        response = interrupt([request])[0]
        
        # 根据响应类型处理
        if response["type"] == "accept":
            return tool.invoke(tool_input)
        elif response["type"] == "edit":
            edited_args = response["args"]["args"]
            return tool.invoke(edited_args)
        elif response["type"] == "respond":
            # 返回自定义反馈给模型
            return response["args"]
        else:
            raise ValueError(f"不支持的响应类型: {response['type']}")
    
    return reviewed_tool

使用这个包装器,可以轻松地为现有工具添加审查功能,而无需修改工具本身的实现:

# 原始工具
def delete_user_account(user_id: str):
    """删除用户账户"""
    return f"已删除用户 {user_id}"

# 添加审查功能
reviewed_delete_tool = add_human_review(delete_user_account)

# 在代理中使用
agent = create_react_agent(
    model="anthropic:claude-3-5-sonnet-latest",
    tools=[reviewed_delete_tool],
    checkpointer=InMemorySaver()
)

这种方式将审查逻辑与业务逻辑解耦,提高了代码的可维护性和复用性。

审查响应的格式规范

为了实现工具审查的标准化,LangGraph 提供了 HumanInterrupt 和 HumanResponse 模式,定义了审查请求和响应的结构。

审查请求包含:

  • action_request: 工具调用的详细信息,包括名称和参数
  • config: 审查配置,指定允许的操作类型(接受、编辑、响应)
  • description: 人类可读的描述信息

审查响应包含:

  • type: 响应类型("accept"、"edit"、"respond")
  • args: 相关参数,根据类型不同而变化

这种标准化格式使得不同的 UI 工具(如 Agent Chat UI)能够与 LangGraph 的审查机制无缝集成,提供一致的用户体验。

与持久化系统的协同

人工介入控制与 LangGraph 的持久化系统紧密集成。每次中断时,当前状态都会被保存到检查点中。这意味着即使系统重启,也能从上次中断的位置恢复执行。

长时间等待的处理

由于状态被持久化,工作流可以暂停任意长的时间。这对于需要等待人类审查的场景特别重要,审查可能需要几小时甚至几天。系统不需要保持运行状态,只需在恢复时加载检查点即可。

# 第一次调用,触发中断
result = graph.invoke(initial_input, config=config)
# 系统可以在这里完全关闭

# 几天后,重新初始化并恢复
# 加载保存的状态
state = graph.get_state(config)

# 检查是否处于中断状态
if state.interrupts:
    # 获取审查结果(可能来自数据库或用户界面)
    human_input = get_review_result_from_database()
    
    # 恢复执行
    final_result = graph.invoke(
        Command(resume=human_input),
        config=config
    )

这种机制确保了工作流的可靠性和容错性,即使在长时间等待人类输入的情况下也能保持一致性。

避免副作用重复执行

由于中断后节点会重新执行,需要特别注意副作用的处理。任何具有副作用的操作(如 API 调用、文件写入)都应该放在 interrupt 调用之后,或者放在单独的节点中。

# 不推荐:副作用在 interrupt 之前
def problematic_node(state: State):
    api_result = call_external_api(state["data"])  # 可能重复执行
    human_input = interrupt({"api_result": api_result})
    return {"result": process(api_result, human_input)}

# 推荐:副作用在 interrupt 之后
def better_node(state: State):
    human_input = interrupt({"data": state["data"]})
    if human_input["proceed"]:
        api_result = call_external_api(state["data"])  # 只执行一次
        return {"result": api_result}
    return {"result": "cancelled"}

# 最佳:副作用在单独节点
def review_node(state: State):
    return interrupt({"data": state["data"]})

def execution_node(state: State):
    api_result = call_external_api(state["data"])
    return {"result": api_result}

遵循这些原则可以确保工作流在恢复时行为正确,不会重复执行已经完成的操作。

人工介入控制为 LangGraph 工作流提供了关键的安全保障和灵活性。通过精心设计中断点和审查流程,我们可以在自动化和人类监督之间找到最佳平衡,构建既高效又可靠的 AI 系统。下一章将探讨如何实现流式交互,让工作流的执行过程更加透明和实时。