目 录CONTENT

文章目录

LangGraph 工具调用实战:ToolNode、Function Call 与错误处理

PySuper
2025-10-04 / 0 评论 / 0 点赞 / 1 阅读 / 0 字
温馨提示:
本文最后更新于2026-05-22,若内容或图片失效,请留言反馈。 所有牛逼的人都有一段苦逼的岁月。 但是你只要像SB一样去坚持,终将牛逼!!! ✊✊✊

作者:PySuper | 来源zhengxingtao.com | 更新日期:2026-03-08

系列:Python/AI工程化 第43篇 | 上一篇:第42篇 LangGraph 快速上手 | 关联阅读:第23篇 Tool Calling 通用实践、第19篇 状态机设计、第39篇 人在回路

一、为什么 Agent 需要工具

纯 LLM 是个「嘴强王者」——能聊会写,但一动手就废:不能联网查资料,不能跑代码,不能操作数据库,连今天的天气都得瞎编。工具(Tool Calling)就是让 Agent 从「只会说」变成「能干活」的关键一跳。

LangGraph 中工具调用的完整生命周期:

plaintext

┌─────────────────────────────────────────────────────────────────────┐
│                    LangGraph 工具调用完整生命周期                     │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│   用户输入                                                          │
│     │                                                               │
│     ▼                                                               │
│  ┌──────────┐    ┌──────────────────┐    ┌──────────────────┐      │
│  │  Agent   │───▶│  LLM 推理        │───▶│ 返回 AIMessage   │      │
│  │  Node    │    │ (带工具描述)      │    │ 含 tool_calls?   │      │
│  └──────────┘    └──────────────────┘    └────────┬─────────┘      │
│       ▲                                           │                │
│       │                                     ┌─────┴──────┐         │
│       │                                     │ 有tool_calls│         │
│       │                                     └─────┬──────┘         │
│       │                                           │ 是             │
│       │                                           ▼                │
│       │                                  ┌───────────────┐         │
│       │                                  │   ToolNode    │         │
│       │                                  │  解析并执行    │         │
│       │                                  │  tool_calls   │         │
│       │                                  └───────┬───────┘         │
│       │                                          │                │
│       │                                          ▼                │
│       │                                  ┌───────────────┐         │
│       │                                  │ ToolMessage   │         │
│       │                                  │ 工具结果回传    │         │
│       │                                  └───────┬───────┘         │
│       │                                          │                │
│       └──────────────────────────────────────────┘                │
│                     (回到 Agent 继续推理)                           │
│                                                                     │
│   无 tool_calls 时 → END(返回最终回答)                              │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

关键点:LLM 不直接执行工具,它只决定"调什么、传什么参数",真正的执行由 ToolNode 完成。 这个分离是 LangGraph 工具调用的核心设计。

二、@tool 装饰器:定义工具

2.1 基本用法

@tool 装饰器做三件事:

  1. 把 Python 函数包装成 LangChain 工具对象

  2. 类型注解 自动生成 JSON Schema(告诉 LLM 参数类型)

  3. docstring 提取工具描述(LLM 靠这个决定调不调)

python

from langchain_core.tools import tool

@tool
def search_web(query: str) -> str:
    """搜索互联网获取实时信息。

    当用户询问最新新闻、实时数据或需要联网查询的信息时使用此工具。
    """
    # 实际实现中调用搜索 API
    return f"搜索 '{query}' 的结果:..."

2.2 类型注解 = 自动 Schema

类型注解不是摆设,@tool 会解析它们生成 JSON Schema:

python

@tool
def calculate(expression: str, precision: int = 2) -> str:
    """计算数学表达式,支持基本四则运算。

    Args:
        expression: 数学表达式,如 "2+3*4"
        precision: 结果保留小数位数,默认2位
    """
    result = eval(expression)  # 生产环境请用安全的方式
    return f"{result:.{precision}f}"

生成的 Schema 大致如下(LLM 看到的):

json

{
  "name": "calculate",
  "description": "计算数学表达式,支持基本四则运算。",
  "parameters": {
    "type": "object",
    "properties": {
      "expression": {"type": "string", "description": "数学表达式,如 \"2+3*4\""},
      "precision": {"type": "integer", "description": "结果保留小数位数,默认2位", "default": 2}
    },
    "required": ["expression"]
  }
}

2.3 docstring 是灵魂

LLM 全靠 docstring 判断"这个工具能不能解决当前问题" 。写好 docstring 是工具调用的第一要务:

python

# ❌ 烂写法:LLM 不知道什么时候该用
@tool
def search(query: str) -> str:
    """搜索"""
    return "..."

# ✅ 好写法:明确说明用途、适用场景、参数含义
@tool
def search_web(query: str) -> str:
    """搜索互联网获取实时信息。

    适用场景:
    - 用户询问最新新闻、实时数据
    - 需要查询当前日期之后的信息
    - 需要验证某个事实的最新状态

    不适用:纯知识类问题(直接回答即可)

    Args:
        query: 搜索关键词,应简洁精准
    """
    return f"搜索结果:..."

2.4 多参数与可选参数

python

from typing import Optional
from pydantic import BaseModel, Field

# 方式1:直接用类型注解 + 默认值
@tool
def query_database(
    sql: str,
    database: str = "default",
    limit: int = 100,
) -> str:
    """执行 SQL 查询。

    Args:
        sql: SQL 查询语句
        database: 数据库名称,默认使用 default
        limit: 返回结果上限,默认100条
    """
    return f"在 {database} 执行: {sql[:50]}... (limit={limit})"

# 方式2:Pydantic Schema(复杂参数推荐)
class FileReadInput(BaseModel):
    file_path: str = Field(description="文件路径,如 /data/report.csv")
    encoding: str = Field(default="utf-8", description="文件编码")
    max_lines: Optional[int] = Field(default=None, description="最大读取行数,None表示全部")

@tool("file_read", args_schema=FileReadInput)
def file_read(file_path: str, encoding: str = "utf-8", max_lines: Optional[int] = None) -> str:
    """读取文件内容。支持文本文件和CSV文件的读取。"""
    return f"读取 {file_path} (encoding={encoding}, max_lines={max_lines})"

2.5 五个实用工具定义

python

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


# 1. 网络搜索
@tool
def web_search(query: str, max_results: int = 5) -> str:
    """搜索互联网获取实时信息。

    适用于:最新新闻、实时数据、事实核查。
    不适用于:纯知识问答、数学计算。

    Args:
        query: 搜索关键词
        max_results: 返回结果数量上限
    """
    # 实际调用搜索 API(如 Tavily、Serper)
    return json.dumps([
        {"title": f"搜索结果 {i+1}", "snippet": f"关于 {query} 的信息..."}
        for i in range(max_results)
    ], ensure_ascii=False)


# 2. 代码执行
class CodeExecInput(BaseModel):
    code: str = Field(description="要执行的 Python 代码")
    timeout: int = Field(default=30, description="执行超时时间(秒)")

@tool("code_execute", args_schema=CodeExecInput)
def code_execute(code: str, timeout: int = 30) -> str:
    """在沙箱中执行 Python 代码并返回结果。

    适用于:数据分析、数学计算、文本处理。
    注意:代码在受限环境中执行,不支持网络访问和文件写入。

    Args:
        code: 要执行的 Python 代码
        timeout: 执行超时时间(秒),默认30秒
    """
    try:
        local_vars = {}
        exec(code, {"__builtins__": {}}, local_vars)
        return str(local_vars.get("result", "代码执行成功,无返回值"))
    except Exception as e:
        return f"执行错误: {type(e).__name__}: {e}"


# 3. 文件读取
@tool
def file_read(file_path: str) -> str:
    """读取文件内容。

    适用于:查看配置文件、读取数据文件、检查日志。
    Args:
        file_path: 文件路径
    """
    try:
        with open(file_path, "r", encoding="utf-8") as f:
            content = f.read(10000)  # 限制读取大小
        return content if content else "文件为空"
    except FileNotFoundError:
        return f"错误:文件 {file_path} 不存在"
    except Exception as e:
        return f"读取错误: {e}"


# 4. 数据库查询
@tool
def db_query(sql: str, database: str = "main") -> str:
    """执行 SQL 查询并返回结果。

    适用于:查询业务数据、统计分析。
    仅支持 SELECT 语句,不支持修改操作。

    Args:
        sql: SQL 查询语句(仅 SELECT)
        database: 数据库名称
    """
    # 安全校验
    if not sql.strip().upper().startswith("SELECT"):
        return "错误:仅支持 SELECT 查询"

    # 模拟查询结果
    return json.dumps({
        "columns": ["id", "name", "value"],
        "rows": [[1, "sample", 42]],
        "row_count": 1
    }, ensure_ascii=False)


# 5. 获取当前时间
@tool
def get_current_time(timezone: str = "Asia/Shanghai") -> str:
    """获取当前日期和时间。

    适用于:需要知道当前时间的场景,如计算截止日期、安排日程。
    Args:
        timezone: 时区,默认 Asia/Shanghai
    """
    now = datetime.now()
    return f"当前时间: {now.strftime('%Y-%m-%d %H:%M:%S')} (时区: {timezone})"

三、ToolNode:LangGraph 的工具执行器

3.1 ToolNode 是什么

ToolNode 是 LangGraph 预置的工具执行节点,它做了一件事:自动执行 LLM 返回的 tool_calls,并把结果包装成 ToolMessage 回传。

看起来简单,但如果你手动实现,要处理的边界情况一堆:

  • 并行工具调用(LLM 一次返回多个 tool_call)

  • 工具执行异常捕获和格式化

  • ToolMessage 的 tool_call_id 必须与 AIMessage.tool_calls 中的 id 对应

  • 工具返回值的序列化(str / dict / list 都要处理)

  • Command 对象的传播(工具内部更新 State)

3.2 基本用法

python

from langgraph.prebuilt import ToolNode

# 创建 ToolNode,传入工具列表
tool_node = ToolNode(tools=[web_search, code_execute, file_read, db_query])

ToolNode 要求 State 中有 messages 字段,且使用 add_messages reducer:

python

from typing import Annotated
from typing_extensions import TypedDict
from langgraph.graph.message import add_messages

class State(TypedDict):
    messages: Annotated[list, add_messages]

3.3 工具调用循环

LangGraph 中工具调用的核心是 Agent → ToolNode 的循环

plaintext

┌─────────────────────────────────────────────────────────────────────┐
│                    ReAct 工具调用循环                                │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│   START                                                             │
│     │                                                               │
│     ▼                                                               │
│  ┌──────────┐                                                       │
│  │  agent   │  call_model(): LLM 推理,决定是否调用工具               │
│  └────┬─────┘                                                       │
│       │                                                             │
│       ▼                                                             │
│  should_continue()                                                  │
│       │                                                             │
│       ├── 有 tool_calls ──▶ ┌──────────┐                            │
│       │                     │  tools   │  ToolNode 执行工具          │
│       │                     └────┬─────┘                            │
│       │                          │                                  │
│       │                          └──────▶ 回到 agent(继续推理)      │
│       │                                                             │
│       └── 无 tool_calls ──▶ END(返回最终回答)                      │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

3.4 should_continue 路由函数

这是整个循环的"方向盘"——判断 LLM 的输出是要调工具,还是直接结束:

python

from typing import Literal
from langgraph.graph import END, MessagesState

def should_continue(state: MessagesState) -> Literal["tools", "__end__"]:
    """判断是否需要继续调用工具。

    检查最后一条消息(LLM 输出)是否包含 tool_calls:
    - 有 → 路由到 "tools" 节点执行工具
    - 无 → 路由到 END,返回最终回答
    """
    messages = state["messages"]
    last_message = messages[-1]

    if last_message.tool_calls:
        return "tools"
    return END

也可以使用 LangGraph 预置的 tools_condition

python

from langgraph.prebuilt import tools_condition

# tools_condition 功能与 should_continue 完全等价
workflow.add_conditional_edges("agent", tools_condition, ["tools", END])

3.5 完整的最小可运行示例

python

from typing import Annotated, Literal
from typing_extensions import TypedDict

from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode


# 1. 定义工具
@tool
def get_weather(location: str) -> str:
    """查询指定城市的天气信息。

    Args:
        location: 城市名称,如 "北京"、"上海"、"San Francisco"
    """
    weather_data = {
        "北京": "晴天,12°C,空气质量良好",
        "上海": "多云,18°C,有轻微雾霾",
        "san francisco": "雾天,15°C,典型SF天气",
    }
    return weather_data.get(location.lower(), f"{location}:暂无天气数据")


@tool
def calculator(expression: str) -> str:
    """计算数学表达式。

    Args:
        expression: 数学表达式,如 "2 + 3 * 4"
    """
    try:
        result = eval(expression)
        return f"计算结果: {expression} = {result}"
    except Exception as e:
        return f"计算错误: {e}"


# 2. 定义状态
class State(TypedDict):
    messages: Annotated[list, add_messages]


# 3. 创建工具节点和模型
tools = [get_weather, calculator]
tool_node = ToolNode(tools)

model = ChatOpenAI(model="gpt-4o-mini", temperature=0)
model_with_tools = model.bind_tools(tools)


# 4. 定义节点函数
def call_model(state: State):
    """Agent 节点:调用 LLM 推理"""
    response = model_with_tools.invoke(state["messages"])
    return {"messages": [response]}


def should_continue(state: State) -> Literal["tools", "__end__"]:
    """路由函数:判断是否调用工具"""
    last_message = state["messages"][-1]
    if last_message.tool_calls:
        return "tools"
    return END


# 5. 构建图
workflow = StateGraph(State)

# 添加节点
workflow.add_node("agent", call_model)
workflow.add_node("tools", tool_node)

# 添加边
workflow.add_edge(START, "agent")
workflow.add_conditional_edges("agent", should_continue, ["tools", END])
workflow.add_edge("tools", "agent")  # 工具执行后回到 agent

# 编译
app = workflow.compile()


# 6. 运行
if __name__ == "__main__":
    result = app.invoke({
        "messages": [{"role": "user", "content": "北京天气怎么样?顺便帮我算一下 (12 + 8) * 3"}]
    })

    for msg in result["messages"]:
        print(f"[{msg.type.upper()}] {msg.content}")

运行输出类似:

plaintext

[HUMAN] 北京天气怎么样?顺便帮我算一下 (12 + 8) * 3
[AI]  (tool_calls: get_weather, calculator)
[TOOL] 晴天,12°C,空气质量良好
[TOOL] 计算结果: (12 + 8) * 3 = 60
[AI] 北京现在是晴天,气温12°C,空气质量良好。另外,(12 + 8) × 3 = 60。

四、实战:多功能研究 Agent

把前面的工具组合起来,构建一个能搜索、能算、能读文件、能查库的研究 Agent:

python

import json
from typing import Annotated, Literal, Optional
from datetime import datetime

from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, START, END, MessagesState
from langgraph.prebuilt import ToolNode
from pydantic import BaseModel, Field


# ==================== 工具定义 ====================

@tool
def web_search(query: str, max_results: int = 3) -> str:
    """搜索互联网获取实时信息。适用于最新新闻、实时数据、事实核查。

    Args:
        query: 搜索关键词,应简洁精准
        max_results: 返回结果数量上限,默认3
    """
    # 实际实现中调用 Tavily / Serper / Bing API
    mock_results = [
        {"title": f"关于 {query} 的最新报道", "url": "https://example.com/1",
         "snippet": f"根据最新消息,{query} 的相关情况如下..."},
        {"title": f"{query} 深度分析", "url": "https://example.com/2",
         "snippet": f"专家对 {query} 的深度解读..."},
    ]
    return json.dumps(mock_results[:max_results], ensure_ascii=False)


class CodeExecInput(BaseModel):
    code: str = Field(description="要执行的 Python 代码,结果赋值给变量 result")
    timeout: int = Field(default=30, description="超时秒数")

@tool("code_execute", args_schema=CodeExecInput)
def code_execute(code: str, timeout: int = 30) -> str:
    """在沙箱中执行 Python 代码。适用于数据分析、数学计算、文本处理。

    Args:
        code: Python 代码,执行结果需赋值给 result 变量
        timeout: 执行超时时间(秒)
    """
    try:
        safe_builtins = {
            "abs": abs, "len": len, "range": range,
            "int": int, "float": float, "str": str,
            "list": list, "dict": dict, "set": set,
            "sum": sum, "min": min, "max": max,
            "sorted": sorted, "enumerate": enumerate,
            "zip": zip, "map": map, "filter": filter,
        }
        local_vars = {}
        exec(code, {"__builtins__": safe_builtins}, local_vars)
        result = local_vars.get("result", "代码执行成功,无返回值")
        return str(result)
    except Exception as e:
        return f"执行错误: {type(e).__name__}: {e}"


@tool
def file_read(file_path: str) -> str:
    """读取文件内容。适用于查看配置、数据文件、日志。

    Args:
        file_path: 文件路径
    """
    try:
        with open(file_path, "r", encoding="utf-8") as f:
            content = f.read(50000)
        return content[:10000] if len(content) > 10000 else content
    except FileNotFoundError:
        return f"错误:文件 {file_path} 不存在"
    except Exception as e:
        return f"读取错误: {e}"


@tool
def db_query(sql: str) -> str:
    """执行只读 SQL 查询。适用于查询业务数据、统计分析。

    仅支持 SELECT 语句,禁止 INSERT/UPDATE/DELETE。

    Args:
        sql: SQL 查询语句
    """
    if not sql.strip().upper().startswith("SELECT"):
        return "安全限制:仅支持 SELECT 查询"

    # 模拟查询
    return json.dumps({
        "columns": ["id", "name", "value"],
        "rows": [[1, "sample", 42], [2, "test", 88]],
        "row_count": 2
    }, ensure_ascii=False)


# ==================== 构建 Agent ====================

tools = [web_search, code_execute, file_read, db_query]
tool_node = ToolNode(tools)

model = ChatOpenAI(model="gpt-4o", temperature=0).bind_tools(tools)


def call_model(state: MessagesState):
    """Agent 节点"""
    system_prompt = {
        "role": "system",
        "content": (
            "你是一个研究助手。你可以搜索互联网、执行代码、读取文件、查询数据库。\n"
            "请根据用户问题,选择合适的工具来获取信息,然后综合分析给出回答。\n"
            "如果需要多步操作,请一步步来,每次调用一个或多个工具。"
        )
    }
    messages = [system_prompt] + state["messages"]
    response = model.invoke(messages)
    return {"messages": [response]}


def should_continue(state: MessagesState) -> Literal["tools", "__end__"]:
    """路由函数"""
    last_message = state["messages"][-1]
    if last_message.tool_calls:
        return "tools"
    return END


# 构建图
workflow = StateGraph(MessagesState)
workflow.add_node("agent", call_model)
workflow.add_node("tools", tool_node)
workflow.add_edge(START, "agent")
workflow.add_conditional_edges("agent", should_continue, ["tools", END])
workflow.add_edge("tools", "agent")

app = workflow.compile()


# ==================== 运行 ====================

if __name__ == "__main__":
    result = app.invoke({
        "messages": [{
            "role": "user",
            "content": "搜索一下 LangGraph 最新的版本号,然后帮我算一下 2^20 是多少"
        }]
    })

    print("=" * 60)
    print("最终回答:")
    print(result["messages"][-1].content)

执行流程图:

plaintext

用户:"搜索 LangGraph 最新版本号,算 2^20"
  │
  ▼
Agent Node (LLM 推理)
  │ → 返回 AIMessage,含 tool_calls:
  │   1. web_search(query="LangGraph latest version")
  │   2. code_execute(code="result = 2**20")
  ▼
ToolNode (并行执行两个工具)
  │ → ToolMessage 1: 搜索结果...
  │ → ToolMessage 2: 1048576
  ▼
Agent Node (LLM 综合分析)
  │ → 返回 AIMessage,无 tool_calls
  ▼
END → "LangGraph 最新版本是 0.2.x,2^20 = 1,048,576"

五、错误处理

工具调用在真实场景中不可能一帆风顺。超时、参数错误、格式异常……都需要妥善处理。

5.1 ToolNode 内置错误处理

ToolNode 自带错误处理机制——当工具执行抛异常时,它不会让整个图崩溃,而是把错误信息包装成 ToolMessage 回传给 LLM,让 LLM 自行修正:

python

# ToolNode 内部大致逻辑(简化版):
# try:
#     result = tool.invoke(tool_call.args)
#     return ToolMessage(content=str(result), tool_call_id=tool_call.id)
# except Exception as e:
#     return ToolMessage(
#         content=f"Error: {e}\n Please fix your mistakes.",
#         tool_call_id=tool_call.id,
#         status="error"  # 标记为错误
#     )

可以通过 handle_tool_errors 参数控制行为:

python

from langgraph.prebuilt import ToolNode

# 方式1:True — 捕获所有异常,回传错误信息给 LLM(默认行为)
tool_node = ToolNode(tools=tools, handle_tool_errors=True)

# 方式2:False — 异常直接抛出,由图级别处理
tool_node = ToolNode(tools=tools, handle_tool_errors=False)

# 方式3:指定只捕获某些异常类型
tool_node = ToolNode(tools=tools, handle_tool_errors=(ValueError, TypeError))

# 方式4:自定义错误消息
tool_node = ToolNode(
    tools=tools,
    handle_tool_errors="工具执行失败,请检查参数后重试"
)

# 方式5:用函数动态生成错误消息
def custom_error_handler(e: Exception) -> str:
    if isinstance(e, TimeoutError):
        return f"操作超时: {e}。请简化请求或增加超时时间。"
    return f"执行出错: {e}。请修正后重试。"

tool_node = ToolNode(tools=tools, handle_tool_errors=custom_error_handler)

5.2 常见错误场景及处理

python

import time
from langchain_core.tools import tool


# 场景1:工具执行超时
@tool
def slow_api_call(query: str) -> str:
    """调用可能超时的外部 API。"""
    import signal

    def timeout_handler(signum, frame):
        raise TimeoutError(f"API 调用超时: {query}")

    # 设置超时
    signal.signal(signal.SIGALRM, timeout_handler)
    signal.alarm(5)  # 5秒超时

    try:
        # 模拟慢 API
        time.sleep(10)
        return "API 结果"
    finally:
        signal.alarm(0)  # 取消定时器


# 场景2:工具参数校验
@tool
def safe_db_query(sql: str) -> str:
    """执行安全的数据库查询。仅支持 SELECT。"""
    # 参数校验
    if not sql or not sql.strip():
        raise ValueError("SQL 语句不能为空")

    if not sql.strip().upper().startswith("SELECT"):
        raise ValueError(f"仅支持 SELECT 查询,收到: {sql[:20]}...")

    if len(sql) > 10000:
        raise ValueError("SQL 语句过长,最大支持10000字符")

    return "查询结果..."


# 场景3:LLM 返回了无法解析的 tool_call
# 这种情况 ToolNode 会自动处理:
# - 找不到对应工具名 → 返回错误 ToolMessage
# - 参数格式不匹配 → 返回错误 ToolMessage

5.3 自定义错误恢复策略

生产环境中,简单的"把错误回传 LLM"不够用。我们需要更精细的恢复策略:

python

from typing import Annotated, Literal
from langchain_core.messages import AIMessage, RemoveMessage, ToolMessage
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, START, END, MessagesState
from langgraph.prebuilt import ToolNode


# 定义工具
@tool
def risky_operation(action: str) -> str:
    """执行可能有风险的操作。"""
    import random
    if random.random() < 0.5:
        raise ConnectionError("网络连接失败")
    if action == "delete":
        raise PermissionError("不允许删除操作")
    return f"操作成功: {action}"


tools = [risky_operation]
tool_node = ToolNode(tools, handle_tool_errors=True)

# 主模型和备用模型
primary_model = ChatOpenAI(model="gpt-4o-mini", temperature=0).bind_tools(tools)
fallback_model = ChatOpenAI(model="gpt-4o", temperature=0).bind_tools(tools)


def call_model(state: MessagesState):
    """Agent 节点"""
    response = primary_model.invoke(state["messages"])
    return {"messages": [response]}


def should_continue(state: MessagesState) -> Literal["tools", "__end__"]:
    """是否需要调用工具"""
    last_message = state["messages"][-1]
    if last_message.tool_calls:
        return "tools"
    return END


def should_fallback(state: MessagesState) -> Literal["agent", "remove_failed", "__end__"]:
    """工具执行后:检查是否有错误,决定恢复策略"""
    messages = state["messages"]
    # 检查最近的 ToolMessage 是否有错误
    failed_tool_messages = [
        msg for msg in messages
        if isinstance(msg, ToolMessage)
        and msg.status == "error"
    ]
    if failed_tool_messages:
        return "remove_failed"  # 有错误 → 清理后用备用模型重试
    return "agent"  # 无错误 → 回到正常 Agent 流程


def remove_failed_tool_call_attempt(state: MessagesState):
    """移除失败的工具调用尝试,清理状态"""
    messages = state["messages"]
    # 找到最近的 AIMessage,移除它和后续的所有 ToolMessage
    last_ai_idx = next(
        i for i, msg in reversed(list(enumerate(messages)))
        if isinstance(msg, AIMessage)
    )
    messages_to_remove = messages[last_ai_idx:]
    return {"messages": [RemoveMessage(id=m.id) for m in messages_to_remove]}


def call_fallback_model(state: MessagesState):
    """使用更强大的模型重试"""
    response = fallback_model.invoke(state["messages"])
    return {"messages": [response]}


# 构建带错误恢复的图
workflow = StateGraph(MessagesState)
workflow.add_node("agent", call_model)
workflow.add_node("tools", tool_node)
workflow.add_node("remove_failed", remove_failed_tool_call_attempt)
workflow.add_node("fallback_agent", call_fallback_model)

workflow.add_edge(START, "agent")
workflow.add_conditional_edges("agent", should_continue, ["tools", END])
workflow.add_conditional_edges("tools", should_fallback)
workflow.add_edge("remove_failed", "fallback_agent")
workflow.add_edge("fallback_agent", "tools")

app = workflow.compile()

错误恢复流程图:

plaintext

┌──────────────────────────────────────────────────────────────────┐
│                  带错误恢复的 Agent 流程                           │
├──────────────────────────────────────────────────────────────────┤
│                                                                  │
│   START                                                          │
│     │                                                            │
│     ▼                                                            │
│  ┌──────────┐                                                    │
│  │  agent   │ (主模型 gpt-4o-mini)                               │
│  └────┬─────┘                                                    │
│       │                                                          │
│  should_continue()                                               │
│       │                                                          │
│       ├── 无 tool_calls ──▶ END                                  │
│       │                                                          │
│       └── 有 tool_calls ──▶ ┌──────────┐                         │
│                             │  tools   │                          │
│                             └────┬─────┘                          │
│                                  │                               │
│                           should_fallback()                      │
│                                  │                               │
│                    ┌─────────────┼─────────────┐                  │
│                    │ 成功        │ 有错误       │                  │
│                    ▼             ▼              │                  │
│               ┌─────────┐  ┌──────────────┐    │                  │
│               │  agent  │  │ remove_failed│    │                  │
│               │(继续)   │  │ (清理状态)    │    │                  │
│               └─────────┘  └──────┬───────┘    │                  │
│                                  │              │                  │
│                                  ▼              │                  │
│                          ┌────────────────┐     │                  │
│                          │fallback_agent  │     │                  │
│                          │(备用模型 gpt-4o)│     │                  │
│                          └───────┬────────┘     │                  │
│                                  │              │                  │
│                                  └──▶ tools ────┘                  │
│                                     (重试工具调用)                  │
│                                                                  │
└──────────────────────────────────────────────────────────────────┘

5.4 带重试和降级的工具包装器

对于工具本身的执行,推荐用装饰器封装重试逻辑:

python

import time
import functools
from typing import Callable, Optional


def resilient_tool(
    max_retries: int = 3,
    retry_delay: float = 1.0,
    fallback_value: Optional[str] = None,
    timeout_seconds: int = 30,
):
    """让工具具备重试和降级能力的装饰器。

    Args:
        max_retries: 最大重试次数
        retry_delay: 重试间隔(秒),指数退避
        fallback_value: 所有重试失败后的降级返回值
        timeout_seconds: 单次执行超时(秒)
    """
    def decorator(func: Callable):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            last_error = None

            for attempt in range(max_retries):
                try:
                    # 这里可以加超时控制
                    return func(*args, **kwargs)
                except Exception as e:
                    last_error = e
                    if attempt < max_retries - 1:
                        delay = retry_delay * (2 ** attempt)  # 指数退避
                        time.sleep(delay)

            # 所有重试都失败
            if fallback_value is not None:
                return fallback_value
            raise last_error

        return wrapper
    return decorator


# 使用示例
@tool
@resilient_tool(max_retries=3, retry_delay=1.0, fallback_value="服务暂时不可用,请稍后重试")
def search_api(query: str) -> str:
    """搜索 API,自带重试和降级。"""
    import random
    if random.random() < 0.7:
        raise ConnectionError("API 连接失败")
    return f"搜索结果: {query}"

六、工具安全与权限控制

Agent 有了工具就像给了员工系统权限——不控制就出事。

6.1 工具白名单/黑名单

python

from langchain_core.tools import tool, BaseTool
from typing import Optional


class ToolAccessControl:
    """工具访问控制器:白名单 + 黑名单机制"""

    def __init__(
        self,
        whitelist: Optional[list[str]] = None,
        blacklist: Optional[list[str]] = None,
    ):
        self.whitelist = whitelist  # 白名单:只允许这些工具
        self.blacklist = blacklist or []  # 黑名单:禁止这些工具

    def filter_tools(self, tools: list[BaseTool], user_role: str = "user") -> list[BaseTool]:
        """根据用户角色过滤可用工具"""
        filtered = []

        for tool in tools:
            tool_name = tool.name

            # 黑名单优先
            if tool_name in self.blacklist:
                continue

            # 白名单模式:只允许白名单中的工具
            if self.whitelist and tool_name not in self.whitelist:
                continue

            filtered.append(tool)

        return filtered


# 按角色定义权限
ROLE_PERMISSIONS = {
    "admin": {
        "whitelist": None,  # None = 允许所有
        "blacklist": [],
    },
    "analyst": {
        "whitelist": ["web_search", "code_execute", "db_query", "file_read"],
        "blacklist": [],
    },
    "viewer": {
        "whitelist": ["web_search", "db_query"],
        "blacklist": ["code_execute", "file_read"],  # 不能执行代码和读文件
    },
}


# 在 Agent 中使用
def create_agent_with_role(role: str):
    """根据角色创建 Agent"""
    all_tools = [web_search, code_execute, file_read, db_query]

    # 获取角色权限
    perms = ROLE_PERMISSIONS.get(role, ROLE_PERMISSIONS["viewer"])

    # 过滤工具
    access_control = ToolAccessControl(
        whitelist=perms["whitelist"],
        blacklist=perms["blacklist"],
    )
    allowed_tools = access_control.filter_tools(all_tools, role)

    tool_node = ToolNode(tools=allowed_tools)
    model = ChatOpenAI(model="gpt-4o-mini", temperature=0).bind_tools(allowed_tools)

    # ... 构建图
    return tool_node, model

6.2 敏感操作需人工确认

对于危险操作(删除、执行不可信代码、发送消息等),应该结合 LangGraph 的 interrupt 机制(详见第39篇人在回路):

python

from langgraph.types import interrupt, Command
from langchain_core.tools import tool


@tool
def delete_records(table: str, condition: str) -> str:
    """删除数据库记录。⚠️ 危险操作,需人工确认。

    Args:
        table: 表名
        condition: 删除条件
    """
    # 请求人工确认
    human_decision = interrupt(
        f"⚠️ 危险操作确认:是否删除 {table} 中满足 {condition} 的记录?\n"
        f"请确认 (yes/no)"
    )

    if human_decision == "yes":
        # 执行删除
        return f"已删除 {table} 中满足 {condition} 的记录"
    else:
        return "操作已取消"


@tool
def send_email(to: str, subject: str, body: str) -> str:
    """发送邮件。⚠️ 需人工确认后发送。

    Args:
        to: 收件人邮箱
        subject: 邮件主题
        body: 邮件正文
    """
    human_decision = interrupt(
        f"📧 发送邮件确认:\n"
        f"收件人: {to}\n"
        f"主题: {subject}\n"
        f"正文: {body[:100]}...\n"
        f"确认发送?(yes/no)"
    )

    if human_decision == "yes":
        return f"邮件已发送至 {to}"
    return "邮件发送已取消"

6.3 工具调用审计日志

python

import json
import logging
from datetime import datetime
from langchain_core.tools import tool
from langchain_core.runnables import RunnableConfig

logger = logging.getLogger("tool_audit")


# 审计装饰器
def audit_tool(func):
    """为工具添加审计日志"""
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        tool_name = func.__name__
        timestamp = datetime.now().isoformat()
        call_id = kwargs.get("config", {}).get("configurable", {}).get("run_id", "unknown")

        # 记录调用
        logger.info(json.dumps({
            "event": "tool_call_start",
            "tool": tool_name,
            "call_id": call_id,
            "timestamp": timestamp,
            "args": str(kwargs)[:500],  # 截断避免日志过大
        }, ensure_ascii=False))

        start_time = time.time()
        try:
            result = func(*args, **kwargs)

            # 记录成功
            logger.info(json.dumps({
                "event": "tool_call_success",
                "tool": tool_name,
                "call_id": call_id,
                "duration_ms": int((time.time() - start_time) * 1000),
                "result_length": len(str(result)),
            }, ensure_ascii=False))

            return result
        except Exception as e:
            # 记录失败
            logger.error(json.dumps({
                "event": "tool_call_error",
                "tool": tool_name,
                "call_id": call_id,
                "duration_ms": int((time.time() - start_time) * 1000),
                "error_type": type(e).__name__,
                "error_message": str(e)[:200],
            }, ensure_ascii=False))
            raise

    return wrapper


# 使用
@tool
@audit_tool
def sensitive_query(sql: str) -> str:
    """查询敏感数据。所有调用都会被审计记录。"""
    return "查询结果..."

6.4 速率限制

python

import time
from collections import defaultdict


class RateLimiter:
    """工具调用速率限制器"""

    def __init__(self, max_calls: int = 10, window_seconds: int = 60):
        self.max_calls = max_calls
        self.window_seconds = window_seconds
        self._call_times: dict[str, list[float]] = defaultdict(list)

    def check(self, tool_name: str, user_id: str = "default") -> bool:
        """检查是否允许调用"""
        key = f"{tool_name}:{user_id}"
        now = time.time()

        # 清理过期记录
        self._call_times[key] = [
            t for t in self._call_times[key]
            if now - t < self.window_seconds
        ]

        if len(self._call_times[key]) >= self.max_calls:
            return False

        self._call_times[key].append(now)
        return True


rate_limiter = RateLimiter(max_calls=10, window_seconds=60)


def rate_limited_tool(func):
    """为工具添加速率限制"""
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        if not rate_limiter.check(func.__name__):
            raise RuntimeError(
                f"工具 {func.__name__} 调用频率超限,"
                f"请稍后重试(限制:10次/分钟)"
            )
        return func(*args, **kwargs)
    return wrapper


# 使用
@tool
@rate_limited_tool
def expensive_api_call(query: str) -> str:
    """调用昂贵的外部 API(有速率限制)。"""
    return f"API 结果: {query}"

6.5 安全工具包装器(综合示例)

把上面的安全措施整合到一个统一的包装器:

python

import functools
import json
import logging
import time
from datetime import datetime
from typing import Optional, Callable
from langchain_core.tools import tool

logger = logging.getLogger("tool_security")


def secure_tool(
    *,
    require_confirmation: bool = False,
    max_retries: int = 2,
    rate_limit: Optional[int] = None,
    audit: bool = True,
    allowed_roles: Optional[list[str]] = None,
    max_result_length: int = 5000,
):
    """统一的安全工具包装器。

    Args:
        require_confirmation: 是否需要人工确认
        max_retries: 最大重试次数
        rate_limit: 速率限制(次/分钟)
        audit: 是否记录审计日志
        allowed_roles: 允许使用的角色列表
        max_result_length: 返回结果最大长度
    """
    def decorator(func: Callable):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            tool_name = func.__name__
            start_time = time.time()

            # 1. 审计日志
            if audit:
                logger.info(json.dumps({
                    "event": "tool_call",
                    "tool": tool_name,
                    "timestamp": datetime.now().isoformat(),
                }, ensure_ascii=False))

            # 2. 速率限制
            if rate_limit:
                if not rate_limiter.check(tool_name):
                    return f"错误:工具 {tool_name} 调用频率超限"

            # 3. 人工确认
            if require_confirmation:
                decision = interrupt(
                    f"⚠️ 工具 {tool_name} 需要确认才能执行。确认?(yes/no)"
                )
                if decision != "yes":
                    return "操作已取消"

            # 4. 带重试的执行
            last_error = None
            for attempt in range(max_retries + 1):
                try:
                    result = func(*args, **kwargs)

                    # 5. 截断结果
                    if isinstance(result, str) and len(result) > max_result_length:
                        result = result[:max_result_length] + f"\n... (已截断,原始长度 {len(result)})"

                    # 审计成功
                    if audit:
                        logger.info(json.dumps({
                            "event": "tool_success",
                            "tool": tool_name,
                            "duration_ms": int((time.time() - start_time) * 1000),
                        }, ensure_ascii=False))

                    return result

                except Exception as e:
                    last_error = e
                    if attempt < max_retries:
                        time.sleep(1 * (2 ** attempt))

            # 所有重试失败
            if audit:
                logger.error(json.dumps({
                    "event": "tool_failed",
                    "tool": tool_name,
                    "error": str(last_error)[:200],
                    "duration_ms": int((time.time() - start_time) * 1000),
                }, ensure_ascii=False))

            return f"工具执行失败: {type(last_error).__name__}: {last_error}"

        return wrapper
    return decorator


# 使用示例
@tool
@secure_tool(
    require_confirmation=True,  # 需人工确认
    max_retries=2,             # 最多重试2次
    rate_limit=5,              # 5次/分钟
    audit=True,                # 记录审计日志
    max_result_length=3000,    # 结果最大3KB
)
def execute_maintenance(action: str, target: str) -> str:
    """执行系统维护操作。⚠️ 危险操作,需确认。

    Args:
        action: 操作类型 (restart/clear_cache/update_config)
        target: 操作目标
    """
    return f"维护操作完成: {action} on {target}"

七、动态工具注册

有时候工具不是固定的——不同用户、不同场景、不同租户可用的工具不同。

7.1 运行时根据 State 决定可用工具

python

from typing import Annotated
from langchain_core.tools import BaseTool
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, START, END, MessagesState
from langgraph.prebuilt import ToolNode


# 所有可用工具的注册表
TOOL_REGISTRY: dict[str, BaseTool] = {
    "web_search": web_search,
    "code_execute": code_execute,
    "file_read": file_read,
    "db_query": db_query,
}

# 按用户角色分配工具
ROLE_TOOL_MAP = {
    "admin": list(TOOL_REGISTRY.keys()),
    "analyst": ["web_search", "code_execute", "db_query"],
    "viewer": ["web_search", "db_query"],
}


class DynamicAgentState(MessagesState):
    """扩展状态,包含用户角色信息"""
    role: str  # 用户角色
    available_tools: list[str]  # 当前可用工具名


def call_model_with_dynamic_tools(state: DynamicAgentState):
    """根据状态中的角色动态绑定工具"""
    role = state.get("role", "viewer")
    available_tool_names = ROLE_TOOL_MAP.get(role, ["web_search"])

    # 获取工具对象
    current_tools = [
        TOOL_REGISTRY[name]
        for name in available_tool_names
        if name in TOOL_REGISTRY
    ]

    # 动态绑定工具到模型
    model = ChatOpenAI(model="gpt-4o-mini", temperature=0)
    model_with_tools = model.bind_tools(current_tools)

    response = model_with_tools.invoke(state["messages"])
    return {"messages": [response]}


def dynamic_tool_node(state: DynamicAgentState):
    """动态选择工具执行"""
    role = state.get("role", "viewer")
    available_tool_names = ROLE_TOOL_MAP.get(role, ["web_search"])

    current_tools = [
        TOOL_REGISTRY[name]
        for name in available_tool_names
        if name in TOOL_REGISTRY
    ]

    tool_node = ToolNode(tools=current_tools)
    return tool_node.invoke(state)


def should_continue(state: DynamicAgentState):
    last_message = state["messages"][-1]
    if last_message.tool_calls:
        return "tools"
    return END


# 构建图
workflow = StateGraph(DynamicAgentState)
workflow.add_node("agent", call_model_with_dynamic_tools)
workflow.add_node("tools", dynamic_tool_node)
workflow.add_edge(START, "agent")
workflow.add_conditional_edges("agent", should_continue, ["tools", END])
workflow.add_edge("tools", "agent")

app = workflow.compile()

# 运行时指定角色
result = app.invoke({
    "messages": [{"role": "user", "content": "帮我搜索一下"}],
    "role": "viewer",  # viewer 只能用 web_search 和 db_query
})

7.2 从配置文件加载工具

yaml

# tools_config.yaml
tools:
  - name: web_search
    enabled: true
    roles: [admin, analyst, viewer]

  - name: code_execute
    enabled: true
    roles: [admin, analyst]

  - name: file_read
    enabled: true
    roles: [admin]
    config:
      allowed_paths: ["/data", "/tmp"]

  - name: db_query
    enabled: true
    roles: [admin, analyst, viewer]

  - name: send_email
    enabled: false  # 暂时禁用
    roles: [admin]

python

import yaml
from pathlib import Path


def load_tools_from_config(config_path: str, role: str) -> list:
    """从 YAML 配置文件加载工具"""
    config = yaml.safe_load(Path(config_path).read_text())

    tool_names = []
    for tool_cfg in config.get("tools", []):
        if not tool_cfg.get("enabled", True):
            continue
        if role not in tool_cfg.get("roles", []):
            continue
        tool_names.append(tool_cfg["name"])

    return [
        TOOL_REGISTRY[name]
        for name in tool_names
        if name in TOOL_REGISTRY
    ]

7.3 插件式工具架构

python

import importlib
import importlib.util
from pathlib import Path
from langchain_core.tools import BaseTool


class ToolPluginManager:
    """插件式工具管理器:自动发现和加载工具"""

    def __init__(self, plugins_dir: str = "plugins"):
        self.plugins_dir = Path(plugins_dir)
        self._registry: dict[str, BaseTool] = {}

    def discover_tools(self) -> dict[str, BaseTool]:
        """扫描插件目录,自动发现和注册工具"""
        if not self.plugins_dir.exists():
            return self._registry

        for file_path in self.plugins_dir.glob("*.py"):
            if file_path.name.startswith("_"):
                continue

            try:
                module_name = f"plugins.{file_path.stem}"
                spec = importlib.util.spec_from_file_location(module_name, file_path)
                module = importlib.util.module_from_spec(spec)
                spec.loader.exec_module(module)

                # 查找模块中所有带 is_tool 属性的对象
                for attr_name in dir(module):
                    obj = getattr(module, attr_name)
                    if hasattr(obj, "name") and hasattr(obj, "invoke"):
                        # 是 LangChain 工具对象
                        self._registry[obj.name] = obj
                        print(f"  发现工具: {obj.name}")

            except Exception as e:
                print(f"  加载失败 {file_path.name}: {e}")

        return self._registry

    def get_tools(self, names: list[str] | None = None) -> list[BaseTool]:
        """获取工具列表"""
        if names is None:
            return list(self._registry.values())
        return [self._registry[n] for n in names if n in self._registry]


# 插件文件示例:plugins/weather.py
# -------------------------
# from langchain_core.tools import tool
#
# @tool
# def get_weather(city: str) -> str:
#     """获取城市天气信息。"""
#     return f"{city}: 晴天,25°C"
# -------------------------


# 使用
plugin_manager = ToolPluginManager("plugins")
plugin_manager.discover_tools()
all_tools = plugin_manager.get_tools()
tool_node = ToolNode(tools=all_tools)

八、Structured Output 与工具结合

有时候你需要的不是自由文本回复,而是结构化数据——比如提取实体、分类结果、表单数据。

8.1 with_structured_output 基础

python

from pydantic import BaseModel, Field
from langchain_openai import ChatOpenAI


class ResearchReport(BaseModel):
    """研究报告结构"""
    title: str = Field(description="报告标题")
    summary: str = Field(description="200字以内的摘要")
    key_findings: list[str] = Field(description="关键发现列表")
    confidence: float = Field(description="置信度 0-1")
    sources: list[str] = Field(description="信息来源列表")


model = ChatOpenAI(model="gpt-4o", temperature=0)

# 方式1:纯结构化输出(不调用工具)
structured_model = model.with_structured_output(ResearchReport)

report = structured_model.invoke("分析一下 LangGraph 工具调用的优缺点")
print(report.title)
print(report.key_findings)

8.2 在 Agent 中同时使用工具和结构化输出

python

from typing import Annotated, Literal, Optional
from pydantic import BaseModel, Field
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, START, END, MessagesState
from langgraph.prebuilt import ToolNode


# 定义工具
@tool
def web_search(query: str) -> str:
    """搜索互联网获取实时信息。"""
    return f"搜索结果: {query}"


@tool
def db_query(sql: str) -> str:
    """查询数据库。"""
    return "查询结果: ..."


# 定义结构化输出 Schema
class AnalysisResult(BaseModel):
    """分析结果结构"""
    conclusion: str = Field(description="分析结论")
    evidence: list[str] = Field(description="支撑证据")
    confidence_score: float = Field(description="置信度评分 0.0-1.0")
    recommendation: Optional[str] = Field(default=None, description="行动建议")


# 构建 Agent
tools = [web_search, db_query]
tool_node = ToolNode(tools)

# 模型绑定工具
model_with_tools = ChatOpenAI(model="gpt-4o", temperature=0).bind_tools(tools)

# 结构化输出模型(用于最后一步)
structured_model = ChatOpenAI(model="gpt-4o", temperature=0).with_structured_output(AnalysisResult)


class AgentState(MessagesState):
    """Agent 状态,增加结构化结果字段"""
    analysis_result: Optional[AnalysisResult] = None


def call_model(state: AgentState):
    """Agent 节点"""
    response = model_with_tools.invoke(state["messages"])
    return {"messages": [response]}


def should_continue(state: AgentState) -> Literal["tools", "structure", "__end__"]:
    """路由:需要工具→tools,最终回答→structure"""
    last_message = state["messages"][-1]

    if last_message.tool_calls:
        return "tools"

    # LLM 认为不需要工具了,进行结构化输出
    return "structure"


def generate_structured_output(state: AgentState):
    """结构化输出节点"""
    # 用对话历史生成结构化结果
    result = structured_model.invoke(state["messages"])
    return {"analysis_result": result}


# 构建图
workflow = StateGraph(AgentState)
workflow.add_node("agent", call_model)
workflow.add_node("tools", tool_node)
workflow.add_node("structure", generate_structured_output)

workflow.add_edge(START, "agent")
workflow.add_conditional_edges("agent", should_continue, ["tools", "structure", END])
workflow.add_edge("tools", "agent")
workflow.add_edge("structure", END)

app = workflow.compile()

# 运行
result = app.invoke({
    "messages": [{
        "role": "user",
        "content": "搜索 LangGraph 的最新功能,然后分析它的优势和不足"
    }]
})

analysis = result["analysis_result"]
print(f"结论: {analysis.conclusion}")
print(f"置信度: {analysis.confidence_score}")
print(f"证据: {analysis.evidence}")

plaintext

┌─────────────────────────────────────────────────────────────────────┐
│              工具调用 + 结构化输出的 Agent 流程                       │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│   START                                                             │
│     │                                                               │
│     ▼                                                               │
│  ┌──────────┐                                                       │
│  │  agent   │ (LLM + tools,自由推理+调用工具)                       │
│  └────┬─────┘                                                       │
│       │                                                             │
│  should_continue()                                                  │
│       │                                                             │
│       ├── 有 tool_calls ──▶ ┌──────────┐ ──▶ agent (循环)           │
│       │                     │  tools   │                             │
│       │                     └──────────┘                             │
│       │                                                             │
│       └── 无 tool_calls ──▶ ┌──────────────┐                        │
│                             │  structure   │ (with_structured_output) │
│                             │  生成结构化   │                         │
│                             └──────┬───────┘                         │
│                                    │                                 │
│                                    ▼                                 │
│                                   END                                │
│                            (返回 AnalysisResult)                     │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

九、踩坑记录

坑1:docstring 不写 → LLM 不知道何时调用

python

# ❌ 灾难写法
@tool
def search(q: str) -> str:
    """search"""
    return "..."

# 问题:LLM 看到 "search" 三个字母,不知道这是搜互联网还是搜数据库
# 结果:要么乱调用,要么该调的时候不调

# ✅ 正确写法
@tool
def search_web(query: str) -> str:
    """搜索互联网获取实时信息。适用于最新新闻、实时数据查询。

    不适用于:纯知识问题(直接回答即可)、数学计算(用 calculator)。

    Args:
        query: 搜索关键词,应简洁精准
    """
    return "..."

核心原则:docstring 就是写给 LLM 的 API 文档,写得越清晰,工具调用越准确。

坑2:工具返回值太大撑爆 State

python

# ❌ 返回整个数据库表
@tool
def query_all_users() -> str:
    """查询所有用户"""
    return json.dumps(users)  # 10万条记录,几十MB

# 结果:State 中的 messages 列表暴涨,内存溢出或 Token 超限

# ✅ 限制返回大小
@tool
def query_users(limit: int = 20, offset: int = 0) -> str:
    """分页查询用户数据。

    Args:
        limit: 每页数量,默认20,最大100
        offset: 偏移量
    """
    limit = min(limit, 100)  # 强制上限
    users = db.query("SELECT * FROM users LIMIT ? OFFSET ?", limit, offset)
    result = json.dumps(users, ensure_ascii=False)
    if len(result) > 50000:  # 50KB 上限
        return result[:50000] + "\n... 结果已截断,请使用分页查询"
    return result

坑3:并行工具调用的顺序问题

LLM 可能一次返回多个 tool_calls,ToolNode 会并行执行它们。但**并行工具之间如果有依赖关系,结果可能不符合预期 **:

python

# 场景:LLM 同时调用 file_read 和 db_query
# file_read 的结果可能被 db_query 需要,但并行执行时 db_query 先完成了

# 解决方案1:在 docstring 中明确说明依赖
@tool
def db_query_with_context(sql: str, context: str = "") -> str:
    """查询数据库,可附带上下文信息。

    如果需要先读取文件再查询,请先调用 file_read,
    然后将读取的内容作为 context 参数传入。

    Args:
        sql: SQL 查询语句
        context: 额外上下文信息(可选)
    """
    return "查询结果..."


# 解决方案2:拆成多步(让 LLM 串行调用)
# 在 system prompt 中明确说明:
# "如果有工具间依赖,请逐个调用,不要并行。"

坑4:工具幂等性缺失导致重试重复执行

python

# ❌ 非幂等操作 + 自动重试 = 灾难
@tool
def send_notification(message: str) -> str:
    """发送通知"""
    api.send(message)  # 如果超时后重试,用户收到两条通知
    return "已发送"

# ✅ 幂等设计
@tool
def send_notification(message: str, idempotency_key: str) -> str:
    """发送通知。使用 idempotency_key 防止重复发送。

    Args:
        message: 通知内容
        idempotency_key: 幂等键,相同 key 的请求只执行一次
    """
    if already_sent(idempotency_key):
        return "通知已发送(跳过重复)"
    api.send(message)
    mark_sent(idempotency_key)
    return "已发送"

坑5:模型不支持 Function Calling 的兼容处理

不是所有模型都支持 tool_calls,需要兼容处理:

python

from langchain_openai import ChatOpenAI
from langchain_core.messages import AIMessage


def safe_bind_tools(model, tools):
    """安全绑定工具,兼容不支持 function calling 的模型"""
    try:
        model_with_tools = model.bind_tools(tools)
        # 测试一下是否能正常工作
        test_response = model_with_tools.invoke("test")
        return model_with_tools
    except (NotImplementedError, ValueError) as e:
        print(f"⚠️ 当前模型不支持 function calling: {e}")
        print("回退到提示词模式:将工具描述写入 system prompt")

        # 回退方案:把工具描述写入 system prompt,让模型用文本格式输出
        tool_descriptions = []
        for t in tools:
            tool_descriptions.append(
                f"- {t.name}: {t.description}\n  参数: {t.args_schema.schema()}"
            )

        system_prompt = (
            "你可以使用以下工具。如需调用,请用以下格式回复:\n"
            "ACTION: <工具名>\n"
            "ARGS: <JSON参数>\n\n"
            "可用工具:\n" + "\n".join(tool_descriptions)
        )

        return model, system_prompt  # 返回原模型 + 提示词


# 使用
model = ChatOpenAI(model="gpt-4o-mini", temperature=0)
tools = [web_search, calculator]
model_with_tools, fallback_prompt = safe_bind_tools(model, tools)

总结

工具调用是 Agent 从"聊天机器人"变成"能干活的人"的关键。LangGraph 的 ToolNode 把工具执行的脏活累活(并行调用、错误处理、消息格式化)都封装好了,让我们能专注于业务逻辑。

核心要点回顾:

表格

主题

要点

@tool

docstring 是灵魂,类型注解自动生成 Schema

ToolNode

自动执行 tool_calls,处理并行/错误/消息格式

should_continue

路由函数是循环的方向盘,有 tool_calls → tools,否则 → END

错误处理

ToolNode 内置错误回传;生产环境需要重试、降级、清理策略

安全控制

白名单/黑名单、人工确认、审计日志、速率限制,缺一不可

动态注册

根据角色/配置/插件动态决定可用工具

Structured Output

工具用于获取信息,结构化输出用于最终结果,两者互补

踩坑

docstring 要写好、返回值要截断、并行注意依赖、操作要幂等

关联阅读

  • 第23篇 Tool Calling 通用实践 — 工具调用的通用设计原则

  • 第39篇 人在回路 — 危险操作的人工确认机制

  • 第42篇 LangGraph 快速上手 — LangGraph 基础入门

  • 第19篇 状态机设计 — LangGraph 状态机原理

0
  1. 支付宝打赏

    qrcode alipay
  2. 微信打赏

    qrcode weixin

评论区