VI ▾
Lấy khóa API

Dùng API mô hình lớn xây bot chat: Backend FastAPI và frontend stream

Muốn làm chatbot hiển thị từng chữ như máy đánh chữ, bạn chỉ cần ba khối: backend chuyển tiếp yêu cầu, trang web đọc stream, và bộ nhớ ghi nhớ hội thoại. Bài viết dùng FastAPI làm backend, trình duyệt dùng fetch đọc stream, lưu lịch sử vào SQLite của bạn. Dưới 100 dòng code, phù hợp cho nguyên mẫu sản phẩm dành cho người trưởng thành.

Cập nhật lúc

Điểm chính

  • Khóa API chỉ đặt ở backend, trình duyệt không bao giờ tiếp xúc, frontend chỉ giao tiếp với endpoint /chat của bạn.
  • Mô hình không ghi nhớ hội thoại: mỗi yêu cầu đều phải lấy từ bộ nhớ của bạn một số đoạn lịch sử gần nhất, rồi gửi lên giao diện.
  • Mấu chốt của stream là backend dùng StreamingResponse để chuyển tiếp, frontend dùng reader.read() để vòng lặp ghép nối.
  • Sản phẩm dành cho người trưởng thành cần xác nhận tuổi tại điểm vào, đồng thời giới hạn độ dài lịch sử và độ dài đầu vào mỗi lần.

Vẽ sơ đồ trước: ba vai trò, mỗi người một việc

Hãy tưởng tượng chatbot như một cửa hàng đồ ăn nhanh: trình duyệt là khách hàng, backend của bạn là nhân viên phục vụ, giao diện mô hình lớn là bếp sau. Khách không bao giờ vào bếp, mọi đơn hàng đều được nhân viên chuyển tiếp, đó là lý do tại sao khóa API không được đặt ở frontend.

Vai tròPhụ tráchKhông được làm
Trình duyệtHiển thị, thu thập đầu vào, đọc streamGiữ khóa API
Backend FastAPILưu lịch sử, ghép messages, chuyển tiếp yêu cầuTrả khóa API về frontend
API mô hìnhSinh phản hồi dựa trên messages——chỉ xử lý những gì bạn gửi

Luồng chuyển tiếp của một cuộc hội thoại: trang web gửi một câu, backend lưu lại, lấy lịch sử gần nhất, ghép với system, yêu cầu giao diện, vừa nhận vừa chuyển tiếp cho trang web, sau khi nhận xong lưu toàn bộ phản hồi lại.

Tại sao chọn backend chuyển tiếp thay vì frontend nối trực tiếp? Ngoài vấn đề bảo mật khóa, còn hai lý do thực tế: một là bạn cần thêm lịch sử, giới hạn tốc độ và lọc khi chuyển tiếp, những việc này chỉ làm được ở backend; hai là khi đổi mô hình hoặc dịch vụ, chỉ cần sửa backend, frontend không cần động.

Backend: FastAPI kết hợp SQLite

Cài đặt phụ thuộc và khởi động trước:

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

Sau đó viết server.py. Code có bốn điểm đáng chú ý:

# 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() chỉ lấy 20 dòng gần nhất để tránh lịch sử dài làm tràn cửa sổ ngữ cảnh 100,000 token.
  2. gen() là trình tạo bất đồng bộ, mỗi khi nhận được một đoạn nhỏ thì yield ra, nhờ đó frontend có thể hiển thị từng chữ.
  3. Cuối stream sẽ có một khối dữ liệu chỉ chứa thông tin sử dụng, không có choices, nên cần kiểm tra rỗng để bỏ qua.
  4. Trong finally lưu toàn bộ phản hồi, ngay cả khi có lỗi giữa chừng, phần đã sinh ra cũng không bị mất.

Nếu bạn muốn chưa dùng database, hãy thay load và save bằng việc đọc/ghi dictionary, các code khác không cần đổi. Ngược lại, khi lưu lượng tăng, SQLite có thể thay bằng bất kỳ database nào bạn quen, giữ nguyên hình dạng của hai hàm này. Lưu ý: ví dụ dùng sqlite3 đồng bộ, ghi rất nhanh, đủ cho nguyên mẫu; ở kịch bản đồng thời cao mới cân nhắc driver bất đồng bộ.

Frontend: Đọc stream bằng fetch

Trình duyệt không cần thư viện nào. resp.body.getReader() lấy trình đọc, vòng lặp read(), mỗi lần nhận một khối byte, dùng TextDecoder giải mã rồi nối vào trang. Lưu nội dung dưới thành index.html, cùng thư mục với 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>

Hai chi tiết: khi giải mã thêm { stream: true } vì byte của một chữ Hán có thể bị chia đôi, nếu không sẽ ra ký tự rác; thứ hai, nút gửi ở ô nhập nên bị vô hiệu hóa trong khi có yêu cầu, tránh người dùng bấm nhiều lần, ví dụ lược bỏ để ngắn gọn, khi ra mắt hãy bổ sung.

Sau khi chạy, mở trình duyệt truy cập cổng 8000, chọn xác nhận độ tuổi, gửi một câu thử. Nếu văn bản xuất hiện cả đoạn thay vì từng chữ, khả năng cao có proxy đang đệm phản hồi, hãy thử kết nối trực tiếp cổng máy chủ để loại trừ; khi triển khai sau Nginx, cần tắt đệm phản hồi cho đường dẫn đó.

Lịch sử hội thoại: Tại sao phải lưu ở backend của bạn

API hội thoại là “không trạng thái”, giống như nhân viên tiếp tân chỉ biết những gì bạn đưa cho anh ta: anh ta chỉ biết những gì trên giấy. Vì vậy, “nhớ ngữ cảnh” là trách nhiệm của bạn, với ba mức độ cụ thể:

  1. Cơ bản nhất:Chỉ lưu trữ từ điển trong bộ nhớ. Dữ liệu sẽ mất khi khởi động lại, phù hợp cho việc gỡ lỗi.
  2. Thường dùng:Lưu SQLite như ví dụ, mỗi tin nhắn một dòng, tra theo session_id để lấy N dòng gần nhất.
  3. Nâng cao:Khi lịch sử quá dài, hãy để mô hình tóm tắt các nội dung cũ hơn thành một đoạn tóm tắt, đặt sau system, các tin nhắn gần đây vẫn giữ nguyên văn bản.

Một lợi ích khác của việc lưu trữ ở backend của bạn là bạn hoàn toàn kiểm soát thời gian lưu giữ. Bạn nên cung cấp nút "Xóa lịch sử trò chuyện" để thực sự xóa các bản ghi của session tương ứng, và ghi rõ trong chính sách bảo mật của bạn những gì bạn đã lưu trữ.

Điều kiện kích hoạt phương pháp tóm tắt có thể rất đơn giản: khi ước lượng token lịch sử vượt quá 10.000, hãy giao nửa tin nhắn đầu tiên cho mô hình tóm tắt thành một đoạn dưới 300 chữ, ghi lại bộ nhớ và xóa tin nhắn gốc. Cách này vừa giữ được mạch hội thoại, vừa giữ thể lượng của mỗi yêu cầu ổn định trong tầm kiểm soát. Lưu ý: bản tóm tắt do mô hình tạo ra, có thể bỏ sót chi tiết; các thiết lập quan trọng (như biệt danh người dùng, chủ đề cấm) nên cố định trong system thay vì dựa vào tóm tắt.

Sản phẩm dành cho người trưởng thành: Điểm vào và Ranh giới

Dịch vụ này chỉ dành cho người dùng từ 18 tuổi trở lên, ứng dụng của bạn cũng nên như vậy. Ví dụ frontend đặt một điểm vào xác nhận đơn giản nhất; các sản phẩm nghiêm ngặt hơn có thể thêm quy trình xác minh độ tuổi đầy đủ hơn. Một số lời khuyên thực tiễn:

  • Trang điểm vào thông báo rõ ràng về giới hạn độ tuổi, không hiển thị giao diện trò chuyện nếu chưa xác nhận.
  • Không quảng bá sản phẩm cho nhóm học sinh hoặc người chưa thành niên, cũng không đưa vào các ngữ cảnh dành cho họ.
  • Nội dung liên quan đến tình dục của người chưa thành niên sẽ bị API chặn và trả về 403 bất kể có phải hư cấu hay không; backend của bạn cần nhận diện trạng thái này và đưa ra một thông báo thân thiện cho người dùng, thay vì hiển thị lỗi gốc.

Từ góc độ thiết kế sản phẩm, bạn cũng có thể cho phép người dùng tự đặt biệt danh và sở thích về giọng điệu trong phần cài đặt; những thông tin này chỉ cần ghi vào system, vừa giúp cuộc trò chuyện cá nhân hóa hơn, vừa không cần mô hình phải đoán.

Gia cố và mở rộng trước khi ra mắt

  • Giới hạn đầu vào:Ví dụ đã cắt ngắn tin nhắn đơn lẻ xuống 4.000 ký tự, bạn có thể điều chỉnh theo nhu cầu.
  • Giới hạn tần suất:Mỗi key được giới hạn 300 yêu cầu mỗi phút; bạn nên thực hiện giới hạn tốc độ ở backend theo người dùng hoặc IP.
  • Hiển thị lỗi:Backend bắt ngoại lệ và trả về thông báo ngắn gọn cho frontend, không để lộ stack trace.
  • Theo dõi lượng dùng:Để thống kê token, bạn có thể đọc usage trong yêu cầu không stream, hoặc đọc ở khối cuối của stream, chi tiết xemToken và tính phí.
  • Thay đổi framework:Khi chuyển frontend sang Vue hoặc React, logic đọc stream hoàn toàn giống nhau. Khi chuyển backend sang Express, chỉ cần thay StreamingResponse bằng cách viết stream tương ứng.

Khi cần giải thích tham số đầy đủ hơn, xemGiải thích chi tiết tham số giao diện; thêm câu hỏi xemCâu hỏi thường gặp.

Cuối cùng, hãy tự kiểm tra trước khi ra mắt: khóa API chỉ nằm trong biến môi trường của server; độ dài đầu vào đơn lẻ và lịch sử có giới hạn trên không; lỗi có thông báo thân thiện không; nút xóa lịch sử có thực sự xóa bản ghi không; xác nhận độ tuổi có ở điểm vào không. Nếu đáp ứng đủ năm điều này, nguyên mẫu này có thể được đưa cho người dùng thực sự để dùng thử.

Câu hỏi thường gặp

Tại sao không cho trình duyệt gọi trực tiếp API?

Vì khóa API sẽ bị lộ trên trang web, bất kỳ ai cũng có thể sao chép và sử dụng trái phép. Cho trình duyệt chỉ truy cập vào backend của bạn, do backend giữ khóa API, là cách an toàn hơn.

Lịch sử trò chuyện nên lưu bao nhiêu tin nhắn?

Tùy thuộc vào ngữ cảnh, ví dụ lấy 20 tin nhắn gần nhất. Khi cần kiểm soát chi phí và độ dài ngữ cảnh, thà mang ít tin nhắn hơn một chút, sau đó kết hợp với tóm tắt để giữ lại các điểm chính.

Phải làm gì nếu stream bị lỗi giữa chừng?

Backend bắt ngoại lệ và trả về một thông báo cho frontend, đồng thời lưu lại phần đã tạo ra. Frontend có thể cung cấp nút thử lại, gửi lại tin nhắn người dùng trước đó.

Chatbot này có thể dành cho người chưa thành niên không?

Không. Dịch vụ chỉ dành cho người từ 18 tuổi trở lên, ứng dụng của bạn cũng cần xác nhận độ tuổi ở điểm vào.

Chỉ cần điền biểu mẫu để lấy khóa API

Tạo tài khoản, sao chép khóa API, sửa đổi Base URL. Cấu hình rất đơn giản như vậy.

Lấy khóa API