【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 调用时重新绑定。


相关推荐
江畔柳前堤4 小时前
roLabelImg 详细安装教程
开发语言·人工智能·后端·云原生
阿里云大数据AI技术4 小时前
分链路差异化设计的DSP准实时数仓|钛动科技基于阿里云实时计算 Flink 版 + DLF Paimon + EMR Serverless StarRocks 的实践
人工智能·flink
陕西企来客4 小时前
2026年7月AI智能搜索曝光趋势研判
大数据·人工智能·机器学习·ai智能搜索曝光
阿里云大数据AI技术5 小时前
从算力到智能体,面向 Agentic AI 的基础设施演进
人工智能·agent
hangyuekejiGEO5 小时前
GEO技术服务选型指南
大数据·人工智能·python
阿里云大数据AI技术6 小时前
EMR Serverless Spark AI Function 的双维降本实践
人工智能·sql·spark
维基框架6 小时前
GitHub源码处理提速 一趟扫描反而更慢
人工智能·github
冬奇Lab6 小时前
代码库知识库系列(05):向量检索 vs 知识图谱——加了调用图并没有变更好
人工智能
AKAMAI6 小时前
你的源服务器可能是你做出的最昂贵决定
运维·人工智能·云计算
冬奇Lab6 小时前
【无标题】
人工智能·开源