LangGraph Tool Calling 入门:从 @tool 到完整调用链

目录

[一、LLM 有大脑,但没有手脚](#一、LLM 有大脑,但没有手脚)

[二、为什么需要 Tool Calling?](#二、为什么需要 Tool Calling?)

[(一)连接「会思考的 LLM」和「能执行的工具」](#(一)连接「会思考的 LLM」和「能执行的工具」)

(二)它解决了什么问题?

三、核心原理:完整链路分四步

[(一)@tool 装饰器:把函数变成工具](#(一)@tool 装饰器:把函数变成工具)

[(二)LangGraph 中的执行循环](#(二)LangGraph 中的执行循环)

四、源码分析:手写工具执行节点

(一)关键代码说明

(二)关键点说明

[1. last_message.tool_calls](#1. last_message.tool_calls)

[2. tool_maptool_name.invoke(tool_args)](#2. tool_map[tool_name].invoke(tool_args))

[3. ToolMessage(..., tool_call_id=tool_id)](#3. ToolMessage(..., tool_call_id=tool_id))

(三)路由函数:决定循环是否继续

[五、实战代码:最小 Tool Agent 骨架](#五、实战代码:最小 Tool Agent 骨架)

(一)整体设计代码

(二)运行效果

(三)如何解读这个输出?

[1. 消息链长度:4 vs 2](#1. 消息链长度:4 vs 2)

[2. 工具返回值不是 LLM 算出来的](#2. 工具返回值不是 LLM 算出来的)

[(四)真实跑一遍:在线 LLM + 在线天气 API](#(四)真实跑一遍:在线 LLM + 在线天气 API)

[1. 工具改造:调用 Open-Meteo 真实天气接口](#1. 工具改造:调用 Open-Meteo 真实天气接口)

[2. LLM 改造:真实模型自主决策](#2. LLM 改造:真实模型自主决策)

[3. 真实运行效果](#3. 真实运行效果)

六、常见坑与排查

[(一)坑 1:工具没有 bind_tools,LLM 永远不会调用工具](#(一)坑 1:工具没有 bind_tools,LLM 永远不会调用工具)

[(二)坑 2:docstring 太敷衍,LLM 选错工具或不选工具](#(二)坑 2:docstring 太敷衍,LLM 选错工具或不选工具)

[(三)坑 3:ToolMessage 缺少 tool_call_id](#(三)坑 3:ToolMessage 缺少 tool_call_id)

七、工程化问题与生产级方案建议

(一)工程化问题

[1. 工具描述质量决定调用准确率](#1. 工具描述质量决定调用准确率)

[2. 参数类型要保持 JSON 兼容](#2. 参数类型要保持 JSON 兼容)

[3. 工具执行一定可能失败](#3. 工具执行一定可能失败)

[4. 有副作用的工具必须加安全控制](#4. 有副作用的工具必须加安全控制)

(二)生产级方案建议

[1. 可观测性](#1. 可观测性)

[2. 参数白名单](#2. 参数白名单)

[3. 工具版本与灰度](#3. 工具版本与灰度)

八、总结

下一篇预告


干货分享,感谢您的阅读!

这是「LangGraph Agent Engineering Mastery」系列 Stage 2 的第一篇。

读完本文,你将搞懂:一个普通 Python 函数是如何变成 LLM 可以调用的「工具」的,以及 LLM 决策 → 工具执行 → 结果回传 这条链路在 LangGraph 中是如何转起来的。

一、LLM 有大脑,但没有手脚

你大概率遇到过这样的场景:

用户问:

北京今天天气怎么样?

LLM 一本正经地回答:

抱歉,我的训练数据截止到 2023 年......

那一刻你会意识到:纯 LLM 本质上是一个有知识但没手脚的大脑。它能聊天、能写文章、能推理,但它不能直接:

  • 查实时天气
  • 下订单
  • 发邮件
  • 查数据库
  • 操作业务系统

Tool Calling,就是给这个"大脑"装上手脚的过程。

本文会用一个能做加法、乘法、查天气的最小 Agent,把 Tool Calling 从里到外讲清楚:不依赖框架黑盒,而是手写完整的 LLM → Tool → LLM 调用循环。

二、为什么需要 Tool Calling?

(一)连接「会思考的 LLM」和「能执行的工具」

当 AI Agent 只能依赖训练数据回答问题时,它本质上只是一个「高级聊天机器人」。而 Tool Calling 让 Agent 能够调用外部函数,从而获取实时数据、执行动作、与真实系统交互。这也是 Agent 从「聊天机器人」进化为「智能助手」的关键分水岭。

用后端工程师更熟悉的话来说:

LLM 像一个只会写 SQL、但连不上数据库的人。

它知道该查什么,但真正执行查询、拿到结果的,是你的运行时框架。

Tool Calling 要做的,就是把「会思考的 LLM」和「能执行的工具」连接起来。

(二)它解决了什么问题?

传统 LLM 应用面临一个根本矛盾:

LLM 的知识是静态的,但用户的需求是动态的。

用户诉求 纯 LLM 接入 Tool Calling 后
今天天气如何 无法可靠回答,因为训练数据有截止日期 调用 search_weather 获取实时天气
帮我算 3 + 5 可能算错,尤其是复杂计算 调用 add 工具精确计算
帮我发邮件 只能生成邮件文本 调用 send_email 真正发送
查我的订单 没有业务系统权限 调用 query_order 查询业务库

Tool Calling 的核心价值是:LLM 负责判断"要做什么",工具负责真正"去执行"。

三、核心原理:完整链路分四步

Tool Calling 的完整链路可以拆成四步:

复制代码
1. 注册工具  →  LLM 知道有哪些工具可用
2. LLM 决策  →  判断是否需要工具,并生成工具名与参数
3. 工具执行  →  框架执行对应函数,拿到返回值
4. 结果整合  →  LLM 将工具结果整理成自然语言回复

注意一个关键点:

工具执行完之后,并不是直接把结果返回给用户,而是把结果塞回消息链,再交给 LLM。

由 LLM 决定:继续调用工具,还是生成最终回答。

(一)@tool 装饰器:把函数变成工具

LangChain 的 @tool 装饰器,是连接 Python 函数与 LLM 的桥梁。例如:

python 复制代码
from langchain_core.tools import tool

@tool
def search_weather(city: str) -> str:
    """查询指定城市的实时天气。当用户询问天气时使用。"""
    return weather_api.query(city)

一个普通函数被 @tool 包装之后,会被转换成 LLM 能理解的工具描述。它主要提取三类信息:

|-----------|---------------------------|
| 来源 | 作用 |
| 函数名 | 工具标识符,例如 search_weather |
| docstring | 工具描述,用来帮助 LLM 判断何时使用 |
| 类型注解 | 参数 Schema,用来约束 LLM 生成参数 |

也就是说,下面这些代码信息:

python 复制代码
def search_weather(city: str) -> str:
    """查询指定城市的实时天气。当用户询问天气时使用。"""

会被转换成类似这样的工具结构:

python 复制代码
工具名: search_weather
工具描述: 查询指定城市的实时天气。当用户询问天气时使用。
参数:
  city: string

这也是为什么工具的 docstring 非常重要。它不是写给人看的注释,而是写给 LLM 看的「工具使用说明书」。

(二)LangGraph 中的执行循环

在 LangGraph 里,Tool Calling 通常形成一个带条件分支的循环:

复制代码
START
  ↓
LLM 节点
  ↓
是否存在 tool_calls?
  ├─ 是 → 工具节点 → 工具结果回传 → LLM 节点
  └─ 否 → END

对应成图就是:

这里最关键的是这个循环:

复制代码
LLM → Tool → LLM

第一次进入 LLM,是让它判断是否需要工具。工具执行完成后再次回到 LLM,是让它基于工具结果生成最终回答。

四、源码分析:手写工具执行节点

(一)关键代码说明

@tool 装饰器的核心实现位于:

复制代码
langchain_core/tools/convert.py

它主要做了三件事:

  1. 使用 inspect.signature() 提取函数参数名和类型注解
  2. 将类型注解转换为 Pydantic BaseModel,也就是 args_schema
  3. 创建 StructuredTool 实例,包含 namedescriptionfuncargs_schema

为了看清 Tool Calling 的每一步,我们的 Demo 没有直接使用框架自带的 ToolNode,而是手写了一个工具执行节点。核心代码如下:

python 复制代码
def tool_node(state: ToolDemoState) -> dict:
    """工具执行节点 --- 解析 tool_calls 并执行对应工具。"""
    messages = state["messages"]
    last_message = messages[-1]

    tool_messages = []
    if hasattr(last_message, "tool_calls") and last_message.tool_calls:
        for tc in last_message.tool_calls:
            tool_name = tc["name"]
            tool_args = tc["args"]
            tool_id = tc["id"]

            log_step(
                logger,
                "工具执行",
                f"调用 {tool_name}({tool_args})",
            )

            if tool_name in tool_map:
                result = tool_map[tool_name].invoke(tool_args)
                log_success(logger, f"工具 {tool_name} 返回: {result}")
            else:
                result = f"错误:未找到工具 '{tool_name}'"

            tool_messages.append(
                ToolMessage(
                    content=str(result),
                    tool_call_id=tool_id,
                    name=tool_name,
                )
            )

    return {"messages": tool_messages}

(二)关键点说明

这段代码里有几个关键点。

1. last_message.tool_calls

LLM 并不会直接执行工具。它只会输出一个结构化调用请求,例如:

python 复制代码
[
    {
        "id": "call_xxx",
        "name": "add",
        "args": {"a": 3, "b": 5}
    }
]

也就是说,LLM 的职责是生成:

请帮我调用 add(a=3, b=5)

而不是自己去执行 Python 函数。

2. tool_map[tool_name].invoke(tool_args)

真正执行工具的是框架或运行时。

python 复制代码
result = tool_map[tool_name].invoke(tool_args)

这里会根据工具名找到对应的 Tool 对象,并把 LLM 生成的参数传进去执行。

比如:

复制代码
add.invoke({"a": 3, "b": 5})

最终得到:

复制代码
8

这个 8 是 Python 函数计算出来的,不是 LLM 自己算出来的。

3. ToolMessage(..., tool_call_id=tool_id)

工具结果需要包装成 ToolMessage 放回消息链。

python 复制代码
ToolMessage(
    content=str(result),
    tool_call_id=tool_id,
    name=tool_name,
)

其中最重要的是:

复制代码
tool_call_id=tool_id

它用于把「工具执行结果」和「LLM 发起的那次工具调用」一一对应起来。这在一次调用多个工具时尤其重要。

(三)路由函数:决定循环是否继续

工具节点之外,还需要一个路由函数来判断是否进入工具执行分支。

python 复制代码
def should_call_tool(state: ToolDemoState) -> str:
    """路由函数:判断最后一条消息是否包含 tool_calls。"""
    messages = state["messages"]
    last_message = messages[-1]
    if hasattr(last_message, "tool_calls") and last_message.tool_calls:
        return "tools"
    return "end"

逻辑非常简单:

复制代码
如果最后一条 AIMessage 里有 tool_calls → 进入 tools 节点
否则 → 结束流程

这就是 LangGraph 中 Tool Calling 循环的控制开关。

五、实战代码:最小 Tool Agent 骨架

(一)整体设计代码

下面是一段自包含的最小骨架,用来理解 LangGraph 中 Tool Calling 的整体结构。完整可运行版本见 main.py

python 复制代码
"""Demo 01: Tool Basics --- @tool 装饰器与 Tool Binding。

演示 Tool Calling 的三大核心要素:
1. @tool 装饰器:将 Python 函数转化为 LLM 可调用的工具
2. Tool Binding:将工具注册给 LLM
3. ToolNode:在 StateGraph 中执行工具调用

运行方式:
    python stages/stage2_tool_calling/01_tool_basics/main.py
"""

from __future__ import annotations

import sys
from pathlib import Path
from typing import Annotated, TypedDict

from langchain_core.messages import (
    AIMessage,
    BaseMessage,
    HumanMessage,
    ToolMessage,
)
from langchain_core.tools import tool
from langgraph.graph import END, START, StateGraph
from langgraph.graph.message import add_messages

sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent))

from shared import get_logger, log_step, log_success

logger = get_logger("demo.01_tool_basics")


# ============================================================
# 第一步:使用 @tool 装饰器定义工具
# @tool 会从函数名、docstring、类型注解自动生成工具描述
# ============================================================
@tool
def add(a: int, b: int) -> int:
    """将两个整数相加并返回结果。当用户要求计算加法时使用此工具。"""
    return a + b


@tool
def multiply(a: int, b: int) -> int:
    """将两个整数相乘并返回结果。当用户要求计算乘法时使用此工具。"""
    return a * b


@tool
def search_weather(city: str) -> str:
    """查询指定城市的实时天气信息。当用户询问天气时使用此工具。

    Args:
        city: 城市名称,如"北京"、"上海"
    """
    weather_data = {
        "北京": "晴,25°C,湿度 40%",
        "上海": "多云,28°C,湿度 65%",
        "广州": "阵雨,30°C,湿度 80%",
    }
    return weather_data.get(city, f"{city}:暂无天气数据")


# ============================================================
# 第二步:定义 State
# 使用 MessagesState 风格,messages 字段自动累加
# ============================================================
class ToolDemoState(TypedDict):
    messages: Annotated[list[BaseMessage], add_messages]


# ============================================================
# 第三步:构建 Tool Calling Graph
# ============================================================
def build_tool_basics_graph(tools: list | None = None):
    """构建演示 Tool Binding 的基础图。

    这个图演示手动处理 tool_calls 的完整流程:
    1. LLM 节点决定是否调用工具
    2. 工具节点执行工具并返回结果
    3. 结果回传给 LLM 节点生成最终回复
    """
    if tools is None:
        tools = [add, multiply, search_weather]

    tool_map = {t.name: t for t in tools}

    def llm_node(state: ToolDemoState) -> dict:
        """模拟 LLM 决策节点。

        分析最后一条消息,决定是否需要调用工具。
        在 Mock 模式下,通过关键词匹配模拟 LLM 的工具调用决策。
        """
        messages = state["messages"]
        last_message = messages[-1]

        if isinstance(last_message, ToolMessage):
            log_step(logger, "LLM 节点", "收到工具结果,生成最终回复")
            tool_result = last_message.content
            return {
                "messages": [AIMessage(content=f"根据工具调用结果:{tool_result}")]
            }

        content = str(last_message.content).lower()
        log_step(logger, "LLM 节点", f"分析用户输入: '{last_message.content}'")

        if "加" in content or "+" in content:
            log_step(logger, "LLM 决策", "需要调用 add 工具")
            return {
                "messages": [
                    AIMessage(
                        content="",
                        tool_calls=[
                            {
                                "id": "call_add_001",
                                "name": "add",
                                "args": {"a": 3, "b": 5},
                            }
                        ],
                    )
                ]
            }
        elif "乘" in content or "×" in content or "*" in content:
            log_step(logger, "LLM 决策", "需要调用 multiply 工具")
            return {
                "messages": [
                    AIMessage(
                        content="",
                        tool_calls=[
                            {
                                "id": "call_mul_001",
                                "name": "multiply",
                                "args": {"a": 4, "b": 7},
                            }
                        ],
                    )
                ]
            }
        elif "天气" in content:
            city = "北京"
            for c in ["北京", "上海", "广州"]:
                if c in content:
                    city = c
                    break
            log_step(logger, "LLM 决策", f"需要调用 search_weather 工具,城市: {city}")
            return {
                "messages": [
                    AIMessage(
                        content="",
                        tool_calls=[
                            {
                                "id": "call_weather_001",
                                "name": "search_weather",
                                "args": {"city": city},
                            }
                        ],
                    )
                ]
            }
        else:
            log_step(logger, "LLM 决策", "不需要工具,直接回复")
            return {
                "messages": [
                    AIMessage(content="你好!我可以帮你做数学计算或查询天气。")
                ]
            }

    def tool_node(state: ToolDemoState) -> dict:
        """工具执行节点 --- 解析 tool_calls 并执行对应工具。"""
        messages = state["messages"]
        last_message = messages[-1]

        tool_messages = []
        if hasattr(last_message, "tool_calls") and last_message.tool_calls:
            for tc in last_message.tool_calls:
                tool_name = tc["name"]
                tool_args = tc["args"]
                tool_id = tc["id"]

                log_step(
                    logger,
                    "工具执行",
                    f"调用 {tool_name}({tool_args})",
                )

                if tool_name in tool_map:
                    result = tool_map[tool_name].invoke(tool_args)
                    log_success(logger, f"工具 {tool_name} 返回: {result}")
                else:
                    result = f"错误:未找到工具 '{tool_name}'"

                tool_messages.append(
                    ToolMessage(
                        content=str(result),
                        tool_call_id=tool_id,
                        name=tool_name,
                    )
                )

        return {"messages": tool_messages}

    def should_call_tool(state: ToolDemoState) -> str:
        """路由函数:判断最后一条消息是否包含 tool_calls。"""
        messages = state["messages"]
        last_message = messages[-1]
        if hasattr(last_message, "tool_calls") and last_message.tool_calls:
            return "tools"
        return "end"

    graph = StateGraph(ToolDemoState)
    graph.add_node("llm", llm_node)
    graph.add_node("tools", tool_node)

    graph.add_edge(START, "llm")
    graph.add_conditional_edges("llm", should_call_tool, {
        "tools": "tools",
        "end": END,
    })
    graph.add_edge("tools", "llm")

    return graph


# ============================================================
# 第四步:运行演示
# ============================================================
def demo_tool_definition():
    """演示 1:@tool 装饰器和工具属性。"""
    print("\n--- 演示 1: @tool 装饰器 ---\n")

    tools = [add, multiply, search_weather]
    for t in tools:
        print(f"  工具名: {t.name}")
        print(f"  描述  : {t.description}")
        print(f"  参数  : {t.args_schema.model_json_schema() if t.args_schema else 'N/A'}")
        print()

    return tools


def demo_tool_binding():
    """演示 2:Tool Binding --- 将工具绑定到 LLM。"""
    print("\n--- 演示 2: Tool Binding ---\n")

    from shared import MockChatModel

    llm = MockChatModel()
    tools = [add, multiply, search_weather]

    log_step(logger, "Tool Binding", f"将 {len(tools)} 个工具绑定到 LLM")

    try:
        llm_with_tools = llm.bind_tools(tools)
        print(f"  绑定后对象: {type(llm_with_tools).__name__}")
    except NotImplementedError:
        llm_with_tools = llm
        print("  (MockChatModel 不支持 bind_tools,生产中使用真实 LLM 即可)")

    print(f"  绑定的工具: {[t.name for t in tools]}")
    print(f"  LLM 类型  : {llm._llm_type}")
    print()
    print("  说明: bind_tools() 将工具的 JSON Schema 注入到 LLM 请求参数中,")
    print("        让 LLM 在每次调用时都能看到可用工具列表。")
    print("        Mock 模式下我们通过手动逻辑模拟工具选择行为。")

    return llm_with_tools


def demo_tool_graph():
    """演示 3:完整的 Tool Calling Graph 执行。"""
    print("\n--- 演示 3: Tool Calling Graph ---\n")

    graph = build_tool_basics_graph()
    app = graph.compile()

    test_cases = [
        "帮我计算 3 加 5",
        "4 乘以 7 等于多少?",
        "北京今天天气怎么样?",
        "你好",
    ]

    results = []
    for question in test_cases:
        print(f"\n  用户: {question}")
        print(f"  {'─' * 40}")
        result = app.invoke({"messages": [HumanMessage(content=question)]})
        final_msg = result["messages"][-1]
        print(f"  助手: {final_msg.content}")
        print(f"  消息链长度: {len(result['messages'])}")
        results.append(result)

    return results


def run_demo() -> dict:
    """运行 Tool Basics 全部演示。"""
    print("=" * 60)
    print("  Demo 01: Tool Basics --- @tool 装饰器与 Tool Binding")
    print("=" * 60)

    tools = demo_tool_definition()
    llm_with_tools = demo_tool_binding()
    results = demo_tool_graph()

    print()
    print("=" * 60)
    print("  关键概念回顾")
    print("=" * 60)
    print("  1. @tool     : 将 Python 函数转化为 LLM 可调用的工具对象")
    print("  2. Binding   : 将工具列表注册给 LLM,LLM 获知可用工具")
    print("  3. tool_calls: LLM 输出的结构化工具调用请求")
    print("  4. ToolMessage: 工具执行结果的标准消息格式")
    print("  5. ToolNode  : StateGraph 中负责执行工具的节点")
    print("  6. 路由函数   : 判断 LLM 输出是否包含 tool_calls")
    print("  7. 循环结构   : tools → llm → (判断) → tools/end")
    print()

    return {
        "tools": tools,
        "llm_with_tools": llm_with_tools,
        "results": results,
    }


if __name__ == "__main__":
    run_demo()

运行方式:

python 复制代码
source .venv/bin/activate
python stages/stage2_tool_calling/01_tool_basics/main.py

这段代码的结构可以理解为:

(二)运行效果

下面是实际执行 main.py 的输出截取。这里使用的是 Mock 模式,工具选择由本地逻辑模拟;如果换成真实 LLM,只需要替换 MockChatModel 即可。

Connected to server 127.0.0.1:56450

============================================================

Demo 01: Tool Basics --- @tool 装饰器与 Tool Binding

============================================================

--- 演示 1: @tool 装饰器 ---

工具名: add

描述 : 将两个整数相加并返回结果。当用户要求计算加法时使用此工具。

参数 : {'description': '将两个整数相加并返回结果。当用户要求计算加法时使用此工具。', 'properties': {'a': {'title': 'A', 'type': 'integer'}, 'b': {'title': 'B', 'type': 'integer'}}, 'required': 'a', 'b', 'title': 'add', 'type': 'object'}

工具名: multiply

描述 : 将两个整数相乘并返回结果。当用户要求计算乘法时使用此工具。

参数 : {'description': '将两个整数相乘并返回结果。当用户要求计算乘法时使用此工具。', 'properties': {'a': {'title': 'A', 'type': 'integer'}, 'b': {'title': 'B', 'type': 'integer'}}, 'required': 'a', 'b', 'title': 'multiply', 'type': 'object'}

工具名: search_weather

描述 : 查询指定城市的实时天气信息。当用户询问天气时使用此工具。

Args:

city: 城市名称,如"北京"、"上海"

参数 : {'description': '查询指定城市的实时天气信息。当用户询问天气时使用此工具。\n\nArgs:\n city: 城市名称,如"北京"、"上海"', 'properties': {'city': {'title': 'City', 'type': 'string'}}, 'required': 'city', 'title': 'search_weather', 'type': 'object'}

--- 演示 2: Tool Binding ---

(MockChatModel 不支持 bind_tools,生产中使用真实 LLM 即可)

绑定的工具: 'add', 'multiply', 'search_weather'

LLM 类型 : mock

说明: bind_tools() 将工具的 JSON Schema 注入到 LLM 请求参数中,

让 LLM 在每次调用时都能看到可用工具列表。

Mock 模式下我们通过手动逻辑模拟工具选择行为。

--- 演示 3: Tool Calling Graph ---

用户: 帮我计算 3 加 5

────────────────────────────────────────

助手: 根据工具调用结果:8

消息链长度: 4

用户: 4 乘以 7 等于多少?

────────────────────────────────────────

助手: 根据工具调用结果:28

消息链长度: 4

用户: 北京今天天气怎么样?

────────────────────────────────────────

2026-06-08 19:24:40 INFO demo.01_tool_basics | 📌 Tool Binding 将 3

个工具绑定到 LLM

INFO demo.01_tool_basics | 📌 LLM 节点 分析用户输入:

'帮我计算 3 加 5'

INFO demo.01_tool_basics | 📌 LLM 决策 需要调用 add

工具

INFO demo.01_tool_basics | 📌 工具执行 调用 add({'a':

3, 'b': 5})

INFO demo.01_tool_basics | ✅ 工具 add 返回: 8

INFO demo.01_tool_basics | 📌 LLM 节点

收到工具结果,生成最终回复

INFO demo.01_tool_basics | 📌 LLM 节点 分析用户输入:

'4 乘以 7 等于多少?'

INFO demo.01_tool_basics | 📌 LLM 决策 需要调用

multiply 工具

INFO demo.01_tool_basics | 📌 工具执行 调用

multiply({'a': 4, 'b': 7})

INFO demo.01_tool_basics | ✅ 工具 multiply 返回: 28

INFO demo.01_tool_basics | 📌 LLM 节点

收到工具结果,生成最终回复

INFO demo.01_tool_basics | 📌 LLM 节点 分析用户输入:

'北京今天天气怎么样?'

INFO demo.01_tool_basics | 📌 LLM 决策 需要调用

search_weather 工具,城市: 北京

INFO demo.01_tool_basics | 📌 工具执行 调用

search_weather({'city': '北京'})

INFO demo.01_tool_basics | ✅ 工具 search_weather 返回:

晴,25°C,湿度 40%

INFO demo.01_tool_basics | 📌 LLM 节点

收到工具结果,生成最终回复

INFO demo.01_tool_basics | 📌 LLM 节点 分析用户输入:

'你好'

INFO demo.01_tool_basics | 📌 LLM 决策

不需要工具,直接回复

助手: 根据工具调用结果:晴,25°C,湿度 40%

消息链长度: 4

用户: 你好

────────────────────────────────────────

助手: 你好!我可以帮你做数学计算或查询天气。

消息链长度: 2

--- 演示 4: 真实 LLM + 真实在线工具 ---

2026-06-08 19:24:41 INFO shared.llm_factory | 已创建 deepseek LLM 实例

2026-06-08 19:24:41 INFO demo.01_tool_basics | ✅ 已获取真实在线

LLM:deepseek

INFO demo.01_tool_basics | 📌 真实 LLM

调用在线模型进行决策......

用户: 上海现在天气怎么样?

──────────────────────────────────────────────────

2026-06-08 19:24:43 INFO demo.01_tool_basics | 📌 LLM 决策 自主选择工具

get_weather_online,参数 {'city': '上海'}

INFO demo.01_tool_basics | 📌 工具执行 调用

get_weather_online({'city': '上海'})

2026-06-08 19:24:45 INFO demo.01_tool_basics | ✅ 工具 get_weather_online

返回: 中国上海 实时天气:局部多云,气温

21.5°C,湿度 60%,风速 8.1 km/h

INFO demo.01_tool_basics | 📌 真实 LLM

调用在线模型进行决策......

2026-06-08 19:24:47 INFO demo.01_tool_basics | 📌 LLM 决策

无需工具,直接回复:

上海现在的天气是局部多云,气温为21.5°C,湿度60%,风

速8.1 km/h。

INFO demo.01_tool_basics | 📌 真实 LLM

调用在线模型进行决策......

助手: 上海现在的天气是局部多云,气温为21.5°C,湿度60%,风速8.1 km/h。

消息链长度: 4

用户: 帮我算一下 128 加 256 等于多少

──────────────────────────────────────────────────

2026-06-08 19:24:48 INFO demo.01_tool_basics | 📌 LLM 决策 自主选择工具

add,参数 {'a': 128, 'b': 256}

INFO demo.01_tool_basics | 📌 工具执行 调用 add({'a':

128, 'b': 256})

INFO demo.01_tool_basics | ✅ 工具 add 返回: 384

INFO demo.01_tool_basics | 📌 真实 LLM

调用在线模型进行决策......

2026-06-08 19:24:49 INFO demo.01_tool_basics | 📌 LLM 决策

无需工具,直接回复: 128 加 256 等于 384。

INFO demo.01_tool_basics | 📌 真实 LLM

调用在线模型进行决策......

助手: 128 加 256 等于 384。

消息链长度: 4

用户: 东京和北京现在分别多少度?

──────────────────────────────────────────────────

2026-06-08 19:24:51 INFO demo.01_tool_basics | 📌 LLM 决策 自主选择工具

get_weather_online,参数 {'city': '东京'}

INFO demo.01_tool_basics | 📌 LLM 决策 自主选择工具

get_weather_online,参数 {'city': '北京'}

INFO demo.01_tool_basics | 📌 工具执行 调用

get_weather_online({'city': '东京'})

2026-06-08 19:24:53 INFO demo.01_tool_basics | ✅ 工具 get_weather_online

返回: 中国东京 实时天气:阴,气温 22.6°C,湿度

62%,风速 7.1 km/h

INFO demo.01_tool_basics | 📌 工具执行 调用

get_weather_online({'city': '北京'})

2026-06-08 19:24:55 INFO demo.01_tool_basics | ✅ 工具 get_weather_online

返回: 中国北京 实时天气:局部多云,气温

23.2°C,湿度 52%,风速 4.9 km/h

INFO demo.01_tool_basics | 📌 真实 LLM

调用在线模型进行决策......

2026-06-08 19:24:57 INFO demo.01_tool_basics | 📌 LLM 决策

无需工具,直接回复:

东京现在的天气是阴天,气温为22.6°C,湿度62%,风速7.

1 km/h。

北京现在的天气是局部

助手: 东京现在的天气是阴天,气温为22.6°C,湿度62%,风速7.1 km/h。

北京现在的天气是局部多云,气温为23.2°C,湿度52%,风速4.9 km/h。

消息链长度: 5

============================================================

关键概念回顾

============================================================

  1. @tool : 将 Python 函数转化为 LLM 可调用的工具对象

  2. Binding : 将工具列表注册给 LLM,LLM 获知可用工具

  3. tool_calls: LLM 输出的结构化工具调用请求

  4. ToolMessage: 工具执行结果的标准消息格式

  5. ToolNode : StateGraph 中负责执行工具的节点

  6. 路由函数 : 判断 LLM 输出是否包含 tool_calls

  7. 循环结构 : tools → llm → (判断) → tools/end

Process finished with exit code 0

伴随日志可以清晰看到整个循环:

复制代码
📌 [LLM 节点] 分析用户输入: '帮我计算 3 加 5'
📌 [LLM 决策] 需要调用 add 工具
📌 [工具执行] 调用 add({'a': 3, 'b': 5})
✅ 工具 add 返回: 8
📌 [LLM 节点] 收到工具结果,生成最终回复

(三)如何解读这个输出?

这里有两个非常重要的细节。

1. 消息链长度:4 vs 2

需要调用工具时,消息链是:

复制代码
HumanMessage
AIMessage(tool_calls)
ToolMessage
AIMessage(final)

所以一共有 4 条。

不需要调用工具时,比如用户只是说「你好」,消息链只有:

复制代码
HumanMessage
AIMessage

所以只有 2 条。这说明:LLM 会自己判断要不要进入工具调用循环。

2. 工具返回值不是 LLM 算出来的

以「帮我计算 3 加 5」为例。LLM 只生成了调用意图:

复制代码
请调用 add(a=3, b=5)

真正的计算发生在 Python 工具函数里:

复制代码
def add(a: int, b: int) -> int:
    return a + b

然后工具返回:

复制代码
8

最后 LLM 再把这个结果包装成自然语言:

复制代码
根据工具调用结果:8

这就是 Tool Calling 的核心分工:

LLM 负责想,工具负责做。

(四)真实跑一遍:在线 LLM + 在线天气 API

Mock 模式适合理解结构,但「关键词匹配 + 写死的天气字典」毕竟是演示。要确认这条链路真的能跑通,必须让两端都变成真实的:

  • -LLM 端:用真实的在线大模型(DeepSeek / OpenAI / Claude / Gemini),由它自己决定调哪个工具、生成什么参数;
  • -工具端:工具内部去打真实的在线 API,而不是返回写死的字典。

1. 工具改造:调用 Open-Meteo 真实天气接口

我们新增了一个 `get_weather_online` 工具,它通过两次真实 HTTP 请求拿到实时天气(Open-Meteo 是免费公开 API,无需 API Key):

python 复制代码
92:112:stages/stage2_tool_calling/01_tool_basics/main.py
@tool
def get_weather_online(city: str) -> str:
    """查询指定城市的实时天气,调用 Open-Meteo 在线公开 API(无需 API Key)。
    当用户询问某个城市当前的天气、气温、湿度、风力时使用此工具。

    Args:
        city: 城市名称,支持中英文,如 "北京"、"上海"、"Tokyo"、"London"
    """
    import httpx

    try:
        # 第一步:地理编码,把城市名解析为经纬度
        geo_resp = httpx.get(
            "https://geocoding-api.open-meteo.com/v1/search",
            params={"name": city, "count": 1, "language": "zh", "format": "json"},
            timeout=10,
        )
        geo_resp.raise_for_status()
        results = geo_resp.json().get("results")
        if not results:
            return f"未找到城市 '{city}' 的地理位置信息,请换一个更明确的城市名。"

注意工具的设计要点:自带容*。在线 API 会超时、会返回空、会 4xx/5xx,工具内部用 `try/except` 兜住所有异常并返回可读的字符串------因为这个返回值最终会变成 `ToolMessage` 喂回给 LLM,让它能基于「失败信息」继续决策,而不是让整条链路崩溃。

2. LLM 改造:真实模型自主决策

关键变化是 `llm_node` 不再做关键词匹配,而是把整段消息历史交给真实 LLM,让模型自己产出 `tool_calls`:

python 复制代码
353:371:stages/stage2_tool_calling/01_tool_basics/main.py
    llm_with_tools = llm.bind_tools(tools)
    tool_map = {t.name: t for t in tools}

    def llm_node(state: ToolDemoState) -> dict:
        """真实 LLM 决策节点 --- 把整段消息历史交给在线 LLM。"""
        messages = state["messages"]
        log_step(logger, "真实 LLM", "调用在线模型进行决策......")

        response = llm_with_tools.invoke(messages)

        if getattr(response, "tool_calls", None):
            for tc in response.tool_calls:
                log_step(
                    logger,
                    "LLM 决策",
                    f"自主选择工具 {tc['name']},参数 {tc['args']}",
                )

这里的 `llm` 由 `_get_online_llm()` 提供:它会优先读 `LLM_PROVIDER`,再根据 `.env` 里已配置的 API Key 自动挑选一个可用的在线 Provider,并用 `fallback_to_mock=False` 确保拿到的不是Mock。没有任何在线 Key 时,演示会优雅跳过并提示如何配置。

3. 真实运行效果

下面是接入真实 DeepSeek + 真实 Open-Meteo 后的实际输出(数字每次跑都不一样,因为是实时数据):

--- 演示 4: 真实 LLM + 真实在线工具 ---

用户: 上海现在天气怎么样?

──────────────────────────────────────────────────

助手: 上海现在的天气是阴天,气温为21.3°C,湿度为67%,风速为13.8 km/h。

消息链长度: 4

用户: 帮我算一下 128 加 256 等于多少

──────────────────────────────────────────────────

助手: 128 加 256 等于 384。

消息链长度: 4

用户: 东京和北京现在分别多少度?

──────────────────────────────────────────────────

助手: 东京现在的气温是22.4°C,天气为阴天,湿度62%,风速6.3 km/h。

北京现在的气温是24.4°C,天气为局部多云,湿度46%,风速2.2 km/h。

消息链长度: 5

三个细节值得品味:

  • 第一句的天气是真的。`21.3°C / 阴天 / 湿度 67%` 不是字典里写死的,而是 Open-Meteo 当下返回的真实观测------换个时间跑,数字就变了。
  • LLM 自己分清了算术和天气。「128 加 256」它选了 `add` 工具而不是瞎算,「天气」它选了 `get_weather_online`,完全没有我们写的 `if "天气" in content` 这类规则。
  • 「消息链长度 5」暴露了并行工具调用。问「东京和北京分别多少度」时,LLM 在一条 `AIMessage` 里同时发起了两个 `tool_calls`(东京 + 北京),工具节点一次执行两次、回填两条 `ToolMessage`,最后 LLM 汇总成一段话。这正是手写工具节点时用 `for tc in tool_calls` 循环、并用 `tool_call_id` 一一对应的意义所在。

> 想自己跑:在 `.env` 里填好任一在线 Provider 的 Key(如 `DEEPSEEK_API_KEY`),然后 `python stages/stage2_tool_calling/01_tool_basics/main.py` 即可。天气 API 无需任何 Key。

六、常见坑与排查

(一)坑 1:工具没有 bind_tools,LLM 永远不会调用工具

现象: 你定义了一堆 @tool,但 LLM 死活不调用,永远走「直接回复」分支。

原因: 定义工具不等于让 LLM 知道工具。@tool 只是把函数包装成 Tool 对象。

你还必须通过 llm.bind_tools([...]) 把工具的 JSON Schema 注入到每次 LLM 请求中。

如果没有 bind,LLM 的请求体里根本没有工具列表,自然无从调用。

解决方式:

python 复制代码
llm_with_tools = llm.bind_tools([add, multiply, search_weather])

# 后续使用 llm_with_tools,而不是原始 llm
llm_with_tools.invoke(messages)

(二)坑 2:docstring 太敷衍,LLM 选错工具或不选工具

**现象:**用户问天气,LLM 却调用了计算器。或者明明应该调用工具,LLM 却直接开始编答案。

**原因:**docstring 是 LLM 判断「这个工具该不该用、什么时候用」的重要依据。

如果你写成这样:

复制代码
"""查询。"""

LLM 很难判断这个工具到底适用于什么场景。

解决方式:

docstring 要写清楚:

  • 工具做什么
  • 什么时候使用
  • 必要时说明什么时候不使用

例如:

python 复制代码
@tool
def db_query(table: str) -> str:
    """查询业务数据库。适用于查找用户信息、订单记录、商品库存等结构化数据。
    不适用于:互联网公开信息、实时新闻(那些请用 web_search)。"""

一个好的 docstring,本质上就是写给 LLM 看的 API 文档。

(三)坑 3:ToolMessage 缺少 tool_call_id

**现象:**手写工具节点时,回填结果后报错:

复制代码
tool_call_id 不匹配

或者真实 LLM Provider 直接返回 400。

原因: OpenAI 等模型供应商通常要求每条 ToolMessage 必须带上对应的 tool_call_id

否则模型无法知道:这个工具结果对应的是哪一次工具调用?

解决方式:

构造 ToolMessage 时,务必把原始 tool call 里的 id 透传进去:

python 复制代码
ToolMessage(
    content=str(result),
    tool_call_id=tc["id"],
    name=tool_name,
)

不要自己随便生成 id。

七、工程化问题与生产级方案建议

(一)工程化问题

Tool Calling 跑通 Demo 不难,但要上生产,还需要考虑很多工程问题。

1. 工具描述质量决定调用准确率

docstring 是「给 LLM 看的 API 文档」。它需要像对外接口文档一样认真写,不能只写一句简单注释。

2. 参数类型要保持 JSON 兼容

LLM 生成的参数本质上是 JSON。因此参数类型最好控制在:

复制代码
str / int / float / bool / list / dict

如果你需要传递复杂对象,就要提前序列化。

3. 工具执行一定可能失败

工具调用会遇到各种失败场景:

  • 超时
  • 网络错误
  • 权限不足
  • 参数非法
  • 第三方 API 不稳定

所以生产环境里,工具节点必须处理异常,并把错误信息以可控方式返回给 LLM。

4. 有副作用的工具必须加安全控制

例如:

  • 写数据库
  • 发邮件
  • 创建订单
  • 转账
  • 删除数据

这些工具都属于有副作用操作,必须加入:

  • 权限校验
  • 参数校验
  • 二次确认
  • 审计日志

不能让 LLM 在没有保护的情况下直接执行高风险操作。

(二)生产级方案建议

如果要把 Tool Calling 用在真实 Agent 项目中,建议至少考虑以下三点。

1. 可观测性

给每次工具调用打点,记录:

  • tool_name
  • 入参
  • 返回值摘要
  • 耗时
  • 成功 / 失败
  • 错误类型

Tool Calling 是 Agent 最容易出问题的环节。没有埋点,就等于盲飞。

2. 参数白名单

如果你实现了类似 calculator 的工具,千万不要直接使用不受控的 eval。生产环境里应该:

  • 使用安全表达式解析库
  • 做字符白名单
  • 限制可调用函数
  • 限制表达式长度

否则很容易变成远程代码执行漏洞。

3. 工具版本与灰度

工具的 docstring、Schema、参数类型一旦变化,LLM 的行为就可能发生漂移。因此工具定义也应该纳入版本管理。

推荐做法是:

  • 工具定义版本化
  • 新工具先灰度
  • 记录不同版本下的调用命中率和失败率
  • 观察稳定后再全量放开

八、总结

Tool Calling 是 LangGraph Agent 的核心能力之一。这一篇我们从底层视角手写了完整链路:

复制代码
@tool 定义工具
    ↓
bind_tools 注册工具
    ↓
LLM 生成 tool_calls
    ↓
工具节点执行函数
    ↓
ToolMessage 回传结果
    ↓
LLM 整合最终回复

最终形成一个完整闭环:

复制代码
LLM 决策 → 工具执行 → 结果回传 → LLM 总结

理解了这个循环,你就理解了大多数 Agent 框架背后的基本运行方式。

下一篇预告

你可能已经发现:

agent ↔ tools 这个循环结构,几乎是所有 Agent 的标配。

如果每次都手写,会比较啰嗦。下一篇《构建你的第一个 Tool Agent:从零理解 ReAct 循环》,我们会把这个循环升级为标准的 ReAct 模式,并看看 create_react_agent 一行代码背后到底封装了什么。

系列导航: LangGraph从零构建生产级 AI Agent 平台的递进式学习项目

相关推荐
Aloudata2 小时前
传统 BI 指标向语义层迁移实操指南与避坑详解
数据分析·agent·bi·语义层·语义编织
半亩码田2 小时前
06.01-06.07 AI大事件速览 | 扣子3.0、Hinton警告AI有意识、千问3.7-Plus
人工智能
MacroZheng2 小时前
这款DeepSeek V4终端编程神器,在GitHub上火了!
人工智能·后端·deepseek
圣殿骑士-Khtangc2 小时前
多智能体协作架构实战:从单 Agent 到 Agent Swarm 的范式跃迁
人工智能
ShyanZh2 小时前
【AI】认识Multica-本地运行时与云端编排的多智能体平台
人工智能·ai·multica
GitCode官方2 小时前
AtomGit 5月:下载中心上线;AtomCode Air 新品发布会顺利开展;AtomGit AI 荣获「昇腾开源合作杰出团队奖」
人工智能·开源·atomgit
是Dream呀2 小时前
通道注意力机制|Channel Attention Neural Network
人工智能·python·深度学习
searchforAI2 小时前
培训视频转文字后怎么做团队复盘?把本地视频整理成AI笔记的实操方案
人工智能·笔记·ai·whisper·音视频·语音识别·腾讯会议