模型为什么会选错工具。
很多时候不是模型突然变笨了,而是工具描述没有把业务边界说清楚。两个工具都叫「查询」,参数一个写 value,另一个写 value2,docstring 只有一句「完成查询」,模型当然很难稳定判断。更麻烦的是,模型即使选对了工具,也可能传入空字符串、错误单位、超出范围的数字,最后把异常推迟到真正访问数据库或第三方 API 的那一刻。
Tool Calling 的质量,先由工具 Schema 决定,再由运行时校验和业务规则共同兜底。本文不讨论 Agent 如何编排多个步骤,而是专门解决一个更基础、也更容易被低估的问题,怎样把 Python 函数表达成模型能够可靠使用的工具契约。
你将看到 LangChain 如何从函数名、类型注解和 docstring 生成工具描述,怎样用 @tool 和 Pydantic args_schema 约束参数,怎样处理枚举、默认值和动态 JSON Schema,以及哪些看起来能用的写法会在生产环境里埋下隐患。
本文解决什么,理解工具 Schema 如何影响模型选工具和传参数,并把类型校验、业务校验与权限边界分开。
本文不展开什么,不展开 Agent 的多轮编排和工具并发调度;本文只关注单个工具的契约设计、参数拒绝和发布前自测。
读完能完成什么,读者可以写出带字段描述和范围约束的工具,检查生成的 JSON Schema,并在不调用模型的情况下验证非法参数会被拒绝。
除「完整的离线脚本」外,本文代码块主要用于展示单个 API 或契约片段,复制时请按片段前的依赖说明补齐定义。
工具 Schema 是给谁看的
工具 Schema 同时服务三个角色。
模型需要通过名称和描述理解工具用途,决定什么时候调用。运行时需要通过参数类型、必填列表和范围约束验证模型生成的参数。维护者需要通过 Schema 理解接口契约,编写测试、日志和权限策略。
#mermaid-svg-UynFIXdfph57iQE3{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-UynFIXdfph57iQE3 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-UynFIXdfph57iQE3 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-UynFIXdfph57iQE3 .error-icon{fill:#552222;}#mermaid-svg-UynFIXdfph57iQE3 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-UynFIXdfph57iQE3 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-UynFIXdfph57iQE3 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-UynFIXdfph57iQE3 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-UynFIXdfph57iQE3 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-UynFIXdfph57iQE3 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-UynFIXdfph57iQE3 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-UynFIXdfph57iQE3 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-UynFIXdfph57iQE3 .marker.cross{stroke:#333333;}#mermaid-svg-UynFIXdfph57iQE3 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-UynFIXdfph57iQE3 p{margin:0;}#mermaid-svg-UynFIXdfph57iQE3 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-UynFIXdfph57iQE3 .cluster-label text{fill:#333;}#mermaid-svg-UynFIXdfph57iQE3 .cluster-label span{color:#333;}#mermaid-svg-UynFIXdfph57iQE3 .cluster-label span p{background-color:transparent;}#mermaid-svg-UynFIXdfph57iQE3 .label text,#mermaid-svg-UynFIXdfph57iQE3 span{fill:#333;color:#333;}#mermaid-svg-UynFIXdfph57iQE3 .node rect,#mermaid-svg-UynFIXdfph57iQE3 .node circle,#mermaid-svg-UynFIXdfph57iQE3 .node ellipse,#mermaid-svg-UynFIXdfph57iQE3 .node polygon,#mermaid-svg-UynFIXdfph57iQE3 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-UynFIXdfph57iQE3 .rough-node .label text,#mermaid-svg-UynFIXdfph57iQE3 .node .label text,#mermaid-svg-UynFIXdfph57iQE3 .image-shape .label,#mermaid-svg-UynFIXdfph57iQE3 .icon-shape .label{text-anchor:middle;}#mermaid-svg-UynFIXdfph57iQE3 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-UynFIXdfph57iQE3 .rough-node .label,#mermaid-svg-UynFIXdfph57iQE3 .node .label,#mermaid-svg-UynFIXdfph57iQE3 .image-shape .label,#mermaid-svg-UynFIXdfph57iQE3 .icon-shape .label{text-align:center;}#mermaid-svg-UynFIXdfph57iQE3 .node.clickable{cursor:pointer;}#mermaid-svg-UynFIXdfph57iQE3 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-UynFIXdfph57iQE3 .arrowheadPath{fill:#333333;}#mermaid-svg-UynFIXdfph57iQE3 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-UynFIXdfph57iQE3 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-UynFIXdfph57iQE3 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-UynFIXdfph57iQE3 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-UynFIXdfph57iQE3 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-UynFIXdfph57iQE3 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-UynFIXdfph57iQE3 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-UynFIXdfph57iQE3 .cluster text{fill:#333;}#mermaid-svg-UynFIXdfph57iQE3 .cluster span{color:#333;}#mermaid-svg-UynFIXdfph57iQE3 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-UynFIXdfph57iQE3 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-UynFIXdfph57iQE3 rect.text{fill:none;stroke-width:0;}#mermaid-svg-UynFIXdfph57iQE3 .icon-shape,#mermaid-svg-UynFIXdfph57iQE3 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-UynFIXdfph57iQE3 .icon-shape p,#mermaid-svg-UynFIXdfph57iQE3 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-UynFIXdfph57iQE3 .icon-shape .label rect,#mermaid-svg-UynFIXdfph57iQE3 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-UynFIXdfph57iQE3 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-UynFIXdfph57iQE3 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-UynFIXdfph57iQE3 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Python 函数
名称
docstring 描述
类型注解
默认值与约束
工具 JSON Schema
模型选择工具
运行时校验参数
测试与文档
如果只关注「函数能不能被 Python 调用」,就会忽略模型需要的语义信息。如果只相信模型会遵守描述,又会忽略运行时必须拒绝非法参数。工具 Schema 不是 Prompt 的替代品,也不是权限系统,它是模型决策和程序校验之间的一份共享契约。
LangChain 1.x 的版本边界
本文按 LangChain 1.x 的 Python API 编写,示例使用 langchain-core 的工具能力和 Pydantic 2。LangChain 的集成包和模型供应商会分别演进,parse_docstring、动态 Schema、严格模式和供应商字段支持都可能存在版本差异。实际项目应固定依赖并在 CI 中执行工具 Schema 快照测试。
安装依赖。
shell
pip install -U "langchain>=1.0,<2" "langchain-openai>=1.0,<2" pydantic python-dotenv
环境变量可以这样配置。
dotenv
OPENAI_API_KEY=replace_with_your_key
MODEL_NAME=gpt-4o-mini
如果使用兼容 OpenAI 协议的网关,模型初始化时应显式指定 base_url 和供应商参数,并确认网关是否真正支持工具调用,而不是只接受普通文本请求。
从普通函数生成工具描述
LangChain 可以通过 convert_to_openai_tool 查看函数最终会被转换成什么样的工具定义。先看一个最小例子。
python
from langchain_core.utils.function_calling import convert_to_openai_tool
def search_products(query: str, limit: int = 5) -> str:
"""搜索可售商品。
Args:
query: 搜索关键词,例如无线耳机或机械键盘。
limit: 最多返回的商品数量,范围是 1 到 20。
"""
return f"搜索{query},最多返回{limit}条"
schema = convert_to_openai_tool(search_products)
print(schema)
输出结构会因版本和供应商适配器略有差异,但核心通常包含 type=function、函数名称、描述和 parameters。参数模式里会记录每个字段的类型、描述和 required 列表。
类型注解来自函数签名。没有 query: str,模型就很难知道参数应该是字符串。没有默认值的参数通常进入 required,有默认值的参数则可以省略。默认值不是让模型永远使用它,而是告诉运行时和模型,这个参数可以缺省。
描述需要写清楚四件事,工具解决什么问题,什么时候应该调用,参数的单位和范围,结果在什么情况下可能为空。不要只写「查询数据」这种几乎没有信息量的句子。
推荐方式,使用 @tool
@tool 能把普通函数包装成 LangChain 工具对象,调用时仍然可以使用 .invoke(),也可以传给 bind_tools 或 Agent。
python
from langchain.tools import tool
@tool
def convert_temperature(value: float, unit: str = "celsius") -> str:
"""把温度转换为摄氏度或华氏度。
Args:
value: 原始温度数值,不要带单位字符串。
unit: 原始单位,只能是 celsius 或 fahrenheit。
"""
if unit == "celsius":
fahrenheit = value * 9 / 5 + 32
return f"{value:.1f} 摄氏度等于 {fahrenheit:.1f} 华氏度"
if unit == "fahrenheit":
celsius = (value - 32) * 5 / 9
return f"{value:.1f} 华氏度等于 {celsius:.1f} 摄氏度"
raise ValueError("unit 必须是 celsius 或 fahrenheit")
print(convert_temperature.name)
print(convert_temperature.description)
print(convert_temperature.args_schema.model_json_schema())
默认情况下,函数 docstring 会成为工具描述。类型注解会成为参数类型。参数有默认值时,LangChain 会把它视为可选参数。函数名通常直接作为工具名,建议保持简短、稳定并使用 snake_case。很多供应商对空格、中文标点和特殊字符的支持并不一致,跨供应商场景尤其应该遵守 ASCII 函数命名习惯。
让 docstring 真正描述参数
如果只把整个 docstring 作为工具总描述,模型能知道工具做什么,但不一定能理解每个参数的边界。使用 parse_docstring=True,可以把 Google 风格的 Args 段落解析成字段描述。
python
from langchain.tools import tool
@tool(parse_docstring=True)
def search_orders(customer_id: str, status: str = "all") -> str:
"""查询当前用户有权访问的订单。
Args:
customer_id: 当前登录用户的客户编号,不接受其他用户的编号。
status: 订单状态,可选 all、paid、shipped 或 canceled。
"""
return f"查询客户 {customer_id} 的 {status} 订单"
解析 docstring 时,参数名称必须和函数签名一致,且每个被描述的参数都应该有类型注解。格式不合法可能在应用启动阶段直接抛异常。启动时失败比线上第一次被模型调用时失败更容易发现,所以建议在测试中导入所有工具并访问 args_schema。
工具描述和字段描述的优先级取决于具体 API 调用。最稳妥的做法是让 @tool 参数、函数签名和 docstring 保持一致,不要在多个地方写互相矛盾的说明。
复杂参数用 Pydantic 表达
当参数有枚举、范围、长度或跨字段规则时,建议单独定义 Pydantic 模型,再通过 args_schema 绑定到工具。
python
from typing import Literal
from pydantic import BaseModel, Field
from langchain.tools import tool
class ProductSearchInput(BaseModel):
"""商品搜索参数契约。"""
query: str = Field(
min_length=1,
max_length=80,
description="商品关键词,例如无线耳机或 27 英寸显示器。",
)
category: Literal["all", "computer", "phone", "audio"] = Field(
default="all",
description="商品类别,只能选择 all、computer、phone 或 audio。",
)
max_price: float | None = Field(
default=None,
gt=0,
description="最高价格,单位为人民币元;不限制价格时省略。",
)
limit: int = Field(
default=5,
ge=1,
le=20,
description="最多返回多少条结果,范围是 1 到 20。",
)
@tool(args_schema=ProductSearchInput)
def search_products(
query: str,
category: Literal["all", "computer", "phone", "audio"] = "all",
max_price: float | None = None,
limit: int = 5,
) -> str:
"""搜索商品并返回精简结果。"""
filters = [f"关键词={query}", f"类别={category}", f"数量={limit}"]
if max_price is not None:
filters.append(f"最高价={max_price}")
return "已执行商品搜索," + ",".join(filters)
print(search_products.args_schema.model_json_schema())
print(search_products.invoke({"query": "无线耳机", "category": "audio"}))
Field 的描述会直接影响模型对字段的理解。Literal 把字符串限制为固定选项,ge、le、gt 和 lt 负责数值范围,min_length 和 max_length 负责字符串长度。Pydantic 的校验发生在工具执行前,因此非法参数不会直接进入业务函数。
这里有一个容易忽略的点,函数签名和 args_schema 应保持语义一致。Pydantic 模型是对外契约,函数签名是内部实现入口。如果两者字段不同,后续维护者会很难判断哪个才是事实来源。对于复杂工具,可以让函数直接接收一个 Pydantic 对象,但要按照 LangChain 当前版本的工具调用方式验证后再决定是否采用。
JSON Schema 适合动态配置
Pydantic 适合静态、可维护的参数契约。某些系统的字段来自数据库配置或租户设置,这时可以在运行时生成 JSON Schema。
python
from langchain.tools import tool
dynamic_schema = {
"type": "object",
"properties": {
"region": {
"type": "string",
"description": "配送区域,只能填写华东、华南或华北。",
"enum": ["华东", "华南", "华北"],
},
"weight": {
"type": "number",
"description": "包裹重量,单位千克,必须大于 0。",
"exclusiveMinimum": 0,
},
},
"required": ["region", "weight"],
}
@tool(args_schema=dynamic_schema)
def estimate_shipping(region: str, weight: float) -> str:
"""估算配送费用。"""
return f"{region}区域,{weight:.1f}千克,演示运费 18 元"
print(estimate_shipping.invoke({"region": "华东", "weight": 2.5}))
动态 Schema 的灵活性很高,但也更容易出现配置错误。生产系统应校验 Schema 本身,禁止租户配置任意函数名、文件路径或 SQL 片段。Schema 只描述参数形状,不能替代后端权限和业务规则。
一个 Schema 到执行的关系图
#mermaid-svg-FOjW7Nmw0YYjDS6x{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-FOjW7Nmw0YYjDS6x .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-FOjW7Nmw0YYjDS6x .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-FOjW7Nmw0YYjDS6x .error-icon{fill:#552222;}#mermaid-svg-FOjW7Nmw0YYjDS6x .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-FOjW7Nmw0YYjDS6x .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-FOjW7Nmw0YYjDS6x .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-FOjW7Nmw0YYjDS6x .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-FOjW7Nmw0YYjDS6x .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-FOjW7Nmw0YYjDS6x .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-FOjW7Nmw0YYjDS6x .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-FOjW7Nmw0YYjDS6x .marker{fill:#333333;stroke:#333333;}#mermaid-svg-FOjW7Nmw0YYjDS6x .marker.cross{stroke:#333333;}#mermaid-svg-FOjW7Nmw0YYjDS6x svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-FOjW7Nmw0YYjDS6x p{margin:0;}#mermaid-svg-FOjW7Nmw0YYjDS6x .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-FOjW7Nmw0YYjDS6x .cluster-label text{fill:#333;}#mermaid-svg-FOjW7Nmw0YYjDS6x .cluster-label span{color:#333;}#mermaid-svg-FOjW7Nmw0YYjDS6x .cluster-label span p{background-color:transparent;}#mermaid-svg-FOjW7Nmw0YYjDS6x .label text,#mermaid-svg-FOjW7Nmw0YYjDS6x span{fill:#333;color:#333;}#mermaid-svg-FOjW7Nmw0YYjDS6x .node rect,#mermaid-svg-FOjW7Nmw0YYjDS6x .node circle,#mermaid-svg-FOjW7Nmw0YYjDS6x .node ellipse,#mermaid-svg-FOjW7Nmw0YYjDS6x .node polygon,#mermaid-svg-FOjW7Nmw0YYjDS6x .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-FOjW7Nmw0YYjDS6x .rough-node .label text,#mermaid-svg-FOjW7Nmw0YYjDS6x .node .label text,#mermaid-svg-FOjW7Nmw0YYjDS6x .image-shape .label,#mermaid-svg-FOjW7Nmw0YYjDS6x .icon-shape .label{text-anchor:middle;}#mermaid-svg-FOjW7Nmw0YYjDS6x .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-FOjW7Nmw0YYjDS6x .rough-node .label,#mermaid-svg-FOjW7Nmw0YYjDS6x .node .label,#mermaid-svg-FOjW7Nmw0YYjDS6x .image-shape .label,#mermaid-svg-FOjW7Nmw0YYjDS6x .icon-shape .label{text-align:center;}#mermaid-svg-FOjW7Nmw0YYjDS6x .node.clickable{cursor:pointer;}#mermaid-svg-FOjW7Nmw0YYjDS6x .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-FOjW7Nmw0YYjDS6x .arrowheadPath{fill:#333333;}#mermaid-svg-FOjW7Nmw0YYjDS6x .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-FOjW7Nmw0YYjDS6x .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-FOjW7Nmw0YYjDS6x .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FOjW7Nmw0YYjDS6x .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-FOjW7Nmw0YYjDS6x .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FOjW7Nmw0YYjDS6x .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-FOjW7Nmw0YYjDS6x .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-FOjW7Nmw0YYjDS6x .cluster text{fill:#333;}#mermaid-svg-FOjW7Nmw0YYjDS6x .cluster span{color:#333;}#mermaid-svg-FOjW7Nmw0YYjDS6x div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-FOjW7Nmw0YYjDS6x .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-FOjW7Nmw0YYjDS6x rect.text{fill:none;stroke-width:0;}#mermaid-svg-FOjW7Nmw0YYjDS6x .icon-shape,#mermaid-svg-FOjW7Nmw0YYjDS6x .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FOjW7Nmw0YYjDS6x .icon-shape p,#mermaid-svg-FOjW7Nmw0YYjDS6x .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-FOjW7Nmw0YYjDS6x .icon-shape .label rect,#mermaid-svg-FOjW7Nmw0YYjDS6x .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FOjW7Nmw0YYjDS6x .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-FOjW7Nmw0YYjDS6x .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-FOjW7Nmw0YYjDS6x :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 通过
失败
函数名与参数
LangChain 生成 Schema
docstring
Pydantic Field 或 JSON Schema
bind_tools 发送给模型
模型生成工具名与参数
参数校验
执行业务函数
返回可重试的工具错误
裁剪结果并记录审计
模型输出的参数不是可信输入。即使 JSON 结构合法,仍可能违反业务规则,例如用户只能访问自己的订单,或者退款金额不能超过订单余额。建议把校验拆成三层。
第一层是 Schema 校验,检查类型、必填项和范围。第二层是业务校验,检查资源归属、状态机和配额。第三层是执行前安全检查,确认是否需要人工审批、是否重复提交以及当前凭证是否有效。
保留参数名 config 和 runtime 的限制
LangChain 内部会使用一些保留参数名传递运行时配置和上下文。不要把业务字段命名为 config 或 runtime,也不要假设它们会像普通用户参数一样出现在模型生成的 JSON 中。需要访问运行时上下文时,应按照当前 LangChain 版本的 ToolRuntime 机制声明,而不是自己伪造一个同名字段。
python
from langchain.tools import tool
@tool
def lookup_customer(customer_id: str) -> str:
"""查询当前会话允许访问的客户摘要。"""
return f"客户 {customer_id} 的演示摘要"
这个例子故意没有把用户身份作为模型参数。身份应该来自已认证的请求上下文,由服务端注入和校验。让模型自行生成 user_id 或 role,会把权限边界交给一个不可信的文本生成器。
常见错误与修复方式
只写一个模糊的 description
「搜索数据」无法说明数据来源、查询条件、返回数量和失败情况。改成「在当前用户可见的商品索引中按关键词搜索,最多返回 20 条摘要,找不到时返回空列表」会更稳定。
类型注解和 docstring 不一致
函数把 limit 写成 int,docstring 却说可以传「少量」;或者代码接受 celsius,描述里又写成摄氏。模型会在冲突中随机选择。把字段说明、默认值和实际逻辑放在同一处测试。
用字符串代替枚举
如果状态只有几个固定值,不要只写 status: str。使用 Literal 或 Enum,可以减少拼写变体,并让供应商收到明确的枚举 Schema。
把默认值当成业务默认策略
limit=5 只能表示缺省时使用 5,不代表所有用户都允许读取 5 条。权限、配额和计费规则必须在服务端重新判断。
依赖模型完成所有校验
模型可能生成合法 JSON,但参数依然不符合业务约束。工具函数收到参数后应再次校验,尤其是金额、资源 ID、文件路径和 SQL 条件。
动态 Schema 没有版本
如果 Schema 由数据库配置生成,每次配置变化都可能改变模型行为。建议给 Schema 分配版本号,在 Trace、缓存键和回归测试中记录版本,并支持灰度和回滚。
生产注意事项
工具 Schema 属于可发布的接口,应像 API 一样进行变更管理。新增可选字段通常风险较低,修改字段含义、删除工具或改变枚举值则可能导致模型行为回归。为关键工具保存 Schema 快照,在 CI 中比较差异,并用代表性问题集运行回归测试。
工具描述不宜写成一篇产品说明书。模型上下文有限,过长的描述会增加 token 成本,反而降低重点信息的权重。一个工具最好只做一件事,复杂流程拆成清晰的阶段,并让每个阶段的输入输出可观察。
执行结果应尽量短小稳定。错误返回要告诉模型是否可以更换参数、询问用户补充信息或直接停止。不要把 Python 堆栈、数据库连接信息和内部路径暴露给模型或用户。
对于删除、支付、发邮件、修改权限等高风险工具,Schema 只负责描述参数,不能直接授权执行。应用应在工具函数前增加权限判断和人工确认;对于可能重试的写操作,使用幂等键和业务流水号;对于读取工具,实施最小权限和字段脱敏。
发布前的自测代码
可以在不调用模型的情况下验证工具契约。
下面是一个完整的离线脚本。它不需要模型密钥,复制后安装 langchain 和 pydantic 即可运行;后面的片段如果引用 search_products,都默认承接这个脚本中的定义。
shell
pip install "langchain>=1.0,<2" "pydantic>=2,<3"
python
from typing import Literal
from langchain.tools import tool
from pydantic import BaseModel, Field, ValidationError
class ProductSearchInput(BaseModel):
"""商品搜索工具的输入契约。"""
query: str = Field(min_length=1, max_length=80)
category: Literal["all", "computer", "phone", "audio"] = "all"
limit: int = Field(default=5, ge=1, le=20)
@tool(args_schema=ProductSearchInput)
def search_products(
query: str,
category: Literal["all", "computer", "phone", "audio"] = "all",
limit: int = 5,
) -> str:
"""在演示商品索引中搜索并返回精简结果。"""
return f"关键词={query},类别={category},最多返回{limit}条演示结果"
def check_tool_contract() -> None:
"""验证工具 Schema 会拒绝空关键词和越界数量。"""
search_products.args_schema.model_validate(
{"query": "键盘", "category": "computer", "limit": 3}
)
try:
search_products.args_schema.model_validate({"query": "", "limit": 99})
except ValidationError as exc:
print("非法参数已被拒绝", exc.errors()[0]["loc"])
else:
raise AssertionError("应该拒绝空关键词和超出范围的数量")
if __name__ == "__main__":
check_tool_contract()
print(search_products.invoke({"query": "无线耳机", "category": "audio"}))
python
from pydantic import ValidationError
def check_tool_contract() -> None:
schema = search_products.args_schema
schema.model_validate({"query": "键盘", "limit": 3})
try:
schema.model_validate({"query": "", "limit": 99})
except ValidationError as exc:
print("非法参数已被拒绝", exc.errors()[0]["loc"])
else:
raise AssertionError("应该拒绝空关键词和超出范围的数量")
if __name__ == "__main__":
check_tool_contract()
这类测试不依赖模型,执行快、结果稳定,适合放进每次提交的 CI。再补充少量模型回归用例,检查模型是否能从真实问题中选择正确工具和参数,就能把「描述写得好不好」从感觉变成证据。
官方文档
- LangChain Tools 官方文档
- LangChain Tool Calling 概念
- convert_to_openai_tool API 参考
- Pydantic 字段与校验
- JSON Schema 官方规范
工具调用接口仍然受模型供应商和集成包版本影响。本文按 LangChain 1.x 的公开 API 组织示例,实际发布前请固定 langchain、集成包和 Pydantic 版本,并用目标模型真实测试 Schema、枚举、默认值和错误返回行为。