← Indice documentazione Microprogettazione › grammar

Metnos

grammar — generazione vincolata per il tool_call
Microprogettazione
Allineata ai moduli runtime/tool_grammar.py, runtime/llm_provider.py, runtime/agent_runtime.py.

Pubblico: chi vuole capire perché Metnos non “chiacchiera” mentre pianifica
e cosa accade fra il modello e l’executor.
Lettura: 12 minuti, zero matematica.

Indice

  1. Il problema: l’LLM “ci pensa su” e si perde
  2. L’idea: la grammatica come binario
  3. I tre pezzi: generatore, filtro, validatore
  4. Cosa garantisce e cosa no
  5. Perche’ tre difetti del llama-server ci hanno fatto perdere mezza giornata
  6. Robustezza, velocita’, estensibilita’
  7. Come si prova
  8. Confronto con altre piattaforme
  9. Riferimenti

1. Il problema: l’LLM “ci pensa su” e si perde

Il pianificatore di Metnos sceglie quale executor invocare a ogni passo. È un modello linguistico locale. Quando la richiesta si fa complessa — poniamo «proponi tre orari per un appuntamento di un'ora con un familiare, una mattina della settimana prossima; poi mandami la scelta per email» — il modello certe volte si avvita in un ragionamento e finisce per scrivere prosa invece di un tool_call strutturato. Il sistema non sa più cosa eseguire. Fine della corsa.

richiesta utente + catalogo tool GBNF grammatica generata dal pool filtrato decodifica vincolata llama.cpp + modello tool_call valido name + args coerenti validator post-decode
Figura 1 — La decodifica vincolata dalla grammatica: dal pool degli strumenti nasce la GBNF, che obbliga il modello a produrre un solo tool_call ben formato; alla fine il validatore lo ricontrolla.

È il “ciclo di ragionamento”. I sintomi che abbiamo visto:

Senza la grammar il pianificatore arrivava in fondo in modo inaffidabile. Inaccettabile, per un assistente personale.

2. L’idea: la grammatica come binario

llama.cpp (il motore che fa girare il modello locale) offre uno strumento chiamato GBNF (Grammar Backus-Naur Form): un linguaggio per scrivere grammatiche formali, così:

root::= "{" ws "\"name\":" name ws "}"
name::= "\"get_now\"" | "\"find_files\"" | "\"send_messages\""
ws::= [ \t\n]*

Quando si dà una grammar al server, il modello non può proprio emettere token che la violino: ogni token candidato passa al vaglio della grammar prima di essere scelto. È un binario, non un consiglio.

Un'immagine. Un vincolo blando, affidato al prompt, è come dire “per favore, tieni la destra”. Una grammar è il guardrail: il primo si può ignorare, il secondo no.

L'idea, allora, è questa: a ogni passo del pianificatore costruiamo una grammar su misura a partire dagli strumenti del pool di quel passo, e la passiamo al server. Il modello resta libero di scegliere quale strumento chiamare e con quali argomenti, ma solo entro ciò che gli schemi dichiarano valido.

3. I tre pezzi: generatore, filtro, validatore

3.1 Generatore (runtime/tool_grammar.py::generate_tool_grammar)

Riceve l'elenco degli strumenti del pool e restituisce una stringa GBNF. Il cuore della grammar è un'unione discriminata:

root::= "{" ws "\"name\":" (pairGetInputs | pairSendMessages |...) ws "}"
pairGetInputs::= "\"get_inputs\"" sep "\"arguments\":" colon (argsGetInputs)
pairSendMessages::= "\"send_messages\"" sep "\"arguments\":" colon (argsSendMessages)...

La parola che conta è discriminata: se il modello imbocca il ramo pairGetInputs, gli argomenti saranno per forza conformi ad argsGetInputs. Non può più sposare il nome di uno strumento sugli argomenti di un altro — ed era proprio il difetto, visto dal vivo, che ci aveva bloccato per mezza giornata.

Per gli argomenti, il generatore legge il JSON Schema dichiarato nel manifest TOML dell'executor e lo traduce in regole GBNF. Per le strutture annidate (un array di oggetti, un oggetto dentro un oggetto) procede per ricorsione:

argsGetInputs::= "{" ws propGetInputsTitle sep propGetInputsDialog
 (sep (propGetInputsFromStep |...))* ws "}"
propGetInputsDialog::= "\"dialog\":" colon (
 "[" ws (GetInputsObjD1I0 (sep GetInputsObjD1I0)*)? ws "]"
)
GetInputsObjD1I0::= "{" ws propGetInputsObjD1I0Var sep propGetInputsObjD1I0Prompt sep
 propGetInputsObjD1I0Schema (sep (...))* ws "}"
propGetInputsObjD1I0Schema::= "\"schema\":" colon (GetInputsObjD3I2)
GetInputsObjD3I2::= "{" ws propGetInputsObjD3I2Kind (sep (...))* ws "}"
propGetInputsObjD3I2Kind::= "\"kind\":" colon (
 "\"text\"" | "\"credentials\"" | "\"yes_no\"" | "\"choice\"" |
 "\"choice_with_preview\"" | "\"multi_choice\"" | "\"number\"" |
 "\"date\"" | "\"file_path\"" | "\"location\""
)

Un caso vero, get_inputs: lo schema dichiara dialog come array di oggetti, ciascuno con {var, prompt, schema}, dove schema.kind è un elenco chiuso di dieci valori. Tutto questo finisce nella grammar: il modello non può emettere kind: "banana", e non può dimenticare var.

Lo schema è la fonte di verità. Se il manifest dichiara items = {type: object} senza properties, la grammar ripiega su un jsonObject generico e il vincolo si perde. Per questo, quando aggiungi un executor con strutture annidate, devi dichiararle nel manifest: la grammar ti protegge solo se lo schema è completo.

3.2 Filtro del pool (runtime/tool_grammar.py::filter_pool_for_grammar)

Anche con una grammar perfetta restava un problema: certi strumenti sono vie di fuga — sempre presenti nel pool per usi speciali — e con la grammar attiva il modello li imbocca a sproposito.

ToolQuando va escluso
request_new_executorse ci sono ≥3 executor adatti (è un ripiego, non la scelta normale)
request_location_from_userse la richiesta non contiene spie di prossimità (“vicino a me”, “qui”, “nearby”)
undo_last_turnse la richiesta non contiene spie di annullamento (“annulla”, “ripristina”)
*_google_workspacese la richiesta non nomina “google”, “drive”, “gmail”…

Il filtro confronta le parole intere (\bspia\b) per non cadere in falsi positivi come «qua» dentro «qualcosa», che ci è costato un altro giro di prove. La logica è una funzione pura: la provi con dodici test e ne garantisci il determinismo (§7.9).

3.3 Validatore (runtime/tool_grammar.py::validate_tool_call)

La grammar è stretta, ma non perfetta: certi vincoli — per esempio l'esclusione reciproca fra due proprietà — in GBNF non si esprimono con naturalezza. Per quelli c'è un validatore, che interviene dopo la decodifica:

tool_call = {"name": "get_inputs", "arguments": {"display_template": "x"}}
ok, err = validate_tool_call(tool_call, pool)
# ok=False, err="missing required args ['title', 'dialog'] for tool 'get_inputs'"

Se il validatore dice no, l'executor non viene chiamato. L'errore entra nella cronologia del modello come messaggio role=tool, e al passo dopo il modello legge «mancano title e dialog» e si corregge. Un contatore (consecutive_blocked) impedisce che la cosa giri all'infinito.

4. Cosa garantisce e cosa no

Garantisce

Non garantisce

5. Perche’ tre difetti del llama-server ci hanno fatto perdere mezza giornata

llama.cpp è software vivo, e la sua resa di GBNF ha un paio di stranezze. Le abbiamo scovate tutte nelle prove di convergenza, durante la sessione.

1. I nomi di regola con underscore vengono ignorati. La versione b540-5755a100c scarta in silenzio i nomi di regola che contengono un underscore: args_get_now::=..., per dire, non viene applicata. Rimedio: nomi di regola sempre in camelCase. Prova: test_no_underscore_in_rule_names.
2. Le regole inutilizzate interferiscono. Anche una regola che nessun'altra richiama può, con la sola presenza, falsare il riconoscimento. Rimedio: chiusura delle dipendenze (_PRIMITIVE_DEPS) ed emissione delle sole primitive davvero usate. Prova: test_primitives_only_referenced_emitted.
3. grammar e tools insieme — HTTP 400. Il llama-server rifiuta una richiesta che porti entrambi i campi. Rimedio: in modalità grammar mettiamo da parte tools e generiamo direttamente il JSON {"name":..., "arguments":...}; anche il modello di chat che emette i marcatori <|tool_call> resta fuori gioco.

Sono rimedi stabili, ma restano rimedi: se un domani llama.cpp risolve i tre casi, potremo semplificare. Intanto li presidiano i test.

6. Robustezza, velocita’, estensibilita’

Robustezza

Prova su dieci richieste rappresentative (semplici, medie, complesse, ambigue), una esecuzione per richiesta: convergenza 100%, strumento giusto al primo colpo 100%, latenza media ~41 s.

Tre cose vale la pena notare:

Velocita’

Generare la grammar è veloce e deterministico: per un pool tipico di otto strumenti produce un'ottantina di regole in circa 2 ms. Al server la grammar costa un po' durante la generazione (deve filtrare i token candidati), ma il guadagno netto è enorme, perché toglie di mezzo:

Estensibilita’

Aggiungere un nuovo qualifier di fornitore (per esempio _notion o _telegram_bot) costa una riga:

_PROVIDER_SUFFIX_MARKERS = {
 "_google_workspace": ("google", "drive", "gmail",...),
 "_notion": ("notion", "notion.so", "page"), # ← aggiunta
}

Aggiungere un executor con uno schema annidato complesso costa zero: il generatore legge il JSON Schema del manifest e produce la grammar da sé, per ricorsione, fino al limite di profondità.

7. Come si prova

Quarantatré test in runtime/tests/test_tool_grammar.py, tutti verdi. Coprono:

Prova dall'inizio alla fine: runtime/bench_grammar.py:

METNOS_GRAMMAR=1 python3 runtime/bench_grammar.py --label grammar_v8
# 10 richieste × 1 esecuzione, ~7 minuti in tutto
# esito: /tmp/bench_grammar_grammar_v8_<ts>.json
# stampa la tabella aggregata, quella per complessità e il dettaglio riga per riga

Per attivare la modalità grammar in sviluppo:

echo '[Service]
Environment=METNOS_GRAMMAR=1' | sudo tee /etc/systemd/system/metnos-http.service.d/grammar.conf
sudo systemctl daemon-reload && sudo systemctl restart metnos-http.service

8. Confronto con altre piattaforme

La generazione vincolata non è un'invenzione di Metnos. Ecco una mappa rapida di chi fa cosa nell'ecosistema degli LLM e degli agenti: serve a capire dove si colloca la nostra scelta, e cosa ci abbiamo guadagnato (o a cosa abbiamo rinunciato) rispetto alle alternative.

Piattaforma Meccanismo Rigido / flessibile Origine schema Note
OpenAI function calling / tools Modello fine-tuned su tool_call. Server emette JSON conforme al JSON Schema della function dichiarata. Con tool_choice="required" forza l’uso di un tool. Misto: rigido sul fatto che uno strumento venga chiamato; flessibile sulla struttura interna degli argomenti (appresa dal modello, non imposta). JSON Schema (sottoinsieme OpenAPI) Fluidità eccellente, ma scatola nera: nessun controllo se il modello inventa un campo non dichiarato. In più, latenza di rete e costo in denaro.
Anthropic Claude tool use Stesso schema di OpenAI, con lo strumento definito in ingresso. Sonnet e Opus seguono lo schema da vicino, ma senza garanzia formale. Flessibile (appresa dal modello) JSON Schema Più robusta del modello locale di base, ma con le stesse riserve: non è locale e costa. Sonnet 4.6 ~$0,30 a turno contro ~$0,001 del modello locale.
LangChain / LlamaIndex Orchestratore: porta il function calling di fornitori diversi (OpenAI, Anthropic, …) sotto un'unica interfaccia. Schema Pydantic in ingresso. Eredita dal fornitore sottostante (flessibile) Pydantic / JSON Schema Uno strato di astrazione, non aggiunge robustezza. Comodo per la portabilità; non risolve il ciclo di ragionamento.
llama.cpp GBNF scelta Metnos Grammatica formale Backus-Naur. Filtra i token candidati a ogni step di decoding. Nativo nel server. Rigido (a livello di campionamento) Manuale o generata dal JSON Schema Massimo controllo, nessuna dipendenza esterna, deterministico. Qualche stranezza del server (vedi §5). Locale, senza costi in denaro.
Outlines / Instructor Libreria Python che costruisce un automa a stati finiti dal JSON Schema/Pydantic e lo applica alla decodifica (maschera sui token). Rigido (a livello di campionamento) Pydantic / JSON Schema Stesso principio di GBNF, ma lato libreria. Dipendenza nuova, modelli supportati pochi (HuggingFace + vllm). Per Metnos: più peso, stesso risultato.
xgrammar (vllm) Libreria C++ che compila il JSON Schema in maschere di token. Integrata in vllm, e presto in altri ambienti di servizio. Rigido (a livello di campionamento) JSON Schema Equivalente a GBNF, con prestazioni migliori in certi casi. Non ancora nella versione stabile di llama.cpp.
JSON Mode (OpenAI/Anthropic) Garantisce un JSON valido nella sintassi, ma nessun vincolo sullo schema. Rigido sulla sintassi JSON, flessibile sullo schema Nessuno Utile come ripiego, non basta per un tool_call strutturato.

Dove si colloca Metnos

La scelta di GBNF è in linea con la frontiera tecnica: gli ambienti di servizio moderni stanno convergendo tutti sui vincoli a livello di campionamento (Outlines, xgrammar, GBNF di llama.cpp). È oggi l'approccio più avanzato per il tool calling in locale.

La differenza rispetto a OpenAI e Anthropic è che la grammar la scriviamo noi, invece di affidarla a un modello addestrato apposta. I vantaggi:

Gli svantaggi:

Quando avrebbe senso cambiare

Se accade……la direzione
llama.cpp integra xgrammar in modo nativoPassare a xgrammar (stessa semantica, prestazioni migliori)
Più modelli di fornitori diversi (per esempio alternare il locale e un modello di frontiera)Aggiungere un adattatore Outlines per i fornitori che non usano llama.cpp
La convergenza sul corpus scende sotto il 95%Capire se è il modello o il filtro; eventualmente un addestramento locale mirato
Difetti del llama-server senza soluzioneSdoppiare il generatore in due dialetti (GBNF + automa da JSON Schema)

9. Riferimenti