各位编程专家、架构师和对未来AI应用充满热情的开发者们,
今天,我们将深入探讨一个在人工智能与实际应用结合中日益关键的话题:如何将我们现有的大量API能力,以一种智能、可控且高效的方式,赋予大型语言模型(LLMs)。具体来说,我们聚焦于一个名为“OpenAPI Tool Generator”的理念——如何从一份标准的Swagger/OpenAPI文档出发,自动化地生成成百上千个可供LangChain框架调用的工具。这不仅仅是技术细节的堆砌,更是一种将LLMs从纯文本生成器提升为能与真实世界交互的智能代理的关键路径。
1. 智能代理的基石:理解LLM与外部工具的桥梁
随着大型语言模型能力的飞速发展,它们在理解、生成和推理方面的表现令人惊叹。然而,这些模型本身是“无手无脚”的,它们无法直接执行外部操作,例如查询数据库、发送电子邮件、调用外部API等。为了让LLMs能够真正成为智能代理,与现实世界进行交互,我们需要为它们提供“工具”。
LangChain框架正是为了解决这一问题而生。它提供了一个结构化的方式,让开发者能够定义各种工具,并将这些工具暴露给LLM。LLM在接收到用户请求后,会根据其内部的推理能力和工具的描述,判断是否需要调用某个工具,以及如何调用它。这个过程通常涉及:
- 用户输入: 代理接收到用户的自然语言指令。
- LLM思考: LLM分析指令,并结合可用的工具描述,决定下一步行动。
- 工具选择: 如果LLM认为某个工具可以帮助完成任务,它会选择该工具。
- 参数提取: LLM从用户指令中提取调用工具所需的参数。
- 工具调用: 代理执行选定的工具,传入LLM提供的参数。
- 结果返回: 工具执行结果返回给LLM。
- LLM回应: LLM根据工具执行结果,生成最终的用户响应。
这里的核心挑战在于:如何高效、准确地为LLM提供这些工具?如果每个API端点都需要我们手动编写一个LangChain工具,那么面对拥有成百上千个端点的大型API,这项工作将变得异常繁重且易错。这正是我们今天讨论的“OpenAPI Tool Generator”所要解决的核心痛点。
2. OpenAPI规范:API世界的通用语言
在深入探讨工具生成之前,我们必须首先理解其数据源——OpenAPI(前身为Swagger)规范。OpenAPI规范是描述RESTful API的行业标准。它以一种机器可读、人类可理解的格式,详细定义了API的各个方面,包括:
- API元数据: 名称、版本、描述等。
- 服务器信息: API的基础URL。
- 路径与操作: API提供的所有端点(路径)以及每个端点支持的HTTP方法(GET、POST、PUT、DELETE等)。
- 参数: 每个操作所需的输入参数,包括它们的位置(路径参数、查询参数、请求头、Cookie)、类型、是否必需、描述和示例。
- 请求体: POST、PUT等操作的请求数据结构,包括内容类型(如
application/json)和其对应的JSON Schema定义。 - 响应: 每个操作可能返回的响应,包括HTTP状态码、描述以及响应数据结构。
- 组件: 可重用的Schema定义(数据模型)、参数、安全方案等,这些使得规范更加模块化和易于维护。
一份设计良好的OpenAPI文档,就像是API的蓝图和合同。它不仅是API消费者理解API的指南,更是自动化工具生成、客户端代码生成、测试和文档生成的强大基石。
以下是一个简化版的OpenAPI YAML示例,它描述了一个假想的“产品目录API”:
openapi: 3.0.0
info:
title: Product Catalog API
version: 1.0.0
description: API for managing products in a catalog.
servers:
- url: https://api.example.com/v1 # API的基础URL
paths:
/products: # 第一个路径:获取所有产品或创建产品
get: # HTTP GET 操作
operationId: getProducts # 唯一操作ID,可作为工具名称的参考
summary: Retrieve a list of products. # 简短摘要
description: Fetches all products, optionally filtered by category and limit.
parameters: # 查询参数
- name: category
in: query
description: Filter products by category.
schema:
type: string
- name: limit
in: query
description: Maximum number of products to return.
schema:
type: integer
format: int32
minimum: 1
default: 10
responses: # 响应定义
'200':
description: A list of products.
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Product' # 引用共享的Product Schema
post: # HTTP POST 操作
operationId: createProduct
summary: Create a new product.
description: Adds a new product to the catalog.
requestBody: # 请求体
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/NewProduct' # 引用共享的NewProduct Schema
responses:
'201':
description: Product created successfully.
content:
application/json:
schema:
$ref: '#/components/schemas/Product'
/products/{productId}: # 第二个路径:根据ID获取产品
get:
operationId: getProductById
summary: Retrieve a product by ID.
description: Fetches a single product by its unique identifier.
parameters: # 路径参数
- name: productId
in: path
required: true
description: The ID of the product to retrieve.
schema:
type: string
responses:
'200':
description: A single product.
content:
application/json:
schema:
$ref: '#/components/schemas/Product'
'404':
description: Product not found.
components: # 可重用组件
schemas:
Product: # Product数据模型
type: object
properties:
id:
type: string
readOnly: true
description: Unique product identifier.
name:
type: string
description: Name of the product.
category:
type: string
description: Category of the product.
price:
type: number
format: float
description: Price of the product.
description:
type: string
nullable: true
description: Detailed description of the product.
required:
- id
- name
- category
- price
NewProduct: # 创建产品时的数据模型,不包含ID
type: object
properties:
name:
type: string
description: Name of the product.
category:
type: string
description: Category of the product.
price:
type: number
format: float
description: Price of the product.
description:
type: string
nullable: true
description: Detailed description of the product.
required:
- name
- category
- price
3. LangChain工具的解剖:Tool与args_schema
在LangChain中,一个工具(Tool)是LLM可以调用的一个函数或操作。它由几个关键部分组成:
name: 工具的唯一标识符。LLM会使用这个名字来引用工具。description: 对工具功能的人类可读描述。这是LLM理解工具用途、何时调用以及如何调用的核心信息。一个好的描述至关重要,它应该清晰地说明工具的作用、它解决的问题以及它所期望的输入。func: 一个Python可调用对象(函数),当LLM决定使用此工具时,它会被执行。这个函数包含了实际的业务逻辑,比如调用外部API、查询数据库等。args_schema: (可选但强烈推荐) 一个Pydantic模型。它定义了func所期望的输入参数的结构、类型和验证规则。当LLM调用工具时,它会尝试根据这个schema来格式化参数。
args_schema的重要性不容小觑。Pydantic模型不仅提供了强大的数据验证能力,更重要的是,它能自动生成JSON Schema。当与支持函数调用(Function Calling)的LLM(如OpenAI的GPT系列模型)结合时,LLM可以直接根据这个JSON Schema生成符合要求的参数JSON,从而大大提高了工具调用的可靠性和准确性。
以下是一个简单的LangChain Tool 示例:
from langchain.tools import Tool
from pydantic import BaseModel, Field
from typing import Optional
# 定义一个Pydantic模型作为工具的参数schema
class SearchArgs(BaseModel):
query: str = Field(description="The search query string.")
limit: Optional[int] = Field(default=10, description="Maximum number of results to return.")
# 定义工具的实际执行函数
def perform_web_search(query: str, limit: int = 10) -> str:
"""Simulates a web search and returns results."""
print(f"Performing web search for: '{query}' with limit {limit}")
# 实际中会调用搜索引擎API
if "latest news" in query.lower():
return f"Found 3 articles about '{query}'. Article 1, Article 2, Article 3."
return f"Simulated search results for '{query}', limited to {limit} items."
# 创建LangChain Tool
web_search_tool = Tool(
name="web_search",
description="Useful for searching the internet for information. "
"Input should be a search query string.",
func=perform_web_search,
args_schema=SearchArgs
)
# 模拟LLM调用
# print(web_search_tool.run({"query": "latest news about AI", "limit": 5}))
4. OpenAPI Tool Generator的核心思想:映射与自动化
我们的目标是为OpenAPI文档中的每一个“操作”(即一个HTTP方法与一个路径的组合)生成一个对应的LangChain Tool。这个过程可以概括为以下映射关系:
| OpenAPI元素 | 对应的LangChain Tool元素 |
备注 |
|---|---|---|
paths 中的每个操作 |
一个独立的 Tool 对象 |
每个HTTP方法(GET, POST等)在其路径下构成一个操作。 |
operationId |
Tool 的 name 属性(如果存在,否则根据路径和方法生成) |
必须是唯一的、合法的Python标识符。 |
summary 和 description |
Tool 的 description 属性,需要结合参数信息进行扩充 |
为LLM提供清晰、详细的工具使用说明。 |
parameters 和 requestBody 中的Schema |
Tool 的 args_schema 属性(一个Pydantic模型) |
定义工具的输入参数结构和类型,实现数据验证和LLM的参数生成。 |
servers.url + paths 中的路径模板 |
Tool 的 func 属性中API请求的基础URL和路径拼接逻辑 |
构建完整的API请求URL。 |
parameters 和 requestBody |
Tool 的 func 属性中API请求的参数组装和发送逻辑 |
根据参数类型(路径、查询、请求体)构建HTTP请求。 |
| API调用和响应处理 | Tool 的 func 属性中的HTTP请求库调用(如requests)和响应解析 |
实际执行API调用,并处理返回结果。 |
这个映射过程的核心挑战在于如何动态地:
- 根据OpenAPI的参数和请求体定义,生成Pydantic模型作为
args_schema。 - 根据操作的HTTP方法、路径和参数,生成一个能够正确调用API的Python函数作为
func。
5. 循序渐进:构建OpenAPI Tool Generator
现在,我们将一步步地实现一个简化的OpenAPItoolGenerator。我们将使用Python的标准库以及requests和pydantic这两个流行的第三方库。
步骤 1: 解析OpenAPI文档
首先,我们需要能够加载和解析OpenAPI文档。OpenAPI文档通常是YAML或JSON格式。我们可以使用pyyaml库来处理YAML文件。
import yaml
import json
import requests
import re
from typing import Dict, Any, Type, Optional, List, Union, Tuple
from pydantic import BaseModel, Field, create_model
from langchain.tools import Tool, BaseTool
import logging
# 配置日志
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
def load_openapi_spec(file_path: str) -> Dict[str, Any]:
"""
加载并解析OpenAPI/Swagger规范文件。
支持YAML和JSON格式。
"""
with open(file_path, 'r', encoding='utf-8') as f:
if file_path.endswith(('.yaml', '.yml')):
return yaml.safe_load(f)
elif file_path.endswith('.json'):
return json.load(f)
else:
raise ValueError("Unsupported file format. Please provide a .yaml, .yml, or .json file.")
# 辅助函数:将OpenAPI类型映射到Python类型
_OPENAPI_TYPE_TO_PYTHON_TYPE_MAP = {
"string": str,
"integer": int,
"number": float,
"boolean": bool,
"array": List, # 数组需要进一步处理items类型
"object": Dict[str, Any], # 对象可能需要生成嵌套Pydantic模型
}
def _map_openapi_type_to_python_type(
openapi_type: str,
openapi_format: Optional[str] = None,
nullable: bool = False,
items_schema: Optional[Dict[str, Any]] = None,
doc: Dict[str, Any] = None # 用于$ref解析
) -> Type:
"""
将OpenAPI类型映射到对应的Python类型。
处理nullable和数组项类型。
"""
py_type = _OPENAPI_TYPE_TO_PYTHON_TYPE_MAP.get(openapi_type)
if py_type is None:
logger.warning(f"Unknown OpenAPI type '{openapi_type}'. Defaulting to Any.")
py_type = Any
if openapi_type == "array" and items_schema:
# 递归处理数组项的类型
resolved_items_schema = _resolve_schema_ref(items_schema, doc) if items_schema.get('$ref') else items_schema
item_py_type = _map_openapi_type_to_python_type(
resolved_items_schema.get('type', 'string'),
resolved_items_schema.get('format'),
resolved_items_schema.get('nullable', False),
resolved_items_schema.get('items'),
doc
)
py_type = List[item_py_type] # type: ignore
if nullable:
return Optional[py_type] # type: ignore
return py_type
def _resolve_schema_ref(schema: Dict[str, Any], doc: Dict[str, Any]) -> Dict[str, Any]:
"""
递归解析OpenAPI schema中的$ref引用。
"""
if '$ref' in schema:
ref_path = schema['$ref'].lstrip('#/')
parts = ref_path.split('/')
current = doc
for part in parts:
if part in current:
current = current[part]
else:
logger.error(f"Failed to resolve $ref: {schema['$ref']}. Path part '{part}' not found.")
raise ValueError(f"Invalid $ref path: {schema['$ref']}")
return current
return schema
def _clean_name(name: str) -> str:
"""清理名称,使其成为合法的Python标识符。"""
name = re.sub(r'[^a-zA-Z0-9_]', '_', name) # 替换非法字符为下划线
name = re.sub(r'^[^a-zA-Z_]+', '', name) # 确保不以下划线或数字开头
if not name: # 如果清理后为空,则使用一个默认名称
return "unnamed_field"
return name
步骤 2: 动态生成Pydantic模型作为args_schema
这是最复杂但也是最关键的一步。我们需要遍历OpenAPI操作的参数和请求体定义,并将其转换为Pydantic模型的字段。
class OpenAPItoolGenerator:
def __init__(self, openapi_spec: Dict[str, Any], api_key: Optional[str] = None, api_key_name: Optional[str] = None, api_key_in: Optional[str] = None):
"""
初始化OpenAPI Tool Generator。
Args:
openapi_spec: 已加载的OpenAPI规范字典。
api_key: 可选的API密钥。如果提供,将用于所有API请求。
api_key_name: API密钥的名称(如'X-API-Key')。
api_key_in: API密钥的位置('header', 'query')。
"""
self.spec = openapi_spec
self.base_url = self._get_base_url()
self.dynamic_pydantic_models: Dict[str, Type[BaseModel]] = {}
self.api_key = api_key
self.api_key_name = api_key_name
self.api_key_in = api_key_in
# 预解析并缓存所有组件schemas,避免重复解析
if 'components' in self.spec and 'schemas' in self.spec['components']:
for schema_name, schema_def in self.spec['components']['schemas'].items():
self.dynamic_pydantic_models[schema_name] = self._create_pydantic_model_from_schema(
_clean_name(schema_name), schema_def, self.spec
)
logger.info(f"Initialized OpenAPItoolGenerator with base URL: {self.base_url}")
def _get_base_url(self) -> str:
"""从OpenAPI spec中提取基础URL。"""
if 'servers' in self.spec and self.spec['servers']:
# 优先使用第一个服务器URL
return self.spec['servers'][0]['url']
logger.warning("No 'servers' defined in OpenAPI spec. Using empty string as base URL.")
return ""
def _create_pydantic_model_from_schema(self, model_name: str, schema_dict: Dict[str, Any], doc: Dict[str, Any]) -> Type[BaseModel]:
"""
根据OpenAPI Schema动态创建一个Pydantic模型。
处理嵌套$ref和数组类型。
"""
fields: Dict[str, Tuple[Type, Any]] = {}
required_fields = set(schema_dict.get('required', []))
properties = schema_dict.get('properties', {})
for prop_name, prop_schema in properties.items():
prop_schema = _resolve_schema_ref(prop_schema, doc) # 解析$ref
python_type: Type
is_nullable = prop_schema.get('nullable', False) or (prop_schema.get('type') == 'string' and 'null' in prop_schema.get('enum', []))
# 处理数组类型
if prop_schema.get('type') == 'array':
items_schema = prop_schema.get('items')
if items_schema:
resolved_items_schema = _resolve_schema_ref(items_schema, doc)
if '$ref' in items_schema: # 数组项是引用类型
ref_name = items_schema['$ref'].split('/')[-1]
# 确保引用的Pydantic模型已经被创建并缓存
if ref_name not in self.dynamic_pydantic_models:
self.dynamic_pydantic_models[ref_name] = self._create_pydantic_model_from_schema(
_clean_name(ref_name), resolved_items_schema, doc
)
item_py_type = self.dynamic_pydantic_models[ref_name]
else:
item_py_type = _map_openapi_type_to_python_type(
resolved_items_schema.get('type', 'string'),
resolved_items_schema.get('format'),
resolved_items_schema.get('nullable', False),
resolved_items_schema.get('items'),
doc
)
python_type = List[item_py_type] # type: ignore
else:
python_type = List[Any] # type: ignore
elif '$ref' in prop_schema: # 属性本身是引用类型
ref_name = prop_schema['$ref'].split('/')[-1]
if ref_name not in self.dynamic_pydantic_models:
# 递归创建引用的模型
self.dynamic_pydantic_models[ref_name] = self._create_pydantic_model_from_schema(
_clean_name(ref_name), prop_schema, doc
)
python_type = self.dynamic_pydantic_models[ref_name]
else: # 普通基本类型
python_type = _map_openapi_type_to_python_type(
prop_schema.get('type', 'string'),
prop_schema.get('format'),
is_nullable,
prop_schema.get('items'),
doc
)
# 处理默认值
default_value = prop_schema.get('default')
# Pydantic Field定义
field_definition = Field(
default=default_value,
description=prop_schema.get('description', ''),
# 这里可以添加更多验证,如min_length, max_length, pattern, ge, le等
)
# 如果字段是必需的,则没有默认值 (除非default_value存在)
if prop_name in required_fields and default_value is None:
# 必需字段且无默认值,类型不应是Optional
fields[_clean_name(prop_name)] = (python_type, ...) # ... 表示必需
else:
# 非必需字段或有默认值,类型应是Optional或已包含默认值
fields[_clean_name(prop_name)] = (python_type if not is_nullable else Optional[python_type], field_definition) # type: ignore
# 使用create_model动态创建Pydantic模型
try:
DynamicModel = create_model(model_name, **fields) # type: ignore
return DynamicModel
except Exception as e:
logger.error(f"Failed to create Pydantic model for '{model_name}': {e}")
# 如果创建失败,返回一个通用的字典模型
return create_model(model_name, **{_clean_name(model_name): (Dict[str, Any], Field(description=f"Raw input for {model_name}"))}) # type: ignore
def _generate_operation_pydantic_schema(self, operation_id: str, operation_details: Dict[str, Any], path_template: str) -> Type[BaseModel]:
"""
为给定的OpenAPI操作生成一个Pydantic模型作为args_schema。
它将所有参数(路径、查询、头部、Cookie)和请求体合并到一个模型中。
"""
# 为每个操作生成一个唯一的模型名称
model_name = f"{_clean_name(operation_id)}Input"
if model_name in self.dynamic_pydantic_models:
return self.dynamic_pydantic_models[model_name]
fields: Dict[str, Tuple[Type, Any]] = {}
# 1. 处理路径、查询、头部、Cookie参数
parameters = operation_details.get('parameters', [])
for param in parameters:
param_name = param['name']
param_in = param['in']
param_required = param.get('required', False)
param_description = param.get('description', f"Parameter for {param_in}: {param_name}")
param_schema = _resolve_schema_ref(param.get('schema', {'type': 'string'}), self.spec)
python_type = _map_openapi_type_to_python_type(
param_schema.get('type', 'string'),
param_schema.get('format'),
param_schema.get('nullable', False),
param_schema.get('items'),
self.spec
)
default_value = param_schema.get('default')
field_definition = Field(
default=default_value,
description=param_description,
alias=param_name # 允许参数名与Python字段名不同
)
# LangChain Pydantic模型字段名必须是合法的Python标识符
# 但OpenAPI参数名可以是任意字符串,这里我们清理一下
cleaned_param_name = _clean_name(param_name)
if param_required and default_value is None:
fields[cleaned_param_name] = (python_type, ...)
else:
fields[cleaned_param_name] = (python_type, field_definition)
# 2. 处理请求体 (requestBody)
request_body = operation_details.get('requestBody')
if request_body:
content = request_body.get('content', {})
# 优先处理 application/json 类型
json_schema = content.get('application/json', {}).get('schema')
if json_schema:
json_schema = _resolve_schema_ref(json_schema, self.spec)
# 如果请求体是一个引用 schema,则将其作为嵌套模型
if '$ref' in json_schema:
ref_name = json_schema['$ref'].split('/')[-1]
if ref_name not in self.dynamic_pydantic_models:
self.dynamic_pydantic_models[ref_name] = self._create_pydantic_model_from_schema(
_clean_name(ref_name), json_schema, self.spec
)
body_model_type = self.dynamic_pydantic_models[ref_name]
body_field_name = "request_body" # 统一命名请求体字段
body_description = request_body.get('description', f"The JSON request body for {operation_id}.")
if request_body.get('required', False):
fields[body_field_name] = (body_model_type, Field(description=body_description))
else:
fields[body_field_name] = (Optional[body_model_type], Field(default=None, description=body_description))
elif json_schema.get('type') == 'object':
# 如果请求体是普通对象,将其属性直接添加到操作模型中
# 注意:这里可能会与URL参数名冲突,需要更精细的命名策略或嵌套
# 为了简化,这里假设请求体属性名不与URL参数名冲突
body_required_fields = set(json_schema.get('required', []))
for prop_name, prop_schema in json_schema.get('properties', {}).items():
prop_schema = _resolve_schema_ref(prop_schema, self.spec)
python_type = _map_openapi_type_to_python_type(
prop_schema.get('type', 'string'),
prop_schema.get('format'),
prop_schema.get('nullable', False),
prop_schema.get('items'),
self.spec
)
default_value = prop_schema.get('default')
field_definition = Field(
default=default_value,
description=prop_schema.get('description', '')
)
cleaned_prop_name = _clean_name(prop_name)
if cleaned_prop_name in fields:
logger.warning(f"Field name conflict: '{cleaned_prop_name}' already exists for {operation_id}. Request body property will be ignored or overwritten.")
# 实际应用中需要更复杂的冲突解决策略,例如添加前缀
fields[f"body_{cleaned_prop_name}"] = (python_type if cleaned_prop_name not in body_required_fields else Optional[python_type], field_definition) # type: ignore
elif prop_name in body_required_fields and default_value is None:
fields[cleaned_prop_name] = (python_type, ...)
else:
fields[cleaned_prop_name] = (python_type, field_definition)
else:
# 如果请求体是基本类型(如 string),作为单独字段
body_type = _map_openapi_type_to_python_type(
json_schema.get('type', 'string'),
json_schema.get('format'),
json_schema.get('nullable', False),
json_schema.get('items'),
self.spec
)
body_description = request_body.get('description', f"The JSON request body for {operation_id}.")
body_field_name = "request_body"
if request_body.get('required', False):
fields[body_field_name] = (body_type, Field(description=body_description))
else:
fields[body_field_name] = (Optional[body_type], Field(default=None, description=body_description))
# 动态创建Pydantic模型并缓存
InputModel = self._create_pydantic_model_from_schema(model_name, {'properties': {k: v[0] for k, v in fields.items()}}, self.spec) # 这里的schema_dict需要更完整,暂时简化
# 实际创建模型时,需要将所有参数和请求体属性作为顶层字段
# 重构_create_pydantic_model_from_schema来处理这个合并逻辑
# 更好的方法是直接使用create_model,并将fields传递进去
try:
InputModel = create_model(model_name, **fields) # type: ignore
self.dynamic_pydantic_models[model_name] = InputModel
return InputModel
except Exception as e:
logger.error(f"Failed to create Pydantic Input model for operation '{operation_id}': {e}")
# 失败时返回一个接受任意字典的通用模型
return create_model(model_name, **{'args': (Dict[str, Any], Field(description=f"Raw input arguments for {operation_id}"))}) # type: ignore
3. 创建func:API调用逻辑
接下来,我们需要为每个操作生成一个Python函数,这个函数将负责实际的HTTP请求。它将接收由LLM提供的参数,根据这些参数构建请求,并调用API。
def _generate_api_caller_func(self, method: str, path_template: str, operation_details: Dict[str, Any]) -> callable:
"""
为给定的OpenAPI操作生成一个API调用函数。
此函数将接收Pydantic模型解析后的参数,并执行HTTP请求。
"""
def api_caller(**kwargs: Any) -> str:
# 基础URL和路径
url = f"{self.base_url}{path_template}"
headers = {}
query_params = {}
json_body = None
# 处理API Key认证 (如果已配置)
if self.api_key and self.api_key_name and self.api_key_in:
if self.api_key_in == 'header':
headers[self.api_key_name] = self.api_key
elif self.api_key_in == 'query':
query_params[self.api_key_name] = self.api_key
# 可以扩展支持更多认证方式,如bearer token等
# 遍历kwargs,根据OpenAPI参数定义将其分类
operation_parameters = operation_details.get('parameters', [])
# 提取路径参数
path_params_to_format = {}
for param in operation_parameters:
if param['in'] == 'path':
param_name = param['name']
cleaned_param_name = _clean_name(param_name)
if cleaned_param_name in kwargs:
path_params_to_format[param_name] = kwargs.pop(cleaned_param_name)
# 格式化URL中的路径参数
try:
# 使用re.sub替换路径模板中的{param}为实际值
# 注意:这里需要确保路径参数名与kwargs中的键对应
formatted_url = url
for key, value in path_params_to_format.items():
# 替换 {param_name} 或 {param_name?}
formatted_url = re.sub(r'{' + re.escape(key) + r'(??)}', str(value), formatted_url)
# 如果还有未替换的可选路径参数,可以移除它们
formatted_url = re.sub(r'{[a-zA-Z0-9_]+??}', '', formatted_url)
url = formatted_url
except KeyError as e:
logger.error(f"Missing path parameter for URL formatting: {e}. Provided kwargs: {kwargs}, Path template: {path_template}")
return f"Error: Missing required path parameter for URL: {e}"
# 提取查询参数、头部参数、请求体
request_body_data = {} # 用于收集请求体数据
# 再次遍历operation_parameters来收集query和header
for param in operation_parameters:
param_name = param['name']
cleaned_param_name = _clean_name(param_name)
if cleaned_param_name in kwargs:
value = kwargs.pop(cleaned_param_name)
if param['in'] == 'query':
if value is not None: # 只有非None值才添加到查询参数
query_params[param_name] = value
elif param['in'] == 'header':
if value is not None:
headers[param_name] = str(value) # 头部值通常是字符串
# Cookie参数通常不需要特殊处理,requests库会处理
# 处理请求体参数
request_body_spec = operation_details.get('requestBody')
if request_body_spec:
content = request_body_spec.get('content', {})
if 'application/json' in content:
# 检查是否是嵌套的 'request_body' 字段
if 'request_body' in kwargs:
json_body = kwargs.pop('request_body')
if isinstance(json_body, BaseModel): # 如果是Pydantic模型实例
json_body = json_body.model_dump(by_alias=True) # 转换为字典,保持OpenAPI原始字段名
else: # 可能是扁平化的请求体属性
for key in list(kwargs.keys()): # 迭代副本以安全修改字典
# 假设请求体属性名与kwargs中剩余的键对应
# 这是一个简化的处理,实际可能需要根据schema进行更精确的匹配
if key in content['application/json'].get('schema', {}).get('properties', {}):
request_body_data[key] = kwargs.pop(key)
if request_body_data:
json_body = request_body_data
# 确保所有kwargs都被处理,否则可能是未预期的参数
if kwargs:
logger.warning(f"Unused arguments passed to API caller for {path_template} {method}: {kwargs}")
try:
logger.info(f"Making API call: {method} {url} with query={query_params}, headers={headers}, json_body={json_body}")
response: requests.Response
if method.upper() == 'GET':
response = requests.get(url, params=query_params, headers=headers, timeout=10)
elif method.upper() == 'POST':
response = requests.post(url, params=query_params, headers=headers, json=json_body, timeout=10)
elif method.upper() == 'PUT':
response = requests.put(url, params=query_params, headers=headers, json=json_body, timeout=10)
elif method.upper() == 'DELETE':
response = requests.delete(url, params=query_params, headers=headers, timeout=10)
else:
return f"Error: Unsupported HTTP method {method}"
response.raise_for_status() # 检查HTTP错误状态
try:
return json.dumps(response.json(), ensure_ascii=False, indent=2)
except json.JSONDecodeError:
return response.text # 如果不是JSON,返回原始文本
except requests.exceptions.HTTPError as e:
logger.error(f"HTTP Error for {method} {url}: {e.response.status_code} - {e.response.text}")
return f"API Error: {e.response.status_code} - {e.response.text}"
except requests.exceptions.ConnectionError as e:
logger.error(f"Connection Error for {method} {url}: {e}")
return f"API Connection Error: {e}"
except requests.exceptions.Timeout as e:
logger.error(f"Timeout Error for {method} {url}: {e}")
return f"API Timeout Error: {e}"
except Exception as e:
logger.error(f"An unexpected error occurred during API call {method} {url}: {e}")
return f"An unexpected error occurred: {e}"
return api_caller
4. 组装LangChain Tool
现在,我们将所有部分组合起来,遍历OpenAPI规范中的每个操作,并生成一个Tool对象。
def generate_tools(self) -> List[BaseTool]: # 使用BaseTool以兼容旧版Tool
"""
从OpenAPI规范中生成所有可用的LangChain工具。
"""
tools: List[BaseTool] = []
# 获取API的基础描述,用于增强工具描述
api_title = self.spec.get('info', {}).get('title', 'API')
api_description_base = self.spec.get('info', {}).get('description', f"Interacts with the {api_title}.")
for path_template, path_item in self.spec.get('paths', {}).items():
for method, operation_details in path_item.items():
# 过滤掉非HTTP方法(如'parameters', 'summary'等)
if method.lower() not in ['get', 'post', 'put', 'delete', 'patch', 'head', 'options', 'trace']:
continue
operation_id = operation_details.get('operationId', f"{method}_{path_template.replace('/', '_').strip('_')}")
# 清理 operation_id 以确保是合法的Python标识符
tool_name = _clean_name(operation_id)
# 生成Pydantic模型作为args_schema
args_schema = self._generate_operation_pydantic_schema(tool_name, operation_details, path_template)
# 生成API调用函数
api_caller_func = self._generate_api_caller_func(method, path_template, operation_details)
# 构建工具描述
summary = operation_details.get('summary', f"Performs a {method.upper()} request to {path_template}.")
description = operation_details.get('description', summary)
# 增强描述以包含参数信息
param_descriptions = []
for param in operation_details.get('parameters', []):
param_name = param['name']
param_in = param['in']
param_desc = param.get('description', 'No description provided.')
param_required = ' (Required)' if param.get('required', False) else ''
param_descriptions.append(f"- `{param_name}` ({param_in}): {param_desc}{param_required}")
request_body_desc = ""
request_body = operation_details.get('requestBody')
if request_body:
body_schema_ref = request_body.get('content', {}).get('application/json', {}).get('schema', {}).get('$ref')
if body_schema_ref:
body_model_name = body_schema_ref.split('/')[-1]
request_body_desc = f" It accepts a JSON request body conforming to the `{body_model_name}` schema."
if request_body.get('description'):
request_body_desc += f" Body description: {request_body.get('description')}."
elif request_body.get('description'):
request_body_desc = f" It accepts a request body: {request_body.get('description')}."
else:
request_body_desc = " It accepts a JSON request body."
full_tool_description = (
f"{api_description_base}nn"
f"**Function:** {summary}n"
f"**Details:** {description}{request_body_desc}n"
)
if param_descriptions:
full_tool_description += "**Parameters:**n" + "n".join(param_descriptions)
# 检查工具名称是否重复
if any(t.name == tool_name for t in tools):
logger.warning(f"Duplicate tool name generated: '{tool_name}'. Appending unique suffix.")
tool_name = f"{tool_name}_{len(tools)}" # 简单处理重复名
try:
tool = Tool(
name=tool_name,
description=full_tool_description,
func=api_caller_func,
args_schema=args_schema
)
tools.append(tool)
logger.info(f"Generated tool: '{tool_name}' for {method.upper()} {path_template}")
except Exception as e:
logger.error(f"Failed to create LangChain Tool for '{operation_id}': {e}")
return tools
5. 完整示例和使用
现在,我们将上面所有的代码片段整合到一个可运行的程序中,并使用我们之前定义的“产品目录API”的OpenAPI规范。
# 将之前定义的所有辅助函数和OpenAPItoolGenerator类放在这里
# ----------------------------------------------------------------------------------------------------
# 完整的OpenAPItoolGenerator类 (包含之前的所有_map_openapi_type_to_python_type等辅助函数)
# 为节省篇幅,这里假设上述所有辅助函数和类定义都在这里
# ----------------------------------------------------------------------------------------------------
# 假设 openapi_spec_content 是上面提供的 YAML 字符串
openapi_spec_content = """
openapi: 3.0.0
info:
title: Product Catalog API
version: 1.0.0
description: API for managing products in a catalog.
servers:
- url: https://api.example.com/v1 # 注意:这里是虚拟的URL,实际调用会失败
paths:
/products:
get:
operationId: getProducts
summary: Retrieve a list of products.
description: Fetches all products, optionally filtered by category and limit.
parameters:
- name: category
in: query
description: Filter products by category.
schema:
type: string
- name: limit
in: query
description: Maximum number of products to return.
schema:
type: integer
format: int32
minimum: 1
default: 10
responses:
'200':
description: A list of products.
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Product'
post:
operationId: createProduct
summary: Create a new product.
description: Adds a new product to the catalog.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/NewProduct'
responses:
'201':
description: Product created successfully.
content:
application/json:
schema:
$ref: '#/components/schemas/Product'
/products/{productId}:
get:
operationId: getProductById
summary: Retrieve a product by ID.
description: Fetches a single product by its unique identifier.
parameters:
- name: productId
in: path
required: true
description: The ID of the product to retrieve.
schema:
type: string
responses:
'200':
description: A single product.
content:
application/json:
schema:
$ref: '#/components/schemas/Product'
'404':
description: Product not found.
components:
schemas:
Product:
type: object
properties:
id:
type: string
readOnly: true
description: Unique product identifier.
name:
type: string
description: Name of the product.
category:
type: string
description: Category of the product.
price:
type: number
format: float
description: Price of the product.
description:
type: string
nullable: true
description: Detailed description of the product.
required:
- id
- name
- category
- price
NewProduct:
type: object
properties:
name:
type: string
description: Name of the product.
category:
type: string
description: Category of the product.
price:
type: number
format: float
description: Price of the product.
description:
type: string
nullable: true
description: Detailed description of the product.
required:
- name
- category
- price
"""
if __name__ == "__main__":
# 将YAML字符串加载为Python字典
spec_dict = yaml.safe_load(openapi_spec_content)
# 实例化生成器,可以传入API Key
# generator = OpenAPItoolGenerator(spec_dict, api_key="YOUR_API_KEY", api_key_name="X-API-Key", api_key_in="header")
generator = OpenAPItoolGenerator(spec_dict)
# 生成工具
generated_tools = generator.generate_tools()
print(f"n--- Generated {len(generated_tools)} LangChain Tools ---")
for tool in generated_tools:
print(f"nTool Name: {tool.name}")
print(f"Tool Description:n{tool.description}")
print(f"Tool Args Schema (Pydantic Model):n{tool.args_schema.schema_json(indent=2)}")
print("-" * 30)
# 演示如何使用其中一个工具 (模拟调用)
# 注意: 这里的API调用是针对虚拟URL,实际会失败,但展示了参数传入方式
if generated_tools:
print("n--- Testing a generated tool (simulated call) ---")
get_products_tool = next((t for t in generated_tools if t.name == 'getProducts'), None)
if get_products_tool:
print(f"nCalling tool: {get_products_tool.name}")
try:
# 模拟LLM调用工具,传入参数
result = get_products_tool.run({"category": "electronics", "limit": 2})
print(f"Tool execution result:n{result}")
except Exception as e:
print(f"Tool execution failed (expected for virtual API): {e}")
create_product_tool = next((t for t in generated_tools if t.name == 'createProduct'), None)
if create_product_tool:
print(f"nCalling tool: {create_product_tool.name}")
try:
# 模拟创建产品,注意 request_body 字段
new_product_data = {
"name": "Wireless Headphones",
"category": "electronics",
"price": 99.99,
"description": "Premium noise-cancelling headphones."
}
result = create_product_tool.run(new_product_data) # 直接传入扁平化参数
print(f"Tool execution result:n{result}")
except Exception as e:
print(f"Tool execution failed (expected for virtual API): {e}")
运行结果(部分输出示例):
--- Generated 3 LangChain Tools ---
Tool Name: getProducts
Tool Description:
API for managing products in a catalog.
**Function:** Retrieve a list of products.
**Details:** Fetches all products, optionally filtered by category and limit.
**Parameters:**
- `category` (query): Filter products by category.
- `limit` (query): Maximum number of products to return. (Required)
Tool Args Schema (Pydantic Model):
{
"title": "getProductsInput",
"type": "object",
"properties": {
"category": {
"title": "Category",
"description": "Filter products by category.",
"type": "string"
},
"limit": {
"title": "Limit",
"default": 10,
"description": "Maximum number of products to return.",
"type": "integer"
}
}
}
------------------------------
Tool Name: createProduct
Tool Description:
API for managing products in a catalog.
**Function:** Create a new product.
**Details:** Adds a new product to the catalog. It accepts a JSON request body conforming to the `NewProduct` schema.
**Parameters:**
- `name` (query): Name of the product. (Required)
- `category` (query): Category of the product. (Required)
- `price` (query): Price of the product. (Required)
- `description` (query): Detailed description of the product.
Tool Args Schema (Pydantic Model):
{
"title": "createProductInput",
"type": "object",
"properties": {
"name": {
"title": "Name",
"description": "Name of the product.",
"type": "string"
},
"category": {
"title": "Category",
"description": "Category of the product.",
"type": "string"
},
"price": {
"title": "Price",
"description": "Price of the product.",
"type": "number"
},
"description": {
"title": "Description",
"description": "Detailed description of the product.",
"nullable": true,
"type": "string"
}
},
"required": [
"name",
"category",
"price"
]
}
------------------------------
--- Testing a generated tool (simulated call) ---
Calling tool: getProducts
API Connection Error: HTTPConnectionPool(host='api.example.com', port=443): Max retries exceeded with url: /v1/products?category=electronics&limit=2 (Caused by NewConnectionError('<urllib3.connection.HTTPSConnection object at 0x...>: Failed to establish a new connection: [Errno 11001] getaddrinfo failed'))
Tool execution failed (expected for virtual API): API Connection Error: HTTPConnectionPool(host='api.example.com', port=443): Max retries exceeded with url: /v1/products?category=electronics&limit=2 (Caused by NewConnectionError('<urllib3.connection.HTTPSConnection object at 0x...>: Failed to establish a new connection: [Errno 11001] getaddrinfo failed'))
Calling tool: createProduct
API Connection Error: HTTPConnectionPool(host='api.example.com', port=443): Max retries exceeded with url: /v1/products (Caused by NewConnectionError('<urllib3.connection.HTTPSConnection object at 0x...>: Failed to establish a new connection: [Errno 11001] getaddrinfo failed'))
Tool execution failed (expected for virtual API): API Connection Error: HTTPConnectionPool(host='api.example.com', port=443): Max retries exceeded with url: /v1/products (Caused by NewConnectionError('<urllib3.connection.HTTPSConnection object at 0x...>: Failed to establish a new connection: [Errno 11001] getaddrinfo failed'))
请注意,由于https://api.example.com/v1是一个虚拟URL,实际的API调用将因网络连接失败而报错,但这个输出清晰地展示了工具的生成过程、其结构以及模拟调用时参数的传入方式。
6. 高级场景与最佳实践
上面的实现已经涵盖了基本功能,但在实际生产环境中,我们还需要考虑更多高级场景和最佳实践:
- 更复杂的Schema解析:
allOf,anyOf,oneOf: 这些组合Schema在Pydantic中可以映射为Union类型或通过智能的类型推断来处理。例如,oneOf可能意味着一个字段可以是多种Pydantic模型中的一种。- 多态性: 当响应或请求体可以基于某个鉴别器字段具有多种不同结构时,这需要更复杂的Pydantic模型继承和运行时类型检查。
- 自定义格式(
format): OpenAPI允许定义自定义的format,如uuid、date-time等。我们可以扩展_map_openapi_type_to_python_type来将它们映射到特定的Pydantic类型(如UUID,datetime)。
- 认证机制:
- OAuth2: 许多API使用OAuth2进行认证。生成器需要能够接收OAuth2 token,并在每次请求中注入
Authorization: Bearer <token>头部。这可能需要一个独立的认证管理器。 - 多个API Keys: 如果API有多个API Key,或者不同的操作需要不同的认证,需要更灵活的配置方式。
- OAuth2: 许多API使用OAuth2进行认证。生成器需要能够接收OAuth2 token,并在每次请求中注入
- 错误处理与重试:
- 在
_generate_api_caller_func中,try-except块应该更加精细,捕获不同类型的HTTP错误(4xx客户端错误,5xx服务器错误),并根据需要进行日志记录或返回更具描述性的错误信息给LLM。 - 对于瞬时错误(如网络波动、服务器过载),可以集成重试机制(例如使用
tenacity库)。
- 在
- 异步支持:
- LangChain支持异步工具(
Atool),可以提高并行处理效率。将requests替换为httpx等异步HTTP客户端,并使用async def定义func,可以实现异步工具。
- LangChain支持异步工具(
- 工具描述质量: LLM对工具的理解程度直接取决于
description的质量。- 清晰、简洁: 避免冗余信息,突出工具的核心功能。
- 参数说明: 清晰解释每个参数的用途、类型和示例。
- 输入输出示例: 提供实际的输入和输出示例可以帮助LLM更好地理解工具的行为。
- LLM的视角: 描述应该从LLM的角度出发,告诉它“这个工具能帮你做什么,当你遇到什么问题时应该调用它,以及如何提供参数”。
- 缓存: 对于幂等的GET请求,可以考虑在
func内部实现简单的缓存机制,避免重复调用相同的API。 - 日志与监控: 详细的日志记录对于调试和监控工具的运行至关重要。记录工具的调用、参数、API响应时间、成功或失败等信息。
- 安全性:
- 输入验证: Pydantic已经提供了强大的输入验证,但仍需警惕潜在的恶意输入。
- 敏感信息处理: 确保API Key、Token等敏感信息不会意外泄露到日志或LLM的输出中。
- 版本控制: 当OpenAPI规范发生变化时,如何管理工具的更新和版本迭代。
- OpenAPI扩展: 如果API使用了OpenAPI的自定义扩展(
x-字段),可能需要解析这些字段以获取额外的工具元数据。
7. 总结与展望
通过本讲座,我们深入探讨了如何从OpenAPI规范自动生成LangChain工具的原理与实践。我们看到了OpenAPI文档作为API“蓝图”的强大潜力,以及Pydantic模型在定义工具args_schema和实现数据验证方面的关键作用。这个自动化生成过程不仅极大地减少了手动编写工具的工作量,更重要的是,它为LLMs与复杂API的无缝集成奠定了坚实基础。
展望未来,随着LLM能力的持续增强和Function Calling等技术的成熟,这种自动化工具生成的能力将变得愈发重要。它将使得构建能够与成千上万个API交互的智能代理成为可能,从而开启AI应用的新篇章,让LLMs真正成为我们数字世界中的强大助手。