AI Agent 开发(05):工具调用基础

QuibblerAgentQuibblerAgent 2026-10-25 约 23 分钟 1 次阅读

工具是 Agent 与世界交互的唯一通道:查天气、搜文档、读数据库、发请求,模型的一切"行动力"都来自工具调用。但 Function Calling 这个名字自带误导——模型从不真正执行函数,它只输出一段结构化的"调用意图",真正动手的始终是你写的代码。理解这条边界,是理解一切 Agent 框架的第一步。

本篇是《AI Agent 开发》系列第 5 篇,代码基于 langchain-core 1.6.x,环境沿用第二篇的 uv 项目与 API Key 配置。全文按"协议机制 → 工具定义 → 参数设计 → 调用循环 → 并行与选择 → 常见坑"的顺序展开,看完你能亲手写出一切 Agent 框架共享的那个内核循环。

1、模型只出意图不出结果

先绕开 LangChain,直接看一次裸 HTTP 的工具调用交互,以 Anthropic Messages API 为例。请求里附上工具清单,每件工具用 JSON Schema 描述自己:

{
  "model": "claude-sonnet-5",
  "max_tokens": 1024,
  "tools": [
    {
      "name": "get_weather",
      "description": "查询指定城市的当前天气",
      "input_schema": {
        "type": "object",
        "properties": {
          "location": { "type": "string", "description": "城市名" }
        },
        "required": ["location"]
      }
    }
  ],
  "messages": [
    { "role": "user", "content": "北京今天适合跑步吗?" }
  ]
}

tools 数组就是模型能"看见"的全部世界:name 是调用标识,description 告诉模型这件工具干什么,input_schema 描述参数结构。用户问"北京今天适合跑步吗",模型的响应如下(节选,省略 id、usage 等无关字段):

{
  "role": "assistant",
  "stop_reason": "tool_use",
  "content": [
    { "type": "text", "text": "我先查一下北京当前的天气。" },
    {
      "type": "tool_use",
      "id": "toolu_01ABC",
      "name": "get_weather",
      "input": { "location": "北京" }
    }
  ]
}

关键有两处:stop_reason 是 tool_use 而非 end_turn;content 里多了一个 tool_use 块,里面只有工具名和参数,没有任何天气数据。模型做的事到此为止——输出一段结构化文本,声明"我想调用 get_weather,参数是北京"。

模型决定"调用什么、传什么参数";你的代码负责"真正执行并回传结果"。

这个分工是整个机制的精髓:模型侧只做"决策",一切副作用都发生在你的代码里——权限控制、审计日志、沙箱隔离、失败重试,全部在你掌控之下。Agent 的安全边界之所以清晰,根源就在这里。

接下来执行函数的是你,执行完还要把结果送回去,模型才能组织出最终回答。第二轮请求的 messages 变成这样(tools 清单原样再带一次,此处省略):

{
  "model": "claude-sonnet-5",
  "max_tokens": 1024,
  "messages": [
    { "role": "user", "content": "北京今天适合跑步吗?" },
    {
      "role": "assistant",
      "content": [
        { "type": "tool_use", "id": "toolu_01ABC",
          "name": "get_weather", "input": { "location": "北京" } }
      ]
    },
    {
      "role": "user",
      "content": [
        { "type": "tool_result", "tool_use_id": "toolu_01ABC",
          "content": "晴,18°C,空气质量良" }
      ]
    }
  ]
}

注意消息历史的完整追加:上一轮的 tool_use 原样保留,执行结果以 tool_result 的形式出现,靠 tool_use_id 与调用精确配对。模型看到"晴、18°C、空气质量良",这一轮才会输出"适合跑步"并正常结束。OpenAI 管这叫 tool_calls,Gemini 叫 functionCall,协议细节见 Anthropic Tool Use 文档 与 OpenAI Function Calling 指南。名字不同、机制一致,LangChain 的价值正是把三家差异抹平成同一套 Python API。

2、用 tool 装饰器定义工具

手写 JSON Schema 没人能坚持三天。LangChain 把这件事变成"写普通 Python 函数":docstring 即描述,类型注解即 schema。定义一个工具只要这样:

from langchain_core.tools import tool

@tool
def get_weather(location: str) -> str:
    """查询指定城市的当前天气。

    Args:
        location: 城市名,如"北京"、"Shanghai"
    """
    # 演示用假数据,实际项目替换为天气 API 调用
    return f"{location}:晴,18°C,空气质量良"

装饰瞬间完成三件事:docstring(含 Args 段)整体成为工具描述,模型完全靠它判断何时调用;location: str 被转成 {"type": "string"} 的参数 schema;函数本体不动,return 的字符串就是工具结果。装饰后的 get_weather 不再是普通函数,而是 StructuredTool 对象,可以查看它生成的 schema:

print(get_weather.name)         # get_weather
print(get_weather.description)  # 查询指定城市的当前天气。Args: …
print(get_weather.args)
# {'location': {'description': '城市名,如 北京、Shanghai',
#              'type': 'string'}}

两个实用细节:工具名保持 snake_case,部分供应商会拒绝带空格或特殊字符的名字;需要覆盖命名或描述时用带参形式 @tool("weather_lookup", description="...")。另外 from langchain.tools import tool 与 from langchain_core.tools import tool 是同一个装饰器的两个入口,随意用。

运行期执行工具就是普通的方法调用:get_weather.invoke({"location": "北京"}) 会执行函数并返回结果字符串——第 4 节循环里用的正是这行。异步工具、多模态返回、return_direct 等更多形态见 LangChain 官方工具文档。

3、参数设计与约束

参数一多、或取值有边界时,裸类型注解就不够用了——用 Pydantic 模型显式声明 args_schema:

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

class WeatherInput(BaseModel):
    """天气查询参数。"""
    location: str = Field(description="城市名,如 北京、Tokyo")
    unit: Literal["celsius", "fahrenheit"] = Field(
        default="celsius", description="温度单位")
    days: int = Field(default=1, ge=1, le=7, description="预报天数")

@tool(args_schema=WeatherInput)
def get_weather(location: str, unit: str = "celsius",
                days: int = 1) -> str:
    """查询城市未来若干天的天气预报。"""
    return f"{location} 未来 {days} 天晴,18 {unit}"

WeatherInput 会被完整翻译成 JSON Schema 发给模型,四层约束各有分工:

手段 作用
Field(description=...) 参数级描述,引导模型正确填参
Literal[...] 枚举约束,取值限定在固定集合内
字段默认值 声明可选参数,模型判断不需要可不传
ge / le 数值上下界,越界直接被 schema 拒绝

枚举约束的收益最直接:unit 的取值只可能是两个之一,"温度单位幻觉"从根上消失;默认值声明的就是可选参数,模型判断不需要时可以不传。经验法则是 schema 越精确、模型填错参数的概率越低,这是工具开发里性价比最高的一次性投入。字段约束的完整能力见 Pydantic 字段文档。

4、bind_tools 与调用循环

bind_tools 做的事很朴素:把工具 schema 附加到模型上,返回一个新实例,之后每次请求自动携带工具清单——它本身不发任何网络请求。配合第三篇用过的 init_chat_model,模型侧准备只要一小段:

from langchain.chat_models import init_chat_model

llm = init_chat_model("anthropic:claude-sonnet-5")
# 换模型只改这一串:openai:gpt-5.2、google_genai:gemini-3-pro

llm_rt = llm.bind_tools([get_weather])   # 返回绑定工具的新实例
resp = llm_rt.invoke("北京今天几度?")     # 请求自动携带工具 schema
print(resp.tool_calls)
# [{'name': 'get_weather', 'args': {'location': '北京'},
#   'id': 'call_x1', 'type': 'tool_call'}]

API Key 从环境变量 ANTHROPIC_API_KEY 读取,OpenAI 与 Google 同理。要说明的是:Claude、GPT、Gemini 的旗舰模型都原生支持工具调用,bind_tools 绑上去就能用;但部分小参数或开源模型虽能绑上,工具选择和填参质量会明显下降——选型阶段务必拿真实问题实测,别只看参数表里的"支持"两个字。

接下来是全文最重要的代码:手写"请求模型 → 执行工具 → 回填结果 → 再请求"的循环。第 6 篇的 LangGraph、create_react_agent 这类预构建 Agent,内核都是它:

from langchain_core.messages import HumanMessage, ToolMessage

def run_agent(question: str, tools: list, llm, max_turns: int = 10) -> str:
    tools_by_name = {t.name: t for t in tools}
    llm_rt = llm.bind_tools(tools)                # 绑定全部工具
    messages = [HumanMessage(content=question)]
    turns = 0
    while True:                                   # 调用循环开始
        turns += 1
        if turns > max_turns:                     # 安全阀,防死循环
            return "已达最大轮次,强制结束。"
        ai_msg = llm_rt.invoke(messages)          # 1. 请求模型决策
        messages.append(ai_msg)
        if not ai_msg.tool_calls:                 # 2. 无调用 → 最终回答
            return ai_msg.text
        for tc in ai_msg.tool_calls:              # 3. 逐个执行工具
            output = tools_by_name[tc["name"]].invoke(tc["args"])
            messages.append(ToolMessage(content=str(output),
                                        tool_call_id=tc["id"]))  # 4. 回填

循环每轮三步。第一步请求决策:ai_msg.tool_calls 为空说明模型已有足够信息,ai_msg.text 就是最终答案;不为空则是一个调用列表,每项包含 name、args、id 三个键。第二步执行:从 tools_by_name 取出工具,tc["args"] 直接作为参数传入。

第三步回填最关键:结果要包成 ToolMessage,tool_call_id 必须严格等于 tc["id"]——这是模型把结果与请求配对的唯一线索,OpenAI 接口对此校验严格,对不上号直接返回 400。max_turns 则是安全阀,防止模型陷入"调用、失败、再调用"的死循环。调用示例:run_agent("上海和北京今天适合跑步吗?", [get_weather], llm)。API 细节可查 langchain-core 工具参考。

5、工具选择与并行调用

问"上海和北京分别几度"——两个独立查询,现代模型会在一次响应里同时给出两个调用:

ai_msg = llm_rt.invoke(
    [HumanMessage(content="上海和北京分别几度?")]
)
for tc in ai_msg.tool_calls:
    print(tc["name"], tc["args"], tc["id"])

输出:

get_weather {'location': '上海'} call_ab12cd34
get_weather {'location': '北京'} call_ef56gh78

第 4 节的 for 循环天然覆盖这种情况:逐个执行、逐个回填,模型下一轮把两个结果合成一个回答。三个值得记住的细节:

  • 相互独立的调用一轮发齐;有依赖的调用模型会自动拆成多轮
  • 循环里是串行执行,网络 IO 型工具可改用 asyncio.gather 并发
  • ToolMessage 的回填顺序无关紧要,ID 对齐即可

工具选择侧还有开关:tool_choice="auto" 是默认值,模型自己决定;tool_choice="any" 强制至少调用一个;tool_choice="get_weather" 强制调用指定工具;parallel_tool_calls=False 可以干脆禁掉并行。强制手段只在"把模型当路由器"的场景有用,日常开发让它自己判断效果更好。

最后一点成本意识:工具 schema 会随每次请求完整发送,持续占用输入 token,工具不是越多越好——超过十几个时模型的选择准确率下降、账单同步上涨,此时该做减法,或引入按需检索工具的方案(第 22 篇展开)。

6、常见坑与对策

6.1、工具描述模糊

症状:明明有工具能答的问题,模型硬编答案,或者调错工具。根因几乎都在 docstring——没说清"什么时候该用、什么时候不该用"。对比一组写法:

模糊写法 改进写法
"""搜索。""" """在公司文档库按关键词搜索段落,回答制度与产品问题用;天气、汇率等外部信息请勿调用"""
query: str query: str = Field(description="关键词,2 到 5 个词效果最佳")

给模型写描述,等价于给新同事写接口文档:适用场景、边界、参数怎么填,一样都不能少。实践里主动写明"不要用于 XX"比堆功能形容词有效得多。

6.2、参数幻觉

症状:模型给 location 填"北京首都机场 T3"、给枚举字段填约束外的值、甚至编造不存在的参数名。防御分两层:schema 层用枚举和范围约束压缩幻觉空间(第 3 节);工具层在函数内部兜底校验,且校验失败要返回错误字符串而不是抛异常——模型读到错误信息,下一轮通常能自我修正:

CITY_LIST = {"北京", "上海", "广州", "深圳"}

@tool
def get_weather(location: str) -> str:
    """查询指定城市的当前天气,仅支持北上广深。"""
    if location not in CITY_LIST:  # 模型可能填"朝阳区""T3 航站楼"
        return f"暂不支持 {location},可选城市:{CITY_LIST}"
    return f"{location}:晴,18°C"

抛异常会让整个循环崩掉;返回可读的错误,等于给了模型一次纠错机会。

6.3、结果撑爆上下文

症状:搜索类工具一次返回 50 KB 的 JSON,两三轮对话后上下文膨胀、费用翻倍、模型注意力涣散。对策是在工具出口主动截断,而不是指望模型自己忽略多余内容:

def clip(text: str, limit: int = 2000) -> str:
    """截断过长文本,保留头部并注明省略量。"""
    if len(text) <= limit:
        return text
    return text[:limit] + f"\n…(已截断 {len(text) - limit} 字符)"

@tool
def search_docs(query: str) -> str:
    """在公司内部文档库中按关键词搜索段落。"""
    raw = "\n".join(f"[{i}] 与 {query} 相关的段落内容……"
                    for i in range(500))   # 模拟 500 条检索结果
    return clip(raw)                       # 出工具前先截断

截断提示里保留"已截断多少字符",模型知道信息不全,会主动把查询拆得更细。更彻底的方案是让工具只返回"ID + 摘要"列表、选定目标后再查明细——工具粒度设计是第 22 篇的主题。

7、总结

工具调用的完整链路一句话收束:模型输出调用意图(tool_calls),执行与回填(ToolMessage)永远是你的代码。围绕这条边界,本篇覆盖了 @tool 装饰器的"docstring 即描述、类型注解即 schema"、Pydantic 的枚举与范围约束、bind_tools 之后的四步循环,以及并行调用与 tool_choice 控制——那段不到 20 行的 while 循环,就是所有 Agent 框架共享的内核。

留一个练习,把本篇知识串起来:写一个"汇率 + 天气"双工具对话。

  • 定义 get_exchange_rate 与 get_weather,货币代码用 Literal 枚举约束
  • 用第 4 节的 run_agent 跑通"去东京旅行"的组合提问
  • 提问示例:"1 万元人民币能换多少日元?东京天气怎么样?"
  • 验收:一次触发双工具、参数合规、长结果已截断

下一篇进入 LangGraph:把 while 循环交给 StateGraph 的节点与条件边,"决策 → 工具 → 回边"变成图里一条声明式的边,状态管理、中断恢复、人机协同从此有了着力点。预构建 Agent 的能力可先看 LangGraph Agent 文档。

相关推荐

Hermes Agent 详解:自进化学习型 AI 代理
Agent

Hermes Agent 详解:自进化学习型 AI 代理

Hermes Agent 详解:自进化学习型 AI 代理Hermes Agent 是由 Nous Research 于 2026 年 2 月开源的自主 AI 代理框架,其核心定位是"与你共同成长的智能体"。与传统的 IDE 编程助手或单一 API 聊天机器人不同,Hermes 运行在你的服务器上,能够记住所学内容,运行时间越长能力越强。它采用 MIT 开源许可,支持 Linux、macOS 和 W

393
Hermes Agent:与你一起成长的开源 AI 代理
Agent

Hermes Agent:与你一起成长的开源 AI 代理

Hermes Agent:与你一起成长的开源 AI 代理大多数 AI 助手是"健忘的租客"——换个窗口就失忆、换个平台就重学、干过的活不留下任何沉淀。Nous Research 出品的 Hermes Agent 反其道而行,口号是"The Agent That Grows With You"(与你一起成长的代理):它是一个开源(MIT)、自托管的 AI 代理,具备持久记忆(学习你的项目、自动生成技

322
speech-to-speech 详解:HuggingFace 的开源语音 Agent 流水线
AI

speech-to-speech 详解:HuggingFace 的开源语音 Agent 流水线

speech-to-speech 详解:HuggingFace 的开源语音 Agent 流水线语音 Agent 的"全开源"路线一直有个尴尬:框架开源了,模型却离不开云端 API。huggingface/speech-to-speech 把这条路走通了:VAD → STT → LLM → TTS 四段低延迟流水线,每个组件都可换成开源模型本地跑——Apple Silicon 一台 Mac 就能撑起

264