Faire produire du JSON au modèle
Quand la réponse du modèle doit être traitée par un programme, il faut un JSON au format fiable. Le mode JSON, la validation avec Pydantic et la correction par le modèle en cas d'échec, puis l'appel d'outils en mode strict pour obtenir une sortie structurée conforme au schéma.
- Environ 40 minutes
- Niveau : Débutant
- Testé : 2026-09-14 deepseek-flash, pydantic 2
Le code et les sorties des programmes sont reproduits tels qu’ils ont tourné : commentaires et sorties sont donc en chinois.
Jusqu'ici, les réponses du modèle étaient destinées à des humains. Mais dès qu'une réponse doit être traitée par un programme, par exemple pour ranger automatiquement une demande d'aide dans une base de données ou pour distribuer les messages à différentes personnes selon leur catégorie, il vous faut un JSON au format fixe, aux champs complets, directement analysable.
Faire « produire du JSON » au modèle est facile ; lui faire produire à chaque fois un JSON valide aux champs corrects demande un peu d'ingénierie. Cette leçon présente trois niveaux de garantie : le mode JSON garantit la syntaxe, la validation Pydantic garantit le contenu, l'appel d'outils en mode strict garantit la structure.
Ce qui ne va pas quand on ne compte que sur le prompt
Le plus direct est d'écrire « produis du JSON » dans le prompt. La plupart du temps, cela marche, mais il arrive toujours que :
- le modèle ajoute avant le JSON une phrase comme « Voici le résultat de l'extraction : », ou l'enveloppe dans un bloc ```json, et
json.loadséchoue ; - les noms de champs varient,
httpx_versionune fois,versionla suivante ; - un champ qui devait être un nombre est une chaîne, une liste devient une chaîne séparée par des virgules ;
- la réponse est trop longue, coupée par
max_tokens, et il manque les accolades finales.
Pour un programme appelé des dizaines de milliers de fois par jour, 1 % d'échec, ce sont des centaines d'erreurs quotidiennes. Il faut donc empiler les garanties.
Premier niveau : le mode JSON
DeepSeek et de nombreux services compatibles avec l'interface d'OpenAI proposent un mode JSON : en ajoutant response_format={"type": "json_object"} à la requête, la sortie du modèle est garantie être un JSON syntaxiquement valide, sans phrase d'introduction superflue.
La documentation de DeepSeek (septembre 2026) pose trois exigences pour le mode JSON :
- Définir
response_format={"type": "json_object"}. - Faire apparaître le mot « json » dans le message system ou user, avec un exemple du format attendu.
- Fixer
max_tokensassez haut pour éviter que le JSON soit tronqué.
La deuxième est impérative. J'ai essayé : sans le mot json dans le prompt, le serveur refuse directement :
提示词里没有 json,报错: Error code: 400 - {'error': {'message': "Prompt must contain the word 'json' in some form to use 'response_format' of type 'json_object'.", 'type': 'invalid_request_error', 'param': None, 'code': 'invalid_request_error'}}
La documentation prévient aussi que l'API peut parfois renvoyer un contenu vide. Même avec le mode JSON, votre code ne peut donc pas supposer qu'il obtiendra toujours un résultat.
Deuxième niveau : valider avec Pydantic
Le mode JSON ne garantit que la syntaxe, pas le contenu : un champ peut manquer, un type être faux. Une fois le JSON obtenu, il faut donc le vérifier par programme.
En Python, l'outil le plus pratique est Pydantic. Il a déjà été installé comme dépendance avec openai. On commence par définir la structure voulue avec une classe :
from pydantic import BaseModel
class BugReport(BaseModel):
title: str
httpx_version: str | None # 原话里没提就是 null
python_version: str | None
os: str | None
error: str | None
missing_info: list[str]
str | None signifie que le champ peut être une chaîne ou null. BugReport.model_validate_json(text) analyse le JSON et vérifie chaque champ : un champ manquant ou un type incorrect lève une ValidationError qui indique clairement quel champ pose quel problème.
Le prompt dit clairement ce qui est attendu et donne un exemple de format, ce qui satisfait en même temps l'exigence de DeepSeek de faire « apparaître le mot json » :
SYSTEM = """从用户的求助原话中提取信息,输出 JSON。原话里没有的字段填 null,不要猜。
JSON 格式示例:
{"title": "一句话概括问题", "httpx_version": "0.27", "python_version": "3.11",
"os": "macOS", "error": "报错类型或信息", "missing_info": ["排查还需要知道的信息"]}"""
Si la validation échoue : dire l'erreur au modèle
Quand la validation échoue, le plus simple et le plus efficace est de renvoyer tel quel le message d'erreur au modèle pour qu'il corrige. Voici un code réutilisable tel quel :
def extract(report, max_attempts=3):
messages = [{"role": "system", "content": SYSTEM}, {"role": "user", "content": report}]
for attempt in range(1, max_attempts + 1):
response = client.chat.completions.create(
model=MODEL,
messages=messages,
response_format={"type": "json_object"},
max_tokens=1000,
extra_body={"thinking": {"type": "disabled"}},
)
content = response.choices[0].message.content
try:
return BugReport.model_validate_json(content), attempt
except ValidationError as e:
# 把错误原样告诉模型,让它改正。json 语法错误和字段错误都会走到这里
print(f"第 {attempt} 次校验失败:{e.errors()[0]['msg']}")
messages += [
{"role": "assistant", "content": content or ""},
{"role": "user", "content": f"你的输出没有通过校验:{e}\n请重新输出完整、正确的 JSON。"},
]
raise RuntimeError(f"{max_attempts} 次都没有得到合法的输出")
Quelques détails :
- Lors d'une nouvelle tentative, la sortie erronée du modèle est remise sous forme de message
assistant, puis un messageuserexplique l'erreur. Le modèle voit où il s'est trompé et corrige plus souvent que si on lui reposait la question depuis le début. - Un contenu vide (
contentàNoneou chaîne vide) échoue aussi à la validation et déclenche une nouvelle tentative, ce qui gère le « contenu parfois vide » mentionné par la documentation. - Fixer un nombre maximal de tentatives. Si trois tentatives échouent, le problème vient très probablement du prompt ou des données ; continuer ne fait que gaspiller de l'argent, il faut lever une erreur et faire intervenir un humain.
Essayons avec la demande d'aide httpx de la leçon 1 (code complet dans code/02-prompting/json_output.py) :
第 1 次成功:
{
"title": "httpx stream下载大文件中途ReadTimeout",
"httpx_version": "0.27",
"python_version": "3.11",
"os": "macOS",
"error": "ReadTimeout",
"missing_info": [
"具体的ReadTimeout异常堆栈",
"当前timeout配置值",
"重试逻辑或下载代码片段",
"网络代理或内网限制情况"
]
}
Cette fois, la validation passe du premier coup. Dans mes tests, avec le mode JSON et un exemple de format, les échecs de validation sont rares. Mais « rare » ne veut pas dire « jamais » : le code de nouvelle tentative est l'assurance pour ces rares cas.
Le report obtenu est un objet Python ; on accède directement aux champs avec report.httpx_version, et l'éditeur les complète automatiquement. C'est bien plus fiable que de puiser dans un dictionnaire avec des chaînes.
Troisième niveau : obtenir une sortie structurée par appel d'outils
Il existe une autre méthode, qui contraint la structure dès le départ : faire « appeler un outil » au modèle, dont les paramètres sont la structure voulue.
L'appel d'outils (function calling) sert normalement à faire appeler des fonctions externes par le modèle ; la leçon 3 du module 03 le détaille. Ici, on n'emprunte qu'une de ses propriétés : on décrit les paramètres de l'outil avec un JSON Schema, et les paramètres générés par le modèle suivent cette structure. DeepSeek propose en outre un mode strict (strict) : activé, les paramètres produits respectent strictement le schéma, et les valeurs énumérées ne peuvent être choisies que parmi les options données.
En septembre 2026, le mode strict de DeepSeek est une fonctionnalité bêta : il faut remplacer base_url par https://api.deepseek.com/beta, écrire "strict": True dans la définition de la fonction, et le schéma doit contenir "additionalProperties": False :
client = OpenAI(
api_key=os.environ["LLM_API_KEY"],
base_url="https://api.deepseek.com/beta",
)
tool = {
"type": "function",
"function": {
"name": "save_bug_report",
"description": "保存从用户原话中提取出的问题信息",
"strict": True,
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "一句话概括问题"},
"httpx_version": {"type": "string", "description": "原话里没有就填空字符串"},
"os": {"type": "string", "enum": ["macOS", "Windows", "Linux", "未知"]},
"severity": {"type": "string", "enum": ["阻塞", "严重", "一般"]},
},
"required": ["title", "httpx_version", "os", "severity"],
"additionalProperties": False,
},
},
}
response = client.chat.completions.create(
model=MODEL,
messages=[{"role": "user", "content": "提取这段求助里的信息并保存:\n" + REPORT}],
tools=[tool],
# 强制调用这个工具,而不是让模型自己决定要不要调用
tool_choice={"type": "function", "function": {"name": "save_bug_report"}},
extra_body={"thinking": {"type": "disabled"}},
)
call = response.choices[0].message.tool_calls[0]
print("模型调用了:", call.function.name)
print(json.dumps(json.loads(call.function.arguments), ensure_ascii=False, indent=2))
Résultat :
模型调用了: save_bug_report
{
"title": "用httpx stream下载2G大文件到一半报ReadTimeout连接中断",
"httpx_version": "0.27",
"os": "macOS",
"severity": "严重"
}
os et severity sont bien dans les valeurs énumérées. Le modèle n'a rien « enregistré » du tout : la fonction save_bug_report n'existe pas, on se sert seulement de ses paramètres pour obtenir des données structurées.
tool_choice impose l'outil à appeler. Sans lui, le modèle peut décider de ne pas appeler d'outil et répondre directement en texte.
Laquelle choisir
| Méthode | Ce qu'elle garantit | Adaptée à |
|---|---|---|
| Mode JSON + validation Pydantic + nouvelle tentative | La syntaxe par le mode, le contenu rattrapé par la validation et les nouvelles tentatives | Premier choix dans la plupart des cas ; tous les fournisseurs le prennent en charge |
| Appel d'outils en mode strict | Sortie strictement conforme au schéma | Structures complexes, nombreuses valeurs énumérées, quand on ne veut pas écrire de logique de nouvelle tentative |
| Le prompt seul | Rien | Quand le modèle ou le fournisseur ne prend en charge aucune des deux autres ; toujours avec une validation |
Quelle que soit la méthode, ne faites jamais l'impasse sur la validation dans le programme. Le mode strict garantit la structure, pas le contenu : le modèle peut toujours extraire un mauvais numéro de version, ou marquer « grave » un problème « ordinaire ». Une structure correcte n'est qu'un premier pas ; l'exactitude du contenu se vérifie par l'évaluation du module 06.
Deux petits rappels encore :
- Moins de champs, plus de stabilité. Extraire vingt champs d'un coup échoue bien plus souvent que cinq. Avec beaucoup de champs, envisagez de répartir en plusieurs appels.
- Autoriser le « rien ». Donnez toujours au modèle une façon d'exprimer « cette information n'est pas dans le texte », par exemple
nullou une chaîne vide, et dites-le dans le prompt. Sinon, pour remplir les champs, le modèle se met à inventer.
Exercices
- Changez
missing_infodansBugReportenlist[int](définition volontairement fausse), lancezjson_output.pyet observez l'échec de validation et les nouvelles tentatives. - Ajoutez à
BugReportun champseveritylimité à « 阻塞 » (bloquant), « 严重 » (grave) ou « 一般 » (ordinaire) (indice :typing.Literal). Donnez volontairement une demande d'aide dont on ne peut pas déduire la gravité, et voyez comment le modèle réagit. - Avec la méthode de
json_strict.py, créez un outil pour la classification de messages de la leçon 2, avec les catégories limitées parenum, traitez 20 messages et vérifiez que le format est toujours correct.
Auto-test
1. Pourquoi valider avec Pydantic alors que le mode JSON est activé ?
Le mode JSON garantit seulement une sortie JSON syntaxiquement valide, pas des champs complets, bien nommés et du bon type. Par ailleurs, la documentation de DeepSeek indique que l'API renvoie parfois un contenu vide. La validation Pydantic détecte ces problèmes, et les nouvelles tentatives les traitent.
2. Lors d'une nouvelle tentative après un échec de validation, pourquoi remettre aussi la sortie erronée du modèle dans les messages ?
Pour que le modèle voie ce qu'il a produit la fois précédente et, avec le message d'erreur que vous donnez, corrige de façon ciblée. Si on repose simplement la question d'origine, il ne sait pas où il s'est trompé et risque de refaire la même erreur.
3. Quand on obtient une sortie structurée par appel d'outils, à quoi sert tool_choice ?
À imposer au modèle l'appel d'un outil donné. Sans lui, le modèle décide lui-même s'il appelle un outil et peut répondre directement en texte ; on n'obtient alors pas les paramètres structurés.
Questions et discussion
Bloqué sur cette leçon ? Posez votre question ici. Et si vous pouvez répondre à quelqu'un, n'hésitez pas.
Une question rapporte 3 points, une réponse 6. Les messages paraissent après vérification.
Chargement de la discussion…