Imate težave z umetno inteligenco ali razvojem celotnega paketa? Naši strokovnjaki so vam na voljo za pomoč: prilagojeni nasveti, tehnična integracija in še več. Obrnite se na [email protected].

Kako razviti pretočni uporabniški vmesnik z žetoni za program LLM z Go, FastAPI in JS

Generativni modeli včasih potrebujejo nekaj časa, da dobijo rezultat, zato je zanimivo uporabiti pretakanje žetonov, da se rezultat sproti prikaže v uporabniškem vmesniku. Tukaj je opisano, kako lahko z Go, FastAPI in Javascriptom ustvarite tak frontend za pretakanje besedil za svoj LLM.

Razvijalec na računalniku PC

Kaj je pretočno pretakanje žetonov?

Naj spomnimo, da je žeton edinstvena enota, ki je lahko majhna beseda, del besede ali ločilo. V povprečju je 1 žeton sestavljen iz 4 znakov, 100 žetonov pa je približno enako 75 besedam. Modeli obdelave naravnega jezika morajo za obdelavo besedila le-tega pretvoriti v žetone.

Pri uporabi modela umetne inteligence za generiranje besedila (znanega tudi kot "generativni" model) je lahko odzivni čas precej dolg, kar je odvisno od strojne opreme in velikosti modela. Na primer, v primeru velikega jezikovnega modela (alsko znanega kot "LLM"), kot je LLaMA 30B, nameščenega na grafičnem procesorju NVIDIA A100 v fp16, model ustvari 100 žetonov v približno 3 sekundah. Če torej pričakujete, da bo vaš generativni model ustvaril velik del besedila s stotinami ali tisoči besed, bo zakasnitev velika in boste morali počakati morda več kot 10 sekund, da boste dobili celoten odgovor.

Dolgotrajno čakanje na odgovor je lahko težava z vidika uporabniške izkušnje. Rešitev v tem primeru je pretakanje žetonov!

Token streaming pomeni sprotno generiranje vsakega novega žetona, namesto da bi čakali, da je celoten odgovor pripravljen. To je tisto, kar na primer v aplikaciji ChatGPT ali pomočniku NLP Cloud ChatDolphin. Besede se prikažejo takoj, ko jih ustvari model. Pomočnika z umetno inteligenco ChatDolphin preizkusite tukaj.

Tokensko pretakanje z ChatDolphinom v oblaku NLP Cloud Tokensko pretakanje s pomočnikom ChatDolphin v oblaku NLP Cloud. Poskusite tukaj.

Izbira mehanizma za sklepanje, ki podpira token streaming

Najprej morate uporabiti mehanizem za sklepanje, ki podpira pretakanje žetonov.

Tukaj je nekaj možnosti, o katerih boste morda želeli razmisliti:

Tukaj je primer z metodo HuggingFace generate():

from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer
from threading import Thread

tokenizer = AutoTokenizer.from_pretrained("gpt2")
model = AutoModelForCausalLM.from_pretrained("gpt2")
inputs = tokenizer(["An increasing sequence: one,"], return_tensors="pt")
streamer = TextIteratorStreamer(tokenizer)

# Run the generation in a separate thread, so that we can fetch the generated text in a non-blocking way.
generation_kwargs = dict(inputs, streamer=streamer, max_new_tokens=20)
thread = Thread(target=model.generate, kwargs=generation_kwargs)
thread.start()
for new_text in streamer:
    print(new_text)

V tem primeru generiramo izpis z modelom GPT-2 in v konzoli izpišemo vsak žeton takoj, ko prispe.

Streaming odziva s FastAPI

Zdaj, ko ste izbrali inferenčni mehanizem, boste morali postreči s svojim modelom in vrniti pretočne žetone.

Vaš model se bo najverjetneje izvajal v okolju Python, zato boste za vračanje žetonov potrebovali strežnik Python. in jih dati na voljo prek vmesnika HTTP API. FastAPI je postal dejanska izbira za takšne primere.

Pri tem uporabljamo Uvicorn in StreamingResponse vmesnika FastAPI, da postrežemo vsak žeton takoj, ko je ustvarjen. Tukaj je primer:

from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer
from threading import Thread

model = AutoModelForCausalLM.from_pretrained("gpt2")
tokenizer = AutoTokenizer.from_pretrained("gpt2")

app = FastAPI()

async def generate():
    inputs = tokenizer(["An increasing sequence: one,"], return_tensors="pt")
    streamer = TextIteratorStreamer(tokenizer)
    generation_kwargs = dict(inputs, streamer=streamer, max_new_tokens=20)
    thread = Thread(target=model.generate, kwargs=generation_kwargs)
    thread.start()
    for new_text in streamer:
        yield new_text

@app.get("/")
async def main():
    return StreamingResponse(generate())

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000)

Streaming strežnik lahko preizkusite z naslednjim ukazom cURL:

curl -N localhost:8000

Zdaj imamo delujoč model umetne inteligence, ki pravilno vrača pretočne žetone.

Te pretočne žetone lahko neposredno preberemo iz odjemalske aplikacije v brskalniku. Vendar tega ne bomo storili iz dveh razlogov.

Prvič, pomembno je ločiti model umetne inteligence od preostalega sklada, saj ne želimo ponovno zagnati modela vsakič, ko bomo izvedli majhno spremembo API. Ne pozabite, da so sodobni generativni modeli umetne inteligence zelo težki in pogosto traja več minut, da se ponovno zaženejo.

Drugi razlog je, da Python ni nujno najboljša izbira ko gre za izgradnjo visoko prepustne sočasne aplikacije, kot jo bomo naredili mi. Ta izbira seveda lahko razpravljamo in je lahko tudi stvar okusa!

Posredovanje žetonov prek Go Gatewaya

Kot smo že omenili, je pomembno, da med modelom in končno stranko dodamo prehod, in Go je dober programski jezik za takšno aplikacijo. V produkciji boste morda želeli dodati tudi povratno med prehodom Go in končnim odjemalcem ter izravnalnik obremenitve med prehodom Go in modelom umetne inteligence, da razporedite obremenitev na več replik modela. Vendar to ne sodi v okvir našega članka!

Naša aplikacija Go bo zadolžena tudi za upodabljanje končne strani HTML.

Ta aplikacija pošlje zahtevo aplikaciji FastAPI, prejme pretočne žetone od FastAPI in posreduje vsakega žeton z uporabo strežniško poslanih dogodkov (SSE). SSE je preprostejši od spletnih vtičnic, ker je enosmeren. Uporablja je dobra izbira, kadar želite zgraditi aplikacijo, ki odjemalcu pošilja informacije, ne da bi poslušala potencialne odziva odjemalca.

Tukaj je koda Go (predlogo HTML/JS/CSS bomo prikazali v naslednjem razdelku):

package main

import (
    "bufio"
    "fmt"
    "html/template"
    "io"
    "log"
    "net/http"
    "strings"
)

var (
    templates      *template.Template
    streamedTextCh chan string
)

func init() {
    // Parse all templates in the templates folder.
    templates = template.Must(template.ParseGlob("templates/*.html"))

    streamedTextCh = make(chan string)
}

// generateText calls FastAPI and returns every token received on the fly through
// a dedicated channel (streamedTextCh).
// If the EOF character is received from FastAPI, it means that text generation is over.
func generateText(streamedTextCh chan<- string) {
    var buf io.Reader = nil

    req, err := http.NewRequest("GET", "http://127.0.0.1:8000", buf)
    if err != nil {
        log.Fatal(err)
    }

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        log.Fatal(err)
    }
    defer resp.Body.Close()

    reader := bufio.NewReader(resp.Body)

outerloop:
    for {
        chunk, err := reader.ReadBytes('\x00')
        if err != nil {
            if err == io.EOF {
                break outerloop
            }
            log.Println(err)
            break outerloop
        }

        output := string(chunk)

        streamedTextCh <- output
    }
}

// formatServerSentEvent creates a proper SSE compatible body.
// Server sent events need to follow a specific formatting that
// uses "event:" and "data:" prefixes.
func formatServerSentEvent(event, data string) (string, error) {
    sb := strings.Builder{}

    _, err := sb.WriteString(fmt.Sprintf("event: %s\n", event))
    if err != nil {
        return "", err
    }
    _, err = sb.WriteString(fmt.Sprintf("data: %v\n\n", data))
    if err != nil {
        return "", err
    }

    return sb.String(), nil
}

// generate is an infinite loop that waits for new tokens received 
// from the streamedTextCh. Once a new token is received,
// it is automatically pushed to the frontend as a server sent event. 
func generate(w http.ResponseWriter, r *http.Request) {
    flusher, ok := w.(http.Flusher)
    if !ok {
        http.Error(w, "SSE not supported", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "text/event-stream")

    for text := range streamedTextCh {
        event, err := formatServerSentEvent("streamed-text", text)
        if err != nil {
            http.Error(w, "Cannot format SSE message", http.StatusInternalServerError)
            return
        }

        _, err = fmt.Fprint(w, event)
        if err != nil {
            http.Error(w, "Cannot format SSE message", http.StatusInternalServerError)
            return
        }

        flusher.Flush()
    }
}

// start starts an asynchronous request to the AI engine.
func start(w http.ResponseWriter, r *http.Request) {
    go generateText(streamedTextCh)
}

func home(w http.ResponseWriter, r *http.Request) {
    if err := templates.ExecuteTemplate(w, "home.html", nil); err != nil {
        log.Println(err.Error())
        http.Error(w, "", http.StatusInternalServerError)
        return
    }
}

func main() {
    http.HandleFunc("/generate", generate)
    http.HandleFunc("/start", start).Methods("POST")
    http.HandleFunc("/", home).Methods("GET")

    log.Fatal(http.ListenAndServe(":8000", r))
}                

Naša stran "/home" prikaže stran HTML/CSS/JS (prikazano kasneje). Stran "/start" prejme zahtevo POST od JS, ki sproži zahtevo za naš model umetne inteligence. Naša stran "/generate" pa vrne rezultat aplikaciji JS prek dogodkov, poslanih s strežnika.

Ko funkcija start() prejme zahtevo POST iz sprednjega dela, samodejno ustvari goroutine, ki izvede zahtevo v našo aplikacijo FastAPI.

Funkcija generateText() pokliče FastAPI in vrne vsak žeton, ki ga prejme sproti prek posebnega kanala (streamedTextCh). Če je od vmesnika FastAPI prejet znak EOF, to pomeni, da je generiranje besedila končano.

Funkcija generate() je neskončna zanka, ki čaka na nove žetone, prejete iz kanala streamedTextCh. Ko je prejet nov žeton, se samodejno prenese na sprednji del kot dogodek, poslan s strežnika. Dogodki, poslani s strežnika, morajo upoštevati posebno oblikovanje, ki uporablja "event:" in "data:". predponi, zato je tu funkcija formatServerSentEvent().

Da bi bil SSE popoln, potrebujemo odjemalca Javascript, ki lahko posluša dogodke, poslane s strežnika, tako da se naroči na stran "generate". V naslednjem razdelku si oglejte, kako to doseči.

Sprejemanje žetonov z Javascriptom v brskalniku

Zdaj morate ustvariti imenik "templates" in vanj dodati datoteko "home.html".

Tukaj je vsebina "home.html":

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Our Streamed Tokens App</title>
</head>
<body>
    <div id="response-section"></div>    
    <form method="POST">
        <button onclick="start()">Start</button>
    </form>
</body>
<script>
    // Disable the default behavior of the HTML form.
    document.querySelector('form').addEventListener('submit', function(e) {
        e.preventDefault()
    })

    // Make a request to the /start to trigger the request to the AI model.
    async function start() {
        try {
            const response = await fetch("/start", {
            method: "POST",
            })
        } catch (error) {
            console.error("Error when starting process:", error)
        }
    }

    // Listen to SSE by subscribing to the /generate page, and
    // put the result in the #response-section div.
    const evtSource = new EventSource("generate")
    evtSource.addEventListener("streamed-text", (event) => {
        document.getElementById('response-section').innerHTML = event.data
    })
</script>
</html>

Kot lahko vidite, je poslušanje SSE v brskalniku precej preprosto.

Najprej se morate naročiti na našo končno točko SSE (stran "/generate"). nato morate dodati poslušalca dogodkov, ki bo prebral pretočne žetone takoj, ko ko jih prejmemo.

Sodobni brskalniki samodejno poskušajo ponovno vzpostaviti povezavo vir dogodkov v primeru težav s povezavo.

Zaključek

Zdaj veste, kako ustvariti sodobno aplikacijo generativne umetne inteligence, ki dinamično v brskalniku pretaka besedilo, à la ChatGPT!

Kot ste opazili, takšna aplikacija ni nujno preprosta, saj je več je vključenih več plasti. Zgornja koda je seveda poenostavljena zaradi primera.

Glavni izziv pri pretakanju žetonov je obvladovanje napak v omrežju. Večina teh napak se zgodi med zalednim delom Go in prednjim delom Javascript. Pri tem boste boste morali raziskati nekaj naprednejših strategij ponovne povezave in poskrbeti, da bodo napake pravilno poročale uporabniškemu vmesniku.

Upam, da se vam je ta vadnica zdela koristna!

Vincent
Zagovornik razvijalcev v NLP Cloud