IT ▾
Ottieni chiave API

Costruisci un chatbot con API LLM: backend FastAPI e frontend streaming

Per un chatbot che scrive parola per parola servono solo tre blocchi: un backend che inoltra le richieste, un frontend che legge lo streaming e un archivio che ricorda la cronologia. Usiamo FastAPI, fetch nativo e SQLite. Meno di 100 righe di codice, adatto a utenti adulti.

Aggiornato il

Punti chiave

  • La chiave API resta solo nel backend; il browser non la tocca mai. Il frontend comunica solo con il tuo endpoint /chat.
  • Il modello non ricorda la conversazione per te: ogni richiesta deve recuperare gli ultimi messaggi dal tuo archivio e inviarli all'endpoint.
  • La chiave dello streaming è il backend che inoltra con StreamingResponse e il frontend che concatena con reader.read() in un ciclo.
  • Per adulti: verifica l'età all'accesso e limita la lunghezza della cronologia e quella del singolo input.

Schema: tre ruoli distinti

Pensa al chatbot come a un ristorante: il browser è il cliente, il tuo backend è il portiere, l'API è la cucina. Il cliente non entra in cucina: il portiere inoltra gli ordini. Ecco perché la chiave non deve stare nel frontend.

RuoloResponsabilitàNon deve fare
BrowserUI, input, lettura streamingTenere la chiave API
Backend FastAPISalva cronologia, assembla messaggi, proxyRestituire la chiave al frontend
API ModelloGenera risposta dai messaggi——Elabora solo ciò che ricevi

Flusso: invio messaggio → salvataggio → recupero cronologia → richiesta API → streaming risposta → salvataggio risposta.

Perché un backend proxy? Oltre alla sicurezza, ti permette di aggiungere cronologia, rate limit e filtri. Inoltre, cambiare modello sarà facile: basterà modificare il backend.

Backend: FastAPI e SQLite

Installa le dipendenze e avvia il server:

pip install fastapi uvicorn openai
export API_KEY=你的密钥
uvicorn server:app --reload --port 8000

Scrivi server.py. Ecco i punti chiave del codice:

# server.py
import os
import sqlite3
import uuid

from fastapi import FastAPI
from fastapi.responses import FileResponse, StreamingResponse
from pydantic import BaseModel
from openai import AsyncOpenAI

app = FastAPI()
client = AsyncOpenAI(base_url="https://api.apidamoxing.com/v1", api_key=os.environ["API_KEY"])

SYSTEM = {"role": "system", "content": "你是『小墨』,一个说话简洁、爱用比喻的聊天伙伴。回答控制在 200 字内。"}
KEEP = 20  # 每次只带最近 20 条消息

db = sqlite3.connect("chat.db", check_same_thread=False)
db.execute("create table if not exists msg(id integer primary key autoincrement, sid text, role text, content text)")

def load(sid, n=KEEP):
    rows = db.execute("select role, content from msg where sid=? order by id desc limit ?", (sid, n)).fetchall()
    return [{"role": r, "content": c} for r, c in reversed(rows)]

def save(sid, role, content):
    db.execute("insert into msg(sid, role, content) values (?,?,?)", (sid, role, content))
    db.commit()

class ChatIn(BaseModel):
    session_id: str
    message: str

@app.get("/")
def index():
    return FileResponse("index.html")

@app.post("/session")
def new_session():
    return {"session_id": uuid.uuid4().hex}

@app.post("/chat")
async def chat(body: ChatIn):
    save(body.session_id, "user", body.message[:4000])
    messages = [SYSTEM] + load(body.session_id)

    async def gen():
        parts = []
        try:
            stream = await client.chat.completions.create(
                model="uncensored", messages=messages,
                stream=True, max_tokens=800, temperature=0.8,
            )
            async for chunk in stream:
                if not chunk.choices:        # 末尾的 usage 块没有 choices
                    continue
                delta = chunk.choices[0].delta.content
                if delta:
                    parts.append(delta)
                    yield delta
        except Exception as e:
            yield f"\n[请求失败:{type(e).__name__}]"
        finally:
            if parts:
                save(body.session_id, "assistant", "".join(parts))

    return StreamingResponse(gen(), media_type="text/plain; charset=utf-8")
  1. load() recupera solo gli ultimi 20 messaggi per evitare di superare i 100.000 token della finestra di contesto.
  2. gen() è un generatore asincrono che invia i chunk man mano che arrivano, abilitando lo streaming.
  3. Alla fine dello stream c'è un blocco contenente solo i dati di utilizzo; non ha choices, quindi va ignorato se vuoto.
  4. finally salva la risposta completa, garantendo che nulla vada perso anche in caso di errore.

Se vuoi evitare il database, sostituisci load e save con letture/scritture su un dizionario. Per scalare, sostituisci SQLite con qualsiasi DB mantenendo l'interfaccia delle due funzioni. L'esempio usa sqlite3 sincrono, veloce per il prototipo; per l'alta concorrenza usa un driver asincrono.

Frontend: leggere lo streaming con fetch

Il browser non serve librerie. Usa resp.body.getReader(), cicla read() e usa TextDecoder per decodificare e accodare i byte al DOM. Salva questo codice in index.html, nella stessa cartella di server.py:

<!doctype html>
<meta charset="utf-8">
<title>小墨</title>
<div id="gate">
  <label><input type="checkbox" id="adult"> 我已年满 18 周岁</label>
  <button id="enter">进入</button>
</div>
<div id="app" hidden>
  <div id="log" style="white-space:pre-wrap;min-height:300px"></div>
  <input id="box" placeholder="说点什么"> <button id="send">发送</button>
</div>
<script>
let sid = localStorage.getItem("sid");
const log = document.getElementById("log");

document.getElementById("enter").onclick = async () => {
  if (!document.getElementById("adult").checked) return;
  if (!sid) {
    const r = await fetch("/session", { method: "POST" });
    sid = (await r.json()).session_id;
    localStorage.setItem("sid", sid);
  }
  document.getElementById("gate").hidden = true;
  document.getElementById("app").hidden = false;
};

document.getElementById("send").onclick = async () => {
  const box = document.getElementById("box");
  const text = box.value.trim();
  if (!text) return;
  box.value = "";
  log.textContent += "\n你:" + text + "\n小墨:";
  const resp = await fetch("/chat", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ session_id: sid, message: text }),
  });
  const reader = resp.body.getReader();
  const decoder = new TextDecoder("utf-8");
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    log.textContent += decoder.decode(value, { stream: true });
  }
  log.textContent += "\n";
};
</script>

Due dettagli: passa { stream: true } perché un carattere può essere diviso tra chunk, evitando caratteri corrotti. Disabilita il pulsante durante la richiesta per evitare doppi click (non implementato qui per brevità, ma da aggiungere in produzione).

Avvia e apri il browser alla porta 8000, conferma l'età e invia un messaggio. Se il testo appare tutto insieme, c'è un proxy che fa buffering: prova a connetterti direttamente alla porta locale. Con Nginx, disabilita il buffering per questo percorso.

Cronologia: perché salvarla nel tuo backend

L'endpoint di chat è stateless: come un addetto che legge solo il foglio che gli passi. Tu devi gestire il contesto. Ecco tre livelli di implementazione:

  1. Minimo:Memorizza solo in memoria usando un dizionario. I dati vengono persi al riavvio, ideale per il debug.
  2. Standard:Memorizza in SQLite come nell'esempio, una riga per messaggio, e interroga le ultime N righe per session_id.
  3. Avanzato:Quando la cronologia è troppo lunga, chiedi al modello di riassumere i messaggi più vecchi in un unico paragrafo da inserire dopo il system prompt; i messaggi recenti vengono mantenuti nel testo originale.

Un altro vantaggio di memorizzare i dati nel tuo backend è che hai il controllo totale sulla durata di conservazione. Si consiglia di fornire un pulsante "Cancella chat" che elimini effettivamente i record della sessione corrispondente e di specificare chiaramente nella tua informativa sulla privacy quali dati conservi.

Usa il riassunto quando i token storici superano 10.000: invia la prima metà al modello per un riassunto di 300 caratteri, salva e cancella i vecchi messaggi. Mantieni il contesto stabile. Il riassunto può perdere dettagli; le impostazioni critiche (nome, tabù) vanno nel system prompt.

Prodotti per adulti: ingressi e limiti

Questo servizio è solo per adulti (18+). Il frontend include un semplice ingresso di conferma età; un prodotto rigoroso dovrebbe implementare una verifica più completa. Consigli pratici:

  • Indica chiaramente il limite di età nella pagina di ingresso; non mostrare l'interfaccia di chat finché l'utente non ha confermato.
  • Non promuovere il prodotto tra gli studenti o i minori e non inserirlo in contesti rivolti a loro.
  • I contenuti sessuali sui minori, anche se fittizi, vengono bloccati con 403. Il tuo backend deve intercettare questo stato e mostrare un messaggio amichevole all'utente, non l'errore grezzo.

Dal punto di vista del design, puoi anche permettere all'utente di impostare un nome utente e delle preferenze di tono nelle impostazioni; inserendoli nel system prompt, la conversazione diventa più personalizzata senza che il modello debba indovinare.

Rafforzamento ed estensioni prima del lancio

  • Limita l'input: L'esempio tronca i singoli messaggi a 4000 caratteri; regola in base alle tue esigenze.
  • Limita la frequenza: Imposta un limite di 300 richieste al minuto per chiave API; applica il rate limiting sul backend in base all'utente o all'IP.
  • Gestione degli errori: Il backend intercetta le eccezioni e restituisce un messaggio breve al frontend; non esporre lo stack trace.
  • Monitoraggio utilizzo: per contare i token, leggi usage nelle richieste non stream o nell'ultimo blocco. Vedi Token e fatturazione.
  • Cambio di framework: Quando passi il frontend a Vue o React, la logica di lettura dello streaming rimane identica. Se passi il backend a Express, basta sostituire StreamingResponse con la scrittura stream appropriata.

Per una spiegazione più completa dei parametri, consulta Dettagli dei parametri dell'API; per ulteriori domande, vedi Domande frequenti.

Esegui un'ultima verifica pre-lancio: la chiave API è presente solo nelle variabili d'ambiente del server? Esiste un limite per la lunghezza dell'input singolo e della cronologia? Gli errori mostrano un messaggio amichevole? Il pulsante "Cancella chat" elimina effettivamente i record? La conferma dell'età è presente all'ingresso? Se tutte e cinque le condizioni sono soddisfatte, questo prototipo è pronto per essere testato da utenti reali.

Domande frequenti

Perché non permettere al browser di chiamare direttamente l'API?

Perché la chiave API verrebbe esposta nel codice web e chiunque potrebbe copiarla e usarla. È più sicuro far sì che il browser acceda solo al tuo backend, che detiene la chiave API.

Quanti messaggi salvare nella cronologia?

Dipende dal contesto; l'esempio usa gli ultimi 20 messaggi. Per controllare costi e contesto, porta meno messaggi e usa il riassunto per i punti chiave.

Cosa fare se lo streaming fallisce a metà?

Il backend intercetta l'eccezione, mostra un messaggio all'utente e salva la parte già generata. Il frontend può fornire un pulsante di retry per reinviare l'ultimo messaggio dell'utente.

Questo bot può essere rivolto ai minori?

No. Il servizio è solo per adulti (18+). La tua app deve effettuare la verifica dell'età all'ingresso.

Compila il modulo per ottenere la chiave API

Crea un account, copia la chiave API e modifica il Base URL. La configurazione è così semplice.

Ottieni la chiave API