Skip to content

如何审查工具调用

前提条件

本指南假设您熟悉以下概念:

人机协作(HIL)交互对于智能代理系统至关重要。一种常见的模式是在某些工具调用之后添加一些人机协作步骤。这些工具调用通常会导致函数调用或保存某些信息。例如包括:

  • 调用一个执行 SQL 的工具,然后由该工具运行
  • 调用一个生成摘要的工具,然后将结果保存到图的状态中

请注意,使用工具调用是常见的 无论是否实际调用工具

在此处,您可能希望进行几种不同的交互:

  1. 批准工具调用并继续
  2. 手动修改工具调用,然后继续
  3. 提供自然语言反馈,然后将反馈传递回代理

我们可以使用 [interrupt()][langgraph.types.interrupt] 函数在 LangGraph 中实现这些功能。interrupt 允许我们停止图的执行以收集用户的输入,并使用收集到的输入继续执行:

def human_review_node(state) -> Command[Literal["call_llm", "run_tool"]]:
    # 这是我们通过 Command(resume=<human_review>) 提供的值
    human_review = interrupt(
        {
            "question": "这是正确的吗?",
            # 显示工具调用以供审查
            "tool_call": tool_call
        }
    )

    review_action, review_data = human_review

    # 批准工具调用并继续
    if review_action == "continue":
        return Command(goto="run_tool")

    # 手动修改工具调用,然后继续
    elif review_action == "update":
        ...
        updated_msg = get_updated_msg(review_data)
        return Command(goto="run_tool", update={"messages": [updated_message]})

    # 提供自然语言反馈,然后将反馈传递回代理
    elif review_action == "feedback":
        ...
        feedback_msg = get_feedback_msg(review_data)
        return Command(goto="call_llm", update={"messages": [feedback_msg]})

设置

我们不会展示我们托管的图的完整代码,但你可以在这里看到它。一旦这个图被托管,我们就可以调用它并等待用户输入。

SDK 初始化

首先,我们需要设置客户端,以便与托管的图进行通信:

from langgraph_sdk import get_client
client = get_client(url=<DEPLOYMENT_URL>)
# 使用名称为 "agent" 的已部署图
assistant_id = "agent"
thread = await client.threads.create()
import { Client } from "@langchain/langgraph-sdk";

const client = new Client({ apiUrl: <DEPLOYMENT_URL> });
// 使用名称为 "agent" 的已部署图
const assistantId = "agent";
const thread = await client.threads.create();
curl --request POST \
  --url <DEPLOYMENT_URL>/threads \
  --header 'Content-Type: application/json' \
  --data '{}'

批准工具的示例

首先,让我们使用一个需要批准工具调用的输入来运行代理:

input = {"messages": [{"role": "user", "content": "what's the weather in sf?"}]}

async for chunk in client.runs.stream(
    thread["thread_id"],
    assistant_id,
    input=input,
    stream_mode="updates",
):
    if chunk.data and chunk.event != "metadata": 
        print(chunk.data)
const input = { "messages": [{ "role": "user", "content": "what's the weather in sf?" }] };

const streamResponse = client.runs.stream(
  thread["thread_id"],
  assistantId,
  {
    input: input,
    streamMode: "updates"
  }
);

for await (const chunk of streamResponse) {
  if (chunk.data && chunk.event !== "metadata") {
    console.log(chunk.data);
  }
}
curl --request POST \
 --url <DEPLOYMENT_URL>/threads/<THREAD_ID>/runs/stream \
 --header 'Content-Type: application/json' \
 --data "{
   \"assistant_id\": \"agent\",
   \"input\": {\"messages\": [{\"role\": \"human\", \"content\": \"what's the weather in sf?\"}]},
   \"stream_mode\": [
     \"updates\"
   ]
 }"

输出:

{'call_llm': {'messages': [{'content': [{'text': "I'll help you check the weather in San Francisco.", 'type': 'text'}, {'id': 'toolu_01142G3woscA8JjFTLdqymtn', 'input': {'city': 'San Francisco'}, 'name': 'weather_search', 'type': 'tool_use'}], 'additional_kwargs': {}, 'response_metadata': {'id': 'msg_01Tdfufy4nZYXMbVZvgyNbhc', 'model': 'claude-3-5-sonnet-20241022', 'stop_reason': 'tool_use', 'stop_sequence': None, 'usage': {'cache_creation_input_tokens': 0, 'cache_read_input_tokens': 0, 'input_tokens': 379, 'output_tokens': 66}, 'model_name': 'claude-3-5-sonnet-20241022'}, 'type': 'ai', 'name': None, 'id': 'run-a33434b2-f5ca-40c6-98e2-6288d349d4ce-0', 'example': False, 'tool_calls': [{'name': 'weather_search', 'args': {'city': 'San Francisco'}, 'id': 'toolu_01142G3woscA8JjFTLdqymtn', 'type': 'tool_call'}], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 379, 'output_tokens': 66, 'total_tokens': 445, 'input_token_details': {'cache_read': 0, 'cache_creation': 0}}}]}}
{'__interrupt__': [{'value': {'question': 'Is this correct?', 'tool_call': {'name': 'weather_search', 'args': {'city': 'San Francisco'}, 'id': 'toolu_01142G3woscA8JjFTLdqymtn', 'type': 'tool_call'}}, 'resumable': True, 'ns': ['human_review_node:9caf42cf-1371-7213-a331-e6fe5d026be8'], 'when': 'during'}]}

为了批准工具调用,我们需要让 human_review_node 知道在节点内定义的 human_review 变量应该使用什么值。我们可以通过向图中传递一个 Command(resume=<human_review>) 输入来提供这个值。由于我们正在批准工具调用,我们将提供一个 resume 值为 {"action": "continue"},以导航到 run_tool 节点:

from langgraph_sdk.schema import Command

async for chunk in client.runs.stream(
    thread["thread_id"],
    assistant_id,
    command=Command(resume={"action": "continue"}),
    stream_mode="updates",
):
    if chunk.data and chunk.event != "metadata": 
        print(chunk.data)
const streamResponse = client.runs.stream(
  thread["thread_id"],
  assistantId,
  {
    command: { resume: { "action": "continue" } },
    streamMode: "updates"
  }
);

for await (const chunk of streamResponse) {
  if (chunk.data && chunk.event !== "metadata") {
    console.log(chunk.data);
  }
}
curl --request POST \
 --url <DEPLOYMENT_URL>/threads/<THREAD_ID>/runs/stream \
 --header 'Content-Type: application/json' \
 --data "{
   \"assistant_id\": \"agent\",
   \"command\": {
     \"resume\": { \"action\": \"continue\"}
   },
   \"stream_mode\": [
     \"updates\"
   ]
 }"

输出:

{'human_review_node': None}
{'run_tool': {'messages': [{'role': 'tool', 'name': 'weather_search', 'content': 'Sunny!', 'tool_call_id': 'toolu_01142G3woscA8JjFTLdqymtn'}]}}
{'call_llm': {'messages': [{'content': "According to the search, it's sunny in San Francisco right now!", 'additional_kwargs': {}, 'response_metadata': {'id': 'msg_01JJE9AtT4a9Lob91RRiW9rU', 'model': 'claude-3-5-sonnet-20241022', 'stop_reason': 'end_turn', 'stop_sequence': None, 'usage': {'cache_creation_input_tokens': 0, 'cache_read_input_tokens': 0, 'input_tokens': 458, 'output_tokens': 18}, 'model_name': 'claude-3-5-sonnet-20241022'}, 'type': 'ai', 'name': None, 'id': 'run-5e8d80b5-c46a-4aad-af37-b01f8bb15963-0', 'example': False, 'tool_calls': [], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 458, 'output_tokens': 18, 'total_tokens': 476, 'input_token_details': {'cache_read': 0, 'cache_creation': 0}}}]}}

编辑工具调用

现在假设我们想要编辑工具调用。例如,更改一些参数(甚至更改调用的工具!),然后执行该工具。

input = {"messages": [{"role": "user", "content": "what's the weather in sf?"}]}

async for chunk in client.runs.stream(
    thread["thread_id"],
    assistant_id,
    input=input,
    stream_mode="updates",
):
    if chunk.data and chunk.event != "metadata": 
        print(chunk.data)
const input = { "messages": [{ "role": "user", "content": "what's the weather in sf?" }] };

const streamResponse = client.runs.stream(
  thread["thread_id"],
  assistantId,
  {
    input: input,
    streamMode: "updates",
  }
);

for await (const chunk of streamResponse) {
  if (chunk.data && chunk.event !== "metadata") {
    console.log(chunk.data);
  }
}
curl --request POST \
 --url <DEPLOYMENT_URL>/threads/<THREAD_ID>/runs/stream \
 --header 'Content-Type: application/json' \
 --data "{
   \"assistant_id\": \"agent\",
   \"input\": {\"messages\": [{\"role\": \"human\", \"content\": \"what's the weather in sf?\"}]},
   \"stream_mode\": [
     \"updates\"
   ]
 }"

为了实现这一点,我们将使用 Command 并将 resume 值设为 {"action": "update", "data": <tool call args>}。这将执行以下操作:

  • 将现有的工具调用与用户提供的工具调用参数结合,并用新的工具调用更新现有的 AI 消息
  • 使用更新后的 AI 消息导航到 run_tool 节点并继续执行
from langgraph_sdk.schema import Command

async for chunk in client.runs.stream(
    thread["thread_id"],
    assistant_id,
    command=Command(
        resume={"action": "update", "data": {"city": "San Francisco, USA"}}
    ),
    stream_mode="updates",
):
    if chunk.data and chunk.event != "metadata": 
        print(chunk.data)
const streamResponse = client.runs.stream(
  thread["thread_id"],
  assistantId,
  {
    command: {
      resume: { "action": "update", "data": { "city": "San Francisco, USA" } }
    },
    streamMode: "updates"
  }
);

for await (const chunk of streamResponse) {
  if (chunk.data && chunk.event !== "metadata") {
    console.log(chunk.data);
  }
}
curl --request POST \
 --url <DEPLOYMENT_URL>/threads/<THREAD_ID>/runs/stream \
 --header 'Content-Type: application/json' \
 --data "{
   \"assistant_id\": \"agent\",
   \"command\": {
     \"resume\": { \"action\": \"update\", \"data\": { \"city\": \"San Francisco, USA\" } }
   },
   \"stream_mode\": [
     \"updates\"
   ]
 }"

输出:

{'human_review_node': {'messages': [{'role': 'ai', 'content': [{'text': "I'll help you check the weather in San Francisco.", 'type': 'text'}, {'id': 'toolu_016L4EDPcaQRzzZxiB4Wq2wa', 'input': {'city': 'San Francisco'}, 'name': 'weather_search', 'type': 'tool_use'}], 'tool_calls': [{'id': 'toolu_016L4EDPcaQRzzZxiB4Wq2wa', 'name': 'weather_search', 'args': {'city': 'San Francisco, USA'}}], 'id': 'run-b07f0c35-4e93-43a5-9b48-363767ada3ca-0'}]}}
{'run_tool': {'messages': [{'role': 'tool', 'name': 'weather_search', 'content': 'Sunny!', 'tool_call_id': 'toolu_016L4EDPcaQRzzZxiB4Wq2wa'}]}}
{'call_llm': {'messages': [{'content': "According to the search, it's sunny in San Francisco right now!", 'additional_kwargs': {}, 'response_metadata': {'id': 'msg_01De5HurjNUMwMUpfRtMLbX1', 'model': 'claude-3-5-sonnet-20241022', 'stop_reason': 'end_turn', 'stop_sequence': None, 'usage': {'cache_creation_input_tokens': 0, 'cache_read_input_tokens': 0, 'input_tokens': 460, 'output_tokens': 18}, 'model_name': 'claude-3-5-sonnet-20241022'}, 'type': 'ai', 'name': None, 'id': 'run-85e2aaaa-6f61-4fa0-b594-b6e57129d7e7-0', 'example': False, 'tool_calls': [], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 460, 'output_tokens': 18, 'total_tokens': 478, 'input_token_details': {'cache_read': 0, 'cache_creation': 0}}}]}}

给工具调用提供反馈

有时候,你可能不希望执行一个工具调用,但又不希望让用户手动修改这个工具调用。在这种情况下,从用户那里获取自然语言的反馈可能是更好的选择。然后你可以将此反馈插入为工具调用的模拟 RESULT

实现这一目标有多种方式:

  1. 你可以在状态中添加一条新消息(代表工具调用的“结果”)。
  2. 你可以在状态中添加两条新消息:一条表示工具调用的“错误”,另一条 HumanMessage 表示用户的反馈。

两者相似之处在于它们都需要向状态中添加消息。主要区别在于 human_review_node 之后的逻辑以及它如何处理不同类型的消息。

在这个例子中,我们只需添加一条代表反馈的工具调用(请参见 human_review_node 的实现)。让我们看看它是如何运作的!

input = {"messages": [{"role": "user", "content": "what's the weather in sf?"}]}

async for chunk in client.runs.stream(
    thread["thread_id"],
    assistant_id,
    input=input,
    stream_mode="updates",
):
    if chunk.data and chunk.event != "metadata": 
        print(chunk.data)
const input = { "messages": [{ "role": "user", "content": "what's the weather in sf?" }] };

const streamResponse = client.runs.stream(
  thread["thread_id"],
  assistantId,
  {
    input: input,
    streamMode: "updates"
  }
);

for await (const chunk of streamResponse) {
  if (chunk.data && chunk.event !== "metadata") {
    console.log(chunk.data);
  }
}
curl --request POST \
 --url <DEPLOYMENT_URL>/threads/<THREAD_ID>/runs/stream \
 --header 'Content-Type: application/json' \
 --data "{
   \"assistant_id\": \"agent\",
   \"input\": {\"messages\": [{\"role\": \"human\", \"content\": \"what's the weather in sf?\"}]},
   \"stream_mode\": [
     \"updates\"
   ]
 }"

要实现这一点,我们将使用 Command 并使用不同的 resume 值 {"action": "feedback", "data": <feedback string>}。这将执行以下操作:

  • 创建一个新的工具消息,该消息结合了 LLM 中现有的工具调用和用户提供的反馈内容。
  • 转到 call_llm 节点,并使用更新后的工具消息继续执行。
from langgraph_sdk.schema import Command

async for chunk in client.runs.stream(
    thread["thread_id"],
    assistant_id,
    command=Command(
        resume={
            "action": "feedback", 
            "data": "User requested changes: use <city, country> format for location"
        }
    ),
    stream_mode="updates",
):
    if chunk.data and chunk.event != "metadata": 
        print(chunk.data)
const streamResponse = client.runs.stream(
  thread["thread_id"],
  assistantId,
  {
    command: {
      resume: {
        "action": "feedback", 
        "data": "User requested changes: use <city, country> format for location"
      }
    },
    streamMode: "updates"
  }
);

for await (const chunk of streamResponse) {
  if (chunk.data && chunk.event !== "metadata") {
    console.log(chunk.data);
  }
}
curl --request POST \
 --url <DEPLOYMENT_URL>/threads/<THREAD_ID>/runs/stream \
 --header 'Content-Type: application/json' \
 --data "{
   \"assistant_id\": \"agent\",
   \"command\": {
     \"resume\": { \"action\": \"feedback\", \"data\": \"User requested changes: use <city, country> format for location\" }
   },
   \"stream_mode\": [
     \"updates\"
   ]
 }"

输出:

{'human_review_node': {'messages': [{'role': 'tool', 'content': 'User requested changes: use <city, country> format for location', 'name': 'weather_search', 'tool_call_id': 'toolu_01RkPHCjpfoUvPAktaq4Cqhm'}]}}
{'call_llm': {'messages': [{'content': [{'text': 'Let me try that again with the correct format:', 'type': 'text'}, {'id': 'toolu_01Rdrag6cVufHZG26BwVaiE7', 'input': {'city': 'San Francisco, USA'}, 'name': 'weather_search', 'type': 'tool_use'}], 'additional_kwargs': {}, 'response_metadata': {'id': 'msg_01EBan969yY5f6iGk6sPgKcj', 'model': 'claude-3-5-sonnet-20241022', 'stop_reason': 'tool_use', 'stop_sequence': None, 'usage': {'cache_creation_input_tokens': 0, 'cache_read_input_tokens': 0, 'input_tokens': 469, 'output_tokens': 68}, 'model_name': 'claude-3-5-sonnet-20241022'}, 'type': 'ai', 'name': None, 'id': 'run-64bbc255-d126-4db0-8ae5-3197cf29bed1-0', 'example': False, 'tool_calls': [{'name': 'weather_search', 'args': {'city': 'San Francisco, USA'}, 'id': 'toolu_01Rdrag6cVufHZG26BwVaiE7', 'type': 'tool_call'}], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 469, 'output_tokens': 68, 'total_tokens': 537, 'input_token_details': {'cache_read': 0, 'cache_creation': 0}}}]}}
{'__interrupt__': [{'value': {'question': 'Is this correct?', 'tool_call': {'name': 'weather_search', 'args': {'city': 'San Francisco, USA'}, 'id': 'toolu_01Rdrag6cVufHZG26BwVaiE7', 'type': 'tool_call'}}, 'resumable': True, 'ns': ['human_review_node:e9856878-e28c-5dd1-d353-4d83aa1a3a2b'], 'when': 'during'}]}

我们可以看到,我们现在又到了另一个中断点——因为模型返回了一个全新的预测,决定调用什么。现在我们批准这个预测并继续。

from langgraph_sdk.schema import Command

async for chunk in client.runs.stream(
    thread["thread_id"],
    assistant_id,
    command=Command(resume={"action": "continue"}),
    stream_mode="updates",
):
    if chunk.data and chunk.event != "metadata": 
        print(chunk.data)
const streamResponse = client.runs.stream(
  thread["thread_id"],
  assistantId,
  {
    command: { resume: { "action": "continue" } },
    streamMode: "updates"
  }
);

for await (const chunk of streamResponse) {
  if (chunk.data && chunk.event !== "metadata") {
    console.log(chunk.data);
  }
}
curl --request POST \
 --url <DEPLOYMENT_URL>/threads/<THREAD_ID>/runs/stream \
 --header 'Content-Type: application/json' \
 --data "{
   \"assistant_id\": \"agent\",
   \"command\": {
     \"resume\": { \"action\": \"continue\"}
   },
   \"stream_mode\": [
     \"updates\"
   ]
 }"

输出:

{'human_review_node': None}
{'run_tool': {'messages': [{'role': 'tool', 'name': 'weather_search', 'content': 'Sunny!', 'tool_call_id': 'toolu_01Rdrag6cVufHZG26BwVaiE7'}]}}
{'call_llm': {'messages': [{'content': 'The weather in San Francisco is sunny!', 'additional_kwargs': {}, 'response_metadata': {'id': 'msg_013WTDHhbg8WiYLiQ9n2CaTk', 'model': 'claude-3-5-sonnet-20241022', 'stop_reason': 'end_turn', 'stop_sequence': None, 'usage': {'cache_creation_input_tokens': 0, 'cache_read_input_tokens': 0, 'input_tokens': 550, 'output_tokens': 12}, 'model_name': 'claude-3-5-sonnet-20241022'}, 'type': 'ai', 'name': None, 'id': 'run-b6c815f0-989a-47cf-b150-33e3bbc4eab7-0', 'example': False, 'tool_calls': [], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 550, 'output_tokens': 12, 'total_tokens': 562, 'input_token_details': {'cache_read': 0, 'cache_creation': 0}}}]}}