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.5BlubTinyLlama. 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 wynosiP(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)
