大模型说能查天气,其实是在编——Tool 链调用缺了这一步

文章目录

    • [真相:模型不会替你调 HTTP](#真相:模型不会替你调 HTTP)
    • [最小工具:@tool 三件套](#最小工具:@tool 三件套)
    • [参数校验:Pydantic args_schema](#参数校验:Pydantic args_schema)
    • 实战:天气查询全链路
      • [① 工具函数 + 环境变量](#① 工具函数 + 环境变量)
      • [② bind_tools + 解析器 + 二次 Prompt](#② bind_tools + 解析器 + 二次 Prompt)
    • 选型速查
    • 我现在怎么用它

大家好,我是大煊,本文「LangChain 实战踩坑」系列继续聊 Tool Calling。

我问 DeepSeek「深圳今天天气怎么样」,它老实交代:没法访问互联网。

后面我按照文档,给方法挂上 @tool(工具装饰器)、写了 bind_tools(绑定工具),模型确实发起了工具调用,终端里却蹦出一整坨 OpenWeather JSON,根本看不懂。后面才知道:Tool 只负责干活,把结果翻译成人话,还得你再接一段 Prompt 链。

对 Javaer 来说,@tool 像给 LLM 暴露的 @RestController 接口 :函数签名 + docstring(文档字符串)就是 Swagger 文档,模型根据描述决定调哪个、传什么参。bind_tools 则是把这份「接口清单」注册进模型上下文------跟 Spring 里扫到哪些 Service Bean 是一个路数。

完整可运行代码(依赖 + 四个脚本)在微信公众号的文末评论区置顶


真相:模型不会替你调 HTTP

大模型嘴皮子利索,本质是静态的------不会自己发 HTTP、读数据库、摸文件。查实时天气这种活,得靠 Tool Calling (工具调用):模型只决定调谁、传啥参,你的 工具函数(Python)才是真干活的人。

LangChain 里缺一层就卡壳,我把它收成三步:

  1. 定义工具@tool 装饰普通函数,docstring 写给模型看
  2. 绑定 + 解析llm.bind_tools([...]),再用 JsonOutputKeyToolsParser 抠出模型选的函数名和参数
  3. 执行 + 后处理:工具返回原始数据后,往往还要接一段 Prompt 转成自然语言

可以记一句:Tool 是 LLM 的手

时序图如下
工具 大模型 程序 用户 工具 大模型 程序 用户 #mermaid-svg-rBNxvFuLzeybehBU{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-rBNxvFuLzeybehBU .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-rBNxvFuLzeybehBU .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-rBNxvFuLzeybehBU .error-icon{fill:#552222;}#mermaid-svg-rBNxvFuLzeybehBU .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-rBNxvFuLzeybehBU .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-rBNxvFuLzeybehBU .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-rBNxvFuLzeybehBU .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-rBNxvFuLzeybehBU .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-rBNxvFuLzeybehBU .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-rBNxvFuLzeybehBU .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-rBNxvFuLzeybehBU .marker{fill:#333333;stroke:#333333;}#mermaid-svg-rBNxvFuLzeybehBU .marker.cross{stroke:#333333;}#mermaid-svg-rBNxvFuLzeybehBU svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-rBNxvFuLzeybehBU p{margin:0;}#mermaid-svg-rBNxvFuLzeybehBU .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-rBNxvFuLzeybehBU text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-rBNxvFuLzeybehBU .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-rBNxvFuLzeybehBU .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-rBNxvFuLzeybehBU .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-rBNxvFuLzeybehBU .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-rBNxvFuLzeybehBU #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-rBNxvFuLzeybehBU .sequenceNumber{fill:white;}#mermaid-svg-rBNxvFuLzeybehBU #sequencenumber{fill:#333;}#mermaid-svg-rBNxvFuLzeybehBU #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-rBNxvFuLzeybehBU .messageText{fill:#333;stroke:none;}#mermaid-svg-rBNxvFuLzeybehBU .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-rBNxvFuLzeybehBU .labelText,#mermaid-svg-rBNxvFuLzeybehBU .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-rBNxvFuLzeybehBU .loopText,#mermaid-svg-rBNxvFuLzeybehBU .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-rBNxvFuLzeybehBU .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-rBNxvFuLzeybehBU .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-rBNxvFuLzeybehBU .noteText,#mermaid-svg-rBNxvFuLzeybehBU .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-rBNxvFuLzeybehBU .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-rBNxvFuLzeybehBU .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-rBNxvFuLzeybehBU .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-rBNxvFuLzeybehBU .actorPopupMenu{position:absolute;}#mermaid-svg-rBNxvFuLzeybehBU .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-rBNxvFuLzeybehBU .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-rBNxvFuLzeybehBU .actor-man circle,#mermaid-svg-rBNxvFuLzeybehBU line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-rBNxvFuLzeybehBU :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户提问 工具信息:天气查询(地点参数) alt 不需要调用工具 需要调用工具 提交用户问题 调用大模型 判断是否需要调用工具 生成回复内容 输出工具名称与入参 根据名称与入参调用工具 返回工具调用结果 携带工具结果再次调用大模型 整合信息给出回复 返回大模型回复


最小工具:@tool 三件套

属性 类型 描述
name str 必选,在提供给LLM或Agent的工具集中必须是唯一的。
description str 可选但建议,描述工具的功能。LLM或Agent将使用此描述作为上下文,使用它确定工具的使用
args_schema Pydantic BaseModel 可选但建议,可用于提供更多信息(例如,few-shot示例)或验证预期参数。
return_direct boolean 仅对Agent相关。当为True时,在调用给定工具后,Agent将停止并将结果直接返回给用户。

函数名、docstring、参数类型------模型全靠这三样决定怎么调。add_number.nameadd_number.descriptionadd_number.args 打印出来,就是模型「读到的接口文档」。

python 复制代码
# Tool_AddNumberTool.py(节选)
from langchain.tools import tool

@tool
def add_number(a: int, b: int) -> int:
    """两个整数相加"""
    return a + b

print(add_number.invoke({"a": 1, "b": 12}))
# 13
print(f"{add_number.name=}\n{add_number.description=}\n{add_number.args=}")

docstring 空着,模型大概率不知道这工具干嘛------我踩过,挂了 @tool 它要么不调,要么参数乱传。


参数校验:Pydantic args_schema

参数复杂或要字段说明时,用 Pydantic BaseModel 定义 args_schema(参数模式)------像 Java 里给 Controller 方法套 @Valid DTO。

python 复制代码
# Tool_AddNumberToolPro.py(节选)
from langchain_core.tools import tool
from pydantic import BaseModel, Field

class FieldInfo(BaseModel):
    a: int = Field(description="第1个参数")
    b: int = Field(description="第2个参数")

@tool(args_schema=FieldInfo)
def add_number(a: int, b: int) -> int:
    return a + b

宽松 int 会把 "41" 转成 41StrictInt(严格整型)直接报错------工具入参要不要「通融」,生产环境得想清楚。仓库里 PydanticDemo.py 有对照,值得扫一眼。


实战:天气查询全链路

① 工具函数 + 环境变量

OpenWeather 免费 Key 去 官网 申请,写入项目根目录 .envOPENWEATHER_API_KEY

python 复制代码
# QueryWeatherTool.py(节选)
from langchain_core.tools import tool
import json
import os
import httpx

@tool
def get_weather(loc):
    """
    查询即时天气。loc 为城市英文名,如 Shenzhen、shanghai。
    中国城市必须用英文拼写,不要用「深圳」。
    """
    params = {
        "q": loc,
        "appid": os.getenv("OPENWEATHER_API_KEY"),
        "units": "metric",
        "lang": "zh_cn",
    }
    response = httpx.get(
        "https://api.openweathermap.org/data/2.5/weather",
        params=params,
        timeout=30,
    )
    return json.dumps(data, indent=2, ensure_ascii=False)

可以看到最原始的返回是一个json对象

踩坑 :中国城市得用英文 loc,写「深圳」OpenWeather 可能查不到。docstring 里写清楚,模型才传对参。

下面是传深圳去调用工具的返回结果

② bind_tools + 解析器 + 二次 Prompt

光有 get_weather 不够,还得让模型决定何时调它,并把 JSON 翻成人话。

python 复制代码
# LLMQueryWeatherDemo.py(节选)
import os
from dotenv import load_dotenv
from langchain.chat_models import init_chat_model
from langchain_core.output_parsers import JsonOutputKeyToolsParser, StrOutputParser
from langchain_core.prompts import PromptTemplate
from QueryWeatherTool import get_weather

load_dotenv()

llm = init_chat_model(
    model=os.getenv("DEEPSEEK_MODEL", "deepseek-chat"),
    model_provider="openai",
    api_key=os.getenv("DEEPSEEK_API_KEY"),
    base_url=os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
)

llm_with_tools = llm.bind_tools([get_weather])
parser = JsonOutputKeyToolsParser(key_name=get_weather.name, first_tool_only=True)

get_weather_chain = llm_with_tools | parser | get_weather

output_prompt = PromptTemplate.from_template(
    """你是天气播报助手。根据下方 OpenWeather JSON,用 2~4 句口语化中文描述当前天气。

必须包含:城市、天气现象、气温(及体感若有)、湿度、风力方向与等级。
可附一句穿衣或出行建议。禁止输出 JSON、列表或 Markdown。

天气数据:
{weather_json}

风格参考:「深圳现在多云,气温 20℃,体感略闷热约 22℃,湿度 75%,东南风 2 级。短袖即可,适合户外。」"""

)
full_chain = get_weather_chain | (lambda x: {"weather_json": x}) | output_prompt | llm | StrOutputParser()

链路由外到内:bind_tools 只让模型知道 有哪些工具,不会 自动发 HTTP;| parser | get_weather 才是执行;output_prompt | llm 是 DTO 转 VO 的「翻译层」。

踩坑 :之前以为 bind_tools 绑完就自动 HTTP 了------其实还得把 Tool 接进 LCEL(LangChain 表达式链)管道,否则只有 tool_calls 没有天气数据。

下面是去掉解析的调用,看上去好像也正常?

但其实 PromptTemplate 填 {weather_json} 时,会把 AIMessage 转成文本。里面可能有 tool_calls 和城市名,但没有气温、湿度、风力等 API 字段。第二个 LLM 照样能写出「深圳今天多云,22℃...」------那是幻觉,不是查出来的

其实是解析提示词里面自带的温度

踩坑JsonOutputKeyToolsParserkey_name 必须和 @tool 函数名一致;first_tool_only=True 只取第一个工具调用,多工具场景记得关掉或自己挑。


选型速查

场景 做法
简单函数暴露给模型 @tool + docstring
参数要字段说明/校验 @tool(args_schema=YourModel)
模型选工具并传参 llm.bind_tools + JsonOutputKeyToolsParser
工具返回原始数据要人话 再接 `PromptTemplate

我现在怎么用它

现在我给 Agent 挂外部能力时:@tool 写清 docstring 和参数类型,bind_tools 注册,JsonOutputKeyToolsParser 抠调用参数,工具返回若是 JSON/API 原始体,一定再接一段 Prompt 做「翻译层」------整套手感从「模型瞎编天气」变成「Controller 调 Service 再 DTO 转 VO」。


这是「AI Agent 实战踩坑」系列的第 6 篇,可以翻阅文章目录查看。