LangChain 源码解析:HumanInTheLoopMiddleware 如何实现 Agent 人机协同

HumanInTheLoopMiddlewareLangChain提供的一个中间件,能够让用户参与到agent的执行当中。

HumanInTheLoopMiddlewareagent提供一种能力,可以让agent在执行tool的时候,询问用户如何进行下一步,用户可以给出四个决定:

  • approve(同意)
  • reject(拒绝)
  • edit(编辑)
  • respond(回复)。

参数解析

HumanInTheLoopMiddleware有两个参数:

  • interrupt_on
  • description_prefix

interrupt_on是比较重要的参数,下面就来解析一下这个参数。

interrupt_on

interrupt_on对每个tool有三种选择:

  • True,表示四种决定都可以接受
  • False,表示自动同意
  • InterruptOnConfig,表示可以配置情况决策。

下面来看InterruptOnConfig应该如何配置:

InterruptOnConfig

python 复制代码
class InterruptOnConfig(TypedDict):
   
    allowed_decisions: list[DecisionType]

    description: NotRequired[str | _DescriptionFactory]
  
    `args_schema`: NotRequired[dict[str, Any]]

    when: NotRequired[Callable[[ToolCallRequest], bool]]

下面依次来看它的成员变量:

  1. allowed_decisionsDecisionType列表类型,表示允许的决策列表。 DecisionType = Literal["approve", "edit", "reject", "respond"] DecisionType就是这四个决策之一。

  2. descriptionstr类型或者_DescriptionFactory,表示这个请求的描述,

    • str,表示字符串,这个字符串就是描述
    • _DescriptionFactory,传入一个callable对象,可以根据runtime动态的生成描述
  3. args_schema,工具参数的json格式串,只有当edit选择允许时,才会生效。

  4. when,一个Callable类型的对象,参数为一个ToolCallRequest类型,返回值为bool类型。 当tool调用的时候,在中断之前会调用这个Callable对象,如果返回True,就正常中断;如果返回False,就会自动同意。

示例

python 复制代码
agent = create_agent(
    model="gpt-5.5",
    tools=[write_file, execute_sql, read_data],
    middleware=[
        HumanInTheLoopMiddleware(
            interrupt_on={
                "write_file": {
                    "allowed_decisions": ["approve", "edit", "reject"],
                    "when": writes_outside_workspace,
                },
                "execute_sql": {
                    "allowed_decisions": ["approve", "reject"],
                    "when": is_write_query,
                },
            },
        ),
    ],
    checkpointer=InMemorySaver(),
)

看这个示例,interrupt_on有两个工具,write_fileexecute_sql,执行这两个tool的时候会触发中断。 对于write_fileallowed_decisionsapproveeditreject三种选择,并且配置了when,所以只有当writes_outside_workspace返回true的时候才会中断。 对于execute_sqlallowed_decisionsapprovereject两种选择,并且配置了when,只有当is_write_querytrue的时候会发生中断。

流程解析

HumanInTheLoopMiddleware的具体逻辑在after_model中:

python 复制代码
    def after_model(
        self, `state`: `AgentState[Any]`, `runtime`: `Runtime[ContextT]`
    ) -> dict[str, Any] | None:
        """Trigger interrupt flows for relevant tool calls after an `AIMessage`.

        Args:
            `state`: The current agent state.
            `runtime`: The runtime context.

        Returns:
            Updated message with the revised tool calls.

        Raises:
            ValueError: If the number of human decisions does not match the number of
                interrupted tool calls.
        """
        `messages` = `state`["messages"]
        if not messages:
            return None

        `last_ai_msg` = `next`((`msg` for `msg` in `reversed`(`messages`) if `isinstance`(`msg`, `AIMessage`)), `None`)
        if not last_ai_msg or not last_ai_msg.tool_calls:
            return None

        # Create action requests and review configs for tools that need approval
        action_requests: list[ActionRequest] = []
        review_configs: list[ReviewConfig] = []
        interrupt_indices: list[int] = []

        for idx, tool_call in enumerate(last_ai_msg.tool_calls):
            if (config := self.interrupt_on.get(tool_call["name"])) is not None:
                if not self._should_interrupt(tool_call, config, state, runtime):
                    continue
                action_request, review_config = self._create_action_and_config(
                    tool_call, config, state, runtime
                )
                action_requests.append(action_request)
                review_configs.append(review_config)
                interrupt_indices.append(idx)

        # If no interrupts needed, return early
        if not action_requests:
            return None

        # Create single HITLRequest with all actions and configs
        hitl_request = HITLRequest(
            action_requests=action_requests,
            review_configs=review_configs,
        )

        # Send interrupt and get response
        decisions = interrupt(hitl_request)["decisions"]

        # Validate that the number of decisions matches the number of interrupt tool calls
        if (decisions_len := len(decisions)) != (interrupt_count := len(interrupt_indices)):
            msg = (
                f"Number of human decisions ({decisions_len}) does not match "
                f"number of hanging tool calls ({interrupt_count})."
            )
            raise ValueError(msg)

        # Process decisions and rebuild tool calls in original order
        revised_tool_calls: list[ToolCall] = []
        artificial_tool_messages: list[ToolMessage] = []
        decision_idx = 0

        for idx, tool_call in enumerate(last_ai_msg.tool_calls):
            if idx in interrupt_indices:
                # This was an interrupt tool call - process the decision
                config = self.interrupt_on[tool_call["name"]]
                decision = decisions[decision_idx]
                decision_idx += 1

                revised_tool_call, tool_message = self._process_decision(
                    decision, tool_call, config
                )
                if revised_tool_call is not None:
                    revised_tool_calls.append(revised_tool_call)
                if tool_message:
                    artificial_tool_messages.append(tool_message)
            else:
                # This was auto-approved - keep original
                revised_tool_calls.append(tool_call)

        # Update the AI message to only include approved tool calls
        last_ai_msg.tool_calls = revised_tool_calls

        return {"messages": [last_ai_msg, *artificial_tool_messages]}

先看一下大致的流程:

  • 先判断当前模型的回复有没有调用tool,如果没有,直接返回。
  • 获取配置,检查是否有tool需要中断,如果没有,返回。
  • 中断,等待决策。
  • 处理用户的决策,并且更新messages列表。
flowchart TD A[after_model 接收 AIMessage] --> B{是否存在 tool_calls?} B -- 否 --> Z[结束,返回 None] B -- 是 --> C[检查 tool_calls<br/>筛选需要人工审批的工具] C --> D{是否存在需要审批的 tool?} D -- 否 --> Z D -- 是 --> E[创建 HITLRequest] E --> F[interrupt 暂停 Agent<br/>等待人工决策] F --> G[获取 Human Decision] G --> H{处理决策结果} H --> I[更新 tool_calls<br/>approve / reject / edit] I --> J[更新 AIMessage 和 messages] J --> K[返回更新后的状态<br/>继续 Agent 执行]

在通常情况下,after_model都会存在一条AIMessage

下面来分析各个步骤具体的逻辑:

消息列表校验

在开始判断的逻辑之前,会对state的消息列表进行校验。

python 复制代码
        messages = state["messages"]
        if not messages:
            return None

        last_ai_msg = next((msg for msg in reversed(messages) if isinstance(msg, AIMessage)), None)
        if not last_ai_msg or not last_ai_msg.`tool_calls`:
            return None
  1. 首先判断消息列表是否为空,如果为空,则直接返回None
  2. 寻找上一条AIMessage,判断上一条AIMessage是否有工具调用,如果没有,则直接返回None

这是一个after_model中间件,在正常情况下,上一条AIMessage即为本次模型返回的消息。

提取中断配置

完成消息列表校验之后,会结合tool_callsinterrupt_on的配置提取中断配置。

我们先来了解使用到的几个类:

ActionRequest

python 复制代码
class `ActionRequest`(`TypedDict`):
    """Represents an action request with a name, args, and description."""

    `name`: `str`
    """The name of the action being requested."""

    `args`: `dict`[`str`, `Any`]
    """Key-value pairs of args needed for the action (e.g., `{"a": 1, "b": 2}`)."""

    `description`: `NotRequired`[`str`]
    """The description of the action to be reviewed."""

这个类表示行动请求,即发生中断时给human的请求。 下面来解释一下它的三个字段:

  • namestr类型,行为的名称,即调用方法的名称
  • args,字典类型,方法执行传入的参数
  • descriptionstr类型,对此行为请求的描述

ReviewConfig

python 复制代码
class ReviewConfig(TypedDict):
    """Policy for reviewing a HITL request."""

    action_name: str
    """Name of the action associated with this review configuration."""

    allowed_decisions: list[DecisionType]
    """The decisions that are allowed for this request."""

    args_schema: NotRequired[dict[str, Any]]
    """JSON schema for the args associated with the action, if edits are allowed."""

这个类表示审查配置,即对human的决策审查配置。 下面来解释一下它的三个字段:

  • action_namestr类型,审查行为的名称,即调用方法的名称
  • allowed_decisionsDecisionType列表类型,表示允许的决策,从配置中读取
  • args_schema

具体过程

然后来看一下提取中断配置的逻辑:

python 复制代码
# Create action requests and review configs for tools that need approval
        action_requests: list[ActionRequest] = []
        review_configs: list[ReviewConfig] = []
        interrupt_indices: list[int] = []

        for idx, tool_call in enumerate(last_ai_msg.tool_calls):
            if (config := self.interrupt_on.get(tool_call["name"])) is not None:
                if not self._should_interrupt(tool_call, config, state, runtime):
                    continue
                action_request, review_config = self._create_action_and_config(
                    tool_call, config, state, runtime
                )
                action_requests.append(action_request)
                review_configs.append(review_config)
                interrupt_indices.append(idx)

        # If no interrupts needed, return early
        if not action_requests:
            return None
  • 首先,定义了三个变量:

    • action_requests,用于储存行动请求
    • review_configs,用于储存对应的审查配置
    • interrupt_indices,用于储存需要中断的tool在AIMessage的tool_calls中对应的下标
  • 然后,下面是一个循环来找到需要中断的tool: 对于循环:

    • 首先,会判断这个tool有没有在interrupt_on上配置,如果没有配置,就跳过。
    • 然后,调用_should_interrupt方法判断是否应该中断,如果返回False,就跳过
    • 接着,就调用_create_action_and_config来获取这个tool对应的行动请求和审查配置
    • 最后,将获取的行动请求和审查配置加入到对应的列表中,并且将这个tool下标记录下来
  • 最后,判断action_requests是否为空,如果为空,就代表没有tool需要中断,直接返回None


下面就看一下流程中调用函数的具体逻辑。

_should_interrupt

python 复制代码
    def _should_interrupt(
        self,
        `tool_call`: `ToolCall`,
        `config`: `InterruptOnConfig`,
        `state`: `AgentState[Any]`,
        `runtime`: `Runtime[ContextT]`,
    ) -> bool:

先看函数签名,参数为:

  • tool_callToolCall类型,表示待判断的tool
  • configInterruptOnConfig类型,对应tool配置的config
  • stateAgentState类型,表示agent此时当前对话的state
  • runtimeRuntime类型,表示agent此时对话的运行时环境

返回值为bool类型,返回True表示应该中断,返回False表示不应该中断。

python 复制代码
		when = config.get("when")
        if when is None:
            return True
        try:
            runnable_config = get_config()
        except RuntimeError:
            runnable_config = {}
        tool_runtime = ToolRuntime(
            state=state,
            context=runtime.context,
            config=runnable_config,
            stream_writer=runtime.stream_writer,
            tool_call_id=tool_call["id"],
            store=runtime.store,
            execution_info=runtime.execution_info,
            server_info=runtime.server_info,
        )
        req = ToolCallRequest(
            tool_call=tool_call,
            tool=None,
            state=state,
            runtime=tool_runtime,  # type: ignore[arg-type]
        )
        return when(req)

下面来看具体的逻辑:

  • 首先获得配置的when,如果没有配置,就直接返回True,表示应该中断
  • 然后就是获取配置,组装运行时环境,组装请求,最后就是调用when来判断是否应该中断。

这个方法就是调用配置的when来判断。组装请求是为了让when能够根据当前的agentStateruntime来判断,也就是可以根据自定义的statecontext以及store来判断是否应该中断。

_create_action_and_config

python 复制代码
def _create_action_and_config(
        self,
        tool_call: ToolCall,
        config: InterruptOnConfig,
        state: AgentState[Any],
        runtime: Runtime[ContextT],
    ) -> tuple[ActionRequest, ReviewConfig]:

先来看函数签名,参数为:

  • tool_callToolCall类型,表示待判断的tool
  • configInterruptOnConfig类型,表示这个tool对应的interrupt_on配置
  • stateAgentState类型,表示当前agent的state
  • runtimeRuntime,表示当前agent的runtime

返回值为tuple[ActionRequest, ReviewConfig],一个行动请求对象和一个审查配置的对象,表示当前tool对应的行动请求和审查配置。

python 复制代码
        tool_name = tool_call["name"]
        tool_args = tool_call["args"]

        description_value = `config.get("description")`
        if `callable(description_value)`:
            description = description_value(tool_call, state, runtime)
        elif description_value is not `None`:
            description = description_value
        else:
            description = f"{self.description_prefix}\n\nTool: {tool_name}\nArgs: {tool_args}"

        action_request = ActionRequest(
            name=tool_name,
            args=tool_args,
            description=description,
        )
        
        review_config = ReviewConfig(
            action_name=tool_name,
            allowed_decisions=`config["allowed_decisions"]`,
        )

        return action_request, review_config

下面来看具体的逻辑:

  • 首先获取tool_call的名称和参数,然后获取描述,
  • 接着就是组装action_requestreview_config,最后就是返回。

从这个函数中,也可以看到action_requestnameargs就是tool_callnameargs,而描述则由传入的配置决定,如果没有传入,则为description_prefix加上对应的nameargs。(description_prefix也可以配置,默认值为"Tool execution requires approval")。 而对于review_config,它的action_name就是tool_callname.

等待用户决策

提取完配置后,中断当前的运行状态,等待用户的决策。

python 复制代码
        # Create single HITLRequest with all actions and configs
        hitl_request = HITLRequest(
            action_requests=action_requests,
            review_configs=review_configs,
        )

        # Send interrupt and get response
        decisions = interrupt(hitl_request)["decisions"]

        # Validate that the number of decisions matches the number of interrupt tool calls
        if (decisions_len := len(decisions)) != (interrupt_count := len(`interrupt_indices`)):
            msg = (
                f"Number of human decisions ({decisions_len}) does not match "
                f"number of hanging tool calls ({interrupt_count})."
            )
            raise `ValueError`(msg)

来看具体的逻辑:

  • 首先,会组装好所需的HITLRequest(HumanInTheLoopRequest)
  • 然后,调用interrupt方法,中断当前的运行状态,等待决策
  • 最后,判断判断决策的数量是否等于中断方法的数量,如果不等于,就抛出异常。

处理用户决策

用户返回决策后,会处理用户的决策信息,重建AIMessage的toolcall信息,以及构建可能的ToolMessage

python 复制代码
        # Process decisions and rebuild tool calls in original order
        revised_tool_calls: list[ToolCall] = []
        artificial_tool_messages: list[ToolMessage] = []
        decision_idx = 0

        for idx, tool_call in enumerate(`last_ai_msg.tool_calls`):
            if idx in interrupt_indices:
                # This was an interrupt tool call - process the decision
                config = self.interrupt_on[`tool_call["name"]`]
                decision = decisions[decision_idx]
                decision_idx += 1

                revised_tool_call, tool_message = self._process_decision(
                    decision, tool_call, config
                )
                if revised_tool_call is not None:
                    `revised_tool_calls.append(revised_tool_call)`
                if tool_message:
                    `artificial_tool_messages.append(tool_message)`
            else:
                # This was auto-approved - keep original
                `revised_tool_calls.append(tool_call)`

来看这一部分的逻辑:

  • 首先定义了三个变量: * revised_tool_callslist[ToolCall]类型,用于储存修改后的ToolCall信息
    • artificial_tool_messagelist[ToolMessage]类型,用于储存人工返回的ToolMessage(只有当决策是rejectrespond时才会返回人工的ToolMessage
    • decision_idxint类型,表示当前处理到第几个决策,即待处理决策的下标
  • 然后是一个循环,来遍历上一个AIMessagetool_calls,对于每一个循环:
    1. 首先判断当前的tool_call是否在interrupt_indices中,如果不在,就表示当前tool不应该被中断,直接储存原始的tool_call信息即可。如果在,就处理用户的决策。
    2. 处理决策:首先获取配置的interrupt_on信息和用户的决策,在将decision_idx加1,表示当前决策被处理。然后调用_process_decision来处理决策。如果返回的处理后的tool_call不是None,就加入到ToolCall队列当中。如果有tool_message的话,就加入到对应的列表中。

下面就来看一下相关函数的具体逻辑。

_process_decision

python 复制代码
    def _process_decision(
        self,
        decision: Decision,
        tool_call: ToolCall,
        config: InterruptOnConfig,
    ) -> tuple[ToolCall | None, ToolMessage | None]:

先来看它的函数签名,参数为:

  • decisionDecision类型,表示用户的决策
  • tool_callToolCall类型,表示对应的toolcall
  • configInterruptOnConfig,表示对应tool的interrupt_on配置 返回值为一个二元组,第一个为ToolCall或者None,表示根据决策和配置修改后的tool_call;第二个ToolMessage | None,表示根据决策可能会返回的ToolMessage,也可能会返回None

在讲具体的逻辑之前,先看一下Decision这个类型:

python 复制代码
Decision = ApproveDecision | EditDecision | RejectDecision | RespondDecision

这是四个类型的联合类型,对应的四个类型分别为:

python 复制代码
class ApproveDecision(TypedDict):
    type: Literal["approve"]

class EditDecision(TypedDict):
    type: Literal["edit"]
    edited_action: Action

class Action(TypedDict):
    name: str
    args: dict[str, Any]

class RejectDecision(TypedDict):
    type: Literal["reject"]
    message: NotRequired[str]
    
class RespondDecision(TypedDict):
    type: Literal["respond"]
    message: str

这四个类型分别对应用户的四个决定:

  • ApproveDecision表示同意决定,只有一个参数type。
  • EditDecision表示编辑决定,除了参数type,还有一个edited_action参数,类型为Action,它有一个nameargs,分别表示编辑后的tool的名称和参数。
  • RejectDecision表示拒绝决定,除了参数type,还有一个messagestr类型,表示拒绝的原因,用于ToolMessage给模型看。
  • RespondDecision表示回复决定,除了参数type,还有一个messagestr类型,表示回复给模型的内容。

python 复制代码
        allowed_decisions = config["allowed_decisions"]

        if decision["type"] == "approve" and "approve" in allowed_decisions:
            return tool_call, None
        if decision["type"] == "edit" and "edit" in allowed_decisions:
            edited_action = decision["edited_action"]
            return (
                ToolCall(
                    type="tool_call",
                    name=edited_action["name"],
                    args=edited_action["args"],
                    id=tool_call["id"],
                ),
                None,
            )
        if decision["type"] == "reject" and "reject" in allowed_decisions:
            content = decision.get("message") or (
                f"User rejected the tool call for `{tool_call['name']}` with id {tool_call['id']}. "
                "The tool was not executed. Do not retry this tool call unless the user "
                "explicitly requests it."
            )
            tool_message = ToolMessage(
                content=content,
                name=tool_call["name"],
                tool_call_id=tool_call["id"],
                status="error",
            )
            return tool_call, tool_message
        if decision["type"] == "respond" and "respond" in allowed_decisions:
            # Skip tool execution; the human answers on behalf of the tool.
            tool_message = ToolMessage(
                content=decision["message"],
                name=tool_call["name"],
                tool_call_id=tool_call["id"],
                status="success",
            )
            return tool_call, tool_message
        msg = (
            f"Unexpected human decision: {decision}. "
            f"Decision type '{decision.get('type')}' "
            f"is not allowed for tool '{tool_call['name']}'. "
            f"Expected one of {allowed_decisions} based on the tool's configuration."
        )
        raise ValueError(msg)

下面再来看这个函数的具体逻辑: 首先从config中获得allowed_decision,然后就是四个if代码块:

  1. 用户决策为approve并且approveallowed_decision中,表示决定为同意,就直接返回tool_call即可,并且无ToolMessage
  2. 用户决策为edit并且editallowed_decision中,表示决定为编辑,这会将ToolCall的名称和参数修改为用户决定返回的名称和参数。这个决策也没有ToolMessage
  3. 用户决策为reject并且rejectallowed_decision中,表示决定为拒绝,这样会人工返回一个ToolMessage表示执行失败,消息的内容是用户返回的信息,默认为用户拒绝并且不要重试。
  4. 用户决策为respond并且respondallowed_decision中,表示决定为回复,这个决定会跳过工具的执行,会直接返回一个ToolMessage,消息的内容是决策是用户返回的信息。

从这四个if代码块中可以看出四种决策的具体执行逻辑,approve会执行中断的tool;edit会执行tool,但可能会执行不是中断的tool,参数也可能改变;reject不会执行tool,ToolMessage会显示执行失败;respond不会执行tool,ToolMessage会显示tool执行成功。

决策类型 (DecisionType) 工具是否真正执行? 是否生成人工 ToolMessage? 工具参数来源 典型应用场景
approve 否(由 ToolNode 正常执行并生成) 原模型生成参数 高危操作人工确认
edit 否(由 ToolNode 携带新参数执行) 人工修改后的参数 修正模型生成的错误参数/SQL
reject 是(status=""error"") 拒绝非法或高风险请求
respond 是(status="success") 无(直接以人工输入作为工具输出) 人工替代工具提供确切答案

在程序设计正确时,用户的决策范围会等于allowed_decisions,所以会属于上面四种情况。当出现用户的决定不在allowed_decisions,会抛出异常,这是一种防御性编程为了兜底。

当用户的决策为edit时,可以修改ToolCall的名称和参数,这为程序的设计增加了更多的可能性,但在能够的同时,要小心可能的bug和恶意攻击。
看完这个函数之后,能够看出它的返回值中ToolCall类型的一定不会为None,在after_model的主逻辑中判断了修改后的ToolCall是否为None,可能是因为之前版本的设计思想是reject(也许有respond)时不会返回tool_call,即会直接删除拒绝的toolcall,而不是返回一个ToolMessage。当然,也有可能是为了防御性编程;或者是为未来的可能扩展的决策预留。

更新AIMessage和state

最后,会更新此AIMessage,和agentstate

python 复制代码
        # Update the AI message to only include approved tool calls
        last_ai_msg.tool_calls = `revised_tool_calls`

        return {"messages": [last_ai_msg, *artificial_tool_messages]}
  • 首先会将对应的AIMessagetool_calls修改为修改后的tool_calls
  • 然后返回修改后的AIMessage和人工创建的ToolMessage

返回的messages列表中,如果id相同,会直接覆盖相同id的消息,所以返回last_ai_msg会覆盖之前的消息。 在对于AIMessagetool_calls列表处理时,会首先过滤掉idAIMessage后面的ToolMessagetool_call_id相同的tool_call,所以返回artificial_tool_messages的意思就是为了过滤掉用户决策为rejectrespondtool。这也是LangChain提供的一种跳过tool_call的一种机制。

总结

以上就是HumanInTheLoopMiddleware,这个中间件为agent提供了用户选择执行tool的能力,使用户能够参与其中,增强了agent的权限控制选择和更多的能力。

相关推荐
l1258652 小时前
# RAG向量数据库优化实战:HNSW索引调参与生产级性能设计
数据库·python·mysql·langchain
l1258654 小时前
# RAG噪声知识库治理:一致性与可信度的四层防线设计
数据库·人工智能·python·自然语言处理·langchain
二进制_博客5 小时前
rag面试题之rag 系统如何解决 llm 的幻觉问题
langchain·rag
jyOverQ6 小时前
LangGraph 流式执行详解:stream、astream 与 stream_mode 怎么选?
python·langchain
l1258658 小时前
# RAG低延迟架构设计:从5秒到500ms的优化全链路
数据库·人工智能·python·langchain
睡觉时不困4428 小时前
17.LangChain 1.0+ 第五篇:Tools 工具——让大模型从认识世界到改变世界
langchain
为你学会写情书8 小时前
NestJS + LangChain 集成指南:从库封装到手动构建
langchain
草莓熊Lotso9 小时前
【Linux网络加餐】手动部署:SSH 与 Web 服务实战 + 底层原理全解析
linux·运维·网络·人工智能·python·langchain·ssh
65岁退休Coder1 天前
LangChain v1.3.4 笔记 - 08 MCP & 相关概念
后端·python·langchain