去年双十一前一周,我们团队正在给一个大客户的电商系统接入AI能力。产品功能其实不复杂:用户问"我的订单到哪了",AI调用物流查询接口返回结果。结果呢?
LLM返回的JSON格式一天变八种:
- 有时候是
{"order_id": "123"} - 有时候是
{"订单号": "123"} - 有时候是
{"id": "123"} - 最离谱的一次,它返回了
"订单号是123"一段纯文本
后端小哥解析JSON报错,前端拿不到数据,用户看到的是空白页面。那三天我们前后端来回撕了无数个会议,最后发现问题根本不在代码逻辑,而是Schema定义太糙了。
今天就把Schema设计里那些容易踩的坑、能让LLM乖乖听话的技巧,全部整理出来。
JSON Schema核心关键字:让LLM精确理解你的意图
Function Calling的本质是让LLM理解"我需要什么参数",而JSON Schema就是你和LLM之间的契约文档。下面这几个关键字,我踩了无数坑才总结出最佳实践。
type:类型声明不只是给自己看的
很多人以为type只是校验用的,其实LLM会参考这个字段来决定生成什么格式。我见过有人把日期字段定义为 "type": "string",结果LLM有时候返回"2024年1月15日",有时候返回"01/15/2024",有时候返回"2024-01-15"。
正确的做法是:在description里明确说明格式期望
# 错误示范
"order_date": {
"type": "string"
}
# 正确示范
"order_date": {
"type": "string",
"description": "订单日期,格式为YYYY-MM-DD,例如2024-06-15"
}
description:这是最重要的字段,没有之一
LLM不像程序员会读Schema文档,它主要靠description理解这个字段是干什么的、期望什么值。description写得好不好,直接决定调用准确率。
我的经验是description要包含三层信息:
- 用途:这个字段用来做什么
- 格式:值的格式要求,越具体越好
- 示例:给一个典型的值,让LLM有参照
"city": {
"type": "string",
"description": "城市名称,使用中文全称,如'北京'、'上海'、'深圳'。不要使用简称如'沪'、'帝都'等。"
}
"email": {
"type": "string",
"description": "用户邮箱地址,格式为[email protected],例如[email protected]"
}
如果你发现LLM总是理解错某个字段,试试在description里加一句"不要...",明确告诉它常见错误。我用这个方法把参数误解析率从15%降到了2%以下。
enum:限制可选值,减少随机性
当一个字段有固定的可选值时,一定要用enum。这不只是数据类型提示,更是告诉LLM"只能从这些值里选"。
# 订单状态枚举
"status": {
"type": "string",
"enum": ["pending", "paid", "shipped", "delivered", "cancelled"],
"description": "订单状态,只能是枚举值之一"
}
# 天气单位
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "温度单位,摄氏度或华氏度"
}
required:明确必填项,但别贪多
required数组里的字段必须全部传递,一个都不能少。但我见过有人把optional字段也塞进required,导致LLM在信息不足时硬塞一个占位值。
原则很简单:只有真正必须知道的参数才放required。
"search_products": {
"type": "object",
"properties": {
"keyword": {
"type": "string",
"description": "搜索关键词,至少2个字符"
},
"category": {
"type": "string",
"description": "商品分类,可空"
},
"sort_by": {
"type": "string",
"enum": ["price_asc", "price_desc", "sales", "newest"],
"description": "排序方式"
}
},
"required": ["keyword"] # keyword是必须的,其他可选
}
properties:嵌套对象的组织方式
对于复杂参数,嵌套对象能组织出清晰的层次。但嵌套太深会导致LLM理解困难。我建议最多嵌套两层。
"filter": {
"type": "object",
"properties": {
"price_range": {
"type": "object",
"properties": {
"min": {"type": "number", "description": "最低价格"},
"max": {"type": "number", "description": "最高价格"}
}
},
"tags": {
"type": "array",
"items": {"type": "string"},
"description": "商品标签数组,如['电子产品', '新品']"
}
}
}
三大平台Schema格式对比:OpenAI vs Anthropic vs Gemini
每个平台的Schema格式略有差异,如果你在做跨平台适配或者需要选型,这部分会很有用。
| 对比维度 | OpenAI | Anthropic Claude | Google Gemini |
|---|---|---|---|
| 参数名 | parameters |
input_schema |
parameters |
| Schema格式 | JSON Schema | JSON Schema | JSON Schema |
| 结构特点 | 嵌套在function对象里 | 平铺在tool对象里 | 嵌套在function对象里 |
| 强制类型校验 | 支持structured outputs | 支持 | 支持 |
| 并行调用 | 原生支持 | 原生支持 | 需配置 |
OpenAI格式(最常见)
{
"type": "function",
"function": {
"name": "get_weather",
"description": "查询指定城市的天气信息",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市名称,中文全称"
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"]
}
},
"required": ["city"]
}
}
}
Anthropic Claude格式
{
"name": "get_weather",
"description": "查询指定城市的天气信息",
"input_schema": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市名称,中文全称"
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"]
}
},
"required": ["city"]
}
}
Google Gemini格式
{
"name": "get_weather",
"description": "查询指定城市的天气信息",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市名称,中文全称"
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"]
}
},
"required": ["city"]
}
}
格式差异很小,主要是嵌套层级不同。如果你要做统一抽象层,建议写一个转换函数,根据平台自动适配。
三大常见场景Schema设计案例
场景1:天气查询API
这是最经典的入门场景。天气API的特点是参数简单、返回值结构固定。关键是让LLM知道城市名必须是标准中文全称。
weather_schema = {
"name": "get_weather",
"description": "查询指定城市的实时天气和预报信息。适用于用户问'天气怎么样'、'要不要带伞'等场景。",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市名称,必须使用中文全称,如'北京市'、'上海市'、'杭州市'。注意:不要使用'帝都'、'魔都'等简称,不要使用英文名如'Beijing'。"
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "温度单位,celsius表示摄氏度,fahrenheit表示华氏度。默认使用摄氏度。"
},
"include_forecast": {
"type": "boolean",
"description": "是否包含3天预报,默认false"
}
},
"required": ["city"]
}
}
早期版本我没加城市名的限制说明,结果用户问"北京天气",LLM有时候返回"Beijing",第三方天气API不认中文名就直接报错了。加上description里的"不要使用英文名"之后,这类错误归零。
场景2:数据库搜索API
数据库搜索比天气查询复杂,因为需要考虑搜索条件、排序、分页等多个维度。Schema设计时要把必填和可选分开。
database_search_schema = {
"name": "search_database",
"description": "在数据库中搜索订单或商品信息。适用于查询订单状态、商品详情、用户信息等场景。",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "object",
"description": "搜索条件对象",
"properties": {
"type": {
"type": "string",
"enum": ["order", "product", "user"],
"description": "搜索类型:order订单、product商品、user用户"
},
"order_id": {
"type": "string",
"description": "订单号,格式为纯数字或字母组合,如'ORD20240615001'"
},
"customer_name": {
"type": "string",
"description": "客户姓名,中文或英文均可"
},
"phone": {
"type": "string",
"description": "手机号,11位数字,格式为13812345678"
},
"date_range": {
"type": "object",
"description": "日期范围筛选",
"properties": {
"start": {"type": "string", "description": "开始日期 YYYY-MM-DD"},
"end": {"type": "string", "description": "结束日期 YYYY-MM-DD"}
}
},
"status": {
"type": "string",
"enum": ["pending", "processing", "completed", "cancelled"],
"description": "订单状态"
}
}
},
"pagination": {
"type": "object",
"description": "分页参数",
"properties": {
"page": {
"type": "integer",
"description": "页码,从1开始",
"default": 1
},
"page_size": {
"type": "integer",
"description": "每页条数,最大50",
"default": 10
}
}
},
"sort": {
"type": "object",
"description": "排序参数",
"properties": {
"field": {
"type": "string",
"enum": ["created_at", "updated_at", "amount"],
"description": "排序字段"
},
"order": {
"type": "string",
"enum": ["asc", "desc"],
"description": "升序或降序"
}
}
}
},
"required": ["query"]
}
}
场景3:文件上传/下载API
文件操作涉及路径、格式、大小等多个参数。要特别注意的是路径格式必须明确,避免LLM生成不存在的路径。
file_operation_schema = {
"name": "file_operation",
"description": "执行文件上传或下载操作。注意:此接口不会真正操作文件,只会验证路径和参数是否正确。",
"parameters": {
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": ["upload", "download", "list", "delete"],
"description": "操作类型:upload上传、download下载、list列出文件、delete删除"
},
"file_path": {
"type": "string",
"description": "文件路径,必须是绝对路径。格式示例:/data/uploads/2024/06/image.jpg 或 C:\\Users\\admin\\Documents\\report.pdf"
},
"file_type": {
"type": "string",
"enum": ["image", "document", "video", "audio", "archive", "other"],
"description": "文件类型分类,用于权限校验"
},
"max_size_mb": {
"type": "integer",
"description": "文件大小上限,单位MB,超出则返回错误",
"default": 10
},
"overwrite": {
"type": "boolean",
"description": "当文件存在时是否覆盖,默认false"
}
},
"required": ["operation", "file_path"]
}
}
Schema版本管理:向后兼容是生死线
Schema不是写完就完事的,随着业务发展你会不断迭代。问题是:旧版Schema已经在用的LLM调用怎么处理?
向后兼容原则
加字段永远安全,删字段或改字段类型是灾难。
- 可以加:添加新的可选字段(default要有值)
- 可以改:扩大enum的可选值范围
- 不可以:删除必填字段、缩小enum范围、改类型
# 假设原Schema
"status": {
"enum": ["active", "inactive"]
}
# 如果要加新状态,正确做法是扩大enum
"status": {
"enum": ["active", "inactive", "suspended", "deleted"]
}
# 错误做法:删掉旧值
"status": {
"enum": ["active", "suspended"] # inactve被删了,旧代码全挂
}
破坏性变更处理
如果确实需要做破坏性变更(比如参数改名),我的建议是:
- 两步走:先加新字段,同时支持新旧两个版本
- 灰度发布:先让10%的流量走新Schema,观察没问题再全量
- 长期共存:旧Schema至少保留3个月,再通知下游废弃
# 版本管理示例
TOOLS_V1 = { # 旧版本,兼容中
"name": "search",
"parameters": {
"type": "object",
"properties": {
"keyword": {"type": "string"} # 旧参数名
}
}
}
TOOLS_V2 = { # 新版本
"name": "search",
"description": "[v2] 搜索功能,keyword和query都支持,推荐使用query",
"parameters": {
"type": "object",
"properties": {
"keyword": {"type": "string", "description": "[兼容]keyword参数仍可用,建议迁移到query"},
"query": {"type": "string", "description": "搜索关键词,V2新增参数,推荐使用"} # 新增参数
}
}
}
Schema调试技巧:让LLM输出稳定可解析
Sandbox测试:先跑通再上生产
每次Schema改完,别急着上生产。先在测试环境里跑几十个不同类型的case,观察LLM返回的格式是否稳定。
我通常会准备一套测试用例集:
- 正常输入:参数齐全、格式正确
- 缺失必填:故意不传某个required字段
- 边界值:空字符串、特殊字符、超长输入
- 语义模糊:用户表达不清时的兜底处理
输出解析加固:正则+JSON5双保险
LLM有时候会返回带markdown代码块的JSON,或者混进一些解释性文字。我的做法是双重解析:
import re
import json
def parse_llm_json(response_text):
"""双重解析LLM输出,确保拿到干净的JSON"""
# 第一步:提取markdown代码块(如果有)
json_text = response_text.strip()
if json_text.startswith("```"):
match = re.search(r'```(?:json)?\n?(.*?)```', json_text, re.DOTALL)
if match:
json_text = match.group(1)
# 第二步:去掉可能的解释性前缀(如"好的,这是JSON:{...}")
# 查找第一个 { 的位置
first_brace = json_text.find('{')
if first_brace > 0:
json_text = json_text[first_brace:]
# 第三步:去掉末尾多余的文字(如"}请注意...")
# 找到最后一个 } 的位置
last_brace = json_text.rfind('}')
if last_brace >= 0:
json_text = json_text[:last_brace + 1]
# 第四步:尝试解析,失败则返回None
try:
return json.loads(json_text)
except json.JSONDecodeError:
# 如果标准JSON解析失败,尝试JSON5容错解析
try:
import json5
return json5.loads(json_text)
except:
return None
强制结构化输出:structured outputs
OpenAI在gpt-4o之后支持structured outputs,可以严格按Schema输出,不再有随机性。如果你的场景对格式要求严格,这是目前最靠谱的方案。
from openai import OpenAI
client = OpenAI()
# 启用structured outputs
response = client.chat.completions.create(
model="gpt-4o-2024-08-06",
messages=[{"role": "user", "content": "提取用户信息"}],
tools=[{
"type": "function",
"function": {
"name": "extract_user",
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
"email": {"type": "string", "format": "email"}
},
"required": ["name", "email"],
"additionalProperties": False # 不允许额外字段
}
}
}],
# 关键参数:严格按Schema输出
response_format={"type": "json_schema", "json_schema": {
"name": "extract_user",
"strict": True,
"schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
"email": {"type": "string"}
},
"required": ["name", "email"],
"additionalProperties": False
}
}}
)
Python Pydantic集成:Schema即类型定义
Pydantic是Python最好的数据验证库,配合Function Calling简直是绝配。我现在的做法是:用Pydantic定义Schema,然后自动转换成各平台的格式。
from pydantic import BaseModel, Field, field_validator
from typing import Optional, List, Literal
from enum import Enum
# 定义Enum
class OrderStatus(str, Enum):
PENDING = "pending"
PAID = "paid"
SHIPPED = "shipped"
DELIVERED = "delivered"
CANCELLED = "cancelled"
# 定义请求模型
class OrderQuery(BaseModel):
"""订单查询参数"""
order_id: Optional[str] = Field(None, description="订单号,格式如ORD20240615001")
customer_name: Optional[str] = Field(None, description="客户姓名")
phone: Optional[str] = Field(None, description="手机号,11位数字")
@field_validator('phone')
@classmethod
def validate_phone(cls, v):
if v and (len(v) != 11 or not v.isdigit()):
raise ValueError('手机号必须是11位数字')
return v
# 定义响应模型
class Order(BaseModel):
"""订单信息"""
order_id: str
status: OrderStatus
amount: float = Field(..., gt=0, description="订单金额,必须大于0")
items: List[str] = Field(..., min_length=1, description="商品列表,至少1个")
# 生成OpenAI格式的Schema
def pydantic_to_openai_schema(model_class):
"""将Pydantic模型转换为OpenAI function格式"""
schema = model_class.model_json_schema()
# 生成描述
description = schema.get('description', model_class.__name__)
return {
"type": "function",
"function": {
"name": model_class.__name__.lower(),
"description": description,
"parameters": {
"type": "object",
"properties": schema.get('properties', {}),
"required": schema.get('required', []),
"additionalProperties": False
}
}
}
# 验证函数:自动校验LLM返回的参数
def validate_and_parse(model_class, data):
"""验证LLM返回的数据并解析为Pydantic模型"""
try:
return model_class(**data)
except Exception as e:
raise ValueError(f"参数校验失败: {e}")
# 使用示例
def handle_order_query(params: dict):
validated = validate_and_parse(OrderQuery, params)
# validated.phone 已经是校验过的格式
return validated
这样做的好处是:Schema定义一次,两种用途。既可以作为Function Calling的描述给LLM看,又可以作为运行时参数校验的规则。一举两得。
利用description引导LLM精确理解参数语义
这是Schema设计的高级技巧。description不只是描述字段,还可以引导LLM的推理过程。
让LLM知道"什么时候该用这个参数"
# 普通写法
"temperature": {
"type": "number",
"description": "温度值"
}
# 高级写法:加入使用场景
"temperature": {
"type": "number",
"description": """温度值,单位摄氏度。
- 当用户说'今天热不热'时,设置25-35
- 当用户说'要不要穿外套'时,设置10-25
- 当用户说'冷不冷'时,设置0-15
如果用户没有明确提及温度感受,设置null让系统使用默认值。"""
}
让LLM知道"常见的错误值"
"category": {
"type": "string",
"description": """商品分类,必须是以下值之一:
- electronics: 电子产品(手机、电脑、耳机等)
- clothing: 服装鞋帽
- food: 食品生鲜
- books: 图书音像
【常见错误】
- '数码'不是有效值,应该用'electronics'
- '衣服'不是有效值,应该用'clothing'
- '电器'不是有效值,应该用'electronics'
"""
}
description不是越长越好。太长会让LLM忽略重点,或者超出上下文窗口限制。我的经验是每个字段的description控制在100字以内,复杂的用列表形式组织。
写在最后
Schema设计是Function Calling的基石。好的Schema能让LLM精确理解你的意图,坏的Schema会让你在后端解析时抓狂。
核心就三点:
- description写详细:用途+格式+示例+禁忌
- 类型要明确:enum限制可选值,string说明格式
- required要克制:只放真正必填的字段
Schema设计好了,Function Calling就成功了一半。剩下的就是业务逻辑和错误处理了。
如果你在找更多AI API相关的实战技巧,欢迎来 TokenNexus 看看,收录了330+国内外AI平台的对比评测。