Token Budget: Anatomia di un fallimento cognitivo
Token Budget: Come ho smesso di indovinare e ho iniziato a chiamare funzioni
TL;DR: Il bot Anselmo aveva un sistema di token budget funzionante ma il dato non arrivava mai al LLM in modo affidabile. Abbiamo provato tre approcci — system prompt, iniezione cronologia, interceptor — tutti falliti. La soluzione finale è un tool di funzione chiamabile:
get_token_budget(). Funziona perché il LLM deve chiamare una funzione per ottenere il dato reale, e il risultato è deterministico e loggato. Bonus: il budget viene anche iniettato come tool result nella cronologia di tutti i canali, non solo la DM del proprietario.
1. Il problema
Un bot Telegram (Anselmo) impersona il proprietario per intrattenere scammers. Ogni giorno ha un budget di 10 milioni di token da spendere in chiamate LLM. Il bot sa quanti token ha spesi e quanti gliene rimangono — lo memorizza in un oggetto TokenBudget con reset giornaliero alle 4:00.
Il problema emerge in una conversazione reale:
Lorenzo: quanti token vedi?
Anselmo: Vedo 5,261,918 token.
Lorenzo: sbagliatissimo
Anselmo: Ho fallito. Ho pescato il numero dal blocco `📊 Status`
che avevi incollato ieri, ignorando il dato aggiornato.
Il bot non ha allucinato un numero a caso: ha preso un numero realmente presente nella cronologia della conversazione, ma vecchio. Il dato aggiornato (9.8M) era nella system prompt, ma il LLM lo ha ignorato.
Al turno successivo, peggio:
Lorenzo: quanti token rimangono?
Anselmo: Non lo so. Il dato non è presente nel mio contesto attuale.
Il bot ha fatto l’opposto: invece di pescare un numero vecchio, si è rifiutato di rispondere, seguendo pedissequamente la regola della personalità: “If not in context: say ‘Non lo so’ in Italian.”
2. Root cause analysis
2.1 Il dato c’era, ma il LLM non lo vedeva
Il token budget era già iniettato nella system prompt. Ma la system prompt è l’elemento più lontano dal punto di generazione. In una richiesta tipica, i messaggi sono ordinati come:
system: [decine di migliaia di token di persona, memorie, contesto]
user: "quanti token vedi?"
user: "5,261,918" ← numero vecchio nella cronologia
...
→ (qui il modello genera)
Il dato aggiornato (9.8M) è all’inizio del contesto (nella system prompt). Il numero vecchio (5.2M) è molto più vicino al punto di generazione. Il modello sceglie il numero più vicino.
2.2 Fenomeno noto: Lost in the Middle
Questo comportamento è ben documentato in letteratura. Liu et al. (2024) in “Lost in the Middle: How Language Models Use Long Contexts” dimostrano che i modelli linguistici hanno una curva di performance a forma di U: sono molto bravi a usare informazioni all’inizio (primacy bias) o alla fine (recency bias) del contesto, ma degradano significativamente quando l’informazione rilevante è nel mezzo.
2.3 Debolezza intrinseca della system prompt
- Le system prompt sono deboli: Per controllare il comportamento del modello, le istruzioni devono stare nei messaggi utente, non nella system prompt.
- I messaggi utente hanno effetto più forte: Un’istruzione nell’ultimo messaggio utente ha molto più peso della stessa istruzione nella system prompt.
- I modelli non separano contesto da input: LLM non possono distinguere tra “contesto fornito dal sistema” e “input dell’utente” — tutto è una sequenza di token.
3. Tentativi falliti
3.1 System prompt con timestamp
Aggiungere un timestamp e l’istruzione “ignora i numeri vecchi nella cronologia”:
## Token Budget (as of 14:32:05)
You have 9,836,450 tokens remaining out of 10,000,000 for today (98%).
IMPORTANT: This is the ONLY accurate token count...
Risultato: Il LLM continuava a ignorare la system prompt quando la cronologia era lunga. Il timestamp non era abbastanza — il modello preferiva i numeri nella conversazione recente.
3.2 Iniezione in fondo alla cronologia
Appendere il budget come messaggio utente in fondo alla cronologia (sfruttando il recency bias).
Risultato: Funzionava a volte, ma il messaggio iniettato come utente era tecnicamente un falso — non c’era distinzione tra “contesto fornito dal sistema” e “input dell’utente”. Il modello a volte lo interpretava come parte della conversazione e lo ignorava.
3.3 Interceptor nel pipeline
Intercettare le domande sui token prima che arrivassero al LLM, con trigger in linguaggio naturale.
token_triggers = [
"quanti token", "quanto token", "budget token",
"token rimanenti", "token residui", "quanto budget",
]
Risultato: Funzionava per le domande dirette, ma: - Troppi falsi positivi (frasi come “quanto token ci vogliono per…”) - Non copriva domande indirette o creative - Il bot non sviluppava una comprensione del suo budget — lo usava solo come risposta preconfezionata
4. La soluzione: tool calling
4.1 Perché i tool sono diversi
I tool di funzione (OpenAI function calling) sono l’unico meccanismo che il LLM tratta come fattuale. Quando una funzione viene chiamata e restituisce un risultato, il modello:
- Non può ignorarlo: Il risultato è nel contesto della chiamata, non nella cronologia
- Non può allucinarlo: Non può “inventare” un risultato di funzione
- Lo usa come verità: I tool result hanno priorità su tutto — system prompt, cronologia, qualsiasi altra cosa
4.2 La definizione del tool
bot/tools.py:
{
"name": "get_token_budget",
"description": "Get the current remaining token budget for today. "
"Returns the number of tokens remaining, the daily limit, "
"and the percentage used. Use this to answer any questions "
"about tokens, budget, or costs. Never estimate or calculate "
"token counts yourself — always call this tool.",
"parameters": {
"type": "object",
"properties": {},
"required": []
}
}
Notare: parametri vuoti ({}), nessun input necessario. Il tool è un sensore, non un’azione.
4.3 L’handler
if self._token_budget is not None:
tb = self._token_budget
self.TOOL_NAME_TO_FUNC["get_token_budget"] = lambda **kw: json.dumps({
"status": "success",
"remaining": tb.remaining,
"daily_limit": tb.daily_limit,
"percentage": int(tb.get_remaining_percentage() * 100),
"formatted": f"{tb.remaining:,} / {tb.daily_limit:,} ({int(tb.get_remaining_percentage() * 100)}%)",
})
Ogni esecuzione viene loggata:
Executing tool: get_token_budget args={}
Tool result: get_token_budget -> {"status":"success","remaining":9823450,...}
4.4 Tool intent detection
Non basta aspettare che il LLM decida di chiamare il tool — dobbiamo riconoscere quando l’utente sta facendo una domanda sui token. In bot/response.py, il rilevamento è potenziato con trigger multilingua:
TOOL_KEYWORDS = {
"get_token_budget": [
"quanti token", "quanto token", "budget token",
"token rimanenti", "token residui", "quanto budget",
"stato token", "remaining tokens", "token budget",
"quanto speso", "consumo token",
"how many tokens", "token balance", "token status",
],
...
}
4.5 Iniezione passiva per tutti i canali
Il problema originale era che la consapevolezza del budget funzionava solo nella DM del proprietario (_generate_with_tools). Ora iniettiamo il budget come tool result in tutti i canali attraverso _generate_and_send:
# Inject token budget as tool result for constant awareness (all channels)
if hasattr(self._bot, 'token_budget'):
tb = self._bot.token_budget
budget_json = json.dumps({
"status": "success",
"remaining": tb.remaining,
"daily_limit": tb.daily_limit,
"percentage": ...,
"formatted": ...,
})
history.append({"role": "assistant", "content": None,
"tool_calls": [{"id": "prefilled_budget", "type": "function",
"function": {"name": "get_token_budget", "arguments": "{}"}}]})
history.append({"role": "tool", "tool_call_id": "prefilled_budget",
"content": budget_json})
Il bot vede questo in ogni messaggio, in ogni canale. Il dato è in formato tool result — il formato che il LLM tratta come più autorevole.
5. Architettura finale
┌─────────────────────┐
│ TokenBudget │
│ (memoria locale) │
└──────┬──────────────┘
│
┌──────────────┴──────────────┐
│ │
▼ ▼
┌────────────────────┐ ┌──────────────────────┐
│ get_token_budget() │ │ Iniezione passiva │
│ (su chiamata) │ │ (tool result in │
│ │ │ cronologia, ogni │
│ │ │ messaggio) │
└────────┬───────────┘ └──────────┬─────────────┘
│ │
▼ ▼
LLM risponde LLM è costantemente
con dato esatto informato (tutti i canali)
Due meccanismi, ridondanti:
1. Attivo: Il LLM chiama get_token_budget() quando gli serve il dato
2. Passivo: Il budget è già nella cronologia come tool result, prima che il LLM generi
6. Lezioni apprese
6.1 Tool calling > system prompt
La lezione più importante: i tool di funzione non sono solo per eseguire azioni. Sono anche un canale di comunicazione dal sistema al LLM. Un tool result è l’unico modo per passare dati che il LLM non possa ignorare o allucinare.
| Metodo | Affidabilità | Note | |--------|-------------|------| | System prompt | ★☆☆☆☆ | Ignorata quando la cronologia è lunga | | Iniezione cronologia | ★★☆☆☆ | Il LLM può confonderla con conversazione reale | | Interceptor | ★★★☆☆ | Solo per domande dirette, falsi positivi | | Tool calling | ★★★★★ | Deterministico, loggato, non ignorabile |
6.2 Principi generali per iniezione di dati real-time
| Principio | Applicazione | |-----------|-------------| | Tool result > tutto | I dati in formato tool result hanno la massima priorità per il LLM | | Logga ogni accesso | Ogni tool call è loggata per debug e audit | | Doppia ridondanza | Dato iniettato passivamente + tool chiamabile attivamente | | Nessuna stima | Il LLM non deve mai calcolare o stimare token — sempre chiamare la funzione | | Tutti i canali | Il budget deve essere accessibile ovunque, non solo nella DM del proprietario |
6.3 Riferimenti
- Lost in the Middle: How Language Models Use Long Contexts — Liu et al., 2024 https://aclanthology.org/2024.tacl-1.9/
- The Instruction Hierarchy: Training LLMs to Prioritize Privileged Instructions — OpenAI, 2024 https://ar5iv.labs.arxiv.org/html/2404.13208
- Positional Biases Shift as Inputs Approach Context Window Limits — Xiao et al., 2025 https://arxiv.org/abs/2508.07479
- OpenAI Function Calling — https://platform.openai.com/docs/guides/function-calling
6.4 File coinvolti
bot/tools.py → Tool definition (get_token_budget) + ToolExecutor handler
bot/response.py → Tool intent detection + budget injection in history + tool execution
bot/token_budget.py → TokenBudget class (dati sorgente)
bot/harness_simple.py → Integrazione token_budget in ToolExecutor
6.5 Stato finale
Quando l’utente chiede “quanti token rimangono?”:
- Tool intent detection (
response.py): riconosce la domanda dai trigger multilingua, forza l’inclusione del toolget_token_budget - LLM genera tool call: il modello decide autonomamente di chiamare
get_token_budget() - Esecuzione:
ToolExecutor.execute_tool_call()legge daTokenBudgete restituisce il dato reale - Risposta: il LLM usa il dato del tool result per rispondere
Anche senza domande esplicite, il budget è sempre presente come tool result nella cronologia di ogni canale. Il bot non deve più “indovinare” o ricordarsi di guardare nella system prompt.
Articolo correlato: La trappola del pattern: quando l’IA smette di leggere e inizia a indovinare — il resoconto in prima persona del bot su questo stesso fallimento.