logo

LLM 结构化输出终极指南

2025年7月20日 · 2443

每个想把 LLM 用于 Agent 系统的开发者,想必都遇到过相同的困惑:如何让 LLM 输出准确的结构化内容?

要知道,大模型的本质是有概率的生成式模型,LLM 每次的返回内容都有可能不同。这在聊天对话中是完全没问题的。但是在 Agent 系统里,我们经常需要 LLM 能输出结构化数据,解析之后再传递给下游。我希望它的输出是完全准确的,因为输出的内容如果不对,整个系统可就都走不通了!

例如,我们希望从一段文本中,提取用户的姓名、年龄、居住地信息。

最合适的方法就是让 LLM 输出 JSON 格式的数据,然后进行解析:

{
    "name": "张三",
    "age": 25,
    "city": "上海"
}

方法一:直接 Prompt

你写出的 Prompt 很可能是这样的:

从以下用户输入中提取用户的姓名、年龄和城市,并严格按照 JSON 格式输出。注意:输出中应只包括 JSON 内容。

示例:
{"name": "张三", "age": 25, "city": "上海"}

用户输入:
你好,我叫张三,今年25岁,目前居住在上海。

这里其实已经用到了一些 Prompt 工程的技巧:

  • 强调了只输出 JSON 内容,防止 LLM 输出掺杂其他文本
  • 给了一个示例,这其实是 One shot prompting,让 LLM 直接模仿,使用正确的 JSON key

不过,以上的 Prompt 还不太够。你会发现,LLM 的输出可能会有以下两种格式:

格式一(只有 JSON):

{"name": "张三", "age": 25, "city": "上海"}

格式二(包裹在代码块里):

```json
{"name": "张三", "age": 25, "city": "上海"}
```

没错,后一种格式确实也是「输出中只包括 JSON」,但是显然不能直接交给 JSON parser 解析。于是我们还要添加一步处理:去除结果中开头结尾的多余字符,只保留花括号中的 JSON 信息。

很快我们就会发现:系统中多了很多修修补补的地方,都是在处理 LLM 输出的不确定性。那么,有没有什么办法,让 LLM 能够乖乖听话,给我们完全准确的 JSON 呢?

方法二:JSON Mode

「让大模型输出合法的 JSON」这件事情,是一个很常见的需求。所以,模型厂商也给出了他们的解决方案。

OpenAI 很早推出了 JSON 模式,启用它只需要在 API 调用时增加一个 response_format 参数即可。例如:

client.chat.completions.create(
  model="gpt-4o-mini",
  # 启用 JSON Mode
  response_format={ "type": "json_object" },
  messages=[
    {"role": "user", "content": "你好,我叫张三,今年25岁,目前居住在上海。"}
  ]
)

JSON Mode 可以保证模型的输出一定是一个合法 JSON。随后,OpenAI 还支持在 response_format 中传入一个 JSON Schema,保证生成的 JSON 一定是你想要的格式。

可惜的是,JSON Mode 虽然好,但是 OpenAI 并没有把它推广成为一个标准协议(这似乎是 OpenAI 的一贯作风,Function Calling 也是这样的)。所以我们会看到,各家模型其实都实现了类似的能力,但是协议各不相同,主打一个百花齐放。而我们的 Agent 系统往往是要接入不同的模型的,一个个去适配,显然有点太不友好了。

那么,有没有一种方法,能够适配各种模型,输出结构化的 JSON 数据呢?

方法三:强制工具调用

工具调用(Function Calling)想必大家都不陌生。可以说,LLM 想要搭建 Agent 系统,做真正有用的工作,那么工具调用是不可或缺的。

因此,现在主流的模型都支持工具调用能力。虽然各个模型实现工具调用的参数有些出入,但大体上都兼容了 OpenAI 的 Function Calling 协议。这比严重碎片化的 JSON Mode 协议要好得多。

如果仔细观察 Function Calling 协议的话,我们会发现,工具调用中也有严格的 Schema 定义,每个 function 的参数该怎么填,是严格要求的,这恰好覆盖了 JSON Schema 的能力。

那么,我们只需要做一个思路上的巧妙转换,就完全可以用工具调用来实现结构化数据的生成。

  • 原始思路:要求 LLM 告诉我们各种信息,返回必须严格符合 JSON 格式。
  • 全新思路:给 LLM 提供一个问卷,LLM 需要严格按问卷上的格式填写各种信息。

这个「问卷」,就是 Function Calling 协议。

同样的用户信息提取的功能,我们的代码可以是这样的:

response = client.chat.completions.create(
  model="gpt-4o-mini",
  # 定义 user_profile 工具,作为「问卷」
  tools=[{
    "type": "function",
    "function": {
      "name": "user_profile",
      "description": "填写用户信息",
      "parameters": {
        "type": "object",
        "properties": {
          "name": {"type": "string"},
          "age": {"type": "number"},
          "city": {"type": "string"}
        },
        "required": ["name", "age", "city"]
      }
    }
  }],
  # 强制要求模型必须调用 user_profile 工具
  tool_choice={"type": "function", "function": {"name": "user_profile"}}
  messages=[
    {"role": "user", "content": "我是王五,今年40了,家在深圳。"}
  ]
)

代码解释:

  • tools 参数给出了 function 的定义,其中包括严格的 Schema,保证信息一定按照我们要求的格式来填写
  • tool_choice 参数实现了强制的工具调用,要求模型必须填写这个问卷,而不是随便输出其他内容

Bingo!这样我们就巧妙地通过工具调用的方法,实现了结构化信息的提取。

相比于前两个方法,这个方法的好处很明显:

  • 相比方法二:JSON Mode 最大的问题是协议不统一,需要兼容不同的模型。而 Function Calling 协议基本是统一的,我们只需要写一份代码,就可以兼容不同的模型

  • 相比方法一:Function Calling 作为流行的协议,各个模型厂商都专门微调过,让模型能够严格遵循 JSON Schema。这比临时写的 Prompt 要可靠得多。

代码实战

让我们用刚才的例子来实战一下。我们使用强制工具调用的方法,再加上 Pydantic 做数据验证。

首先,我们使用 Pydantic 定义用户信息的数据结构:

import pydantic

class UserProfile(pydantic.BaseModel):
    """用来存储和验证用户信息的 Pydantic 模型"""
    name: str = pydantic.Field(description="用户的姓名")
    age: int = pydantic.Field(description="用户的年龄")
    city: str = pydantic.Field(description="用户居住的城市")

Pydantic 的强大之处在于,它可以自动生成一个数据结构的 JSON Schema,免去了我们手写 Schema 出错的可能性。

schema = UserProfile.model_json_schema()

调用 LLM 的方式与之前所说的类似,我们还是提供工具,并强制要求模型调用工具。唯一的区别是,我们直接使用 Pydantic 自动生成的 Schema。

import os
from openai import OpenAI

# 假设你已经设置了环境变量 OPENAI_API_KEY
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))

response = client.chat.completions.create(
  model="gpt-4o-mini",
  # 定义 user_profile 工具,作为「问卷」
  tools=[{
    "type": "function",
    "function": {
      "name": "user_profile",
      "description": "填写用户信息",
      "parameters": UserProfile.model_json_schema()
  }],
  # 强制要求模型必须调用 user_profile 工具
  tool_choice={"type": "function", "function": {"name": "user_profile"}}
  messages=[
    {"role": "user", "content": "我是王五,今年40了,家在深圳。"}
  ]
)

此次 LLM 的响应不再是文本,而是一个 tool_calls 对象。我们需要从中提取模型填写好的用户信息。

# 提取模型返回的工具调用参数(这是一个 JSON 字符串)
tool_call = response.choices[0].message.tool_calls[0]
arguments_json = tool_call.function.arguments

print(arguments_json)
# 输出: {"name":"张三","age":25,"city":"上海"}

最后,Pydantic 又一次出马了。我们继续使用一开始定义的数据结构,Pydantic 可以自动解析 JSON 并转换为 Python 对象,在这个过程中对数据进行校验:

# 使用 Pydantic 模型进行最终的解析、验证和实例化
try:
    user_instance = UserProfile.model_validate_json(arguments_json)
    
    print("成功解析并验证后的 Python 对象:")
    print(user_instance)
    
    # 现在你可以像操作任何 Python 对象一样使用它
    print(f"姓名: {user_instance.name}, 年龄: {user_instance.age}")

except Exception as e:
    print(f"数据验证失败: {e}")

这样,我们就优雅地实现了 LLM 的结构化数据输出,这个方案不但能让模型稳定地输出结构化数据,还可以兼容各种模型。更重要的是,我们不再需要手写各种繁琐的示例或者 Schema,只需要定义一个数据结构,一切都自然地完成了。当我们需要 LLM 输出另一种结构化数据时,只需要定义另一个数据结构就可以了。

总结

我们从三种方案一路梳理下来,可以看到,利用工具调用来实现结构化输出确实是现在最可靠的方案,再结合 Pydantic 的能力,可以做到优雅的工程实现。

你可能也能说过一些业界的库有封装类似的能力,比如 Instructor、Pydantic AI,以及 LangChain 的 with_structured_output。不过我不会直接跟你推荐某个库,因为每个人使用的 Agent 框架都不一样,有可能一些库并不是很方便集成到你的项目中。

另外,有些库会暗中修改你的 Prompt。如果你希望实现手工精细的控制,你大概不会喜欢 Prompt 被改掉的感觉。

所以,最好的方法是先熟悉原理,先把「强制工具调用 + Pydantic」这个基准方案吃透。当了解了背后的原理,你就知道该使用什么库了(其实它们无非就是我们讨论过的方案的变种,再加上对不同模型的适配)。这样你就能够真正驾驭工具库,不会因为工具库的封装而感觉云里雾里,遇到问题也能够一眼定位到原因所在。

祝你能够顺利实现 LLM 结构化输出!