实现参考
工具调用 schema 排障

工具调用 schema 排障:docstring、参数校验与工具异常实测

工具调用出问题时,最常见的错误思路是盯着 Python 函数本身看。模型看不到你的函数,它看到的只有 LangChain 根据函数生成的一份 JSON schema:名称、描述、每个参数的类型和说明。schema 写得含糊,模型就传错参数;参数校验的宽严程度,决定了错误参数是被挡住还是悄悄放行;工具内部抛异常,又会走完全不同的路径。

本页是查表用的参考页,写给正在给 Agent 写工具的开发者:用三组脚本把这三件事的真实行为跑一遍,最后给出一张排障对照表。

适用版本与验证信息

项目内容
验证日期2026-10-26
Python3.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 传参,那取决于具体模型。

先说结论

  1. 默认情况下,参数说明不会进入参数 schema。 @tool 会把整段 docstring(包括 Args: 部分)原样塞进工具的 description,每个参数的 schema 里没有 description。想让模型看到每个参数的含义,用 parse_docstring=True,或者用 args_schema 配合 Field(description=...)(实验 1)。
  2. 没有 docstring 又没提供 description 的工具,在定义时就会报错,不会拖到运行期(实验 1)。
  3. 参数校验是宽松的。 字符串 "3" 会被接受并转成整数 3;未知字段被静默丢弃;只有类型无法转换、越界、枚举不匹配和缺少必填字段才会被拒绝(实验 2)。
  4. 工具内部抛出的异常默认让整个运行失败。 想把错误交给模型继续处理,要显式配置:给 ToolException 打开 handle_tool_error,或者用 @wrap_tool_call 中间件统一转换。官方文档也说明,工具错误处理是通过中间件配置的(实验 3)。
  5. 模型生成的参数不满足 schema 时,不是异常而是一条错误消息。 框架把校验错误作为 ToolMessage(status="error") 交回模型,运行继续,这个现象的实测见故障诊断中心。

实验 1:模型到底收到了什么 schema

同一个搜索工具,用三种方式定义:默认的 docstring、parse_docstring=True、args_schema 加 Field 约束。脚本用 convert_to_openai_tool 把它们转换成模型实际收到的 JSON,只打印描述和参数部分:

schema_from_signature.py
"""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 种参数分别被接受还是被拒绝,以及函数体实际收到什么:

argument_validation.py
"""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 循环,工具以四种方式失败。每次都是模型先调用工具,之后再给出最终答案:

tool_errors_in_agent.py
"""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 是否有 descriptionparse_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 的遵守程度。上线前请用你实际使用的模型,配合验收脚本做一次端到端检查。

相关阅读

来源

以上来源均于 2026-10-26 核对。本页所有输出来自站点仓库 labs/tool-calling-schema/ 在上表版本下的实际运行。

维护信息

  • 编辑:LangChain 中文网编辑部(内容标准见编辑与纠错说明);
  • 最近验证:2026-10-26;
  • 复现:先执行 labs/setup-envs.sh v1 重建环境,再执行 python3 labs/run.py --lab tool-calling-schema;
  • 发现错误请按联系我们反馈。