Aller au contenu principal

Demander une sortie structurée au modèle

Un code qui attend une valeur, et non un paragraphe, passe un JSON Schema à ctx.llm.complete ou ctx.llm.chat. L'appel contraint alors ce que le modèle peut émettre, vérifie ce qui revient contre ce même schéma, et vous rend la valeur validée sous la forme d'un objet Python ordinaire. Il n'y a rien à analyser et aucune expression régulière à maintenir.

Où ctx.llm est joignable​

ctx.llm est un service que le runtime peut laisser non attaché, et il vaut alors None : la référence ctx.llm le dit. Il est attaché au contexte d'un workflow dès que le runtime a un modèle configuré, et pas à celui d'un outil, qui joint le modèle par l'agent qui l'a appelé. Cette page décrit le contrat de l'appel là où le service est attaché, et c'est aussi ce que sert MockContext dans un test unitaire.

Passer un schéma, recevoir une valeur​

from apollia.types import Ctx

REPORT = {
"type": "object",
"properties": {
"title": {"type": "string"},
"count": {"type": "integer"},
},
"required": ["title", "count"],
}


async def summarize(ctx: Ctx, text: str) -> dict[str, object]:
report = await ctx.llm.complete(
[{"role": "user", "content": text}],
schema=REPORT,
)
# `report` is a dict, already checked against REPORT.
return {"title": report["title"], "count": report["count"]}

Sans schema, l'appel rend un LlmResponse et response.content est une chaîne, comme avant. Ajouter schema change le type de retour, et c'est le propos : un appel qui promet une forme ne devrait pas rendre quelque chose que vous devez encore inspecter.

Ce que le schéma peut contenir​

Objets et tableaux s'imbriquent librement, et leurs membres sont typés. Le schéma voyage en response_format, la surface de sortie structurée que le protocole OpenAI définit. Sur le moteur local llama.cpp, le llama-server embarqué en tire une grammaire qui contraint la réponse jeton par jeton, de sorte que le modèle ne peut pas émettre une forme que le schéma interdit ; un modèle qui réfléchit d'abord le fait librement, et la contrainte porte sur ce qu'il répond ensuite. Là, un schéma que le runtime ne sait pas exprimer en grammaire est refusé avant l'appel plutôt qu'assoupli.

Sur le moteur MLX et sur un serveur compatible OpenAI que vous avez déclaré, response_format est une demande : le serveur applique la contrainte dans la mesure où il l'implémente, et le runtime ne vérifie pas le schéma avant l'appel. Sur tous les backends, la réponse est validée contre le schéma avant que votre code ne la reçoive.

ConstructionPrise en charge
object avec properties, imbriquéoui
array avec items, imbriquéoui
string, number, integer, boolean, nulloui
enum, sur n'importe quel typeoui
requiredoui
minimum, maximum, minLength, maxLength, minItems, maxItemsvérifié, non contraint
anyOf, oneOf, allOf, not, $refrefusé nommément sur le moteur local llama.cpp

Les deux dernières lignes sont les intéressantes.

Une borne sur un nombre ou sur une longueur est vérifiée après la génération et jamais pendant, car aucune grammaire de décodage n'en exprime. Déclarez-les : elles sont appliquées, simplement une étape plus tard que le reste.

Sur le moteur local llama.cpp, un combinateur est refusé plutôt qu'ignoré. Une construction non prise en charge qui relâcherait la contrainte en silence vous laisserait valider une réponse que personne n'a contrainte, et en tenir le modèle pour responsable.

Quand cela échoue​

Un échec lève apollia.errors.StructuredOutputError, qui porte le chemin JSON du noeud fautif, de sorte que vous branchez sur un champ plutôt que sur une phrase.

from apollia.errors import StructuredOutputError

try:
rapport = await ctx.llm.complete(messages, schema=REPORT)
except StructuredOutputError as e:
ctx.logger.warning("sortie structurée en échec sur %s : %s", e.path, e.reason)

e.kind distingue les trois cas :

  • schema_unsupported : le schéma ne peut pas devenir une contrainte sur le moteur local llama.cpp. Levé avant l'appel au modèle, donc aucun jeton n'a été dépensé.
  • backend_unsupported : le backend résolu n'a pas de mode de sortie structurée. Aucun backend de cette version ne le lève : tous reçoivent le schéma.
  • response_invalid : la réponse est revenue et le schéma la refuse.

Rien n'est réessayé pour vous. Qu'une seconde tentative vaille ses jetons dépend de l'appelant, et le runtime n'a pas à en décider.

Ce que cela coûte​

Un appel contraint est un appel au modèle, donc il compte pour une étape dans le StepBudget de l'exécution, exactement comme un appel non contraint. Le journal d'audit note que la génération était contrainte, ainsi qu'une empreinte du schéma ; le schéma lui-même n'est jamais écrit dans le journal.

Étapes suivantes​