PL ▾
Pobierz klucz API

Chatbot na API dużego modelu: backend FastAPI i strumieniowy frontend

Aby stworzyć chatbota wypisującego tekst znak po znak, potrzebujesz backendu przesyłającego zapytania, frontendu odczytującego strumień i pamięci historii. Używamy FastAPI i fetch w przeglądarce. Historia trafia do Twojego SQLite. Kod ma mniej niż 100 linii – idealny prototyp dla użytkowników pełnoletnich.

Zaktualizowano

Kluczowe punkty

  • Klucz API jest tylko po stronie backendu. Przeglądarka nie ma do niego dostępu i komunikuje się tylko z Twoim endpointem /chat.
  • Model nie pamięta konwersacji. Za każdym razem pobierasz ostatnie wpisy z bazy i wysyłasz je jako prompt.
  • Kluczem jest przesyłanie strumienia przez StreamingResponse w backendzie i łączenie danych w pętli reader.read() po stronie frontendu.
  • Dla użytkowników pełnoletnich wymagaj potwierdzenia wieku i ogranicz długość historii oraz pojedynczej wiadomości.

Najpierw szkic: trzy role, trzy strefy

Traktuj chatbota jak restaurację: przeglądarka to klient, Twój backend to recepcja, a API modelu to kuchnia. Klient nie wchodzi do kuchni, więc klucz API nie może być po stronie frontendu.

RolaZadanieZabronione
PrzeglądarkaWyświetlanie, zbieranie danych, odczyt strumieniaPrzechowywanie klucza API
Backend FastAPIZapis historii, budowanie messages, przesyłanie zapytańZwracanie klucza do frontendu
Interfejs modeluGenerowanie odpowiedzi na podstawie messages—— obsługuje tylko to, co mu wyślesz

Przepływ: przeglądarka wysyła wiadomość, backend zapisuje ją i pobiera historię, dodaje system prompt, wysyła zapytanie, przesyła strumień do przeglądarki i zapisuje pełną odpowiedź.

Przekazywanie przez backend jest bezpieczniejsze i pozwala na dodanie historii, limitów i filtrów. Ponadto łatwiej jest zmienić model lub usługę – modyfikujesz tylko backend, frontend pozostaje bez zmian.

Backend: FastAPI i SQLite

Zainstaluj zależności i uruchom serwer:

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

Następnie stwórz plik server.py. Zwróć uwagę na cztery elementy kodu:

# 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() pobiera tylko ostatnie 20 wpisów, aby nie przekroczyć 100 000 tokenów w oknie kontekstu.
  2. gen() to asynchroniczny generator, który zwraca (yield) fragmenty po otrzymaniu, dzięki czemu frontend wyświetla tekst znak po znaku.
  3. Na końcu strumienia znajduje się blok danych zawierający tylko zużycie (bez choices), który należy sprawdzić i pominąć.
  4. W bloku finally zapisujemy pełną odpowiedź, więc nawet przy błędzie nie tracimy wygenerowanego tekstu.

Jeśli nie chcesz jeszcze używać bazy danych, zamień load i save na operacje na słowniku. Po wzroście ruchu możesz zastąpić SQLite dowolną inną bazą, zachowując ten sam interfejs. Przykład używa synchronnego sqlite3, co jest wystarczające dla prototypu; przy dużym obciążeniu rozważ sterownik asynchroniczny.

Frontend: odczyt strumienia przez fetch

Nie potrzebujesz bibliotek. resp.body.getReader() zwraca czytnika, iteruj read(), odbieraj kawałek bajtów, dekoduj przez TextDecoder i dodawaj do strony. Zapisz jako index.html obok 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>

Dwa szczegóły: podczas dekodowania dodaj { stream: true }, ponieważ bajty jednego znaku mogą być podzielone na dwie części; bez tego pojawią się znaki mojibake. Po drugie, logika wysyłania w polu tekstowym powinna wyłączać przycisk podczas żądania, aby zapobiec wielokrotnemu klikaniu. W przykładzie pominięto to dla zwięzłości, ale w wersji produkcyjnej należy to dodać.

Po uruchomieniu otwórz przeglądarkę pod adresem localhost:8000, zaznacz potwierdzenie wieku i wyślij wiadomość. Jeśli tekst pojawia się naraz, a nie znak po znak, prawdopodobnie buforuje go proxy; przetestuj połączenie bezpośrednie. Jeśli wdrażasz za Nginx, wyłącz buforowanie odpowiedzi dla tej ścieżki.

Historia konwersacji: dlaczego po Twojej stronie

Interfejs czatu jest „bezstanowy” — jak recepcjonista, który wie tylko to, co widzi na kartce, którą mu podajesz. To Ty odpowiadasz za kontekst. Istnieją trzy podejścia:

  1. Najprostszy:Przechowuj słownik wyłącznie w pamięci. Po restarcie dane znikają, co sprawdza się podczas debugowania.
  2. Standardowy:Zapisuj dane do SQLite, jak w przykładzie: każda wiadomość jako osobny wiersz, a najnowsze N wiadomości pobierane według session_id.
  3. Zaawansowane: gdy historia jest zbyt długa, zleć modelowi podsumowanie starszych wiadomości w jeden akapit i umieść go po system, zachowując niedawne wiadomości w oryginalnej formie.

Kolejna zaleta przechowywania danych we własnym backendzie to pełna kontrola nad okresem retencji. Zalecamy dodanie przycisku „Wyczyść konwersację”, który faktycznie usuwa zapisy danej sesji, oraz jasne wskazanie w polityce prywatności, jakie dane przechowujesz.

Warunek wyzwalania metody podsumowania może być prosty: gdy oszacowana liczba tokenów historii przekracza 10 000, zleć modelowi podsumowanie najstarszej połowy wiadomości do akapitu o długości nieprzekraczającej 300 znaków, zapisz podsumowanie w pamięci masowej i usuń oryginalne wiadomości. Dzięki temu zachowujesz kontekst, a wielkość każdego żądania pozostaje stabilna i kontrolowana. Pamiętaj, że podsumowanie jest generowane przez model i może pomijać szczegóły; ważne ustawienia (np. pseudonim użytkownika, tematy zakazane) lepiej zdefiniować w system, niż polegać na podsumowaniu.

Produkty dla dorosłych: wejście i granice

Usługa przeznaczona jest wyłącznie dla użytkowników pełnoletnich (18+), a Twoja aplikacja powinna to respektować. We frontendzie przykładu znajduje się najprostszy element potwierdzający wiek; w bardziej rygorystycznych produktach można wdrożyć pełniejszy proces weryfikacji. Kilka zaleceń praktycznych:

  • Na stronie wejściowej wyraźnie wskaż ograniczenie wiekowe; jeśli użytkownik go nie potwierdzi, nie wyświetlaj interfejsu czatu.
  • Nie kieruj promocji produktu do uczniów lub osób niepełnoletnich, ani nie umieszczaj go w środowiskach przeznaczonych dla nich.
  • Treści o charakterze seksualnym z udziałem osób niepełnoletnich (nawet w fikcji) są blokowane przez interfejs i zwracają kod 403. Twój backend powinien wykrywać ten status i wyświetlać użytkownikowi uprzejmy komunikat zamiast surowego błędu.

Z perspektywy UX możesz pozwolić użytkownikowi na ustawienie własnego imienia i preferencji tonu w ustawieniach aplikacji. Wystarczy zapisać te dane w system prompt — dzięki temu rozmowa będzie bardziej spersonalizowana, a model nie będzie musiał zgadywać preferencji.

Wzmocnienie i rozszerzenie przed uruchomieniem

  • Ograniczenie wejścia:W przykładzie pojedyncza wiadomość jest obcięta do 4000 znaków; dostosuj ten limit do swoich potrzeb.
  • Ograniczenie częstotliwości:Ustal limit 300 zapytań na minutę na klucz API. Zaimplementuj throttling w backendzie na poziomie użytkownika lub adresu IP.
  • Wyświetlanie błędów:Backend powinien przechwytywać wyjątki i zwracać frontendowi krótki komunikat. Nie udostępniaj stosu wyjątków (stack trace).
  • Monitorowanie zużycia:Aby liczyć tokeny, odczytaj pole usage w zapytaniach niestreamingowych lub w ostatnim bloku strumienia. Szczegóły znajdziesz w sekcji Tokeny i rozliczenia.
  • Zmiana frameworka:Przejście frontendu na Vue lub React nie zmienia logiki odczytu strumienia. Zmiana backendu na Express wymaga jedynie zamiany StreamingResponse na odpowiednią implementację strumieniowania.

Po więcej szczegółów dotyczących parametrów zobacz Szczegółowy opis parametrów interfejsu; na inne pytania odpowiadamy w Najczęściej zadawanych pytaniach.

Przed uruchomieniem wykonaj ostateczny audyt: czy klucz API znajduje się wyłącznie w zmiennych środowiskowych backendu? Czy wprowadzono limity długości pojedynczej wiadomości i historii? Czy błędy są komunikowane w przyjazny sposób? Czy opcja „Wyczyść konwersację” faktycznie usuwa zapisy? Czy weryfikacja wieku jest widoczna na stronie wejściowej? Jeśli spełnione są wszystkie te pięć warunków, prototyp jest gotowy do testów przez rzeczywistych użytkowników.

Najczęściej zadawane pytania

Dlaczego nie należy wywoływać interfejsu bezpośrednio z przeglądarki?

Ponieważ sekretny klucz jest widoczny w przeglądarce, każdy może go skopiować i wykorzystać. Bezpieczniejszym rozwiązaniem jest kierowanie przeglądarki tylko do własnego backendu, który przechowuje klucz.

Ile historii konwersacji należy przechowywać?

To zależy od scenariusza; w przykładzie przyjęto ostatnie 20 wiadomości. Aby kontrolować koszty i długość okna kontekstu, lepiej przesłać mniej wiadomości i uzupełnić kontekst podsumowaniem kluczowych fragmentów.

Co zrobić, jeśli strumieniowanie zostanie przerwane?

Backend powinien przechwycić wyjątek, wyświetlić użytkownikowi komunikat i zapisać wygenerowaną dotychczas treść. Frontend może udostępnić przycisk „Ponów”, który ponownie wyśle ostatnią wiadomość użytkownika.

Czy ten bot może być przeznaczony dla osób niepełnoletnich?

Nie. Usługa jest ograniczona do osób pełnoletnich (18+). Twoja aplikacja musi wymagać potwierdzenia wieku przy wejściu.

Uzyskaj klucz API, wypełniając formularz

Utwórz konto, skopiuj klucz i zmień Base URL. Konfiguracja jest prosta.

Uzyskaj klucz API