· 10 min de lecture

Comment nous avons rendu des LLM bon marché fiables sur DynamoDB : validateurs d’outils + évals

L’assistant IA de DynoTable s’exécute sur tes propres identifiants , ce qui veut dire que c’est toi qui choisis le modèle — et beaucoup de gens en choisissent un bon marché. Nous ne calibrons donc pas l’assistant face à un modèle frontier. Notre plancher, c’est Amazon Nova Lite, un modèle qui coûte une erreur d’arrondi par requête et qui échoue de façons dont les modèles frontier ne le font pas.

Voici la chute de tout cet article : sur un scénario d’ — une jointure qui a besoin de la table à clé composite comme base — Nova Lite a obtenu 0 %. Nous n’avons pas changé de modèle, ni ajouté d’exemples few-shot, ni fait de fine-tuning. Nous avons réécrit un seul message d’erreur de validateur pour dire exactement quoi faire à la place. Il est passé à 100 %, en basculant la jointure dès l’étape suivante.

Le modèle n’a jamais été le goulot d’étranglement. C’était le message.

Voici l’histoire d’ingénierie derrière cette leçon : les validateurs qui entourent chaque appel d’outil, les choses que nous ne validons délibérément pas, et le harnais d’évals qui arbitre chaque changement. Si tu construis un sur un modèle économique — que ce soit sur DynamoDB ou autre chose — l’essentiel se transpose.

Pourquoi les modèles bon marché échouent spécifiquement sur DynamoDB

DynamoDB est une cible hostile pour un petit modèle parce que la surface de requêtage ressemble à du SQL sans en être. PartiQL accepte la syntaxe SELECT mais ne prend en charge ni JOIN, ni GROUP BY, ni DISTINCT, ni LIKE — et un modèle qui a appris le SQL sur internet les émet tous avec assurance.

Les échecs que nous avons réellement observés, chacun tiré d’une exécution réellement journalisée :

  • Arguments limit oubliés. Les omettent régulièrement la limite de lignes optionnelle sur un outil de Query. Le backend renvoie joyeusement tout, un résultat d’outil de 200 KB atterrit dans la conversation, et le tour de modèle suivant meurt sur le plafond de contexte — une ValidationException en pleine boucle, imputée au mauvais composant.
  • Constructions non prises en charge. JOIN, GROUP BY, agrégats en PartiQL — des instructions que DynamoDB n’exécutera jamais, émises parce qu’elles seraient correctes sur Postgres.
  • Substitutions d’identifiants. Nova Lite, avec SELECT * FROM customers et l’instruction « ajoute un WHERE pour l’Allemagne », renvoyait parfois SELECT * FROM orders WHERE … — une table que l’utilisateur n’a jamais mentionnée. L’instruction se parse parfaitement. Rien de syntaxique ne l’attrape.
  • Mauvais routage d’outils. Interrogé sur un fichier joint, Nova Lite appelait l’outil de listing des onglets ouverts en cherchant des fichiers dans les onglets — il utilisait rarement le mécanisme de recherche d’outils, si bien que tout ce qui n’était pas directement visible n’existait pas.

Aucun de ces cas n’est hypothétique. Chacun a motivé un garde-fou spécifique ci-dessous.

Valider à la frontière de l’outil, et faire de l’erreur une leçon

La décision d’architecture la plus importante : la validation vit à l’intérieur de l’étape d’exécution de l’outil, pas dans le schema d’entrée. Un rejet de schema est une impasse — la boucle voit une erreur de type et n’apprend rien. Un outil qui s’exécute, vérifie et renvoie une erreur structurée donne au modèle un résultat d’outil normal auquel il peut réagir à l’étape suivante.

Et cette erreur est écrite pour le modèle, pas pour un journal humain. Les erreurs de notre outil d’export se lisent ainsi, à l’identique :

startExport requires exactly one of {tabId} or {sql}.
FIX: call listOpenTabs and pass {tabId}, or pass {sql} with a single SELECT.
startExport {sql} must be a read-only single SELECT.
FIX: remove any INSERT/UPDATE/DELETE/DDL and pass one SELECT statement.

Les échecs de validation de Query portent un préfixe validation:validation: parse-error: …, validation: partiql-unsupported: JOIN is not supported by DynamoDB PartiQL. — que le system prompt établit comme le signal de corriger l’instruction plutôt que de réessayer la même entrée. Un message de rejet est un signal d’apprentissage. Avant que nous adoptions la convention FIX:, les mêmes échecs se terminaient par le modèle qui s’excusait et disait à l’utilisateur de cliquer lui-même sur le bouton d’export. Après, il se corrige tout seul et accomplit la tâche.

Le corollaire a mis plus de temps à s’imposer : l’endroit où vit l’exemple concret est un axe de conception. Si une erreur est attrapée par un validateur à l’exécution, garde le system prompt générique et laisse le message d’erreur dynamique porter les tables spécifiques et le correctif — le prompt reste petit et la leçon arrive exactement quand il le faut. Seules les erreurs qu’aucun validateur ne peut attraper (comme router d’emblée une question d’agrégation vers le bon outil) méritent un exemple concret dans le prompt lui-même.

DynamoDBTool boundaryModelDynamoDBTool boundaryModelrunPartiQL("SELECT … JOIN …")validation: partiql-unsupported:JOIN is not supported by DynamoDB PartiQL.runWorkbenchSql("SELECT … JOIN …")streamed pages (capped)rows10 rows / 5 KB + "Showing 10 of 4,200…"

Parser les choses que tu vas rejeter

Rejeter JOIN avec un générique syntax error at offset 27 n’apprend rien. Notre parseur PartiQL écrit à la main fait donc quelque chose d’un peu pervers : il parse délibérément des constructions que DynamoDB ne prend pas en charge en un arbre syntaxique valide — juste pour qu’un walker puisse émettre un diagnostic spécifique et pédagogique. Tout à l’identique, tout destiné au modèle :

  • JOIN is not supported by DynamoDB PartiQL.
  • GROUP BY is not supported by DynamoDB PartiQL.
  • TOP N is not supported. Use the API limit parameter.
  • IN list has 120 items. DynamoDB PartiQL caps IN at 50 values (PK column) or 100 values (non-key column).
  • IS NOT NULL is not supported in DynamoDB PartiQL. Use attribute_exists.
  • lower(path) is not a DynamoDB PartiQL function. Use … instead. — avec le remplacement résolu par fonction.

Chaque message nomme la construction et l’issue de secours. Couplé à la règle du prompt « si PartiQL rejette une construction non prise en charge, passe immédiatement au workbench SQL », un modèle bon marché se remet de ses instincts Postgres en une étape au lieu de s’agiter dans le vide.

Savoir ce qu’il ne faut PAS valider

Le niveau conscient du schema de notre validateur SQL résout les références de tables et de colonnes par rapport aux descriptions de tables réelles que l’app détient déjà. Au début, il signalait aussi les références à des attributs que DynamoDB ne déclarait pas — ce qui semble juste et est exactement faux. Les attributeDefinitions de DynamoDB ne listent que les attributs de clé, donc la vérification rejetait WHERE <non-key> = … — la forme de requête légitime la plus courante qui existe. Cette vérification est désormais un avertissement qui ne bloque jamais.

Chaque validateur est un pari que la forme rejetée est plus probablement fausse que juste. Quand le modèle de données ne peut pas soutenir la vérification, ne fais pas le pari.

Les plafonds de résultats comme comportement, pas seulement comme sécurité

Le correctif pour l’explosion du limit oublié n’était pas « ajouter la limite au prompt » (les modèles bon marché l’oublient de toute façon). Les résultats de l’outil de Query sont plafonnés durement — 10 lignes ou 5 KB, selon ce qui arrive en premier, en gardant toujours au moins la première ligne — et l’avis de troncature est lui-même prescriptif :

Showing 10 of 4,200. To filter: re-run with WHERE.
To view in UI: emit proposeOpenTable.
To aggregate: use runWorkbenchSql GROUP BY.

Les plafonds sont volontairement bien en dessous de ce que le contexte pourrait contenir. L’objectif est comportemental : forcer le modèle à remettre à l’utilisateur une vraie vue de table ou un agrégat exact, jamais à énumérer des lignes dans le chat. L’indice de récupération nomme les outils exacts, si bien que le plafond enseigne le routage au moment où le modèle en a besoin. La même enveloppe est répliquée octet pour octet dans le harnais d’évals, et un scénario de régression épingle truncated: true face à une table de 10 000 items pour que le plafond ne puisse jamais disparaître en silence.

Au lieu d’énumérer des lignes dans le chat, l’assistant émet une pastille sur laquelle tu cliques pour ouvrir le résultat comme un véritable onglet DynoTable.
Au lieu d’énumérer des lignes dans le chat, l’assistant émet une pastille sur laquelle tu cliques pour ouvrir le résultat comme un véritable onglet DynoTable.

Les évals sont l’arbitre

Rien de ce qui précède n’a été conçu à l’intuition et gardé par foi. Chaque clause de prompt, message de validateur et plafond est verrouillé par une suite d’évals qui exécute de vraies boucles d’agent contre un DynamoDB Local pré-alimenté — de vrais outils, une vraie exécution de requêtes, les constructeurs de prompts et validateurs de production importés directement plutôt que réimplémentés — notés par des vérifications binaires sur la trace des outils, l’état final de la base de données et le texte.

Ce que les évals ont attrapé et que l’intuition a raté :

  • Une généricisation « plus propre » d’un prompt a fait régresser un scénario de 100 % à 0 %. Ça se lisait mieux pour nous. Le modèle plancher n’était pas d’accord. Seule l’éval l’a remarqué.
  • Les conseils de prompt pour modèles frontier sont carrément faux pour les modèles bon marché. Les recommandations publiées disent d’adoucir le langage CRITICAL/MUST parce que les gros modèles sur-réagissent. Les modèles bon marché sous-obéissent et ont besoin de la version ferme. Nous verrouillons désormais par éval tout conseil emprunté au lieu de l’appliquer par foi.
  • Des assertions strictes sur les outils punissaient un comportement correct. Un scorer qui exigeait que le modèle appelle l’outil ask pour demander des précisions passait à 19 % sur toute l’échelle de modèles — presque chaque « échec » était un modèle qui avait correctement demandé des précisions en texte. Le scorer accepte désormais les deux. Note l’intention perçue par l’utilisateur, pas le mécanisme.
  • L’échelle de modèles a mérité son resserrement. Nous avons commencé large — Mistral, AI21, GLM, Qwen, Nova Pro, et d’autres — et l’avons réduite à deux échelons (Nova Lite, Claude Haiku) pour des raisons que seules de vraies exécutions font émerger : une famille coûtait ~10× par appel parce que Bedrock ne lui offrait aucun prompt caching ; Nova Pro hallucinait des noms d’attributs concaténés comme customers.customerName sur les jointures ouvertes. Et une famille a dû être mise sur liste de blocage purement et simplement — l’API unifiée de Bedrock accepte volontiers des définitions d’outils pour des modèles dont l’adaptateur émet ensuite les appels de fonction en texte brut, ce que les métadonnées de l’API ne te diront pas. Nous l’avons découvert avec une sonde smoke payante, pas avec une spec.

Deux disciplines gardent la suite honnête. Les tables d’exemple ne sont jamais égales aux tables de test : les exemples de prompt utilisent des noms neutres qui n’existent pas dans les données de seed, et les évals s’exécutent sur des tables différentes — ainsi une réussite prouve que le modèle a généralisé le motif, pas qu’il a mémorisé notre exemple. Et le mode watch à 0 $ refuse les fixtures enregistrées : des sorties de modèle rejouées feraient passer au vert des assertions qui dépendent d’une vraie activité d’outils, donc le palier gratuit ne valide que le câblage, et tout ce qui revendique un score a dû dépenser de vrais tokens sur un vrai modèle.

La checklist, si tu construis l’un de ces systèmes

  • Choisis d’abord ton modèle plancher et évalue toujours par rapport à lui ; les modèles frontier cachent tes bugs.
  • Mets la validation dans l’étape d’exécution de l’outil ; renvoie des erreurs sur lesquelles la boucle peut agir, jamais des impasses de schema.
  • Écris chaque rejet sous la forme what's wrong + FIX: exact next action, et interpole-y les vraies tables de l’utilisateur.
  • Parse ce que tu comptes rejeter, pour que le diagnostic puisse être spécifique.
  • Audite chaque validateur par rapport à ton modèle de données — supprime ceux qui rejettent des formes légitimes.
  • Plafonne les résultats d’outils en dessous de ce qui tient, et fais en sorte que l’avis de troncature oriente le modèle vers le bon outil suivant.
  • Exécute les évals comme de vraies boucles contre un vrai backend (local), en réutilisant les chemins de code de production ; garde les données d’exemple disjointes des données de test ; fais de l’éval — pas du goût — l’arbitre de chaque changement de prompt et de message.

Est-ce que tout cela compte si tu utilises un modèle frontier ?

Moins souvent, mais oui. Les validateurs sont porteurs à tous les niveaux — un modèle frontier ne peut toujours pas exécuter JOIN sur DynamoDB, et les erreurs prescriptives sont simplement consommées plus vite. Sur nos propres exécutions, le comportement d’émission de pastilles d’un modèle de classe frontier est une fois tombé à 0 % sous une formulation de prompt que des modèles plus petits géraient — le harnais d’évals a attrapé ça aussi. L’ingénierie de la fiabilité n’est pas une taxe sur les petits modèles ; les petits modèles font juste arriver la facture plus tôt.

Où tout cela s’exécute

Tout cela est livré dans l’assistant de DynoTable et son catalogue d’outils sous contrôle : du requêtage en langage naturel conscient du schema sur tes propres identifiants Bedrock, des agrégats exacts sur des tables entières, et des écritures qui n’atterrissent jamais que dans une zone de staging vérifiable. Les agents externes obtiennent la même boîte à outils validée via le serveur MCP — comment nous avons construit cela en toute sécurité est une histoire à part, d’OAuth à l’isolation des identifiants.

Et quand tu veux du déterminisme plutôt que de la génération — la requête que tu vas committer dans ton code — saute le modèle entièrement : le Générateur d’expressions assemble la requête exacte à la main.

Travaille avec DynamoDB sans la Console

DynoTable est un client de bureau rapide pour DynamoDB — parcours les tables, exécute des requêtes de style SQL et édite les items en local.