ID ▾
Dapatkan kunci API

Buat chatbot dengan API LLM: Backend FastAPI dan frontend streaming

Membangun chatbot dengan output karakter demi karakter sebenarnya hanya membutuhkan tiga blok bangunan: backend untuk meneruskan permintaan, halaman web untuk membaca streaming, dan penyimpanan untuk mengingat percakapan. Artikel ini menggunakan FastAPI untuk backend, browser native fetch untuk membaca output streaming, dan menyimpan riwayat percakapan ke SQLite Anda sendiri. Seluruhnya kurang dari seratus baris kode, cocok untuk prototipe produk pengguna dewasa.

Diperbarui pada

Poin penting

  • Kunci API hanya disimpan di backend, browser tidak pernah menyentuhnya, frontend hanya berkomunikasi dengan endpoint /chat milik Anda sendiri.
  • Model tidak menyimpan riwayat percakapan untuk Anda: setiap permintaan harus mengambil beberapa riwayat terakhir dari penyimpanan Anda, lalu mengirimkannya ke endpoint.
  • Kunci streaming adalah meneruskan respons menggunakan StreamingResponse di backend, lalu menggabungkannya di frontend dengan loop reader.read().
  • Untuk produk yang ditujukan kepada dewasa, titik masuk harus melakukan konfirmasi usia, serta membatasi panjang riwayat dan panjang input per permintaan.

Mulailah dengan menggambar skema kecil: tiga peran yang masing-masing mengelola area mereka sendiri

Bayangkan chatbot sebagai sebuah restoran pengiriman makanan: browser adalah pelanggan, backend Anda adalah bagian depan, dan interface model besar adalah dapur. Pelanggan tidak pernah langsung masuk ke dapur, semua pesanan diteruskan oleh bagian depan, itulah sebabnya mengapa kunci API tidak boleh ditempatkan di frontend.

PeranTanggung jawabTidak boleh melakukan
BrowserMenampilkan, mengumpulkan input, membaca streamMemegang kunci API
Backend FastAPIMenyimpan riwayat, menyusun pesan, meneruskan permintaanMengembalikan kunci ke frontend
Endpoint modelMenghasilkan balasan berdasarkan pesan— Ia hanya memproses apa yang Anda kirimkan

Alur satu percakapan adalah: halaman web mengirim satu kalimat, backend menyimpannya, mengambil riwayat terakhir, menggabungkannya dengan system prompt, meminta endpoint, meneruskan balasan ke halaman web saat diterima, dan menyimpan balasan lengkap setelah selesai.

Mengapa meneruskan melalui backend, bukan langsung dari frontend? Selain keamanan kunci, ada dua alasan praktis: pertama, Anda perlu menambahkan riwayat, membatasi laju, dan memfilter permintaan di backend; kedua, jika Anda mengganti model atau layanan, cukup ubah backend, frontend tidak perlu diubah.

Backend: FastAPI dan SQLite

Instal dependensi dan mulai server:

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

Kemudian tulis server.py. Ada empat poin penting dalam kode ini:

# 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() hanya mengambil 20 pesan terakhir untuk mencegah jendela konteks melebihi 100.000 token.
  2. gen() adalah generator asinkron yang menghasilkan setiap bagian kecil saat diterima, memungkinkan frontend menampilkan teks secara streaming.
  3. Di akhir stream, ada blok data yang hanya berisi penggunaan tanpa choices, sehingga harus dilewati jika kosong.
  4. Di finally, simpan balasan lengkap. Bahkan jika terjadi kesalahan, bagian yang sudah dihasilkan tidak akan hilang.

Jika Anda ingin tidak menyentuh database terlebih dahulu, ganti load dan save dengan operasi baca-tulis pada kamus, kode lainnya tidak perlu diubah. Sebaliknya, setelah volume lalu lintas meningkat, SQLite dapat diganti dengan database apa pun yang Anda kuasai, asalkan interface mempertahankan bentuk dua fungsi ini. Satu hal lagi: contoh menggunakan sqlite3 sinkron yang penulisan datanya cepat dan cukup untuk prototipe; untuk skenario permintaan paralel tinggi, pertimbangkan driver asinkron.

Frontend: Membaca stream dengan fetch

Browser tidak memerlukan pustaka apa pun. resp.body.getReader() mendapatkan reader, loop read(), setiap kali mendapatkan satu blok byte, dekode menggunakan TextDecoder lalu tambahkan ke halaman. Simpan konten berikut sebagai index.html, dalam direktori yang sama dengan 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>

Dua detail: tambahkan { stream: true } saat decoding karena byte satu karakter Han mungkin terpecah di dua blok, jika tidak akan muncul teks acak (mojibake); selain itu, logika pengiriman input harus menonaktifkan tombol selama permintaan berlangsung untuk mencegah klik ganda, contoh tidak menuliskannya agar lebih singkat, silakan tambahkan saat peluncuran.

Setelah dijalankan, buka browser ke port 8000, centang verifikasi usia, dan kirim pesan. Jika teks muncul sekaligus bukan streaming, mungkin ada proxy yang melakukan buffering. Coba akses langsung ke port lokal. Jika menggunakan Nginx, nonaktifkan buffering respons untuk path tersebut.

Riwayat percakapan: Mengapa harus disimpan di sisi Anda

Endpoint chat bersifat stateless, seperti resepsionis yang hanya melihat catatan yang Anda berikan. Jadi, tanggung jawab Anda adalah "mengingat konteks". Ada tiga tingkat implementasi:

  1. Paling sederhana:hanya menggunakan kamus di memori. Data hilang saat restart, cocok untuk debugging.
  2. Umum:Simpan ke SQLite seperti contoh, setiap pesan sebagai satu baris, dan ambil N pesan terbaru berdasarkan session_id.
  3. Lanjutan:Saat riwayat terlalu panjang, minta model untuk meringkas pesan-pesan lama menjadi satu ringkasan. Letakkan ringkasan ini setelah system prompt, sementara pesan-pesan terbaru tetap disimpan sebagai teks asli.

Keuntungan lain menyimpan data di backend Anda sendiri adalah Anda memiliki kendali penuh atas durasi penyimpanan. Disarankan untuk menyediakan tombol "Hapus Percakapan" yang benar-benar menghapus riwayat sesi terkait, serta jelaskan secara jelas dalam kebijakan privasi apa saja yang Anda simpan.

Pemicu metode ringkasan bisa sangat sederhana: ketika estimasi token riwayat melebihi 10.000, serahkan separuh pesan terawal kepada model untuk diringkas menjadi satu paragraf maksimal 300 kata. Tulis kembali ke penyimpanan dan hapus pesan asli. Dengan cara ini, konteks tetap terjaga dan volume setiap permintaan tetap stabil dalam batas yang terkendali. Perlu dicatat bahwa ringkasan dihasilkan oleh model, sehingga detail mungkin terlewatkan. Pengaturan penting (seperti nama panggilan pengguna atau topik tabu) lebih baik ditetapkan secara tetap di system prompt, bukan bergantung pada ringkasan.

Produk untuk dewasa: Pintu masuk dan batasan

Layanan ini hanya untuk pengguna dewasa berusia 18 tahun ke atas, dan aplikasi Anda juga harus demikian. Contoh frontend menyertakan pintu masuk konfirmasi sederhana; produk yang lebih ketat dapat menambahkan proses verifikasi usia yang lebih lengkap. Beberapa saran praktik:

  • Halaman pintu masuk menampilkan peringatan batas usia dengan jelas; antarmuka chat tidak ditampilkan sebelum konfirmasi.
  • Jangan promosikan produk kepada siswa atau kelompok di bawah umur, dan jangan masukkan ke dalam skenario yang ditujukan untuk mereka.
  • Konten seksual yang melibatkan anak di bawah umur akan diblokir oleh endpoint dan mengembalikan status 403, baik itu fiksi maupun tidak. Backend Anda harus dapat mendeteksi status ini dan memberikan pesan ramah kepada pengguna, bukan menampilkan error mentah.

Dari sisi desain produk, Anda juga dapat memungkinkan pengguna menetapkan nama panggilan dan preferensi gaya bahasa di pengaturan. Informasi ini cukup ditulis ke system prompt agar percakapan lebih personal tanpa perlu model menebak-nebak.

Penguatan dan ekstensi pra-peluncuran

  • Batas input:Contoh telah memotong pesan tunggal menjadi 4000 karakter; sesuaikan sesuai kebutuhan.
  • Batas frekuensi:Batasi 300 permintaan per menit per kunci. Lakukan rate limiting di backend berdasarkan pengguna atau IP.
  • Tampilan error:Backend menangkap exception dan mengembalikan pesan singkat ke frontend. Jangan tampilkan stack trace.
  • Pemantauan penggunaan:Untuk menghitung token, baca field usage pada permintaan non-streaming, atau baca pada blok terakhir dari stream. Lihat detailnya di Token dan Penagihan.
  • Penggantian framework:Saat frontend diganti menjadi Vue atau React, logika membaca stream tetap sama. Saat backend diganti menjadi Express, cukup ganti StreamingResponse dengan penulisan stream yang sesuai.

Untuk penjelasan parameter yang lebih lengkap, lihat Penjelasan Parameter API; untuk pertanyaan lainnya, lihat FAQ.

Lakukan pemeriksaan mandiri pra-peluncuran sekali lagi: apakah kunci hanya ada di variabel lingkungan server; apakah ada batas untuk input tunggal dan panjang riwayat; apakah error memiliki pesan yang ramah; apakah "Hapus Percakapan" benar-benar menghapus riwayat; apakah konfirmasi usia ada di pintu masuk. Jika kelima hal ini terpenuhi, prototipe ini siap untuk diuji oleh pengguna nyata.

Pertanyaan Umum

Mengapa browser tidak bisa memanggil API langsung?

Karena kunci API akan terekspos di halaman web dan bisa disalin atau disalahgunakan oleh siapa saja. Cara yang lebih aman adalah membiarkan browser hanya mengakses backend Anda sendiri, di mana kunci API disimpan.

Berapa banyak riwayat percakapan yang harus disimpan?

Tergantung pada skenario; contoh mengambil 20 pesan terbaru. Saat perlu mengontrol biaya dan panjang konteks, lebih baik membawa lebih sedikit pesan dan melengkapi dengan ringkasan untuk mempertahankan poin-poin penting.

Bagaimana jika streaming gagal di tengah jalan?

Backend menangkap exception dan menampilkan pesan peringatan ke frontend, sekaligus menyimpan bagian yang sudah dihasilkan. Frontend dapat menyediakan tombol coba ulang untuk mengirim kembali pesan pengguna sebelumnya.

Apakah chatbot ini dapat ditujukan kepada anak-anak di bawah umur?

Tidak. Layanan ini hanya untuk pengguna dewasa berusia di atas 18 tahun, dan aplikasi Anda juga harus melakukan konfirmasi usia di titik masuk.

Isi formulir untuk mendapatkan kunci API

Buat akun, salin kunci, dan ubah Base URL. Konfigurasinya sangat sederhana.

Dapatkan Kunci API