grammar — generazione vincolata per il tool_callIl 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.
tool_call ben formato; alla fine il validatore lo ricontrolla.È il “ciclo di ragionamento”. I sintomi che abbiamo visto:
{"name":"get_inputs","arguments":{"kind":"choice","from_step":1,...}}
— il nome di uno strumento con gli argomenti di un altro.request_new_executor (cioè «sintetizzami un verbo
nuovo») anche quando un executor adatto c'è già. Oppure
request_location_from_user per «crea la cartella
/tmp/x» — che con la posizione GPS non c'entra nulla.Senza la grammar il pianificatore arrivava in fondo in modo inaffidabile. Inaccettabile, per un assistente personale.
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.
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.
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.
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.
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.
| Tool | Quando va escluso |
|---|---|
request_new_executor | se ci sono ≥3 executor adatti (è un ripiego, non la scelta normale) |
request_location_from_user | se la richiesta non contiene spie di prossimità (“vicino a me”, “qui”, “nearby”) |
undo_last_turn | se la richiesta non contiene spie di annullamento (“annulla”, “ripristina”) |
*_google_workspace | se 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).
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.
name.kind ammette dieci
valori, il modello ne sceglie uno solo di quei dieci.title: "": è pur sempre una stringa. Controllarlo tocca
all'executor._MAX_RECURSION_DEPTH = 4 si ripiega su un
jsonObject generico. Casi rari, da tenere d'occhio.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.
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.
_PRIMITIVE_DEPS) ed emissione delle sole primitive davvero usate.
Prova: test_primitives_only_referenced_emitted.
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.
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:
final_answer sensato, invece di ripiegare a caso su
una via di fuga.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:
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à.
Quarantatré test in runtime/tests/test_tool_grammar.py,
tutti verdi. Coprono:
args_complexity, is_complex);get_inputs.dialog che emette la sotto-regola).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
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. |
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:
| Se accade… | …la direzione |
|---|---|
| llama.cpp integra xgrammar in modo nativo | Passare 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 soluzione | Sdoppiare il generatore in due dialetti (GBNF + automa da JSON Schema) |
runtime/tool_grammar.pyruntime/llm_provider.py::LlamaCppProvider.chat_with_toolsruntime/bench_grammar.pyruntime/tests/test_tool_grammar.py