跳至内容

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

工具调用出问题时,最常见的错误思路是盯着 Python 函数本身看,但模型看不到你的函数,它只看到 LangChain 根据函数生成的一份 JSON schema——名称、描述,以及每个参数的类型和说明。如果你正在给 Agent 写工具,这篇用三组脚本跑出三件事的真实行为:schema 写得含糊时模型怎么传错参数,参数校验的宽严程度如何决定错误参数是被挡住还是悄悄放行,工具内部抛异常时 Agent 会走哪条路径,最后给出一张排障对照表。

适用范围

项目内容
Python3.12.12
包版本langchain==1.4.7、langchain-core==1.7.0、pydantic==2.14.1、langgraph==1.2.17
模型第一、二节不涉及模型;第三节使用脚本化模型(按预设顺序回放固定消息),不联网、不需要 API Key
官方来源Tools、Middleware 概览、内置 Middleware、自定义 Middleware,对照官方文档仓库 langchain-ai/docs 提交 b8d4ced(2026-10-16)
实验代码站点仓库 labs/tool-calling-schema/,依赖锁定在 labs/requirements/v1.txt
  • 覆盖:框架如何生成工具 schema、如何校验参数、工具内部抛出异常时 Agent 会怎样。
  • 未覆盖:真实模型是否会按 schema 传参(取决于具体模型);内置的工具重试等中间件。

schema、参数校验和异常,各自决定什么

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

一、模型到底收到了什么 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 一起发给模型,但模型是否遵守取决于模型本身,服务端校验仍然是最后一道防线(见第二节);
  • 最后一行:没有 docstring 的函数在定义时就抛出 ValueError。官方文档说明默认用 docstring 作为工具描述,并且类型提示是必需的,因为它们定义了输入 schema;需要覆盖描述时,可以显式传 description。

二、参数校验到底有多严

下面用 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 模型里收紧,并用类似上面的脚本验证你的收紧是否生效。

三、工具内部抛异常,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 是否有 descriptionparse_docstring=True,或 args_schema 加 Field(description=...)(第一节)
定义工具时报 ValueError: Function must have a docstring if description not provided.没有 docstring,也没有传 description看报错位置的函数定义加 docstring,或者显式传 description(第一节)
工具收到的值和模型传的字符串不一样校验是宽松模式,"3" 被转成了 3在函数里打印收到的参数类型在 args_schema 的模型里收紧类型,并用脚本验证(第二节)
模型传了某个参数,工具却用了默认值参数名拼错,未知字段被静默丢弃对比模型原始的 tool_calls 与函数实际收到的参数重要参数设为必填;监控原始 tool_calls 与函数入参的差异(第二节)
工具一抛异常整个运行就失败了默认不转换异常在 invoke 外层捕获异常,看类型可恢复错误用 ToolException 加 handle_tool_error=True;统一策略用 @wrap_tool_call(第三节)
打开 handle_tool_error 之后,普通 RuntimeError 仍然让运行失败handle_tool_error 只处理 ToolException看工具里抛出的异常类型改抛 ToolException,或使用 @wrap_tool_call(第三节)
运行没有异常,但工具没被执行,最终回复为空模型返回的工具参数不是合法 JSON,进入 invalid_tool_calls打印最后一条消息的 invalid_tool_calls见 OpenAI 兼容模型接入检查
日志里出现 ToolMessage(status=error),Agent 在继续运行模型的参数不满足 schema,或调用了不存在的工具在消息轨迹里统计 status="error" 的比例改进工具描述;对比例设告警,见故障速查

常见失败与处理

  • 改动 schema 的风险:改工具的描述或参数说明,会改变模型的调用行为,且这种变化不会触发任何编译或类型错误。把工具的 JSON schema 输出纳入版本控制(文中第一节的脚本就是一个最小模板),变更时 diff 一下就能看到模型实际收到的差别;
  • 回滚:工具定义放在同一个模块里统一导出,回滚就是回退这次提交;有外部副作用的工具,回滚同时要考虑已经发生的调用是否需要补偿,参见 LangGraph checkpoint 与故障恢复 里的幂等讨论;
  • 局限:已验证框架行为;真实模型对 schema 的遵守程度不在验证范围。上线前请用你实际使用的模型,配合检查脚本做一次端到端检查。

依据与复现

  • Tools:@tool、docstring 作为描述、类型提示必需、args_schema、自定义描述,以及“工具错误处理通过 middleware 配置”;
  • Middleware 概览:中间件的用途与挂载方式;
  • 内置 Middleware:内置的工具重试等中间件(不在本次验证范围);
  • 自定义 Middleware:wrap_tool_call 等钩子;
  • 复现:先执行 labs/setup-envs.sh v1 重建环境,再执行 python3 labs/run.py --lab tool-calling-schema;
  • 输出范围:全部输出来自站点仓库 labs/tool-calling-schema/ 在“适用范围”所列版本下的实际运行;
  • 官方来源核对于 2026-10-26。

继续阅读

最后更新于