RU ▾
Получить API-ключ

Создание чат-бота на базе API LLM: FastAPI-бэкенд и потоковый фронтенд

Чтобы создать чат-бота с посимвольным выводом, достаточно трёх компонентов: бэкенда для пересылки запросов, веб-страницы для чтения потока и хранилища для сохранения истории диалога. В этой статье бэкенд написан на FastAPI, потоковый вывод читается через нативный fetch в браузере, а история диалогов сохраняется в вашу базу SQLite. Весь код занимает менее ста строк — это прототип продукта, подходящий для взрослых пользователей.

Обновлено:

Ключевые моменты

  • API-ключ только в бэкенде, браузер его не видит. Фронтенд общается только с вашим интерфейсом /chat.
  • Модель не запоминает за вас: каждый запрос требует загрузки последних сообщений из вашего хранилища и отправки их в интерфейс.
  • Ключ потоковой передачи: бэкенд использует StreamingResponse, фронтенд использует цикл reader.read() для конкатенации.
  • Продукт для взрослых пользователей: при входе требуется подтверждение возраста, а также ограничиваются длина истории и максимальная длина одного ввода.

Схема: три роли и их задачи

Представьте чат-бота как доставку еды: браузер — клиент, ваш бэкенд — ресепшн, интерфейс большой модели — кухня. Клиент не заходит на кухню, все заказы передаются через ресепшн. Поэтому ключ нельзя хранить на фронтенде.

РольОтветственностьНе должна
БраузерОтображение, ввод, чтение потокаХранить API-ключ
Бэкенд FastAPIХранение истории, сбор сообщений, пересылка запросовВозвращать ключ на фронтенд
API моделиГенерация ответа по сообщениям— обрабатывает только то, что вы отправите

Поток: браузер отправляет текст → бэкенд сохраняет → берёт историю → добавляет system → запрос API → потоковая отдача → сохранение ответа.

Почему бэкенд проксирует, а не фронтенд подключается напрямую? Помимо безопасности ключа, есть две причины: 1) нужно добавлять историю, лимиты и фильтрацию — это делает бэкенд. 2) при смене модели или сервиса меняется только бэкенд, фронтенд остаётся без изменений.

Бэкенд: FastAPI и SQLite

Установите зависимости и запустите:

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

Затем создайте server.py. Обратите внимание на четыре момента:

# 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() берёт последние 20 сообщений, чтобы не превысить контекстное окно в 100 000 токенов.
  2. gen() — асинхронный генератор, отправляющий данные частями для посимвольного вывода.
  3. В конце потока идёт блок с данными об использовании (без choices), поэтому нужно проверять его на пустоту и пропускать.
  4. В блоке finally сохраняется полный ответ, чтобы данные не потерялись при ошибке.

Если вы не хотите пока работать с базой данных, просто замените load и save на чтение и запись словаря — остальной код менять не нужно. И наоборот: при росте нагрузки SQLite можно заменить на любую знакомую вам базу данных, сохранив интерфейс этих двух функций. Ещё один момент: в примере используется синхронный sqlite3, который работает быстро и подходит для прототипа; для сценариев с высокими параллельными запросами рассмотрите асинхронные драйверы.

Фронтенд: чтение потока через fetch

В браузере не нужны дополнительные библиотеки. resp.body.getReader() возвращает объект чтения (reader). Циклически вызывайте read(), каждый раз получая блок байтов, декодируйте его с помощью TextDecoder и добавляйте на страницу. Сохраните приведённый ниже код в файл index.html в той же директории, что и 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>

Два важных момента: при декодировании добавьте { stream: true }, так как байты одного иероглифа могут оказаться в разных блоках; без этого параметра появится искажённый текст. Кроме того, логика отправки сообщения должна блокировать кнопку во время выполнения запроса, чтобы избежать повторных нажатий. В примере это опущено ради краткости, но перед запуском обязательно добавьте эту проверку.

После запуска откройте в браузере адрес на порту 8000, подтвердите возраст и отправьте сообщение. Если текст появляется сразу целым абзацем, а не посимвольно, скорее всего, где-то на пути стоит прокси-сервер, буферизирующий ответ. Попробуйте подключиться напрямую к локальному порту для диагностики. При развёртывании за Nginx необходимо отключить буферизацию ответов для данного пути.

История диалога: зачем хранить её у себя

Интерфейс чата «без состояния», как администратор, который видит только то, что ему передали. Поэтому «запоминание контекста» — ваша задача. Есть три уровня реализации:

  1. Простой:храните словарь только в памяти. Данные теряются при перезапуске, что подходит для отладки.
  2. Распространённый:сохраняйте данные в SQLite, как в примере: по одной строке на сообщение, запрос последних N сообщений по session_id.
  3. Продвинутый:при слишком длинной истории передавайте ранние сообщения модели для создания краткого содержания. Размещайте его после system, а последние сообщения сохраняйте в исходном виде.

Ещё одно преимущество хранения на вашем бэкенде — полный контроль над сроком хранения данных. Рекомендуется добавить кнопку «Очистить чат» для удаления записей соответствующего session и чётко указать в политике конфиденциальности, какие данные вы сохраняете.

Триггер для метода суммирования может быть простым: если количество токенов в истории превышает 10 000, передайте первые полтора сообщения модели для суммирования в один абзац длиной не более 300 символов, сохраните результат в хранилище и удалите исходные сообщения. Это сохраняет контекст и стабилизирует объём каждого запроса. Учтите, что само суммирование генерируется моделью и может упускать детали. Важные настройки (например, имя пользователя или запретные темы) лучше зафиксировать в system-промпте, а не полагаться на сохранение через суммирование.

Продукты для взрослых: вход и границы

Этот сервис предназначен только для пользователей старше 18 лет, и ваше приложение должно соответствовать этому. Во фронтенде примера есть самый простой элемент подтверждения; для более строгих продуктов можно добавить полноценный процесс проверки возраста. Несколько практических рекомендаций:

  • На странице входа явно укажите ограничение по возрасту; если подтверждение не пройдено, интерфейс чата не отображается.
  • Не рекламируйте продукт студентам или несовершеннолетним и не размещайте его в сценариях, ориентированных на них.
  • Контент сексуального характера с участием несовершеннолетних будет блокироваться API с возвратом статуса 403, независимо от того, вымышлен он или нет. Ваш бэкенд должен распознавать этот статус и выводить пользователю дружелюбное сообщение, а не показывать исходную ошибку.

С точки зрения дизайна продукта, также можно позволить пользователям задавать имя и предпочтения по тону в настройках. Эти данные достаточно добавить в system, чтобы сделать диалог более персонализированным и не заставлять модель угадывать.

Укрепление и расширение перед запуском

  • Ограничение ввода:в примере длина одного сообщения ограничена 4000 символами; настраивайте по мере необходимости.
  • Ограничение частоты:300 запросов в минуту на ключ; реализуйте ограничение на бэкенде по пользователю или IP.
  • Отображение ошибок:бэкенд перехватывает исключения и возвращает фронтенду краткое сообщение, не раскрывая стек вызовов.
  • Мониторинг использования:для подсчёта токенов читайте usage в не-stream запросах или в последнем блоке stream. Подробности см. в разделе Токены и тарификация.
  • Смена фреймворка:при переходе фронтенда на Vue или React логика чтения stream остаётся прежней. При смене бэкенда на Express достаточно заменить StreamingResponse на соответствующий способ потоковой передачи.

Для получения более полного описания параметров см. Подробности параметров API; дополнительные вопросы — в разделе Часто задаваемые вопросы.

Перед запуском выполните финальную проверку: ключ хранится только в переменных окружения бэкенда; есть ли ограничения на длину ввода и истории; отображаются ли дружелюбные сообщения об ошибках; удаляет ли кнопка очистки чата записи; проходит ли подтверждение возраста на странице входа. Если все пять условий выполнены, прототип можно передавать реальным пользователям для тестирования.

Часто задаваемые вопросы

Почему браузер не должен напрямую обращаться к API?

Потому что ключ окажется в открытом доступе на веб-странице, и любой сможет его скопировать и использовать. Безопаснее, если браузер обращается только к вашему бэкенду, который хранит ключ.

Сколько сообщений истории нужно хранить?

Зависит от сценария; в примере берутся последние 20 сообщений. Для контроля расходов и длины контекста лучше взять меньше сообщений и использовать краткое содержание для сохранения ключевых моментов.

Что делать, если stream прервался?

Бэкенд перехватывает исключение, выводит пользователю сообщение и сохраняет уже сгенерированную часть. Фронтенд может предложить кнопку повтора для отправки предыдущего сообщения пользователя.

Можно ли использовать этого бота для несовершеннолетних?

Нет. Сервис доступен только для пользователей старше 18 лет, и ваше приложение также должно проверять возраст при входе.

Заполните форму, чтобы получить ключ

Создайте аккаунт, скопируйте ключ и измените Base URL. Настройка проста.

Получить API-ключ