探索LangGraph:如何创建一个既智能又可控的航空客服AI

1,010 阅读6分钟

上节课,我们将利用LangGraphinterrupt_before功能,在执行任何工具之前,暂停流程并把控制权交还给用户。没看过的同学可以点击链接LangGraph实战:可控的AI航空客服助手查阅。在本章节中,我们通过将工具分为只读(安全)和修改数据(敏感)两类,来优化我们的中断机制。我们仅对敏感工具实施中断,使得机器人能够自主处理一些简单的查询。

这种设计既保持了用户控制权,又确保了对话流程的顺畅。但随着工具数量的增加,单一的图结构可能会变得过于复杂。我们将在下一节中解决这个问题。

第三部分的图将类似于下面的示意图:

第三部分示意图

状态定义

首先,定义图的状态。我们的状态和LLM调用与第二部分相同

from typing import Annotated

from langchain_anthropic import ChatAnthropic
from langchain_community.tools.tavily_search import TavilySearchResults
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import Runnable, RunnableConfig
from typing_extensions import TypedDict

from langgraph.graph.message import AnyMessage, add_messages


class State(TypedDict):
    messages: Annotated[list[AnyMessage], add_messages]
    user_info: str


class Assistant:
    def __init__(self, runnable: Runnable):
        self.runnable = runnable

    def __call__(self, state: State, config: RunnableConfig):
        while True:
            result = self.runnable.invoke(state)
            # 如果LLM碰巧返回了一个空响应,我们将重新提示它
            # 以获得一个实际的响应。
            if not result.tool_calls and (
                not result.content
                or isinstance(result.content, list)
                and not result.content[0].get("text")
            ):
                messages = state["messages"] + [("user", "请给出真实的输出。")]
                state = {**state, "messages": messages}
                messages = state["messages"] + [("user", "请给出真实的输出。")]
                state = {**state, "messages": messages}
            else:
                break
        return {"messages": result}


# Haiku更快更便宜,但准确性较低
# llm = ChatAnthropic(model="claude-3-haiku-20240307")
llm = ChatAnthropic(model="claude-3-sonnet-20240229", temperature=1)
# 你可以更新LLMs,尽管你可能需要更新提示
# from langchain_openai import ChatOpenAI

# llm = ChatOpenAI(model="gpt-4-turbo-preview")

assistant提示 = ChatPromptTemplate.from_messages(
    [
        (
            "system",
            "你是一位乐于助人的瑞士航空客户支持助手。"
            " 使用提供的工具搜索航班、公司政策和其他信息以协助用户的查询。"
            " 当搜索时,要坚持不懈。如果第一次搜索没有结果,扩大你的查询范围。"
            " 如果搜索空手而归,请在放弃之前扩大你的搜索。"
            "\n\n当前用户:\n<User>\n{user_info}\n</User>"
            "\n当前时间:{time}。",
        ),
        ("placeholder", "{messages}"),
    ]
).partial(time=datetime.now())


# "阅读"仅工具(例如检索器)不需要用户确认即可使用
part_3_safe_tools = [
    TavilySearchResults(max_results=1),
    fetch_user_flight_information,
    search_flights,
    lookup_policy,
    search_car_rentals,
    search_hotels,
    search_trip_recommendations,
]

# 这些工具都改变了用户的预订。
# 用户有权控制做出什么决定
part_3_sensitive_tools = [
    update_ticket_to_new_flight,
    cancel_ticket,
    book_car_rental,
    update_car_rental,
    cancel_car_rental,
    book_hotel,
    update_hotel,
    cancel_hotel,
    book_excursion,
    update_excursion,
    cancel_excursion,
]
sensitive_tool_names = {t.name for t in part_3_sensitive_tools}
# 我们的LLM不需要知道它必须路由到哪个节点。在它的"思维"中,它只是在调用函数。
part_3_assistant_runnable = assistant_prompt | llm.bind_tools(
    part_3_safe_tools + part_3_sensitive_tools
)

图的定义

现在,创建图。我们的图与第二部分几乎相同,只是我们将工具分为了两个不同的节点。我们只在实际更改用户预订的工具之前进行中断。

from typing import Literal

from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.graph import END, StateGraph
from langgraph.prebuilt import tools_condition

builder = StateGraph(State)


def user_info(state: State):
    return {"user_info": fetch_user_flight_information.invoke({})}


# 新增:fetch_user_info节点首先运行,这意味着我们的助手可以在
# 不需要采取行动的情况下看到用户的航班信息
builder.add_node("fetch_user_info", user_info)
builder.set_entry_point("fetch_user_info")
builder.add_node("assistant", Assistant(part_3_assistant_runnable))
builder.add_node("safe_tools", create_tool_node_with_fallback(part_3_safe_tools))
builder.add_node(
    "sensitive_tools", create_tool_node_with_fallback(part_3_sensitive_tools)
)
# 定义逻辑
builder.add_edge("fetch_user_info", "assistant")


def route_tools(state: State) -> Literal["safe_tools", "sensitive_tools", "__end__"]:
    next_node = tools_condition(state)
    # 如果没有调用工具,返回用户
    if next_node == END:
        return END
    ai_message = state["messages"][-1]
    # 这假设是单个工具调用。要处理并行工具调用,你将想要
    # 使用ANY条件
    first_tool_call = ai_message.tool_calls[0]
    if first_tool_call["name"] in sensitive_tool_names:
        return "sensitive_tools"
    return "safe_tools"


builder.add_conditional_edges(
    "assistant",
    route_tools,
)
builder.add_edge("safe_tools", "assistant")
builder.add_edge("sensitive_tools", "assistant")

memory = SqliteSaver.from_conn_string(":memory:")
part_3_graph = builder.compile(
    checkpointer=memory,
    # 新增:图将在执行"tools"节点之前始终停止。
    # 用户可以在助手继续之前批准或拒绝(甚至更改请求)
    interrupt_before=["sensitive_tools"],
)

示例对话

接下来,让我们尝试新修订的聊天机器人!我们将在以下对话列表上运行它。这次,我们将减少确认的次数。

import shutil
import uuid

# 使用备份文件更新,以便我们可以从每个部分的原始位置重新启动
shutil.copy(backup_file, db)
thread_id = str(uuid.uuid4())

config = {
    "configurable": {
        # passenger_id在我们的航班工具中使用
        # 以获取用户的航班信息
        "passenger_id": "3442 587242",
        # 通过thread_id访问检查点
        "thread_id": thread_id,
    }
}

tutorial_questions = [
    "嗨,我的航班是什么时候?",
    "我可以更新我的航班到更早的时间吗?我想今天晚些时候离开。",
    "那就更新到下周的某个时间",
    "下一个可用的选项很好",
    "关于住宿和交通呢?",
    "是的,我想要一个负担得起的酒店,用于我为期一周的住宿(7天)。我还想要租一辆车。",
    "好的,可以为你推荐的酒店预订吗?听起来不错。",
    "是的,继续预订任何中等费用且有可用性的酒店。",
    "现在对于汽车,我的选择是什么?",
    "太棒了,我们只需要最便宜的选项。继续预订7天",
    "好的,现在你对旅行有什么建议?",
    "在我在那里的时候,它们可用吗?",
    "有趣 - 我喜欢博物馆,有什么选择?",
    "好的,选一个并在我到达的第二天为我预订。",
]


_printed = set()
# 我们可以重用第一部分的教程问题,看看它的表现如何。
for question in tutorial_questions:
    events = part_3_graph.stream(
        {"messages": ("user", question)}, config, stream_mode="values"
    )
    for event in events:
        _print_event(event, _printed)
    snapshot = part_3_graph.get_state(config)
    while snapshot.next:
        # 我们有中断!代理试图使用工具,用户可以批准或拒绝它
        # 注意:此代码都在图之外。通常,你会将输出流到UI。
        # 然后,当用户提供输入时,你会通过API调用触发新的运行。
        user_input = input(
            "你是否批准上述操作?输入'y'继续;"
            " 否则,请说明你请求的更改。\n\n"
        )
        if user_input.strip() == "y":
            # 只是继续
            result = part_3_graph.invoke(
                None,
                config,
            )
        else:
            # 通过提供有关请求更改/改变主意的说明
            # 满足工具调用
            result = part_3_graph.invoke(
                {
                    "messages": [
                        ToolMessage(
                            tool_call_id=event["messages"][-1].tool_calls[0]["id"],
                            content=f"API调用被用户拒绝。理由:'{user_input}'。继续协助,考虑用户的输入。",
                        )
                    ]
                },
                config,
            )
        snapshot = part_3_graph.get_state(config)

第三部分回顾

现在,我们的聊天机器人工作得很好,你可以通过LangSmith跟踪来检查它的最新运行情况。这个设计可能已经满足了你的需求。代码是封闭的,并且它的行为符合预期。

然而,这个设计的一个潜在问题是,它对单个提示施加了很大压力。如果我们想要添加更多工具,或者每个工具变得更加复杂,那么机器人使用工具的效率和整体行为可能会受到影响。

在下一节中,我们将展示如何通过根据用户的意图将用户引导至专业代理或子图,来更精确地控制不同的用户体验。