跳到正文

04. Tool 与工具调用

学习目标

完成本章后,你应该能够:

  • 说明 LangChain 中的 Tool 包含哪些信息。
  • 理解“模型请求工具”和“程序执行工具”的职责边界。
  • @tool 把普通 Python 函数暴露给聊天模型。
  • 识别本地 Ollama 模型在工具调用上的常见限制。
  • 按合理顺序调试工具调用失败。

Tool 是什么

Tool 是 Agent 可以使用的外部能力。它通常由四部分组成:

  • name:工具名称,例如 addmultiply。模型会用这个名称表达“我要调用哪个工具”。
  • description:工具描述,通常来自函数 docstring。它告诉模型什么时候应该用这个工具。
  • schema:参数结构,例如 a: int, b: int。模型需要按这个结构生成参数。
  • return:工具执行后的返回值。程序会把结果交回模型,或者直接展示给用户。

关键点是:模型本身不会真的执行 Python 函数。模型只会生成一个工具调用请求;你的程序读取请求、找到对应工具、执行函数,再处理结果。

最小示例

运行本章示例:

bash
uv run python examples/03_tools_cli.py

示例中有两个工具:

python
@tool
def add(a: int, b: int) -> int:
    """Add two integers and return the sum."""
    return a + b


@tool
def multiply(a: int, b: int) -> int:
    """Multiply two integers and return the product."""
    return a * b

程序把工具绑定到模型:

python
model = ChatOllama(..., temperature=0).bind_tools([add, multiply])

之后调用模型时,模型可能返回 response.tool_calls。如果它请求 addmultiply,程序会根据工具名查找本地函数并执行,再打印结果。

好工具描述的标准

好的工具描述应该清楚、具体、可执行:

  • 名称短而稳定,能准确表达能力。
  • docstring 说明工具做什么,而不是泛泛地写“处理输入”。
  • 参数类型尽量简单,字段名能表达含义。
  • 返回值容易被模型继续理解,例如数字、短文本或结构清晰的对象。
  • 一个工具只做一类事,不把多个互不相关的能力塞进同一个函数。

如果模型经常选错工具,先检查工具名称、docstring 和参数 schema,再检查 prompt。

本地模型限制

并不是所有 Ollama 本地模型都稳定支持 tool calling。有些模型能正常聊天,但不会生成 tool_calls;有些模型会生成格式不完整的参数;还有些模型会把工具调用写进普通文本,而不是结构化字段。

调试时建议按这个顺序检查:

  1. Ollama 是否运行,模型是否已经 ollama pull
  2. 当前模型是否明确支持工具调用。
  3. bind_tools(...) 是否传入了正确工具列表。
  4. 工具 docstring 和参数类型是否足够清楚。
  5. 打印 response.contentresponse.tool_calls,确认模型到底返回了什么。

如果示例提示没有收到工具调用请求,不一定是代码错误,也可能是当前本地模型的工具调用能力不可靠。

工具设计原则

一个好工具应该“小而确定”。它应该完成一件清楚的事情,而不是把多个行为混在一起。

推荐:

  • search_knowledge_base(query: str) -> str
  • add(a: int, b: int) -> int
  • get_weather(city: str) -> str

不推荐:

  • handle_user_request(text: str) -> str
  • process_data(data: str) -> str
  • do_everything(task: str) -> str

模型选择工具时主要依赖工具名称、描述和参数 schema。如果工具太抽象,模型很难判断什么时候该用它,也很难给出正确参数。

代码阅读重点

阅读 examples/03_tools_cli.py 时,重点看三件事:

  1. @tool 如何把 Python 函数变成 LangChain Tool。
  2. ChatOllama(...).bind_tools(TOOLS) 如何把工具 schema 暴露给模型。
  3. 程序如何读取 response.tool_calls 并真正执行对应工具。

注意:这个示例不是完整 Agent。它只演示“模型是否会请求工具调用”。完整的工具循环在下一章由 create_agent 管理。

实验练习

  • add 的 docstring 改得含糊一些,观察模型是否还会稳定调用它。
  • 新增一个 subtract(a: int, b: int) 工具,并更新 TOOLS
  • 输入一个不需要数学工具的问题,观察模型是否仍然请求工具。

自测问题

  • 模型会直接执行 Python 函数吗?
  • 工具返回值应该长还是短?
  • 为什么工具参数类型越简单越适合初学阶段?

深入理解:Tool Calling 的真实边界

Tool calling 不是模型真的调用了函数。模型只是生成一个结构化意图,通常包含:

  • 工具名。
  • 参数 JSON。
  • 可能还有调用 id。

权限与副作用

真正执行函数的是 LangChain 或你的应用代码。这个边界非常重要,因为它决定了安全模型:模型永远不应该直接拥有数据库、文件系统、网络请求或支付权限。它只能请求,程序负责验证、执行和记录。

在本地模型场景里,tool calling 的不稳定通常来自三个地方:

  1. 模型没有学好函数调用格式。
  2. 工具 schema 对模型来说太复杂。
  3. 工具说明不足以让模型判断何时使用。

因此,提升工具调用稳定性不只是换模型,也包括减少工具数量、拆分工具职责、简化参数结构、缩短返回值。

工具 schema 设计细节

参数字段应该尽量贴近用户语言。例如:

  • citylocation_input_value 更好。
  • query 适合搜索类工具。
  • ab 适合教学中的数学工具,但业务工具里应使用更有语义的字段。

参数类型应该先从简单类型开始:

  • 优先:strintfloatbool
  • 谨慎:嵌套对象、数组、联合类型。
  • 避免:让模型传入大段自由格式 JSON,再由工具猜测含义。

工具返回值也要面向模型设计。返回太长会污染上下文,返回太短又缺少事实依据。RAG 工具通常应该返回“来源 + 摘要或片段”,而不是整篇文档。

反模式

  • 万能工具:一个 execute_task(task: str) 处理所有事情,模型无法判断边界。
  • 高风险工具无保护:删除文件、发邮件、扣款等操作没有人工确认。
  • 工具描述写给人看,不写给模型看:描述里没有说明什么时候用、参数是什么、返回什么。
  • 工具返回内部异常栈:模型读不懂,也可能泄漏敏感信息。
  • 工具数量过多:十几个相似工具会让模型选择困难。

真实项目中,工具设计应该像 API 设计一样严肃。一个坏工具会把模型推向错误决策,一个清晰工具能显著降低 prompt 压力。

完整示例

python
from __future__ import annotations

import sys
from pathlib import Path

sys.path.append(str(Path(__file__).resolve().parents[1]))

from langchain_core.tools import tool
from langchain_ollama import ChatOllama

from examples.common.config import load_config, ollama_client_kwargs
from examples.common.ollama_check import assert_model_available, assert_ollama_running


@tool
def add(a: int, b: int) -> int:
    """Add two integers and return the sum."""
    return a + b


@tool
def multiply(a: int, b: int) -> int:
    """Multiply two integers and return the product."""
    return a * b


TOOLS = [add, multiply]


def main() -> None:
    config = load_config()
    try:
        assert_ollama_running(config.ollama_base_url)
        assert_model_available(config.ollama_base_url, config.ollama_model)
    except RuntimeError as exc:
        print(f"错误:{exc}")
        return

    model = ChatOllama(
        model=config.ollama_model,
        base_url=config.ollama_base_url,
        client_kwargs=ollama_client_kwargs(),
        temperature=0,
    ).bind_tools(TOOLS)

    default_question = "What is (12 + 8) * 3?"
    question = input(f"数学问题(默认:{default_question}):").strip() or default_question

    response = model.invoke(question)
    print("\n模型原始内容:")
    print(response.content or "(空)")

    tool_calls = response.tool_calls
    print("\n模型请求的工具调用:")
    if not tool_calls:
        print(
            "没有收到工具调用请求。当前选择的本地模型可能不能稳定支持 tool calling,"
            "可以尝试更换支持工具调用的 Ollama 模型,或先检查工具描述和模型输出。"
        )
        return

    tool_by_name = {tool_item.name: tool_item for tool_item in TOOLS}
    for tool_call in tool_calls:
        tool_name = tool_call.get("name")
        tool_args = tool_call.get("args")
        if not isinstance(tool_name, str) or not isinstance(tool_args, dict):
            print(f"- 无法执行不完整的工具调用:{tool_call}")
            print("  建议:检查模型是否稳定支持 tool calling,并简化工具参数。")
            continue

        print(f"- {tool_name}({tool_args})")

        selected_tool = tool_by_name.get(tool_name)
        if selected_tool is None:
            print(f"  结果:未找到名为 `{tool_name}` 的工具。")
            continue

        try:
            result = selected_tool.invoke(tool_args)
        except Exception as exc:  # Tool args can be malformed when local models call tools.
            print(f"  工具执行失败:{exc}")
            print("  建议:检查工具参数类型,或换用更稳定支持工具调用的模型。")
            continue

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


if __name__ == "__main__":
    main()