FR ▾
Obtenir votre clé API

Créer un chatbot avec l'API de grand modèle : backend FastAPI et frontend en streaming

Vous souhaitez créer un chatbot qui affiche le texte caractère par caractère comme une machine à écrire ? Il vous suffit de trois briques : un backend qui relaie les requêtes, une page web qui lit le streaming, et un stockage qui conserve l'historique des conversations. Nous utilisons FastAPI pour le backend, l'API fetch native du navigateur pour lire le streaming, et votre propre base SQLite pour l'historique. Le tout en moins d'une centaine de lignes de code, idéal pour un prototype destiné aux utilisateurs adultes.

Mis à jour le

Points clés

  • La clé API est uniquement sur le backend ; le navigateur n'y accède jamais. Le frontend communique uniquement avec votre endpoint /chat.
  • Le modèle ne retient pas la conversation : à chaque requête, vous devez extraire les messages récents de votre stockage et les envoyer à l'interface.
  • Le streaming repose sur un StreamingResponse côté backend et une boucle reader.read() côté frontend.
  • Pour un produit adulte, implémentez une vérification d'âge et limitez la longueur de l'historique et de l'entrée utilisateur.

Schéma : trois rôles distincts

Imaginez le chatbot comme un restaurant : le navigateur est le client, votre backend est le serveur, et l'interface du LLM est la cuisine. Le client n'entre jamais en cuisine ; toutes les commandes passent par le serveur. C'est pourquoi la clé API ne doit pas être dans le frontend.

RôleResponsabilitéInterdiction
NavigateurAfficher, collecter l'entrée, lire le streamDétention de la clé API
Backend FastAPISauvegarde, assemblage des messages et relaisRetourner la clé au frontend
API du modèleGénérer une réponse basée sur les messages— Il ne traite que ce que vous lui envoyez

Le flux d'une conversation est : le frontend envoie un message, le backend le stocke, extrait l'historique, concatène le system, appelle l'interface, relaie le streaming au frontend, puis stocke la réponse complète.

Pourquoi un backend relais ? Outre la sécurité, vous devez gérer l'historique, les limites de débit et les filtres côté serveur. De plus, changer de modèle ou de fournisseur ne nécessitera de modifier que le backend.

Backend : FastAPI et SQLite

Installez les dépendances et lancez le serveur :

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

Écrivez ensuite server.py. Le code contient quatre points qui méritent que vous vous arrêtiez pour les examiner :

# 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() ne charge que les 20 dernières entrées pour éviter de saturer la fenêtre de contexte de 100 000 tokens.
  2. gen() est un générateur asynchrone qui émet chaque morceau reçu, permettant un affichage caractère par caractère.
  3. La fin du stream contient un bloc de données avec uniquement l'usage. Il n'a pas de choices ; il faut donc vérifier qu'il n'est pas vide et le sauter.
  4. Le bloc finally sauvegarde la réponse complète. Même en cas d'erreur, le texte généré n'est pas perdu.

Si vous ne voulez pas utiliser de base de données, remplacez load et save par des lectures/écritures sur un dictionnaire. Pour gérer un fort trafic, remplacez SQLite par n'importe quelle base de données familière en gardant la signature des fonctions. L'exemple utilise sqlite3 (synchrone et rapide), mais passez à un pilote asynchrone en cas de forte concurrence.

Frontend : lecture de streaming avec fetch

Aucune dépendance. resp.body.getReader() donne un lecteur. Bouclez sur read(), décodez avec TextDecoder et ajoutez à la page. Sauvegardez en index.html, même dossier que 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>

Deux détails : ajoutez { stream: true } au décodage car les octets d'un caractère peuvent être répartis sur deux blocs, ce qui causerait des caractères corrompus sans cela. Ensuite, désactivez le bouton d'envoi pendant la requête pour éviter les clics multiples ; l'exemple l'omet pour sa brièveté, mais ajoutez-le avant la mise en production.

Une fois lancé, accédez au port 8000, cochez la case de vérification d'âge et envoyez un message. Si le texte apparaît en bloc et non mot par mot, un proxy met en cache la réponse. Connectez-vous directement au port local pour exclure cette cause. Si vous utilisez Nginx, désactivez le buffering de réponse pour ce chemin.

Historique : pourquoi le stocker côté serveur

L'interface de chat est « sans état », comme un agent qui ne voit que le papier que vous lui tendez. « Mémoriser le contexte » est votre responsabilité. Voici trois niveaux de mise en œuvre :

  1. Minimaliste : Stockez uniquement en mémoire à l'aide d'un dictionnaire. Les données sont perdues au redémarrage, ce qui convient au débogage.
  2. Courant : Stockez dans SQLite comme dans l'exemple, une ligne par message, et interrogez les N derniers messages par session_id.
  3. Avancé : Si l'historique est trop long, demandez au modèle de résumer les messages anciens en un résumé placé après le system. Les messages récents conservent leur texte original.

Un autre avantage de stocker dans votre propre backend est que vous contrôlez entièrement la durée de rétention. Il est conseillé de fournir un bouton « Effacer la conversation » pour supprimer réellement les enregistrements de la session correspondante, et d'indiquer clairement dans votre politique de confidentialité ce que vous conservez.

Déclenchez la méthode de résumé lorsque le nombre estimé de tokens dépasse 10 000. Envoyez la première moitié des messages au modèle pour générer un résumé de moins de 300 mots, stockez-le et supprimez les messages originaux. Cela stabilise le volume de chaque requête. Notez que le résumé peut omettre des détails ; les éléments importants (surnom, interdits) doivent être fixés dans le system, pas dépendre du résumé.

Produits pour adultes : entrée et limites

Ce service est réservé aux adultes de 18 ans et plus. Votre application doit en faire autant. Le frontend d'exemple propose une entrée de confirmation minimale. Pour un produit plus strict, ajoutez un processus de vérification d'âge complet. Voici quelques conseils pratiques :

  • Indiquez clairement la limite d'âge sur la page d'entrée ; n'affichez pas l'interface de chat tant que la confirmation n'est pas effectuée.
  • Ne diffusez pas le produit auprès des étudiants ou des mineurs, et ne l'intégrez pas dans des contextes destinés à ces publics.
  • Le contenu sexuel impliquant des mineurs sera intercepté par l'endpoint et renverra un code 403, qu'il soit fictif ou non. Votre backend doit détecter ce statut et afficher un message convivial à l'utilisateur, au lieu de montrer l'erreur brute.

Sur le plan du design produit, vous pouvez également permettre à l'utilisateur de définir un surnom et des préférences de ton dans les paramètres. Ces éléments s'écrivent directement dans le prompt system, ce qui personnalise la conversation sans obliger le modèle à deviner.

Durcissement et extensions avant la mise en production

  • Limitez l'entrée : L'exemple tronque chaque message à 4 000 caractères ; ajustez selon vos besoins.
  • Limitez la fréquence : Limitez à 300 requêtes par minute par clé. Appliquez le rate limiting côté backend en fonction de l'utilisateur ou de l'adresse IP.
  • Affichage des erreurs : Capturez les exceptions côté backend et renvoyez un message court au frontend. N'exposez pas la pile d'appels (stack trace).
  • Suivi de l'usage : Pour compter les tokens, lisez l'usage dans les requêtes non streamées ou dans le dernier bloc du stream. Voir Tokens et facturation pour les détails.
  • Changement de framework : La logique de lecture du stream reste identique si vous passez à Vue ou React au frontend. Si vous changez le backend pour Express, il suffit de remplacer StreamingResponse par l'écriture streamée correspondante.

Pour plus de détails sur les paramètres, consultez Détails des paramètres de l'interface. Pour d'autres questions, voir FAQ.

Effectuez une dernière vérification avant la mise en production : la clé est-elle uniquement stockée dans les variables d'environnement du serveur ? Les entrées individuelles et la longueur de l'historique ont-elles une limite ? Les erreurs affichent-elles un message convivial ? Le bouton « Effacer la conversation » supprime-t-il réellement les enregistrements ? La confirmation d'âge est-elle présente à l'entrée ? Si ces cinq points sont validés, ce prototype est prêt à être testé par des utilisateurs réels.

Foire aux questions

Pourquoi ne pas appeler l'API directement depuis le navigateur ?

Parce que la clé API serait exposée dans le code du site web et pourrait être copiée ou volée par n'importe qui. Il est plus sûr que le navigateur n'accède qu'à votre propre backend, qui conserve la clé API.

Combien de messages d'historique faut-il conserver ?

Cela dépend du contexte ; l'exemple conserve les 20 derniers messages. Pour maîtriser les coûts et la longueur de la fenêtre de contexte, il vaut mieux en conserver moins et s'appuyer sur le résumé pour garder les points essentiels.

Que faire en cas d'échec du streaming en cours ?

Le backend capture l'exception, affiche un message à l'utilisateur et sauvegarde le contenu déjà généré. Le frontend peut proposer un bouton de nouvelle tentative pour renvoyer le dernier message de l'utilisateur.

Ce chatbot peut-il s'adresser aux mineurs ?

Non. Le service est réservé aux adultes de plus de 18 ans ; votre application doit également demander la confirmation d'âge à l'entrée.

Remplissez simplement le formulaire pour obtenir votre clé API

Créez un compte, copiez votre clé API et modifiez l'URL de base. La configuration est aussi simple que cela.

Obtenir une clé API