Chapter 03

工具调用 Tool Use 深度

工具调用是 Agent 连接真实世界的桥梁。掌握工具定义规范、完整调用流程、结构化输出和健壮的错误处理。

工具调用的工作原理

工具调用(Tool Use / Function Calling)本质上是 LLM 与外部系统交互的协议。LLM 不能直接执行代码,但它可以输出结构化的"调用意图",由宿主程序代为执行,再将结果反馈给 LLM。

工具调用完整流程: 用户输入: "北京今天的天气?" │ ▼ ┌─────────────────────────────────────────────────┐ │ LLM 推理(带工具描述的 System Prompt) │ │ → 输出工具调用意图(JSON 格式): │ │ {"name": "get_weather", │ │ "arguments": {"city": "北京"}} │ └─────────────────┬───────────────────────────────┘ │ (不是真实执行,只是意图输出) ▼ ┌─────────────────────────────────────────────────┐ │ Agent 框架(Python 代码) │ │ → 解析 JSON │ │ → 调用 get_weather("北京") │ │ → 收到结果: {"temp": 22, "weather": "晴"} │ └─────────────────┬───────────────────────────────┘ │ (结果以 ToolMessage 追加到历史) ▼ ┌─────────────────────────────────────────────────┐ │ LLM 再次推理(含工具结果的完整历史) │ │ → 生成最终回答: "北京今天晴天,气温22°C" │ └─────────────────────────────────────────────────┘

工具定义规范

LangChain 提供了多种工具定义方式,推荐使用 @tool 装饰器配合 Pydantic 模型,以获得最佳的类型安全和文档生成:

from langchain_core.tools import tool
from pydantic import BaseModel, Field
from typing import Optional, Literal

# ── 方式1:简单函数 + docstring(推荐用于简单工具)────────
@tool
def get_word_count(text: str) -> int:
    """统计文本中的单词数量。

    Args:
        text: 要统计的文本内容
    Returns:
        单词数量
    """
    return len(text.split())

# ── 方式2:Pydantic Schema(推荐用于复杂参数)────────────
class WebSearchInput(BaseModel):
    query: str = Field(description="搜索关键词,使用英文可获得更好结果")
    num_results: int = Field(default=5, ge=1, le=20, description="返回结果数量")
    time_range: Literal["day", "week", "month", "year"] = Field(
        default="week",
        description="时间范围过滤"
    )

@tool("web_search", args_schema=WebSearchInput)
def search_web(query: str, num_results: int = 5,
               time_range: str = "week") -> str:
    """在网络上搜索最新信息,适用于需要实时数据的问题。"""
    # 实际使用 Tavily API 或 SerpAPI
    return f"[搜索结果:{query},{num_results}条,时间:{time_range}]"

# 查看工具的 JSON Schema(会被发送给 LLM)
print(search_web.tool_call_schema.schema())

工具调用的完整实现

下面展示如何手动处理工具调用循环,帮助你理解框架内部的工作原理:

from langchain_openai import ChatOpenAI
from langchain_core.messages import (
    HumanMessage, AIMessage, ToolMessage, SystemMessage
)
import json

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
tools = [search_web, get_word_count]
llm_with_tools = llm.bind_tools(tools)

# 工具名 → 函数的映射表
tool_map = {t.name: t for t in tools}

def run_agent_loop(user_input: str, max_iterations: int = 10) -> str:
    messages = [
        SystemMessage(content="你是一个智能助手,可以使用工具来回答问题。"),
        HumanMessage(content=user_input),
    ]

    for i in range(max_iterations):
        response = llm_with_tools.invoke(messages)
        messages.append(response)

        # 没有工具调用 → Agent 完成
        if not response.tool_calls:
            return response.content

        # 执行每个工具调用
        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})")

            try:
                tool_fn = tool_map[tool_name]
                result = tool_fn.invoke(tool_args)
            except KeyError:
                result = f"错误:工具 '{tool_name}' 不存在"
            except Exception as e:
                result = f"工具执行失败:{e}"

            # 将工具结果作为 ToolMessage 追加
            messages.append(ToolMessage(
                content=str(result),
                tool_call_id=tool_id
            ))

    return "已达到最大迭代次数,任务未完成。"

result = run_agent_loop("搜索 LangGraph 0.2 的新特性,并统计摘要字数")
print(result)

结构化输出(Structured Output)

有时你需要 LLM 返回特定格式的数据(如 JSON 对象),而不是工具调用。with_structured_output 是最佳方案:

from pydantic import BaseModel, Field
from typing import List

class ResearchReport(BaseModel):
    """研究报告结构"""
    title: str = Field(description="报告标题")
    summary: str = Field(description="200字以内的执行摘要")
    key_findings: List[str] = Field(description="3-5条核心发现")
    confidence: float = Field(ge=0, le=1, description="置信度 0-1")
    sources: List[str] = Field(description="参考来源URL列表")

# 绑定结构化输出 Schema
structured_llm = llm.with_structured_output(ResearchReport)

report = structured_llm.invoke(
    "请总结 2025 年 AI Agent 框架的发展趋势"
)

print(report.title)          # str
print(report.confidence)     # float
print(report.key_findings)   # list[str]

# 在 Agent 中作为"路由判断"工具使用
class RouteDecision(BaseModel):
    next_action: Literal["search", "analyze", "generate", "finish"]
    reasoning: str
    priority: int = Field(ge=1, le=5)

router_llm = llm.with_structured_output(RouteDecision)

def structured_router(state):
    decision = router_llm.invoke([
        SystemMessage(content="分析当前任务状态,决定下一步行动。"),
        *state["messages"]
    ])
    return decision.next_action

工具选择策略

工具数量控制
LLM 的工具选择精度随工具数量增加而下降。建议单次 Agent 运行的工具数量不超过 15 个。工具过多时,应根据用户意图动态注入相关工具子集(Tool Routing)。
工具描述质量
工具的 docstring 和参数 description 是 LLM 选择工具的唯一依据。描述要明确说明"何时使用"、"参数格式"和"返回值含义",避免模糊表述。
工具隔离与幂等性
读取类工具(搜索、查询)可重复调用;写入类工具(发邮件、修改数据库)必须有确认机制。生产环境中写入工具应实现幂等性,或在调用前要求人工确认。

错误处理最佳实践

from langchain_core.tools import tool, ToolException
from tenacity import retry, stop_after_attempt, wait_exponential
import httpx

# ── 方案1:工具内部处理,返回错误信息给 LLM ───────────────
@tool
def robust_search(query: str) -> str:
    """搜索网络信息。如果失败,会返回错误描述供 Agent 决策。"""
    try:
        result = call_search_api(query)
        return result
    except httpx.TimeoutException:
        return "搜索超时,建议尝试更简短的关键词或稍后重试"
    except httpx.HTTPStatusError as e:
        return f"搜索API返回错误 {e.response.status_code},请尝试其他方式"
    except Exception as e:
        return f"搜索失败:{type(e).__name__}: {str(e)}"

# ── 方案2:自动重试(适合网络不稳定的工具)───────────────
@tool
@retry(stop=stop_after_attempt(3),
       wait=wait_exponential(multiplier=1, min=2, max=10))
def reliable_api_call(endpoint: str, params: str) -> str:
    """调用外部 API,自动重试最多3次。"""
    response = httpx.get(endpoint, params=json.loads(params), timeout=10)
    response.raise_for_status()
    return response.text

# ── 方案3:ToolNode 级别的统一错误处理 ───────────────────
from langgraph.prebuilt import ToolNode

# handle_tool_errors=True:工具异常会被捕获并作为 ToolMessage 返回
# 让 LLM 能看到错误并决定如何处理(重试/换方法/告知用户)
tool_node = ToolNode(tools, handle_tool_errors=True)

# 自定义错误处理器
def custom_error_handler(error: Exception) -> str:
    error_type = type(error).__name__
    if isinstance(error, httpx.TimeoutException):
        return "工具调用超时(10s),请简化请求或换一种方式完成任务"
    elif isinstance(error, ValueError):
        return f"参数格式错误:{error},请检查参数类型"
    return f"{error_type}:{str(error)[:200]}"

tool_node_robust = ToolNode(tools, handle_tool_errors=custom_error_handler)
工具安全性警告 永远不要让 Agent 无限制地执行危险操作(删除文件、发送大量邮件、修改生产数据库)。写入操作必须:1)限制执行频率;2)实现事务回滚;3)关键操作需人工确认(Human-in-the-loop)。

并行工具调用

GPT-4o 和 Claude 3.5 支持一次输出多个工具调用。LangGraph 的 ToolNode 会自动并行执行:

import asyncio
from langchain_core.tools import tool

# 声明异步工具(支持并发执行)
@tool
async def async_search(query: str) -> str:
    """异步搜索工具,支持并发调用。"""
    async with httpx.AsyncClient() as client:
        resp = await client.get(
            "https://api.tavily.com/search",
            params={"query": query},
            timeout=10
        )
        return resp.json()["results"][0]["content"]

# ToolNode 自动并行执行多个工具调用
# 当 LLM 输出:[search("A"), search("B"), calculate("1+1")]
# ToolNode 会并发执行三个工具,汇总结果后返回
tool_node = ToolNode([async_search, calculate])

# 运行时使用异步版本
async def run():
    result = await graph.ainvoke({...})
    return result
工具调用 vs 结构化输出 — 如何选择 使用工具调用:当你需要 Agent 执行副作用操作(搜索、计算、写入)时。使用结构化输出:当你只需要 LLM 按特定格式生成数据(分类、评分、摘要提取)时。后者不触发工具执行,速度更快,成本更低。