11506 字
58 分钟
LangChain Core 源码解剖:Tool Calling 工具调用链路

LangChain Core 源码学习路线:第 4 篇 Tool Calling 源码解剖#

核心问题: 普通 Python 函数如何成为模型可声明、运行时可校验、执行器可调用并能返回 ToolMessage 的工具?

源码主线: @tool → StructuredTool → args_schema → bind_tools → ToolCall → BaseTool.invoke → ToolMessage

前置文章: 第 1 篇《Runnable 源码解剖》、第 2 篇《Prompt、Message 与 ChatModel 源码解剖》、第 3 篇《结构化输出源码解剖》

依赖基线: langchain-core==1.4.8langchain==1.3.11

源码基线: https://github.com/langchain-ai/langchain/tree/83e824922ac9bf3bf882347fecabd09614bccc37 ,commit 83e824922ac9bf3bf882347fecabd09614bccc37

阅读边界: 本篇覆盖 Tool 声明和执行协议,不展开 create_agent 的图循环与 middleware 编排。


0. 本篇在源码学习主线中的位置#

前三篇已经建立了三条核心链路:

第 1 篇:
Prompt、Model、Parser
统一实现 Runnable 协议
通过 RunnableSequence 组合执行
第 2 篇:
输入变量
ChatPromptTemplate
ChatPromptValue
list[BaseMessage]
BaseChatModel
AIMessage
第 3 篇:
模型自然语言输出
OutputParser / with_structured_output
JSON / Pydantic 结构化对象

这一篇继续回答 Agent 系统中最关键的问题之一:

模型如何从“只能生成文本”,升级为“能够请求外部能力”?

表面上,我们只写了一个装饰器:

@tool
def search_attractions(city: str, preference: str) -> str:
"""Search attractions in a city based on user preference."""
return f"{city} attractions for {preference}"

但框架内部实际发生了多层转换:

普通 Python 函数
读取函数名、签名、类型注解、docstring
生成 Pydantic args_schema
构造 StructuredTool
转换成模型供应商可识别的 JSON Schema
绑定给 ChatModel
模型返回结构化 ToolCall
执行层根据 name 查找工具
BaseTool 校验 args、注入 config、启动 callbacks
StructuredTool 调用真实 Python 函数
返回值转换为 ToolMessage
ToolMessage 重新进入 messages
模型根据工具结果继续生成

因此,Tool Calling 不是一个单独 API,而是至少涉及四个协议:

协议层解决的问题核心对象
工具声明协议Python 能力如何描述给框架@toolStructuredToolargs_schema
模型工具协议工具如何描述给模型name、description、JSON Schema、bind_tools
工具调用协议模型如何表达调用意图AIMessage.tool_callsToolCall
工具结果协议执行结果如何返回模型ToolMessagetool_call_id

如果没有把这四层区分开,就很容易产生以下误解:

误解 1:模型会直接执行 Python 函数。
误解 2:bind_tools() 会执行工具。
误解 3:@tool 只是给函数增加一个名字。
误解 4:工具函数返回字符串后,模型会自动看到它。
误解 5:Tool Calling 本身就是 ReAct。
误解 6:tool.invoke(dict) 一定返回 ToolMessage。
误解 7:只要参数有类型注解,就不需要工具描述。

本篇将逐层拆开这些机制。


1. 本篇问题、学习目标与能力边界#

本篇目标是理解:

普通 Python 函数如何被包装为 LangChain Tool,如何被模型选择,如何被执行层调用,以及执行结果如何通过 ToolMessage 返回模型。

你需要掌握:

  • @tool
  • BaseTool
  • StructuredTool
  • Tool
  • args_schema
  • tool_call_schema
  • tool name
  • tool description
  • bind_tools
  • ToolCall
  • AIMessage.tool_calls
  • ToolMessage
  • ToolException
  • handle_tool_error
  • handle_validation_error
  • response_format
  • RunnableConfig
  • Tool Calling 与 Agent Loop 的边界

完成本篇后,你应该能回答:

  1. @tool 装饰器为什么返回的是 StructuredTool,而不是原始 Python 函数?
  2. 函数名、docstring、类型注解分别会进入工具的哪个字段?
  3. args_schema 如何从函数签名生成?
  4. Pydantic Schema、JSON Schema 与模型原生 Tool Schema 有什么关系?
  5. model.bind_tools() 到底绑定了什么?它是否执行工具?
  6. 模型为什么返回 AIMessage.tool_calls,而不是直接调用本地函数?
  7. BaseTool.invoke() 与直接调用 Python 函数有什么区别?
  8. BaseTool.invoke()run()_parse_input()_run() 的职责如何划分?
  9. 为什么有时 tool.invoke() 返回字符串,有时返回 ToolMessage
  10. ToolMessage.tool_call_id 为什么必须与模型调用请求中的 ID 对应?
  11. 参数校验错误、业务错误和系统异常分别如何处理?
  12. ToolException 为什么适合表示“可反馈给模型的业务失败”?
  13. StructuredTool.ainvoke() 在没有异步函数时如何工作?
  14. 工具 description 为什么会影响模型的工具选择?
  15. Tool Calling、ToolNode、ReAct 和 create_agent 之间是什么关系?

2. 核心概念与最小心智模型#

Tool-use Agent → LangChain Tool / StructuredTool#

范式层表达:

用户提出任务
模型判断是否需要外部能力
模型选择工具并生成参数
系统执行真实工具
系统把观察结果返回模型
模型继续决策或生成答案

LangChain 框架表达:

HumanMessage
ChatModel.bind_tools(tools)
AIMessage.tool_calls
BaseTool.invoke(tool_call)
StructuredTool._run(...)
ToolMessage
ChatModel.invoke(messages + ToolMessage)
AIMessage

对象映射如下:

Tool-use 范式概念LangChain 对象作用
CapabilityBaseTool / StructuredTool对外部能力进行统一封装
Capability Nametool.name模型和执行器定位工具
Capability Instructiontool.description告诉模型何时、为何使用
Input Contractargs_schema定义参数类型、必填项和说明
Action RequestToolCall模型生成的工具调用请求
Actiontool.invoke()受控执行真实能力
ObservationToolMessage将执行结果返回给模型
Correlation IDtool_call_id关联请求与结果
Recoverable FailureToolException让模型看到并处理工具失败
Agent Loopcreate_agent / ToolNode / 自定义图控制模型与工具之间的循环

Tool Calling 是能力协议,不是 Agent 控制循环#

需要明确区分:

Tool Calling:
模型能够输出结构化工具请求。
ReAct:
模型在“决策 → 行动 → 观察”之间循环。
LangChain Tool:
把外部能力标准化为可描述、可校验、可执行的对象。
ToolNode / Agent Runtime:
接收 tool_calls,执行工具,再把 ToolMessage 放回消息历史。

因此:

@tool

只完成能力封装;

model.bind_tools([tool])

只完成能力声明;

AIMessage.tool_calls

只表示模型提出了调用请求;

真正的执行循环仍然需要:

  • 手动编写循环;
  • LangGraph ToolNode
  • LangChain create_agent
  • 或自定义 Agent Runtime。

3. 完整执行链路#

最小代码并入完整执行链,重点观察 Python 函数、ToolCall 与 ToolMessage 的类型变化。

最小代码#

把普通函数包装成工具#

from langchain.tools import tool
@tool
def search_attractions(city: str, preference: str) -> str:
"""Search attractions in a city based on user preference."""
return f"{city} attractions for {preference}"
print(type(search_attractions))
print(search_attractions.name)
print(search_attractions.description)
print(search_attractions.args)
print(search_attractions.args_schema)

典型输出语义:

type:
StructuredTool
name:
search_attractions
description:
Search attractions in a city based on user preference.
args:
{
"city": {"type": "string"},
"preference": {"type": "string"}
}

此时变量 search_attractions 已经不再是原始函数引用,而是一个 StructuredTool 实例。

查看 MRO 与 Runnable 身份#

print(type(search_attractions).__mro__)

你会观察到类似继承关系:

StructuredTool
BaseTool
RunnableSerializable
Serializable

这说明:

Tool 不是独立于 Runnable 的另一套体系,BaseTool 本身就是一个 Runnable。

因此工具也拥有统一调用入口:

search_attractions.invoke({
"city": "大阪",
"preference": "美食和城市漫步",
})

查看真实 JSON Schema#

schema = search_attractions.args_schema.model_json_schema()
print(schema)

可能得到:

{
"title": "search_attractions",
"description": "Search attractions in a city based on user preference.",
"type": "object",
"properties": {
"city": {
"title": "City",
"type": "string"
},
"preference": {
"title": "Preference",
"type": "string"
}
},
"required": [
"city",
"preference"
]
}

这里要建立第一个关键认识:

Python 类型注解
Pydantic Model
JSON Schema
模型供应商工具描述格式

直接调用与 ToolCall 调用不是同一种语义#

直接传参数字典:

result = search_attractions.invoke({
"city": "大阪",
"preference": "美食",
})
print(type(result))
print(result)

因为输入里没有 tool_call_id,通常返回原始工具结果:

str
大阪 attractions for 美食

传入完整 ToolCall

tool_call = {
"name": "search_attractions",
"args": {
"city": "大阪",
"preference": "美食",
},
"id": "call_001",
"type": "tool_call",
}
result = search_attractions.invoke(tool_call)
print(type(result))
print(result)

此时框架可以得到工具调用 ID,因此返回:

ToolMessage(
content="大阪 attractions for 美食",
tool_call_id="call_001",
name="search_attractions",
status="success",
)

这是本篇最重要的源码细节之一:

BaseTool 是否把返回值包装为 ToolMessage,取决于这次执行是否携带 tool_call_id


完整执行流程#

工具定义阶段#

Python function
@tool
读取 __name__
读取 inspect.signature()
读取类型注解
读取 docstring
生成 Pydantic args_schema
构造 StructuredTool

模型绑定阶段#

StructuredTool
读取 name
读取 description
读取 tool_call_schema
转换为供应商 Tool Schema
model.bind_tools([...])
返回已绑定工具的 ChatModel Runnable

注意:

bind_tools 不会执行工具。
bind_tools 只是把工具的“说明书”发送给模型。

模型决策阶段#

messages
model_with_tools.invoke(messages)
供应商模型判断是否需要工具
供应商返回原生 tool call
LangChain 适配为标准 ToolCall
AIMessage.tool_calls

标准化后的调用通常形如:

{
"name": "search_attractions",
"args": {
"city": "大阪",
"preference": "美食",
},
"id": "call_001",
"type": "tool_call",
}

工具执行阶段#

ToolCall
根据 name 查找 StructuredTool
BaseTool.invoke(tool_call)
_prep_run_args()
BaseTool.run()
_parse_input()
_to_args_and_kwargs()
StructuredTool._run()
调用真实 Python function

结果回填阶段#

Python function return value
BaseTool.run()
_format_output()
ToolMessage(
content=...,
tool_call_id=...,
status=...
)
追加到 messages
模型再次调用
最终 AIMessage

完整时序:

User
│ HumanMessage
ChatModel with tools
│ AIMessage(tool_calls=[...])
Tool Executor
│ BaseTool.invoke(ToolCall)
StructuredTool
│ Python function result
ToolMessage
│ messages += [AIMessage, ToolMessage]
ChatModel with tools
│ Final AIMessage
User

4. 源码地图、关键文件与阅读顺序#

核心目录:

langchain_core/tools/
langchain/tools/
langchain_core/messages/tool.py
langchain_core/utils/function_calling.py
langchain_core/language_models/chat_models.py

重点源码文件:

libs/core/langchain_core/tools/convert.py
libs/core/langchain_core/tools/base.py
libs/core/langchain_core/tools/structured.py
libs/core/langchain_core/tools/simple.py
libs/core/langchain_core/messages/tool.py
libs/core/langchain_core/utils/function_calling.py

重点类与函数:

tool
_create_tool_factory
StructuredTool
StructuredTool.from_function
BaseTool
BaseTool.invoke
BaseTool.run
BaseTool._parse_input
BaseTool._to_args_and_kwargs
create_schema_from_function
ToolException
ToolCall
ToolMessage
_format_output
convert_to_openai_function
convert_to_openai_tool

推荐源码阅读顺序:

1. convert.py::tool
2. convert.py::_create_tool_factory
3. structured.py::StructuredTool.from_function
4. base.py::create_schema_from_function
5. base.py::BaseTool
6. base.py::BaseTool.invoke
7. base.py::_prep_run_args
8. base.py::BaseTool.run
9. base.py::BaseTool._parse_input
10. base.py::BaseTool._to_args_and_kwargs
11. structured.py::StructuredTool._run
12. base.py::_format_output
13. messages/tool.py::ToolCall
14. messages/tool.py::ToolMessage
15. utils/function_calling.py::convert_to_openai_tool

5. 对象模型、继承关系与协议边界#

Tool 对象模型:先看继承关系#

BaseTool 为什么继承 RunnableSerializable#

源码结构可以抽象为:

class BaseTool(
RunnableSerializable[
str | dict[str, Any] | ToolCall,
Any
]
):
...

这说明 BaseTool 的输入可以是:

str
dict[str, Any]
ToolCall

输出则取决于调用方式和工具返回值,可能是:

str
dict
list
ToolMessage
Command
自定义 ToolOutputMixin

继承 Runnable 后,Tool 自动进入 LangChain Core 的统一执行体系:

tool.invoke(...)
await tool.ainvoke(...)
tool.batch(...)
await tool.abatch(...)
tool.with_config(...)
tool.with_retry(...)

但需要注意:

Tool 虽然是 Runnable,却不是普通的 RunnableLambda。

BaseTool 在 Runnable 协议之上额外加入了:

  • 输入 Schema;
  • 工具名和描述;
  • 参数校验;
  • callback 生命周期;
  • ToolCall ID;
  • 错误语义;
  • ToolMessage 格式化;
  • 工具执行上下文注入。

因此可以把它理解为:

BaseTool
=
Runnable 执行协议
+
工具能力描述
+
输入契约
+
执行生命周期
+
结果协议

BaseTool 的关键字段#

源码中的关键字段可以概括为:

class BaseTool(...):
name: str
description: str
args_schema: BaseModel | dict | None
return_direct: bool = False
callbacks: Callbacks = None
tags: list[str] | None = None
metadata: dict[str, Any] | None = None
handle_tool_error: bool | str | Callable | None = False
handle_validation_error: bool | str | Callable | None = False
response_format: Literal[
"content",
"content_and_artifact"
] = "content"

职责如下:

字段主要消费者作用
name模型、工具注册表、执行器唯一定位工具
description模型判断何时调用
args_schema模型、BaseTool参数说明与校验
return_direct部分 Agent Runtime是否直接结束循环
callbacks观测系统工具执行事件
tagstracing标识工具调用类别
metadatatracing附加业务上下文
handle_tool_errorBaseTool处理 ToolException
handle_validation_errorBaseTool处理参数校验失败
response_formatBaseTool区分普通内容与 artifact

StructuredTool 与 Tool 的区别#

StructuredTool#
支持多个命名参数;
通常拥有 Pydantic args_schema;
适合绝大多数现代 Tool Calling 场景。

例如:

@tool
def search_attractions(
city: str,
preference: str,
limit: int = 5,
) -> str:
...

通常生成 StructuredTool

Tool#
主要面向简单 string → output 函数;
不强调多字段结构化输入;
更多承担兼容或简单工具场景。

现代 Agent 开发中,推荐优先使用:

StructuredTool + 明确 args_schema

核心对象关系#

RunnableSerializable
BaseTool
├─ StructuredTool
└─ Tool

四层协议边界#

协议核心对象职责
工具声明@toolStructuredTool把本地能力包装成 Tool
模型声明JSON Schema、bind_tools()把工具描述提供给模型
调用请求ToolCall表达模型的调用意图
调用结果ToolMessage把本地执行结果返回模型上下文

6. 源码阅读策略与证据标准#

阅读顺序#

公开入口
输入输出类型
构建期对象
运行时主链
分支、异常与停止条件
扩展接口

证据标准#

标记使用条件
源码事实当前正式版源码可以直接证明
官方契约官方文档或 API Reference 明确承诺
简化伪代码压缩真实控制流,且明确不是逐字源码
作者推断根据调用关系得出,必须标注为推断
工程建议说明适用条件,不写成框架保证

7. 构建期源码解剖#

本章解释装饰器、工厂、Schema 和模型工具描述如何被构建。

@tool 装饰器源码解剖#

@tool 不是简单语法糖#

当 Python 执行:

@tool
def search_attractions(...):
...

等价于:

def search_attractions(...):
...
search_attractions = tool(search_attractions)

因此装饰完成后:

search_attractions

变量指向的是一个 BaseTool 子类实例,而不是原函数。

原始函数会保存在:

search_attractions.func

中。

tool() 支持多种调用方式#

源码中的 tool() 使用多个 overload,支持:

@tool
def search(...):
...
@tool("web_search")
def search(...):
...
@tool(
"web_search",
description="Search public web pages.",
parse_docstring=True,
)
def search(...):
...
tool("my_tool", runnable)

它的主要参数包括:

description
return_direct
args_schema
infer_schema
response_format
parse_docstring
error_on_invalid_docstring
extras

tool() 入口伪代码#

把源码拆成更容易理解的伪代码:

def tool(
name_or_callable=None,
runnable=None,
*,
description=None,
return_direct=False,
args_schema=None,
infer_schema=True,
response_format="content",
parse_docstring=False,
error_on_invalid_docstring=True,
extras=None,
):
# 情况 1:tool("name", runnable)
if runnable is not None:
validate_name(name_or_callable)
factory = _create_tool_factory(name_or_callable)
return factory(runnable)
# 情况 2:@tool
# name_or_callable 直接就是被装饰函数
if callable(name_or_callable):
tool_name = name_or_callable.__name__
factory = _create_tool_factory(tool_name)
return factory(name_or_callable)
# 情况 3:@tool("custom_name")
if isinstance(name_or_callable, str):
return _create_tool_factory(name_or_callable)
# 情况 4:@tool(...)
# 此时先返回一个等待接收函数的装饰器
if name_or_callable is None:
def partial(func):
tool_name = (
func.get_name()
if isinstance(func, Runnable)
else func.__name__
)
factory = _create_tool_factory(tool_name)
return factory(func)
return partial
raise ValueError(...)

逐步解释:

第一步:判断调用形式#

框架首先判断用户是:

  • 直接装饰函数;
  • 指定工具名称;
  • 带参数的装饰器;
  • 把 Runnable 转成 Tool。
第二步:确定工具名#

默认工具名来自:

func.__name__

自定义名称则来自:

@tool("web_search")
第三步:创建工具工厂#

真正把函数转成 Tool 的逻辑不在最外层 tool() 中,而在:

_create_tool_factory(tool_name)
第四步:工具工厂接收原函数#

工厂再根据原对象类型决定:

  • 同步函数;
  • 异步函数;
  • Runnable;
  • StructuredTool;
  • 简单 Tool。

_create_tool_factory() 源码解剖#

工厂的核心职责#

简化伪代码:

def _create_tool_factory(tool_name):
def _tool_factory(dec_func):
tool_description = description
if isinstance(dec_func, Runnable):
validate_runnable_input_schema(dec_func)
def invoke_wrapper(callbacks=None, **kwargs):
return dec_func.invoke(
kwargs,
{"callbacks": callbacks},
)
async def ainvoke_wrapper(callbacks=None, **kwargs):
return await dec_func.ainvoke(
kwargs,
{"callbacks": callbacks},
)
func = invoke_wrapper
coroutine = ainvoke_wrapper
schema = dec_func.input_schema
if tool_description is None:
tool_description = repr(dec_func)
elif inspect.iscoroutinefunction(dec_func):
func = None
coroutine = dec_func
schema = args_schema
else:
func = dec_func
coroutine = None
schema = args_schema
if infer_schema or args_schema is not None:
return StructuredTool.from_function(
func=func,
coroutine=coroutine,
name=tool_name,
description=tool_description,
args_schema=schema,
infer_schema=infer_schema,
response_format=response_format,
parse_docstring=parse_docstring,
error_on_invalid_docstring=(
error_on_invalid_docstring
),
extras=extras,
)
return Tool(
name=tool_name,
func=func,
coroutine=coroutine,
description=f"{tool_name} tool",
...
)
return _tool_factory

为什么默认得到 StructuredTool#

默认配置是:

infer_schema=True

因此普通函数会进入:

StructuredTool.from_function(...)

只有明确关闭 Schema 推断时:

@tool(infer_schema=False)

才可能落到简单 Tool 分支。

Runnable 也能转 Tool#

如果被装饰对象是 Runnable,工厂会创建包装函数:

def invoke_wrapper(callbacks=None, **kwargs):
return runnable.invoke(
kwargs,
{"callbacks": callbacks},
)

这意味着:

Runnable
包装为普通 callable
StructuredTool

但 Runnable 的输入 Schema 必须能够描述为对象:

{
"type": "object",
"properties": {...}
}

否则无法让模型生成命名参数。

同步与异步函数的分流#

同步函数:

func = dec_func
coroutine = None

异步函数:

func = None
coroutine = dec_func

随后由 StructuredTool 决定:

invoke → _run → func
ainvoke → _arun → coroutine

若只有同步函数却调用 ainvoke(),框架可以把同步执行放入线程执行器,避免直接阻塞异步事件循环。


StructuredTool.from_function() 源码解剖#

第一步:确定源函数#

伪代码:

if func is not None:
source_function = func
elif coroutine is not None:
source_function = coroutine
else:
raise ValueError(
"Function and/or coroutine must be provided"
)

源函数用于提取:

  • __name__
  • __doc__
  • inspect.signature
  • 参数类型注解

第二步:确定工具名#

name = name or source_function.__name__

优先级:

显式 name
>
函数 __name__

例如:

@tool("attraction_search")
def search_attractions(...):
...

最终:

函数名:search_attractions
工具名:attraction_search

模型看到和调用的是工具名,而不是 Python 局部变量名。

第三步:生成 args_schema#

伪代码:

if args_schema is None and infer_schema:
args_schema = create_schema_from_function(
name,
source_function,
parse_docstring=parse_docstring,
error_on_invalid_docstring=(
error_on_invalid_docstring
),
filter_args=_filter_schema_args(
source_function
),
)

只有在:

未显式提供 args_schema
并且 infer_schema=True

时,才自动从函数签名推断。

第四步:确定 description#

源码逻辑可以抽象为:

description_ = explicit_description
if description_ is None and not parse_docstring:
description_ = source_function.__doc__
if description_ is None and args_schema is not None:
description_ = args_schema_description
if description_ is None:
raise ValueError(
"Function must have a docstring "
"if description not provided."
)
description_ = dedent(description_).strip()

description 的主要优先级:

显式 description
>
函数 docstring
>
args_schema 的 description/docstring

因此下面的代码可能失败:

@tool
def search_attractions(city: str) -> str:
return city

因为没有:

  • 显式 description;
  • 函数 docstring;
  • 可用的 Schema 描述。

第五步:构造 StructuredTool#

return StructuredTool(
name=name,
func=func,
coroutine=coroutine,
args_schema=args_schema,
description=description_,
return_direct=return_direct,
response_format=response_format,
**kwargs,
)

最终对象同时保存:

给模型看的能力契约
+
给执行器调用的真实函数

可以把它理解为:

StructuredTool(
model_interface={
"name": ...,
"description": ...,
"args_schema": ...,
},
runtime_implementation={
"func": ...,
"coroutine": ...,
},
)

args_schema 自动生成源码解剖#

自动推断的输入信息#

考虑函数:

def search_attractions(
city: str,
preference: str,
limit: int = 5,
) -> list[str]:
"""Search attractions.
Args:
city: Destination city.
preference: User interests.
limit: Maximum result count.
"""

Schema 生成需要读取:

函数签名
参数名
参数类型
默认值
是否必填
docstring
参数描述
Annotated / Field 描述

create_schema_from_function() 的总体伪代码#

def create_schema_from_function(
model_name,
func,
*,
filter_args=None,
parse_docstring=False,
error_on_invalid_docstring=False,
include_injected=True,
):
# 1. 读取 Python 函数签名
sig = inspect.signature(func)
# 2. 使用 Pydantic 包装并构造验证模型
validated = validate_arguments(
func,
config=_SchemaConfig,
)
# 3. 获取 Pydantic 自动推断的完整模型
inferred_model = validated.model
# 4. 确定需要过滤的框架参数
filter_args_ = resolve_filter_args(
func,
sig,
filter_args,
)
# 5. 解析函数和参数说明
description, arg_descriptions = (
_infer_arg_descriptions(
func,
parse_docstring=parse_docstring,
error_on_invalid_docstring=(
error_on_invalid_docstring
),
)
)
# 6. 移除 Pydantic 生成的虚拟字段
valid_properties = []
for field in get_fields(inferred_model):
if field in {"args", "kwargs",
"v__duplicate_kwargs"}:
continue
if field in filter_args_:
continue
valid_properties.append(field)
# 7. 只保留模型可见字段
return _create_subset_model(
model_name,
inferred_model,
valid_properties,
descriptions=arg_descriptions,
fn_description=description,
)

第一步:inspect.signature()#

Python 函数签名:

(city: str, preference: str, limit: int = 5)

会被解析为:

city
annotation = str
default = empty
required = True
preference
annotation = str
default = empty
required = True
limit
annotation = int
default = 5
required = False

第二步:Pydantic 构造验证模型#

LangChain 当前源码会借助 Pydantic 的参数验证能力得到中间模型。

中间模型的意义是:

Python 函数签名
Pydantic 字段模型
可校验输入
可输出 JSON Schema

源码注释还记录了一项后续重构方向:

未来可直接使用 inspect.signature + create_model
重写这一部分。

这说明:

Schema 推断的目标不是依赖某个特定装饰器,而是从 Python 签名构造标准数据模型。

第三步:过滤框架注入参数#

工具函数可能包含不应该由模型生成的参数:

def my_tool(
query: str,
config: RunnableConfig,
runtime: ToolRuntime,
callbacks=None,
):
...

模型应该只看到:

query

而不应该看到:

config
runtime
callbacks
run_manager

这些参数由框架运行时注入。

当前官方文档明确保留了:

config
runtime

作为框架参数名。

第四步:解析 docstring#

默认:

parse_docstring=False

此时通常把整个函数 docstring 用作工具 description,但不会一定把 Args: 中每个参数说明拆成字段说明。

启用:

@tool(parse_docstring=True)

后,框架会尝试按 Google Style 解析:

def search_attractions(
city: str,
preference: str,
) -> str:
"""Search attractions for a traveler.
Args:
city: Destination city name.
preference: User interest, such as food or art.
"""

可能生成:

{
"properties": {
"city": {
"type": "string",
"description": "Destination city name."
},
"preference": {
"type": "string",
"description": "User interest, such as food or art."
}
}
}

若 docstring 中出现函数签名不存在的参数:

Args:
country: ...

而函数没有 country,在严格配置下会抛出错误。

Annotated 参数描述#

当前 Schema 推断也会读取 Annotated 中的字符串或 Pydantic Field 描述:

from typing import Annotated
@tool
def search_attractions(
city: Annotated[str, "Destination city name"],
preference: Annotated[
str,
"Traveler interests such as food, art, or history",
],
) -> str:
"""Search attractions matching traveler preferences."""
...

这使参数契约更靠近类型定义本身。

自动生成 Schema 的结果#

函数:

@tool(parse_docstring=True)
def search_attractions(
city: str,
preference: str,
limit: int = 5,
) -> list[str]:
"""Search attractions matching user interests.
Args:
city: Destination city.
preference: Traveler preference.
limit: Maximum number of attractions.
"""

近似得到:

{
"title": "search_attractions",
"description": "Search attractions matching user interests.",
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "Destination city."
},
"preference": {
"type": "string",
"description": "Traveler preference."
},
"limit": {
"type": "integer",
"default": 5,
"description": "Maximum number of attractions."
}
},
"required": [
"city",
"preference"
]
}

显式 args_schema:把工具输入当成正式 API 契约#

使用 Pydantic Model#

from typing import Literal
from pydantic import BaseModel, Field
from langchain.tools import tool
class AttractionSearchInput(BaseModel):
"""Input schema for attraction search."""
city: str = Field(
description="Destination city name, such as Osaka"
)
preference: str = Field(
description="Traveler interest, such as food or architecture"
)
limit: int = Field(
default=5,
ge=1,
le=20,
description="Maximum number of results"
)
language: Literal["zh", "en", "ja"] = Field(
default="zh",
description="Preferred result language"
)
@tool(args_schema=AttractionSearchInput)
def search_attractions(
city: str,
preference: str,
limit: int = 5,
language: str = "zh",
) -> list[str]:
"""Search attractions matching traveler preferences."""
return []

显式 Schema 的优势:

  • 字段描述更清楚;
  • 支持枚举;
  • 支持范围约束;
  • 支持正则与长度约束;
  • Schema 可以独立测试;
  • 可作为 Tool API 契约;
  • 修改函数实现时不必改变模型接口。

使用 JSON Schema 字典#

args_schema 也可以是 JSON Schema:

ATTRACTION_SCHEMA = {
"title": "search_attractions",
"description": "Search attractions matching traveler interests.",
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "Destination city",
},
"preference": {
"type": "string",
"description": "Traveler preference",
},
},
"required": ["city", "preference"],
"additionalProperties": False,
}

但源码有一个重要差异:

if isinstance(args_schema, dict):
return tool_input

也就是说,BaseTool._parse_input() 对 Pydantic Model 会执行:

model_validate(...)

而对普通 JSON Schema 字典不会自动调用完整 JSON Schema Validator。

因此:

JSON Schema 能描述给模型看,不等于本地执行层一定完成同等强度的参数校验。

若需要严格本地校验,应:

  • 优先使用 Pydantic Model;
  • 或在工具实现中显式使用 JSON Schema Validator;
  • 或在业务 Service 层再次校验。

args_schematool_call_schema#

两者不要混淆。

args_schema#

工具完整的输入 Schema,可能包含:

  • 模型生成字段;
  • 框架注入字段;
  • 运行时字段。
tool_call_schema#

真正展示给模型的工具调用 Schema。

源码会从完整 Schema 中排除注入参数:

args_schema
过滤 ToolRuntime / Injected 参数
tool_call_schema
发送给模型

因此工具函数可以写:

@tool
def get_user_preference(
preference_name: str,
runtime: ToolRuntime,
) -> str:
...

模型只看到:

{
"preference_name": {
"type": "string"
}
}

runtime 由执行环境注入,不应该由模型伪造。


Tool name 与 description:模型选择工具的控制面#

name 不是普通函数名#

工具名同时承担:

  • 模型选择标识;
  • ToolCall 中的 name
  • 执行器工具注册表 Key;
  • trace 中的能力标识;
  • 版本兼容接口。

推荐:

search_attractions
query_weather
get_hotel_quote
optimize_route
create_booking_request

不推荐:

tool1
do_task
handle
helper
search

官方文档建议优先使用:

snake_case
字母、数字、下划线或连字符

以提高跨供应商兼容性。

description 是模型决策指令#

模型看不到函数实现,只能看到:

name
description
parameters

因此工具 description 应至少回答:

这个工具做什么?
什么时候应该使用?
什么时候不应该使用?
返回什么?
有什么重要限制?

较弱描述:

"""Search attractions."""

较强描述:

"""Search public attractions in a destination city.
Use this tool when the user needs candidate attractions,
opening information, or preference-based attraction discovery.
Do not use it for live weather, hotel pricing, restaurant
reservations, or route optimization.
The result contains attraction candidates rather than a
final day-by-day itinerary.
"""

参数描述也会影响参数生成#

弱参数:

city: str
date: str

强参数:

city: str = Field(
description="Destination city in English or local language"
)
date: str = Field(
description="Local date in ISO 8601 format: YYYY-MM-DD"
)

模型需要知道:

  • 字段语义;
  • 格式;
  • 单位;
  • 可选值;
  • 默认行为;
  • 字段之间的关系。

description 不是安全边界#

即使 description 写了:

Do not call this tool without confirmation.

也不能保证模型永远遵守。

真正的安全约束应放在执行层:

权限校验
参数白名单
业务规则
人工确认
幂等控制
审计日志
额度限制

模型描述是软约束;业务校验是硬约束。


从 BaseTool 到模型供应商 Tool Schema#

bind_tools() 绑定的是 Schema#

代码:

model_with_tools = model.bind_tools([
search_attractions
])

不是:

把 Python 函数上传给模型执行。

而是:

把 Tool 的描述和输入 Schema
转换成供应商支持的工具定义格式。

以 OpenAI 风格为例,近似格式:

{
"type": "function",
"function": {
"name": "search_attractions",
"description": "Search attractions in a city based on user preference.",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string"
},
"preference": {
"type": "string"
}
},
"required": [
"city",
"preference"
]
}
}
}

convert_to_openai_tool() 伪代码#

这是 OpenAI 适配路径的代表性源码:

def convert_to_openai_tool(tool, strict=None):
# 已经是供应商原生工具字典
if isinstance(tool, dict) and is_known_tool(tool):
return tool
# 特殊 custom tool
if is_custom_tool(tool):
return {
"type": "custom",
"name": tool.name,
"description": tool.description,
...
}
# 统一转换为 OpenAI function schema
function = convert_to_openai_function(
tool,
strict=strict,
)
return {
"type": "function",
"function": function,
}

convert_to_openai_function() 会支持多种输入:

LangChain BaseTool
Pydantic Model
TypedDict
普通 Python callable
JSON Schema dict
供应商格式 dict

若输入是 BaseTool,核心逻辑近似:

def _format_tool_to_openai_function(tool):
schema = tool.tool_call_schema
if schema is PydanticModel:
return pydantic_to_function_schema(
schema,
name=tool.name,
description=tool.description,
)
if schema is dict:
return json_schema_to_function_schema(
schema,
name=tool.name,
description=tool.description,
)
...

不同供应商有不同原生格式#

LangChain 上层统一为:

BaseTool
ToolCall
AIMessage
ToolMessage

供应商适配层负责:

LangChain Tool Schema
OpenAI tool schema
LangChain Tool Schema
Anthropic tool schema
LangChain Tool Schema
Gemini function declaration
LangChain Tool Schema
Bedrock toolSpec

因此:

BaseTool 是框架内部统一能力抽象,供应商 integration package 负责协议翻译。


构建期总结、伪代码与源码证据#

以下为保留关键控制流的简化伪代码,不是源码逐字复制:

def tool(function):
schema = create_schema_from_function(function)
return StructuredTool.from_function(function, args_schema=schema)

源码证据:


8. 运行时主链源码解剖#

本章沿 ToolCall 进入 BaseTool,再到真实函数和 ToolMessage 返回。

模型返回 ToolCall:调用请求不是函数执行#

标准 ToolCall 结构#

LangChain 的 ToolCall 是结构化字典:

{
"name": "search_attractions",
"args": {
"city": "大阪",
"preference": "美食",
},
"id": "call_001",
"type": "tool_call",
}

字段含义:

字段作用
name要调用的工具
args模型生成的参数
id本次调用唯一标识
type类型判别字段

ToolCall 会被保存在:

ai_message.tool_calls

中。

AIMessage 可能包含多个工具调用#

例如:

AIMessage(
content="",
tool_calls=[
{
"name": "query_weather",
"args": {"city": "大阪"},
"id": "call_weather",
"type": "tool_call",
},
{
"name": "search_attractions",
"args": {
"city": "大阪",
"preference": "美食",
},
"id": "call_attractions",
"type": "tool_call",
},
],
)

这意味着执行层可能并行执行两个工具。

因此每个结果都必须带回对应 ID:

ToolMessage(
content="晴,24°C",
tool_call_id="call_weather",
)
ToolMessage(
content="道顿堀、大阪城、黑门市场",
tool_call_id="call_attractions",
)

为什么必须有 tool_call_id#

如果只有工具名:

query_weather
search_attractions

在并行调用或重复调用时,无法可靠判断:

哪个结果对应哪个请求?

ToolCall ID 解决的是关联问题:

ToolCall.id
ToolMessage.tool_call_id

它不是业务订单号,也不是 trace ID,而是:

一次模型工具请求与一次工具结果之间的关联 ID。


BaseTool.invoke() 源码解剖#

invoke 是 Runnable 入口#

源码伪代码非常简洁:

def invoke(self, input, config=None, **kwargs):
tool_input, run_kwargs = _prep_run_args(
input,
config,
**kwargs,
)
return self.run(
tool_input,
**run_kwargs,
)

表面只有两步:

准备运行参数
进入 run()

但真正复杂的逻辑全部在:

_prep_run_args
run
_parse_input
_to_args_and_kwargs
_run
_format_output

_prep_run_args():识别 ToolCall#

详细伪代码:

def _prep_run_args(value, config, **kwargs):
config = ensure_config(config)
if is_tool_call(value):
tool_call_id = value["id"]
tool_input = value["args"].copy()
else:
tool_call_id = None
tool_input = value
run_kwargs = {
"callbacks": config.get("callbacks"),
"tags": config.get("tags"),
"metadata": config.get("metadata"),
"run_name": config.get("run_name"),
"run_id": config.pop("run_id", None),
"config": config,
"tool_call_id": tool_call_id,
**kwargs,
}
return tool_input, run_kwargs

识别完整 ToolCall 的条件近似为:

isinstance(value, dict)
and value.get("type") == "tool_call"

因此下面两种输入不同:

普通参数字典#
{
"city": "大阪",
"preference": "美食",
}

结果:

tool_call_id = None
完整 ToolCall#
{
"name": "search_attractions",
"args": {
"city": "大阪",
"preference": "美食",
},
"id": "call_001",
"type": "tool_call",
}

结果:

tool_call_id = "call_001"
tool_input = {
"city": "大阪",
"preference": "美食",
}

RunnableConfig 如何进入工具执行#

_prep_run_args() 会把配置拆给 run()

callbacks
tags
metadata
run_name
run_id
config

因此:

tool.invoke(
tool_call,
config={
"tags": ["travel", "attraction"],
"metadata": {
"request_id": "req-001",
"user_id": "user-123",
},
},
)

可以把观测信息传入工具执行链。


BaseTool.run() 源码逐层拆解#

run() 是同步工具执行的核心生命周期。

第一层:配置 CallbackManager#

伪代码:

callback_manager = CallbackManager.configure(
runtime_callbacks,
self.callbacks,
self.verbose or verbose,
runtime_tags,
self.tags,
runtime_metadata,
self.metadata,
)

这里会合并:

调用时 config
+
Tool 对象自身配置

第二层:触发 on_tool_start#

run_manager = callback_manager.on_tool_start(
{
"name": self.name,
"description": self.description,
},
tool_input_string,
inputs=filtered_tool_input,
tool_call_id=tool_call_id,
...
)

此时 tracing 系统可以记录:

  • 工具名;
  • 工具描述;
  • 输入参数;
  • tool_call_id;
  • run_id;
  • tags;
  • metadata;
  • 开始时间。

第三层:创建子配置#

child_config = patch_config(
config,
callbacks=run_manager.get_child(),
)

作用是:

工具内部如果继续调用其他 Runnable,子调用可以继承当前 trace 上下文。

第四层:解析输入#

tool_args, tool_kwargs = (
self._to_args_and_kwargs(
tool_input,
tool_call_id,
)
)

这一层负责:

Schema 校验
默认值填充
注入参数处理
字符串输入兼容
dict → kwargs

第五层:注入 run_manager#

_run() 声明了:

run_manager

框架会传入:

tool_kwargs["run_manager"] = run_manager

自定义 Tool 可以通过它发送回调事件。

第六层:注入 RunnableConfig#

若真实函数签名中存在框架识别的 config 参数:

config: RunnableConfig

框架会把当前 config 注入函数。

注意:

config 不应该由模型生成。

第七层:调用 _run()#

response = context.run(
self._run,
*tool_args,
**tool_kwargs,
)

这里使用上下文执行,使 contextvars 和运行配置能够沿调用链传播。

第八层:解析 response_format#

默认:

response_format="content"

此时:

content = response
artifact = None

若:

response_format="content_and_artifact"

真实函数必须返回:

(content, artifact)

例如:

@tool(response_format="content_and_artifact")
def generate_route_map(...) -> tuple[str, dict]:
summary = "已生成路线图"
artifact = {
"map_url": "...",
"raw_route": {...},
}
return summary, artifact

content 给模型;

artifact 给应用或后续节点,不必全部塞进模型上下文。

第九层:错误分支#

源码区分:

Pydantic ValidationError
ToolException
其他 Exception / KeyboardInterrupt

后文会详细展开。

第十层:格式化输出#

output = _format_output(
content,
artifact,
tool_call_id,
self.name,
status,
)

第十一层:触发 on_tool_end#

run_manager.on_tool_end(
output,
name=self.name,
)

最后返回 output。

run() 总体伪代码#

def run(tool_input, ..., tool_call_id=None):
manager = configure_callbacks(...)
run_manager = manager.on_tool_start(...)
content = None
artifact = None
status = "success"
try:
child_config = patch_config(...)
args, kwargs = self._to_args_and_kwargs(
tool_input,
tool_call_id,
)
inject_run_manager_if_needed(kwargs)
inject_config_if_needed(kwargs)
response = self._run(*args, **kwargs)
if self.response_format == \
"content_and_artifact":
content, artifact = response
else:
content = response
except ValidationError as error:
if not self.handle_validation_error:
raise
content = handle_validation_error(error)
status = "error"
except ToolException as error:
if not self.handle_tool_error:
raise
content = handle_tool_error(error)
status = "error"
except Exception:
raise
output = _format_output(
content=content,
artifact=artifact,
tool_call_id=tool_call_id,
name=self.name,
status=status,
)
run_manager.on_tool_end(output)
return output

_parse_input() 源码解剖#

输入是字符串#

历史兼容场景:

tool.invoke("Osaka")

若 Tool 是单参数工具,框架会使用 Schema 的第一个字段进行验证,但为了兼容,最后仍把字符串作为位置参数传入:

"Osaka"
校验为第一个字段
("Osaka",)

对于现代多参数 StructuredTool,更推荐始终传字典或完整 ToolCall。

输入是字典 + Pydantic Schema#

伪代码:

if args_schema is PydanticModel:
validated_model = args_schema.model_validate(
tool_input
)
validated_dict = (
validated_model.model_dump()
)
return select_explicit_and_default_fields(
validated_model,
validated_dict,
original_input=tool_input,
)

这一层会处理:

  • 类型转换;
  • 必填字段;
  • 默认值;
  • 范围约束;
  • 枚举;
  • 自定义验证器;
  • 多余字段策略。

例如:

{
"city": "大阪",
"preference": "美食",
"limit": "5"
}

Pydantic 可能把:

"5"

转换为:

5

具体是否允许转换取决于 Schema 配置。

默认值如何进入真实函数#

假设:

limit: int = 5

模型没有提供 limit

{
"city": "大阪",
"preference": "美食"
}

校验后可能补全:

{
"city": "大阪",
"preference": "美食",
"limit": 5
}

随后传入真实函数。

注入 tool_call_id#

某些旧式或底层注入参数需要当前 ToolCall ID。

源码会检查 Schema 中是否含有 ToolCall ID 注入类型:

需要 InjectedToolCallId
并且当前 invoke 没有完整 ToolCall
抛出 ValueError

这也是为什么:

tool.invoke({"city": "大阪"})

与:

tool.invoke({
"name": "...",
"args": {"city": "大阪"},
"id": "call_001",
"type": "tool_call",
})

在运行上下文上不完全等价。

当前官方实践更推荐通过统一的:

ToolRuntime

访问 runtime.tool_call_id

JSON Schema 字典不会自动进行 Pydantic 校验#

源码分支:

if isinstance(args_schema, dict):
return tool_input

因此对 JSON Schema 字典,需要明确区分:

用于描述给模型
自动成为本地 Pydantic 验证器

_to_args_and_kwargs():从工具输入到 Python 调用参数#

伪代码:

def _to_args_and_kwargs(
tool_input,
tool_call_id,
):
# 无参数工具
if schema_is_empty_model():
return (), {}
parsed_input = self._parse_input(
tool_input,
tool_call_id,
)
# 兼容单字符串工具
if isinstance(parsed_input, str):
return (parsed_input,), {}
# StructuredTool 常见路径
if isinstance(parsed_input, dict):
return (), parsed_input.copy()
raise TypeError(...)

因此:

单字符串工具#

tool.invoke("Osaka")

转换为:

_run("Osaka")

多字段工具#

tool.invoke({
"city": "大阪",
"preference": "美食",
})

转换为:

_run(
city="大阪",
preference="美食",
)

StructuredTool._run():真正调用 Python 函数#

同步执行伪代码#

def _run(
self,
*args,
config,
run_manager=None,
**kwargs,
):
if self.func is None:
raise NotImplementedError(
"StructuredTool does not support "
"sync invocation."
)
# 如果真实函数支持 callbacks,
# 把当前工具运行的子 callback 传进去
if (
run_manager is not None
and "callbacks" in signature(
self.func
).parameters
):
kwargs["callbacks"] = (
run_manager.get_child()
)
# 如果真实函数声明了 RunnableConfig,
# 注入当前 config
config_param = detect_config_param(
self.func
)
if config_param:
kwargs[config_param] = config
return self.func(*args, **kwargs)

这一步才真正进入用户写的业务代码。

异步执行伪代码#

async def _arun(
self,
*args,
config,
run_manager=None,
**kwargs,
):
if self.coroutine is not None:
inject_callbacks_if_needed(...)
inject_config_if_needed(...)
return await self.coroutine(
*args,
**kwargs,
)
return await super()._arun(
*args,
config=config,
run_manager=run_manager,
**kwargs,
)

只有同步函数时调用 ainvoke()#

StructuredTool.ainvoke() 会检查:

if not self.coroutine:
return await run_in_executor(
config,
self.invoke,
input,
config,
**kwargs,
)

也就是说:

同步函数
ainvoke()
线程执行器
调用同步 invoke()

这能提供异步兼容,但不代表同步 I/O 自动变成真正的非阻塞 I/O。

高并发场景仍建议直接实现:

async def

版本的工具。


_format_output() 与 ToolMessage 自动包装#

核心伪代码#

def _format_output(
content,
artifact,
tool_call_id,
name,
status,
):
# 工具已经返回 ToolMessage、Command
# 或其他 ToolOutputMixin
if isinstance(content, ToolOutputMixin):
return content
# 没有 ToolCall ID,视为普通直接调用
if tool_call_id is None:
return content
normalized_content = (
normalize_message_content(content)
)
if normalized_content is None:
normalized_content = stringify(content)
return ToolMessage(
content=normalized_content,
artifact=artifact,
tool_call_id=tool_call_id,
name=name,
status=status,
)

为什么直接调用返回原始值#

tool.invoke({
"city": "大阪",
"preference": "美食",
})

没有完整 ToolCall:

tool_call_id = None

因此:

return content

为什么 Agent 执行返回 ToolMessage#

tool.invoke({
"name": "search_attractions",
"args": {...},
"id": "call_001",
"type": "tool_call",
})

存在:

tool_call_id = call_001

因此:

return ToolMessage(...)

工具可以直接返回 ToolMessage#

若工具自己返回一个 ToolOutputMixin 对象,框架不会再重复包装。

但业务工具通常不应该手动构造 ToolMessage,除非需要:

  • 精确控制 content;
  • 设置 artifact;
  • 设置 status;
  • 更新图状态;
  • 返回 Command

ToolMessage 源码解剖#

ToolMessage 的核心字段#

class ToolMessage(BaseMessage, ToolOutputMixin):
tool_call_id: str
type: Literal["tool"] = "tool"
artifact: Any = None
status: Literal["success", "error"] = "success"

以及继承自 BaseMessage 的:

content
name
id
additional_kwargs
response_metadata

tool_call_id#

作用:

将模型提出的 ToolCall
与工具执行结果关联。

必须满足:

AIMessage.tool_calls[i]["id"]
==
ToolMessage.tool_call_id

content#

这是会进入模型上下文的主要工具结果。

可以是:

字符串
标准内容块列表
多模态内容块

对普通对象,框架会尝试进行字符串化或标准内容归一化。

artifact#

artifact 用于保存:

应用需要保留
但不希望全部发送给模型

的数据。

例如:

ToolMessage(
content="已找到 10 个景点,推荐前 3 个。",
artifact={
"all_results": [...],
"raw_api_response": {...},
"map_features": [...],
},
tool_call_id="call_001",
)

用途:

  • 减少模型上下文;
  • 保存原始 API 响应;
  • 提供前端渲染数据;
  • 为后续节点提供结构化对象;
  • 保留审计证据。

status#

status="success"

或:

status="error"

ToolException 或 ValidationError 被配置为“转成工具结果”时,ToolMessage 会带:

status="error"

模型可以据此:

  • 修正参数;
  • 换工具;
  • 请求用户补充;
  • 终止任务;
  • 转人工。

content 类型强制#

ToolMessage 初始化时会尽量把非字符串内容转为:

str
list[str | dict]

例如整数:

ToolMessage(
content=42,
tool_call_id="call_001",
)

会被规范为字符串内容。


运行时总结、伪代码与源码证据#

以下为保留关键控制流的简化伪代码,不是源码逐字复制:

def invoke_tool(tool, tool_call, config):
args, tool_call_id = prepare_run_args(tool_call, config)
parsed = tool._parse_input(args, tool_call_id)
response = tool._run(*to_args_and_kwargs(parsed))
return format_output(response, tool_call_id)

源码证据:


9. 关键分支、异常与边界#

本章解释同步异步、返回值形态、参数校验和 ToolException 的恢复边界。

Tool Error Handling 源码解剖#

工具错误至少分为三类:

错误类型典型来源默认行为
参数校验错误Pydantic ValidationError抛出
可恢复业务错误ToolException抛出,除非配置处理
非预期系统错误普通 Exception抛出

参数校验错误#

例如:

@tool
def get_hotel_quote(
city: str,
budget: int,
) -> str:
"""Get a hotel quote."""
return "..."

调用:

get_hotel_quote.invoke({
"city": "大阪",
"budget": "not-a-number",
})

可能触发 Pydantic ValidationError

BaseTool 配置:

handle_validation_error=False

默认会继续抛出异常。

handle_validation_error=True#

tool = StructuredTool.from_function(
func=get_hotel_quote,
handle_validation_error=True,
)

源码会把校验错误转换为固定内容:

Tool input validation error

若有 tool_call_id,最终得到:

ToolMessage(
content="Tool input validation error",
tool_call_id="call_001",
status="error",
)

自定义校验错误文本#

handle_validation_error=(
"参数格式错误,请检查 city 和 budget。"
)

也可以传函数:

def format_validation_error(error):
return (
"工具参数不合法。请按照 Schema 重新生成参数:"
f"{error}"
)
tool = StructuredTool.from_function(
func=get_hotel_quote,
handle_validation_error=(
format_validation_error
),
)

ToolException:可反馈给模型的业务失败#

工具内部:

from langchain_core.tools import ToolException
@tool
def query_weather(
city: str,
date: str,
) -> str:
"""Query weather for a city and date."""
if date_is_out_of_range(date):
raise ToolException(
"Weather is only available for the next 14 days."
)
return "..."

ToolException 的设计目的:

工具执行失败,但失败信息可以作为 observation 返回 Agent,而不是一定终止整个 Agent。

handle_tool_error#

支持:

False
True
固定字符串
Callable[[ToolException], str]
False#
继续抛异常,终止当前执行链。
True#
使用 ToolException 的异常消息作为工具结果。
固定字符串#
handle_tool_error=(
"天气服务暂时不可用,请稍后重试。"
)
Callable#
def handle_weather_error(error: ToolException) -> str:
return (
"天气查询失败:"
f"{error}. 请调整日期或城市后重试。"
)

普通 Exception#

例如:

raise ConnectionError(...)

BaseTool 的本地 handle_tool_error 不会自动把所有普通异常都转换为 ToolMessage。

普通异常默认继续抛出:

Exception
on_tool_error
raise

生产环境可以在更高层使用:

  • Agent middleware;
  • ToolRetryMiddleware
  • wrap_tool_call
  • LangGraph ToolNode 错误策略;
  • 自定义重试和降级;
  • 熔断器;
  • fallback tool。

为什么不要把所有异常都吞掉#

错误全部转换为字符串会导致:

  • 系统故障被伪装为业务结果;
  • tracing 无法正确标记失败;
  • 上游无法触发重试;
  • 数据库写入失败可能被模型误认为成功;
  • 安全问题被隐藏;
  • 线上告警失效。

建议分类:

参数错误
→ 反馈模型修正参数
可预期业务失败
→ ToolException / 业务状态
短暂外部故障
→ 重试 / fallback / 熔断
程序 Bug
→ 抛出并告警
高风险动作失败
→ 中止 + 人工接管

工具返回值设计#

返回字符串#

return "大阪今天晴,24°C"

适合:

  • 简单事实;
  • 模型直接阅读;
  • 无需保留复杂结构。

返回字典#

return {
"city": "大阪",
"temperature_c": 24,
"condition": "sunny",
}

框架会把对象转换为工具结果内容,模型可以读取字段。

适合:

  • 数据字段明确;
  • 下游推理依赖结构;
  • 需要减少自然语言歧义。

返回 content + artifact#

@tool(
response_format="content_and_artifact"
)
def search_attractions(...) -> tuple[str, dict]:
raw_results = call_api(...)
content = (
"已找到 20 个景点,"
"其中最符合偏好的有 5 个。"
)
artifact = {
"raw_results": raw_results
}
return content, artifact

适合:

  • API 原始响应很大;
  • 前端需要完整结构;
  • 模型只需摘要;
  • 后续节点要访问原始对象;
  • 希望控制 Token 成本。

返回 Command#

在 LangGraph / Agent Runtime 中,工具还可以返回 Command 更新 State。

这已经超出纯 Tool Calling,进入:

工具执行
+
状态变更
+
控制流

这一部分应在 LangGraph ToolNode 与 State 更新章节继续深入。


分支语义总结、伪代码与源码证据#

以下为保留关键控制流的简化伪代码,不是源码逐字复制:

def execute_with_error_policy(tool, tool_call):
try:
return tool.invoke(tool_call)
except ValidationError as error:
return handle_validation_error(error)
except ToolException as error:
return handle_tool_error(error)

源码证据:


10. 扩展机制与框架协作#

本章解释 ToolRuntime、隐藏参数、手动 Tool Calling Loop 与 Agent 执行器的协作。

手动实现完整 Tool Calling 循环#

定义工具#

from langchain.tools import tool
@tool
def search_attractions(
city: str,
preference: str,
) -> str:
"""Search attractions in a city matching traveler interests."""
return (
f"Recommended attractions in {city} "
f"for {preference}: ..."
)

绑定工具#

from langchain.chat_models import init_chat_model
model = init_chat_model(
"openai:gpt-4.1-mini"
)
model_with_tools = model.bind_tools([
search_attractions
])

第一次调用模型#

from langchain_core.messages import HumanMessage
messages = [
HumanMessage(
content=(
"我准备去大阪旅行,喜欢美食和城市漫步,"
"请帮我找一些景点。"
)
)
]
ai_message = model_with_tools.invoke(messages)
messages.append(ai_message)
print(ai_message.tool_calls)

模型可能返回:

[
{
"name": "search_attractions",
"args": {
"city": "大阪",
"preference": "美食和城市漫步",
},
"id": "call_001",
"type": "tool_call",
}
]

执行工具#

tools_by_name = {
search_attractions.name: search_attractions
}
for tool_call in ai_message.tool_calls:
selected_tool = tools_by_name[
tool_call["name"]
]
tool_message = selected_tool.invoke(
tool_call
)
messages.append(tool_message)

这里传入的是完整 ToolCall,因此工具返回 ToolMessage。

把工具结果重新交给模型#

final_response = model_with_tools.invoke(
messages
)
messages.append(final_response)
print(final_response.content)

消息顺序必须是:

HumanMessage
AIMessage(tool_calls=[...])
ToolMessage(tool_call_id=...)
AIMessage(final answer)

不能只发送 ToolMessage 而丢失产生该调用的 AIMessage。

多个并行 ToolCall#

tool_messages = []
for tool_call in ai_message.tool_calls:
tool = tools_by_name[tool_call["name"]]
result = tool.invoke(tool_call)
tool_messages.append(result)
messages.extend(tool_messages)

每一个 ToolCall 都应该得到一个对应 ToolMessage,除非运行时有明确的中断、取消或错误协议。


ToolRuntime 与隐藏参数#

为什么需要隐藏参数#

工具可能需要:

  • 当前会话 State;
  • 用户 ID;
  • 数据库连接;
  • 长期 Store;
  • stream writer;
  • thread_id;
  • run_id;
  • tool_call_id;
  • RunnableConfig。

这些信息不应该由模型生成。

当前推荐的 ToolRuntime#

from langchain.tools import tool, ToolRuntime
@tool
def get_user_preference(
preference_name: str,
runtime: ToolRuntime,
) -> str:
"""Get a preference from current agent state."""
preferences = runtime.state.get(
"user_preferences",
{}
)
return preferences.get(
preference_name,
"Not set",
)

模型可见 Schema 只包含:

preference_name

隐藏:

runtime

runtime 可以提供的能力#

当前官方设计中,ToolRuntime 可统一访问:

state
context
store
stream_writer
execution_info
server_info
config
tool_call_id

这说明工具不仅是纯函数,也可能是:

Agent Runtime 中受控执行的能力节点。

保留参数名#

不要把普通业务参数命名为:

config
runtime

这些名称承担框架注入语义。


扩展协作总结、伪代码与源码证据#

以下为保留关键控制流的简化伪代码,不是源码逐字复制:

def execute_in_runtime(tool_call, runtime, wrapper):
request = inject_runtime(tool_call, runtime)
return wrapper(request, lambda current: current.tool.invoke(current.tool_call))

源码证据:


11. 工程决策与适用场景#

旅行规划助手中的工具拆解#

建议的工具清单#

Tool 名称输入输出所属范式是否有副作用
search_attractionscity、preference、limit景点候选Tool-use
query_weathercity、date天气信息Tool-use
search_hotelscity、dates、budget酒店候选Tool-use
query_transportorigin、destination、mode交通信息Tool-use
optimize_routeattractions、constraints优化路线Executor
save_itineraryuser_id、itinerary保存结果Action Tool
create_booking_requestbooking payload预订请求Action Tool

对每个 Tool 做六层标注#

示例:

@tool
def search_attractions(
city: str,
preference: str,
limit: int = 5,
) -> list[dict]:
"""Search public attractions in a city.
Use this tool to discover candidate attractions
based on traveler interests.
Do not use it for restaurants, weather, hotels,
transport routes, or final itinerary generation.
"""
...

标注:

1. Python 实现:
search_attractions function
2. Tool 类型:
StructuredTool
3. 模型接口:
name + description + args_schema
4. 执行输入:
ToolCall.args
5. 执行输出:
list[dict]
6. Agent Observation:
ToolMessage

工具分析表#

在项目中建立:

| Tool | Tool Class | Args Schema | Return Type | Side Effect | Error Policy | State Usage |
|---|---|---|---|---|---|---|
| search_attractions | StructuredTool | AttractionSearchInput | list[dict] | No | retry/fallback | read |
| save_itinerary | StructuredTool | SaveItineraryInput | dict | Yes | no auto retry | write |

读取类工具与写入类工具要分开#

读取类:

query_weather
search_attractions
search_hotels

可以:

  • 自动重试;
  • 并行执行;
  • 缓存;
  • 降级。

写入类:

save_itinerary
create_booking_request
cancel_booking

需要:

  • 权限校验;
  • 幂等 Key;
  • 人工确认;
  • 审计日志;
  • 防止重复执行;
  • 明确失败语义。

工程设计建议#

把 Tool 当成受控 API,而不是随意函数#

每个 Tool 都应该明确:

名称
描述
输入 Schema
输出 Schema
异常类型
副作用
权限
幂等性
重试策略
超时
审计字段

工具函数应薄,业务逻辑放 Service#

推荐:

Tool Adapter
Application Service
Domain / API / Database

示例:

@tool(args_schema=SearchInput)
def search_attractions(...) -> dict:
return attraction_service.search(...)

不要把:

  • HTTP 重试;
  • 数据库事务;
  • 权限体系;
  • 复杂业务规则;

全部堆在 Tool 函数里。

工具输出不要无限进入模型上下文#

优先:

content:模型真正需要的摘要
artifact:完整原始结果

写操作默认不自动重试#

写工具例如:

create_booking
cancel_order
send_email
issue_refund

重试前必须确认:

  • 是否幂等;
  • 上一次是否已经成功;
  • 是否有 idempotency key;
  • 是否会重复扣款或重复发送。

工具描述与执行权限分离#

description
→ 帮助模型正确选择
permission / policy
→ 决定系统是否允许执行

不要用 Prompt 代替权限系统。


12. 常见误区与源码纠正#

误区:模型直接执行工具#

纠正:

模型只生成 ToolCall;
本地执行层调用工具。

误区:bind_tools() 会执行工具#

纠正:

bind_tools 只绑定 Schema;
执行需要 Agent、ToolNode 或手动循环。

误区:@tool 只给函数加元数据#

纠正:

@tool 返回 StructuredTool;
原函数保存在 .func;
变量本身已不是普通函数。

误区:args_schema 只用于模型#

纠正:

Pydantic args_schema 同时用于:
模型 Schema + 本地输入校验。

但普通 JSON Schema dict 在 BaseTool 内不一定自动进行同等本地校验。

误区:tool.invoke() 总是返回 ToolMessage#

纠正:

普通参数调用
→ 通常返回原始结果
完整 ToolCall 调用
→ 有 tool_call_id
→ 自动返回 ToolMessage

误区:ToolMessage 只需要工具名#

纠正:

必须用 tool_call_id 关联具体请求,
尤其是并行工具调用。

误区:description 写得越长越好#

纠正:

description 应该:

  • 明确;
  • 区分相似工具;
  • 包含使用与禁用场景;
  • 避免无关背景;
  • 避免与 Schema 冲突。

过长会浪费上下文并增加模型混淆。

误区:ToolException 等于所有异常#

纠正:

ToolException
→ 可预期、可反馈给 Agent 的失败
普通 Exception
→ 系统错误、Bug、基础设施故障

误区:工具调用成功就代表业务成功#

纠正:

Python 函数没有抛异常
业务状态已经正确落库

高风险 Action Tool 还需要:

  • 业务响应码检查;
  • 状态回查;
  • 幂等验证;
  • 审计记录;
  • verifier。

误区:同步 Tool 的 ainvoke() 就是真异步#

纠正:

没有 coroutine 时,
同步 invoke 可能被放入线程池。

高并发网络 I/O 工具仍应实现原生 async。


13. 最终心智模型与掌握检查#

工具定义链路#

Python Function
@tool
_create_tool_factory
StructuredTool.from_function
create_schema_from_function
BaseTool + args_schema

模型绑定链路#

BaseTool
tool_call_schema
供应商 Schema 转换
bind_tools
模型可见工具说明

模型请求链路#

Messages
ChatModel
AIMessage
tool_calls[
name,
args,
id
]

工具执行链路#

ToolCall
BaseTool.invoke
_prep_run_args
BaseTool.run
_parse_input
_to_args_and_kwargs
StructuredTool._run
Python Function

工具结果链路#

Function Result
_format_output
ToolMessage
messages
ChatModel

一句话总结:

@tool 把 Python 函数转换成“模型可理解、执行器可校验、运行时可观测、结果可回填”的能力对象;模型只负责生成 ToolCall,LangChain/LangGraph 运行时负责真正执行工具并用 ToolMessage 闭合调用协议。


14. 参考资料与下一篇衔接#

官方资料#

官方概念文档#

  1. LangChain Tools
    https://docs.langchain.com/oss/python/langchain/tools

  2. LangChain Models:Tool Calling
    https://docs.langchain.com/oss/python/langchain/models

  3. LangChain Messages:ToolMessage
    https://docs.langchain.com/oss/python/langchain/messages

  4. LangGraph Quickstart:手动工具执行循环
    https://docs.langchain.com/oss/python/langgraph/quickstart

  5. LangGraph Workflows and Agents:ToolNode
    https://docs.langchain.com/oss/python/langgraph/workflows-agents

官方 API Reference#

  1. tool decorator
    https://reference.langchain.com/python/langchain-core/tools/convert/tool

  2. BaseTool
    https://reference.langchain.com/python/langchain-core/tools/base/BaseTool

  3. StructuredTool
    https://reference.langchain.com/python/langchain-core/tools/structured/StructuredTool

  4. ToolNode
    https://reference.langchain.com/python/langgraph.prebuilt/tool_node/ToolNode

  5. LangChain Core Tools 模块
    https://reference.langchain.com/python/langchain-core/tools

官方源码#

  1. langchain_core/tools/convert.py
    https://github.com/langchain-ai/langchain/blob/83e824922ac9bf3bf882347fecabd09614bccc37/libs/core/langchain_core/tools/convert.py

  2. langchain_core/tools/base.py
    https://github.com/langchain-ai/langchain/blob/83e824922ac9bf3bf882347fecabd09614bccc37/libs/core/langchain_core/tools/base.py

  3. langchain_core/tools/structured.py
    https://github.com/langchain-ai/langchain/blob/83e824922ac9bf3bf882347fecabd09614bccc37/libs/core/langchain_core/tools/structured.py

  4. langchain_core/tools/simple.py
    https://github.com/langchain-ai/langchain/blob/83e824922ac9bf3bf882347fecabd09614bccc37/libs/core/langchain_core/tools/simple.py

  5. langchain_core/messages/tool.py
    https://github.com/langchain-ai/langchain/blob/83e824922ac9bf3bf882347fecabd09614bccc37/libs/core/langchain_core/messages/tool.py

  6. langchain_core/utils/function_calling.py
    https://github.com/langchain-ai/langchain/blob/83e824922ac9bf3bf882347fecabd09614bccc37/libs/core/langchain_core/utils/function_calling.py


下一篇衔接#

完成本篇后,你已经理解:

普通 Python 函数
Tool 能力对象
模型 ToolCall
真实函数执行
ToolMessage

下一篇可以继续进入:

create_agent / ToolNode 与 ReAct 实现机制

重点回答:

谁读取 AIMessage.tool_calls?
谁查找对应 Tool?
多个工具如何并行执行?
ToolMessage 如何写回 Agent State?
模型为什么会再次被调用?
循环何时终止?
ToolNode 如何处理错误、注入 State 与 Runtime?
LangChain Core 源码解剖:Tool Calling 工具调用链路
https://jupiter-ws.cn/posts/agent-frameworks/04_langchain_core_tool_calling_source_deep_dive/
作者
Jupiter
发布于
2026-03-08
许可协议
CC BY-NC-SA 4.0