AI API Schema设计实战:从踩坑到精通

为什么你的LLM总是返回奇怪的JSON格式?答案可能就在你的Schema里

去年双十一前一周,我们团队正在给一个大客户的电商系统接入AI能力。产品功能其实不复杂:用户问"我的订单到哪了",AI调用物流查询接口返回结果。结果呢?

LLM返回的JSON格式一天变八种:

后端小哥解析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要包含三层信息:

  1. 用途:这个字段用来做什么
  2. 格式:值的格式要求,越具体越好
  3. 示例:给一个典型的值,让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调用怎么处理?

向后兼容原则

加字段永远安全,删字段或改字段类型是灾难。

# 假设原Schema
"status": {
    "enum": ["active", "inactive"]
}

# 如果要加新状态,正确做法是扩大enum
"status": {
    "enum": ["active", "inactive", "suspended", "deleted"]
}

# 错误做法:删掉旧值
"status": {
    "enum": ["active", "suspended"]  # inactve被删了,旧代码全挂
}

破坏性变更处理

如果确实需要做破坏性变更(比如参数改名),我的建议是:

  1. 两步走:先加新字段,同时支持新旧两个版本
  2. 灰度发布:先让10%的流量走新Schema,观察没问题再全量
  3. 长期共存:旧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返回的格式是否稳定。

我通常会准备一套测试用例集

输出解析加固:正则+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会让你在后端解析时抓狂。

核心就三点:

  1. description写详细:用途+格式+示例+禁忌
  2. 类型要明确:enum限制可选值,string说明格式
  3. required要克制:只放真正必填的字段

Schema设计好了,Function Calling就成功了一半。剩下的就是业务逻辑和错误处理了。

如果你在找更多AI API相关的实战技巧,欢迎来 TokenNexus 看看,收录了330+国内外AI平台的对比评测。

发现更多AI API平台

TokenNexus收录330+国内外AI API平台,帮你找到最适合的服务

立即探索