慕雪的小助手正在绞尽脑汁···
慕雪小助手的总结
DeepSeek & LongCat

欢迎阅读慕雪撰写的AI Agent专栏,本专栏目录如下

  1. 【MCP】详细了解MCP协议:和function call的区别何在?如何使用MCP?
  2. 【AI】AI对26届及今后计算机校招的影响
  3. 【Agent.01】AI Agent智能体开发专题引言
  4. 【Agent.02】市面上常见的大模型有哪些?
  5. 【Agent.03】带你学会写一个基础的Prompt
  6. 【Agent.04】AI时代的hello world:调用OpenAI接口,与大模型交互
  7. 【Agent.05】OpenAI接口Function Calling工具调用详解
  8. 【Agent.06】使用openai sdk实现多轮对话
  9. 【Agent.07】什么是Agent?从Chat到ReAct的AI进化之路
  10. 【Agent.08】LangChain的第一个Demo:从零开始构建Agent
  11. 【Agent.09】LangChain里面使用MCP工具
  12. 【Agent.10】OpenAI接口输出格式约束(response_format)

本专栏所有代码都会归档至 musnows/agent-blog 开源仓库。

1. 引言

Agent专栏已经写到LangChain部分了,突然想起来,还遗漏了一个重要的OpenAI接口提供的特性没有使用:结果response_format的格式化输出。

在一般情况下,AI输出的都是自然语言,和我们人类输入的信息一样。在一般的问答Agent场景中,输出自然语言是OK的,但当我们希望使用AI来生成测试用例、分析报告、数据总结等等信息的时候,就会需要AI输出结构化的数据,这样我们才能进行有效的解析和后处理。

在继续阅读本文之前,你需要对序列化、反序列化概念有所了解,并知晓json序列化协议的基本结构。

举个最简单的例子:当我们需要AI输出针对需求的测试用例时,如果AI使用的是自然语言输出,如“链接网络,打开手机APP,点击播放视频的按钮,确认视频能正常播放”,我们就没有办法对这个测试用例进行有效拆分,从而提取出前置条件、测试步骤、预期结果。

但如果我们要求AI以json格式输出这些信息,解析这个测试用例就很容易了,比如要求AI按如下格式输出:

1
2
3
4
5
{
"preStep": ["链接网络"],
"testStep":["打开手机APP","点击播放视频的按钮"],
"expectation":["视频能正常播放"]
}

有了这个json,我们想解析前置步骤、测试步骤、预期结果就非常简单了,直接对json进行反序列化(如python里面的json.loads)就可以加载到这串结构化的数据,进行后续的其他处理了。

2. 怎么约束输出格式?

理解了这个背景后,想必你已经知道为啥需要让AI结构化输出信息了。那要怎么做呢?

最简单的做法,就是在Prompt里面新增输出格式的要求,让AI遵循我们的要求,直接在回答里面输出json或其他可序列化的格式(xml、yaml),然后我们对返回的string进行解析,得到最终的结构化数据。

但是,这样做有非常大的弊端:

  1. AI可能因为幻觉,不按我们预定的格式进行输出(现在的AI对Prompt遵循性相比半年之前有显著提升,这个问题出现次数减少了。
  2. AI可能会在输出中包含其他说明文字(这个问题至今依旧没有解决,AI总是喜欢给你加点其他说明,即便Prompt里面多次说明“禁止包含其他信息”)
  3. AI可能使用markdown代码块包裹信息输出,而不是只输出结构化数据(需要我们处理返回值里面的代码块)

所以,OpenAI提供了json_schema格式化输出的约定字段,可以要求AI依照预定的response_format进行输出。

注意,这个字段依赖于OpenAI服务提供商对response_format支持,如果你使用的是第三方服务商提供的OpenAI兼容API,需要查看该服务商的文档,确认其支持response_format字段。目前轨迹流动是支持的,而美团的LongCat就不支持(不会遵循response_format)。

测试方式也比较简单,用本文给出的response_format设置代码去测试请求一次就能看出来AI是否有遵循response_format了。

image.png

3. 使用json_schema限定AI输出格式

使用openai的python sdk,我们可以直接在创建会话的时候,传入response_format字段对返回值进行格式控制。

代码如下所示,我们希望AI格式化解析用户提供的购物清单,精准输出购买商品的名字name、数量quantity、单位unit。

在response_format的设置中,"type": "array"代表items是一个json的list,"type": "object"则代表是一个json的对象(对应python的dict)。给定的name/quantity/unit这三个字段都是required必填字段,"strict": True则是要求AI必须严格遵循这个结构进行输出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import os
from openai import OpenAI
from dotenv import load_dotenv

load_dotenv(override=True)

OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "")
OPENAI_BASE_URL = os.getenv("OPENAI_BASE_URL", "https://api.siliconflow.cn/v1")
OPENAI_MODEL = os.getenv("OPENAI_MODEL", "Qwen/Qwen3-8B")

client = OpenAI(
api_key=OPENAI_API_KEY,
base_url=OPENAI_BASE_URL
)


def parse_shopping_list_with_schema(user_input: str):
"""使用JSON Schema解析购物清单"""
response = client.chat.completions.create(
model=OPENAI_MODEL,
messages=[
{"role": "system", "content": "解析用户输入的购物清单,生成结构化数据"},
{"role": "user", "content": user_input}
],
response_format={
"type": "json_schema",
"json_schema": {
"name": "shopping_list",
"schema": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"quantity": {"type": "integer"},
"unit": {"type": "string"}
},
"required": ["name", "quantity", "unit"]
}
}
},
"required": ["items"],
"strict": True
}
}
}
)

return response.choices[0].message.content

其他更复杂的格式都是在这套的基础上进行扩展,可把你的需要直接发送给编程AI助手,让他根据你的需要生成对应的json格式要求就可以了。

4. 效果对比

4.1. 使用Prompt限定代码

作为对比,这里提供了一份使用Prompt限定输出格式的OpenAI调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
def parse_shopping_list_with_prompt(user_input: str):
"""使用prompt限定格式解析购物清单"""
system_prompt = """你是一个购物清单解析助手。请解析用户的购物清单,并严格按照以下JSON格式返回:

{
"items": [
{
"name": "商品名称",
"quantity": 数量,
"unit": "单位"
}
]
}

要求:
1. 必须返回有效的JSON格式
2. name字段为字符串类型
3. quantity字段为整数类型
4. unit字段为字符串类型
5. 所有字段都是必需的
6. 不要添加任何额外的文字说明,只返回JSON
7. 不要使用markdown代码块格式
"""

response = client.chat.completions.create(
model=OPENAI_MODEL,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_input}
],
temperature=0.1 # 温度越低AI回答越不容易跑偏
)

return response.choices[0].message.content

4.2. 测试结果

测试使用硅基流动的Qwen/Qwen3-8B模型

使用“我买了苹果5斤,牛奶2箱,面包3个”进行测试,可以看到,Qwen/Qwen3-8B对这种简单任务的Prompt遵循性还不错,不管是使用response_format还是使用Prompt的方式进行指定,都按照我们的要求进行输出了,且没有提供任何的说明文字。但Qwen/Qwen3-8B还是输出了markdown代码块包裹了这个json(Prompt里面要求不要使用)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
测试用例 1:
用户输入: 我买了苹果5斤,牛奶2箱,面包3个

==================================================
1. 使用JSON Schema方法:
{"items": [{"name": "苹果", "quantity": 5, "unit": "斤"}, {"name": "牛奶", "quantity": 2, "unit": "箱"}, {"name": "面包", "quantity": 3, "unit": "个"}]}

------------------------------
2. 使用prompt限定格式方法:
```json
{
"items": [
{
"name": "苹果",
"quantity": 5,
"unit": "斤"
},
{
"name": "牛奶",
"quantity": 2,
"unit": "箱"
},
{
"name": "面包",
"quantity": 3,
"unit": "个"
}
]
}
```

相同一次运行里面的其他输入,他又可能不会输出markdown代码块(AI幻觉导致)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
测试用例 3:
用户输入: 可乐2瓶,薯片5包,巧克力4块

==================================================
1. 使用JSON Schema方法:
{
"items": [
{
"name": "可乐",
"quantity": 2,
"unit": "瓶"
},
{
"name": "薯片",
"quantity": 5,
"unit": "包"
},
{
"name": "巧克力",
"quantity": 4,
"unit": "块"
}
]
}

------------------------------
2. 使用prompt限定格式方法:
{
"items": [
{
"name": "可乐",
"quantity": 2,
"unit": "瓶"
},
{
"name": "薯片",
"quantity": 5,
"unit": "包"
},
{
"name": "巧克力",
"quantity": 4,
"unit": "块"
}
]
}

因此可见,如果使用的OpenAI服务提供商支持response_format,使用response_format来控制AI输出结构是更好的方式,同时也避免我们去过多调试“约束AI生成数据结构”的Prompt了。

5. 从AI回答里面精准提取json字符串

不过呢,输出markdown代码块是一个小问题了,我们可以很轻松地编写一个json提取函数,从AI的输出里面精准提取出完整的json来,只要AI输出的json没有断。

如果你不想自己实现,可以使用pypi上已有的解析器:JsonExtractor

在很多场景下都可以使用这个json对AI的输出进行处理(即便提供了response_format也可以使用这个函数先处理一下),保证我们后续节点一定能得到一个有效的json结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
import json
import re
from typing import Union

def extract_json_with_regex(response_str: str) -> Union[dict,list]:
"""
使用算法从字符串中精准提取唯一的完整JSON

Args:
response_str: 包含JSON的原始回答字符串

Returns:
dict/list: 解析后的JSON对象或数组,无法解析时返回None
"""
# 先直接进行一次json处理,失败了再往后走
try:
return json.loads(response_str)
except json.JSONDecodeError as e:
pass # 第一次可能失败,不对异常做任何处理

# 步骤1:移除Markdown代码块标记(```json、```等)
response_str = re.sub(r'```(?:json)?\s*|\s*```', '', response_str, flags=re.IGNORECASE)

# 步骤2:使用改进的算法提取JSON,支持嵌套结构
# 由于Python re不支持递归,使用平衡括号算法
matches = []

# 提取JSON对象和数组
for i, char in enumerate(response_str):
if char == '{':
json_str = _extract_balanced(response_str, i, '{', '}')
if json_str:
matches.append(json_str)
elif char == '[':
json_str = _extract_balanced(response_str, i, '[', ']')
if json_str:
matches.append(json_str)

# 步骤3:没有找到匹配,直接返回None
if not matches:
return None

# 取最长匹配(避免截取不完整的嵌套结构)
json_str = max(matches, key=len).strip()

# 尝试逐步清理尾部可能的无效字符
while json_str and json_str[-1] not in '}]':
json_str = json_str[:-1].strip()
if json_str and json_str[-1] in ',;':
json_str = json_str[:-1].strip()

try:
return json.loads(json_str)
except json.JSONDecodeError as e:
print(f"JSON解析失败:{str(e)}\n提取的内容:{json_str}")
return None

def _extract_balanced(text: str, start_idx: int, open_char: str, close_char: str) -> str:
"""
提取平衡的括号内容

Args:
text: 源文本
start_idx: 起始位置(必须是开括号)
open_char: 开括号字符
close_char: 闭括号字符

Returns:
str: 平衡的括号内容,如果无法平衡则返回None
"""
if text[start_idx] != open_char:
return None

stack = 1
in_string = False
escape_next = False

for i in range(start_idx + 1, len(text)):
char = text[i]

# 处理字符串中的转义字符
if escape_next:
escape_next = False
continue

if char == '\\' and in_string:
escape_next = True
continue

# 处理字符串开始和结束
if char == '"' and not escape_next:
in_string = not in_string
continue

# 只在非字符串状态下计算括号
if not in_string:
if char == open_char:
stack += 1
elif char == close_char:
stack -= 1
if stack == 0:
return text[start_idx:i+1]

# 无法平衡,返回None
return None

当然,这个函数没办法处理AI输出的json的结构不对的情况(比如缺key、key的名字不对、结构错乱等问题),只能保证剔除回答里面的其他无效信息,解析出一个有效的json。

同时,这个函数也不能支持回答里面有多个独立的json的情况。不推荐让AI输出多个独立的json,如果有多个独立json输出的要求,请使用一个大的json,用key包含这些json进行输出,比如指定多个大key来保存独立的json

1
2
3
4
5
{
"key1": {},
"key2": {},
...
}

6. The end

本文介绍了如何在调用OpenAI接口的时候约束AI的输出结构。这个场景几乎是AI Agent开发里面最常见的场景。即便我们后续使用LangChain SDK,也一样会遇到需要要求AI输出结构化数据的场景。