LLM 的 Tool Call:从字符串协议到 LangChain 工具调用

用户问"北京天气怎么样",模型需要先获得天气数据,才能给出有依据的回答。Tool Call 就是把这个需求交给程序执行的一种机制。

在本文的本地函数示例中,模型负责选择工具和生成参数,Python 负责执行工具,再将结果交回模型。

text 复制代码
用户:查一下 beijing 的天气
  ↓
模型请求:get_weather(city="beijing")
  ↓
Python 执行:得到 sunny,30度
  ↓
结果交回模型
  ↓
模型回答:北京晴,气温 30 度

这里的天气来自代码中写死的字典,只是演示数据。接入真实天气服务时,需要替换工具内部的查询逻辑。

项目中的三个文件,逐步展示了这套机制:

文件 请求怎样产生 程序怎样处理
01_toolcall.py 手写字符串,模拟模型输出 按冒号拆分,再调用函数
02_prompt_protocol_model.py 用提示词要求模型输出标签 正则提取工具名,JSON 解析参数,再调用函数
03_langchain_model.py 向模型提供工具定义,接收结构化请求 读取 tool_calls、执行工具、回传 ToolMessage

本文基于这些源文件讲解。代码修正以片段形式给出,原 Python 文件未修改,也未实际调用模型验证。

从第一个文件开始,先不用模型。

01_toolcall.py 定义了一个普通 Python 函数:

python 复制代码
def get_weather(city: str) -> str:
    weather = {
        "beijing": "sunny",
        "shanghai": "cloudy",
        "fuzhou": "rainy",
    }
    return weather.get(city, "未知天气")

然后用一行字符串模拟模型输出:

python 复制代码
model_output = "get_weather: fuzhou"

程序按约定拆出工具名和参数:

python 复制代码
def parse_model_output(text: str) -> tuple[str, dict[str, str]]:
    tool_name, city = text.split(":", maxsplit=1)
    return tool_name.strip(), {"city": city.strip()}

tool_name, tool_args = parse_model_output(model_output)

if tool_name == "get_weather":
    result = get_weather(**tool_args)

其中,get_weather(**{"city": "fuzhou"}) 相当于 get_weather(city="fuzhou")

这段代码展示了工具调用的基本结构:解析请求 → 找到函数 → 传入参数 → 得到结果。它没有调用 LLM,也没有把结果交回模型。

这种字符串协议适合入门,但扩展起来很麻烦。没有冒号会解析失败,多个参数也需要重新设计格式。

第二个文件接入了真实模型,但工具调用格式仍由提示词约定。

02_prompt_protocol_model.py 要求模型在遇到天气问题时输出:

xml 复制代码
<Tool>get_weather</Tool>
<Args>{"city":"南昌"}</Args>

程序从普通文本中提取两个标签:

python 复制代码
tool_match = re.search(r"<Tool>(.*?)</Tool>", text, re.DOTALL)
args_match = re.search(r"<Args>(.*?)</Args>", text, re.DOTALL)

(.*?) 捕获标签内的内容,re.DOTALL 让匹配可以跨行。提取到的参数仍然是字符串,需要再解析:

python 复制代码
args = json.loads(args_match.group(1))

于是,模型生成的文本被转换为:

python 复制代码
{"tool": "get_weather", "args": {"city": "南昌"}}

接下来仍由 Python 执行 get_weather(**call["args"])。当前示例到打印工具结果为止,没有再次调用模型生成自然语言回答。

这也解释了自定义协议的问题:模型可能漏写标签、输出无效 JSON,或者缺少 city。原代码遇到 JSON 解析失败时返回空字典,后续调用又可能因为缺少参数而报错。更合适的做法是明确报告格式错误,并在执行前检查参数:

python 复制代码
args = call["args"]
if not isinstance(args, dict) or not isinstance(args.get("city"), str):
    raise ValueError("工具参数必须包含字符串类型的 city")

readme.md 中的 <Tool><Args> 应理解为本例的自定义协议,不能据此推断所有模型内部都使用这套标签。

第三个文件使用模型接口支持的结构化工具调用,并通过 LangChain 统一处理。

03_langchain_model.py 给函数添加了 @tool

python 复制代码
from langchain_core.tools import tool

@tool
def get_weather(city: str) -> str:
    """查询演示天气,city 使用 beijing、fuzhou 或 nanchang。"""
    weather = {
        "beijing": "sunny,30度",
        "fuzhou": "rainy,20度",
        "nanchang": "cloudy,15度",
    }
    return weather.get(city, "未知天气")

默认情况下,函数名用作工具名,文档字符串说明用途,类型注解帮助生成参数定义。装饰器将函数包装成 LangChain 工具,供后续绑定和执行。LangChain 工具定义文档

然后将工具绑定到模型:

python 复制代码
llm = load_llm().bind_tools([get_weather])
response = llm.invoke(messages)

bind_tools() 向模型提供工具定义。调用后,可以从 response.tool_calls 读取结构化请求;执行仍由应用代码负责。底层模型和服务接口需要支持工具调用。LangChain 工具调用文档

返回值可能类似下面这样,具体 ID 由接口生成:

python 复制代码
[
    {
        "name": "get_weather",
        "args": {"city": "beijing"},
        "id": "call_example",
        "type": "tool_call",
    }
]

这里有三个需要使用的字段:

字段 用途
name 找到要执行的工具
args 提供已经解析好的参数
id 将执行结果与原调用关联

因此,第三种方式省去了手写标签和正则解析。它仍然需要程序检查参数、处理执行错误,不能保证模型一定选对工具或填对城市。

执行工具后,还差一次结果回传。

原代码在 for tool_call in response.tool_calls 循环外创建 ToolMessage,会出现两个问题:

  • 没有工具调用时,resulttool_call 尚未赋值,却被使用。
  • 一次请求多个工具时,只回传最后一个结果,前面的调用缺少对应消息。

每个工具调用都应该得到自己的结果消息。 ToolMessagetool_call_id 必须对应原请求的 id,模型才能关联请求与结果。LangChain 结果回传说明

可以保留第三个文件中的工具定义、load_llm() 和入口代码,用下面的函数替换 main()

python 复制代码
def main() -> None:
    user_prompt = " ".join(sys.argv[1:]).strip() or DEFAULT_USER_PROMPT
    tools = {get_weather.name: get_weather}
    llm = load_llm().bind_tools(list(tools.values()))

    messages = [
        SystemMessage(content=SYSTEM_PROMPT),
        HumanMessage(content=user_prompt),
    ]

    for _ in range(5):
        response = llm.invoke(messages)
        messages.append(response)

        if response.invalid_tool_calls:
            print("工具调用参数解析失败,请检查模型输出。")
            return

        if not response.tool_calls:
            print(response.content)
            return

        for call in response.tool_calls:
            selected_tool = tools.get(call["name"])

            try:
                if selected_tool is None:
                    raise ValueError(f"未知工具:{call['name']}")
                result = selected_tool.invoke(call["args"])
            except Exception as exc:
                result = f"工具执行失败:{exc}"

            messages.append(
                ToolMessage(
                    content=str(result),
                    name=call["name"],
                    tool_call_id=call["id"],
                )
            )

    print("已达到 5 轮调用上限,任务尚未完成。")

这个循环处理了三种情况:没有工具请求就输出回答;有请求就逐个执行、逐个回填;模型需要继续调用工具时进入下一轮。5 轮是教学示例的预算,达到上限会明确停止。模型接口本身的网络异常仍需另行处理。

对一次天气查询,常见的消息顺序是:

text 复制代码
HumanMessage:查询北京天气
AIMessage:请求 get_weather,参数为 beijing
ToolMessage:返回 sunny,30度,关联该次调用 ID
AIMessage:根据工具结果回答用户

修正后的代码还通过工具名查表分发,避免将所有请求都直接交给 get_weather。以后添加其他工具时,可以沿用这套分发方式。

运行前,在项目目录安装所需依赖:

powershell 复制代码
cd D:\workspace\ysh_ai\backend\python\toolcall
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install langchain-openai langchain-core python-dotenv

第二、第三个文件都从脚本所在目录加载 .env,对应路径是 src/.env

dotenv 复制代码
API_KEY=你的密钥
BASE_URL=服务接口地址
MODEL=该服务支持的模型名称

这里的变量名是项目约定。第三个示例所选模型必须支持工具调用,具体可用模型以服务提供方为准。

原代码还有几处值得在运行前处理:

  • 第二个文件会打印完整 API Key,删除该调试输出。
  • 第二个文件的 f-string 内外复用了双引号。为兼容 Python 3.10、3.11,将内层改为单引号:return f"{city} 的天气是: {weather.get(city, '未知天气')}"
  • 第二个文件只打印了"模型原始输出"的标题,没有打印正文。取得 model_output 后补上 print(model_output),才能观察标签或普通文本回答。
  • 第三个文件用 "".join(sys.argv[1:]) 拼接参数,会丢失单词间空格。上面的修正版使用 " ".join(...)

然后按顺序运行:

powershell 复制代码
.\.venv\Scripts\python.exe src\01_toolcall.py
.\.venv\Scripts\python.exe src\02_prompt_protocol_model.py "帮我查一下南昌的天气"
.\.venv\Scripts\python.exe src\03_langchain_model.py "帮我查一下 beijing 的天气"

三个文件的城市键并不完全一致。例如第三个示例使用 nanchang,传入"南昌"会查不到。可以统一参数约定,或在工具内部增加城市别名转换。

验证第三个示例时,分别输入"你好""查询 beijing 天气""查询 beijing 和 fuzhou 天气",观察零次、一次和可能的多次工具请求。重点看 response.tool_calls 与结果消息是否逐一对应;模型是否在同一轮请求两个城市,取决于实际响应。

当这套流程跑通后,把字典查询换成天气 API、数据库查询或文档检索,工具调用的主流程仍然相同:接收模型请求,执行明确的操作,把结果交回模型。

相关推荐
小马过河R2 小时前
开篇|3天从0到1入门AI应用开发
人工智能·语言模型·llm·agent·ai编程·deepagent
liulilittle3 小时前
Linux AI 开发环境搭建实录
linux·ai·llm·agent·hyper-v·dev·opencode
happy_king_zi5 小时前
Milvus 生产环境部署,优化,日常维护中遇到的问题
llm·milvus
桃西西呀6 小时前
一句话换个词序意思就全变,大模型是怎么读出门道的?手搓一个 Transformer 看清楚
人工智能·llm·ai编程
KimLiu6 小时前
LCODER之AI Agent开发实战一 :问数项目智能体搭建(3)元数据知识库的构建
langchain·llm·agent
Together_CZ6 小时前
在线蒸馏(OPD)、递归自我改进(RSI)与递归自我学习(RSL)整体学习理解
llm·agent·opd·rsi·在线蒸馏·rsl·递归自我学习
AINative软件工程7 小时前
LLM 应用的 Bulkhead 隔离工程实践:用舰壁模式防止一个功能的过载拖左整个 AI 系统
后端·llm·ai编程
武子康16 小时前
小智断网后还能做什么?沿一次唤醒看清设备与服务端的分工
人工智能·llm·agent
米小虾18 小时前
加了 20 条示例反而变差:你的 few-shot 提升,可能只是 prompt 变长的功劳
人工智能·llm