跳到内容

人机协作 (Human-in-the-loop)

人机协作(或“人介入循环”)通过几种常见的用户交互模式增强了代理的能力。

常见的交互模式包括

(1) 批准 - 我们可以中断代理,向用户显示当前状态,并允许用户接受某个操作。

(2) 编辑 - 我们可以中断代理,向用户显示当前状态,并允许用户编辑代理状态。

(3) 输入 - 我们可以显式地创建一个图节点来收集人类输入,并将该输入直接传递给代理状态。

这些交互模式的用例包括

(1) 审查工具调用 - 我们可以中断代理以审查和编辑工具调用的结果。

(2) 时间旅行 - 我们可以手动回放和/或分叉代理的过去操作。

持久性

所有这些交互模式都由 LangGraph 的内置持久化层启用,该层将在每一步写入图状态的检查点。持久化允许图停止,以便人类可以审查和/或编辑图的当前状态,然后以人类的输入恢复执行。

断点

在图流的特定位置添加断点是实现人机协作的一种方式。在这种情况下,开发人员知道工作流中需要人类输入的位置,并简单地在该特定图节点之前或之后放置一个断点。

在这里,我们使用检查点和在需要中断的节点step_for_human_in_the_loop之前设置的断点来编译我们的图。然后我们执行上述交互模式之一,如果人类编辑了图状态,这将创建一个新的检查点。新的检查点保存到线程中,我们可以通过传入null作为输入从那里恢复图的执行。

// Compile our graph with a checkpointer and a breakpoint before "step_for_human_in_the_loop"
const graph = builder.compile({ checkpointer, interruptBefore: ["step_for_human_in_the_loop"] });

// Run the graph up to the breakpoint
const threadConfig = { configurable: { thread_id: "1" }, streamMode: "values" as const };
for await (const event of await graph.stream(inputs, threadConfig)) {
    console.log(event);
}

// Perform some action that requires human in the loop

// Continue the graph execution from the current checkpoint 
for await (const event of await graph.stream(null, threadConfig)) {
    console.log(event);
}

动态断点

或者,开发人员可以定义一些必须满足的条件来触发断点。这种动态断点的概念在开发人员希望在特定条件下暂停图时非常有用。这使用了NodeInterrupt,这是一种特殊类型的错误,可以根据某些条件从节点内部引发。例如,我们可以定义一个动态断点,当input的长度超过5个字符时触发。

function myNode(state: typeof GraphAnnotation.State): typeof GraphAnnotation.State {
    if (state.input.length > 5) {
        throw new NodeInterrupt(`Received input that is longer than 5 characters: ${state['input']}`);
    }
    return state;
}

假设我们使用一个触发动态断点的输入运行图,然后尝试简单地传入null作为输入来恢复图的执行。

// Attempt to continue the graph execution with no change to state after we hit the dynamic breakpoint 
for await (const event of await graph.stream(null, threadConfig)) {
    console.log(event);
}

图将再次中断,因为该节点将以相同的图状态重新运行。我们需要更改图状态,使触发动态断点的条件不再满足。因此,我们可以简单地将图状态编辑为满足动态断点条件的输入(<5个字符),然后重新运行该节点。

// Update the state to pass the dynamic breakpoint
await graph.updateState(threadConfig, { input: "foo" });
for await (const event of await graph.stream(null, threadConfig)) {
    console.log(event);
}

或者,如果我们想保留当前输入并跳过执行检查的节点(myNode)怎么办?为此,我们可以简单地将"myNode"作为第三个位置参数执行图更新,并传入null作为值。这将不对图状态进行任何更新,但会以myNode的形式运行更新,从而有效地跳过该节点并绕过动态断点。

// This update will skip the node `myNode` altogether
await graph.updateState(threadConfig, null, "myNode");
for await (const event of await graph.stream(null, threadConfig)) {
    console.log(event);
}

有关如何操作的详细说明,请参阅我们的指南

交互模式

批准

有时我们希望批准代理执行中的某些步骤。

我们可以在要批准的步骤之前,在断点处中断我们的代理。

这通常建议用于敏感操作(例如,使用外部API或写入数据库)。

通过持久化,我们可以将当前的代理状态以及下一步显示给用户进行审查和批准。

如果获得批准,图将从保存到线程的最后一个保存的检查点恢复执行。

// Compile our graph with a checkpointer and a breakpoint before the step to approve
const graph = builder.compile({ checkpointer, interruptBefore: ["node_2"] });

// Run the graph up to the breakpoint
for await (const event of await graph.stream(inputs, threadConfig)) {
    console.log(event);
}

// ... Get human approval ...

// If approved, continue the graph execution from the last saved checkpoint
for await (const event of await graph.stream(null, threadConfig)) {
    console.log(event);
}

有关如何操作的详细说明,请参阅我们的指南

编辑

有时我们希望审查和编辑代理的状态。

与批准一样,我们可以在要检查的步骤之前,在断点处中断我们的代理。

我们可以将当前状态显示给用户,并允许用户编辑代理状态。

例如,这可用于在代理出错时纠正它(例如,参见下面的工具调用部分)。

我们可以通过分叉当前检查点来编辑图状态,该检查点保存到线程中。

然后我们可以像以前一样从我们分叉的检查点继续图的执行。

// Compile our graph with a checkpointer and a breakpoint before the step to review
const graph = builder.compile({ checkpointer, interruptBefore: ["node_2"] });

// Run the graph up to the breakpoint
for await (const event of await graph.stream(inputs, threadConfig)) {
    console.log(event);
}

// Review the state, decide to edit it, and create a forked checkpoint with the new state
await graph.updateState(threadConfig, { state: "new state" });

// Continue the graph execution from the forked checkpoint
for await (const event of await graph.stream(null, threadConfig)) {
    console.log(event);
}

有关如何操作的详细说明,请参阅此指南

输入

有时我们希望在图的特定步骤中显式地获取人类输入。

我们可以为此创建一个指定的图节点(例如,我们示例图中的human_input)。

与批准和编辑一样,我们可以在此节点之前,在断点处中断我们的代理。

然后我们可以执行包含人类输入的状态更新,就像我们编辑状态时所做的那样。

但是,我们添加了一点

我们可以使用"human_input"作为状态更新的节点,以指定状态更新应被视为一个节点

这很微妙,但很重要

通过编辑,用户决定是否编辑图状态。

通过输入,我们显式地在图中定义一个节点来收集人类输入!

然后,包含人类输入的状态更新作为此节点运行。

// Compile our graph with a checkpointer and a breakpoint before the step to collect human input
const graph = builder.compile({ checkpointer, interruptBefore: ["human_input"] });

// Run the graph up to the breakpoint
for await (const event of await graph.stream(inputs, threadConfig)) {
    console.log(event);
}

// Update the state with the user input as if it was the human_input node
await graph.updateState(threadConfig, { user_input: userInput }, "human_input");

// Continue the graph execution from the checkpoint created by the human_input node
for await (const event of await graph.stream(null, threadConfig)) {
    console.log(event);
}

有关如何操作的详细说明,请参阅此指南

用例

审查工具调用

有些用户交互模式结合了上述想法。

例如,许多代理使用工具调用来做出决策。

工具调用提出了一个挑战,因为代理必须正确处理两件事

(1) 要调用的工具的名称

(2) 要传递给工具的参数

即使工具调用是正确的,我们也可能希望酌情处理

(3) 工具调用可能是一个敏感操作,我们希望获得批准

考虑到这些点,我们可以结合上述想法,创建一个人机协作的工具调用审查。

// Compile our graph with a checkpointer and a breakpoint before the step to review the tool call from the LLM 
const graph = builder.compile({ checkpointer, interruptBefore: ["human_review"] });

// Run the graph up to the breakpoint
for await (const event of await graph.stream(inputs, threadConfig)) {
    console.log(event);
}

// Review the tool call and update it, if needed, as the human_review node
await graph.updateState(threadConfig, { tool_call: "updated tool call" }, "human_review");

// Otherwise, approve the tool call and proceed with the graph execution with no edits 

// Continue the graph execution from either: 
// (1) the forked checkpoint created by human_review or 
// (2) the checkpoint saved when the tool call was originally made (no edits in human_review)
for await (const event of await graph.stream(null, threadConfig)) {
    console.log(event);
}

有关如何操作的详细说明,请参阅此指南

时光穿梭

在使用代理时,我们经常希望仔细检查它们的决策过程

(1) 即使它们达到了预期的最终结果,导致该结果的推理通常也很重要。

(2) 当代理犯错时,理解原因通常很有价值。

(3) 在上述两种情况中,手动探索替代决策路径都很有用。

总的来说,我们称这些调试概念为时间旅行,它们由回放分叉组成。

回放

有时我们只想回放代理过去的动作。

上面,我们展示了从图的当前状态(或检查点)执行代理的情况。

我们通过简单地将null作为输入并带有一个threadConfig来实现这一点。

const threadConfig = { configurable: { thread_id: "1" } };
for await (const event of await graph.stream(null, threadConfig)) {
    console.log(event);
}

现在,我们可以通过传入检查点ID来修改它,以从特定检查点回放过去的动作。

要获取特定的检查点ID,我们可以轻松获取线程中的所有检查点并过滤到我们想要的那个。

const allCheckpoints = [];
for await (const state of app.getStateHistory(threadConfig)) {
    allCheckpoints.push(state);
}

每个检查点都有一个唯一的ID,我们可以使用它从特定的检查点回放。

假设从审查检查点中,我们希望从xxx回放。

我们在运行图时只需传入检查点ID。

const config = { configurable: { thread_id: '1', checkpoint_id: 'xxx' }, streamMode: "values" as const };
for await (const event of await graph.stream(null, config)) {
    console.log(event);
}

重要的是,图知道哪些检查点已先前执行过。

因此,它将回放任何先前执行的节点,而不是重新执行它们。

有关回放的相关上下文,请参阅此附加概念指南

有关如何进行时间旅行的详细说明,请参阅此指南

分叉

有时我们希望分叉代理过去的动作,并探索图中的不同路径。

如上所述,编辑正是我们如何对图的当前状态进行此操作!

但是,如果我们想分叉图的过去状态怎么办?

例如,假设我们想编辑一个特定的检查点,xxx

我们在更新图状态时传入此checkpoint_id

const config = { configurable: { thread_id: "1", checkpoint_id: "xxx" } };
await graph.updateState(config, { state: "updated state" });

这将创建一个新的分叉检查点xxx-fork,然后我们可以从它运行图。

const config = { configurable: { thread_id: '1', checkpoint_id: 'xxx-fork' }, streamMode: "values" as const };
for await (const event of await graph.stream(null, config)) {
    console.log(event);
}

有关分叉的相关上下文,请参阅此附加概念指南

有关如何进行时间旅行的详细说明,请参阅此指南