Skip to content

如何等待用户输入

人在回路交互模式的主要方式之一是等待用户输入。一个关键用例是向用户询问澄清性问题。实现这一点的一种方法是直接转到 END 节点并退出图。然后,用户的任何回复都会作为对图的全新调用返回。这本质上就是创建一个聊天机器人架构。

这样做的问题在于,很难在图中的特定点恢复执行。很多时候,智能体在某个流程进行到一半时,只需要一些用户输入。虽然可以通过设计图的方式,设置一个 conditional_entry_point 来将用户消息路由回正确的位置,但这种方法的可扩展性不强(因为这本质上需要一个路由函数,而这个函数最终可能会指向几乎任何位置)。

另一种方法是专门设置一个节点来获取用户输入。在笔记本环境中很容易实现这一点——只需在节点中放入一个 input() 调用即可。但这并不适合生产环境。

幸运的是,LangGraph 使得以适合生产环境的方式实现类似功能成为可能。基本思路如下: - 设置一个代表用户输入的节点。该节点可以有特定的入边和出边(可按需设置)。实际上,这个节点内部不应包含任何逻辑。 - 在该节点之前添加一个断点。这将在该节点执行之前暂停图的执行(这是好事,因为该节点本身并没有实际逻辑)。 - 使用 .update_state 来更新图的状态。传入你获取到的任何用户回复。这里的关键是使用 as_node 参数来应用此更新,就好像你就是那个节点一样。这样做的效果是,当下次恢复执行时,会从该节点刚刚执行完的状态继续,而不是从头开始。

配置

我们不会展示所托管图的完整代码,但如果你想查看,可以点击此处。一旦该图完成托管,我们就可以调用它并等待用户输入。

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 '{}'

等待用户输入

初始调用

现在,让我们通过在 ask_human 节点之前中断来调用我们的图:

input = {
    "messages": [
        {
            "role": "user",
            "content": "Ask the user where they are, then look up the weather there",
        }
    ]
}

async for chunk in client.runs.stream(
    thread["thread_id"],
    assistant_id,
    input=input,
    stream_mode="updates",
    interrupt_before=["ask_human"],
):
    if chunk.data and chunk.event != "metadata": 
        print(chunk.data)
const input = {
  messages: [
    {
      role: "human",
      content: "Ask the user where they are, then look up the weather there" }
  ]
};

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

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\": \"Ask the user where they are, then look up the weather there\"}]},
   \"interrupt_before\": [\"ask_human\"],
   \"stream_mode\": [
     \"updates\"
   ]
 }" | \
 sed 's/\r$//' | \
 awk '
 /^event:/ {
     if (data_content != "" && event_type != "metadata") {
         print data_content "\n"
     }
     sub(/^event: /, "", $0)
     event_type = $0
     data_content = ""
 }
 /^data:/ {
     sub(/^data: /, "", $0)
     data_content = $0
 }
 END {
     if (data_content != "" && event_type != "metadata") {
         print data_content "\n"
     }
 }
 '

输出:

{'agent': {'messages': [{'content': [{'text': "Certainly! I'll use the AskHuman function to ask the user about their location, and then I'll use the search function to look up the weather for that location. Let's start by asking the user where they are.", 'type': 'text'}, {'id': 'toolu_01RFahzYPvnPWTb2USk2RdKR', 'input': {'question': 'Where are you currently located?'}, 'name': 'AskHuman', 'type': 'tool_use'}], 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'ai', 'name': None, 'id': 'run-a8422215-71d3-4093-afb4-9db141c94ddb', 'example': False, 'tool_calls': [{'name': 'AskHuman', 'args': {'question': 'Where are you currently located?'}, 'id': 'toolu_01RFahzYPvnPWTb2USk2RdKR'}], 'invalid_tool_calls': [], 'usage_metadata': None}]}}

向状态中添加用户输入

现在我们想使用用户的响应来更新这个线程。然后我们可以启动另一次运行。

因为我们将此视为一个工具调用,所以我们需要像处理工具调用的响应一样更新状态。为此,我们需要检查状态以获取工具调用的 ID。

state = await client.threads.get_state(thread['thread_id'])
tool_call_id = state['values']['messages'][-1]['tool_calls'][0]['id']

# We now create the tool call with the id and the response we want
tool_message = [{"tool_call_id": tool_call_id, "type": "tool", "content": "san francisco"}]

await client.threads.update_state(thread['thread_id'], {"messages": tool_message}, as_node="ask_human")
const state = await client.threads.getState(thread["thread_id"]);
const toolCallId = state.values.messages[state.values.messages.length - 1].tool_calls[0].id;

// We now create the tool call with the id and the response we want
const toolMessage = [
  {
    tool_call_id: toolCallId,
    type: "tool",
    content: "san francisco"
  }
];

await client.threads.updateState(
  thread["thread_id"],
  { values: { messages: toolMessage } },
  { asNode: "ask_human" }
);
curl --request GET \
 --url <DEPLOYMENT_URL>/threads/<THREAD_ID>/state \
 | jq -r '.values.messages[-1].tool_calls[0].id' \
 | sh -c '
     TOOL_CALL_ID="$1"

     # Construct the JSON payload
     JSON_PAYLOAD=$(printf "{\"messages\": [{\"tool_call_id\": \"%s\", \"type\": \"tool\", \"content\": \"san francisco\"}], \"as_node\": \"ask_human\"}" "$TOOL_CALL_ID")

     # Send the updated state
     curl --request POST \
          --url <DEPLOYMENT_URL>/threads/<THREAD_ID>/state \
          --header "Content-Type: application/json" \
          --data "${JSON_PAYLOAD}"
 ' _ 

输出:

{'configurable': {'thread_id': 'a9f322ae-4ed1-41ec-942b-38cb3d342c3a',
'checkpoint_ns': '',
'checkpoint_id': '1ef58e97-a623-63dd-8002-39a9a9b20be3'}}

接收用户输入后调用

现在我们可以告诉代理继续。我们可以将 None 作为输入传递给图,因为不需要额外的输入:

async for chunk in client.runs.stream(
    thread["thread_id"],
    assistant_id,
    input=None,
    stream_mode="updates",
):
    if chunk.data and chunk.event != "metadata": 
        print(chunk.data)
const streamResponse = client.runs.stream(
  thread["thread_id"],
  assistantId,
  {
    input: null,
    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\",
   \"stream_mode\": [
     \"updates\"
   ]
 }"| \ 
 sed 's/\r$//' | \
 awk '
 /^event:/ {
     if (data_content != "" && event_type != "metadata") {
         print data_content "\n"
     }
     sub(/^event: /, "", $0)
     event_type = $0
     data_content = ""
 }
 /^data:/ {
     sub(/^data: /, "", $0)
     data_content = $0
 }
 END {
     if (data_content != "" && event_type != "metadata") {
         print data_content "\n"
     }
 }
 '

输出:

{'agent': {'messages': [{'content': [{'text': "Thank you for letting me know that you're in San Francisco. Now, I'll use the search function to look up the weather in San Francisco.", 'type': 'text'}, {'id': 'toolu_01K57ofmgG2wyJ8tYJjbq5k7', 'input': {'query': 'current weather in San Francisco'}, 'name': 'search', 'type': 'tool_use'}], 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'ai', 'name': None, 'id': 'run-241baed7-db5e-44ce-ac3c-56431705c22b', 'example': False, 'tool_calls': [{'name': 'search', 'args': {'query': 'current weather in San Francisco'}, 'id': 'toolu_01K57ofmgG2wyJ8tYJjbq5k7'}], 'invalid_tool_calls': [], 'usage_metadata': None}]}}
{'action': {'messages': [{'content': '["I looked up: current weather in San Francisco. Result: It\'s sunny in San Francisco, but you better look out if you\'re a Gemini 😈."]', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'tool', 'name': 'search', 'id': '8b699b95-8546-4557-8e66-14ea71a15ed8', 'tool_call_id': 'toolu_01K57ofmgG2wyJ8tYJjbq5k7'}]}}
{'agent': {'messages': [{'content': "Based on the search results, I can provide you with information about the current weather in San Francisco:\n\nThe weather in San Francisco is currently sunny. It's a beautiful day in the city! \n\nHowever, I should note that the search result included an unusual comment about Gemini zodiac signs. This appears to be either a joke or potentially irrelevant information added by the search engine. For accurate and detailed weather information, you might want to check a reliable weather service or app for San Francisco.\n\nIs there anything else you'd like to know about the weather or San Francisco?", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'ai', 'name': None, 'id': 'run-b4d7309f-f849-46aa-b6ef-475bcabd2be9', 'example': False, 'tool_calls': [], 'invalid_tool_calls': [], 'usage_metadata': None}]}}

Comments