Agent Tool 调用

模型本身只能生成文本。

Agent 想要读取实时信息、执行代码、查询数据库、创建文件,就必须把外部能力包装成 Tool。

Tool 可以理解成 Agent 连接现实世界的接口:

  • 读取新信息,比如搜索、数据库、内部系统。
  • 执行外部动作,比如发请求、写文件、调用业务接口。
  • 把模型不擅长的事情交给确定性的代码,比如计算、校验、格式转换。

先记住一句话:模型负责决定要不要调用 Tool,以及生成 Tool 参数;代码负责真正执行 Tool。

Tool 解决什么问题

没有 Tool 时,模型只能基于已有上下文回答。

它不能自己访问数据库,也不能自己读取文件,更不能真的执行一个外部 API。

例如:直接问模型"现在是什么时间",它可能会告诉你自己无法实时获取当前时间,因为这类问题依赖运行时环境,不属于模型静态知识。

Tool 的作用就是把一段代码能力暴露给模型,让模型在需要时生成调用意图。

txt 复制代码
用户问题 -> 模型判断需要 Tool -> 生成 tool_calls -> 程序执行 Tool -> ToolMessage 回传结果 -> 模型继续回答

这里要分清两个动作:

  • 模型生成的是"调用计划",不是实际执行。
  • 真正执行 Tool 的是你的程序。

tool_calls 从哪里来

模型并不是"真的知道"你的函数。

你把 Tool 的 namedescriptionargs_schema 绑定给模型后,模型会根据这些信息预测下一步要不要调用 Tool。

AIMessage.tool_calls 里通常会包含:

txt 复制代码
name:模型想调用哪个 Tool
args:模型为 Tool 生成的参数
id:这次 Tool 调用的唯一标识

所以 Tool 调用本质上是一个闭环:

txt 复制代码
invoke -> 读取 tool_calls -> 执行 Tool -> 回传 ToolMessage -> 再次 invoke

只要模型还在返回 tool_calls,程序就继续执行 Tool 并把结果交回去。

定义 Tool

Python 里可以用 @tool 把普通函数包装成 LangChain Tool。

python 复制代码
from datetime import datetime  
from typing import Literal  
from zoneinfo import ZoneInfo  
  
from pydantic import BaseModel, Field  
  
from langchain_core.tools import tool  
  
  
class CurrentTimeInput(BaseModel):  
    """查询当前时间需要的参数。"""  
  
    time_zone: Literal["Asia/Shanghai", "UTC", "America/New_York"] = Field(  
        default="Asia/Shanghai",  
        description="IANA 时区名称。北京时间用 Asia/Shanghai。",  
    )  
  
  
@tool(  
    "get_current_time",  
    args_schema=CurrentTimeInput,  
    description="查询指定时区的当前日期和时间",  
)  
def get_current_time(time_zone: str = "Asia/Shanghai") -> str:  
    # 这里是真正执行的代码:由运行时环境读取当前时间  
    current_time = datetime.now(ZoneInfo(time_zone))  
    return current_time.strftime("%Y-%m-%d %H:%M:%S %Z")

这段代码分成两层:

  • 执行逻辑:get_current_time()
  • Tool 说明:Tool 名、descriptionargs_schema

descriptionField(description=...) 很重要。

模型并不会读你的函数实现,它主要靠 Tool 描述和字段描述来判断:

txt 复制代码
什么时候调用这个 Tool
调用时应该填哪些参数
每个参数应该是什么含义

如果模型经常传错参数,通常先检查描述是不是写得太模糊。

直接执行 Tool

Tool 本质上仍然是一段可执行代码。

绑定给模型之前,可以先直接调用它,确认 Tool 本身逻辑没问题。

python 复制代码
# 直接传入符合 args_schema 的参数
result = get_current_time.invoke(
    {
        "time_zone": "Asia/Shanghai",
    }
)

print(result)

这一步和模型无关。

它只是验证:如果参数正确,Tool 能不能正常返回结果。

绑定 Tool

定义 Tool 之后,还要把 Tool 绑定给模型。

python 复制代码
# bind_tools 会把 Tool 的 name、description、args_schema 注入到模型请求中
model_with_tools = model.bind_tools(
    [get_current_time],

    # strict 会要求模型严格遵守 Tool 参数 schema
    strict=True,

    # auto 表示让模型自己判断是否需要调用 Tool
    tool_choice="auto",
)

可以这样理解:

txt 复制代码
@tool:把函数变成 Tool
bind_tools():把 Tool 列表交给模型

tool_choice 控制模型能不能调用 Tool:

  • "auto":模型自己判断,最常用。
  • "none":禁止 Tool 调用,模型只能直接回答。
  • 指定某个 Tool:强制模型调用某个 Tool。

生产环境里建议尽量开启 strict=True

它能减少模型生成错误参数结构的概率。

读取 tool_calls

绑定 Tool 后,模型第一次回答时不一定直接给最终答案。

如果它判断需要 Tool,会在 tool_calls 里给出调用计划。

python 复制代码
from langchain_core.messages import HumanMessage


messages = [
    HumanMessage(content="现在北京时间是几点?"),
]

# 这一步只是让模型决定要不要调用 Tool
ai_message = model_with_tools.invoke(messages)

print("第一次模型返回:")  
print(f"content: {ai_message.content}")  
print(f"tool_calls: {ai_message.tool_calls}")

tool_calls 可能长这样:

python 复制代码
[
    {
        "name": "get_current_time",
        "args": {
            "time_zone": "Asia/Shanghai",
        },
        "id": "call_123",
        "type": "tool_call",
    }
]

# 这里是列表,说明模型一次可能会生成多个 Tool 调用

注意:这时候 Tool 还没执行。

模型只是说:"我想调用 get_current_time,参数是这些。"

执行 Tool 并回填结果

拿到 tool_calls 后,程序要找到对应 Tool 并执行。

执行结果不能随便拼回对话里,而要作为 ToolMessage 回填给模型。

Python 里把完整 tool_call 传给 Tool 的 invoke() 时,LangChain 会帮你生成带 tool_call_id 的 ToolMessage。

python 复制代码
from langchain_core.messages import HumanMessage
from langchain_core.tools import BaseTool

# 收集 tools
tools = [get_current_time]

def find_tool(tools: list[BaseTool], name: str) -> BaseTool:
    # 根据模型生成的 tool call name 找到本地 Tool
    for current_tool in tools:
        if current_tool.name == name:
            return current_tool

    raise RuntimeError(f"模型调用了不存在的 Tool: {name}")


messages = [
    HumanMessage(content="现在北京时间是几点?请用一句话告诉我结果"),
]

ai_message = model_with_tools.invoke(messages)
messages.append(ai_message)

# 遍历 ai_message 找到的 tool_calls
for tool_call in ai_message.tool_calls:
    # 1. 根据模型返回的 name 找到本地 Tool
    selected_tool = find_tool(tools, tool_call["name"])

    # 2. 传入完整 tool_call,LangChain 会保留 tool_call_id
    tool_message = selected_tool.invoke(tool_call)
    # tool_message 是 ToolMessage,里面会带上 content 和 tool_call_id

    # 3. 把 Tool 结果回填到消息历史
    messages.append(tool_message)

# 4. 再次调用模型,让模型基于 Tool 结果生成最终回答
final_message = model_with_tools.invoke(messages)

print("最终模型回答:")
print(final_message.content)

这里最容易漏的是 tool_call_id

它是 Tool 调用和 Tool 结果之间的对应关系:

txt 复制代码
AIMessage.tool_calls[0]["id"] -> ToolMessage.tool_call_id

把完整 tool_call 传给 Tool 时,LangChain 会自动处理这个对应关系。

那么经过这一轮"执行 Tool -> 回填结果 -> 再次调用模型"之后,就可以拿到正确的时间了。

invoke 接收消息列表

前面很多示例都写成:

python 复制代码
model.invoke("现在北京时间是几点?")

这是最简单的写法,相当于只传一条用户消息。

但在 Agent Tool 调用里,更常见的是把消息整理成列表:

python 复制代码
messages = [
    HumanMessage(content="现在北京时间是几点?"),
]

ai_message = model_with_tools.invoke(messages)

invoke(messages) 的意思是:把这一组消息作为本轮上下文交给模型。

所以 Tool 调用不是一次 invoke() 就结束,而是围绕这个 messages 列表不断补充上下文。

大致关系是:

txt 复制代码
HumanMessage:用户原始问题
AIMessage:模型返回的 tool_calls
ToolMessage:Tool 执行结果
AIMessage:模型基于 Tool 结果生成最终回答

代码里反复出现 messages.append(...),就是在维护这个传给 invoke() 的消息列表:

  • messages.append(ai_message):把模型的 Tool 调用请求放回上下文。
  • messages.append(tool_message):把 Tool 执行结果放回上下文。
  • 再次 invoke(messages):让模型基于完整上下文继续回答。

如果不把这些消息放回列表,模型下一轮就看不到 Tool 结果,也不知道刚刚发生了哪次 Tool 调用。

多轮 Tool 循环

一个真实 Agent 不一定只调用一次 Tool。

模型可能调用 Tool A,看到结果后又决定调用 Tool B。

所以通常要写一个循环:

python 复制代码
from langchain_core.messages import HumanMessage
from langchain_core.tools import BaseTool

# 收集 tools,这里可能会存在很多个
tools = [get_current_time]

def find_tool(tools: list[BaseTool], name: str) -> BaseTool:
    # 根据 Tool 名查找本地 Tool
    for current_tool in tools:
        if current_tool.name == name:
            return current_tool

    raise RuntimeError(f"模型调用了不存在的 Tool: {name}")


messages = [
    HumanMessage(content="现在北京时间是几点?如果已经过了 18 点,提醒我整理日报"),
]

ai_message = model_with_tools.invoke(messages)
messages.append(ai_message)

while ai_message.tool_calls:
    for tool_call in ai_message.tool_calls:
        # 1. 根据模型返回的 name 找到本地 Tool
        selected_tool = find_tool(tools, tool_call["name"])

        # 2. 传入完整 tool_call,执行 Tool 并生成 ToolMessage
        tool_message = selected_tool.invoke(tool_call)

        # 3. 把 Tool 结果回填到 messages,下一轮模型才能看到
        messages.append(tool_message)

    # 4. Tool 结果都回填完后,再让模型继续判断下一步
    ai_message = model_with_tools.invoke(messages)
    messages.append(ai_message)

print(ai_message.content)

这个循环就是 Agent 的雏形:

txt 复制代码
模型思考 -> 调用 Tool -> 观察结果 -> 继续思考

读取文件 Tool

再看一个更接近开发场景的 Tool:读取项目里的文本文件。

python 复制代码
from pathlib import Path

from pydantic import BaseModel, Field

from langchain_core.messages import HumanMessage, SystemMessage
from langchain_core.tools import BaseTool, tool


PROJECT_ROOT = Path.cwd()
MAX_FILE_CHARS = 2000


class ReadFileInput(BaseModel):
    # 尽量要求模型使用项目内相对路径
    file_path: str = Field(description="项目内文本文件路径,例如 src/main.py")


@tool(
    "read_file",
    args_schema=ReadFileInput,
    description="读取指定路径的文本文件内容。当用户询问文件内容或代码时使用。",
)
def read_file(file_path: str) -> str:
    root = PROJECT_ROOT.resolve()
    target = Path(file_path)

    # 1. 相对路径按项目根目录解析
    if not target.is_absolute():
        target = root / target

    resolved_target = target.resolve()

    # 2. 防止读取项目目录外的文件
    if root not in resolved_target.parents and resolved_target != root:
        return "读取失败: 文件路径必须位于当前项目目录内"

    if not resolved_target.is_file():
        return f"读取失败: 文件不存在 {file_path}"

    try:
        # 3. 只读取 UTF-8 文本文件
        content = resolved_target.read_text(encoding="utf-8")
    except UnicodeDecodeError:
        return "读取失败: 只支持 UTF-8 文本文件"

    # 4. 截断过长内容,避免一次塞太多 token 给模型
    if len(content) > MAX_FILE_CHARS:
        return content[:MAX_FILE_CHARS] + "\n...(内容过长,已截断)"

    return content


def find_tool(tools: list[BaseTool], name: str) -> BaseTool:
    for current_tool in tools:
        if current_tool.name == name:
            return current_tool

    raise RuntimeError(f"模型调用了不存在的 Tool: {name}")


tools = [read_file]
model_with_tools = model.bind_tools(tools, strict=True, tool_choice="auto")

messages = [
    SystemMessage(content="回答文件相关问题前,必须先调用 read_file 读取文件内容。"),
    HumanMessage(content="请读取 src/main.py,并解释这个文件做了什么。"),
]

ai_message = model_with_tools.invoke(messages)
messages.append(ai_message)

while ai_message.tool_calls:
    for tool_call in ai_message.tool_calls:
        # 1. 根据模型返回的 name 找到本地 Tool
        selected_tool = find_tool(tools, tool_call["name"])

        # 2. 传入完整 tool_call,执行 Tool 并生成 ToolMessage
        tool_message = selected_tool.invoke(tool_call)

        # 3. 把 Tool 结果回填到 messages,下一轮模型才能看到
        messages.append(tool_message)

    # 4. Tool 结果都回填完后,再让模型继续判断下一步
    ai_message = model_with_tools.invoke(messages)
    messages.append(ai_message)

print(ai_message.content)

这个例子里,模型不负责读取文件。

模型只负责判断:

txt 复制代码
用户在问文件内容 -> 应该调用 read_file -> 参数是 src/main.py

真正的文件读取、安全校验、内容截断都在 Tool 函数里完成。

小结

这一篇要记住几个核心点:

  1. Tool 是 Agent 连接外部世界的接口。
  2. 模型不会真的执行 Tool,它只生成 tool_calls
  3. namedescriptionargs_schema 决定模型能不能正确调用 Tool。
  4. bind_tools() 会把 Tool 定义注入给模型。
  5. Tool 结果要作为 ToolMessage 回填给模型。
  6. tool_call_id 必须和模型生成的 tool call id 对齐。
  7. Tool 循环就是 Agent "思考-行动-观察"的基础结构。
相关推荐
十九画生1 小时前
Ajax 入门:用 XHR 理解前后端异步请求
前端·javascript·后端
Java编程爱好者2 小时前
Java 8老系统SQL Agent实战:AI生成候选SQL,安全引擎拦截后再执行
后端
Amo Xiang2 小时前
SpiderDemo 第5题:OB混淆实战 —— 反调试绕过与 signature 签名还原
python·js逆向·爬虫逆向·反调试·spiderdemo·ob混淆
copyer_xyf2 小时前
Agent 结构化输出
后端·python·agent
ping某2 小时前
为什么我背了很多年 TCP 三次握手,还是总觉得差一点?
后端
FBI HackerHarry浩2 小时前
Ollama如何安装到D盘
python·ai
玉鸯2 小时前
理解 Agent 的运行时心脏--从零写一个 Agent Loop
agent
HIT_Weston2 小时前
115、【Agent】【OpenCode】项目配置(SemVer)
人工智能·agent·opencode
一个做软件开发的牛马2 小时前
Spring Boot 自动配置原理揭秘:从 @SpringBootApplication 到手写自定义 Starter
java·后端