Chatbot bouwen met LLM API: FastAPI backend en streaming frontend
Een chatbot die karakters per karakter uitvoert, vereist slechts drie onderdelen: een backend die verzoeken doorstuurt, een webpagina die de stream leest en een opslag die gesprekken onthoudt. We gebruiken FastAPI als backend, browser fetch voor streaming output en sla de geschiedenis op in je eigen SQLite. De code is minder dan honderd regels, ideaal voor een volwassen productprototype.
Bijgewerkt op
Kernpunten
- De API-sleutel zit alleen in de backend; de browser raakt hem nooit aan. De frontend communiceert alleen met je eigen /chat endpoint.
- Het model onthoudt de conversatie niet: je moet bij elk verzoek de laatste geschiedenis uit je opslag halen en naar het endpoint sturen.
- Het geheim van streaming is dat de backend StreamingResponse gebruikt om door te sturen en de frontend een lus maakt met reader.read() om alles samen te voegen.
- Voor een volwassen product moet je bij de ingang leeftijdcontrole uitvoeren en zowel de lengte van de geschiedenis als de enkele invoerlengte beperken.
Eerst een schema: drie rollen, drie taken
Stel je de chatbot voor als een afhaalrestaurant: de browser is de klant, jouw backend is de balie en de LLM is de keuken. Klanten gaan niet de keuken in; alle bestellingen worden door de balie doorgegeven. Daarom mag je de sleutel niet in de frontend plaatsen.
| Rol | Verantwoordelijk voor | Mag nooit |
|---|---|---|
| Browser | Weergeven, invoer verzamelen, stream lezen | API-sleutel bewaren |
| FastAPI backend | Geschiedenis opslaan, messages samenstellen, requests forwarden | Sleutel teruggeven aan frontend |
| Model API | Genereer antwoord op basis van messages | —— verwerkt alleen wat jij stuurt |
Een gesprek verloopt als volgt: de webpagina stuurt een bericht, de backend slaat het op, haalt de recente geschiedenis op, voegt system toe, doet een verzoek naar de endpoint, stuurt de ontvangen data direct door naar de webpagina en slaat na afloop het volledige antwoord ook op.
Waarom kiezen voor backend forwarding in plaats van directe frontend connectie? Naast sleutelbeveiliging zijn er twee praktische redenen: je moet tijdens het forwarden geschiedenis toevoegen, rate limits toepassen en filteren, wat alleen in de backend kan. En bij een model- of servicewissel hoef je alleen de backend aan te passen, niet de frontend.
Backend: FastAPI en SQLite
Installeer eerst de afhankelijkheden en start de server:
pip install fastapi uvicorn openai
export API_KEY=你的密钥
uvicorn server:app --reload --port 8000Schrijf vervolgens server.py. Let op vier belangrijke punten in de code:
# 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")load()haalt alleen de laatste 20 berichten op om te voorkomen dat de conversatie te lang wordt en het contextvenster van 100,000 tokens volloopt.gen()is een async generator. Deze yieldt fragmenten zodra ze binnenkomen, zodat de frontend ze karakter voor karakter kan tonen.- Aan het einde van de stream komt er een data-blok met alleen verbruiksinformatie; dit heeft geen choices, dus je moet controleren op leegte en het overslaan.
- In de
finallyblock slaan we het volledige antwoord op. Zelfs bij een fout gaan de gegenereerde fragmenten niet verloren.
Wil je eerst geen database gebruiken, vervang dan load en save door lezen en schrijven naar een dictionary; de rest van de code hoeft niet te veranderen. Als het verkeer toeneemt, kun je SQLite vervangen door elke database die je kent; de interface moet de vorm van deze twee functies behouden. Let op: het voorbeeld gebruikt de synchrone sqlite3, wat snel genoeg is voor een prototype; kies voor een asynchrone driver bij hoge gelijktijdige verzoeken.
Frontend: streaming met fetch
De browser heeft geen bibliotheken nodig. resp.body.getReader() geeft een reader die je in een lus read() gebruikt. Elke ontvangen byte-blok decodeer je met TextDecoder en voeg je toe aan de pagina. Sla de volgende inhoud op als index.html, in dezelfde map als 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>Twee details: voeg { stream: true } toe tijdens het decoderen omdat de bytes van één Chinees karakter over twee blokken kunnen vallen, wat anders leidt tot onleesbare karakters. Daarnaast moet de verzendlogica van het invoerveld de knop uitschakelen tijdens het verzoek om dubbelklikken te voorkomen; dit is wegens de beknopte code in het voorbeeld weggelaten, maar voeg het toe bij de lancering.
Open de browser en ga naar poort 8000. Bevestig de leeftijd en stuur een bericht. Verschijnt de tekst in één blok in plaats van karakter voor karakter? Dan bufferd een proxy. Test eerst direct op localhost. Bij Nginx moet je response buffering uitzetten voor dit pad.
Gespreksgeschiedenis: waarom eigen opslag
Het chat-endpoint is 'stateless', zoals een balie-medewerker die alleen ziet wat je hem voorlegt. De verantwoordelijkheid voor context ligt bij jou. De specifieke aanpak kent drie niveaus:
- Simpelst: sla op in een dictionary in het geheugen. Alles gaat verloren bij een herstart, ideaal voor debugging.
- Standaard:Sla de data op in SQLite, zoals in het voorbeeld. Bewaar elke berichtregel apart en haal de laatste N berichten op via de session_id.
- Geavanceerd:Als de geschiedenis te lang wordt, vraag het model dan om een samenvatting van de eerdere berichten. Plaats deze samenvatting na de system prompt; de recente berichten blijven als volledige tekst bewaard.
Een ander voordeel van opslag op je eigen backend is dat jij de bewaartermijn volledig bepaalt. Het is aan te raden een knop ‘Gesprek wissen’ te bieden die de bijbehorende sessiegegevens daadwerkelijk verwijdert, en duidelijk in je privacybeleid te vermelden wat je bewaart.
De trigger voor samenvatting kan eenvoudig zijn: als de geschatte history tokens tienduizend overschrijden, geef dan de eerste helft van de berichten aan het model om samen te vatten tot een tekst van maximaal 300 woorden. Schrijf dit terug naar de opslag en verwijder de originele berichten. Zo behoud je de context en blijft de omvang van elk verzoek beheersbaar. Houd er rekening mee dat samenvattingen door het model gegenereerd worden en details kunnen missen; belangrijke instellingen (zoals de gebruikersnaam of taboes) kun beter in de system vastleggen in plaats van te vertrouwen op samenvatting.
Producten voor volwassenen: toegang en grenzen
Deze service is alleen voor volwassenen van 18 jaar en ouder; jouw applicatie moet dat ook zijn. Het voorbeeld heeft een eenvoudige bevestigingsingang; strengere producten kunnen een uitgebreider verificatieproces toevoegen. Tips:
- Geef op de toegangspagina duidelijk de leeftijdslimiet aan; toon het chatinterface pas na bevestiging.
- Promoot het product niet aan studenten of minderjarigen, en gebruik het niet in omgevingen die specifiek voor hen bedoeld zijn.
- Seksuele inhoud met minderjarigen wordt altijd geblokkeerd met een 403-status, ook als fictief. Je backend moet deze status herkennen en een vriendelijke melding tonen in plaats van de ruwe fout.
Je kunt ook vanuit het productontwerp gebruikers in staat stellen een eigen bijnaam en toonvoorkeuren in te stellen in de instellingen. Schrijf dit gewoon in de system-prompt, zodat het gesprek persoonlijker wordt en het model niet hoeft te raden.
Versterking en uitbreiding vóór de lancering
- Beperk invoer: het voorbeeld snijdt een enkel bericht af bij 4000 tekens; pas dit naar behoefte aan.
- Beperk frequentie: elke sleutel mag 300 verzoeken per minuut; pas rate limits toe in de backend per gebruiker of IP.
- Foutweergave: De backend vangt uitzonderingen af en geeft een korte melding terug aan de frontend. Toon de stacktrace niet.
- Gebruiksmonitoring: lees het verbruik op in niet-streaming verzoeken, of in het laatste blok van de streaming. Zietoken en facturatie voor details.
- Framework wisselen: Als je frontend overstapt op Vue of React, is de logica voor het lezen van de stream identiek. Als je backend overstapt naar Express, vervang je StreamingResponse gewoon door de equivalente stream-implementatie.
Voor meer complete parameterbeschrijving zieAPI-parameterdetails; meer vragen zieVeelgestelde vragen.
Doe tot slot een laatste check vóór de lancering: wordt de sleutel alleen bewaard in server-side environment variables? Zijn er limieten aan de inputlengte en de lengte van de geschiedenis? Geven fouten een vriendelijke melding? Verwijdert het wissen van het gesprek de records echt? Wordt de leeftijdcontrole aan de ingang uitgevoerd? Als aan al deze vijf punten is voldaan, kun je deze prototype aan echte gebruikers aanbieden voor testen.
Veelgestelde vragen
Waarom niet de API rechtstreeks vanuit de browser aanroepen?
Omdat je API-sleutel dan in de webpagina zou staan en iedereen die zou kunnen kopiëren en misbruiken. Het is veiliger om de browser alleen je eigen backend te laten aanroepen, terwijl de backend de sleutel vasthoudt.
Hoeveel gespreksgeschiedenis moet je opslaan?
Dit hangt af van de use case. Het voorbeeld gebruikt de meest recente 20 berichten. Om kosten en de contextvensterlengte te beheersen, is het beter om minder berichten mee te sturen en de kernpunten te behouden via samenvatting.
Wat als de streamingoutput halverwege faalt?
De backend vangt de uitzondering af en geeft een melding terug aan de frontend, terwijl het al gegenereerde deel wordt opgeslagen. De frontend kan een knop voor opnieuw proberen bieden om het laatste bericht van de gebruiker opnieuw te verzenden.
Kan deze bot aan minderjarigen worden aangeboden?
Nee. De dienst is alleen voor volwassenen van 18 jaar en ouder. Jouw applicatie moet ook een leeftijdcontrole uitvoeren bij de ingang.
Vul het formulier in om je API-sleutel te ontvangen
Maak een account aan, kopieer je API-sleutel en pas de Base URL aan. Zo eenvoudig is configureren.