Una risposta rifiutata deve comunque addebitare i token
Se il runtime rifiuta la risposta, conservo i token della chiamata produce già fatturata. Onestà del ledger per agenti governabili, da mklang 1.3.7 / pull 111.
Ho tradotto questo articolo dall’inglese con un modello linguistico. Leggi l’originale in inglese
Perché conta
Non posso governare un agente se una risposta rifiutata sparisce dal ledger. Il provider ha già fatturato la chiamata. Se RunResult.usage e il costo dello step leggono 0, mento a me stesso su quanto ha speso il run.
È contabilità, non un halt più veloce. Una risposta troncata sotto on_truncate: halt, o un fallimento di parse:, resta una produce che è successa. La policy può rifiutare il testo. Non può fingere che i token non siano mai stati ordinati.
La parte sul linguaggio è in mklang: il documento è il programma. Questa nota riguarda il ledger. La ricevuta pubblica è gianlucamazza/mklang#111, uscita come pacchetto 1.3.7 il 2026-09-29. Sto leggendo quell'albero. Non aggiungo un benchmark.
Che cosa registra la ricevuta
Il CHANGELOG 1.3.7 dice il bug in una frase: una risposta prodotta che il run rifiuta viene addebitata dopo la fix, e prima no.
Prima della fix, deps.llm.produce(...) poteva tornare una risposta troncata o un testo che falliva parse:. Il motore alzava un ValueError nudo. L'except Exception generico del loop lo trasformava in state-error: … e perdeva i token della chiamata già fatta e fatturata. Il changelog registra l'osservazione in produzione: una risposta da ~4096 token sotto on_truncate="halt". Quel numero è il wording della ricevuta. Non è un claim di throughput.
Il motivo di halt era già corretto. Il ledger no.
OutputRejected è l'errore tipizzato che chiude il buco. Porta i token della chiamata come CallFailed già faceva per una sotto-macchina:
class OutputRejected(MklangError):
"""A produce call answered (and was billed) but its answer was refused."""
def __init__(self, error: str, input_tokens: int = 0, output_tokens: int = 0):
super().__init__(error)
self.error = error
self.input_tokens = input_tokens
self.output_tokens = output_tokens
_exec_produce lo alza per l'halt da troncamento e per i fallimenti di parse. Il percorso a stato singolo lo cattura prima dell'handler generico, addebita lo step, lo registra con il suo cost e si ferma con il motivo invariato: state-error: output-truncated, state-error: parse-list-truncated, o state-error: parse-json: …. Un branch fan-out rifiutato allo stesso modo tiene i suoi token nel marker [branch-error: …], come già faceva un branch call fallito.
La scheda progetto è l'indice pubblico. Spec 0.4 e pacchetto 1.3.7 sono le versioni che dichiara l'albero. Non le tratto come certificato di maturità.
Che cosa spariva
L'addebito mancante era un incidente di control flow. Dopo il ritorno di produce, due percorsi di rifiuto condividevano un raise sbagliato:
- Halt da troncamento.
on_truncate: haltvedevaProduced.truncatede alzavaValueError("output-truncated"). - Fallimento di parse. JSON parziale da uno stop per lunghezza, o testo che non era JSON valido, alzava
ValueErrorda_parse_structured.
Entrambi sono rifiuti di una risposta che esisteva già. L'handler generico non sapeva che erano stati fatturati. CallFailed lo sapeva già: una sotto-macchina in halt tiene input_tokens e output_tokens perché il parent possa addebitarli. L'output rifiutato non aveva quel tipo, così i token morivano in except Exception.
È il buco che mi interessa in un agente governabile. La macchina può fermarsi. La macchina può riparare. La macchina non può perdere il conto del lavoro già ordinato e poi dirmi che il run era economico.
Non pretendo che il vecchio percorso fosse raro. Pretendo che fosse disonesto quando succedeva. Il changelog nomina un caso di produzione. I test nominano il contratto.
Che cosa l'halt deve conservare
Due fatti devono sopravvivere insieme: il motivo e il costo.
tests/engine/test_truncation.py è il fixture. Quattro test fallivano su usage == 0 prima della fix:
test_halt_on_truncation_still_charges_the_call— motivostate-error: output-truncated; usage e ilcostdello step in halt tengono i token della chiamata.test_parse_truncated_halt_still_charges_the_call—state-error: parse-list-truncated; stesso addebito.test_unparseable_output_halt_still_charges_the_call—state-error: parse-json: output is not valid JSON (; stesso addebito.test_refused_branch_keeps_its_tokens— un fan-outsample: 2finiscedonecon marker[branch-error: parse-json: …]; i token di entrambi i branch restano sullo step.
L'LLM di fixture restituisce input_tokens=1200 e output_tokens=4096. Sono valori di test, non una distribuzione misurata in produzione. Il caso a due branch li raddoppia perché sono girate due produce. Non pubblico un modello di costo da lì.
r = run(m, {}, {m.name: m}, _billed("cut", truncated=True), TIERS, on_truncate="halt")
assert (r.status, r.error) == ("halt", "state-error: output-truncated")
assert r.usage == {"input_tokens": 1200, "output_tokens": 4096}
assert r.trace[-1]["cost"] == {"input_tokens": 1200, "output_tokens": 4096}
Il commento nel motore in src/mklang/engine.py è la policy che voglio sul muro: dal momento in cui produce torna, la chiamata è stata fatturata; una risposta rifiutata si ferma via OutputRejected, che porta i token perché l'halt li addebiti comunque.
_halt_output_rejected addebita, scrive policy="state-error", registra lo step e torna _halt(f"state-error: {e.error}"). La stringa su cui una dashboard era già agganciata non cambia. Il ledger sì.
Che cosa non ho cambiato
La pull request è esplicita sul confine.
I judge call restano fuori. Gli adapter alzano JudgeUnparseable prima di esporre l'usage. È un percorso diverso. Non l'ho piegato in OutputRejected. Un install verde di 1.3.7 non significa che un gate-judge fallito adesso addebiti allo stesso modo. Se mi servirà, è un'altra ricevuta.
I motivi di halt restano gli stessi. state-error: output-truncated è ancora state-error: output-truncated. Non ho inventato uno status nuovo per festeggiare l'onestà. Il punto è che il vecchio motivo ora sta accanto a un cost diverso da zero.
Nessun claim di performance, sicurezza o maturità. Addebitare una chiamata rifiutata non rende il modello migliore, il gate più saggio o il pacchetto pronto per la produzione. Rende vero il libro. Le eval di livello bancario restano dopo: un halt addebitato non prova che la policy fosse giusta.
Non tratto nemmeno questo come un trucco di consenso o un segnale di mercato. I token su uno step rifiutato sono un conto. Non sono un biglietto.
Pratica: il conto accanto al rifiuto
La pratica che posso sostenere è più stretta di un pitch da prodotto sul costo.
Quando eseguo una macchina che può fermarsi su truncate o parse, voglio tre cose visibili su quello step: il motivo di halt, i token di input e i token di output. Se usage è 0 dopo una produce che ha restituito testo, il ledger mente. mklang 1.3.7 è il cambio dell'interprete che ferma quella menzogna per quei due percorsi di rifiuto. Non butto il pacchetto in un host e lo chiamo governato.
Se un branch fan-out viene rifiutato, voglio i suoi token nello step come già faceva una call fallita. Se un judge non parsa una scelta, non pretendo che 1.3.7 l'abbia coperto. Se non so dire queste cose, non so rendere conto del run.
L'articolo mklang resta il posto in cui descrivo il documento. Questa pagina è il posto in cui descrivo il conto che sopravvive a un rifiuto.
FAQ
Perché addebitare una risposta che il runtime rifiuta?
Perché la produce è già successa e il provider l'ha già fatturata. Rifiutare il testo è una decisione di policy. Cancellare i token è una menzogna sul costo. Non posso governare un run il cui ledger perde il lavoro che ha ordinato.
Quali motivi di halt restano invariati?
state-error: output-truncated, state-error: parse-list-truncated e state-error: parse-json: …. La pull request tiene quelle stringhe. Cambia solo l'addebito.
I judge call addebitano allo stesso modo?
No. La ricevuta lascia i judge call come erano. Gli adapter alzano JudgeUnparseable prima di esporre l'usage. Non è il pattern di OutputRejected.
È un vantaggio di performance o di sicurezza?
No. È onestà del ledger per due percorsi di rifiuto nell'interprete di riferimento. Non pretendo un halt più veloce, un agente più sicuro o un salto di maturità.
Dov'è la ricevuta?
mklang#111, CHANGELOG 1.3.7, errors.py e tests/engine/test_truncation.py. La scheda progetto è l'indice pubblico.
Articoli correlati
mklang: il documento è il programma
Un file .mkl dichiarativo per macchine a stati LLM. I modelli generano. La macchina decide cosa succede dopo. Cosa il linguaggio garantisce e cosa no.
2 ott 20267 min di lettura#mklang#LLM#DSL#State-Machine#AgentsProof of work e inference: il costo come segnale, non come identità
L'inference non è proof of work. Distinguo due spese di compute: una regola per il consenso scarso, e un modello più un prompt per l'utilità.
2 ott 202610 min di lettura#Inference#Cost#Eval#LLM#ProductionI dati non fidati non devono possedere il flusso di controllo
Mostro come reasoning-kernel tiene i dati non fidati fuori dagli effetti: due reasoner non fidati, un gate deterministico. Topologia, non certificato.
2 ott 20268 min di lettura#Agents#Security#LLM#Python#Production