摘要:从工具的四种定义方式(@tool/StructuredTool/Pydantic/BaseTool)出发,手动拆解工具调用全流程------bind_tools 绑定、tool_calls 解析、执行工具并回传 ToolMessage 的循环、流式与服务端工具,配合天气/计算/Tavily 联网搜索实战,帮你搞懂 create_agent 底层做了什么。
前言
上一篇,我们梳理了结构化输出,让模型能按定义好的结构返回数据。结构化输出底层走的是 tool_choice 机制,本篇要讲的工具调用也用到它------上一篇踩过的思考模式那个坑,这里也会遇到。
传送门:【LangChain 1.x】05、结构化输出|三种结构定义模式与两种结果获取方式
这一篇,我们进入工具调用(Tool Calling)。第2篇快速上手时,我们用 create_agent 搭过一个查询天气的小 Agent,它能自动调用工具、拿到结果、再组织回答。那时候我们只管「用」,没深究 Agent 底层到底替我们做了什么。
本篇,就和大家一起把工具调用的循环手动拆开来看看:怎么定义工具、怎么让模型决定调用哪个工具、怎么执行工具并把结果回传、怎么循环。理解了这个手动过程,再回过头看 create_agent,就会很清楚了。
一、基础概念
1.1 什么是工具调用
模型本身只能生成文本,不能联网搜索、查数据库、做精确计算。工具调用(Tool Calling)就是给模型加个「能力外挂」:我们定义好一批工具,模型回答时如果需要,会主动要求调用某个工具,拿到结果后再继续。
工具本质上是「schema + 函数」的配对:
- schema:告诉模型工具叫什么、做什么、接收什么参数
- 函数:实际执行的逻辑(查天气、算数学、调 API 等)
1.2 工具调用的工作流程
一次完整的工具调用是这样的:
用户提问
↓
模型决策:要不要调工具?调哪个? ← 返回 tool_calls
↓
执行工具,拿到结果 ← 我们自己来做
↓
模型基于结果继续决策:够了吗? ← 返回 tool_calls 或最终回答
↓
给出最终回答
关键点:模型返回的只是一个「调用请求」(tool_calls),执行工具这件事得我们自己来做(除非用 agent)。这是本篇要重点拆解的部分。
1.3 手动循环 vs Agent 自动循环
第2篇用过的 create_agent,底层其实就是自动跑了上面那个循环:模型决策 → 执行工具 → 回传 → 再决策......直到给出最终回答。
本篇要做的是把这个循环手动拆开 ,一步步实现。理解了手动过程,create_agent 就不再是个黑盒。
1.4 思考模式与 tool_choice
第5篇,在 DeepSeek 思考模式下使用 with_structured_output 结构化输出会出现报错,但是,单凭这样认为「思考模式不支持 tool_choice」是不准确的:
bind_tools默认用tool_choice="auto"(模型自主决定),思考模式兼容with_structured_output内部强制tool_choice="any"(必须调用),思考模式不兼容
所以,不是「思考模式不能用工具调用」,而是「思考模式不支持强制 调用工具」。本篇示例以 bind_tools 的默认 auto 为主,思考和非思考模式都能跑。代码里默认用关闭思考的模型(和前几篇一致),需要时会切到思考模式展示 reasoning_content(模型的思考过程)。
ini
思考模式 + tool_choice='auto' → 成功
思考模式 + tool_choice='any' → 报错 Thinking mode does not support this tool_choice
二、定义工具
刚刚有提到,工具是「schema + 函数」的配对:schema 告诉模型这个工具叫什么、做什么、接收什么参数;函数是实际执行的逻辑。LangChain 提供了几种定义工具的方式,先看它们各自长什么样、生成的 schema 有什么差别。
2.1 @tool 装饰器(推荐)
常用且简单的方式,给函数加上 @tool,LangChain 会自动从函数签名和 docstring 生成 schema:
python
from langchain_core.tools import tool
@tool
def get_weather(location: str) -> str:
"""获取指定城市的天气信息"""
return f"{location} 天气晴朗,25度"
生成的 schema:
css
工具名: get_weather
描述: 获取指定城市的天气信息
参数 schema: {'location': {'title': 'Location', 'type': 'string'}}
docstring 会成为工具的描述(description),模型据此判断什么时候该调用它。注意这里的参数 location 只有 type,没有描述------这个细节后面会对比。
2.2 StructuredTool.from_function
@tool 其实是它的快捷方式。如果需要显式指定 name、description(比如函数名和想给工具起的名字不一致),可以用 StructuredTool:
python
from langchain_core.tools import StructuredTool
def _calculate(expression: str) -> str:
"""计算数学表达式"""
...
calculate_tool = StructuredTool.from_function(
func=_calculate,
name="calculate",
description="计算数学表达式的结果,如 1+2、3*4",
)
schema:
css
工具名: calculate
描述: 计算数学表达式的结果,如 1+2、3*4
参数 schema: {'expression': {'title': 'Expression', 'type': 'string'}}
2.3 直接传 Pydantic schema 给 bind_tools
前两种方式都绑定了执行函数。如果只想让模型生成调用参数、执行逻辑由我们自己写(比如去调一个外部 API),可以直接把 Pydantic 类传给 bind_tools:
python
from pydantic import BaseModel, Field
class SearchInput(BaseModel):
"""网络搜索输入"""
query: str = Field(description="搜索关键词")
max_results: int = Field(default=5, description="最大返回结果数")
schema(用 model_json_schema() 查看):
arduino
{'description': '网络搜索输入',
'properties': {
'query': {'description': '搜索关键词', 'type': 'string'},
'max_results': {'default': 5, 'description': '最大返回结果数', 'type': 'integer'}},
'required': ['query'], ...}
这种方式生成的 schema 最完整,字段都带 description、default、required,因为 Pydantic 本身就支持这些。
2.4 BaseTool 子类
灵活但繁琐的方式,适合需要复杂逻辑的工具。继承 BaseTool,用 args_schema 指定参数结构,实现 _run 方法:
python
from langchain_core.tools import BaseTool
from pydantic import BaseModel, Field
class UnitConverterInput(BaseModel):
value: float = Field(description="要换算的数值")
from_unit: str = Field(description="原始单位,如 km、celsius")
to_unit: str = Field(description="目标单位,如 mile、fahrenheit")
class UnitConverterTool(BaseTool):
name: str = "unit_converter"
description: str = "单位换算,如 km转mile、摄氏转华氏"
args_schema: type[BaseModel] = UnitConverterInput
def _run(self, value, from_unit, to_unit) -> str:
...
schema:
css
工具名: unit_converter
参数 schema: {'value': {'description': '要换算的数值', ...},
'from_unit': {'description': '原始单位...', ...},
'to_unit': {'description': '目标单位...', ...}}
它的参数 schema 里每个字段都带 description,因为用了 Pydantic 的 Field。
2.5 一个细节:参数描述的差别
把四种方式的参数 schema 放一起对比,会发现一个差别:
| 方式 | 工具描述来源 | 参数是否带 description |
|---|---|---|
@tool |
docstring | 否(需用 Annotated 补) |
| StructuredTool | 显式传 description | 否(需用 Annotated 补) |
| Pydantic 直接传 | 类 docstring | 是(Field 的 description) |
| BaseTool | 类属性 description | 是(args_schema 的 Field) |
@tool 和 StructuredTool 的参数默认不带描述。如果想让模型更准确地填写参数,可以给参数加描述,用 typing.Annotated:
python
from typing import Annotated
@tool
def get_weather(location: Annotated[str, "城市名称,如 北京、上海"]) -> str:
"""获取指定城市的天气信息"""
...
这样 location 就会带上 description。参数描述会影响模型传参的准确性,建议在字段含义不明显、可能有歧义时加上。
至于这几种方式怎么选:
-
简单的本函数工具用
@tool就够了; -
需要复用一个已有函数并自定义名字/描述时用
StructuredTool; -
只想生成调用参数、执行逻辑自己掌控时用 Pydantic;复杂逻辑、需要内部状态的工具可以用
BaseTool。
三、绑定与模型决策
定义好工具后,用 bind_tools 绑定到模型上。绑定后,模型在 invoke 时就能「选择」是否调用工具。
3.1 bind_tools 绑定工具
python
from my_llm import deepseek_llm
from tool_definition import get_weather, calculate_tool
model_with_tools = deepseek_llm.bind_tools([get_weather, calculate_tool])
response = model_with_tools.invoke("北京天气怎么样?")
返回的还是 AIMessage,但多了 tool_calls 字段:
css
返回类型: AIMessage
内容: 好的,我来帮你查一下北京的天气情况。
tool_calls: [{'name': 'get_weather', 'args': {'location': '北京'}, 'id': 'call_00_X4Jm...', 'type': 'tool_call'}]
模型可能同时输出 content(说明文字)和 tool_calls(调用请求),两者并不互斥。
3.2 tool_calls 结构
每个工具调用包含三个关键字段:
| 字段 | 含义 | 示例 |
|---|---|---|
name |
要调用的工具名 | get_weather |
args |
参数(模型按 schema 填好,dict) | {'location': '北京'} |
id |
本次调用的唯一标识 | call_00_X4Jm... |
id 很重要------回传 ToolMessage 时要靠它对上号(第四章会用到)。
3.3 不需要工具时
如果问题不需要工具(比如闲聊),模型直接回答,tool_calls 为空列表:
makefile
内容: 我是一个 AI 助手...
tool_calls: []
判断模型有没有调用工具,看 response.tool_calls 是否为空即可。
3.4 tool_choice 控制
bind_tools 可以传 tool_choice 控制调用行为:
"auto"(默认):模型自主决定"any":必须调用某个工具"none":禁止调用工具
python
# 强制必须调用某个工具
model = deepseek_llm.bind_tools([get_weather, calculate_tool], tool_choice="any")
# 禁止调用工具,让模型直接回答
model = deepseek_llm.bind_tools([get_weather, calculate_tool], tool_choice="none")
不过有个坑:思考模式不支持 tool_choice="any",需要强制调用就得关闭思考模式。实测对比(思考模式):
ini
--- 思考模式 + tool_choice='auto' ---
[成功] tool_calls: ['get_weather']
reasoning_content: 用户想知道北京的天气。让我调用天气工具来获取信息。
--- 思考模式 + tool_choice='any' ---
[失败] Thinking mode does not support this tool_choice
顺带一提:思考模式下 AIMessage 会多出 reasoning_content(思考过程)和 reasoning_tokens(思考消耗的 token),这是思考模式独有的信息,非思考模式没有。
四、手动执行工具调用循环
这一章是本篇内容的核心,把工具调用的完整循环手动实现一遍------从最简单的单次调用,逐步加到多工具、多轮,最后封装成可复用的循环。
先准备工具映射,方便后面按工具名查找执行:
python
from langchain_core.messages import HumanMessage, ToolMessage
from my_llm import deepseek_llm
from tool_definition import get_weather, calculate_tool
TOOLS = [get_weather, calculate_tool]
TOOL_MAP = {t.name: t for t in TOOLS} # name → 工具 的映射
4.1 单次工具调用闭环
最简单的情况:用户问一个问题,模型调用一个工具,拿到结果后给出最终回答。
python
model_with_tools = deepseek_llm.bind_tools(TOOLS)
# 第1步:用户提问 → 模型决策(返回含 tool_calls 的 AIMessage)
messages = [HumanMessage("北京天气怎么样?")]
ai_response = model_with_tools.invoke(messages)
# 第2步:把 AIMessage 加入历史,按 name 找工具执行
messages.append(ai_response)
for tc in ai_response.tool_calls:
result = TOOL_MAP[tc["name"]].invoke(tc["args"])
# 第3步:构造 ToolMessage 回传(tool_call_id 要对上)
messages.append(ToolMessage(content=result, tool_call_id=tc["id"], name=tc["name"]))
# 第4步:模型基于工具结果生成最终回答
final_response = model_with_tools.invoke(messages)
运行结果:
css
模型决策: tool_calls = [{'name': 'get_weather', 'args': {'location': '北京'}, 'id': 'call_00_p70w...', 'type': 'tool_call'}]
工具执行: get_weather({'location': '北京'}) = 北京 天气晴朗,25度
最终回答: 北京目前的天气情况:晴朗,25°C,适合外出活动...
几个关键点:
- AIMessage 要加入 messages:模型下一轮需要看到自己之前的决策
- 按 name 找工具执行 :tool_calls 里的
name对应TOOL_MAP的 key - ToolMessage 的 tool_call_id 要对上:模型靠 id 把结果和调用请求关联起来(第三章 3.2 提到的 id 就是用在这里)
4.2 一次调用多个工具(并发)
模型在一轮里可以同时返回多个工具调用。比如问「北京天气怎么样?顺便算一下 100 * 23」:
ini
模型决策: ['get_weather', 'calculate']
最终回答: 1. 北京的天气:晴朗,25°C 2. 100 × 23 = 2300
模型一次性决定调用 get_weather 和 calculate 两个工具,我们遍历 tool_calls 逐个执行、逐个回传即可,下一轮模型基于两个结果综合回答。代码和 4.1 完全一样(for 循环本来就支持多个 tool_call),不用改。
4.3 多轮工具调用循环(顺序)
有些问题模型会分多轮调用工具。比如问「先查上海天气,再算 999 / 3」:
css
第 1 轮:调用工具 ['get_weather']
get_weather({'location': '上海'}) = 上海 天气晴朗,25度
第 2 轮:调用工具 ['calculate']
calculate({'expression': '999/3'}) = 999/3 = 333.0
第 3 轮:模型给出最终回答
这里模型选择一轮调一个工具,分两轮完成。用循环处理最合适:
python
messages = [HumanMessage("先查上海天气,再算 999 / 3")]
for i in range(max_iterations):
ai_response = model_with_tools.invoke(messages)
messages.append(ai_response)
if not ai_response.tool_calls: # 没有工具调用,说明给出最终回答
print(f"最终回答: {ai_response.content}")
break
for tc in ai_response.tool_calls:
result = TOOL_MAP[tc["name"]].invoke(tc["args"])
messages.append(ToolMessage(content=result, tool_call_id=tc["id"], name=tc["name"]))
一次多工具 vs 多轮:
观察下来,问题表述会影响模型的选择。
- 「顺便」「和」这类并列表述,模型倾向于一轮并发调用;
- 「先......再......」这类顺序表述,模型倾向于分轮顺序调用;
两种都能被上面的循环正确处理------这其实也是用循环(而不是写死步骤)的好处。
4.4 封装成可复用的循环
把上面的循环封装成函数,就是一个简易 agent 了:
python
def run_with_tools(user_input, tools, max_iterations=5):
model_with_tools = deepseek_llm.bind_tools(tools)
tool_map = {t.name: t for t in tools}
messages = [HumanMessage(user_input)]
print(f"用户提问: {user_input}")
for i in range(max_iterations):
ai_response = model_with_tools.invoke(messages)
messages.append(ai_response)
if not ai_response.tool_calls:
print(f"第 {i + 1} 轮:模型给出最终回答")
return ai_response.content
print(f"第 {i + 1} 轮:模型决定调用 {[tc['name'] for tc in ai_response.tool_calls]}")
for tc in ai_response.tool_calls:
result = tool_map[tc["name"]].invoke(tc["args"])
print(f" 执行 {tc['name']}({tc['args']}) = {result}")
messages.append(ToolMessage(content=result, tool_call_id=tc["id"], name=tc["name"]))
return "达到最大轮次仍未完成"
运行结果:
css
用户提问: 天津天气和 50 + 50 的结果
第 1 轮:模型决定调用 ['get_weather', 'calculate']
执行 get_weather({'location': '天津'}) = 天津 天气晴朗,25度
执行 calculate({'expression': '50+50'}) = 50+50 = 100
第 2 轮:模型给出最终回答
最终回答: 1. 天津天气:晴朗,25°C 2. 50 + 50 = 100
到这一步,我们其实已经手动实现了 create_agent 的核心逻辑。第2篇那个会自动调天气工具的 Agent,底层跑的就是这个循环------只是 create_agent 帮我们封装好了,还额外加了状态管理、流式输出、HITL 等生产级能力。
max_iterations 是个保护,防止模型陷入「不停调工具」的死循环,生产环境一般设个合理上限。
五、工具调用的流式处理
第4篇讲过 stream,普通文本流式输出时一个个 token 返回。工具调用也支持流式,但有个区别:tool_calls 是分片返回的,需要把分片累加起来才能拿到完整的调用信息。
5.1 stream 中的 tool_call 分片
python
model_with_tools = deepseek_llm.bind_tools([get_weather, calculate_tool])
full = None
for chunk in model_with_tools.stream("帮我算 88 * 77"):
full = chunk if full is None else full + chunk # 关键:用 + 累加
if chunk.tool_call_chunks:
print(f"chunk: {chunk.tool_call_chunks}")
print(f"\n累加后的完整 tool_calls: {full.tool_calls}")
观察每个分片:
python
chunk: [{'name': 'calculate', 'args': '', 'id': 'call_00_gIHw...', 'index': 0, 'type': 'tool_call_chunk'}]
chunk: [{'name': None, 'args': '{', 'id': None, 'index': 0, ...}]
chunk: [{'name': None, 'args': '"expression"', 'id': None, 'index': 0, ...}]
chunk: [{'name': None, 'args': ': ', 'id': None, 'index': 0, ...}]
chunk: [{'name': None, 'args': '"88 * 77"', 'id': None, 'index': 0, ...}]
chunk: [{'name': None, 'args': '}', 'id': None, 'index': 0, ...}]
可以看到几个规律:
- 第一个分片 携带
name(工具名)和id(调用标识),args 还是空的 - 后续分片只有 args 的片段,name 和 id 都是 None
- args 本质是一段 JSON 字符串,被拆成
{、"expression"、:、"88 * 77"、}一片片返回 index字段标记这是第几个工具调用(并发多工具时用来区分不同的调用)
5.2 累加得到完整结果
用 + 把所有 chunk 累加(和第4篇 stream 累加 AIMessageChunk 是一样的操作),最终拿到完整的 tool_calls:
css
累加后的完整 tool_calls: [{'name': 'calculate', 'args': {'expression': '88 * 77'}, 'id': 'call_00_gIHw...', 'type': 'tool_call'}]
完整内容: 好的,我来帮你计算 88 * 77。
累加后 args 会自动从 JSON 字符串片段拼合并解析成 dict({'expression': '88 * 77'}),可以直接用。
5.3 什么时候用得上
老实说,手动循环(第四章)大多数场景用 invoke 就够了,不需要 stream。流式处理工具调用主要用在:
- 实时 UI:聊天界面里,模型一边想一边显示「正在调用 calculate 工具......」
- 接入 agent:agent 的 stream 模式会把工具调用过程作为事件流出来,这时就得理解分片累加
所以这一章了解机制就行,日常手写循环不用纠结流式。
六、服务端工具(Server-side tool use)
前面五章讲的都是「客户端工具」:模型返回 tool_calls,我们自己执行,再用 ToolMessage 回传。还有一类叫「服务端工具」,由模型提供商在服务端直接执行,我们不用管执行,也不用回传。
6.1 什么是服务端工具
部分提供商(如 OpenAI)提供内置的服务端工具,比如 web_search(联网搜索)、code_interpreter(代码执行)。模型在服务端直接调用这些工具、拿到结果、再组织回答,整个过程在一个对话轮次里完成。
用法很简单,bind_tools 时传一个工具类型即可:
python
from my_llm import openai_llm
# web_search 是 OpenAI 内置的服务端工具
model_with_server_tool = openai_llm.bind_tools([{"type": "web_search"}])
response = model_with_server_tool.invoke("最近有什么科技新闻?")
6.2 content_blocks:调用过程和结果都在这里
服务端工具不返回 tool_calls (我们也不需要执行),调用过程和结果都放在 content_blocks 里。实测结果:
ini
content_blocks:
[调用] name=web_search, args={'type': 'search', 'query': '最近科技新闻', 'queries': ['最近科技新闻']}
[结果] status=success
[文本] 最近的科技新闻包括:1. 人工智能与教育:纽约州试点"机器人老师"...【IT之家】(https://www.itho...)
content_blocks 里有三种块:
server_tool_call:模型决定调用 web_search,带 name 和 argsserver_tool_result:搜索结果状态(success)text:模型基于搜索结果组织的最终回答(还带引用链接)
整个搜索、拿结果、组织回答的过程都在服务端一轮完成,我们这边只是一次 invoke。
6.3 和客户端工具的对比
| 维度 | 客户端工具(本篇 1~5 章) | 服务端工具(本章) |
|---|---|---|
| 执行方 | 我们自己执行 | 提供商服务端执行 |
| 返回方式 | tool_calls |
content_blocks |
| 是否需要 ToolMessage | 需要,多轮回传 | 不需要,单轮完成 |
| 工具来源 | 我们自定义(@tool 等) | 提供商内置(web_search 等) |
| 适用场景 | 业务自定义逻辑 | 通用能力(搜索、代码执行) |
简单说:需要调用自己的业务接口(查数据库、调内部 API),用客户端工具;需要通用能力(联网搜索、运行代码),用服务端工具。
需要注意,服务端工具依赖提供商支持,DeepSeek 目前不支持,要用 OpenAI 等;另外我测试用的中转站正好支持 web_search,部分中转站可能不支持,以实际测试为准。
七、实战案例
前面几章的工具都比较简单(模拟天气、eval 计算)。这一章用一个智能助手把多个工具组合起来,其中联网搜索换成 Tavily(真实联网,不再是模拟),更贴近实际业务。
工具集:
| 工具 | 类型 | 作用 |
|---|---|---|
get_weather |
@tool(模拟) | 查天气 |
calculate |
@tool(eval) | 算数学 |
tavily_search |
TavilySearch(联网) | 搜股价等实时信息 |
小提示:
tavily_search用的是langchain-tavily包的TavilySearch。旧版TavilySearchResults(langchain-community)已废弃、1.0 移除,看到LangChainDeprecationWarning记得按提示迁移。
先定义三个工具,并复用第四章封装好的 run_assistant 循环:
python
from langchain_tavily import TavilySearch
from langchain_core.tools import tool
@tool
def get_weather(location: str) -> str:
"""获取指定城市的天气信息"""
return f"{location} 今天晴,气温 22-30°C,湿度 45%"
@tool
def calculate(expression: str) -> str:
"""计算数学表达式,如 1+2、3*4、(5+6)*2"""
...
# 联网搜索(真实联网,不再是模拟数据)
tavily_search = TavilySearch(max_results=3)
TOOLS = [get_weather, calculate, tavily_search]
# run_assistant 直接复用第四章 4.4 封装好的循环
说明:这里的
get_weather返回的信息比第二章 2.1 的更详细(多了气温范围、湿度),两个文件的实现略有差异,以实际代码为准。
7.1 案例1:天气 + 计算(多工具组合)
python
run_assistant("查一下北京和上海的天气,再算一下 365 * 24 是多少小时")
运行过程:
css
用户提问: 查一下北京和上海的天气,再算一下 365 * 24 是多少小时
第 1 轮:模型决定调用 ['get_weather', 'get_weather', 'calculate']
执行 get_weather({'location': '北京'}) = 北京 今天晴,气温 22-30°C,湿度 45%
执行 get_weather({'location': '上海'}) = 上海 今天晴,气温 22-30°C,湿度 45%
执行 calculate({'expression': '365 * 24'}) = 365 * 24 = 8760
第 2 轮:模型给出最终回答
注意第 1 轮模型一次性发起了 3 个工具调用(两次 get_weather + 一次 calculate),并发执行,第 2 轮再汇总。这就是第三章说的「一次多工具」。
7.2 案例2:股票查询(Tavily 联网搜索)
python
run_assistant("帮我查一下苹果(AAPL)和贵州茅台(600519)的最新股价")
运行过程:
css
用户提问: 帮我查一下苹果(AAPL)和贵州茅台(600519)的最新股价
第 1 轮:模型决定调用 ['tavily_search', 'tavily_search']
执行 tavily_search({'query': '苹果 AAPL 最新股价 2025', ...})
执行 tavily_search({'query': '贵州茅台 600519 最新股价 2025', ...})
第 2 轮:模型决定调用 ['tavily_search', 'tavily_search'] ← 又搜了一轮
执行 tavily_search({'query': 'Apple AAPL stock price today real-time', ...})
执行 tavily_search({'query': '贵州茅台 600519 股价 今日', ...})
第 3 轮:模型给出最终回答
这个案例值得留意的是模型搜了两轮:第一轮用中文关键词搜,拿到结果后觉得不够精确,第二轮自动换成英文 query + 调整关键词再搜,最后才汇总成带涨跌幅、52 周范围的报告。这正是「多轮循环」的价值------模型会根据中间结果自主决定要不要再调一次工具。
而且 tavily_search 的参数(query、search_depth、topic)都是模型自己填的,说明它读得懂 TavilySearch 的 schema。
7.3 案例3:综合查询
python
run_assistant("深圳天气怎么样?顺便算一下 12 * 15,再查下特斯拉(TSLA)最新股价")
运行过程:
css
用户提问: 深圳天气怎么样?顺便算一下 12 * 15,再查下特斯拉(TSLA)最新股价
第 1 轮:模型决定调用 ['get_weather', 'calculate', 'tavily_search']
执行 get_weather({'location': '深圳'}) = ...
执行 calculate({'expression': '12 * 15'}) = 12 * 15 = 180
执行 tavily_search({'query': '特斯拉 TSLA 最新股价', ...})
第 2 轮:模型给出最终回答
一次调用三种不同类型的工具(本地模拟 + 计算 + 联网搜索),模型能根据问题把合适的工具组合起来。
7.4 实战要点
- 多工具并发:一轮里可以同时调多个工具,循环时统一执行、统一回传
- 多轮搜索:模型对中间结果不满意时会自主调整 query 再搜,这是手写循环才能清楚看到的过程
- 工具 schema 要清晰:模型靠 schema 决定调哪个工具、怎么填参数,描述写得越准,调用越准
- 留意 deprecation:LangChain 1.x 的社区工具在陆续拆到独立包,看到警告及时迁移
八、总结
本篇把工具调用的循环手动拆解了一遍:
- 定义工具 :
@tool(推荐)、StructuredTool、Pydantic 直接传、BaseTool 子类;注意@tool和 StructuredTool 的参数默认不带描述,可用 Annotated 补 - 绑定与决策 :
bind_tools绑定,模型返回tool_calls(name、args、id);tool_choice控制调用行为 - 手动循环(核心):解析 tool_calls → 执行工具 → ToolMessage 回传 → 模型再决策,循环到模型不再调用工具
- 流式输出 :tool_calls 分片返回,用
+累加;日常 invoke 就够用 - 服务端工具 :结果走
content_blocks,单轮完成,不需要 ToolMessage
还有两个值得记住的点:
- 思考模式兼容
tool_choice='auto',但不支持强制的'any'(需要强制调用就得关闭思考) - 多工具可以并发调用,模型也会根据中间结果自主多轮调用(比如搜索不满意时换关键词再搜)
理解了这个手动过程,再回过头看第2篇的 create_agent,它其实就是把这个循环自动化了------工具定义、绑定、循环、回传,都封装好了。
下一篇我们会进入模型的高级特性(多模态、推理能力、本地模型等)。