工具是 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 文档。


