HumanInTheLoopMiddleware是LangChain提供的一个中间件,能够让用户参与到agent的执行当中。
HumanInTheLoopMiddleware给agent提供一种能力,可以让agent在执行tool的时候,询问用户如何进行下一步,用户可以给出四个决定:
approve(同意)reject(拒绝)edit(编辑)respond(回复)。
参数解析
HumanInTheLoopMiddleware有两个参数:
interrupt_ondescription_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]]
下面依次来看它的成员变量:
-
allowed_decisions,DecisionType列表类型,表示允许的决策列表。DecisionType = Literal["approve", "edit", "reject", "respond"]DecisionType就是这四个决策之一。 -
description,str类型或者_DescriptionFactory,表示这个请求的描述,str,表示字符串,这个字符串就是描述_DescriptionFactory,传入一个callable对象,可以根据runtime动态的生成描述
-
args_schema,工具参数的json格式串,只有当edit选择允许时,才会生效。 -
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_file和execute_sql,执行这两个tool的时候会触发中断。 对于write_file,allowed_decisions有approve,edit,reject三种选择,并且配置了when,所以只有当writes_outside_workspace返回true的时候才会中断。 对于execute_sql,allowed_decisions有approve和reject两种选择,并且配置了when,只有当is_write_query为true的时候会发生中断。
流程解析
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列表。
在通常情况下,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
- 首先判断消息列表是否为空,如果为空,则直接返回
None - 寻找上一条
AIMessage,判断上一条AIMessage是否有工具调用,如果没有,则直接返回None
这是一个
after_model中间件,在正常情况下,上一条AIMessage即为本次模型返回的消息。
提取中断配置
完成消息列表校验之后,会结合tool_calls和interrupt_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的请求。 下面来解释一下它的三个字段:
name,str类型,行为的名称,即调用方法的名称args,字典类型,方法执行传入的参数description,str类型,对此行为请求的描述
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_name,str类型,审查行为的名称,即调用方法的名称allowed_decisions,DecisionType列表类型,表示允许的决策,从配置中读取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下标记录下来
- 首先,会判断这个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_call,ToolCall类型,表示待判断的toolconfig,InterruptOnConfig类型,对应tool配置的configstate,AgentState类型,表示agent此时当前对话的stateruntime,Runtime类型,表示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能够根据当前的agentState和runtime来判断,也就是可以根据自定义的state,context以及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_call,ToolCall类型,表示待判断的toolconfig,InterruptOnConfig类型,表示这个tool对应的interrupt_on配置state,AgentState类型,表示当前agent的stateruntime,Runtime,表示当前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_request和review_config,最后就是返回。
从这个函数中,也可以看到
action_request的name和args就是tool_call的name和args,而描述则由传入的配置决定,如果没有传入,则为description_prefix加上对应的name和args。(description_prefix也可以配置,默认值为"Tool execution requires approval")。 而对于review_config,它的action_name就是tool_call的name.
等待用户决策
提取完配置后,中断当前的运行状态,等待用户的决策。
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_calls,list[ToolCall]类型,用于储存修改后的ToolCall信息artificial_tool_message,list[ToolMessage]类型,用于储存人工返回的ToolMessage(只有当决策是reject和respond时才会返回人工的ToolMessage)decision_idx,int类型,表示当前处理到第几个决策,即待处理决策的下标
- 然后是一个循环,来遍历上一个
AIMessage的tool_calls,对于每一个循环:- 首先判断当前的
tool_call是否在interrupt_indices中,如果不在,就表示当前tool不应该被中断,直接储存原始的tool_call信息即可。如果在,就处理用户的决策。 - 处理决策:首先获取配置的
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]:
先来看它的函数签名,参数为:
decision,Decision类型,表示用户的决策tool_call,ToolCall类型,表示对应的toolcallconfig,InterruptOnConfig,表示对应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,它有一个name和args,分别表示编辑后的tool的名称和参数。RejectDecision表示拒绝决定,除了参数type,还有一个message,str类型,表示拒绝的原因,用于ToolMessage给模型看。RespondDecision表示回复决定,除了参数type,还有一个message,str类型,表示回复给模型的内容。
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代码块:
- 用户决策为
approve并且approve在allowed_decision中,表示决定为同意,就直接返回tool_call即可,并且无ToolMessage。 - 用户决策为
edit并且edit在allowed_decision中,表示决定为编辑,这会将ToolCall的名称和参数修改为用户决定返回的名称和参数。这个决策也没有ToolMessage。 - 用户决策为
reject并且reject在allowed_decision中,表示决定为拒绝,这样会人工返回一个ToolMessage表示执行失败,消息的内容是用户返回的信息,默认为用户拒绝并且不要重试。 - 用户决策为
respond并且respond在allowed_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,和agent的state。
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]}
- 首先会将对应的
AIMessage的tool_calls修改为修改后的tool_calls - 然后返回修改后的
AIMessage和人工创建的ToolMessage
返回的
messages列表中,如果id相同,会直接覆盖相同id的消息,所以返回last_ai_msg会覆盖之前的消息。 在对于AIMessage的tool_calls列表处理时,会首先过滤掉id与AIMessage后面的ToolMessage的tool_call_id相同的tool_call,所以返回artificial_tool_messages的意思就是为了过滤掉用户决策为reject和respond的tool。这也是LangChain提供的一种跳过tool_call的一种机制。
总结
以上就是HumanInTheLoopMiddleware,这个中间件为agent提供了用户选择执行tool的能力,使用户能够参与其中,增强了agent的权限控制选择和更多的能力。