【langgraph 从入门到精通】工具调用 — 让 AI 拥有行动能力

文章目录

  • [第 5 章:工具调用 --- 让 AI 拥有行动能力](#第 5 章:工具调用 — 让 AI 拥有行动能力)
    • [5.1 本章目标](#5.1 本章目标)
    • [5.2 核心概念(图文讲解)](#5.2 核心概念(图文讲解))
      • [5.2.1 生动类比:给 AI 装上手脚](#5.2.1 生动类比:给 AI 装上手脚)
      • [5.2.2 工具调用时序图](#5.2.2 工具调用时序图)
      • [5.2.3 关键术语解释](#5.2.3 关键术语解释)
    • [5.3 实战:旅行规划助手 v0.4](#5.3 实战:旅行规划助手 v0.4)
      • [5.3.1 场景描述](#5.3.1 场景描述)
      • [5.3.2 完整代码](#5.3.2 完整代码)
      • [5.3.3 运行结果展示](#5.3.3 运行结果展示)
      • [5.3.4 代码逐段解析](#5.3.4 代码逐段解析)
    • [5.4 API 速查](#5.4 API 速查)
    • [5.5 常见错误与避坑指南](#5.5 常见错误与避坑指南)
      • [错误 1:忘记将工具结果追加到消息列表](#错误 1:忘记将工具结果追加到消息列表)
      • [错误 2:工具函数没有文档字符串](#错误 2:工具函数没有文档字符串)
      • [错误 3:工具返回值不是字符串](#错误 3:工具返回值不是字符串)
      • [错误 4:忘记在工具调用循环中处理异常](#错误 4:忘记在工具调用循环中处理异常)
      • [错误 5:工具调用循环无限循环](#错误 5:工具调用循环无限循环)
    • [5.6 最佳实践总结](#5.6 最佳实践总结)

第 5 章:工具调用 --- 让 AI 拥有行动能力

5.1 本章目标

学完本章后,你将能够:

  • 使用 @tool 装饰器定义 LLM 可调用的工具函数
  • 使用 bind_tools() 将工具绑定到 LLM,让模型知道有哪些工具可用
  • 实现工具调用循环(Tool Calling Loop),让 LLM 自主决定何时调用工具
  • 理解 Function Calling 的完整流程:LLM 请求调用 → 执行工具 → 返回结果 → LLM 综合
  • 构建旅行规划助手 v0.4:接入模拟的机票、酒店、天气查询工具

5.2 核心概念(图文讲解)

5.2.1 生动类比:给 AI 装上手脚

到目前为止,我们的 Agent 只能「思考」------它调用 LLM 获取信息,然后生成文本。这就像一个只有大脑但没有手脚的人:他能思考、能规划,但无法行动

工具调用(Tool Calling) 就是给 AI 装上「手脚」:

  • search_flights() --- 查询机票,就像用手打开手机查航班
  • search_hotels() --- 查询酒店,就像用脚走到酒店前台
  • 感官get_weather() --- 查询天气,就像用眼睛看天气预报

当用户问「帮我查一下 7 月 20 日从北京到东京的机票」,AI 的思考过程是:

  1. 理解意图:用户想查机票
  2. 选择工具 :我需要用 search_flights 工具
  3. 调用工具search_flights("北京", "东京", "2026-07-20")
  4. 获取结果:工具返回航班列表
  5. 综合回复:把航班信息整理成易读的格式回复用户

5.2.2 工具调用时序图

工具函数 LLM (gpt-4o-mini) call_llm_with_tools Entrypoint 用户 工具函数 LLM (gpt-4o-mini) call_llm_with_tools Entrypoint 用户 #mermaid-svg-dceUJfWVwZ78VYGr{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-dceUJfWVwZ78VYGr .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-dceUJfWVwZ78VYGr .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-dceUJfWVwZ78VYGr .error-icon{fill:#552222;}#mermaid-svg-dceUJfWVwZ78VYGr .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-dceUJfWVwZ78VYGr .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-dceUJfWVwZ78VYGr .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-dceUJfWVwZ78VYGr .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-dceUJfWVwZ78VYGr .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-dceUJfWVwZ78VYGr .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-dceUJfWVwZ78VYGr .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-dceUJfWVwZ78VYGr .marker{fill:#333333;stroke:#333333;}#mermaid-svg-dceUJfWVwZ78VYGr .marker.cross{stroke:#333333;}#mermaid-svg-dceUJfWVwZ78VYGr svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-dceUJfWVwZ78VYGr p{margin:0;}#mermaid-svg-dceUJfWVwZ78VYGr .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-dceUJfWVwZ78VYGr text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-dceUJfWVwZ78VYGr .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-dceUJfWVwZ78VYGr .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-dceUJfWVwZ78VYGr .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-dceUJfWVwZ78VYGr .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-dceUJfWVwZ78VYGr #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-dceUJfWVwZ78VYGr .sequenceNumber{fill:white;}#mermaid-svg-dceUJfWVwZ78VYGr #sequencenumber{fill:#333;}#mermaid-svg-dceUJfWVwZ78VYGr #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-dceUJfWVwZ78VYGr .messageText{fill:#333;stroke:none;}#mermaid-svg-dceUJfWVwZ78VYGr .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-dceUJfWVwZ78VYGr .labelText,#mermaid-svg-dceUJfWVwZ78VYGr .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-dceUJfWVwZ78VYGr .loopText,#mermaid-svg-dceUJfWVwZ78VYGr .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-dceUJfWVwZ78VYGr .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-dceUJfWVwZ78VYGr .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-dceUJfWVwZ78VYGr .noteText,#mermaid-svg-dceUJfWVwZ78VYGr .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-dceUJfWVwZ78VYGr .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-dceUJfWVwZ78VYGr .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-dceUJfWVwZ78VYGr .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-dceUJfWVwZ78VYGr .actorPopupMenu{position:absolute;}#mermaid-svg-dceUJfWVwZ78VYGr .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-dceUJfWVwZ78VYGr .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-dceUJfWVwZ78VYGr .actor-man circle,#mermaid-svg-dceUJfWVwZ78VYGr line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-dceUJfWVwZ78VYGr :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} loop 工具调用循环 "帮我查北京到东京的机票" 调用 LLM Task 发送消息 + 工具列表 返回 tool_calls(需要调用 search_flights) 执行 search_flights("北京","东京","...") 返回航班数据 发送工具返回结果 返回最终回复(不需要更多工具) 返回最终回复 展示航班信息

5.2.3 关键术语解释

术语 含义 在「装手脚」中的类比
@tool 将普通函数标记为 LLM 可调用的工具 给手脚贴上「可用」标签
bind_tools() 将工具列表绑定到 LLM,让模型知道有哪些工具 告诉 AI 你有哪些手脚可以用
tool_calls LLM 返回的「请调用工具」请求 AI 说「我需要用右手拿这个」
工具调用循环 LLM 反复请求工具 → 执行 → 返回结果,直到不再需要 AI 反复用手脚做事直到完成
Function Calling OpenAI 的原生能力,让 LLM 输出结构化工具调用请求 AI 大脑的「行动指令」功能

5.3 实战:旅行规划助手 v0.4

5.3.1 场景描述

v0.4 为旅行规划助手添加三个真实工具:(1) search_flights --- 搜索航班;(2) search_hotels --- 搜索酒店;(3) get_weather --- 查询天气。这些工具使用模拟数据,但结构和调用方式与真实 API 完全一致。用户可以直接说「帮我查北京到东京的机票」或「东京下周天气怎么样」,Agent 会自动选择合适的工具并返回结果。

5.3.2 完整代码

python 复制代码
# ============================================================
# 旅行规划助手 v0.4 --- 第 5 章完整可运行代码
# 保存为 ch05_tool_calling.py 后运行:python ch05_tool_calling.py
# 运行前请确保已设置:export OPENAI_API_KEY="your-key"
# 依赖:langgraph>=1.2.9, langchain>=0.3.0, langchain-openai>=0.3.0
# ============================================================

import os  # 导入操作系统模块,用于读取环境变量
import json  # 导入 JSON 模块,用于序列化工具参数

from pydantic import BaseModel, Field  # 导入 Pydantic 用于定义 State
from typing import Optional, Literal  # 导入类型注解工具

from langgraph.func import entrypoint, task  # 导入 LangGraph Functional API
from langchain_openai import ChatOpenAI  # 导入 OpenAI 聊天模型
from langchain.tools import tool  # 导入 @tool 装饰器,用于定义 LLM 可调用的工具
from langchain_core.messages import (
    HumanMessage,  # 人类消息类型
    AIMessage,  # AI 回复消息类型
    ToolMessage,  # 工具返回消息类型
    SystemMessage,  # 系统消息类型
)


# ============================================================
# 定义 State
# ============================================================
# 注意:v0.4 的 State 设计发生了重大变化!
# v0.3 使用结构化字段(destination, days, budget 等),
# v0.4 改用 messages 列表承载所有对话内容。
# 原因:工具调用场景下,LLM 需要完整的对话历史------
# SystemMessage、HumanMessage、AIMessage(含 tool_calls)、ToolMessage------
# 这种多类型消息序列用 messages 列表管理更自然。
# 第 6 章将展示如何将两种方式结合(结构化字段 + messages 列表)。
class TravelPlanState(BaseModel):
    """旅行规划状态 v0.4:支持工具调用"""

    # 对话消息列表(存储所有消息,包括工具调用和返回)
    messages: list = Field(
        default_factory=list,  # 默认空列表,使用 default_factory 避免可变默认值问题
        description="完整的对话消息列表",
    )

    # 用户原始输入
    raw_input: str = Field(default="", description="用户的原始输入")

    # 最终回复
    final_output: str = Field(default="", description="最终回复给用户的内容")


# ============================================================
# 第一步:定义工具 --- 使用 @tool 装饰器
# 每个工具都有:名称、描述(告诉 LLM 何时使用)和参数
# ============================================================

@tool  # @tool 装饰器:将函数标记为 LLM 可调用的工具
def search_flights(
    departure: str,  # 出发城市
    destination: str,  # 到达城市
    date: str,  # 出发日期,格式:YYYY-MM-DD
) -> str:
    """搜索航班信息。当用户询问机票、航班、飞行相关问题时使用此工具。

    参数:
    - departure: 出发城市名称(中文)
    - destination: 到达城市名称(中文)
    - date: 出发日期,格式为 YYYY-MM-DD
    """
    # 模拟航班数据(实际应用中应调用真实机票 API)
    mock_flights = [
        {
            "flight_no": "CA1234",  # 航班号
            "airline": "中国国际航空",  # 航空公司
            "departure_time": f"{date} 08:00",  # 出发时间
            "arrival_time": f"{date} 11:30",  # 到达时间
            "duration": "3小时30分",  # 飞行时长
            "price": 2800,  # 价格(人民币)
            "stops": "直飞",  # 经停信息
        },
        {
            "flight_no": "MU5678",  # 航班号
            "airline": "东方航空",  # 航空公司
            "departure_time": f"{date} 13:00",  # 出发时间
            "arrival_time": f"{date} 16:20",  # 到达时间
            "duration": "3小时20分",  # 飞行时长
            "price": 2200,  # 价格(人民币)
            "stops": "直飞",  # 经停信息
        },
        {
            "flight_no": "CZ9012",  # 航班号
            "airline": "南方航空",  # 航空公司
            "departure_time": f"{date} 07:30",  # 出发时间
            "arrival_time": f"{date} 12:00",  # 到达时间
            "duration": "4小时30分",  # 飞行时长
            "price": 1800,  # 价格(人民币)
            "stops": "经停首尔",  # 经停信息
        },
    ]

    # 将模拟数据格式化为可读文本
    result = f"从 {departure} 到 {destination} 的航班({date}):\n\n"
    for i, flight in enumerate(mock_flights, 1):  # enumerate 从 1 开始编号
        result += (
            f"{i}. {flight['airline']} {flight['flight_no']}\n"  # 航空公司和航班号
            f"   出发:{flight['departure_time']}\n"  # 出发时间
            f"   到达:{flight['arrival_time']}\n"  # 到达时间
            f"   时长:{flight['duration']}\n"  # 飞行时长
            f"   价格:¥{flight['price']}\n"  # 价格
            f"   类型:{flight['stops']}\n\n"  # 经停信息
        )
    return result  # 返回格式化后的航班信息


@tool  # 标记为 LLM 可调用的工具
def search_hotels(
    city: str,  # 城市名称
    check_in: str,  # 入住日期
    check_out: str,  # 退房日期
    budget: str = "中等",  # 预算水平,默认值"中等"
) -> str:
    """搜索酒店信息。当用户询问酒店、住宿、民宿相关问题时使用此工具。

    参数:
    - city: 城市名称(中文)
    - check_in: 入住日期,格式 YYYY-MM-DD
    - check_out: 退房日期,格式 YYYY-MM-DD
    - budget: 预算水平(经济型/中等/豪华),默认中等
    """
    # 模拟酒店数据(实际应用中应调用真实酒店 API)
    mock_hotels = [
        {
            "name": f"{city}中心商务酒店",  # 酒店名称
            "location": f"{city}市中心",  # 酒店位置
            "rating": 4.5,  # 评分
            "price_per_night": 500,  # 每晚价格
            "amenities": "WiFi、健身房、早餐",  # 设施
            "type": "中等",  # 酒店类型
        },
        {
            "name": f"{city}花园酒店",  # 酒店名称
            "location": f"{city}景区附近",  # 酒店位置
            "rating": 4.8,  # 评分
            "price_per_night": 900,  # 每晚价格
            "amenities": "WiFi、泳池、SPA、早餐",  # 设施
            "type": "豪华",  # 酒店类型
        },
        {
            "name": f"{city}青年旅舍",  # 酒店名称
            "location": f"{city}交通枢纽旁",  # 酒店位置
            "rating": 4.2,  # 评分
            "price_per_night": 150,  # 每晚价格
            "amenities": "WiFi、公共厨房",  # 设施
            "type": "经济型",  # 酒店类型
        },
    ]

    # 根据预算筛选酒店
    filtered = [h for h in mock_hotels if h["type"] == budget]  # 筛选匹配预算的酒店
    if not filtered:
        filtered = mock_hotels  # 如果没有匹配的,显示全部

    # 格式化酒店信息
    result = f"{city} 酒店推荐({check_in} 至 {check_out},预算:{budget}):\n\n"
    for i, hotel in enumerate(filtered, 1):  # 从 1 开始编号
        result += (
            f"{i}. {hotel['name']}\n"  # 酒店名称
            f"   位置:{hotel['location']}\n"  # 位置
            f"   评分:{hotel['rating']}/5.0\n"  # 评分
            f"   价格:¥{hotel['price_per_night']}/晚\n"  # 每晚价格
            f"   设施:{hotel['amenities']}\n\n"  # 设施
        )
    return result  # 返回格式化后的酒店信息


@tool  # 标记为 LLM 可调用的工具
def get_weather(
    city: str,  # 城市名称
    days: int = 3,  # 查询天数,默认 3 天
) -> str:
    """查询天气信息。当用户询问天气、气候、温度相关问题时使用此工具。

    参数:
    - city: 城市名称(中文)
    - days: 查询天数,默认 3 天
    """
    # 模拟天气数据(实际应用中应调用真实天气 API)
    import random  # 用于生成随机温度
    weather_conditions = ["晴", "多云", "阴", "小雨", "阵雨"]  # 可能的天气状况

    result = f"{city} 未来 {days} 天天气预报:\n\n"
    for i in range(days):  # 遍历每一天
        # 随机生成温度(模拟不同季节的温度范围)
        high = random.randint(20, 30)  # 最高温 20-30°C
        low = random.randint(10, 18)  # 最低温 10-18°C
        condition = random.choice(weather_conditions)  # 随机选择天气状况
        result += f"第{i+1}天:{condition},{low}°C ~ {high}°C\n"  # 格式化输出
    return result  # 返回格式化后的天气信息


# ============================================================
# 第二步:初始化 LLM 并绑定工具
# ============================================================

# 将所有工具放入列表
tools = [search_flights, search_hotels, get_weather]  # LLM 可以调用的工具集合

# 初始化 LLM
llm = ChatOpenAI(
    model="gpt-4o-mini",  # 使用 gpt-4o-mini(支持 Function Calling)
    temperature=0.3,  # 工具调用场景建议使用较低温度,确保调用准确
)

# 将工具绑定到 LLM:bind_tools() 告诉模型有哪些工具可用
# 绑定后,LLM 会在需要时自动返回 tool_calls 而不是普通文本
llm_with_tools = llm.bind_tools(tools)  # 返回绑定了工具的新 LLM 实例


# ============================================================
# 第三步:定义 Task --- 带工具调用的 LLM 交互
# ============================================================
@task  # 标记为 LangGraph 任务
def call_llm_with_tools(state: TravelPlanState) -> TravelPlanState:
    """调用 LLM 并处理工具调用循环。
    核心逻辑:LLM 回复 → 检查是否需要工具 → 执行工具 → 返回结果给 LLM → 重复。
    """

    # 获取当前消息列表
    messages = state.messages.copy()  # 复制消息列表,避免修改原 state

    # ---- 工具调用循环 ----
    # 循环条件:LLM 可能返回 tool_calls(需要调用工具)
    # 循环体内:执行工具 → 将结果追加到消息 → 再次调用 LLM
    while True:
        # 调用绑定了工具的 LLM
        response = llm_with_tools.invoke(messages)  # response 是 AIMessage
        messages.append(response)  # 将 AI 回复追加到消息列表

        # 检查 LLM 是否请求调用工具
        if response.tool_calls:  # tool_calls 是列表,非空表示 LLM 想调用工具
            # 遍历每个工具调用请求
            for tool_call in response.tool_calls:
                # 获取工具名称
                tool_name = tool_call["name"]  # 如 "search_flights"
                # 获取工具参数(JSON 字符串格式)
                tool_args = tool_call["args"]  # 如 {"departure": "北京", ...}

                # 根据工具名称查找对应的工具函数
                tool_fn = None  # 初始化工具函数引用
                for t in tools:  # 遍历工具列表
                    if t.name == tool_name:  # 匹配工具名称
                        tool_fn = t  # 找到对应工具
                        break  # 跳出循环

                # 执行工具并获取结果
                if tool_fn:
                    # 调用工具函数,**tool_args 将字典解包为关键字参数
                    tool_result = tool_fn.invoke(tool_args)  # 工具返回字符串结果
                else:
                    # 如果找不到对应工具,返回错误信息
                    tool_result = f"错误:未找到工具 {tool_name}"

                # 将工具返回结果包装为 ToolMessage 并追加到消息列表
                messages.append(
                    ToolMessage(
                        content=tool_result,  # 工具返回的内容
                        tool_call_id=tool_call["id"],  # 对应 tool_call 的 ID
                    )
                )
        else:
            # 没有 tool_calls:LLM 认为任务完成,不再需要工具
            break  # 退出循环

    # 更新 state:保存最终的消息列表和 LLM 回复
    updated_state = state.model_copy(
        update={
            "messages": messages,  # 保存完整消息历史
            "final_output": messages[-1].content,  # 最后一条消息是 LLM 的最终回复
        }
    )
    return updated_state  # 返回更新后的 state


# ============================================================
# 第四步:定义 Entrypoint
# ============================================================
@entrypoint()  # 标记为工作流入口
def travel_planner_v4(raw_input: str) -> TravelPlanState:
    """旅行规划助手 v0.4 入口函数。
    支持通过工具调用查询航班、酒店和天气。
    """

    # 构建初始消息列表
    system_prompt = (
        "你是一个专业的旅行规划助手。"
        "你可以使用以下工具帮助用户:"
        "1. search_flights - 搜索航班信息"
        "2. search_hotels - 搜索酒店信息"
        "3. get_weather - 查询天气信息"
        "当用户提出相关需求时,请主动使用合适的工具。"
        "回答要友好、专业、清晰。"
    )

    messages = [
        SystemMessage(content=system_prompt),  # 系统提示词
        HumanMessage(content=raw_input),  # 用户输入
    ]

    # 创建初始 State
    state = TravelPlanState(
        raw_input=raw_input,  # 保存用户原始输入
        messages=messages,  # 初始化消息列表
    )

    # 调用 LLM Task(内部包含工具调用循环)
    state = call_llm_with_tools(state).result()

    # 返回最终 state
    return state


# ============================================================
# 程序入口
# ============================================================
if __name__ == "__main__":
    # 检查 API Key
    if not os.environ.get("OPENAI_API_KEY"):
        print("错误:未设置 OPENAI_API_KEY 环境变量!")
        print("请先运行:export OPENAI_API_KEY='your-api-key-here'")
        exit(1)

    print("=" * 60)
    print("旅行规划助手 v0.4 --- 工具调用演示")
    print("=" * 60)

    # 测试 1:查询航班
    print("\n【测试 1】查询航班")
    print("-" * 60)
    result1 = travel_planner_v4.invoke("帮我查一下7月20日从北京到东京的航班")
    print(result1.final_output)  # 打印 LLM 最终回复

    # 测试 2:查询酒店
    print("\n" + "=" * 60)
    print("【测试 2】查询酒店")
    print("-" * 60)
    result2 = travel_planner_v4.invoke("我7月20日到22日要去东京,帮我找找中等价位的酒店")
    print(result2.final_output)  # 打印 LLM 最终回复

    # 测试 3:查询天气
    print("\n" + "=" * 60)
    print("【测试 3】查询天气")
    print("-" * 60)
    result3 = travel_planner_v4.invoke("东京未来5天天气怎么样?")
    print(result3.final_output)  # 打印 LLM 最终回复

    # 测试 4:综合查询(LLM 可能需要调用多个工具)
    print("\n" + "=" * 60)
    print("【测试 4】综合查询")
    print("-" * 60)
    result4 = travel_planner_v4.invoke(
        "我计划7月20日从上海去东京玩3天,帮我查一下航班、酒店和天气"
    )
    print(result4.final_output)  # 打印 LLM 最终回复

5.3.3 运行结果展示

测试 1(查询航班)

复制代码
============================================================
旅行规划助手 v0.4 --- 工具调用演示
============================================================

【测试 1】查询航班
------------------------------------------------------------
为您查询到以下从北京到东京的航班(2026年7月20日):

1. **中国国际航空 CA1234**
   - 出发:08:00
   - 到达:11:30
   - 时长:3小时30分
   - 价格:¥2,800
   - 类型:直飞

2. **东方航空 MU5678**
   - 出发:13:00
   - 到达:16:20
   - 时长:3小时20分
   - 价格:¥2,200
   - 类型:直飞

3. **南方航空 CZ9012**
   - 出发:07:30
   - 到达:12:00
   - 时长:4小时30分
   - 价格:¥1,800
   - 类型:经停首尔

推荐东方航空 MU5678,性价比最高!需要我帮您预订吗?

测试 4(综合查询)

复制代码
【测试 4】综合查询
------------------------------------------------------------
好的!为您汇总一下从上海到东京的旅行信息:

**航班信息(7月20日):**
- 中国国际航空 CA1234:08:00-11:30,直飞,¥2,800
- 东方航空 MU5678:13:00-16:20,直飞,¥2,200
- 南方航空 CZ9012:07:30-12:00,经停首尔,¥1,800

**酒店推荐(7月20日-22日,中等预算):**
- 东京中心商务酒店:市中心,评分4.5,¥500/晚

**天气预告(7月20日-22日):**
- 第1天:晴,16°C ~ 28°C
- 第2天:多云,12°C ~ 25°C
- 第3天:阵雨,14°C ~ 22°C

建议您选择东方航空 MU5678,下午到达后可以直接入住酒店。第三天有雨,建议安排室内活动!

5.3.4 代码逐段解析

第一部分:@tool 装饰器

python 复制代码
@tool
def search_flights(departure: str, destination: str, date: str) -> str:
    """搜索航班信息。当用户询问机票、航班、飞行相关问题时使用此工具。"""

@tool 装饰器将普通 Python 函数转换为 LLM 可调用的工具。关键点:(1) 函数文档字符串(docstring)会被发送给 LLM 作为工具描述,告诉模型何时使用此工具;(2) 参数类型注解也会被发送给 LLM,帮助模型理解每个参数的含义;(3) 返回值必须是字符串(str),因为 LLM 只能处理文本。

第二部分:bind_tools()

python 复制代码
llm_with_tools = llm.bind_tools(tools)

bind_tools() 将工具列表「注册」到 LLM。调用后,每次 llm_with_tools.invoke() 时,LLM 都会收到「你可以使用这些工具」的信息。当 LLM 认为需要工具时,它会返回 tool_calls 而不是普通文本。

第三部分:工具调用循环

python 复制代码
while True:
    response = llm_with_tools.invoke(messages)
    messages.append(response)
    if response.tool_calls:
        for tool_call in response.tool_calls:
            tool_fn = find_tool(tool_call["name"])
            tool_result = tool_fn.invoke(tool_call["args"])
            messages.append(ToolMessage(content=tool_result, ...))
    else:
        break

这是工具调用循环的核心逻辑:(1) 调用 LLM;(2) 检查是否有 tool_calls;(3) 如果有,执行工具并将结果追加到消息列表;(4) 再次调用 LLM,让它基于工具结果继续思考;(5) 重复直到 LLM 不再需要工具(返回纯文本回复)。

第四部分:ToolMessage

python 复制代码
messages.append(
    ToolMessage(
        content=tool_result,
        tool_call_id=tool_call["id"],
    )
)

ToolMessage 是专门用于包装工具返回结果的消息类型。它必须包含 tool_call_id 来关联对应的工具调用请求,否则 LLM 无法正确匹配。

5.4 API 速查

API 类型 说明 签名/导入路径
@tool 装饰器 将函数标记为 LLM 可调用的工具 from langchain.tools import tool
bind_tools(tools) 方法 将工具列表绑定到 LLM llm_with_tools = llm.bind_tools([t1, t2])
AIMessage.tool_calls 属性 LLM 返回的工具调用请求列表 response.tool_calls
ToolMessage 封装工具返回结果的消息类型 from langchain_core.messages import ToolMessage
SystemMessage 系统级别的消息(设定 AI 行为) from langchain_core.messages import SystemMessage
HumanMessage 用户发送的消息 from langchain_core.messages import HumanMessage
.invoke(args) 方法 同步调用工具函数 result = my_tool.invoke({"param": "value"})

5.5 常见错误与避坑指南

错误 1:忘记将工具结果追加到消息列表

错误代码

python 复制代码
while True:
    response = llm_with_tools.invoke(messages)
    if response.tool_calls:
        for tool_call in response.tool_calls:
            tool_result = execute_tool(tool_call)
            # 错误:工具结果没有追加到 messages 中
            # LLM 不知道工具已经执行了,会重复请求相同的工具调用
    else:
        break

正确代码

python 复制代码
while True:
    response = llm_with_tools.invoke(messages)
    messages.append(response)  # 先追加 AI 消息
    if response.tool_calls:
        for tool_call in response.tool_calls:
            tool_result = execute_tool(tool_call)
            # 正确:将工具结果作为 ToolMessage 追加到消息列表
            messages.append(
                ToolMessage(
                    content=tool_result,
                    tool_call_id=tool_call["id"],
                )
            )
    else:
        break

原因:LLM 是无状态的------它不会记住上一次调用时发生了什么。每次调用都需要完整的消息历史。如果不追加工具结果,LLM 会认为工具还没有被调用,从而陷入无限循环。

错误 2:工具函数没有文档字符串

错误代码

python 复制代码
@tool
def search_flights(departure: str, destination: str, date: str) -> str:
    # 错误:没有 docstring,LLM 不知道何时使用此工具
    return "模拟航班数据..."

正确代码

python 复制代码
@tool
def search_flights(departure: str, destination: str, date: str) -> str:
    """搜索航班信息。当用户询问机票、航班、飞行相关问题时使用此工具。

    参数:
    - departure: 出发城市名称(中文)
    - destination: 到达城市名称(中文)
    - date: 出发日期,格式为 YYYY-MM-DD
    """
    return "模拟航班数据..."

原因@tool 装饰器会将函数的 docstring 发送给 LLM 作为工具的使用说明。没有 docstring 的工具,LLM 不知道何时调用它、如何传参,导致工具永远不会被使用。

错误 3:工具返回值不是字符串

错误代码

python 复制代码
@tool
def search_flights(departure: str, destination: str, date: str) -> dict:
    # 错误:返回字典而不是字符串
    return {"flights": [...], "count": 3}

正确代码

python 复制代码
@tool
def search_flights(departure: str, destination: str, date: str) -> str:
    # 正确:返回格式化后的字符串
    result = "航班信息:\n"
    for flight in mock_flights:
        result += f"- {flight['airline']}: ¥{flight['price']}\n"
    return result  # 返回字符串

原因 :LLM 只能处理文本。工具返回值必须是字符串(str),因为返回值会被包装成 ToolMessage(content=...) 发送给 LLM。如果返回字典或列表,LLM 无法正确理解。

错误 4:忘记在工具调用循环中处理异常

错误代码

python 复制代码
while True:
    response = llm_with_tools.invoke(messages)
    messages.append(response)
    if response.tool_calls:
        for tool_call in response.tool_calls:
            tool_fn = find_tool(tool_call["name"])
            tool_result = tool_fn.invoke(tool_call["args"])  # 可能抛出异常
            messages.append(ToolMessage(content=tool_result, ...))
    else:
        break

正确代码

python 复制代码
while True:
    response = llm_with_tools.invoke(messages)
    messages.append(response)
    if response.tool_calls:
        for tool_call in response.tool_calls:
            try:
                tool_fn = find_tool(tool_call["name"])
                if tool_fn is None:
                    tool_result = f"错误:未找到工具 {tool_call['name']}"
                else:
                    tool_result = tool_fn.invoke(tool_call["args"])
            except Exception as e:
                # 捕获异常,将错误信息作为工具结果返回给 LLM
                tool_result = f"工具执行出错:{str(e)}"
            messages.append(
                ToolMessage(content=tool_result, tool_call_id=tool_call["id"])
            )
    else:
        break

原因:工具执行过程中可能因为各种原因失败(网络超时、参数错误、数据不存在等)。如果不捕获异常,整个工作流会崩溃。将异常信息作为工具结果返回给 LLM,LLM 可以理解错误并采取替代方案。

错误 5:工具调用循环无限循环

错误代码

python 复制代码
while True:
    response = llm_with_tools.invoke(messages)
    messages.append(response)
    if response.tool_calls:
        for tool_call in response.tool_calls:
            tool_result = execute_tool(tool_call)
            messages.append(ToolMessage(content=tool_result, ...))
    # 错误:没有 break 条件!即使没有 tool_calls 也不会退出

正确代码

python 复制代码
max_iterations = 10  # 设置最大迭代次数,防止无限循环
iteration = 0

while iteration < max_iterations:
    iteration += 1
    response = llm_with_tools.invoke(messages)
    messages.append(response)
    if response.tool_calls:
        for tool_call in response.tool_calls:
            tool_result = execute_tool(tool_call)
            messages.append(ToolMessage(content=tool_result, ...))
    else:
        break  # 正确:没有 tool_calls 时退出循环
else:
    # 达到最大迭代次数仍未结束
    print("警告:工具调用循环达到最大迭代次数,强制退出")

原因:即使正确处理了 tool_calls,在某些边缘情况下 LLM 可能反复请求工具调用。设置最大迭代次数是安全网,防止无限循环消耗 API 额度。

5.6 最佳实践总结

  1. 工具描述要详细准确:docstring 是 LLM 理解工具的唯一途径。清晰描述工具的功能、使用场景和每个参数的含义。好的描述 = 准确的工具调用。

  2. 工具返回值要结构化:虽然返回值必须是字符串,但字符串内容应该结构化(使用列表、表格、分段等格式)。这帮助 LLM 更好地理解工具结果并提供高质量回复。

  3. 设置最大迭代次数 :工具调用循环中始终设置 max_iterations。正常情况 LLM 会在 1-3 轮内完成,但安全网是必需的。

  4. 工具异常要捕获并返回给 LLM:不要在工具内部让异常向上传播。捕获异常并将其作为工具结果返回,让 LLM 知道发生了什么并尝试替代方案。

  5. bind_tools 放在模块顶层bind_tools() 只需要调用一次,将绑定后的 LLM 实例放在全局作用域复用。不要在每次 task 调用时重新绑定。


相关推荐
ShallWeL1 小时前
AUC、F1、召回率怎么选
人工智能·算法·机器学习
Sirius Wu1 小时前
OpenClaw SubAgent 三层安全约束完整详解
人工智能·安全·ai·架构·aigc
老刘干货2 小时前
深度拆解:Harness与Multica的核心差异及AI工程化落地最佳实践
人工智能
再让我睡两分钟2 小时前
【无标题】
android·java·数据库·人工智能·prompt·ai应用开发
糖果店的幽灵2 小时前
【langgraph 从入门到精通】多步骤编排与条件分支 — 使用 Functional API 控制流程
langgraph
盖立克思GEO研究院2 小时前
【2026】海外 B2B 工厂 AI 询盘破局:独立站 + GEO 双驱动,ChatGPT/Gemini1 个月精准获客案例拆解
人工智能·chatgpt
东坡肘子2 小时前
当每一次写入都有了新价格 -- 肘子的 Swift 周报 #144
人工智能·swiftui·swift
水龙吟啸2 小时前
华为2026.6.3机考选择题+编程题【速刷敲黑板】
人工智能·深度学习·算法·华为
郭泽斌之心2 小时前
让别人临时接管你的 AI 助手:分级授权的远程控制怎么设计
人工智能·深度学习·量化交易·ea·mt5·fay数字人·easydeal