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.8、langchain==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 系统中最关键的问题之一:
模型如何从“只能生成文本”,升级为“能够请求外部能力”?
表面上,我们只写了一个装饰器:
@tooldef 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 能力如何描述给框架 | @tool、StructuredTool、args_schema |
| 模型工具协议 | 工具如何描述给模型 | name、description、JSON Schema、bind_tools |
| 工具调用协议 | 模型如何表达调用意图 | AIMessage.tool_calls、ToolCall |
| 工具结果协议 | 执行结果如何返回模型 | ToolMessage、tool_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 返回模型。
你需要掌握:
@toolBaseToolStructuredToolToolargs_schematool_call_schema- tool name
- tool description
bind_toolsToolCallAIMessage.tool_callsToolMessageToolExceptionhandle_tool_errorhandle_validation_errorresponse_formatRunnableConfig- Tool Calling 与 Agent Loop 的边界
完成本篇后,你应该能回答:
@tool装饰器为什么返回的是StructuredTool,而不是原始 Python 函数?- 函数名、docstring、类型注解分别会进入工具的哪个字段?
args_schema如何从函数签名生成?- Pydantic Schema、JSON Schema 与模型原生 Tool Schema 有什么关系?
model.bind_tools()到底绑定了什么?它是否执行工具?- 模型为什么返回
AIMessage.tool_calls,而不是直接调用本地函数? BaseTool.invoke()与直接调用 Python 函数有什么区别?BaseTool.invoke()、run()、_parse_input()、_run()的职责如何划分?- 为什么有时
tool.invoke()返回字符串,有时返回ToolMessage? ToolMessage.tool_call_id为什么必须与模型调用请求中的 ID 对应?- 参数校验错误、业务错误和系统异常分别如何处理?
ToolException为什么适合表示“可反馈给模型的业务失败”?StructuredTool.ainvoke()在没有异步函数时如何工作?- 工具 description 为什么会影响模型的工具选择?
- 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 对象 | 作用 |
|---|---|---|
| Capability | BaseTool / StructuredTool | 对外部能力进行统一封装 |
| Capability Name | tool.name | 模型和执行器定位工具 |
| Capability Instruction | tool.description | 告诉模型何时、为何使用 |
| Input Contract | args_schema | 定义参数类型、必填项和说明 |
| Action Request | ToolCall | 模型生成的工具调用请求 |
| Action | tool.invoke() | 受控执行真实能力 |
| Observation | ToolMessage | 将执行结果返回给模型 |
| Correlation ID | tool_call_id | 关联请求与结果 |
| Recoverable Failure | ToolException | 让模型看到并处理工具失败 |
| Agent Loop | create_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
@tooldef 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 ▼User4. 源码地图、关键文件与阅读顺序
核心目录:
langchain_core/tools/langchain/tools/langchain_core/messages/tool.pylangchain_core/utils/function_calling.pylangchain_core/language_models/chat_models.py重点源码文件:
libs/core/langchain_core/tools/convert.pylibs/core/langchain_core/tools/base.pylibs/core/langchain_core/tools/structured.pylibs/core/langchain_core/tools/simple.pylibs/core/langchain_core/messages/tool.pylibs/core/langchain_core/utils/function_calling.py重点类与函数:
tool_create_tool_factoryStructuredToolStructuredTool.from_functionBaseToolBaseTool.invokeBaseTool.runBaseTool._parse_inputBaseTool._to_args_and_kwargscreate_schema_from_functionToolExceptionToolCallToolMessage_format_outputconvert_to_openai_functionconvert_to_openai_tool推荐源码阅读顺序:
1. convert.py::tool2. convert.py::_create_tool_factory3. structured.py::StructuredTool.from_function4. base.py::create_schema_from_function5. base.py::BaseTool6. base.py::BaseTool.invoke7. base.py::_prep_run_args8. base.py::BaseTool.run9. base.py::BaseTool._parse_input10. base.py::BaseTool._to_args_and_kwargs11. structured.py::StructuredTool._run12. base.py::_format_output13. messages/tool.py::ToolCall14. messages/tool.py::ToolMessage15. utils/function_calling.py::convert_to_openai_tool5. 对象模型、继承关系与协议边界
Tool 对象模型:先看继承关系
BaseTool 为什么继承 RunnableSerializable
源码结构可以抽象为:
class BaseTool( RunnableSerializable[ str | dict[str, Any] | ToolCall, Any ]): ...这说明 BaseTool 的输入可以是:
strdict[str, Any]ToolCall输出则取决于调用方式和工具返回值,可能是:
strdictlistToolMessageCommand自定义 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 | 观测系统 | 工具执行事件 |
tags | tracing | 标识工具调用类别 |
metadata | tracing | 附加业务上下文 |
handle_tool_error | BaseTool | 处理 ToolException |
handle_validation_error | BaseTool | 处理参数校验失败 |
response_format | BaseTool | 区分普通内容与 artifact |
StructuredTool 与 Tool 的区别
StructuredTool
支持多个命名参数;通常拥有 Pydantic args_schema;适合绝大多数现代 Tool Calling 场景。例如:
@tooldef search_attractions( city: str, preference: str, limit: int = 5,) -> str: ...通常生成 StructuredTool。
Tool
主要面向简单 string → output 函数;不强调多字段结构化输入;更多承担兼容或简单工具场景。现代 Agent 开发中,推荐优先使用:
StructuredTool + 明确 args_schema核心对象关系
RunnableSerializable ↓BaseTool ├─ StructuredTool └─ Tool四层协议边界
| 协议 | 核心对象 | 职责 |
|---|---|---|
| 工具声明 | @tool、StructuredTool | 把本地能力包装成 Tool |
| 模型声明 | JSON Schema、bind_tools() | 把工具描述提供给模型 |
| 调用请求 | ToolCall | 表达模型的调用意图 |
| 调用结果 | ToolMessage | 把本地执行结果返回模型上下文 |
6. 源码阅读策略与证据标准
阅读顺序
公开入口 ↓输入输出类型 ↓构建期对象 ↓运行时主链 ↓分支、异常与停止条件 ↓扩展接口证据标准
| 标记 | 使用条件 |
|---|---|
| 源码事实 | 当前正式版源码可以直接证明 |
| 官方契约 | 官方文档或 API Reference 明确承诺 |
| 简化伪代码 | 压缩真实控制流,且明确不是逐字源码 |
| 作者推断 | 根据调用关系得出,必须标注为推断 |
| 工程建议 | 说明适用条件,不写成框架保证 |
7. 构建期源码解剖
本章解释装饰器、工厂、Schema 和模型工具描述如何被构建。
@tool 装饰器源码解剖
@tool 不是简单语法糖
当 Python 执行:
@tooldef search_attractions(...): ...等价于:
def search_attractions(...): ...
search_attractions = tool(search_attractions)因此装饰完成后:
search_attractions变量指向的是一个 BaseTool 子类实例,而不是原函数。
原始函数会保存在:
search_attractions.func中。
tool() 支持多种调用方式
源码中的 tool() 使用多个 overload,支持:
@tooldef search(...): ...@tool("web_search")def search(...): ...@tool( "web_search", description="Search public web pages.", parse_docstring=True,)def search(...): ...tool("my_tool", runnable)它的主要参数包括:
descriptionreturn_directargs_schemainfer_schemaresponse_formatparse_docstringerror_on_invalid_docstringextrastool() 入口伪代码
把源码拆成更容易理解的伪代码:
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_funccoroutine = None异步函数:
func = Nonecoroutine = dec_func随后由 StructuredTool 决定:
invoke → _run → funcainvoke → _arun → coroutine若只有同步函数却调用 ainvoke(),框架可以把同步执行放入线程执行器,避免直接阻塞异步事件循环。
StructuredTool.from_function() 源码解剖
第一步:确定源函数
伪代码:
if func is not None: source_function = funcelif coroutine is not None: source_function = coroutineelse: 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因此下面的代码可能失败:
@tooldef 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而不应该看到:
configruntimecallbacksrun_manager这些参数由框架运行时注入。
当前官方文档明确保留了:
configruntime作为框架参数名。
第四步:解析 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
@tooldef 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 Literalfrom pydantic import BaseModel, Fieldfrom 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_schema 与 tool_call_schema
两者不要混淆。
args_schema
工具完整的输入 Schema,可能包含:
- 模型生成字段;
- 框架注入字段;
- 运行时字段。
tool_call_schema
真正展示给模型的工具调用 Schema。
源码会从完整 Schema 中排除注入参数:
args_schema ↓过滤 ToolRuntime / Injected 参数 ↓tool_call_schema ↓发送给模型因此工具函数可以写:
@tooldef get_user_preference( preference_name: str, runtime: ToolRuntime,) -> str: ...模型只看到:
{ "preference_name": { "type": "string" }}runtime 由执行环境注入,不应该由模型伪造。
Tool name 与 description:模型选择工具的控制面
name 不是普通函数名
工具名同时承担:
- 模型选择标识;
- ToolCall 中的
name; - 执行器工具注册表 Key;
- trace 中的能力标识;
- 版本兼容接口。
推荐:
search_attractionsquery_weatherget_hotel_quoteoptimize_routecreate_booking_request不推荐:
tool1do_taskhandlehelpersearch官方文档建议优先使用:
snake_case字母、数字、下划线或连字符以提高跨供应商兼容性。
description 是模型决策指令
模型看不到函数实现,只能看到:
namedescriptionparameters因此工具 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, restaurantreservations, or route optimization.
The result contains attraction candidates rather than afinal day-by-day itinerary."""参数描述也会影响参数生成
弱参数:
city: strdate: 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 BaseToolPydantic ModelTypedDict普通 Python callableJSON 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 上层统一为:
BaseToolToolCallAIMessageToolMessage供应商适配层负责:
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)源码证据:
- https://github.com/langchain-ai/langchain/blob/83e824922ac9bf3bf882347fecabd09614bccc37/libs/core/langchain_core/tools/convert.py
- https://github.com/langchain-ai/langchain/blob/83e824922ac9bf3bf882347fecabd09614bccc37/libs/core/langchain_core/tools/structured.py
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_weathersearch_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_argsrun_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():
callbackstagsmetadatarun_namerun_idconfig因此:
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 = responseartifact = 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, artifactcontent 给模型;
artifact 给应用或后续节点,不必全部塞进模型上下文。
第九层:错误分支
源码区分:
Pydantic ValidationErrorToolException其他 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 的:
contentnameidadditional_kwargsresponse_metadatatool_call_id
作用:
将模型提出的 ToolCall与工具执行结果关联。必须满足:
AIMessage.tool_calls[i]["id"]==ToolMessage.tool_call_idcontent
这是会进入模型上下文的主要工具结果。
可以是:
字符串标准内容块列表多模态内容块对普通对象,框架会尝试进行字符串化或标准内容归一化。
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)源码证据:
- https://github.com/langchain-ai/langchain/blob/83e824922ac9bf3bf882347fecabd09614bccc37/libs/core/langchain_core/tools/base.py
- https://github.com/langchain-ai/langchain/blob/83e824922ac9bf3bf882347fecabd09614bccc37/libs/core/langchain_core/tools/structured.py
9. 关键分支、异常与边界
本章解释同步异步、返回值形态、参数校验和 ToolException 的恢复边界。
Tool Error Handling 源码解剖
工具错误至少分为三类:
| 错误类型 | 典型来源 | 默认行为 |
|---|---|---|
| 参数校验错误 | Pydantic ValidationError | 抛出 |
| 可恢复业务错误 | ToolException | 抛出,除非配置处理 |
| 非预期系统错误 | 普通 Exception | 抛出 |
参数校验错误
例如:
@tooldef 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
@tooldef 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
支持:
FalseTrue固定字符串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)源码证据:
- https://github.com/langchain-ai/langchain/blob/83e824922ac9bf3bf882347fecabd09614bccc37/libs/core/langchain_core/tools/base.py
- https://github.com/langchain-ai/langchain/blob/83e824922ac9bf3bf882347fecabd09614bccc37/libs/core/langchain_core/messages/tool.py
10. 扩展机制与框架协作
本章解释 ToolRuntime、隐藏参数、手动 Tool Calling Loop 与 Agent 执行器的协作。
手动实现完整 Tool Calling 循环
定义工具
from langchain.tools import tool
@tooldef 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)消息顺序必须是:
HumanMessageAIMessage(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
@tooldef 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隐藏:
runtimeruntime 可以提供的能力
当前官方设计中,ToolRuntime 可统一访问:
statecontextstorestream_writerexecution_infoserver_infoconfigtool_call_id这说明工具不仅是纯函数,也可能是:
Agent Runtime 中受控执行的能力节点。保留参数名
不要把普通业务参数命名为:
configruntime这些名称承担框架注入语义。
扩展协作总结、伪代码与源码证据
以下为保留关键控制流的简化伪代码,不是源码逐字复制:
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))源码证据:
- https://github.com/langchain-ai/langchain/blob/83e824922ac9bf3bf882347fecabd09614bccc37/libs/core/langchain_core/tools/base.py
- https://github.com/langchain-ai/langgraph/blob/5931a5f0b313feff24e2516a586c55601b868ac1/libs/prebuilt/langgraph/prebuilt/tool_node.py
11. 工程决策与适用场景
旅行规划助手中的工具拆解
建议的工具清单
| Tool 名称 | 输入 | 输出 | 所属范式 | 是否有副作用 |
|---|---|---|---|---|
search_attractions | city、preference、limit | 景点候选 | Tool-use | 否 |
query_weather | city、date | 天气信息 | Tool-use | 否 |
search_hotels | city、dates、budget | 酒店候选 | Tool-use | 否 |
query_transport | origin、destination、mode | 交通信息 | Tool-use | 否 |
optimize_route | attractions、constraints | 优化路线 | Executor | 否 |
save_itinerary | user_id、itinerary | 保存结果 | Action Tool | 是 |
create_booking_request | booking payload | 预订请求 | Action Tool | 是 |
对每个 Tool 做六层标注
示例:
@tooldef 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_weathersearch_attractionssearch_hotels可以:
- 自动重试;
- 并行执行;
- 缓存;
- 降级。
写入类:
save_itinerarycreate_booking_requestcancel_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_bookingcancel_ordersend_emailissue_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. 参考资料与下一篇衔接
官方资料
官方概念文档
-
LangChain Tools
https://docs.langchain.com/oss/python/langchain/tools -
LangChain Models:Tool Calling
https://docs.langchain.com/oss/python/langchain/models -
LangChain Messages:ToolMessage
https://docs.langchain.com/oss/python/langchain/messages -
LangGraph Quickstart:手动工具执行循环
https://docs.langchain.com/oss/python/langgraph/quickstart -
LangGraph Workflows and Agents:ToolNode
https://docs.langchain.com/oss/python/langgraph/workflows-agents
官方 API Reference
-
tooldecorator
https://reference.langchain.com/python/langchain-core/tools/convert/tool -
BaseTool
https://reference.langchain.com/python/langchain-core/tools/base/BaseTool -
StructuredTool
https://reference.langchain.com/python/langchain-core/tools/structured/StructuredTool -
ToolNode
https://reference.langchain.com/python/langgraph.prebuilt/tool_node/ToolNode -
LangChain Core Tools 模块
https://reference.langchain.com/python/langchain-core/tools
官方源码
-
langchain_core/tools/convert.py
https://github.com/langchain-ai/langchain/blob/83e824922ac9bf3bf882347fecabd09614bccc37/libs/core/langchain_core/tools/convert.py -
langchain_core/tools/base.py
https://github.com/langchain-ai/langchain/blob/83e824922ac9bf3bf882347fecabd09614bccc37/libs/core/langchain_core/tools/base.py -
langchain_core/tools/structured.py
https://github.com/langchain-ai/langchain/blob/83e824922ac9bf3bf882347fecabd09614bccc37/libs/core/langchain_core/tools/structured.py -
langchain_core/tools/simple.py
https://github.com/langchain-ai/langchain/blob/83e824922ac9bf3bf882347fecabd09614bccc37/libs/core/langchain_core/tools/simple.py -
langchain_core/messages/tool.py
https://github.com/langchain-ai/langchain/blob/83e824922ac9bf3bf882347fecabd09614bccc37/libs/core/langchain_core/messages/tool.py -
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?