Kurs LLM Basics, zdjęcie laptopa.

Modele językowe (LLM Basics) – Lab 1: Hello LLM

Twój pierwszy krok do własnej aplikacji AI

Witam w pierwszej części praktycznego kursu budowania aplikacji opartych o LLM (Large Language Models).

Zanim zbudujemy zaawansowanego agenta RAG, który będzie przeszukiwał bazy wiedzy i wywoływał zewnętrzne narzędzia (co jest celem naszego projektu zaliczeniowego), musimy zrozumieć fundamenty. Dzisiaj nauczymy się, jak „rozmawiać” z modelem – nie przez czat w przeglądarce, ale przez kod.

🎯 Cel na dziś
To laboratorium to fundament pod Twój przyszły projekt. Twoim zadaniem jest:

  • Uruchomić model językowy na dwa sposoby: przez zewnętrzne API oraz lokalnie na własnym sprzęcie.
  • Zrozumieć „pokrętła” sterujące (temperature, top_p).
    Nauczyć się liczyć tokeny, szacować koszty i odczytywać takie informacje od dostawców API (kluczowe dla budżetu projektu).
  • Wymusić bardziej deterministyczne odpowiedzi.
  • Zbudować prosty logger, który będzie zapisywał wyniki Twoich eksperymentów.

💡Dlaczego to ważne dla projektu? W wymaganiach kluczowych projektu zaliczeniowego punktowane są logi, metryki dla lokalnych oraz zewnętrznych modeli. To co napiszesz dzisiaj będzie bazą dla Twojej aplikacji końcowej.


Środowisko i konfiguracja

Zanim zaczniemy, przygotujmy warsztat. Będziemy używać Pythona 3.9+.

Instalacja zależności
Otwórz terminal i zainstaluj niezbędne biblioteki. Będziemy potrzebować klientów API (OpenAI / Groq, Google) oraz biblioteki do modeli lokalnych (Hugging Face). Na potrzeby tego kursu skorzystamy z wirtualnego środowiska w Pythonie.

# Tworzenie środowiska wirtualnego
python -m venv .venv
# Aktywacja (Windows): .\.venv\Scripts\Activate.ps1
# Aktywacja (Mac/Linux): source .venv/bin/activate

# Instalacja paczek
pip install -U python-dotenv openai google-genai httpx tiktoken transformers accelerate torch

🔐 Bezpieczeństwo: plik .env
Nigdy nie wpisuj kluczy API bezpośrednio w kodzie! Użyjemy do tego pliku .env.
1. Utwórz plik o nazwie .env w folderze projektu.
2. Wpisz tam swoje klucze (np. do Groq, OpenAI lub Google Gemini).

UWAGA: Jeżeli używasz systemu kontroli wersji do przechowywania swojego projektu, to należy wykluczyć powyższy plik z repozytorium, dodając odpowiedni wpis do pliku .gitignore. Wyciek kluczy to najczęstszy błąd początkującego inżyniera AI.

# Przykładowy plik .env

# Google Gemini (Google GenAI SDK)
GOOGLE_API_KEY="AIza..."
GEMINI_MODEL_NAME="gemini-2.0-flash"

# Groq (OpenAI-compatible)
GROQ_API_KEY="gsk_..."
GROQ_BASE_URL="https://api.groq.com/openai/v1"
GROQ_MODEL_NAME="openai/gpt-oss-20b"

# Lokalny model (HF Transformers)
LOCAL_MODEL_NAME="Qwen/Qwen2.5-0.5B-Instruct"

Hostowane API: Szybki start

Większość nowoczesnych aplikacji LLM korzysta z modeli hostowanych w chmurze. Są szybkie i potężne. W kursie skupimy się na dwóch standardach: OpenAI-compatible (np. Groq, OpenAI) oraz Google GenAI. Dla tego pierwszego używamy klienta openai i ustawiamy base_url. Google przeniosło się na Google GenAI SDK (google-genai), stare google-generativeai jest legacy i ma EOL 30 listopada 2025.

Dodatkowo: w ekosystemie OpenAI (i kompatybilnych) nowe projekty kieruje się na Responses API zamiast Chat Completions.

Groq przez OpenAI-compatible (Responses API)
Groq jest świetny do nauki, bo jest niesamowicie szybki i oferuje darmowy tier. Zauważ, że używamy parametru temperature=0, aby uzyskać jak najbardziej przewidywalne odpowiedzi.

import os, time
from dotenv import load_dotenv
from openai import OpenAI

load_dotenv()

BASE_URL = os.getenv("GROQ_BASE_URL", "https://api.groq.com/openai/v1")
MODEL_NAME = os.getenv("GROQ_MODEL_NAME", "openai/gpt-oss-20b")
GROQ_API_KEY = os.getenv("GROQ_API_KEY", None)

SYSTEM_PROMPT = "Odpowiadaj po polsku i zwięźle."
USER_PROMPT = "Podaj 3 krótkie pomysły na aktywność fizyczną w domu."

client = OpenAI(
    api_key=GROQ_API_KEY,
    base_url=BASE_URL,
)

def groq_generate(
    prompt: str,
    system: str = "You are a helpful assistant.",
    temperature: float = 0.0,
    top_p: float = 1.0,
    max_output_tokens: int = 256,
) -> dict:
    t0 = time.perf_counter()
    resp = client.responses.create(
        model=MODEL_NAME,
        instructions=system,
        input=prompt,
        temperature=temperature,
        top_p=top_p,
        max_output_tokens=max_output_tokens,
    )
    dt = time.perf_counter() - t0

    usage = getattr(resp, "usage", None)
    usage_dict = None if usage is None else getattr(usage, "model_dump", lambda: usage)()

    return {
        "text": resp.output_text,
        "latency_s": round(dt, 3),
        "usage": usage_dict,
    }

out = groq_generate(
    USER_PROMPT,
    system=SYSTEM_PROMPT,
    temperature=0.0,   # bardziej deterministycznie
    top_p=1.0,
    max_output_tokens=120,
)
print(out["text"])
print(out["latency_s"], "s")
print(out["usage"])

Co zapamiętać:
temperature=0.0 + top_p=1 to najprostsza droga do „prawie deterministycznie”. Nadal mogą zdarzać się różnice (backend, load balancing, itp.).
max_output_tokens to twardy limit długości odpowiedzi w Responses API.

Na koniec (dobry nawyk przy dłuższych sesjach):
client.close()

Groq przez Chat Completions (czasem nadal wygodne)
Chat Completions nadal działa, ale w nowych projektach warto trzymać się Responses.

import os, time
from openai import OpenAI
from dotenv import load_dotenv

load_dotenv()

BASE_URL = os.getenv("GROQ_BASE_URL", "https://api.groq.com/openai/v1")
MODEL_NAME = os.getenv("GROQ_MODEL_NAME", "openai/gpt-oss-20b")
GROQ_API_KEY = os.getenv("GROQ_API_KEY", None)

SYSTEM_PROMPT = "Odpowiadaj po polsku i zwięźle."
USER_PROMPT = "Podaj 3 krótkie pomysły na aktywność fizyczną w domu."

client = OpenAI(
    api_key=GROQ_API_KEY,
    base_url=BASE_URL,
)

def groq_chat(
    prompt: str,
    system: str = "You are a helpful assistant.",
    temperature: float = 0.0,
    top_p: float = 1.0,
    max_output_tokens: int = 256,
) -> dict:
    t0 = time.perf_counter()
    r = client.chat.completions.create(
        model=MODEL_NAME,
        messages=[
            {"role": "system", "content": system},
            {"role": "user", "content": prompt},
        ],
        temperature=temperature,
        top_p=top_p,
        max_tokens=max_output_tokens,
    )
    dt = time.perf_counter() - t0
    choice = r.choices[0]
    usage = getattr(r, "usage", None)
    usage_dict = None if usage is None else usage.model_dump()
    return {"text": choice.message.content, "latency_s": round(dt, 3), "usage": usage_dict}

out = groq_chat(
    USER_PROMPT,
    system=SYSTEM_PROMPT,
    temperature=0.0,   # bardziej deterministycznie
    top_p=1.0,
    max_output_tokens=120,
)
print(out["text"])
print(out["latency_s"], "s")
print(out["usage"])

Gemini przez Google GenAI SDK (google-genai)
Google niedawno zaktualizowało swoje biblioteki. Google rekomenduje migrację na Google GenAI SDK (pip install google-genai). Stare google-generativeai jest legacy i ma EOL 30.11.2025.

import os, time
from dotenv import load_dotenv
from google import genai
from google.genai import types

load_dotenv()

GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.0-flash")
GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY", None)

SYSTEM_PROMPT = "Odpowiadaj po polsku i zwięźle."
USER_PROMPT = "Podaj 3 krótkie pomysły na aktywność fizyczną w domu."

gclient = genai.Client(
    api_key=GOOGLE_API_KEY
)

def gemini_generate(
    prompt: str,
    system: str = "You are a helpful assistant.",
    temperature: float = 0.0,
    top_p: float = 1.0,
    top_k: int = 40,
    max_output_tokens: int = 256,
) -> dict:
    t0 = time.perf_counter()
    resp = gclient.models.generate_content(
        model=GEMINI_MODEL,
        contents=prompt,
        config=types.GenerateContentConfig(
            system_instruction=system,
            temperature=temperature,
            top_p=top_p,
            top_k=top_k,
            max_output_tokens=max_output_tokens,
        ),
    )
    dt = time.perf_counter() - t0

    usage = getattr(resp, "usage_metadata", None)
    usage_dict = {
        "prompt_tokens": getattr(usage, "prompt_token_count", None),
        "completion_tokens": getattr(usage, "candidates_token_count", None),
        "total_tokens": getattr(usage, "total_token_count", None),
    } if usage is not None else None

    text = getattr(resp, "text", None)
    return {"text": text if text is not None else str(resp), "latency_s": round(dt, 3), "usage": usage_dict}

out = gemini_generate(
    USER_PROMPT,
    system=SYSTEM_PROMPT,
    temperature=0.0,
    top_p=1.0,
    max_output_tokens=120,
)
print(out["text"])
print(out["latency_s"], "s")
print(out["usage"])

Tokeny i prosty koszt (praktycznie)

Gdy API zwraca usage, to sytuacja jest dość prosta. Należy zaufać temu co zwraca dostawca (promp/completion/total) i skorzystać z informacji przekazanych w usage, to będzie najbliżej prawdy.

Jeżeli dostawca nie zwraca takiego pola, liczymy przybliżeniem. tiktoken potrafi policzyć tokeny dla wielu modelu „w stylu OpenAI”, ale dla różnych nazw modeli bywa różnie – wtedy robimy prosty fallback.

import tiktoken
from typing import List

def approx_tokens(texts: List[str], model_name: str = "gpt-4o-mini") -> int:
    try:
        enc = tiktoken.encoding_for_model(model_name)
        return sum(len(enc.encode(t)) for t in texts)
    except Exception:
        # prosta heurystyka (z grubsza): 1 token ~ 4 znaki (ang), PL bywa inaczej
        return sum(max(1, len(t)//4) for t in texts)

def estimate_cost_usd(prompt_tokens: int, completion_tokens: int, price_in: float, price_out: float) -> float:
    return prompt_tokens * price_in + completion_tokens * price_out

# Użycie:
prompt = "Podaj 3 krótkie pomysły na aktywność fizyczną w domu."
completion = "1) ...\n2) ...\n3) ..."

pt = approx_tokens([prompt])
ct = approx_tokens([completion])

# UWAGA: tu podstaw realne ceny z cennika dostawcy/modelu
print("USD:", estimate_cost_usd(pt, ct, price_in=0.0, price_out=0.0))

Lokalny LLM (Hugging Face Transformers)

💡Do testów na CPU wybieramy małe modele (tzw. SLM – Small Language Models) np. Qwen2.5-0.5B lub TinyLlama. Modele powyżej 7B parametrów mogą wymagać karty graficznej.

W projekcie zaliczeniowym musi pojawić się obsługa w trybie offline (lokalnego modelu). Daje to prywatność i niezależność od dostawców. Użyjemy do tego biblioteki Hugging Face Transformers. Tu chodzi o to, aby zdobyć umiejętność odpalania modelu, kontrolować generację i tokenizację. Transformers robi generację przez generate(), a temperature/top_p/top_k to standardowe strategie dekodowania.

import os, torch, time
from typing import Optional, Dict, Any
from transformers import AutoTokenizer, AutoModelForCausalLM

SYSTEM_PROMPT = "Odpowiadaj po polsku i zwięźle."
USER_PROMPT = "Podaj 3 krótkie pomysły na aktywność fizyczną w domu."

LOCAL_MODEL_NAME = os.getenv("LOCAL_MODEL_NAME", "Qwen/Qwen2.5-0.5B-Instruct")

tokenizer = AutoTokenizer.from_pretrained(LOCAL_MODEL_NAME, trust_remote_code=True)
model = AutoModelForCausalLM.from_pretrained(
    LOCAL_MODEL_NAME,
    dtype=torch.float16 if torch.cuda.is_available() else torch.float32,
    device_map="auto" if torch.cuda.is_available() else None,
)

device = "cuda" if torch.cuda.is_available() else "cpu"
model = model.to(device)

print("Model:", LOCAL_MODEL_NAME)
print("Device:", device)

def build_prompt(system: str, user: str) -> str:
    messages = [{"role": "system", "content": system}, {"role": "user", "content": user}]
    if hasattr(tokenizer, "apply_chat_template"):
        # tokenize=False → dostajemy tekst; potem tokenizujemy normalnie
        return tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
    return f"[SYSTEM]\n{system}\n[USER]\n{user}\n[ASSISTANT]\n"

def count_tokens_local(text: str) -> int:
    return len(tokenizer.encode(text))

@torch.inference_mode()
def generate_local(
    prompt: str,
    system: str = "You are a helpful assistant.",
    max_output_tokens: int = 128,
    temperature: float = 0.0,
    top_p: float = 0.9,
    top_k: Optional[int] = None,
) -> str:
    t0 = time.perf_counter()
    text = build_prompt(system, prompt)
    inputs = tokenizer(text, return_tensors="pt").to(device)

    do_sample = temperature > 0.0
    gen_kwargs: Dict[str, Any] = dict(
        max_new_tokens=max_output_tokens,
        do_sample=do_sample,
        eos_token_id=tokenizer.eos_token_id,
    )
    if do_sample:
        gen_kwargs.update(dict(temperature=temperature, top_p=top_p))
        if top_k is not None:
            gen_kwargs["top_k"] = int(top_k)

    output_ids = model.generate(**inputs, **gen_kwargs)

    # wycinamy prompt, zostawiamy tylko dopisane tokeny
    gen_only = output_ids[0, inputs["input_ids"].shape[-1]:]
    output_txt = tokenizer.decode(gen_only, skip_special_tokens=True)

    dt = time.perf_counter() - t0
    ptoks = count_tokens_local(prompt) + count_tokens_local(system)
    ctoks = count_tokens_local(output_txt)

    return {
        "text": output_txt,
        "latency_s": round(dt, 3),
        "usage": {
            "prompt_tokens": ptoks,
            "completion_tokens": ctoks,
            "total_tokens": ptoks + ctoks,
        }
    }

out = generate_local(
    USER_PROMPT,
    system=SYSTEM_PROMPT,
    temperature=0.0,
    top_p=1.0,
    max_output_tokens=256,
)
print(out["text"])
print(out["latency_s"], "s")
print(out["usage"])

Prosty logger do CSV (czas + tokeny + parametry)

To jest klocek, który później wykorzystamy przy ewaluacji i w projekcie (metryki, logi, testy).

import csv
import os
from datetime import datetime
from typing import Dict, Any

TOP_P = 0.9
TOP_K = None
TEMPERATURE = 0.2

def log_row(path: str, row: Dict[str, Any]) -> None:
    fieldnames = sorted(row.keys())
    exists = os.path.exists(path)
    with open(path, "a", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=fieldnames)
        if not exists:
            w.writeheader()
        w.writerow(row)

# przykład: log lokalnego wywołania
out = generate_local(
    USER_PROMPT,
    system=SYSTEM_PROMPT,
    temperature=TEMPERATURE,
    top_p=TOP_P,
    max_output_tokens=256,
)

row = {
    "timestamp": datetime.utcnow().isoformat(),
    "mode": "local",
    "model": LOCAL_MODEL_NAME,
    "latency_s": out["latency_s"],
    "prompt_tokens": out["usage"]["prompt_tokens"],
    "completion_tokens": out["usage"]["completion_tokens"],
    "temperature": TEMPERATURE,
    "top_p": TOP_P,
    "top_k": TOP_K,
}
log_row("lab01_logs.csv", row)
print("OK: lab01_logs.csv")

Hiperparametry: sterowanie chaosem

To tutaj magia zmienia się w inżynierię. W modelu LLM każde słowo (tak na prawdę model nie widzi słów a tokeny – części słów) jest wybierane na podstawie prawdopodobieństwa. Możemy tym sterować:

  • Temperature
    Niska (0.0 – 0.3): Model wybiera zawsze najbardziej prawdopodobne tokeny. Odpowiedzi są spójne, logiczne, ale powtarzalne. Idealne do zadań analitycznych, ekstrakcji danych i kodowania.
    Wysoka (0.7 – 1.0+): Model pozwala sobie na wybór mniej oczywistych słów. Zwiększa się „kreatywność”, ale też ryzyko halucynacji.
  • Top_p (Nucleus Sampling)
    Zamiast rozważać cały słownik, model bierze pod uwagę tylko najmniejszy zbiór tokenów, których suma prawdopodobieństw wynosi P (np. 0.9). To odcina „ogony” – czyli słowa / tokeny bardzo mało prawdopodobne i bezsensowne.
  • Top_k
    Rzadziej spotykane niż Top_p, sprowadza się do po prostu wyboru konkretnej ilości (k) najbardziej prawdopodobnych tokenów.

Dlaczego musimy mierzyć ilość tokenów?
Limity kontekstu – każdy model ma limit (np. 8k, 128k tokenów). Jeśli go przekroczymy, model „zapomina” początek rozmowy.
Koszty – płacimy za każdy milion tokenów wejściowych i wyjściowych.

Jak działają hiperparametry?
Poniżej mini-symulator (1 krok dekodowania), żeby zobaczyć liczby i jak działa powyższe:

import numpy as np

def softmax(logits):
    x = np.asarray(logits, dtype=float)
    x = x - np.max(x)
    e = np.exp(x)
    return e / np.sum(e)

def softmax_with_temperature(logits, temperature: float):
    if temperature <= 0:
        raise ValueError("temperature must be > 0")
    x = np.asarray(logits, dtype=float) / float(temperature)
    return softmax(x)

def top_k_mask(probs, top_k: int | None):
    p = np.asarray(probs, dtype=float)
    if top_k is None or top_k <= 0 or top_k >= len(p):
        return np.ones_like(p, dtype=bool)
    idx = np.argpartition(-p, top_k - 1)[:top_k]
    mask = np.zeros_like(p, dtype=bool)
    mask[idx] = True
    return mask

def top_p_mask(probs, top_p: float):
    p = np.asarray(probs, dtype=float)
    if top_p >= 1.0:
        return np.ones_like(p, dtype=bool)
    order = np.argsort(-p)
    csum = np.cumsum(p[order])
    keep = csum <= top_p
    # zapewnij, że przynajmniej 1 token zostaje
    keep[0] = True
    mask = np.zeros_like(p, dtype=bool)
    mask[order[keep]] = True
    return mask

def renorm(probs, mask):
    p = np.asarray(probs, dtype=float) * mask.astype(float)
    s = p.sum()
    return p / s if s > 0 else p

def sample_next_token(
    logits,
    temperature: float = 1.0,
    top_p: float = 1.0,
    top_k: int | None = None,
    rng: np.random.Generator | None = None,
):
    rng = rng or np.random.default_rng()
    base = softmax(logits)
    scaled = softmax_with_temperature(logits, temperature=temperature)
    mask = top_k_mask(scaled, top_k) & top_p_mask(scaled, top_p)
    filtered = renorm(scaled, mask)
    idx = rng.choice(len(filtered), p=filtered)
    return idx, base, scaled, filtered, mask

def fmt(a): return " ".join(f"{v:0.3f}" for v in a)

logits = [2.0, 1.0, 0.0, -0.5, -1.0]
idx, base, scaled, filtered, mask = sample_next_token(logits, temperature=0.3, top_p=0.95, top_k=None)
print("logits:   ", logits)
print("softmax:  ", fmt(base))
print("temp:     ", fmt(scaled))
print("mask:     ", mask)
print("filtered: ", fmt(filtered))
print("sampled idx:", idx)

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *