本章目标:理解工具调用的完整机制,掌握用
@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 和参数描述来决定:
- 什么时候调用这个工具?(工具的 docstring)
- 每个参数应该传什么值?(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)}"
三个核心原则:
- 安全检查先行:在执行任何操作前验证输入
- 捕获所有异常:工具不应该抛异常,应该返回包含错误信息的字符串
- 返回值必须是字符串: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_check、demo_tools_directly、demo_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_operations 和 demo_llm_file_agent 两个演示函数,直接运行:
python lessons/04_tools/03_file_operations.py
十、工具设计的最佳实践
📌 下一章预告:工具让 AI 能执行单次动作,但现实问题需要多次推理和行动。第05章我们学习 Agent(智能体),让 AI 能自主循环决策,直到解决问题。
作者:阿聪谈架构
公众号:阿聪谈架构(分享后端架构 / AI / Java 技术文章)
相关代码关注公众号:【阿聪谈架构】 回复:AI专栏代码
本文属于《AI开发入门系列》,后续会持续更新。 关注博主,第一时间收到最新文章,获取完整学习路线与资料