LangChain 源码解析:HumanInTheLoopMiddleware 如何实现 Agent 人机协同
英文标题:LangChain 源码解析:HumanInTheLoopMiddleware 如何实现 Agent 人机协同
HumanInTheLoopMiddleware 是 LangChain 提供的人机协同中间件,可在 Agent 执行特定 tool 前暂停流程并请求人工决策。它支持 approve、reject、edit 和 respond 四类决策,并可通过 interrupt_on 为不同工具配置审批条件、允许的决策及动态描述。该中间件的核心逻辑位于 after_model:筛选需要中断的 tool call、构建 HITLRequest、调用 interrupt 等待人工输入,并据此更新 AIMessage 中的工具调用。对于需要控制文件写入、数据库写操作等高风险 Agent 行为的开发者而言,这一机制提供了可配置的运行时安全审查层。
6.2 KB
中文
HumanInTheLoopMiddleware 是 LangChain 提供的一个中间件,能够让用户参与到 agent 的执行过程中。
HumanInTheLoopMiddleware 为 agent 提供了一种能力:当 agent 执行 tool 时,可以询问用户下一步应如何处理。用户可以给出四种决定:
approve(同意)reject(拒绝)edit(编辑)respond(回复)
参数解析
HumanInTheLoopMiddleware 有两个参数:
interrupt_ondescription_prefix
interrupt_on 是较为重要的参数,下面对这一参数进行解析。
interrupt_on
对于每个 tool,interrupt_on 有三种配置方式:
True:表示接受全部四种决定。False:表示自动同意。InterruptOnConfig:表示可按具体情况配置决策。
下面来看 InterruptOnConfig 应如何配置:
InterruptOnConfig
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,则自动同意。
示例
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 中:
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
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 not action_requests:
return None
hitl_request = HITLRequest(
action_requests=action_requests,
review_configs=review_configs,
)
decisions = interrupt(hitl_request)["decisions"]
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)
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:
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:
revised_tool_calls.append(tool_call)
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 的消息列表进行校验。
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
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
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
具体过程
然后来看提取中断配置的逻辑:
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 not action_requests:
return None
下面来看流程中调用函数的具体逻辑。
_should_interrupt
def _should_interrupt(
self,
`tool_call`: `ToolCall`,
`config`: `InterruptOnConfig`,
`state`: `AgentState[Any]`,
`runtime`: `Runtime[ContextT]`,
) -> bool:
先看函数签名,其参数为:
tool_call:ToolCall类型,表示待判断的 tool。config:InterruptOnConfig类型,表示对应 tool 的配置。state:AgentState类型,表示 agent 此时当前对话的 state。runtime:Runtime类型,表示 agent 此时对话的运行时环境。
返回值为 bool 类型:返回 True 表示应当中断,返回 False 表示不应中断。
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,
)
return when(req)
下面来看具体逻辑:
- 首先获取配置中的
when。如果未配置,则直接返回True,表示应当中断。 - 随后获取配置、组装运行时环境、组装请求,最后调用
when判断是否应当中断。
该方法通过调用配置中的
when判断是否中断。组装请求是为了让when能根据当前的agentState和runtime作出判断,也就是说,可以根据自定义的state、context和store判断是否应当中断。
_create_action_and_config
def _create_action_and_config(
self,
tool_call: ToolCall,
config: InterruptOnConfig,
state: AgentState[Any],
runtime: Runtime[ContextT],
) -> tuple[ActionRequest, ReviewConfig]:
先来看函数签名,其参数为:
tool_call:ToolCall类型,表示待判断的 tool。config:InterruptOnConfig类型,表示该 tool 对应的interrupt_on配置。state:AgentState类型,表示当前 agent 的 state。runtime:Runtime类型,表示当前 agent 的 runtime。
返回值为 tuple[ActionRequest, ReviewConfig],即一个行动请求对象和一个审查配置对象,分别表示当前 tool 对应的行动请求和审查配置。
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。
等待用户决策
提取完配置后,中断当前运行状态,等待用户决策。
hitl_request = HITLRequest(
action_requests=action_requests,
review_configs=review_configs,
)
decisions = interrupt(hitl_request)["decisions"]
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 的 tool call 信息,并构建可能的 ToolMessage。
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_indice
English / 原文
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
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,就会自动同意。
示例
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中:
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
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 not action_requests:
return None
hitl_request = HITLRequest(
action_requests=action_requests,
review_configs=review_configs,
)
decisions = interrupt(hitl_request)["decisions"]
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)
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:
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:
revised_tool_calls.append(tool_call)
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的消息列表进行校验。
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
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
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
具体过程
然后来看一下提取中断配置的逻辑:
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 not action_requests:
return None
下面就看一下流程中调用函数的具体逻辑。
_should_interrupt
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表示不应该中断。
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,
)
return when(req)
下面来看具体的逻辑:
- 首先获得配置的
when,如果没有配置,就直接返回True,表示应该中断 - 然后就是获取配置,组装运行时环境,组装请求,最后就是调用
when来判断是否应该中断。
这个方法就是调用配置的
when来判断。组装请求是为了让when能够根据当前的agentState和runtime来判断,也就是可以根据自定义的state,context以及store来判断是否应该中断。
_create_action_and_config
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对应的行动请求和审查配置。
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.
等待用户决策
提取完配置后,中断当前的运行状态,等待用户的决策。
hitl_request = HITLRequest(
action_requests=action_requests,
review_configs=review_configs,
)
decisions = interrupt(hitl_request)["decisions"]
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。
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:
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:
`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
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这个类型:
Decision = ApproveDecision | EditDecision | RejectDecision | RespondDecision
这是四个类型的联合类型,对应的四个类型分别为:
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类型,表示回复给模型的内容。
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:
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。
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的权限控制选择和更多的能力。