grammar — constrained generation per il tool_callIl PLANNER di Metnos sceglie quale executor invocare a ogni
step. E’ un LLM locale (Qwen 3.6 35B-A3B). Quando la query e’ complessa — tipo
«proponi 3 orari per un appuntamento di un’ora con un familiare la mattina
della settimana prossima, dopo la scelta mandami una email con la scelta» — l’LLM a volte
entra in un loop di ragionamento e finisce per emettere prosa al posto di un
tool_call strutturato. Il sistema non sa cosa eseguire. Game over.
tool_call ben formato, infine ricontrollato dal validator.Si chiama “thinking loop”. Sintomi osservati:
{"name":"get_inputs","arguments":
{"kind":"choice","from_step":1,...}} — il nome di un tool con
gli argomenti di un altro tool.request_new_executor
(cioe’ «sintetizzati un nuovo verbo») anche quando c’e’ gia’ un
executor canonico ovvio. O request_location_from_user per la query
«crea cartella /tmp/x» (no, non mi serve la tua posizione GPS).Senza la grammar il PLANNER convergeva in modo inaffidabile. Inaccettabile per un assistente personale.
llama.cpp (il motore che esegue Qwen) supporta una feature chiamata GBNF (Grammar Backus-Naur Form). E’ un linguaggio per scrivere grammatiche formali, tipo:
root::= "{" ws "\"name\":" name ws "}"
name::= "\"get_now\"" | "\"find_files\"" | "\"send_messages\""
ws::= [ \t\n]*
Quando passi una grammar al server, l’LLM fisicamente non puo’ emettere token che la violino. Ogni token candidato a essere generato viene filtrato contro la grammar prima della scelta. E’ un binario, non una linea guida.
L’idea di e’: a ogni step del PLANNER, generiamo una grammar specifica a partire dai tools del pool dello step, e la passiamo al server. Il modello e’ libero di scegliere quale tool chiamare e quali argomenti passargli, ma SOLO entro quello che gli schemi dichiarano valido.
runtime/tool_grammar.py::generate_tool_grammar)Riceve la lista di tools del pool e ritorna una stringa GBNF. Il core della grammar e’ una discriminated union:
root::= "{" ws "\"name\":" (pairGetInputs | pairSendMessages |...) ws "}"
pairGetInputs::= "\"get_inputs\"" sep "\"arguments\":" colon (argsGetInputs)
pairSendMessages::= "\"send_messages\"" sep "\"arguments\":" colon (argsSendMessages)...
La parola chiave e’ discriminated: se l’LLM sceglie il ramo
pairGetInputs, gli args saranno per forza conformi a
argsGetInputs. Non puo’ mescolare il nome di un tool con gli args di un
altro — ed era il bug live che ci ha bloccato per mezza giornata.
Per gli args, il generator legge il JSON Schema dichiarato nel manifest TOML dell’executor e lo traduce in regole GBNF. Per i casi nested (array di object, object dentro object) il generator e’ ricorsivo:
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\""
)
Esempio reale per get_inputs: lo schema dichiara dialog
come array di object, ognuno con {var, prompt, schema}, dove
schema.kind e’ un enum di 10 valori. Tutto questo finisce nella grammar:
il modello non puo’ emettere kind: "banana", e
non puo’ dimenticarsi var.
items = {type: object} senza properties, la grammar fa
fallback su jsonObject generico, e il vincolo si perde. Per questo,
quando aggiungi un executor con strutture nested, devi dichiararle nel
manifest — la grammar lo protegge solo se lo schema e’ completo.
runtime/tool_grammar.py::filter_pool_for_grammar)Anche con la grammar perfetta, c’era un problema: certi tool sono escape-hatch — iniettati sempre nel pool per uso speciale — e con la grammar attiva il modello li sceglie a sproposito.
| Tool | Quando va escluso |
|---|---|
request_new_executor | se ci sono ≥3 tool canonical (e’ un fallback, non un default) |
request_location_from_user | se la query NON contiene marker prossimita’ (“vicino a me”, “qui”, “nearby”) |
undo_last_turn | se la query NON contiene marker undo (“annulla”, “ripristina”) |
*_google_workspace | se la query NON menziona “google”, “drive”, “gmail”... |
Il filter usa word-boundary regex (\bmarker\b) per
evitare i falsi positivi tipo «qua» ⊂ «qualcosa» che ci ha fatto
perdere un altro giro di bench. La logica e’ una funzione pura: la testi con 12
unit-test, garantisci determinismo (§7.9).
runtime/tool_grammar.py::validate_tool_call)La grammar e’ tight, ma non e’ perfetta — alcuni vincoli (es. mutua esclusivita’ tra due property) non si esprimono naturalmente in GBNF. Per quelli c’e’ un validator post-decode:
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 validator dice no, l’executor NON viene chiamato. L’errore viene
iniettato nel history dell’LLM come messaggio role=tool, e
allo step successivo il modello vede «mi mancano title e dialog»
e corregge. Un contatore consecutive_blocked previene loop infiniti.
name.kind ha 10 valori, il modello
ne sceglie SOLO uno tra quei 10.title: "": e’ una stringa. Toccaall’executor controllare._MAX_RECURSION_DEPTH = 4: oltre, fallback su
jsonObject generico. Casi rari, da monitorare.llama.cpp e’ software vivo, e la sua implementazione GBNF ha un paio di quirk’. Tutti scoperti in convergence test durante la sessione.
b540-5755a100c ignora silenziosamente le rule names che
contengono underscore. Tipo args_get_now::=... non viene
applicata. Workaround: rule names sempre camelCase. Test:
test_no_underscore_in_rule_names.
_PRIMITIVE_DEPS), emettiamo SOLO le primitives effettivamente
referenziate. Test: test_primitives_only_referenced_emitted.
grammar + tools insieme — HTTP 400.
llama-server rifiuta payload che hanno entrambi i campi. Workaround: in
grammar-mode bypassiamo tools e generiamo direttamente il JSON
{"name":..., "arguments":...}. Il chat-template di Qwen che
emette <|tool_call> markers viene anch’esso bypassato.
Sono workaround stabili, ma sono workaround. Se in futuro llama.cpp li risolve, possiamo semplificare. Sono presidiati da test.
Bench n=10 query rappresentative (simple/medium/complex/ambiguous), n=1 run per query: 100% convergenza, 100% first_tool_match, latenza media ~41s.
Tre cose vale la pena notare:
final_answer sensato, invece di scegliere un escape-hatch
a caso.La generazione della grammar e’ deterministica e veloce: per un pool tipico di 8 tool produce ~80 regole in ~2 ms. La grammar «costa» un po’ al server durante la generazione (filtering token candidati), ma il guadagno netto e’ enorme perche’ eliminiamo:
Aggiungere un nuovo provider qualifier (es. _notion o
_telegram_bot) costa una riga:
_PROVIDER_SUFFIX_MARKERS = {
"_google_workspace": ("google", "drive", "gmail",...),
"_notion": ("notion", "notion.so", "page"), # ← aggiunta
}
Aggiungere un nuovo executor con schema nested complesso costa zero: il generator legge il JSON Schema dichiarato nel manifest e produce la grammar automaticamente, ricorsivamente, fino al cap di depth.
43 unit-test in runtime/tests/test_tool_grammar.py verdi.
Coprono:
args_complexity, is_complex).get_inputs.dialog
emette sub-rule).Bench end-to-end: runtime/bench_grammar.py:
METNOS_GRAMMAR=1 python3 runtime/bench_grammar.py --label grammar_v8 # 10 query × 1 run, ~7 minuti totali # output: /tmp/bench_grammar_grammar_v8_<ts>.json # stampa tabella aggregata + per-complexity + detail riga-per-riga
Per attivare grammar mode 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 constrained generation non e’ un’invenzione di Metnos. Ecco una mappa veloce di chi fa cosa nell’ecosistema LLM/agent — serve per capire dove si colloca la scelta di e cosa abbiamo guadagnato (o rinunciato) rispetto alle alternative.
| Piattaforma | Meccanismo | Hard / soft | Schema source | 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: hard sul fatto che un tool sia chiamato; soft sulla struttura interna degli args (modello-trained, non hard-rail). | JSON Schema (subset OpenAPI) | Eccellente fluidita’, but black-box: niente controllo se il modello inventa un campo non dichiarato. Latency rete + costo $. |
| Anthropic Claude tool use | Stesso pattern OpenAI, con tool definito in input. Sonnet/Opus seguono schema strettamente, ma niente garanzia formale. | Soft (model-trained) | JSON Schema | Robustezza superiore al baseline locale, ma stesse riserve di non-localita’ e costo. Sonnet 4.6 ~$0.30/turn vs Qwen locale ~$0.001. |
| LangChain / LlamaIndex | Orchestrator: adatta function calling di provider diversi (OpenAI, Anthropic,...) sotto un’interfaccia unica. Pydantic schema in input. | Eredita dal provider sottostante (soft) | Pydantic / JSON Schema | Layer di astrazione, non aggiunge robustezza. Utile per portability; non risolve thinking-loop. |
| llama.cpp GBNF scelta Metnos | Grammatica formale Backus-Naur. Filtra i token candidati a ogni step di decoding. Nativo nel server. | Hard (sampler-level) | Manuale o generata da JSON Schema | Massimo controllo, zero dipendenze esterne, deterministico. Quirks bug-server (vedi §5). Locale, no costi $. |
| Outlines / Instructor | Libreria Python che costruisce un FSM (finite state machine) dal JSON Schema/Pydantic e lo applica al decoding (token-level mask). | Hard (sampler-level) | Pydantic / JSON Schema | Stesso paradigma di GBNF ma library-side. Dipendenza nuova, supporto modelli limitato (HuggingFace + vllm). Per Metnos = piu’ peso, stesso risultato. |
| xgrammar (vllm) | Libreria C++ che compila JSON Schema in maschere di token. Integrata in vllm e a breve in altri serving stack. | Hard (sampler-level) | JSON Schema | Equivalente a GBNF, performance superiore in alcuni scenari. Non ancora in llama.cpp stable. |
| JSON Mode (OpenAI/Anthropic) | Garantisce JSON valido sintatticamente, niente vincolo su schema. | Hard sulla sintassi JSON, soft sullo schema | Nessuno | Utile come fallback, non basta per tool_call strutturato. |
La scelta GBNF di e’ allineata con la frontiera tecnica: i serving stack moderni stanno tutti convergendo su sampler-level constraints (Outlines, xgrammar, llama.cpp GBNF). E’ il pattern state of the art per tool calling locale.
La differenza rispetto a OpenAI/Anthropic e’ che noi scriviamo la grammar invece di delegarla a un modello fine-tuned. Vantaggi:
Svantaggi:
| Trigger | Direzione |
|---|---|
| llama.cpp introduce xgrammar nativo | Switch a xgrammar (stessa semantica, performance migliori) |
| Multi-modello cross-provider (us. switch Qwen ↔ Sonnet) | Aggiungere adapter Outlines per provider non-llama.cpp |
| Query corpus convergenza scende sotto 95% | Investigare se è il modello o il filter; eventuale fine-tune locale |
| Bug llama-server irrisolvibili | Fork generator a 2 dialetti (GBNF + JSON Schema FSM) |
runtime/tool_grammar.pyruntime/llm_provider.py::LlamaCppProvider.chat_with_toolsruntime/bench_grammar.pyruntime/tests/test_tool_grammar.py