摘要:Agent 很神奇,但拆开看核心只有一层协议——模型怎么"知道"有哪些工具、怎么"表达"要用哪个工具、结果怎么"送回"给它。本文把 tool calling 的底层机制一次讲透:bind_tools 绑定工具时发生了什么(四种工具定义形式与 Schema 生成);AIMessage.tool_calls 的标准结构(模型表达意图的唯一出口);tool_choice 五种模式从自由到强制的控制光谱;并行调用与 tool_call_id 的对应关系;手动执行工具循环的完整代码——这是理解 create_agent 自动循环的前提。最后回答一个关键问题:什么时候手动管理循环,什么时候交给 Agent。
关键词:LangChain、bind_tools、tool_calls、工具调用、tool_choice、ToolMessage、tool_call_id、Agent 循环、大模型应用开发
一、Agent 的魔法,拆开只有一层协议
看一个 Agent 自动查天气、查完再回答,很多人的第一反应是"模型学会动手了"。
其实没有任何魔法。模型从头到尾只做一件事:生成文本。所谓"调用工具",是模型在输出里塞了一段结构化的"意图声明"——工具名 + 参数。真正执行工具的,是外层的循环代码;执行结果,再作为消息送回模型。
这层协议有三个角色:
- bind_tools:告诉模型"你有哪些工具可用";
- tool_calls:模型表达"我想调哪个工具、传什么参数";
- ToolMessage:把执行结果送回模型,继续推理。
理解了这三者,Agent 就从"黑盒魔法"变成"白盒协议"。官方 LangChain 博客的原话:这套标准化接口让所有支持工具调用的模型(OpenAI、Anthropic、Gemini、Mistral……)行为完全一致——换模型不改代码。

二、bind_tools:把工具"注册"给模型
绑定工具只需要一行:
from langchain_core.tools import tool
@tool
def get_weather(city: str) -> str:
"""获取给定城市的当前天气。city 使用中文城市名。"""
return f"{city} 天气晴朗,26℃"
llm_with_tools = llm.bind_tools([get_weather])
绑定瞬间发生了什么?LangChain 把工具转换成 Schema 描述(名称、描述、参数结构),随每次请求发给模型。注意:绑定不会执行任何东西,它只是让模型"看得见"这个工具。
bind_tools 接受四种工具定义形式,全部自动转成 Schema:
# 形式一:@tool 装饰器(推荐)——类型注解 + docstring 就是 Schema
@tool
def get_weather(city: str) -> str:
"""获取给定城市的当前天气。"""
...
# 形式二:Pydantic 类——只要定义,不要实现(模型先"报名")
from pydantic import BaseModel, Field
class Multiply(BaseModel):
"""返回 x 和 y 的乘积。"""
x: float = Field(description="第一个因子")
y: float = Field(description="第二个因子")
# 形式三:普通函数——docstring 与类型注解同样生效
def subtract(x: float, y: float) -> float:
"""用 x 减去 y。"""
return x - y
# 形式四:原始 dict(OpenAI 格式)——极端情况手写 Schema
四种形式的共同点:Schema 都从类型注解和 docstring 自动生成。这也再次印证了前一篇的结论——类型注解和 docstring 不是代码整洁问题,是协议的一部分,直接决定模型的调用准确率。
三、tool_calls:模型意图的唯一出口
绑定之后调用模型,返回的 AIMessage 上多了一个字段:
resp = llm_with_tools.invoke("北京今天天气怎么样?")
resp.tool_calls
# [{
# "name": "get_weather",
# "args": {"city": "北京"},
# "id": "call_00_Y1R8gZ...",
# "type": "tool_call",
# }]
这个列表就是模型"动手意图"的唯一标准出口:工具名、参数字典、本次调用的唯一 ID。三个要点:
- 模型不执行任何东西——它只是声明了"我想调 get_weather,参数是北京";
- id 是对应关系的钥匙——执行结果回传时必须带上这个 id(下一节);
- 没打算调工具时列表为空——此时
resp.content就是普通文本回答。
各厂商原始的工具调用格式互不兼容(OpenAI 用 tool_calls,Anthropic 用 input_schema,Gemini 又是另一套),LangChain 把它们全部归一到这个标准结构——这就是"统一模型接口"在工具层的落地。
四、ToolMessage:结果回流的闭环
拿到 tool_calls 后,执行并把结果送回去:
from langchain_core.messages import ToolMessage, HumanMessage
messages = [HumanMessage(content="北京今天天气怎么样?")]
ai_msg = llm_with_tools.invoke(messages)
messages.append(ai_msg)
for tc in ai_msg.tool_calls:
result = get_weather.invoke(tc["args"]) # 真正执行
messages.append(ToolMessage(
content=str(result),
tool_call_id=tc["id"], # 关键:id 对应
))
final = llm_with_tools.invoke(messages) # 模型基于结果作答
print(final.content) # "北京今天晴朗,26℃。"
tool_call_id 的对应关系是闭环的关键:一次对话可能有多个工具调用,模型靠 id 把"哪个结果对应哪次请求"对上号。漏掉 id 会直接报错——这不是可选项。
这个手动循环就是 Agent 的全部底层。create_agent 做的事,就是把"调模型 → 执行工具 → 结果回传 → 再调模型"这个 while 循环自动化,外加中间件和状态管理。理解了手动版本,Agent 的行为就完全透明。

五、tool_choice:从自由到强制的控制光谱
默认情况下,模型自行决定调不调工具、调哪个。tool_choice 参数提供五个档位:
| 取值 | 行为 | 适用 |
|---|---|---|
"auto" |
模型自由决定(含不调) | 大多数情况 |
"none" |
禁止调用 | 临时关闭工具能力 |
"any" / True |
强制至少调一个 | 抽取类任务 |
"工具名" |
强制调用指定工具 | 路由到固定工具 |
False / None |
默认行为 | —— |
# 强制每次都走搜索工具
llm_forced = llm.bind_tools([search], tool_choice="any")
# 强制调用指定工具
llm_route = llm.bind_tools([search, calc], tool_choice="search")
强制模式在结构化提取场景特别有用——上一篇文章讲的 ToolStrategy,底层正是"强制模型调用结构化输出工具"(tool_choice 指向 Schema 工具)。两篇文章在这里会师。
六、并行调用与 strict 模式
并行调用是默认开启的能力。问"北京和东京的天气",模型会一次返回两个 tool_calls:
resp = llm_with_tools.invoke("北京和东京的天气怎么样?")
# tool_calls: [
# {"name": "get_weather", "args": {"city": "北京"}, "id": "call_1"},
# {"name": "get_weather", "args": {"city": "东京"}, "id": "call_2"},
# ]
两个调用相互独立,可以并行执行再一起回传。需要禁用时(比如保证执行顺序):
llm.bind_tools([get_weather], parallel_tool_calls=False)
strict 模式(部分厂商支持)则是给 Schema 上锁:
llm.bind_tools([get_weather], strict=True)
开启后模型输出保证严格匹配工具定义的 JSON Schema,参数校验由厂商兜底。生产环境建议开启——它把上一篇文章 Pydantic 校验的可靠性又提高了一档。
七、手动循环 vs create_agent:怎么选
现在你能看清两者的边界了:
| 维度 | 手动 bind_tools 循环 | create_agent |
|---|---|---|
| 控制粒度 | 每一步自己把控 | 框架托管 |
| 代码量 | 多(循环、消息管理全手写) | 少(声明式配置) |
| 适用 | 单轮工具调用、自定义路由逻辑 | 多轮任务、需要记忆/中间件 |
| 学习价值 | 理解协议本质 | 快速交付 |
实践建议很清晰:单次"调工具拿结果"用 bind_tools 手动循环就够——比如查询增强(先强制搜索再回答),三行代码;多轮推进、需要状态和管控的任务交给 create_agent。先写一遍手动循环再上手 Agent,是理解这套体系最快的路径。
八、给 Java / 大数据工程师的对照
- bind_tools ≈ 注册 RPC 接口:把接口签名(Schema)暴露给调用方,调用方(模型)按签名发请求;
- tool_calls ≈ RPC 请求报文:方法名 + 参数 + 请求 ID,只是"调用方"是概率性的模型;
- tool_call_id 对应 ≈ 请求-响应关联:异步消息系统里靠 correlation_id 配对,这里同构;
- 手动循环 ≈ 写了一遍 Flink 的迭代算子:数据流入 → 处理 → 结果回流 → 再触发,直到没有新工具调用(收敛)。
九、小结
工具调用协议的检查清单:
- Schema 质量决定一切——类型注解 + docstring 就是给模型的接口文档;
- tool_calls 是意图出口,模型不执行——执行永远在你的代码里;
- tool_call_id 必须回传——多工具调用场景对不上号就崩;
- tool_choice 从 auto 到强制是一段完整控制光谱,抽取场景用强制;
- 生产开 strict——厂商级 Schema 校验兜底。
这套协议是 Agent、结构化输出、RAG 工具增强的共同地基。把它吃透,LangChain 的一切"智能体魔法"都是它的组合。
本文基于 LangChain 官方博客与 v1.0 文档整理,代码在 Python 3.13 + langchain 1.2.0 环境下验证通过。