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 结构化输出!