第04章:AI Tools 工具调用 —— 让 AI 伸出“手“与世界交互

6 阅读32分钟

本章目标:理解工具调用的完整机制,掌握用 @tool 装饰器创建工具、Pydantic 参数验证、工具与 LLM 绑定,以及安全执行工具的原则。

前期回顾


写给零基础开发者:3 个问题,5 分钟搞懂 Tool Calling

在深入代码之前,先用最简单的语言回答三个最重要的问题:

❓ 问题一:什么是工具调用(Tool Calling)?

Tool Calling 是让 LLM 在回答问题时,不再局限于"凭记忆编造答案",而是能够主动调用你写好的函数来获取真实数据或执行真实操作,最终基于真实结果给出准确回答。

换一种说法:你写了一批 Python 函数(查天气、查数据库、发邮件……),然后把这些函数"介绍"给 LLM。当用户问 LLM 问题时,LLM 会自动判断需不需要调用某个函数,如果需要,它会告诉你"请帮我调用 get_weather('北京')",你的代码执行后把结果告诉 LLM,LLM 再给出最终回答。

一句话总结:Tool Calling = LLM 的大脑 + 你写的函数(工具)= AI 既会思考,又会行动

❓ 问题二:为什么需要工具调用?LLM 自己不行吗?

LLM 有三个根本性的短板,只靠 LLM 无法解决:

短板举例后果
知识有截止日期问"今天比特币多少钱?"LLM 凭训练数据猜一个,可能差十倍
不擅长精确计算问"12345 × 6789 等于多少?"LLM 可能算错(它是文字预测机器,不是计算器)
无法访问你的业务数据问"我的订单 #12345 发货了吗?"LLM 根本不知道你数据库里有什么

加上工具之后,这三个短板全部消失:

# 没有工具:LLM 瞎猜
用户:"上海今天气温是多少?"
LLM:"上海今天气温约为 25°C。"   # ← 纯编造,不知道今天几号

# 有了工具:LLM 调用 get_weather(),拿到真实数据再回答
LLM 内部:[调用 get_weather(city="上海")]
→ 工具返回:"上海当前:小雨,气温 18°C,湿度 82%"
LLM:"上海今天下小雨,气温 18°C,比较凉,出门记得带伞和薄外套。"  # ← 基于真实数据

❓ 问题三:什么时候我的项目需要加工具?

一分钟自测:对照以下 4 个问题,只要有一个答案为"是",就需要工具:

1. 我的 AI 需要知道"当前"的数据吗?(实时天气、股价、汇率、订单状态……)
□ 2. 我的 AI 需要访问我自己的数据库或文件吗?(用户数据、业务记录……)
□ 3. 我的 AI 需要执行某些操作吗?(发邮件、写文件、调用接口……)
□ 4. 我的 AI 需要做精确计算吗?(金融计算、统计分析、单位换算……)

如果 4 个都是"否"——例如你只是做一个写故事、翻译文章、总结文本的 AI——那么不需要工具,直接用 LLM 就好。


新手快速入门:10 分钟完成一个完整的 Tool Calling 示例

理论说完了,我们直接上手。以下是从零开始完成一个工具调用的最简化完整流程,5 步搞定:

第一步:写一个工具函数

from langchain_core.tools import tool
from pydantic import BaseModel, Field

# 定义工具的输入参数(Pydantic 负责验证类型)
class WeatherInput(BaseModel):
    city: str = Field(description="城市名称,如'北京'、'上海'")

# @tool 装饰器:把普通函数变成 LLM 可以调用的工具
@tool(args_schema=WeatherInput)
def get_weather(city: str) -> str:
    """
    获取城市当前天气。当用户询问天气、温度、是否需要带伞时调用此工具。
    """
    # 实际项目中这里调用真实 API,这里用模拟数据演示
    mock_data = {"北京": "晴天 22°C", "上海": "多云 26°C", "广州": "小雨 28°C"}
    return mock_data.get(city, f"暂无 {city} 的天气数据")

第二步:不经过 LLM,直接测试工具是否正常工作

# 这一步非常关键!先验证工具本身,不要一上来就跑全链路
result = get_weather.invoke({"city": "北京"})
print(result)  # 输出:晴天 22°C

result = get_weather.invoke({"city": "火星"})
print(result)  # 输出:暂无 火星 的天气数据

💡 为什么要先单独测工具? 如果全链路跑失败了,你不知道是工具有 bug,还是 LLM 调用姿势不对,还是 API Key 配置问题。先单独测工具,把问题逐层隔离。

第三步:把工具绑定给 LLM

import os
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage

llm = ChatOpenAI(
    model="qwen-plus",
    base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
    api_key=os.getenv("DASHSCOPE_API_KEY"),
)

# bind_tools:告诉 LLM"你有这个工具可以用"
llm_with_tools = llm.bind_tools([get_weather])

# 发送问题,LLM 会决定是否调用工具
response = llm_with_tools.invoke([HumanMessage(content="北京今天热吗?")])

# 查看 LLM 的决策:它调用了什么工具?传了什么参数?
print(response.tool_calls)
# 输出:[{'name': 'get_weather', 'args': {'city': '北京'}, 'id': 'call_xxx'}]

第四步:执行工具并把结果返回给 LLM

from langchain_core.messages import ToolMessage

# 执行 LLM 选择的工具
tool_call = response.tool_calls[0]
tool_result = get_weather.invoke(tool_call["args"])  # → "晴天 22°C"

# 把工具结果包装成 ToolMessage,发回给 LLM
messages = [
    HumanMessage(content="北京今天热吗?"),
    response,  # LLM 的工具调用指令
    ToolMessage(content=tool_result, tool_call_id=tool_call["id"]),  # 工具执行结果
]

final_response = llm_with_tools.invoke(messages)
print(final_response.content)
# 输出:"北京今天晴天,气温 22°C,不算很热,是个适合外出的好天气。"

第五步:验证工具的 Schema(LLM 看到的工具描述)

import json

# 打印 LLM 看到的工具信息——如果工具总不被调用,先检查这里
print(f"工具名:{get_weather.name}")
print(f"工具描述:{get_weather.description}")
print(f"参数Schema:{json.dumps(get_weather.args_schema.model_json_schema(), ensure_ascii=False, indent=2)}")

🎉 恭喜!你已经完成了一个完整的 Tool Calling 流程。 后面的章节将深入讲解每一步的原理、最佳实践和生产级代码。


一、工具调用解决了什么问题?

1.1 LLM 的天然局限性

LLM(大语言模型)本质上是一个"文字预测机器":输入一段文字,输出下一段文字。这个设计在处理知识型问答时很出色,但它有几个根本性的局限:

局限具体表现原因
没有实时数据不知道今天的天气、股价、新闻训练数据有截止日期,无法联网
数学计算不精确复杂运算(如 1234567 * 891011)容易出错LLM 按 token 预测,不是真正执行算术
无法访问文件读不了本地文档、数据库记录推理时没有文件系统访问权限
无法调用外部服务发不了邮件、下不了订单纯文字生成,没有 I/O 能力
知识存在截止日期不了解近期发布的软件版本、新事件训练完成后知识就冻结了

简单说:LLM 是一位才华横溢的顾问,但他被关在一个没有网络、没有电话、没有文件的房间里。 他只能基于训练时学到的知识作答,一旦问题需要"查一下""算一下""做一件事",他就无能为力——或者更危险地,他会凭空编造一个听起来合理的答案。

1.2 没有 Tools 会怎样?——LLM 的"幻觉"问题

来看一个具体例子。假设你问 LLM:"北京今天天气怎么样?"

❌ 没有 Tools 的情况(纯 LLM):

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

LLM(无工具):北京今天天气晴朗,气温约 18-25°C,微风,空气质量良好,
              非常适合户外活动。下午可能有轻微降温,建议傍晚出门时
              带件薄外套。

这个回答听起来非常合理,但它完全是 LLM 根据北京的历史天气模式瞎猜出来的!它不知道今天是什么日期,更没有查询任何天气数据。如果今天其实是暴雨天,用户却相信了这个回答……

这就是 LLM 的"幻觉"(Hallucination):模型在不确定时不会说"我不知道",而是会生成一个听起来合理但可能完全错误的回答。

✅ 有 Tools 的情况(LLM + 工具调用):

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

LLM(有工具):[调用工具] get_weather(city="北京")
工具返回:北京当前天气:小雨,温度 12°C,湿度 88%

LLM(收到工具结果后):北京今天天气不太好,有小雨,气温只有 12°C,
                      湿度较高,出门记得带伞并穿厚一点。

这次 LLM 的回答基于真实数据,准确可靠。

1.3 没有 LLM 会怎样?——硬编码路由的脆弱

那反过来,如果只有代码逻辑、没有 LLM 呢?比如你用传统的 if-else 来判断用户想做什么:

❌ 没有 LLM 的纯硬编码路由:

def handle_request(user_input: str):
    if "天气" in user_input:
        city = extract_city(user_input)  # 如果用户说"帝都今天咋样"就提取不到
        return get_weather(city)
    elif "计算" in user_input or "等于" in user_input:
        expr = extract_expression(user_input)  # "一百二十三加四百五十六"怎么处理?
        return calculator(expr)
    else:
        return "抱歉,我不理解您的问题"

这种方式的问题:

  • 脆弱:用户换个说法("帝都"代替"北京")就失效了
  • 难维护:每增加一个功能就要修改 if-else 树
  • 无法泛化:无法处理"北京和上海哪个更热?"这种需要多次调用的复合问题
  • 无语义理解:无法理解"最近北京适合户外跑步吗?"这种需要推理的问题

✅ LLM + Tools 的组合优势:

LLM 负责理解意图、决定路由,Tools 负责执行具体动作、获取真实数据。两者分工明确:

LLM 擅长:理解自然语言 → 知道用户要做什么
Tools 擅长:执行确定性操作 → 准确地做那件事

这就像一位有经验的客服经理(LLM)搭配一套专业系统工具(Tools):经理理解客户需求并决定调用哪个系统,系统精确地完成操作。两者缺一不可。

1.4 三种方案的核心能力对比

用一张表彻底说清楚"只用 LLM"、"只用代码"、"LLM + Tools" 三种方案的差别:

能力维度只用 LLM(无工具)只用代码(无 LLM)LLM + Tools(推荐)
自然语言理解✅ 出色,理解各种说法❌ 只认固定格式✅ 出色
实时数据获取❌ 不行,只有训练数据✅ 可以,只要写对 if-else✅ 可以
精确计算❌ 容易出错✅ 精确✅ 精确
复杂推理/规划✅ 出色❌ 需要大量手工逻辑✅ 出色
多工具协调❌ 无法实际执行❌ 需要硬编码所有路由✅ LLM 自动规划调用顺序
扩展新功能只需修改 prompt需要修改代码+重测只需新增一个 @tool 函数
可控性和安全❌ 无法约束执行边界✅ 代码逻辑完全可控✅ LLM 决策 + 代码控制执行
开发成本高(需要大量 NLP 处理)中(工具写一次,复用无限次)

结论:LLM + Tools 是一种"扬长避短"的架构——让 LLM 做它最擅长的(理解语言、规划步骤),让代码工具做它最擅长的(精确执行、实时数据)。

1.5 工具调用的完整交互流程

工具调用(Tool Calling / Function Calling) 让 LLM 能够"决定调用哪个工具",然后由你的代码执行,再将结果返回给 LLM,最终 LLM 基于真实数据给出回答。

一个比喻:LLM 像一位博学的顾问,工具调用让它能够从图书馆查资料、用计算器做计算。顾问决定用什么工具,但真正的"动手"还是你(代码)在做。

关键点:LLM 不执行工具,它只是"决策者"。实际执行由你的代码负责,这意味着你对工具的安全性、权限、超时等完全掌控。


二、工具调用的适用场景 —— 什么时候该用 Tool?

2.1 六大典型使用场景

在你的项目中,遇到以下这些需求时,都应该考虑用 Tools:

场景类别典型需求示例 Tool
实时数据查询天气、股价、汇率、新闻头条、航班状态get_weather(city), get_stock_price(symbol)
精确计算数学运算、统计分析、财务计算、单位换算calculator(expr), unit_converter(value, from, to)
文件/数据库读写读配置文件、查订单记录、写日志、更新数据read_file(path), query_db(sql), write_report(content)
外部 API 调用发邮件、发短信、支付下单、推送通知send_email(to, subject, body), create_order(items)
代码执行/测试运行脚本、验证代码结果、执行测试用例run_python(code), run_shell_command(cmd)
系统操作查日志、管理定时任务、监控系统状态tail_log(file, lines), check_service_status(name)

2.2 什么时候不需要 Tools?

并非所有 LLM 应用都需要 Tools。以下场景中,LLM 本身就能很好地完成任务,强行加 Tools 反而增加复杂度:

  • 纯知识问答:"Python 的 GIL 是什么?" — LLM 知识库足够回答
  • 文本摘要/改写:"帮我把这段话浓缩成三句话" — 只需处理给定的文本
  • 创意写作:写故事、写诗、写广告文案 — 不需要外部数据
  • 代码生成:生成一段排序算法 — 不需要运行,只需生成
  • 翻译:中英互译 — 语言转换,不需要外部信息
  • 情感分析/文本分类:对评论进行分类 — 纯推理任务

判断标准:如果完成任务需要"查到"某些实时/私有数据,或需要"做到"某件影响外部系统的事,就需要 Tools。

2.3 决策流程图:我需要 Tool 吗?

在这里插入图片描述

2.4 一个形象的比喻

情形比喻能力
只用 LLM,无 Tools才华横溢的顾问被关在空房间里,只能凭记忆作答知识丰富但与世界隔绝,可能编造答案
只用硬编码,无 LLM功能强大的自动化脚本,但只认固定格式的指令精确执行但无法理解自然语言
LLM + Tools顾问手边有电话、电脑、资料库,随时可查询和操作理解意图 + 精确执行,两全其美

2.5 实战:一个电商客服 AI 需要哪些工具?

下面用一个真实场景帮你理解如何在项目中规划工具:

场景:你要给电商平台开发一个 AI 客服助手,能回答用户关于订单、商品、优惠券的问题。

第一步:列出用户可能问的所有问题类型

1. "我的订单 #12345 发货了吗?"   → 需要查订单数据库
2. "这件衣服有L码吗?"            → 需要查商品库存
3. "我有什么优惠券可以用?"        → 需要查用户优惠券表
4. "1000 元的商品打 8.5 折是多少?" → 需要精确计算
5. "你们的退换货政策是什么?"      → LLM 知识库可以直接回答(无需工具)
6. "帮我取消订单 #12345"          → 需要执行数据库更新操作

第二步:为每个"需要工具"的问题类型设计一个工具

@tool
def query_order_status(order_id: str) -> str:
    """查询订单的物流状态。当用户询问订单是否发货、快递状态、预计到达时间时调用。"""
    # 查数据库...

@tool
def check_product_stock(product_id: str, size: str = None) -> str:
    """查询商品库存。当用户询问某商品是否有货、特定尺码是否还有时调用。"""
    # 查商品库...

@tool
def get_user_coupons(user_id: str) -> str:
    """查询用户的可用优惠券列表。当用户询问有哪些折扣、优惠券时调用。"""
    # 查优惠券表...

@tool
def calculate_discount(original_price: float, discount_rate: float) -> str:
    """计算折扣后的价格。当用户询问打折后价格、实付金额时调用。"""
    result = original_price * discount_rate
    return f"{original_price} 元打 {discount_rate*10} 折 = {result:.2f} 元"

@tool
def cancel_order(order_id: str, reason: str) -> str:
    """取消指定订单。当用户明确要求取消订单时调用。注意:已发货订单无法取消。"""
    # 执行取消逻辑...

第三步:LLM 自动路由用户问题到对应工具

# 绑定所有工具,LLM 会根据问题自动选择
llm_with_tools = llm.bind_tools([
    query_order_status, check_product_stock,
    get_user_coupons, calculate_discount, cancel_order
])

# 用户问:"我的订单 #12345 有没有发货?"
# → LLM 自动调用 query_order_status(order_id="12345")

# 用户问:"800 元的商品打七折是多少?"
# → LLM 自动调用 calculate_discount(original_price=800, discount_rate=0.7)

# 用户问:"你们的客服电话是多少?"
# → LLM 直接回答(这是知识问答,不需要工具)

这个例子清晰展示了:工具规划不是技术问题,而是业务分析问题——先把用户需求分类,识别哪些需要实时数据/执行操作,就能知道需要哪些工具。


三、开发工具的完整流程 —— 从零到生产

3.1 整体开发流程

从产生一个"我需要工具"的想法,到工具安全稳定地在生产环境运行,通常分为六个步骤:

Step 1:需求分析 —— 明确工具要做什么

在写任何代码之前,先回答这三个问题:

输入是什么? — 工具需要哪些参数才能完成任务?

  • 例:天气查询需要城市名,可选温度单位

输出是什么? — 工具返回什么格式的数据?

  • 例:返回一句描述天气的自然语言字符串,方便 LLM 理解

副作用是什么? — 工具会修改什么、调用什么外部服务?

  • 例:只读查询(无副作用),还是会发邮件(有副作用,需要权限控制)

💡 设计原则:每个工具只做一件事(单一职责)。如果一个工具又查天气又发邮件,LLM 就很难准确理解它的用途。

Step 2:接口设计 —— Pydantic 参数 + 精心撰写 docstring

这一步是整个工具开发中最关键的一步,因为 LLM 完全依赖 docstring 和参数描述来决定:

  1. 什么时候调用这个工具?(工具的 docstring)
  2. 每个参数应该传什么值?(Field 的 description)
from pydantic import BaseModel, Field
from langchain_core.tools import tool

# ✅ 好的接口设计:描述清晰、参数有意义
class WeatherInput(BaseModel):
    city: str = Field(
        description="城市名称,支持中文城市名,如'北京'、'上海'、'广州'"
    )
    unit: str = Field(
        default="celsius",
        description="温度单位:'celsius'(摄氏度,默认)或 'fahrenheit'(华氏度)"
    )

@tool(args_schema=WeatherInput)
def get_weather(city: str, unit: str = "celsius") -> str:
    # 当用户询问天气、温度、是否需要带伞、适不适合出行时调用此工具。
    # 返回天气状况、温度和湿度信息。
    ...
# ❌ 糟糕的接口设计:描述模糊,LLM 不知道何时调用,也不知道传什么
@tool
def weather(q) -> str:
    # 获取天气    <-- 太模糊!LLM 不知道 q 是什么
    ...

docstring 写作要点

  • 第一句话说清工具的核心功能
  • 说明什么情况下应该调用(触发条件)
  • 说明返回内容是什么
  • 如有限制,明确说明(如"只支持国内城市")

Step 3:功能实现 —— 三个必须遵守的原则

@tool(args_schema=WeatherInput)
def get_weather(city: str, unit: str = "celsius") -> str:
    # 原则1:安全检查放在最前面
    if not city or len(city) > 50:
        return "错误:城市名称无效"
    
    try:
        # 原则2:真正的业务逻辑
        result = call_weather_api(city)
        
        # 原则3:始终返回字符串(LLM 只能处理文字)
        return f"{city}当前天气:{result['condition']},温度 {result['temp']}°C"
        
    except Exception as e:
        # 捕获所有异常,返回错误字符串而不是抛出异常
        # 如果工具抛异常,整个对话就会中断;返回错误字符串让 LLM 能优雅处理
        return f"查询失败:{str(e)}"

三个核心原则

  1. 安全检查先行:在执行任何操作前验证输入
  2. 捕获所有异常:工具不应该抛异常,应该返回包含错误信息的字符串
  3. 返回值必须是字符串:LLM 只能处理文字,即使是数字也要 str() 转换

Step 4:单元测试 —— 不经过 LLM 直接测试工具逻辑

单元测试的目的是验证工具本身的逻辑,与 LLM 无关。直接调用 tool.invoke() 即可:

def test_get_weather():
    # 正常情况
    result = get_weather.invoke({"city": "北京"})
    assert "北京" in result
    assert "°C" in result
    print(f"✅ 正常查询:{result}")
    
    # 不支持的城市
    result = get_weather.invoke({"city": "火星"})
    assert "暂无" in result or "错误" in result
    print(f"✅ 未知城市:{result}")
    
    # 华氏度
    result = get_weather.invoke({"city": "上海", "unit": "fahrenheit"})
    assert "°F" in result
    print(f"✅ 华氏度:{result}")

test_get_weather()

💡 好处:单元测试运行快(不需要调用 LLM API),能精确定位工具逻辑的 bug,节省调试时间和 API 费用。

Step 5:集成测试 —— 验证 LLM 能正确路由到工具

集成测试验证的是LLM 的工具选择是否正确

def test_llm_routes_to_weather_tool():
    llm = ChatOpenAI(model="qwen-plus", ...)
    llm_with_tools = llm.bind_tools([get_weather, calculator])
    
    # 问天气 → 应该路由到 get_weather
    response = llm_with_tools.invoke([HumanMessage(content="北京今天热吗?")])
    
    assert response.tool_calls, "LLM 应该调用工具,但没有调用"
    assert response.tool_calls[0]["name"] == "get_weather", \
        f"期望调用 get_weather,实际调用了 {response.tool_calls[0]['name']}"
    assert response.tool_calls[0]["args"]["city"] == "北京"
    print("✅ LLM 正确路由到 get_weather 工具")

Step 6:边界测试 —— 测试异常路径

def test_edge_cases():
    # 空输入
    result = get_weather.invoke({"city": ""})
    assert "错误" in result or "无效" in result
    
    # 超长输入(潜在的注入攻击)
    result = get_weather.invoke({"city": "A" * 1000})
    assert "错误" in result or "无效" in result
    
    # 特殊字符
    result = get_weather.invoke({"city": "北京'; DROP TABLE weather;--"})
    # 应该返回错误或正常处理,不应该崩溃
    assert isinstance(result, str)
    
    print("✅ 边界测试全部通过")

3.2 工具设计检查清单 ✅

在将工具投入使用前,逐项检查:

功能设计

  • 工具只做一件事(单一职责)
  • 函数名以动词开头,见名知意(get_, search_, create_, send_
  • docstring 第一句话清楚说明工具用途和触发条件
  • 所有参数都有详细的 Field description

安全性

  • 对输入做了合法性校验(类型、长度、格式)
  • 文件操作限制在沙箱目录内
  • 命令执行有白名单限制
  • 设置了超时保护(防止卡死)

健壮性

  • 所有代码路径都用 try-except 包裹
  • 异常时返回错误字符串(不抛出异常)
  • 返回值始终是字符串类型

测试

  • 单元测试覆盖正常路径
  • 单元测试覆盖错误路径
  • 集成测试验证 LLM 能正确路由
  • 边界测试验证异常输入不会崩溃

四、工具验证与调试技巧

4.1 独立测试工具(不经过 LLM)

调试工具时,最高效的方式是绕过 LLM 直接调用工具,这样可以精确定位问题:

# 方式1:直接调用(像普通函数一样)
result = calculator("2 + 3 * 4")
print(result)  # 输出:2 + 3 * 4 = 14

# 方式2:通过 .invoke() 调用(传字典,模拟 LLM 调用方式)
result = calculator.invoke({"expression": "2 + 3 * 4"})
print(result)  # 输出:2 + 3 * 4 = 14

# 方式3:测试错误处理
result = calculator.invoke({"expression": "1 / 0"})
print(result)  # 输出:错误:除数不能为零

💡 tool.invoke() 和直接调用的区别invoke() 会触发 LangChain 的工具调用管道(包括参数验证),更接近 LLM 实际调用时的行为。建议测试时两种方式都用。

4.2 检查工具元数据

LLM 是通过工具的 Schema 来理解工具的,调试时可以打印出来检查 LLM"看到的"工具信息:

import json

def inspect_tool(t):
    print(f"{'='*50}")
    print(f"工具名称:{t.name}")
    print(f"工具描述:{t.description}")
    print(f"参数 Schema:")
    schema = t.args_schema.model_json_schema()
    print(json.dumps(schema, ensure_ascii=False, indent=2))
    print()

inspect_tool(calculator)
inspect_tool(get_weather)

输出示例:

==================================================
工具名称:calculator
工具描述:计算数学表达式的值。支持基本运算:加(+)、减(-)、乘(*)、除(/)、幂(**)、括号()。
参数 Schema:
{
  "title": "CalculatorInput",
  "type": "object",
  "properties": {
    "expression": {
      "description": "要计算的数学表达式,如 '2 + 3 * 4' 或 '(10 + 5) / 3'",
      "type": "string"
    }
  },
  "required": ["expression"]
}

4.3 检查 LLM 的工具调用决策

当 LLM 绑定工具后,打印 response.tool_calls 可以看到 LLM 的"决策过程":

llm_with_tools = llm.bind_tools([calculator, get_weather, unit_converter])
response = llm_with_tools.invoke([HumanMessage(content="北京今天热吗?")])

# 检查 LLM 是否决定调用工具
if response.tool_calls:
    print("LLM 决定调用以下工具:")
    for tc in response.tool_calls:
        print(f"  - 工具名: {tc['name']}")
        print(f"    参数:   {tc['args']}")
        print(f"    调用ID: {tc['id']}")
else:
    print("LLM 直接回答,未调用工具:")
    print(response.content)

4.4 常见问题与解决方案

问题现象可能原因解决方法
LLM 从不调用工具工具 docstring 描述不够清晰,LLM 不知道什么时候应该用在 docstring 中明确说明触发条件,如"当用户询问XX时调用"
LLM 调用了错误的工具多个工具的描述有歧义或功能重叠区分每个工具的边界,在描述中明确"本工具用于XX,不用于YY"
工具参数填写错误Field description 不够具体,LLM 不知道该传什么格式在 Field 中添加具体示例,如 description="城市名称,如'北京'、'上海'"
工具抛出异常,对话中断工具内部没有捕获异常用 try-except 包裹所有操作,返回错误字符串而非抛出异常
LLM 忽略工具结果,用旧知识回答ToolMessage 的 tool_call_id 与请求不匹配确保 ToolMessage(tool_call_id=tool_call["id"]) 中的 id 对应正确
工具偶尔不被调用LLM 的不确定性,或问题描述不够明确在系统提示中添加"当需要XX时,请优先使用工具"

4.5 调试检查清单

当工具行为不符合预期时,按以下顺序排查:

1. 直接调用工具(绕过 LLM),确认工具本身逻辑正确
□ 2. 打印 tool.name 和 tool.description,确认 LLM 看到的描述是否准确
□ 3. 打印 tool.args_schema.model_json_schema(),确认参数 Schema 正确
□ 4. 打印 response.tool_calls,看 LLM 是否调用了工具及传了什么参数
□ 5. 检查 ToolMessage 的 tool_call_id 是否与 response.tool_calls[i]["id"] 一致
□ 6. 如果 LLM 不调用工具,检查问题描述是否明确触发了工具的使用条件
□ 7. 如果 LLM 调用了错误工具,检查多个工具的描述是否存在歧义

五、Step 1:创建工具(01_basic_tools.py

用 Pydantic 定义参数(推荐方式)

代码文件 lessons/04_tools/01_basic_tools.py 中定义了三个带 Pydantic 参数验证的工具:

import json
import os
from langchain_core.messages import HumanMessage, ToolMessage
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
from pydantic import BaseModel, Field


# ── 工具1:计算器(Pydantic 参数验证) ───────────────────────────
class CalculatorInput(BaseModel):
    """计算器工具的参数模型"""
    expression: str = Field(description="要计算的数学表达式,如 '2 + 3 * 4' 或 '(10 + 5) / 3'")


@tool(args_schema=CalculatorInput)
def calculator(expression: str) -> str:
    """
    计算数学表达式的值。
    支持基本运算:加(+)、减(-)、乘(*)、除(/)、幂(**)、括号()。
    不支持三角函数等高级函数。
    """
    try:
        # 只允许数字和运算符,防止代码注入
        allowed_chars = set("0123456789+-*/().**% \t\n")
        if not all(c in allowed_chars for c in expression):
            return f"错误:表达式包含不允许的字符"

        result = eval(expression, {"__builtins__": {}})   # 限制内置函数
        return f"{expression} = {result}"
    except ZeroDivisionError:
        return "错误:除数不能为零"
    except Exception as e:
        return f"计算错误:{str(e)}"


# ── 工具2:天气查询(Pydantic 参数验证) ─────────────────────────
class WeatherInput(BaseModel):
    """天气查询工具的参数模型"""
    city: str = Field(description="城市名称,如'北京'、'上海'、'广州'")
    unit: str = Field(
        default="celsius",
        description="温度单位:celsius(摄氏度)或 fahrenheit(华氏度)"
    )


@tool(args_schema=WeatherInput)
def get_weather(city: str, unit: str = "celsius") -> str:
    """
    获取指定城市的当前天气信息。
    返回天气状况、温度和湿度。
    注意:这是一个模拟工具,实际项目中应调用真实天气API。
    """
    weather_data = {
        "北京": {"condition": "晴天", "temp_celsius": 22, "humidity": 45},
        "上海": {"condition": "多云", "temp_celsius": 26, "humidity": 70},
        "广州": {"condition": "小雨", "temp_celsius": 28, "humidity": 85},
        "成都": {"condition": "阴天", "temp_celsius": 20, "humidity": 75},
        "深圳": {"condition": "晴天", "temp_celsius": 29, "humidity": 65},
    }

    data = None
    for key, val in weather_data.items():
        if key in city or city in key:
            data = val
            city = key
            break

    if not data:
        return f"抱歉,暂无{city}的天气数据"

    temp = data["temp_celsius"]
    if unit == "fahrenheit":
        temp = temp * 9 / 5 + 32              # 转换为华氏度
        unit_str = "°F"
    else:
        unit_str = "°C"

    return (
        f"{city}当前天气:{data['condition']},"
        f"温度 {temp}{unit_str},"
        f"湿度 {data['humidity']}%"
    )


# ── 工具3:单位转换(Pydantic 参数验证) ─────────────────────────
class UnitConvertInput(BaseModel):
    """单位转换工具的参数模型"""
    value: float = Field(description="要转换的数值")
    from_unit: str = Field(description="原始单位(如:km, m, kg, lb, c, f)")
    to_unit: str = Field(description="目标单位(如:km, m, kg, lb, c, f)")


@tool(args_schema=UnitConvertInput)
def unit_converter(value: float, from_unit: str, to_unit: str) -> str:
    """
    进行单位换算,支持长度、重量、温度的转换。
    支持的单位:km(千米), m(米), cm(厘米), kg(千克), g(克), lb(磅), c(摄氏), f(华氏)
    """
    conversions = {
        ("km", "m"): lambda x: x * 1000,
        ("m", "km"): lambda x: x / 1000,
        ("m", "cm"): lambda x: x * 100,
        ("cm", "m"): lambda x: x / 100,
        ("km", "cm"): lambda x: x * 100000,
        ("kg", "g"): lambda x: x * 1000,
        ("g", "kg"): lambda x: x / 1000,
        ("kg", "lb"): lambda x: x * 2.20462,
        ("lb", "kg"): lambda x: x / 2.20462,
        ("c", "f"): lambda x: x * 9 / 5 + 32,
        ("f", "c"): lambda x: (x - 32) * 5 / 9,
    }

    key = (from_unit.lower(), to_unit.lower())
    if key not in conversions:
        return f"不支持从 {from_unit}{to_unit} 的转换"

    result = conversions[key](value)
    return f"{value} {from_unit} = {result:.4f} {to_unit}"

docstring 的重要性:LLM 通过读取工具的描述(docstring)来判断何时使用哪个工具。描述越清晰,LLM 的工具选择就越准确。

查看工具元数据

def demo_tool_inspection():
    """查看工具的属性(名称、描述、参数 Schema)。"""
    tools = [calculator, get_weather, unit_converter]
    for t in tools:
        print(f"工具名称:{t.name}")
        print(f"工具描述:{t.description}")
        # Pydantic v2 用 model_json_schema(),不是旧版的 .schema()
        print(f"参数Schema:{json.dumps(t.args_schema.model_json_schema(), ensure_ascii=False, indent=2)}")
        print()

⚠️ Pydantic v2 注意:用 t.args_schema.model_json_schema() 获取 JSON Schema,不能使用旧版 Pydantic v1 的 .schema() 方法(已废弃)。


六、Step 2:将工具绑定到 LLM(01_basic_tools.py

创建工具后,需要告诉 LLM"你有这些工具可以使用":

def create_llm() -> ChatOpenAI:
    """创建百炼 API ChatOpenAI 实例。"""
    return ChatOpenAI(
        model="qwen-plus",
        base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
        api_key=os.getenv("DASHSCOPE_API_KEY"),
    )

def demo_llm_with_tools(llm: ChatOpenAI):
    """将工具绑定到 LLM,让 LLM 自动决定调用哪个工具。"""
    tools = [calculator, get_weather, unit_converter]

    # bind_tools:将工具定义注入 LLM,LLM 会在需要时主动选择调用
    llm_with_tools = llm.bind_tools(tools)

    # 构建工具映射(用于后续执行工具)
    tool_map = {t.name: t for t in tools}

    questions = [
        "北京今天天气怎么样?",
        "计算 (123 + 456) * 2 等于多少?",
        "5千克等于多少磅?",
    ]

    for question in questions:
        print(f"用户问题:{question}")

        messages = [HumanMessage(content=question)]
        response = llm_with_tools.invoke(messages)

        if response.tool_calls:
            # LLM 返回了工具调用指令(不是最终答案)
            print(f"LLM 决定调用工具:{[tc['name'] for tc in response.tool_calls]}")

            # 执行工具调用
            tool_messages = []
            for tool_call in response.tool_calls:
                tool_name = tool_call["name"]
                tool_args = tool_call["args"]
                tool_func = tool_map[tool_name]

                tool_result = tool_func.invoke(tool_args)
                print(f"  工具 [{tool_name}] 执行结果:{tool_result}")

                # 创建 ToolMessage 将结果返回给 LLM
                tool_messages.append(
                    ToolMessage(
                        content=str(tool_result),
                        tool_call_id=tool_call["id"],    # 必须与 LLM 的 tool_call id 对应
                    )
                )

            # 将工具结果发回给 LLM,获取最终回答
            all_messages = messages + [response] + tool_messages
            final_response = llm_with_tools.invoke(all_messages)
            print(f"最终回答:{final_response.content}")
        else:
            # LLM 直接回答(不需要工具)
            print(f"LLM 直接回答:{response.content}")
        print()

七、Step 3:执行工具调用(完整循环)

LLM 只是告诉你"用哪个工具、传什么参数",实际执行需要你的代码完成。上面的 demo_llm_with_tools 演示了单轮工具调用;多轮循环(Tool Loop)的核心逻辑如下:

from langchain_core.messages import HumanMessage, ToolMessage

tools = [calculator, get_weather, unit_converter]
llm_with_tools = llm.bind_tools(tools)
tool_map = {t.name: t for t in tools}

def run_tool_loop(user_question: str) -> str:
    """执行完整的工具调用循环,直到 LLM 给出最终答案。"""
    messages = [HumanMessage(content=user_question)]

    while True:
        # Step 1:LLM 推理(可能返回工具调用指令,也可能返回最终答案)
        response = llm_with_tools.invoke(messages)
        messages.append(response)

        # Step 2:没有工具调用 → LLM 已有最终答案,退出循环
        if not response.tool_calls:
            return response.content

        # Step 3:执行每个工具调用
        for tool_call in response.tool_calls:
            tool_name = tool_call["name"]
            tool_args = tool_call["args"]
            tool_id   = tool_call["id"]

            print(f"→ 执行工具:{tool_name}({tool_args})")

            # 执行工具,未找到时返回错误字符串(不抛异常)
            if tool_name in tool_map:
                result = tool_map[tool_name].invoke(tool_args)
            else:
                result = f"错误:未找到工具 '{tool_name}'"

            print(f"  工具结果:{result}")

            # 将工具结果加入消息历史,tool_call_id 必须对应
            messages.append(ToolMessage(
                content=str(result),
                tool_call_id=tool_id,
            ))

        # Step 4:循环回 Step 1,LLM 根据工具结果继续推理

# 运行示例
print(run_tool_loop("北京和上海今天的天气分别怎么样?"))

八、Step 4:本地命令执行工具(02_local_commands.py

让 AI 执行系统命令是强大但危险的能力,lessons/04_tools/02_local_commands.py 通过白名单 + 黑名单双重检查实现安全控制:

import os
import subprocess
from pathlib import Path
from langchain_core.tools import tool
from pydantic import BaseModel, Field

# ── 安全配置 ──────────────────────────────────────────────────
# 白名单:只有这些命令才能执行(主要防线)
ALLOWED_COMMANDS = {
    "ls", "dir", "pwd", "echo", "cat", "head", "tail",
    "find", "grep", "wc", "date", "whoami", "hostname",
    "uname", "df", "du", "ps", "env",
}

# 黑名单:明确拒绝的危险命令(额外保护)
DANGEROUS_COMMANDS = {
    "rm", "rmdir", "del", "format", "dd", "mkfs",
    "shutdown", "reboot", "kill", "pkill", "killall",
    "chmod", "chown", "sudo", "su",
    "curl", "wget", "nc", "netcat",
    "ssh", "scp", "rsync",
    ">", ">>",                         # 输出重定向写入
}

COMMAND_TIMEOUT = 10                   # 超时(秒)


def is_safe_command(command: str) -> tuple[bool, str]:
    """
    检查命令是否安全。返回 (是否安全, 拒绝原因)。
    先检查黑名单,再检查白名单,最后检查管道命令。
    """
    if not command.strip():
        return False, "命令为空"

    parts = command.strip().split()
    cmd_name = parts[0].lower()

    # 去掉路径前缀(如 /bin/ls → ls)
    if "/" in cmd_name:
        cmd_name = cmd_name.split("/")[-1]

    # 黑名单检查
    for dangerous in DANGEROUS_COMMANDS:
        if dangerous in command.lower():
            return False, f"命令包含危险操作:{dangerous}"

    # 白名单检查
    if cmd_name not in ALLOWED_COMMANDS:
        return False, f"命令 '{cmd_name}' 不在允许列表中"

    # 管道命令检查
    if "|" in command:
        for pipe_cmd in command.split("|")[1:]:
            pipe_name = pipe_cmd.strip().split()[0].lower() if pipe_cmd.strip() else ""
            if pipe_name and pipe_name not in ALLOWED_COMMANDS:
                return False, f"管道命令 '{pipe_name}' 不在允许列表中"

    return True, ""


class RunCommandInput(BaseModel):
    command: str = Field(
        description="要执行的系统命令(仅支持安全命令:ls, pwd, echo, date, whoami, df 等)"
    )


@tool(args_schema=RunCommandInput)
def run_safe_command(command: str) -> str:
    """
    执行安全的只读系统命令。
    只允许以下命令:ls, pwd, echo, date, whoami, hostname, uname, df 等。
    不允许修改文件、执行危险操作或网络操作。
    """
    safe, reason = is_safe_command(command)
    if not safe:
        return f"⛔ 命令被拒绝:{reason}"

    try:
        result = subprocess.run(
            command,
            shell=True,
            capture_output=True,
            text=True,
            timeout=COMMAND_TIMEOUT,          # 超时保护,防止命令卡死
            cwd=os.path.expanduser("~"),      # 在用户主目录执行
        )

        output = result.stdout.strip()
        error  = result.stderr.strip()

        if result.returncode != 0:
            return f"命令执行失败(退出码 {result.returncode}):{error}"

        return output if output else "命令执行成功(无输出)"

    except subprocess.TimeoutExpired:
        return f"命令超时(超过 {COMMAND_TIMEOUT} 秒)"
    except Exception as e:
        return f"命令执行错误:{str(e)}"

⚠️ 生产建议:命令执行工具应在 Docker 容器或专用沙箱(如 firejail)中运行,不要在宿主机上直接执行用户提供的命令。

02_local_commands.py 还包含 list_directory(列目录)和 read_text_file(读文本文件)两个工具,以及三个演示函数 demo_security_checkdemo_tools_directlydemo_llm_with_local_tools,可直接运行:

python lessons/04_tools/02_local_commands.py

九、Step 5:文件操作工具(03_file_operations.py

lessons/04_tools/03_file_operations.py 将所有文件操作限制在沙箱目录 SAFE_WORKSPACE 内,核心安全函数 resolve_safe_path 防止路径穿越攻击:

import os
import json
from datetime import datetime
from pathlib import Path
from langchain_core.tools import tool
from pydantic import BaseModel, Field

# 安全工作目录(所有文件操作限制在此目录内)
SAFE_WORKSPACE = Path(os.environ.get("AI_WORKSPACE", "/tmp/ai_file_workspace"))
SAFE_WORKSPACE.mkdir(parents=True, exist_ok=True)

# 允许写入的文件类型白名单
ALLOWED_WRITE_EXTENSIONS = {".txt", ".md", ".json", ".csv", ".log", ".yaml", ".yml"}


def resolve_safe_path(filename: str) -> tuple[Path, str]:
    """
    解析文件路径,确保它在安全工作目录内。
    返回 (绝对路径, 错误信息),有错误时路径为 None。
    """
    safe_path = (SAFE_WORKSPACE / filename).resolve()
    try:
        # relative_to 会在路径越界时抛出 ValueError
        safe_path.relative_to(SAFE_WORKSPACE)
    except ValueError:
        return None, f"⛔ 路径不在允许范围内:{filename}"
    return safe_path, ""


# ── 工具1:读取文件 ────────────────────────────────────────────
class ReadFileInput(BaseModel):
    filename: str = Field(description="要读取的文件名(相对于工作目录)")


@tool(args_schema=ReadFileInput)
def read_file(filename: str) -> str:
    """读取工作目录中的文本文件内容。"""
    file_path, error = resolve_safe_path(filename)
    if error:
        return error
    if not file_path.exists():
        return f"文件不存在:{filename}\n(工作目录:{SAFE_WORKSPACE})"
    try:
        content = file_path.read_text(encoding="utf-8")
        lines = content.splitlines()
        return f"文件:{filename}{len(lines)}行,{len(content)}字符)\n\n{content}"
    except Exception as e:
        return f"读取失败:{str(e)}"


# ── 工具2:写入文件 ────────────────────────────────────────────
class WriteFileInput(BaseModel):
    filename: str = Field(description="要写入的文件名(如 'output.txt'、'data.json')")
    content:  str = Field(description="要写入的文本内容")
    mode: str     = Field(
        default="overwrite",
        description="写入模式:'overwrite'(覆盖,默认)或 'append'(追加)"
    )


@tool(args_schema=WriteFileInput)
def write_file(filename: str, content: str, mode: str = "overwrite") -> str:
    """将文本内容写入工作目录中的文件,支持覆盖和追加两种模式。"""
    file_path, error = resolve_safe_path(filename)
    if error:
        return error
    if file_path.suffix.lower() not in ALLOWED_WRITE_EXTENSIONS:
        return f"⛔ 不允许写入该类型的文件:{file_path.suffix}"
    file_path.parent.mkdir(parents=True, exist_ok=True)
    try:
        write_mode = "a" if mode == "append" else "w"
        with open(file_path, write_mode, encoding="utf-8") as f:
            f.write(content)
        file_size = file_path.stat().st_size
        action = "追加" if mode == "append" else "写入"
        return f"✅ 成功{action}文件:{filename}(文件大小:{file_size}字节)"
    except Exception as e:
        return f"写入失败:{str(e)}"


# ── 工具3:列出工作目录文件 ───────────────────────────────────
class ListFilesInput(BaseModel):
    pattern: str = Field(
        default="*",
        description="文件名匹配模式(如 '*.txt' 匹配所有txt文件,'*' 匹配所有文件)"
    )


@tool(args_schema=ListFilesInput)
def list_workspace_files(pattern: str = "*") -> str:
    """列出工作目录中的所有文件,可用通配符筛选。"""
    try:
        files = list(SAFE_WORKSPACE.glob(pattern))
        if not files:
            return f"工作目录中没有匹配 '{pattern}' 的文件"
        file_info = []
        for f in sorted(files):
            if f.is_file():
                size = f.stat().st_size
                modified = datetime.fromtimestamp(f.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
                file_info.append(f"  📄 {f.name} ({size}字节, 修改于 {modified})")
        return (
            f"工作目录:{SAFE_WORKSPACE}\n找到 {len(file_info)} 个文件:\n"
            + "\n".join(file_info)
        )
    except Exception as e:
        return f"列出文件失败:{str(e)}"

03_file_operations.py 还包含 delete_file 工具,以及 demo_direct_file_operationsdemo_llm_file_agent 两个演示函数,直接运行:

python lessons/04_tools/03_file_operations.py

十、工具设计的最佳实践


📌 下一章预告:工具让 AI 能执行单次动作,但现实问题需要多次推理和行动。第05章我们学习 Agent(智能体),让 AI 能自主循环决策,直到解决问题。

作者:阿聪谈架构
公众号:阿聪谈架构(分享后端架构 / AI / Java 技术文章)
相关代码关注公众号:【阿聪谈架构】 回复:AI专栏代码

本文属于《AI开发入门系列》,后续会持续更新。 关注博主,第一时间收到最新文章,获取完整学习路线与资料