工具调用 schema 排障:docstring、参数校验与工具异常实测
工具调用出问题时,最常见的错误思路是盯着 Python 函数本身看。模型看不到你的函数,它看到的只有 LangChain 根据函数生成的一份 JSON schema:名称、描述、每个参数的类型和说明。schema 写得含糊,模型就传错参数;参数校验的宽严程度,决定了错误参数是被挡住还是悄悄放行;工具内部抛异常,又会走完全不同的路径。
本页是查表用的参考页,写给正在给 Agent 写工具的开发者:用三组脚本把这三件事的真实行为跑一遍,最后给出一张排障对照表。
适用版本与验证信息
| 项目 | 内容 |
|---|---|
| 验证日期 | 2026-10-26 |
| Python | 3.12.12 |
| 包版本 | langchain==1.4.7、langchain-core==1.7.0、pydantic==2.14.1、langgraph==1.2.17 |
| 模型 | 实验 1、2 不涉及模型;实验 3 使用脚本化模型(按预设顺序回放固定消息),不联网、不需要 API Key |
| 官方来源 | Tools (opens in a new tab)、Middleware 概览 (opens in a new tab)、内置 Middleware (opens in a new tab)、自定义 Middleware (opens in a new tab),对照官方文档仓库 langchain-ai/docs (opens in a new tab) 提交 b8d4ced(2026-10-16) |
| 实验代码 | 站点仓库 labs/tool-calling-schema/,依赖锁定在 labs/requirements/v1.txt |
本页验证的是框架如何生成 schema、如何校验、如何传播异常,不涉及任何真实模型“会不会”按 schema 传参,那取决于具体模型。
先说结论
- 默认情况下,参数说明不会进入参数 schema。
@tool会把整段 docstring(包括Args:部分)原样塞进工具的description,每个参数的 schema 里没有description。想让模型看到每个参数的含义,用parse_docstring=True,或者用args_schema配合Field(description=...)(实验 1)。 - 没有 docstring 又没提供
description的工具,在定义时就会报错,不会拖到运行期(实验 1)。 - 参数校验是宽松的。 字符串
"3"会被接受并转成整数 3;未知字段被静默丢弃;只有类型无法转换、越界、枚举不匹配和缺少必填字段才会被拒绝(实验 2)。 - 工具内部抛出的异常默认让整个运行失败。 想把错误交给模型继续处理,要显式配置:给
ToolException打开handle_tool_error,或者用@wrap_tool_call中间件统一转换。官方文档也说明,工具错误处理是通过中间件配置的(实验 3)。 - 模型生成的参数不满足 schema 时,不是异常而是一条错误消息。 框架把校验错误作为
ToolMessage(status="error")交回模型,运行继续,这个现象的实测见故障诊断中心。
实验 1:模型到底收到了什么 schema
同一个搜索工具,用三种方式定义:默认的 docstring、parse_docstring=True、args_schema 加 Field 约束。脚本用 convert_to_openai_tool 把它们转换成模型实际收到的 JSON,只打印描述和参数部分:
"""What the model actually receives: the JSON schema LangChain builds from a Python tool definition."""
import json
from typing import Literal, Optional
from langchain_core.tools import tool
from langchain_core.utils.function_calling import convert_to_openai_tool
from pydantic import BaseModel, Field
def show(label: str, fn) -> None:
spec = convert_to_openai_tool(fn)["function"]
print(f"== {label}")
print("description:", json.dumps(spec["description"], ensure_ascii=False))
print("parameters :", json.dumps(spec["parameters"]["properties"], ensure_ascii=False, sort_keys=True))
print("required :", spec["parameters"].get("required", []))
@tool
def search_plain(query: str, limit: int = 5, lang: Literal["zh", "en"] = "zh", tags: Optional[list[str]] = None) -> str:
"""Search the knowledge base.
Args:
query: What to search for.
limit: Maximum number of results.
lang: Result language.
tags: Optional tag filters.
"""
return "ok"
@tool(parse_docstring=True)
def search_parsed(query: str, limit: int = 5, lang: Literal["zh", "en"] = "zh", tags: Optional[list[str]] = None) -> str:
"""Search the knowledge base.
Args:
query: What to search for.
limit: Maximum number of results.
lang: Result language.
tags: Optional tag filters.
"""
return "ok"
class SearchArgs(BaseModel):
query: str = Field(description="What to search for.")
limit: int = Field(default=5, ge=1, le=20, description="Maximum number of results (1-20).")
@tool(args_schema=SearchArgs)
def search_schema(query: str, limit: int = 5) -> str:
"""Search the knowledge base."""
return "ok"
show("plain docstring (default)", search_plain)
show("parse_docstring=True", search_parsed)
show("args_schema with Field constraints", search_schema)
print("== no docstring")
try:
@tool
def undocumented(x: int) -> int:
return x
except Exception as exc:
print(f"{type(exc).__name__}: {exc}")== plain docstring (default)
description: "Search the knowledge base.\n\n Args:\n query: What to search for.\n limit: Maximum number of results.\n lang: Result language.\n tags: Optional tag filters."
parameters : {"lang": {"default": "zh", "enum": ["zh", "en"], "type": "string"}, "limit": {"default": 5, "type": "integer"}, "query": {"type": "string"}, "tags": {"anyOf": [{"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "default": null}}
required : ['query']
== parse_docstring=True
description: "Search the knowledge base."
parameters : {"lang": {"default": "zh", "description": "Result language.", "enum": ["zh", "en"], "type": "string"}, "limit": {"default": 5, "description": "Maximum number of results.", "type": "integer"}, "query": {"description": "What to search for.", "type": "string"}, "tags": {"anyOf": [{"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "default": null, "description": "Optional tag filters."}}
required : ['query']
== args_schema with Field constraints
description: "Search the knowledge base."
parameters : {"limit": {"default": 5, "description": "Maximum number of results (1-20).", "maximum": 20, "minimum": 1, "type": "integer"}, "query": {"description": "What to search for.", "type": "string"}}
required : ['query']
== no docstring
ValueError: Function must have a docstring if description not provided.逐项看差别:
- 默认方式:
description是整段 docstring,缩进和Args:都在里面;参数里没有任何description。模型要靠读这段长文本去猜参数含义; parse_docstring=True:description只剩第一句摘要,query、limit、lang、tags各自带上了说明;args_schema:参数说明来自Field(description=...),ge、le变成了 schema 里的minimum、maximum。约束会随 schema 一起发给模型,但模型是否遵守取决于模型本身,服务端校验仍然是最后一道防线(见实验 2);- 最后一行:没有 docstring 的函数在定义时就抛出
ValueError。官方文档说明默认用 docstring 作为工具描述,并且类型提示是必需的,因为它们定义了输入 schema;需要覆盖描述时,可以显式传description。
实验 2:参数校验到底有多严
下面用 args_schema 定义工具,然后直接调用 invoke,看 7 种参数分别被接受还是被拒绝,以及函数体实际收到什么:
"""How strictly are tool arguments validated before the function body runs?"""
from typing import Literal
from langchain_core.tools import tool
from pydantic import BaseModel, Field
class SearchArgs(BaseModel):
query: str
limit: int = Field(default=5, ge=1, le=20)
lang: Literal["zh", "en"] = "zh"
CALLS: list = []
@tool(args_schema=SearchArgs)
def search(query: str, limit: int = 5, lang: str = "zh") -> str:
"""Search the knowledge base."""
CALLS.append((query, limit, lang))
return "ok"
CASES = [
("string that looks like an int", {"query": "a", "limit": "3"}),
("string that is not an int", {"query": "a", "limit": "many"}),
("float with a fraction", {"query": "a", "limit": 2.5}),
("out of range", {"query": "a", "limit": 99}),
("value outside the Literal", {"query": "a", "lang": "fr"}),
("unknown extra field", {"query": "a", "page": 2}),
("missing required field", {}),
]
for label, args in CASES:
CALLS.clear()
try:
search.invoke(args)
outcome = f"accepted, function received {CALLS[0]}"
except Exception as exc:
errors = exc.errors()
outcome = f"{type(exc).__name__} ({errors[0]['type']} at {errors[0]['loc']})"
print(f"{label:<32} {outcome}")string that looks like an int accepted, function received ('a', 3, 'zh')
string that is not an int ValidationError (int_parsing at ('limit',))
float with a fraction ValidationError (int_from_float at ('limit',))
out of range ValidationError (less_than_equal at ('limit',))
value outside the Literal ValidationError (literal_error at ('lang',))
unknown extra field accepted, function received ('a', 5, 'zh')
missing required field ValidationError (missing at ('query',))"3"被接受并转换成整数 3,函数收到的是3;"many"无法转换,被拒绝;带小数的2.5也被拒绝(int_from_float);- 越界的
99被拒绝,枚举之外的"fr"被拒绝,缺少必填字段query被拒绝; - 未知字段
page=2被接受,函数完全没收到它,没有任何警告。如果模型把一个重要参数名拼错了,这个参数会悄悄消失,工具以默认值继续执行。
所以对重要的工具,不要只依赖“参数校验会兜底”:拼错的字段名是校验发现不了的;需要更严格的类型时,在 args_schema 的 pydantic 模型里收紧,并用类似上面的脚本验证你的收紧是否生效。
实验 3:工具内部抛异常,Agent 会怎样
同一个 Agent 循环,工具以四种方式失败。每次都是模型先调用工具,之后再给出最终答案:
"""What happens to the agent run when a tool fails? Four ways to configure it."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "agent-vs-langgraph"))
from langchain.agents import create_agent
from langchain.agents.middleware import wrap_tool_call
from langchain_core.messages import AIMessage, ToolMessage
from langchain_core.tools import ToolException, tool
from shared import ScriptedChatModel
@tool
def raises_runtime(key: str) -> str:
"""Always fails with RuntimeError."""
raise RuntimeError("database unavailable")
@tool
def raises_tool_exception(key: str) -> str:
"""Always fails with ToolException."""
raise ToolException("record not found")
@tool
def raises_tool_exception_handled(key: str) -> str:
"""Always fails with ToolException, with handle_tool_error enabled."""
raise ToolException("record not found")
raises_tool_exception_handled.handle_tool_error = True
@wrap_tool_call
def convert_failures(request, handler):
"""Turn any tool exception into an error ToolMessage the model can read."""
try:
return handler(request)
except Exception as exc:
return ToolMessage(
content=f"Tool failed: {type(exc).__name__}: {exc}",
tool_call_id=request.tool_call["id"],
status="error",
)
def run(label: str, the_tool, middleware=()) -> None:
script = [
AIMessage(content="", tool_calls=[{"name": the_tool.name, "args": {"key": "a"}, "id": "c1"}]),
AIMessage(content="done"),
]
agent = create_agent(ScriptedChatModel(script=script), tools=[the_tool], middleware=list(middleware))
try:
result = agent.invoke({"messages": [("user", "go")]})
tool_msg = next(m for m in result["messages"] if m.type == "tool")
outcome = f"run continued; ToolMessage(status={tool_msg.status}): {tool_msg.content}; final answer: {result['messages'][-1].content!r}"
except Exception as exc:
outcome = f"run failed with {type(exc).__name__}: {exc}"
print(f"{label:<44} {outcome}")
run("RuntimeError, no configuration", raises_runtime)
run("ToolException, no configuration", raises_tool_exception)
run("ToolException, handle_tool_error=True", raises_tool_exception_handled)
run("RuntimeError + @wrap_tool_call middleware", raises_runtime, [convert_failures])RuntimeError, no configuration run failed with RuntimeError: database unavailable
ToolException, no configuration run failed with ToolException: record not found
ToolException, handle_tool_error=True run continued; ToolMessage(status=error): record not found; final answer: 'done'
RuntimeError + @wrap_tool_call middleware run continued; ToolMessage(status=error): Tool failed: RuntimeError: database unavailable; final answer: 'done'RuntimeError,不做任何配置:整个运行失败,异常原样抛给调用方;ToolException,不做配置:同样整个运行失败,ToolException本身并不会自动被处理;ToolException加handle_tool_error=True:运行继续,错误文本成为ToolMessage(status="error"),模型随后给出了最终答案;@wrap_tool_call中间件:连RuntimeError这样的任意异常也能被统一转换成错误消息,信息由你自己决定写什么。
两类处理的取舍:handle_tool_error 只处理 ToolException,适合工具自己知道“这是可恢复的业务错误”的场景;中间件能覆盖所有异常,适合在一处统一策略,但要小心把真正的程序错误也吞掉,所以转换后的消息应当同时写日志。
排障对照表
| 现象 | 最可能的原因 | 怎么验证 | 处理 |
|---|---|---|---|
| 模型总是忽略某个参数,或者误解参数含义 | 默认定义方式下参数 schema 没有说明 | 对工具执行 convert_to_openai_tool,检查每个 property 是否有 description | parse_docstring=True,或 args_schema 加 Field(description=...)(实验 1) |
定义工具时报 ValueError: Function must have a docstring if description not provided. | 没有 docstring,也没有传 description | 看报错位置的函数定义 | 加 docstring,或者显式传 description(实验 1) |
| 工具收到的值和模型传的字符串不一样 | 校验是宽松模式,"3" 被转成了 3 | 在函数里打印收到的参数类型 | 在 args_schema 的模型里收紧类型,并用脚本验证(实验 2) |
| 模型传了某个参数,工具却用了默认值 | 参数名拼错,未知字段被静默丢弃 | 对比模型原始的 tool_calls 与函数实际收到的参数 | 重要参数设为必填;监控原始 tool_calls 与函数入参的差异(实验 2) |
| 工具一抛异常整个运行就失败了 | 默认不转换异常 | 在 invoke 外层捕获异常,看类型 | 可恢复错误用 ToolException 加 handle_tool_error=True;统一策略用 @wrap_tool_call(实验 3) |
打开 handle_tool_error 之后,普通 RuntimeError 仍然让运行失败 | handle_tool_error 只处理 ToolException | 看工具里抛出的异常类型 | 改抛 ToolException,或使用 @wrap_tool_call(实验 3) |
| 运行没有异常,但工具没被执行,最终回复为空 | 模型返回的工具参数不是合法 JSON,进入 invalid_tool_calls | 打印最后一条消息的 invalid_tool_calls | 见 OpenAI 兼容模型接入验收 |
日志里出现 ToolMessage(status=error),Agent 在继续运行 | 模型的参数不满足 schema,或调用了不存在的工具 | 在消息轨迹里统计 status="error" 的比例 | 改进工具描述;对比例设告警,见故障诊断中心 |
失败与回滚
- 改动 schema 的风险:改工具的描述或参数说明,会改变模型的调用行为,且这种变化不会触发任何编译或类型错误。把工具的 JSON schema 输出纳入版本控制(本页实验 1 的脚本就是一个最小模板),变更时 diff 一下就能看到模型实际收到的差别;
- 回滚:工具定义放在同一个模块里统一导出,回滚就是回退这次提交;有外部副作用的工具,回滚同时要考虑已经发生的调用是否需要补偿,参见 LangGraph checkpoint 与故障恢复 里的幂等讨论;
- 局限:本页验证了框架行为,没有验证任何真实模型对 schema 的遵守程度。上线前请用你实际使用的模型,配合验收脚本做一次端到端检查。
相关阅读
- 把线上遇到的报错对号入座:LangChain v1 与 LangGraph 生产故障诊断中心;
- 模型返回的工具参数非法时的静默失败:OpenAI 兼容模型接入验收;
- 工具有副作用、需要人工审批:create_agent 还是 LangGraph?。
来源
- Tools (opens in a new tab):
@tool、docstring 作为描述、类型提示必需、args_schema、自定义描述,以及“工具错误处理通过 middleware 配置”; - Middleware 概览 (opens in a new tab):中间件的用途与挂载方式;
- 内置 Middleware (opens in a new tab):内置的工具重试等中间件(本页未验证);
- 自定义 Middleware (opens in a new tab):
wrap_tool_call等钩子。
以上来源均于 2026-10-26 核对。本页所有输出来自站点仓库 labs/tool-calling-schema/ 在上表版本下的实际运行。