三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

LLM结构化输出:JSON Schema约束与Tool Calling原理对比与应用指南

LLM结构化输出:JSON Schema约束与Tool Calling原理对比与应用指南

1. 项目概述:从“自由发挥”到“精准执行”

如果你最近在折腾大语言模型(LLM)的应用开发,比如想做一个能自动整理会议纪要、生成格式化报告,或者构建一个能稳定调用外部API的智能助手,那你肯定遇到过这个让人头疼的问题:你让模型“返回一个JSON对象,包含姓名、年龄和职业”,它可能给你来一段散文,或者JSON的键名随心所欲地变化,甚至偶尔还会在JSON外面包裹一段解释性的文字。这种输出的不确定性,是LLM从“聊天玩具”走向“生产级工具”的最大障碍之一。

结构化输出,就是解决这个问题的钥匙。它本质上是一种“约束”,告诉模型:“请严格按照我规定的格式来回答。”目前,业界主要有两大流派来实现这种约束:JSON Schema约束Tool Calling。乍一看,它们好像都在做同一件事——让输出变规矩。但深入其原理和应用场景,你会发现它们的设计哲学和适用领域截然不同。JSON Schema像是给模型戴上了一副精确的“答题卡”,要求它把答案填在指定的格子里;而Tool Calling则是赋予了模型“手脚”,让它能根据你的指令,去执行一个个定义好的“动作”,并返回动作的结果。

理解这两者的区别,不仅关乎你选择哪种技术方案,更决定了你设计的AI应用是更偏向于“数据提取与格式化”,还是“任务规划与工具执行”。接下来,我们就抛开那些笼统的概念,直接深入到技术实现层,拆解它们的工作原理、背后的生成逻辑,以及在实际项目中如何选择和避坑。

2. 核心原理深度拆解:两种约束的本质差异

要理解JSON Schema和Tool Calling,不能只看它们表面都能输出结构化的内容,而必须深入到LLM生成文本的底层机制和它们与模型交互的方式。

2.1 JSON Schema约束:在解码阶段戴上“紧箍咒”

JSON Schema约束的核心思想是在文本生成(解码)的过程中,实时地限制下一个可能出现的token(词元),确保最终生成的字符串完全符合预定义的JSON结构。

2.1.1 工作原理与流程

这个过程可以类比为在一个迷宫中行走,JSON Schema就是那张唯一正确的地图。

  1. 定义Schema:首先,开发者需要定义一个详细的JSON Schema。这个Schema不仅仅规定了要有哪些字段(如name,age),还包括字段的类型(string,integer)、是否必需、枚举值、嵌套对象的结构等。例如,一个简单的用户信息Schema:

    { "type": "object", "properties": { "name": { "type": "string" }, "age": { "type": "integer", "minimum": 0 }, "hobbies": { "type": "array", "items": { "type": "string" } } }, "required": ["name", "age"] }
  2. 提示词工程:在给模型的系统提示(System Prompt)或用户提示(User Prompt)中,会明确指令模型按照给定的JSON Schema输出,通常会将Schema以文本形式插入。例如:“请根据以下JSON Schema格式输出用户信息:{schema_text}”。

  3. 约束解码:这是最关键的环节。当模型开始逐词生成输出时:

    • 初始阶段:模型必须生成一个左花括号{,因为Schema定义的是对象。
    • 键名生成:生成完{后,模型接下来应该生成一个键名。约束解码器会根据Schema的properties列表,将当前可生成的token集合限制在"name""age""hobbies"这几个键名的字符串开头(包括引号)。模型无法生成"address"或其他未定义的键。
    • 键值生成:生成完"name":之后,约束解码器知道接下来需要一个字符串值,因此可能会限制生成的token,例如避免过早生成结束引号或控制字符串长度(虽然精细的长度控制较难)。
    • 类型控制:当生成"age":之后,约束解码器会强制接下来的token必须是一个数字字符(0-9)或负号,引导模型生成一个整数。如果模型试图生成字母,会被约束机制排除。
    • 结构导航:在生成数组hobbies时,约束解码器需要管理方括号[]的生成、数组元素间的逗号分隔,以及最终方括号的闭合。

    整个过程中,约束解码算法(如基于上下文无关文法的解码、或外挂的验证器引导的重采样)像一个严格的语法检查器,在每一个生成步骤,都动态计算当前所有有效的后续token集合(符合Schema语法的token),并强制模型从这个有效集合中采样。如果模型产出了一个无效token,高级的实现会通过“重采样”或“回溯”机制进行纠正。

2.1.2 技术实现要点

  • 库支持OpenAI的API在response_format参数中直接支持{ “type”: “json_schema” }Llama.cppvLLM等推理框架也通过类似grammar的功能支持。
  • 本质:这是一种输出格式的强制规范。模型仍然在进行“文本补全”,只不过每一步的选择空间被大幅收窄了。
  • 优势:输出格式极其稳定、精确。非常适合数据提取(从文本中抽取出结构化的实体)、格式化生成(生成固定格式的邮件、报告、代码片段)等场景。

2.2 Tool Calling:基于函数描述的推理与调度

Tool Calling 的原理与 JSON Schema 约束有根本性不同。它并非在解码时进行字符级的强制约束,而是利用LLM的理解与推理能力,让模型“意识”到它可以调用某些工具,并自主决定在何时、调用哪个工具、传入什么参数

2.2.1 工作原理与流程

这个过程更像是在给一个聪明的助手一份“工具说明书”,然后让它自己决定干活时用什么工具。

  1. 工具定义:开发者定义一系列“工具”(本质上是函数)。每个定义包括工具名称、描述、以及参数的JSON Schema。这个描述至关重要,它用自然语言告诉模型这个工具是干什么用的。例如:

    { "type": "function", "function": { "name": "get_current_weather", "description": "获取指定城市的当前天气信息", "parameters": { "type": "object", "properties": { "location": { "type": "string", "description": "城市名,例如:北京" }, "unit": { "type": "string", "enum": ["celsius", "fahrenheit"], "description": "温度单位" } }, "required": ["location"] } } }
  2. 模型推理与决策:将用户查询(如“北京今天热吗?”)和工具定义一起提供给模型。模型基于对查询意图的理解和对工具描述的理解,进行推理:

    • 是否需要调用工具?用户的问题需要实时天气数据吗?需要。
    • 调用哪个工具?从定义列表中,匹配到get_current_weather
    • 参数是什么?从查询中提取location为“北京”,unit可以默认为“celsius”或询问用户(在复杂Agent中)。
  3. 结构化输出(工具调用请求):模型此时会生成一个特殊的、结构化的中间输出,这不是最终给用户的答案,而是一个“动作指令”。这个指令本身是一个符合特定格式的JSON(例如OpenAI的tool_calls数组),包含了它决定调用的工具ID、名称和解析出的参数。

    { "tool_calls": [ { "id": "call_abc123", "type": "function", "function": { "name": "get_current_weather", "arguments": "{\"location\": \"北京\", \"unit\": \"celsius\"}" } } ] }

    注意:这个JSON的生成,在高级实现中也可能受到约束,但其核心是模型“思考后”的决策结果。

  4. 执行与回复:应用程序收到这个调用请求后,在后台执行对应的函数(如调用天气API),将执行结果(如{“temperature”: 28, “condition”: “晴朗”})再次作为上下文返回给模型。模型再根据这个结果,组织最终的自然语言回复给用户(如“北京今天天气晴朗,气温28摄氏度,比较热。”)。

2.2.2 技术实现要点

  • 本质:这是一种任务分解与工具调用的协调机制。模型的核心能力是理解和规划,结构化输出(工具调用请求)是它规划动作的“指令集”。
  • 优势:极大地扩展了LLM的能力边界,使其能够获取实时信息、执行具体操作(发邮件、查数据库、控制设备)。它是构建AI Agent智能工作流的基石。
  • 与JSON Schema的关系:Tool Calling的定义中,参数的描述依赖于JSON Schema。所以你可以理解为,Tool Calling在“调用”这个层面,内部使用JSON Schema来规范每个工具的参数格式。

3. 对比分析与选型指南

理解了原理,我们就能清晰地对比二者,并做出正确的技术选型。

特性维度JSON Schema 约束Tool Calling
核心目标强制规范输出格式,确保数据形状一致。赋予模型行动能力,使其能调用外部工具。
工作阶段文本解码阶段,进行token级约束。模型推理阶段,作为决策和规划的输出。
输出性质最终答案。直接返回用户所需的结构化数据。中间指令。是一个待执行的函数调用请求。
模型角色数据填写员/格式化工。规划者/调度员。
关键输入描述输出数据结构的JSON Schema。描述工具功能、参数的工具定义列表。
典型应用场景信息抽取、表单生成、代码结构化生成、API响应格式化。智能助手、AI Agent、复杂工作流编排、需要实时数据或操作的任务。
稳定性极高。格式被严格锁定,几乎不会出错。依赖模型推理。可能误判是否需要调用、选错工具或参数解析错误。
复杂度相对较低,主要是Schema设计。较高,涉及工具管理、执行、错误处理、多轮对话状态维护。

3.1 如何选择?一个简单的决策树

  1. 你的需求仅仅是让LLM的输出变得规整、易于程序处理吗?

    • -> 优先选择JSON Schema约束。例如:从客户邮件中提取订单号、商品列表和地址;让模型生成一个固定格式的周报JSON。
    • -> 进入下一步。
  2. 你的应用需要LLM根据情况,决定去查询信息、进行计算或触发某个真实世界的操作吗?

    • -> 你需要Tool Calling。例如:一个客服机器人需要根据用户问题查询知识库、订单系统或发起退款流程;一个智能分析助手需要检索数据库、运行Python代码画图。
    • -> 你可能只需要简单的文本生成或问答,无需复杂结构化。
  3. 可以结合使用吗?

    • 当然可以,而且非常常见!这是构建强大应用的关键。例如:
      • 在一个Agent工作流中,Tool Calling负责调度“获取股票价格”工具。
      • 该工具执行后返回原始数据,你可能再用一个具备JSON Schema约束的LLM调用,将这些数据整理成一份标准化的分析报告JSON。
      • 或者,一个Tool本身内部在准备返回给模型的数据时,就使用了JSON Schema来确保数据格式的规范性。

3.2 实操心得与避坑指南

JSON Schema 约束方面:

  • Schema设计要精确而宽松:字段描述尽量清晰,但避免过度严格的约束(如过短的字符串最大长度),以免把模型“逼死”导致生成失败。对于非关键字段,可以使用”required”: false
  • 注意上下文长度:复杂的Schema会占用大量token,减少模型处理实际问题的上下文空间。尽量精简Schema。
  • 不是万能的:它只能保证格式正确,不能保证内容语义正确。模型仍然可能在一个“整数”字段里生成不合逻辑的数字(如年龄为-5)。需要在后处理中增加业务逻辑校验。
  • 测试边界情况:用一些刁钻的输入测试,看模型在约束下是会输出空值、默认值,还是会产生错误。

Tool Calling 方面:

  • 工具描述是灵魂description字段一定要用模型能理解的自然语言,清晰说明工具的功能、适用场景和参数含义。这是模型能否正确调用的关键。

    好的描述:“获取用户最近一笔订单的详细信息,包括订单号、商品列表、金额和状态。” 差的描述:“查询订单。”

  • 处理模型的不确定性:模型可能一次调用多个工具,也可能在不需要时强行调用。你的代码需要能处理:
    • 无工具调用:直接回复。
    • 单个/多个工具调用:并行或串行执行。
    • 参数解析错误:尝试提供默认值或向用户澄清。
  • 错误处理与重试:工具执行可能失败(网络错误、API限流)。需要设计重试机制,或将错误信息反馈给模型,让它决定下一步(如重试、换工具或向用户道歉)。
  • 成本与延迟:每一轮Tool Calling都意味着多次LLM API调用(第一次决定调用,第二次根据结果生成回复),会增加成本和响应时间。对于简单查询,可能不如直接检索高效。

4. 进阶应用:构建一个混合型智能邮件助手

为了将理论付诸实践,我们设计一个综合案例:一个能自动处理用户邮件的智能助手。它需要完成两个任务:1) 从杂乱邮件中提取结构化信息;2) 根据信息类型执行不同操作。

4.1 系统架构设计

我们将结合使用JSON Schema约束和Tool Calling。

  1. 信息提取层:使用JSON Schema约束,让一个LLM专门从邮件正文中提取关键信息。
  2. 决策执行层:根据提取出的结构化信息,另一个LLM使用Tool Calling来决定并执行后续动作。

4.2 核心实现步骤

步骤1:定义信息提取Schema我们设计一个Schema来分类提取邮件意图和内容。

{ “type”: “object”, “properties”: { “intent”: { “type”: “string”, “enum”: [“查询订单状态”, “投诉建议”, “预约服务”, “其他”] }, “entities”: { “type”: “object”, “properties”: { “order_id”: { “type”: “string” }, “customer_name”: { “type”: “string” }, “phone_number”: { “type”: “string” }, “problem_description”: { “type”: “string” } } }, “urgency”: { “type”: “string”, “enum”: [“高”, “中”, “低”] } }, “required”: [“intent”, “entities”, “urgency”] }

步骤2:实现约束提取调用支持JSON Schema的LLM API(如OpenAI GPT-4o),将邮件正文和上述Schema作为提示。

# 伪代码示例 def extract_email_info(email_body): prompt = f””” 请从以下用户邮件中提取结构化信息。严格按照给定的JSON Schema输出。 邮件内容: {email_body} “”” response = openai.chat.completions.create( model=“gpt-4o”, messages=[{“role”: “user”, “content”: prompt}], response_format={“type”: “json_schema”, “json_schema”: {“schema”: email_schema}}, # 假设的API参数 ) return json.loads(response.choices[0].message.content)

这一步会得到一个稳定的JSON,如:

{ “intent”: “查询订单状态”, “entities”: {“order_id”: “ORD123456”, “customer_name”: “张三”}, “urgency”: “中” }

步骤3:定义决策工具根据提取的intent,我们定义不同的处理工具。

tools = [ { “type”: “function”, “function”: { “name”: “query_order_status”, “description”: “根据订单号在内部系统中查询订单的当前状态和物流信息。”, “parameters”: {“type”: “object”, “properties”: {“order_id”: {“type”: “string”}}, “required”: [“order_id”]} } }, { “type”: “function”, “function”: { “name”: “create_service_ticket”, “description”: “根据客户描述的问题创建一张工单,并分配优先级。”, “parameters”: { “type”: “object”, “properties”: { “customer_name”: {“type”: “string”}, “problem”: {“type”: “string”}, “urgency”: {“type”: “string”, “enum”: [“高”, “中”, “低”]} }, “required”: [“customer_name”, “problem”] } } } ]

步骤4:实现Tool Calling与执行将提取出的结构化信息转化为自然语言摘要,作为新一轮LLM调用的输入,并开启Tool Calling。

def process_extracted_info(info): # 将提取的信息转化为对话上下文 context = f”用户意图:{info[‘intent’]}。相关实体:{info[‘entities’]}。紧急程度:{info[‘urgency’]}。” response = openai.chat.completions.create( model=“gpt-4o”, messages=[{“role”: “user”, “content”: f”请处理以下客户请求:{context}”}], tools=tools, tool_choice=“auto” # 让模型自行决定是否调用以及调用哪个工具 ) message = response.choices[0].message # 检查是否有工具调用 if message.tool_calls: for tool_call in message.tool_calls: function_name = tool_call.function.name function_args = json.loads(tool_call.function.arguments) # 根据工具名执行对应函数 if function_name == “query_order_status”: result = database.query_order(function_args[“order_id”]) elif function_name == “create_service_ticket”: result = ticketing_system.create_ticket(**function_args) # … 将执行结果追加到对话历史中,让模型生成最终回复 final_reply = generate_final_reply_with_result(result) return final_reply else: # 如果没有工具调用,直接返回模型的回复(例如对于“其他”意图) return message.content

4.3 方案优势与注意事项

  • 优势
    • 精度高:第一层的信息提取格式稳定,为后续决策提供了干净、可靠的数据。
    • 灵活性好:第二层的Tool Calling可以根据清晰的意图,灵活地路由到不同的业务系统。
    • 可维护:两个阶段职责分离,Schema和工具列表可以独立更新和扩展。
  • 注意事项
    • 流水线延迟:需要两次LLM调用,总响应时间更长。可以考虑对简单意图进行优化,或使用更快的模型处理提取层。
    • 错误传播:第一层提取错误(如错误分类意图)会导致后续全盘错误。需要设计校验机制,例如对低置信度的提取结果,转入人工审核或让模型澄清。
    • 成本:两次调用意味着双倍的成本,需要在业务价值和成本间取得平衡。

5. 常见问题与排查技巧实录

在实际开发和调试中,你会遇到各种问题。以下是一些典型场景和解决思路。

5.1 JSON Schema约束常见问题

  • 问题:模型返回了格式错误或非JSON内容。
    • 排查
      1. 检查提示词:是否清晰、强硬地要求模型“必须输出JSON”、“不要有任何额外解释”?将指令放在系统提示中通常更有效。
      2. 检查Schema兼容性:确认你使用的模型和API是否支持JSON Schema约束。不是所有模型或封装库都支持。
      3. 简化Schema:过于复杂的Schema(尤其是多重嵌套、复杂的条件逻辑oneOf/anyOf)可能超出约束解码器的处理能力。尝试简化Schema,分步提取。
      4. 调整温度参数:将温度(temperature)设为0或较低值,减少随机性。
  • 问题:字段值的内容不符合业务逻辑(如在“年龄”字段生成“年轻”)。
    • 排查
      1. 强化字段描述:在Schema的字段描述中明确规则。例如:“age”: {“type”: “integer”, “description”: “用户的年龄,必须是0到120之间的正整数”}
      2. 后处理校验:LLM不是数据库,约束只能保证类型,不能保证语义。必须在代码层对提取出的值进行业务规则校验。
  • 问题:约束导致生成速度变慢。
    • 排查:约束解码需要进行大量的实时语法检查,肯定会比自由生成慢。这是性能与精度的权衡。考虑是否真的需要如此严格的约束,或者能否接受后处理清洗。

5.2 Tool Calling常见问题

  • 问题:模型不调用工具,而是用自然语言回答。
    • 排查
      1. 检查工具描述description是否足够清晰,让模型理解这个工具能解决当前问题?用更具体、场景化的语言重写描述。
      2. 检查用户查询:查询是否足够明确,触发了工具使用的需求?有时需要在前端引导用户提出更明确的需求。
      3. 调整tool_choice参数:如果你确定必须调用某个工具,可以将该参数设为{“type”: “function”, “function”: {“name”: “xxx”}}进行强制调用。
      4. 提供示例:在系统提示中提供少量“用户查询-工具调用”的示例(Few-shot Learning),能显著提升模型调用工具的准确性。
  • 问题:模型调用了错误的工具,或参数解析错误。
    • 排查
      1. 工具区分度:不同工具的描述是否太相似?确保每个工具的名称和描述都有独特的定位。
      2. 参数描述:每个参数的description字段是否写清楚了格式和示例?例如“date”: {“type”: “string”, “description”: “日期,格式为YYYY-MM-DD,例如2023-10-27”}
      3. 实施验证与重试:在代码中,对解析出的参数进行预验证(如必填项、格式)。如果失败,可以将错误信息连同原始问题再次发送给模型,要求它纠正。这构成了一个简单的自我修正循环。
  • 问题:多轮对话中,工具调用状态混乱。
    • 排查:这是构建Agent的复杂性问题。你需要维护完整的对话历史,并将每次工具调用的ID、名称、参数、执行结果都完整地追加到消息列表中。模型需要看到完整的上下文才能做出连贯的决策。使用LangChainLangGraphDify这类框架可以大大简化状态管理。

5.3 通用性能与优化技巧

  • 缓存:对于常见、结果固定的查询(如根据产品ID查名称),可以将“查询-结果”对缓存起来,避免重复调用LLM和外部工具。
  • 异步与并行:如果一次需要调用多个不依赖的工具,尽量使用异步方式并行执行,减少总体延迟。
  • 降级方案:当主要工具(如某个API)失效时,应有备选方案。例如,天气API挂了,可以转而调用另一个备用API,或者让模型直接回复“暂时无法获取”。
  • 监控与评估:记录每次LLM调用的输入、输出、token用量和工具调用结果。定期评估准确率、成本,作为优化提示词、调整Schema或工具定义的依据。

理解JSON Schema约束和Tool Calling的原理差异,就像掌握了让LLM从“诗人”变为“工程师”的两套不同工具箱。前者用于“塑形”,确保输出的数据整洁、可用;后者用于“赋能”,让模型能够连接世界、执行任务。在实际项目中,它们往往不是二选一,而是相辅相成,共同构建起稳定、强大且智能的应用系统。

← 返回列表