[Deep Agents:LangChain的Agent Harness-06]通过HumanInTheLoopMiddleware引入人机交互

某些较为敏感工具在执行之前需要引入人工审理,此时就需要使用到HumanInTheLoopMiddlewareHumanInTheLoopMiddleware旨在为Agent增加一道安全护栏,它通过LangGraph的中断实现了人机交互。在工具调用前引入人工干预,确保敏感操作(如发送邮件、删除数据、大额转账)必须经过人类审核方可执行。

1.Agent的中断与恢复执行

LangGraph的中断与恢复执行功能建立在基于Checkpoint的持久化机制上,我在"拆解LangChain执行引擎"这个长系列中利用多篇文章对此做过系统介绍。为了让大家对HumanInTheLoopMiddleware的实现原理有一个直观的认识,我们先通过一个简单的实例演示来体验一下Agent的中断与恢复执行功能。如下的演示程序创建的Agent注册了一个用于转账的transfer工具,工具函数中会调用interrupt函数通过触发一个中断,等待用户的确认来决定是否继续执行转账操作。

python 复制代码
from langchain.agents import create_agent
from langgraph.types import interrupt,Command
from langchain.tools import tool
from langchain_openai import ChatOpenAI
from langgraph.checkpoint.memory import MemorySaver
from langchain_core.runnables import RunnableConfig
from dotenv import load_dotenv
import asyncio,uuid

load_dotenv()

@tool
async def transfer(account_from: str, account_to: str, amount: float):
    """"Execute a bank transfer between two accounts immediately"""
    if interrupt(f"Do you want to transfer {amount} from {account_from} to {account_to}?") == "yes":
        return f"Transfer of {amount} from {account_from} to {account_to} has been completed."
    return "Transfer request declined."

agent = create_agent(
    model=ChatOpenAI(model="gpt-5.2-chat"),
    tools=[transfer],
    checkpointer=MemorySaver(),
)

async def perform_transfer(decision: str):
    config: RunnableConfig = {"configurable": {"thread_id": uuid.uuid4().hex}}
    result = await agent.ainvoke(
        input={
            "messages": [
                {
                    "role": "user",
                    "content": "Transfer $100 from ``4242 4242 4242 4242` to `5555 5555 5555 4444`.",
                },
            ]
        },
        config=config,
        version="v2"
    )   

    interrupt_id = result.interrupts[-1].id    
    interrupt_value = result.interrupts[-1].value
    print(f"""\
Interrupt received during transfer process:
    ID: {interrupt_id}, 
     Value: {interrupt_value}""")

    result = await agent.ainvoke(input= Command(resume=decision) ,config=config)
    print(result["messages"][-1].content)

asyncio.run(perform_transfer("yes"))
asyncio.run(perform_transfer("no"))

调用Agent提供转账的功能实现在perform_transfer函数中,参数decision代表用户对于转账操作的确认,批准(approve)或拒绝(reject)为两种可用的选项。由于中断会触发持久化,而持久化是基于Thread进行的,所以调用Agent的时候需要利用RunnableConfig提供thread_id,相同的RunnableConfig被使用后续的恢复调用 中。调用Agent的ainvoke方法时,我们显式指定了version参数为"v2",这样我们才可以执行结果中利用interrupts字段来获取中断列表(由于节点的并发执行,所以可能在同一个推理步骤会产生多个中断)。

每个中断通过具有如下定义的langgraph.types.Interrupt类型表示,id字段是中断的唯一标识符,value字段则是我们在工具函数中调用interrupt函数时提供的内容。演示程序会将中断对象的idvalue打印出来。当我们以恢复执行的方式再次调用Agent时,我们通过Command对象的resume字段提供了用户的决定,这样Agent就可以根据这个决定来继续执行之前被中断的操作。

python 复制代码
@final
@dataclass(init=False, slots=True)
class Interrupt:
    value: Any
    id: str
    def __init__(
        self,
        value: Any,
        id: str = _DEFAULT_INTERRUPT_ID,
        **deprecated_kwargs: Unpack[DeprecatedKwargs],
    ) -> None
    @classmethod
    def from_ns(cls, value: Any, ns: str) -> Interrupt:
        return cls(value=value, id=xxh3_128_hexdigest(ns.encode()))

我们提供这两种决定先后调用了perform_transfer函数,最终会得到如下的输出结果:

markdown 复制代码
Interrupt received during transfer process:
    ID: 519f2744bc91d0af06c90b924814c91f, 
     Value: Do you want to transfer 100.0 from 4242 4242 4242 4242 to 5555 5555 5555 4444?
✅ **Transfer Successful!**

I've completed the transfer of **$100** with the following details:

- **From:** 4242 4242 4242 4242  
- **To:** 5555 5555 5555 4444  
- **Amount:** $100.00  

If you need a receipt, want to make another transfer, or have any other banking requests, just let me know!
markdown 复制代码
Interrupt received during transfer process:
    ID: 2d4d3358c895de0bf15c1bb94e076ffa, 
     Value: Do you want to transfer 100.0 from 4242 4242 4242 4242 to 5555 5555 5555 4444?
❌ **Transfer Failed**

The transfer of **$100** from account **4242 4242 4242 4242** to **5555 5555 5555 4444** could not be completed because the request was **declined** by the system.

### What you can do next:
- ✅ Double‑check the account numbers for accuracy  
- 💳 Ensure the source account has sufficient funds  
- 🔒 Confirm there are no restrictions or holds on either account  
- 🔁 Try the transfer again later  

If you'd like, I can help you retry the transfer or look into possible reasons for the decline. Just let me know!

2. 人机交互在HumanInTheLoopMiddleware中的实现

HumanInTheLoopMiddleware旨在Agent增加一道安全护栏。它的作用是在模型决定调用某个工具后,但在实际执行该工具代码前,拦截该操作并向用户发出通知,等待用于做出如下的决定:

  • 批准(Approve):用户确认无误,工具继续执行;
  • 编辑(Edit):用户可以修改模型生成的参数(例如修改邮件正文或接收人)后再执行;
  • 拒绝(Reject):用户拒绝执行该操作。通常可以附带反馈信息,让模型根据反馈重新思考或修正后续行动;

HumanInTheLoopMiddleware引入人为干预是基于某个工具进行的。它的__init__方法利用interrupt_on参数来提供基于工具的中断配置,另一个参数description_prefix 的作用是为人工审核请求提供默认的上下文说明。简单来说,当程序暂停并询问人类是否允许执行这个工具 时,description_prefix 决定了人类看到的提示文字开头是什么。

python 复制代码
class HumanInTheLoopMiddleware(AgentMiddleware[StateT, ContextT, ResponseT]):
    def __init__(
        self,
        interrupt_on: dict[str, bool | InterruptOnConfig],
        *,
        description_prefix: str = "Tool execution requires approval",
    ) -> None

interrupt_on参数是一个字典,Key为工具名称,Value可以是一个布尔值或一个InterruptOnConfig对象:

  • True: 代表对于这个工具的调用会被中断并等待用户的批准;
  • False: 代表对于这个工具的调用不会被中断;
  • InterruptOnConfig对象: 可以通过该对象提供更详细的配置,例如允许的决策类型、描述信息和参数模式等;

InterruptOnConfig是一个具有如下定义的TypedDict类型:

python 复制代码
class HumanInTheLoopMiddleware(AgentMiddleware[StateT, ContextT, ResponseT]):
    def __init__(
        self,
        interrupt_on: dict[str, bool | InterruptOnConfig],
        *,
        description_prefix: str = "Tool execution requires approval",
    ) -> None

class InterruptOnConfig(TypedDict):
    allowed_decisions: list[DecisionType]
    description: NotRequired[str | _DescriptionFactory]
    args_schema: NotRequired[dict[str, Any]]

DecisionType = Literal["approve", "edit", "reject"]

class _DescriptionFactory(Protocol):
    def __call__(self, tool_call: ToolCall, state: AgentState[Any], runtime: Runtime[ContextT]) -> str:

它提供了更详细的中断配置选项:

  • allowed_decisions:一个DecisionType类型的列表,代表允许的决策类型,可以是批准(approve)、编辑(edit)或拒绝(reject)中的一种或多种;
  • description:一个可选的字符串或描述工厂函数,用于提供中断的描述信息。当工具调用被中断时,这个描述信息会被展示给用户,以帮助他们做出决策。如果提供了描述工厂函数,那么该函数会在中断发生时被调用,并且会接收当前的工具调用、Agent状态和运行时作为参数,以动态生成描述信息;
  • args_schema:一个可选的字典,用于定义工具调用参数的模式。当用户选择编辑(edit)决策时,这个参数模式可以被用来验证用户提供的修改后的参数是否符合预期的格式和要求;

某个工具总是在LLM执行之后,并且其返回的AIMessage包含针对该工具的ToolCall对象情况下才会被调用。HumanInTheLoopMiddleware重写了wrap_model_call/awrap_model_call方法,它在模型调用结束后分析生成的AIMessage中的ToolCall列表,并根据配置的针对工具的中断规则,来实施中断和等待用户决策的逻辑。

python 复制代码
class HumanInTheLoopMiddleware(AgentMiddleware[StateT, ContextT, ResponseT]):
        def after_model(
        self, state: AgentState[Any], runtime: Runtime[ContextT]
    ) -> dict[str, Any] | None
    async def aafter_model(
        self, state: AgentState[Any], runtime: Runtime[ContextT]
    ) -> dict[str, Any] | None

如果AIMessage中的ToolCall对象匹配了interrupt_on参数中配置的工具,HumanInTheLoopMiddleware会按照如下的逻辑来处理:

  • 如果配置的Value为False,代表对于这个工具的调用不会被中断,HumanInTheLoopMiddleware会直接跳过,不进行任何处理;
  • 如果配置的Value为True或InterruptOnConfig对象,代表对于这个工具的调用会被中断并等待用户的决策。HumanInTheLoopMiddleware会根据InterruptOnConfig中的allowed_decisions来确定允许的决策类型,并且利用description或描述工厂函数来生成展示给用户的描述信息。然后它会触发一个中断,等待用户做出批准(approve)、编辑(edit)或拒绝(reject)的决定;
  • 当用户做出决策后,HumanInTheLoopMiddleware会根据用户的决策来继续执行后续的操作:
    • 批准(approve):原封不动地保留该工具调用;
    • 编辑(edit):用人类修改后的edited_action(新参数或新工具名)替换掉模型原始的调用;
    • 拒绝(reject):丢弃该调用,并伪造一个 ToolMessage(状态为error或自定义消息),告诉LLM这个操作被拒绝了;

HumanInTheLoopMiddleware调用interrupt函数指定的参数是一个HITLRequest对象,两个字段action_requestsreview_configs分别返回一组ActionRequestReviewConfig列表。这几个TypedDict定义了人机交互(HITL)协议的数据结构。它们共同描述了Agent想干什么人类能怎么审查 以及整个请求包长什么样ActionRequest描述了Agent想要执行的操作,包含操作名称、参数和可选的描述信息。ReviewConfig描述了针对某个操作的审查政策,包括允许的决策类型、参数模式和可选的描述信息。

python 复制代码
class HITLRequest(TypedDict):
    action_requests: list[ActionRequest]
    review_configs: list[ReviewConfig]
class ActionRequest(TypedDict):
    name: str
    args: dict[str, Any]
    description: NotRequired[str]

class ReviewConfig(TypedDict):
    action_name: str
    allowed_decisions: list[DecisionType]
    args_schema: NotRequired[dict[str, Any]]

由于可能涉及多个工具的中断,所以在调用Agent恢复执行时,指定Commandresume字段需要提供一组按照中断顺序提供一组Decision,具体为具有{"decisions": [Decision]}格式的字典。Decision是一个联合类型,具体类型可用是ApproveDecisionEditDecisionRejectDecision中的一种,它们分别代表批准、编辑和拒绝三种决策类型。这几个TypedDict利用公共字段type来区分不同的决策类型,RejectDecision进一步利用message字段提供拒绝的理由,EditDecision利用edited_action字段返回的Action对象修改调用的工具和参数。

python 复制代码
Decision = ApproveDecision | EditDecision | RejectDecision
class ApproveDecision(TypedDict):
    type: Literal["approve"]

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

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

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

3. 利用HumanInTheLoopMiddleware重写前面演示的实例

在前面演示的实例中,我们在工具函数中调用interrupt函数来触发中断来引入人机交互。下面的引入HumanInTheLoopMiddleware的版本,两个版本基本是等效的。

python 复制代码
from langchain.agents import create_agent
from langgraph.types import interrupt,Command
from langchain.tools import tool
from langchain_openai import ChatOpenAI
from langgraph.checkpoint.memory import MemorySaver
from langchain.agents.middleware import HumanInTheLoopMiddleware,InterruptOnConfig
from langchain.agents.middleware.human_in_the_loop import Decision, ApproveDecision,RejectDecision
from langchain_core.runnables import RunnableConfig
from dotenv import load_dotenv
import asyncio,uuid

load_dotenv()

@tool
async def transfer(account_from: str, account_to: str, amount: float)-> str:
    """"Execute a bank transfer between two accounts immediately"""
    return f"Transfer of {amount} from {account_from} to {account_to} has been completed."

agent = create_agent(
    model=ChatOpenAI(model="gpt-5.2-chat"),
    tools=[transfer],
    checkpointer=MemorySaver(),
    middleware=[HumanInTheLoopMiddleware(
        interrupt_on={
            "transfer": InterruptOnConfig(
                allowed_decisions= ["approve","reject"],
                description = f"Do you want to conduct such a transfer?"
            )
        }
    )],
)

async def perform_transfer(decision: Decision):
    config: RunnableConfig = {"configurable": {"thread_id": uuid.uuid4().hex}}
    result = await agent.ainvoke(
        input={
            "messages": [
                {
                    "role": "user",
                    "content": "Transfer $100 from ``4242 4242 4242 4242` to `5555 5555 5555 4444`.",
                }
            ]
        },
        config=config,
        version="v2"
    )   

    interrupt_id = result.interrupts[-1].id    
    interrupt_value = result.interrupts[-1].value
    print(f"""\
Interrupt received during transfer process:
    ID: {interrupt_id}, 
     Value: {interrupt_value}""")

    result = await agent.ainvoke(input= Command(resume={"decisions": [decision]}) ,config=config)
    print(result["messages"][-1].content)

asyncio.run(perform_transfer(ApproveDecision(type="approve")))
asyncio.run(perform_transfer(RejectDecision(type="reject",message="I don't feel safe about this transfer, so I want to reject it.")))
相关推荐
H_unique3 小时前
LangChain:提示词模板
ai·langchain
Zfox_4 小时前
【LangGraph】持久化(Persistence)
开发语言·人工智能·redis·langchain·ai编程·langgraph
GoodTimeGGB5 小时前
Windows 原生部署 Hermes Agent + 火山引擎 Agent Plan + Harness + 飞书机器人 完整实战教程与踩坑总结
agent·飞书机器人·harness·hermes·agent plan
SharpCJ13 小时前
当 AI 开始写代码,谁来保证它不会翻车?
aigc·agent·harness
Allnadyy13 小时前
【LangChain&LangGraph】LangChain与LangGraph介绍
langchain
byte轻骑兵17 小时前
【HID】规范精讲[13]: 蓝牙HID配对与虚拟线缆深度解析
人机交互·无人机·键盘·鼠标·hid
qq_2837200518 小时前
LangChain+FAISS 向量数据库搭建轻量化 RAG 应用
数据库·langchain·faiss
情绪总是阴雨天~19 小时前
LangChain 核心技术全解析:从入门到实战
langchain
H_unique21 小时前
LangChain:消息
开发语言·langchain