Chatbot LLM: streaming, compressione del contesto e memoria
Chatbot passo passo: scelta del modello, Stop lato provider, budget del contesto misurato, compressione in memoria e ricerca web via gateway.
Le nostre scelte
prezzi in tempo reale| Modello | Verdetto | Prezzo |
|---|---|---|
| Gemini 3.6 Flash Veloce, con un contesto reale di 1M (informazione recuperata a 972K) e, impostando il ragionamento al minimo, un costo misurato per chiamata inferiore del 91-97% nelle attività a passaggio singolo in formato chat. | Migliore scelta predefinita | da $1.5/M |
| DeepSeek V4 Flash La scelta compatibile con la chat più economica di questa pagina, con lo sconto maggiore della tabella sulle letture dalla cache: rileggere una cronologia lunga costa quasi nulla. È anche il riepilogatore predefinito dell'MVP. | Scelta economica | da $0.138/M |
| Claude Sonnet 5 La migliore coerenza di personalità e scrittura tra i quattro. Nota sul budget: lo stesso testo viene tokenizzato con il 41% di token in più rispetto a Sonnet 4.6, quindi confronta il numero di token, non i prezzi di listino. | Scelta per la qualità | da $2/M |
| GLM-5.2 Un turno di chiamata a uno strumento con cache attiva ha registrato un costo di $0.0009 rispetto a $0.0051 su claude-opus-4-8. La latenza mediana per un turno con cache attiva è stata di 6.6s, quindi usalo solo nei flussi in cui l'utente si aspetta di attendere. | Chiamate a strumenti economiche | da $1.4/M |
Indice
- Cosa deve fare un chatbot MVP?
- Com’è strutturato?
- Quali modelli inserire nel selettore?
- Cosa vede davvero il modello a ogni turno?
- Come funziona la prompt cache e quali modelli la supportano?
- Come capire se il limite del contesto è vicino?
- Cosa succede quando si raggiunge il limite?
- Come persiste la memoria tra sessioni?
- Cosa succede quando l’utente preme Stop?
- Cosa succede quando un turno fallisce?
- Come funziona la ricerca web senza un tool loop nel codice?
- Come si renderizza il Markdown senza romperlo durante lo streaming?
- Quanto costa una conversazione?
- Dove si configurano tutte le opzioni?
- Cosa manca e dove andrebbe aggiunto?
- Letture correlate
Questa guida costruisce un chatbot completo che potete eseguire in locale in circa due minuti e analizzare in un pomeriggio: un server FastAPI, una pagina statica, nessun database e nessuna build. Vedremo come sono progettati i singoli sottosistemi, in quale ordine vengono eseguiti a ogni turno e quali problemi abbiamo incontrato durante lo sviluppo. Il sorgente completo si trova su github.com/synthorai-io/use-cases, nella directory chatbot/. Tutte le richieste passano da un unico endpoint compatibile con OpenAI, quindi i modelli sono opzioni di un menu, non integrazioni separate.
git clone https://github.com/synthorai-io/use-cases
cd use-cases
cp .env.example .env # put your API key in it
cd chatbot
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
uvicorn server:app --reload

Cosa deve fare un chatbot MVP?
Otto cose. Ognuna evita un problema specifico che emerge quando manca:
| Funzionalità | Problema che evita | Dove si trova |
|---|---|---|
| Conversazioni persistenti (elenco, ricerca, rinomina) | una chat che dimentica tutto al ricaricamento è una demo, non uno strumento | storage.py |
| Selezione del modello, modificabile durante la conversazione | usare per ogni domanda un modello dimensionato su quella più difficile fa pagare troppo tutte le altre | config.py |
| Persona modificabile, con preset | il system prompt è il prodotto; per cambiarlo non deve servire un deploy | presets/ |
| Streaming, con uno Stop che raggiunge il provider | il silenzio prima del primo token sembra un errore; uno Stop finto continua a generare costi | server.py |
| Budget del contesto, con compressione al limite | ogni modello ha una finestra; superarla senza avvisi è il modo in cui i bot iniziano a sembrare “stupidi” | context.py |
| Memoria a lungo termine | doversi ripresentare a ogni sessione è uno dei difetti più evidenti di un chatbot | context.py + .data/memory.md |
| Ricerca web e recupero di pagine web | le conoscenze del modello si fermano all’addestramento, mentre molte domande in chat riguardano informazioni attuali | tools.py |
| Visibilità dei costi, per turno e per sessione | il costo cresce insieme alla cronologia; non si può ottimizzare ciò che non si vede | server.py |
Ricerca e recupero di pagine web richiedono qualche spiegazione in più, perché sono le prime due funzionalità che spesso vengono escluse da un MVP. Le conoscenze di un modello si fermano al cutoff di addestramento, mesi prima della data corrente, mentre le domande in chat riguardano spesso prezzi, versioni, release e richieste come “X supporta già Y?”. Un chatbot che risponde usando solo i dati di addestramento non è semplicemente meno preciso: può dare una risposta errata con sicurezza, senza che l’utente riesca a capire quanto sia obsoleta. Il problema non si limita ai fatti mancanti. Durante lo sviluppo abbiamo scoperto che, senza un riferimento alla data corrente, il modello considera il proprio cutoff come “oggi”. Finisce così per formulare query errate, inserendo anni non più attuali. Per un chatbot, il retrieval non è un arricchimento facoltativo: separa un’interfaccia verso un’istantanea del passato da un assistente.
Servono due tool perché ricerca e recupero rispondono a domande diverse. La ricerca restituisce snippet di poche centinaia di caratteri, spesso in conflitto tra loro, e risponde a “cosa esiste?”. Il recupero scarica una pagina completa e risponde a “cosa dice questa pagina?”, permettendo di verificare un dato preciso. Il primo scopre, il secondo verifica. A ogni turno è il modello a decidere se usarli. Se non servono, il turno non ha costi aggiuntivi; i limiti per utilizzo contengono invece il costo quando vengono chiamati.
Sono incluse anche le altre funzioni di base: Regenerate, rendering Markdown che non si rompe durante lo streaming e percorsi di recupero specifici per ogni errore. Le sezioni seguenti ripercorrono l’elenco più o meno dall’alto verso il basso, seguendo il flusso di una richiesta nel codice.
Com’è strutturato?
Tre componenti: una pagina statica, sei piccoli file Python e il gateway. La pagina invia un messaggio; il server compone il contesto, lo comprime se necessario, restituisce la completion in streaming tramite server-sent events e registra l’utilizzo misurato. Le conversazioni sono file JSON leggibili con cat.
static/index.html the chat UI (vanilla JS: picker, budget bar, markdown, activity trail)
server.py routes; the per-turn pipeline: project → compress → stream → record
context.py token budgeting, compression call, memory file, message assembly
tools.py the /v1/messages transport used by tool-enabled turns
storage.py one JSON file per conversation under .data/ (settings included)
config.py env-driven settings: model lineup, budgets, prompts
presets/ system-prompt presets, one .txt each
La pipeline per turno in server.py è la parte da comprendere meglio. Ogni messaggio inviato dall’utente attraversa gli stessi cinque passaggi, ciascuno approfondito più avanti:
user message
│
▼
[1] project the next request's size last measured prompt_tokens
│ + estimate(new message, pessimistic)
│ + completion reserve
▼
[2] over budget? ──yes──▶ compress old turns ──▶ rolling summary
│ └───────▶ durable facts ──▶ memory.md
▼
[3] assemble and send [persona][memory][summary][date][history]
│ cache mark after the system blocks
▼
[4] stream SSE back to the page delta / reasoning / search / fetch /
│ compression notice / warning / error
▼
[5] record measured usage usage.prompt_tokens becomes step [1]'s
input on the next turn
Il ciclo si chiude su sé stesso: il valore misurato al passaggio 5 diventa l’input del passaggio 1 al turno successivo. Il budget si basa quindi sempre sul conteggio effettivo dell’API, non su una stima locale.
Va chiarita subito una biforcazione architetturale: i turni normali usano /v1/chat/completions, mentre quelli con ricerca o recupero web attivi passano da /v1/messages. Non è una preferenza stilistica. La sezione sui tool mostra il comportamento misurato che impone questa scelta.
Quali modelli inserire nel selettore?
Il file .env include sette modelli, divisi nel selettore per fascia (veloci ed economici, bilanciati, frontier). Da Settings potete aggiungere qualsiasi id servito dal gateway; gli id aggiunti vengono salvati in .data/models.json. Il modello predefinito conta più dell’intero elenco. L’MVP parte con Gemini 3.6 Flash e imposta il reasoning al minimo. Le risposte in chat sono attività a singolo passaggio. In questi task, reasoning_effort: "minimal" ha ridotto del 91-97% il costo misurato per chiamata rispetto al valore predefinito, senza differenze percepibili nel risultato. L’impostazione è definita per modello nella configurazione, perché non tutti i modelli la accettano:
# config.py — extra request params per model
MODEL_PARAMS: dict[str, dict] = {
"gemini-3.6-flash": {"reasoning_effort": "minimal"},
}
DeepSeek V4 Flash compare in due ruoli: è l’opzione economica nel selettore e il summarizer predefinito per la compressione, perché anche il riassunto è un’attività a singolo passaggio che tollera qualche compromesso sulla qualità. Claude Sonnet 5 è la scelta adatta quando la qualità della scrittura è parte del prodotto. Per stimarne il costo, confrontate il numero di token e non il prezzo di listino: lo stesso testo produce il 41% di token in più rispetto a Sonnet 4.6. GLM-5.2 trova spazio nei flussi con molti tool: un turno caldo con tool call è costato $0.0009 contro $0.0051 su Claude Opus 4.8. La sua mediana di 6.6s per un turno caldo, però, è una latenza chiaramente percepibile in chat.
Le capacità di ogni modello vengono misurate, non date per scontate. Il codice gestisce le differenze invece di ignorarle. In questo gruppo, solo DeepSeek e GLM espongono il proprio ragionamento in streaming come reasoning_content; i modelli Claude non restituiscono blocchi di pensiero attraverso il gateway, nemmeno impostando il parametro dedicato. L’interfaccia mostra quindi il pannello live “Thinking” solo quando può ricevere dati.
Cambiare modello durante la conversazione non richiede modifiche strutturali. La cronologia usa messaggi neutrali rispetto al provider, nel formato {"role", "content"}. Potete quindi iniziare una conversazione con un modello economico e passare a quello di qualità quando arriva una domanda difficile. Esiste però un costo nascosto: il cambio di modello abbandona la prompt cache del modello precedente, quindi il primo turno successivo rilegge l’intero contesto al prezzo di una richiesta cold.
Cosa vede davvero il modello a ogni turno?
Un prompt a livelli, composto in un solo punto e in un ordine preciso: prima i contenuti più stabili.
| Livello | Origine | Frequenza di modifica |
|---|---|---|
| System prompt (persona) | presets/*.txt o testo libero | mai, salvo modifiche manuali |
| Memoria a lungo termine | .data/memory.md | raramente (aggiunta di fatti) |
| Riassunto progressivo | compressione | solo quando scatta la compressione |
| Data corrente | orologio del server | ogni giorno |
| Istruzioni per i tool | Settings, solo nei turni con tool | raramente |
| Turni recenti | conversazione | a ogni turno |
La regola è mettere prima ciò che cambia meno, per sfruttare la cache descritta nella prossima sezione. La persona resta invariata, la memoria cambia raramente, il riassunto viene aggiornato solo durante la compressione e la cronologia cambia a ogni turno. Ogni livello segue quindi tutti quelli più stabili.
La data corrente è necessaria, e anche la sua posizione è intenzionale. Senza questo riferimento, il modello considera il cutoff di addestramento come “oggi”: formula query con anni non più attuali e interpreta una tabella di release come se l’ultima riga fosse ancora quella corrente. Inseriamo però una data, non un timestamp. Poiché il valore rientra nel prefisso del prompt, una precisione superiore al giorno invaliderebbe la cache a ogni richiesta. Inoltre viene dopo persona, memoria e riassunto, così questi livelli restano in cache anche allo scoccare della mezzanotte.
Come funziona la prompt cache e quali modelli la supportano?
Il principio è semplice: i provider memorizzano il prefisso byte-identico di una richiesta e lo riutilizzano con un forte sconto, applicando un piccolo sovrapprezzo alla prima scrittura. La chat è il workload ideale, perché ogni richiesta coincide con la precedente più due messaggi: per costruzione, l’intera cronologia passata è un prefisso stabile. Il sovrapprezzo di scrittura si recupera già al turno successivo. Nei modelli Anthropic, per il TTL di 5 minuti, scrivere costa 1.25x e leggere circa 0.1x; qui abbiamo misurato i costi di scrittura. I marker cache_control hanno ridotto dell’88-89% il costo misurato su tre fasce di modelli Claude. Nelle conversazioni con memoria e riassunto reali, i turni successivi costano circa un decimo del primo turno cold.
Il termine “caching” copre però due meccanismi diversi. I provider si dividono in due gruppi, entrambi presenti in questo elenco:
| Modello | Tipo di caching | Cosa dovete fare | Come vengono segnalati gli hit |
|---|---|---|---|
| Famiglia Claude | esplicito: breakpoint cache_control | posizionare il marker | cache_read_input_tokens |
| DeepSeek V4 Flash | implicito: prefisso automatico | nulla | prompt_tokens_details.cached_tokens |
| GLM-5.2 | implicito: prefisso automatico | nulla | prompt_tokens_details.cached_tokens |
| Gemini 3.6 Flash | implicito; il marker viene accettato e ignorato | non è possibile intervenire | non segnalati tramite questo gateway |
Con il caching esplicito, il modello Anthropic richiede di indicare il breakpoint, ma mostra esattamente cosa è successo: campi separati per i token scritti e letti, così lo sconto è verificabile per ogni turno. Il caching implicito, tipico dei provider in stile OpenAI, non richiede marker e si attiva automaticamente quando il prefisso si ripete. Il provider decide però cosa mettere in cache, e l’unica conferma arriva a posteriori nel campo cached_tokens; l’affidabilità varia molto da un provider all’altro. L’MVP supporta entrambi i gruppi con la stessa strategia: ordina il prompt partendo dai contenuti più stabili, come richiede il caching implicito, e invia sempre il marker esplicito, che gli altri provider ignorano senza conseguenze. La forma della richiesta resta unica e ogni modello usa il caching che supporta.
La posizione del marker esplicito deriva da misurazioni, non dalla documentazione. Su questo gateway, cache_control viene rispettato in un blocco system e ignorato senza errori in qualsiasi altra posizione. Il pattern comune che marca il messaggio utente più recente, consentendo in teoria di mettere in cache l’intera cronologia, restituisce zero token dalla cache e addebita il prezzo pieno. L’MVP posiziona quindi il marker alla fine della sezione system. Il prefisso memorizzabile comprende persona, memoria e riassunto. Ne derivano due conseguenze. Il caching si attiva solo quando il prefisso supera la soglia minima del modello, circa 1,024 token: una conversazione nuova non mette nulla in cache, mentre una con memoria e riassunto accumulati memorizza tutto. Inoltre, modificare il prefisso ha un costo: cambiando il system prompt, la cache diventa cold dal primo byte; la compressione riscrive il riassunto e impone una lettura cold, descritta più avanti; il cambio di modello abbandona interamente la cache precedente.
Il TTL è una scelta di configurazione con lo stesso principio per tutti: la cache predefinita di 5 minuti copre una conversazione attiva, mentre quella da 1 ora copre il ritorno di un utente che si è allontanato, al doppio del sovrapprezzo di scrittura. Basta impostare CACHE_TTL=1h. La convenienza dipende da quanti utenti tornano davvero entro un’ora.
Come capire se il limite del contesto è vicino?
Bisogna misurare, non indovinare. L’unico conteggio di token valido per il modello in uso è usage.prompt_tokens, restituito dall’API per la richiesta precedente. Gli stimatori locali sono fuorvianti tra provider, e le differenze sono ampie: due modelli dello stesso vendor producono una differenza del 41% sullo stesso testo inglese, come abbiamo misurato; tra vendor il divario è ancora maggiore. L’MVP stima localmente solo ciò che l’API non ha ancora visto, cioè il messaggio che sta per essere inviato, arrotondando intenzionalmente per eccesso:
# context.py
def needs_compression(last_prompt_tokens, pending_text, message_count, budget=None):
if message_count <= config.KEEP_RECENT_MESSAGES:
return False # nothing old enough to fold away
projected = (
last_prompt_tokens # measured, last response
+ estimate_tokens(pending_text) # estimated, pessimistic
+ config.MAX_COMPLETION_TOKENS # worst-case reply
)
return projected > (budget or config.CONTEXT_BUDGET_TOKENS)
Una sovrastima attiva la compressione un turno prima e costa una chiamata economica al summarizer. Una sottostima supera la finestra e provoca una richiesta fallita o troncata senza avvisi. Questa asimmetria determina la direzione dell’arrotondamento.
Anche la misurazione nasconde un’insidia: i provider non concordano sul significato di prompt_tokens. Alcuni includono già i token in cache, altri riportano solo il delta non memorizzato. Un’interpretazione errata ha effetti concreti, perché questo valore è la base del budget. Se viene sottostimato della dimensione della cache, la compressione non scatta mai e la finestra viene superata senza segnalazioni. Il server normalizza i dati: se prompt_tokens è inferiore al numero di token dichiarati in cache, non può rappresentare l’intero prompt, quindi somma le parti. Mantiene inoltre la propria stima come limite minimo del valore misurato. Il contenuto effettivo di usage per ogni provider è trattato separatamente in Anatomia dell’utilizzo dei token LLM.
Il budget predefinito è di 102,400 token, una soglia che una conversazione normale difficilmente raggiunge. Non rappresenta il limite del modello, ma il budget operativo: va impostato nel punto in cui il costo di un turno supera il suo valore. Per osservare il meccanismo, aprite Settings, abbassate la finestra a poche migliaia di token per una conversazione, dato che il budget è specifico per conversazione, e incollate alcuni messaggi lunghi. La barra del contesto nell’header usa il valore misurato e mostra i componenti che occupano la finestra: persona, memoria, riassunto e cronologia.

Cosa succede quando si raggiunge il limite?
Si comprime, senza troncare. Il troncamento fa dimenticare al bot l’inizio della conversazione e l’utente lo percepisce come un peggioramento improvviso delle capacità. La soluzione è condensare in un riassunto progressivo tutti i messaggi tranne i più recenti, 8 per impostazione predefinita, usando il modello summarizer economico. Il riassunto viene poi incluso come blocco system. La finestra recente resta identica, quindi il tono a breve termine non cambia; solo il passato più remoto viene compresso con perdita. Nulla scompare senza avvisi: quando scatta la compressione, nella conversazione compare una notifica con il numero di messaggi e la dimensione del riassunto.
Ecco una compressione completa:
before (projected next request 103.1k > 102.4k budget)
[persona][memory][date][ m1 ..................... m34 │ m35 ....... m42 ]
old enough to fold last 8, kept
one summary call to deepseek-v4-flash:
in: prior summary + m1 ... m34
out: {"summary": "one dense paragraph: topics, decisions,
open questions, promises",
"facts": ["prefers Python", "timezone is UTC+8"]}
after
[persona][memory + new facts][summary][date][ m35 ....... m42 ]
unchanged grows rarely replaced verbatim
Tre scelte progettuali rendono affidabile la chiamata di compressione:
- Il nuovo riassunto incorpora quello precedente. La seconda compressione include il primo riassunto insieme ai messaggi appena diventati vecchi. Rimane così un singolo paragrafo progressivo, invece di una catena di riassunti di riassunti che cresce senza limiti.
- Un errore non fa perdere il turno. Se la chiamata al summarizer fallisce, il server elimina i turni più vecchi senza riassumerli, indica esattamente cosa è andato perso e risponde comunque al messaggio. Una memoria imperfetta è preferibile a un chatbot inutilizzabile. Il percorso di errore che non compare mai nelle demo è quello che genera incidenti in produzione.
- Il prompt è configurabile, con un vincolo. Ciò che chiediamo al summarizer di conservare determina cosa ricorderà la conversazione, quindi il prompt di compressione è modificabile per ogni conversazione. Il server rifiuta le modifiche che rimuovono il placeholder
{transcript}, perché quel prompt non riassumerebbe nulla e l’errore emergerebbe molto più tardi, al primo overflow. Lo stesso principio vale perCOMPRESS_MAX_TOKENS: il limite di output lascia margine rispetto a quanto richiesto dal prompt, perché un riassunto troncato a metà frase verrebbe ereditato da tutti i turni successivi.
Dal punto di vista della cache, la compressione comporta esattamente una rilettura cold: il blocco del riassunto cambia, quindi tutto ciò che segue la memoria è cold per una richiesta. Subito dopo, il nuovo prefisso ridotto viene nuovamente memorizzato. Il costo reale è quindi una chiamata di riassunto più una lettura cold, in cambio di tutti i turni successivi calcolati su un contesto più piccolo e caldo.
Come persiste la memoria tra sessioni?
Il bot usa tre archivi di memoria, distinti per ambito e durata. Tutte le scelte di questa sezione derivano da questa separazione:
| Archivio | Ambito | Durata | Dimensione nella richiesta |
|---|---|---|---|
| Turni recenti, invariati | questa conversazione | finché non vengono incorporati dalla compressione | testo completo degli ultimi 8 messaggi |
| Riassunto progressivo | questa conversazione | termina con la conversazione | un paragrafo, meno di 200 parole |
Fatti in memory.md | tutte le conversazioni | finché una persona non li elimina | poche righe |
Riassunto e fatti possono sembrare simili, ma invecchiano in modo diverso. Per questo restano separati. Il riassunto progressivo segue la struttura della conversazione: decisioni, domande aperte e impegni presi dall’assistente. È corretto che termini insieme alla conversazione. I fatti durevoli sull’utente, come “preferisce Python” o “il suo fuso orario è UTC+8”, restano validi anche la settimana successiva e non devono scomparire.
Il prompt di compressione richiede entrambi gli output nella stessa chiamata e restituisce JSON: una stringa summary e un array facts. I fatti vengono aggiunti a .data/memory.md, che ogni conversazione carica come blocco system. L’estrazione avviene durante la compressione per contenere i costi. In quel momento un modello sta già rileggendo i vecchi turni, quindi può estrarre i fatti usando token già inclusi nel lavoro. Una chiamata produce due output.
La deduplicazione dell’MVP confronta le righe esatte, e il limite emerge rapidamente: una compressione salva “User prefers Python over Node.js”, mentre una successiva aggiunge “The user prefers Python over Node.js.” La deduplicazione semantica richiede embedding o il confronto tramite LLM di ogni candidato con l’archivio. È una funzionalità vera, con un costo reale, quindi l’MVP usa l’approccio semplice e ne dichiara il limite. Il file della memoria è in Markdown proprio per consentire a una persona di leggerlo e ripulirlo. Settings lo espone in un campo di testo modificabile, che funziona anche da pannello di trasparenza: ciò che il bot sa sull’utente è contenuto in un file apribile.
Cosa succede quando l’utente preme Stop?
Il provider interrompe la generazione. Questa è la funzionalità, e manca in molte interfacce di chat: uno Stop che nasconde soltanto l’output mentre la completion continua fa pagare token che nessuno leggerà.
La catena ha tre passaggi. Durante lo streaming, il pulsante Send diventa Stop, nella stessa posizione e senza conferme. Il clic interrompe la fetch del browser. Tra un evento e l’altro, il server controlla se il client si è disconnesso; quando lo rileva, esce dal loop di streaming e chiude la connessione upstream, fermando il provider. Il testo già ricevuto viene salvato e marcato come interrupted, con un bordo tratteggiato nella conversazione. La stessa catena si attiva quando la scheda viene chiusa o cade la connessione, perché dal punto di vista del server sono lo stesso evento.
In questo flusso, il salvataggio deve avvenire esattamente una volta. Il codice lo gestisce in modo esplicito: su una conclusione regolare salva all’interno del loop di streaming; se lo stream si interrompe, salva nell’exception handler; su una disconnessione netta salva nel blocco finally, perché il framework annulla il generatore e il codice dopo il loop non viene eseguito. La funzione è idempotente, quindi prevale il primo percorso che riesce a completarsi.
Stop è abbinato a Regenerate: potete interrompere una risposta indesiderata e rigenerarla senza riscrivere il messaggio. Regenerate elimina i turni assistant finali, compreso quello interrotto, facendo terminare di nuovo la conversazione sul messaggio user, poi genera una nuova risposta con il modello corrente. Poiché il selettore del modello si applica al turno successivo, Stop più Regenerate consente anche di ripetere la stessa domanda con un modello più potente.
Cosa succede quando un turno fallisce?
Si verifica uno di sei casi, ognuno con un percorso di recupero dedicato invece di un generico messaggio rosso. classify() in server.py associa le eccezioni upstream ai tipi di errore; l’interfaccia associa ogni tipo a un’azione:
| Errore | Cosa vede l’utente | Recupero |
|---|---|---|
| Errore di rete | identificato esplicitamente | pulsante Retry |
| Rate limit | tempo di attesa ricavato da retry-after, se presente | pulsante Retry |
| API key errata | nome esatto della variabile d’ambiente da correggere | modifica di .env |
| Filtro dei contenuti | ”riprovare con lo stesso testo produrrà un nuovo rifiuto” | Edit & resend |
| Interruzione durante lo streaming | risposta parziale conservata e marcata come interrotta | Retry |
| Compressione fallita | avviso che indica cosa è stato eliminato | nessuna azione; il turno continua |
Due invarianti gestiscono la maggior parte dei casi. Primo, il messaggio dell’utente non va mai perso. Se la richiesta fallisce prima del primo evento, il server rimuove il messaggio dalla conversazione e l’interfaccia ripristina la bozza nel composer, evitando che un retry lo invii due volte. Se lo stream si interrompe dopo aver ricevuto del testo, la risposta parziale viene conservata e marcata. Secondo, una risposta rifiutata usa rewind, non retry: il server estrae il messaggio user dalla cronologia e lo rimette nel composer per consentirne la modifica, perché ripetere lo stesso testo contro un filtro produce sempre lo stesso errore.
Alla base di entrambi c’è una regola di persistenza meno ovvia: un turno assistant vuoto non viene mai salvato. Riprodurre upstream un messaggio assistant senza contenuto viola l’alternanza user/assistant richiesta da alcuni provider. L’errore può emergere solo in un turno successivo e indicare il messaggio sbagliato, rendendo il debug estremamente difficile. L’MVP rimuove il contenuto vuoto dal formato inviato e non salva risposte prive di testo, a meno che il turno contenga attività di ricerca o reasoning da conservare. In quel caso salva l’attività e rimuove il testo vuoto prima dell’invio.
Come funziona la ricerca web senza un tool loop nel codice?
Il gateway esegue i tool lato server, cambiando il punto in cui si trova la logica. Nel classico function calling, il loop è nel vostro codice: il modello restituisce un blocco tool_calls, il server esegue il tool, aggiunge un messaggio tool_result e invia di nuovo l’intera conversazione, una volta per chiamata. Con i tool server-side, il loop passa al gateway. La richiesta dichiara synthorai:web_search o synthorai:web_fetch; il gateway si colloca tra LLM e provider dei tool, inoltra le chiamate del modello alle API esterne di ricerca e recupero e reinserisce i risultati nel contesto del modello. Non c’è nulla di magico: ogni ricerca e ogni recupero corrispondono a una chiamata verso un’API esterna, ed è proprio per questo che vengono fatturati per utilizzo. In questo codebase non esiste un round trip tool_result, ma solo eventi da visualizzare:
browser server (tools.py) Synthorai gateway LLM / tool providers
│ POST /chat │ │
├──────────────▶│ declare tools + │
│ │ budget note │
│ ├────────────────────▶│── question + tools ──▶ [LLM]
│ │ │◀── tool_use: search ── [LLM]
│ │ │── query ─────────────▶ [search API]
│ │ search results │◀── results ─────────── [search API]
│ SSE: search ◀─┤◀────────────────────┤── results ───────────▶ [LLM]
│ │ │◀── tool_use: fetch ─── [LLM]
│ │ │── URL ───────────────▶ [fetch API]
│ │ fetch result │◀── page text ───────── [fetch API]
│ SSE: fetch ◀──┤◀────────────────────┤── page text ─────────▶ [LLM]
│ SSE: delta ◀──┤◀────────────────────┤◀── answer tokens ───── [LLM]
│ SSE: done │ │
I ruoli sono ben distinti: il modello decide se cercare, se basta uno snippet o se serve recuperare una pagina, e quando smettere di usare i tool per scrivere la risposta; il gateway esegue le richieste al provider di ricerca o recupero e restituisce i risultati al modello; il vostro server si limita a mostrare gli eventi che attraversano lo stream. Entrambi i tool sono attivi per impostazione predefinita. Un turno che non ne ha bisogno non costa nulla in più, quindi un calcolo resta gratuito mentre una domanda su informazioni correnti avvia una ricerca. Anche il loop lato gateway ha un limite: se il loop di ricerca server-side si ferma al proprio limite di iterazioni (pause_turn), il transport rimanda indietro il turno per riprenderlo, per un massimo di tre volte.
Le quattro lezioni emerse durante lo sviluppo sono più utili del percorso ideale:
- L’endpoint determina ciò che potete osservare. Entrambi gli endpoint eseguono e fatturano la ricerca, secondo le misurazioni del 2026-08-04 sui canali Anthropic e Gemini, ma solo
/v1/messagesespone l’attività: query e URL dei risultati arrivano come blocchi tipizzati che il codice può visualizzare e salvare. Su/v1/chat/completions, la stessa ricerca avviene senza visibilità: HTTP 200, una risposta che inizia con “Based on the search results…” e nessuna citazione o annotazione, sia nel percorso streaming sia in quello non streaming. Una ricerca pagata ma non verificabile è un degrado silenzioso, la forma di errore peggiore: nulla fallisce, scompare solo la provenienza. Questo è l’unico motivo per cuitools.pyesiste come secondo transport, invece di aggiungere un campo alla richiesta normale. - Il modello deve conoscere il proprio budget, espresso a parole. I limiti, per impostazione predefinita 3 ricerche e 2 recuperi per turno, vengono applicati silenziosamente dal gateway. Un modello che non li conosce pianifica come se i tool fossero illimitati, esaurisce l’ultima chiamata a metà ragionamento e termina il turno con “Let me search for…” senza fornire una risposta. L’MVP aggiunge una frase che dichiara il budget. La formulazione incide in modo misurabile sui costi: nei test, senza alcuna nota il modello ha consumato l’intero budget e si è fermato a metà frase; con una nota troppo rigida ha rinunciato, chiedendo all’utente di leggere personalmente la pagina; con la formulazione inclusa nell’MVP ha saltato del tutto la ricerca, recuperato le due pagine autorevoli e prodotto il costo più basso dei tre casi. Il testo è modificabile in Settings; cambiatelo e osservate il costo per turno.
- I limiti sono l’unico freno. Entrambi i tool vengono fatturati per utilizzo e il modello decide quanti round eseguire. In un test, un singolo turno senza limiti ha effettuato tre ricerche e due recuperi prima che venisse fatturato un solo token di output.
- Meglio degradare che fallire. Due errori ricevono lo stesso trattamento: rimuovere il tool, riprovare una volta e informare l’utente. Web fetch richiede un’abilitazione sulla chiave; senza, l’intera richiesta fallisce con
web_fetch_not_enabledinvece di degradare. Gemini, invece, accetta la dichiarazione del tool ma genera un errore quando prova a chiamarlo (Function call is missing a thought_signature). In entrambi i casi, perdere il turno dell’utente per un tool facoltativo è il compromesso sbagliato.
Tutte le attività svolte dal modello per produrre una risposta, cioè ragionamento, ricerca e lettura, vengono mostrate in streaming in una traccia sopra la risposta. La traccia è compressa in una riga discreta, come “Thought for 5s · Searched the web · Read 2 pages”, ed è espandibile in una timeline con query, domini dei risultati e testo del reasoning. Viene salvata insieme al messaggio: se scomparisse al ricaricamento, non potrebbe servire per verificare la risposta in seguito. Le citazioni appaiono sotto la risposta come riferimenti numerati con il dominio.

Come si renderizza il Markdown senza romperlo durante lo streaming?
Il testo accumulato viene diviso tra un prefisso stabile, che può essere renderizzato in sicurezza, e una coda ancora incompleta. Renderizzare un costrutto aperto solo a metà fa saltare il layout quando il token successivo lo chiude. Il renderer si ferma quindi all’ultima riga completa. Se una code fence aperta non ha ancora una chiusura, tutto il contenuto a partire da quel punto resta testo semplice finché non viene completata. La bubble viene renderizzata di nuovo solo quando cresce davvero il prefisso stabile, evitando di ricostruire il DOM a ogni token. I blocchi di codice includono un pulsante per la copia. Il renderer è composto da un centinaio di righe di vanilla JS, senza librerie: è una scelta su ciò che serve a un MVP, non un giudizio sulle librerie Markdown.
Quanto costa una conversazione?
Costa quanto indicato da usage del gateway, e il compito dell’MVP è interpretare quei dati correttamente. Due problemi di normalizzazione si sono rivelati essenziali:
- I vendor usano nomi di campo diversi. Anche solo per i token in cache, i modelli Anthropic riportano
cache_read_input_tokens, DeepSeek e GLM soloprompt_tokens_details.cached_tokens, Gemini nessuno dei due. La riga delle statistiche legge il campo disponibile, così “cached” mantiene un significato unico. - Un costo mancante non equivale a zero. Il gateway restituisce
costin alcuni turni e lo omette in altri, in modo riproducibile quando un tool è dichiarato ma non viene usato. I turni privi del campo di costo vengono conteggiati e mostrati come “+N unreported”, non sommati come zero. Un totale che omette silenziosamente alcuni turni sembra una fattura completa, ma rappresenta solo un limite inferiore.
L’header mostra i totali progressivi della sessione, cioè input, output, quota in cache, ricerche, recuperi, costo e turni. Ogni risposta include inoltre i propri dati. La quota in cache è il valore da controllare: rende misurabile sulla vostra conversazione il comportamento descritto nella sezione sul caching.
Dove si configurano tutte le opzioni?
In un solo pannello Settings. La scelta progettuale da riutilizzare è l’ambito: quasi tutte le impostazioni appartengono a una singola conversazione.

Solo due impostazioni sono globali, perché descrivono l’utente e non una conversazione: l’elenco dei modelli, a cui si può aggiungere qualsiasi id servito dal gateway e che viene salvato in .data/models.json, e il file di memoria a lungo termine, mostrato in un campo di testo modificabile. Tutto il resto riguarda la conversazione aperta: system prompt, con i preset di presets/ disponibili in un menu, budget del contesto, toggle per ricerca e recupero web, istruzioni per i tool e prompt di compressione. Due conversazioni possono quindi usare contemporaneamente persona, budget e tool diversi sugli stessi modelli. In questo modo potete riprodurre le affermazioni della guida invece di fidarvi soltanto delle nostre conclusioni.
Ogni campo ha un piccolo indicatore informativo, visibile al passaggio del mouse e fissabile con un clic, che spiega il costo della modifica. Molte opzioni hanno infatti un prezzo non evidente nell’interfaccia: modificare la persona invalida la cache dal primo byte e costa un turno cold; cambiare le istruzioni per i tool incide sul costo per turno; ridurre il budget anticipa la compressione. Il selettore del modello nella barra superiore è l’unica impostazione salvata immediatamente, perché cambiare modello durante una conversazione è un’azione di primo livello, non una modifica di configurazione.
Cosa manca e dove andrebbe aggiunto?
Sono omissioni intenzionali, ciascuna con un punto di integrazione preciso:
- Autenticazione e multiutente — un livello di sessione davanti alle route. Le conversazioni hanno già un id, quindi associarle a un utente richiede un prefisso nel nome del file, non una riprogettazione. Fino ad allora, questo è uno strumento da localhost: ogni richiesta usa la vostra API key, quindi non esponetelo così com’è su Internet.
- Rate limiting — stesso punto e stessa motivazione: protegge un deployment condiviso, e questo non lo è ancora.
- RAG — i documenti recuperati vanno inseriti dopo il riassunto e prima dei messaggi recenti. I contenuti volatili devono comparire tardi nel prefisso, così non invalidano i blocchi in cache di persona e memoria. Dal lato del modello, è qui che le opzioni con contesto da 1M sfruttano davvero la propria finestra.
- Function calling lato client — il loop di streaming riceve un ramo
tool_callse un executor. L’analisi di GLM-5.2 descrive le differenze contrattuali tra provider da gestire. I tool lato gateway illustrati sopra evitano intenzionalmente quel loop. - Deduplicazione semantica della memoria — creare un embedding per ogni fatto candidato, confrontarlo con l’archivio e conservare solo quelli nuovi. Il formato del file di memoria resta invariato.
- Syntax highlighting — il pulsante per la copia è la funzione che le persone usano davvero; l’evidenziazione richiede una scelta di libreria per un frontend completo.
- Un vero database —
storage.pycontiene poche funzioni. Portarle su SQLite richiede un pomeriggio, mentre i file JSON sono la scelta giusta finché non ci sono utenti.
Letture correlate
- Miglior LLM per caso d’uso (2026): matrice dei costi per chat, RAG e agenti — la formula dei costi ottimizzata da questa implementazione, applicata a diversi workload.
- Gemini 3.6 Flash: il controllo del reasoning che cambia i costi di 30x — le misurazioni alla base dell’impostazione minima del reasoning.
- Il tokenizer di Claude Sonnet 5 — perché tra modelli bisogna confrontare il numero di token, non i prezzi.
- Anatomia dell’utilizzo dei token LLM — cosa contiene davvero
usageper ciascun provider. - Tool call con GLM 5.2 — costi dei turni caldi e differenze contrattuali nell’estensione per il function calling.