Python skripta koja komunicira sa lokalnim modelom
Dosad si sa modelom pričao kroz curl. To je dobro za razumevanje i za jednokratne provere, ali čim treba obraditi listu fajlova, ponoviti neuspeo zahtev ili proslediti izlaz dalje, bash postaje tesan.
Ovaj tekst gradi Python klijent — od najmanjeg primera do alatke koju stvarno možeš staviti u /usr/local/bin.
Priprema
Zvanična openai biblioteka radi sa svakim OpenAI-kompatibilnim servisom, pa i sa tvojim:
$ python3 -m venv ~/.venv/ai $ source ~/.venv/ai/bin/activate $ pip install openai
Sistemski Python nemoj dirati; na novijim distribucijama pip to ionako odbija.
Podešavanja idu u okruženje, ne u kod:
$ cat ~/.ai_env export AI_BASE_URL=http://localhost:11434/v1 export AI_API_KEY=k_alen_3f8a2b91c7d45e6f export AI_MODEL=qwen3:8b
$ chmod 600 ~/.ai_env $ source ~/.ai_env
Ovim ista skripta radi i sa lokalnom Ollamom i sa vLLM-om iza proxy-ja, bez ijedne izmene.
Najmanji klijent
#!/usr/bin/env python3
import os
from openai import OpenAI
klijent = OpenAI(
base_url=os.environ["AI_BASE_URL"],
api_key=os.environ.get("AI_API_KEY", "nije-bitno"),
)
odgovor = klijent.chat.completions.create(
model=os.environ["AI_MODEL"],
messages=[
{"role": "system", "content": "Odgovaraj kratko, na srpskom."},
{"role": "user", "content": "Šta radi komanda ss -tulpn?"},
],
temperature=0.2,
max_tokens=256,
)
print(odgovor.choices[0].message.content)
$ python3 klijent.py Prikazuje otvorene portove sa procesima koji ih drže.
Biblioteka očekuje ključ i kad ga servis ne traži, pa Ollami daš bilo šta. Struktura odgovora je ista kao u tekstu o API-ju, samo pristupaš atributima umesto poljima u JSON-u.
Potrošnja tokena je tu:
print(f"{odgovor.usage.prompt_tokens} + {odgovor.usage.completion_tokens} tokena")
Bez biblioteke
Ako ne želiš zavisnost, dovoljna je requests ili čak standardna biblioteka:
import json, urllib.request
def pitaj(tekst, model="qwen3:8b"):
podaci = json.dumps({
"model": model,
"messages": [{"role": "user", "content": tekst}],
"temperature": 0.2,
}).encode()
zahtev = urllib.request.Request(
"http://localhost:11434/v1/chat/completions",
data=podaci,
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(zahtev, timeout=300) as veza:
return json.load(veza)["choices"][0]["message"]["content"]
Za skriptu koja ide na server bez virtuelnog okruženja ovo je često praktičnije. Rad sa JSON-om u Pythonu pokriva poseban tekst.
Streaming
tok = klijent.chat.completions.create(
model=os.environ["AI_MODEL"],
messages=[{"role": "user", "content": "Objasni šta je inode"}],
stream=True,
)
for deo in tok:
komad = deo.choices[0].delta.content
if komad:
print(komad, end="", flush=True)
print()
Provera if komad nije višak — prvi i poslednji komad u nizu često nemaju sadržaj, pa bez nje dobijaš None u ispisu.
Za obradu u pozadini streaming ne koristi. Ima smisla samo kad neko gleda u ekran; inače je jednostavniji oblik i pouzdaniji i kraći.
Razgovor sa istorijom
Server ne pamti ništa, pa istoriju vodiš sam:
class Razgovor:
def __init__(self, klijent, model, sistemski=None, max_poruka=20):
self.klijent = klijent
self.model = model
self.max_poruka = max_poruka
self.poruke = []
if sistemski:
self.poruke.append({"role": "system", "content": sistemski})
def pitaj(self, tekst, **opcije):
self.poruke.append({"role": "user", "content": tekst})
odgovor = self.klijent.chat.completions.create(
model=self.model,
messages=self.poruke,
**opcije,
)
sadrzaj = odgovor.choices[0].message.content
self.poruke.append({"role": "assistant", "content": sadrzaj})
self._skrati()
return sadrzaj
def _skrati(self):
sistemske = [p for p in self.poruke if p["role"] == "system"]
ostale = [p for p in self.poruke if p["role"] != "system"]
self.poruke = sistemske + ostale[-self.max_poruka:]
razgovor = Razgovor(klijent, os.environ["AI_MODEL"],
sistemski="Ti si pomoćnik za Linux administraciju.")
print(razgovor.pitaj("Kako da vidim zauzeće diska?"))
print(razgovor.pitaj("A sortirano po veličini?"))
Metod _skrati je ovde ključan. Bez skraćivanja istorija raste dok ne premaši kontekst, a onda zahtev pukne sa greškom 400. Skraćivanje po broju poruka je grubo ali dovoljno; tačnije bi bilo po broju tokena, uz brojanje kroz usage iz prethodnih odgovora.
Strukturiran izlaz
Ono zbog čega ovo uopšte radiš u Pythonu, a ne u bashu:
import json
SISTEMSKI = """Klasifikuješ zapise iz sistemskih logova.
Vraćaš isključivo JSON, bez ikakvog dodatnog teksta.
Polja: tip (string), ozbiljnost (broj 1-5), ip (string ili null)."""
def klasifikuj(zapis):
odgovor = klijent.chat.completions.create(
model=os.environ["AI_MODEL"],
messages=[
{"role": "system", "content": SISTEMSKI},
{"role": "user", "content": zapis},
],
temperature=0,
response_format={"type": "json_object"},
)
return json.loads(odgovor.choices[0].message.content)
>>> klasifikuj("SSH login failed for root from 203.0.113.45")
{'tip': 'neuspela_prijava', 'ozbiljnost': 4, 'ip': '203.0.113.45'}
Ovo i dalje može da zakaže — model ume da vrati ispravan JSON sa poljima koja nisu ona koja si tražio. Provera pre upotrebe:
def klasifikuj_sigurno(zapis):
try:
rezultat = klasifikuj(zapis)
except json.JSONDecodeError:
return None
if not {"tip", "ozbiljnost", "ip"} <= rezultat.keys():
return None
if not isinstance(rezultat["ozbiljnost"], int):
return None
if not 1 <= rezultat["ozbiljnost"] <= 5:
return None
return rezultat
Nikad ne veruj izlazu modela bez provere. Ispravan oblik nije isto što i tačan sadržaj.
Greške i ponovni pokušaji
from openai import APIConnectionError, APIStatusError, APITimeoutError
import sys, time
def pitaj_uporno(poruke, pokusaja=3, **opcije):
for pokusaj in range(1, pokusaja + 1):
try:
odgovor = klijent.chat.completions.create(
model=os.environ["AI_MODEL"],
messages=poruke,
timeout=300,
**opcije,
)
return odgovor.choices[0].message.content
except APIConnectionError:
print(f"Servis nedostupan (pokušaj {pokusaj})", file=sys.stderr)
except APITimeoutError:
print(f"Isteklo vreme (pokušaj {pokusaj})", file=sys.stderr)
except APIStatusError as g:
if g.status_code in (401, 403, 404):
print(f"Greška {g.status_code}, nema smisla ponavljati", file=sys.stderr)
raise
print(f"Greška {g.status_code} (pokušaj {pokusaj})", file=sys.stderr)
if pokusaj < pokusaja:
time.sleep(2 ** pokusaj)
raise RuntimeError(f"Neuspeh posle {pokusaja} pokušaja")
Razlikovanje grešaka je ovde suština. Kod 503 znači da se model učitava i vredi sačekati; kod 401 znači pogrešan ključ i ponavljanje ničemu ne služi. Razmak između pokušaja se udvostručuje, da servis koji se diže ne bombarduješ.
Podrazumevani rok od deset minuta je kratak za dugačke odgovore na sporijem hardveru — postavi timeout izričito.
Gotova alatka
Sve zajedno, u obliku koji ide u /usr/local/bin:
#!/usr/bin/env python3
"""Pitaj lokalni model. Čita sa argumenta ili sa standardnog ulaza."""
import argparse
import os
import sys
from openai import OpenAI, APIConnectionError, APIStatusError
def main():
p = argparse.ArgumentParser(description="Upit lokalnom modelu")
p.add_argument("pitanje", nargs="*", help="tekst pitanja")
p.add_argument("-s", "--sistemski", help="sistemski prompt")
p.add_argument("-t", "--temperatura", type=float, default=0.2)
p.add_argument("-n", "--max-tokena", type=int, default=1024)
p.add_argument("-m", "--model", default=os.environ.get("AI_MODEL", "qwen3:8b"))
p.add_argument("--stream", action="store_true", help="postepen ispis")
args = p.parse_args()
pitanje = " ".join(args.pitanje)
if not sys.stdin.isatty():
ulaz = sys.stdin.read().strip()
pitanje = f"{pitanje}\n\n{ulaz}" if pitanje else ulaz
if not pitanje:
p.error("nema pitanja ni na argumentu ni na ulazu")
poruke = []
if args.sistemski:
poruke.append({"role": "system", "content": args.sistemski})
poruke.append({"role": "user", "content": pitanje})
klijent = OpenAI(
base_url=os.environ.get("AI_BASE_URL", "http://localhost:11434/v1"),
api_key=os.environ.get("AI_API_KEY", "nije-bitno"),
)
try:
if args.stream:
tok = klijent.chat.completions.create(
model=args.model, messages=poruke,
temperature=args.temperatura, max_tokens=args.max_tokena,
stream=True, timeout=300,
)
for deo in tok:
komad = deo.choices[0].delta.content
if komad:
print(komad, end="", flush=True)
print()
else:
odgovor = klijent.chat.completions.create(
model=args.model, messages=poruke,
temperature=args.temperatura, max_tokens=args.max_tokena,
timeout=300,
)
print(odgovor.choices[0].message.content)
except APIConnectionError:
print("Servis nije dostupan.", file=sys.stderr)
sys.exit(1)
except APIStatusError as g:
print(f"Greška {g.status_code}.", file=sys.stderr)
sys.exit(1)
except KeyboardInterrupt:
sys.exit(130)
if __name__ == "__main__":
main()
$ sudo install -m 755 pitaj.py /usr/local/bin/pitaj
Provera sys.stdin.isatty() je ono što ovu skriptu čini upotrebljivom u lancu komandi:
$ pitaj "Objasni šta radi umask"
$ journalctl -p err -n 30 --no-pager | pitaj "Sažmi ove greške u pet tačaka"
$ pitaj --stream -s "Odgovaraj samo komandom, bez objašnjenja" \
"kako naći fajlove veće od 100MB"
$ cat nginx.conf | pitaj "Nađi bezbednosne propuste u ovoj konfiguraciji"
Izlazni kod 130 je dogovor za prekid preko Ctrl+C i čini skriptu pristojnom prema ostatku lanca. O rukovanju argumentima i izlaznim kodovima ima više u tekstu o argumentima u bash skriptama — načela su ista.
Obrada u paketu
Kada obrađuješ mnogo zapisa, paralelno slanje se isplati samo ako je ispod vLLM. Ollama zahteve ionako reda, pa tu paralelizam ne donosi ništa.
from concurrent.futures import ThreadPoolExecutor
import json
def obradi_sve(zapisi, niti=4):
with ThreadPoolExecutor(max_workers=niti) as izvrsilac:
return list(izvrsilac.map(klasifikuj_sigurno, zapisi))
with open("/var/log/auth.log") as f:
zapisi = [red.strip() for red in f if "Failed" in red][:100]
for zapis, rezultat in zip(zapisi, obradi_sve(zapisi)):
if rezultat and rezultat["ozbiljnost"] >= 4:
print(json.dumps(rezultat, ensure_ascii=False))
Oznaka ensure_ascii=False je ovde bitna — bez nje naši znakovi izlaze kao \u010d umesto č.
Broj niti drži blizu vrednosti --max-num-seqs na serveru. Više od toga samo puni red čekanja.
Praktični scenariji
Scenario 1: veza istekne na dugačkim odgovorima
Postavi timeout=300 ili više. Podrazumevana vrednost je kratka za sporiji hardver.
Scenario 2: JSON parsiranje puca
Model je vratio propratni tekst uz JSON. Dodaj response_format, a ako i to podbaci, izvuci deo između prve { i poslednje }.
Scenario 3: greška o dužini konteksta posle nekoliko pitanja
Istorija je narasla preko prozora. Smanji max_poruka ili podigni kontekst servisa, uz proveru memorije iz teksta o VRAM-u.
Scenario 4: skripta radi ručno, ne radi iz crona
Cron ne učitava ~/.ai_env. Postavi promenljive u samom cron zapisu ili u skripti, kako je opisano u tekstu o cronu.
Scenario 5: naši znakovi izlaze kao kodovi
Dodaj ensure_ascii=False u json.dumps.
Scenario 6: paralelna obrada nije ništa ubrzala
Ispod je Ollama, koja zahteve reda. Za paralelizam treba vLLM.
Kratka referenca
pip install openai— zvanična biblioteka radi i sa lokalnim servisomOpenAI(base_url=..., api_key=...)— usmeravanje na svoj servischat.completions.create()— glavni poziv.choices[0].message.content— tekst odgovora.usage— potrošnja tokenastream=True— postepen ispis, uz proveru praznog komadaresponse_format={"type": "json_object"}— JSON izlaztimeout=300— postavi izričitoAPIStatusError.status_code— 401 ne ponavljaj, 503 ponovisys.stdin.isatty()— čitanje sa standardnog ulazaensure_ascii=False— naši znakovi u JSON izlazuThreadPoolExecutor— ima smisla samo uz vLLM- Istoriju skraćuj, inače premaši kontekst
- Izlaz modela proveri pre upotrebe
Vežba
- Napiši najmanji klijent i ispiši potrošnju tokena uz odgovor.
- Dopuni ga streaming režimom i uporedi doživljaj sa običnim pozivom.
- Napravi klasu za razgovor i postavi četiri povezana pitanja zaredom.
- Napiši funkciju koja vraća JSON sa tri polja i proveri svako pre upotrebe.
- Instaliraj alatku u
/usr/local/bini propusti kroz nju izlaz komandejournalctl. - Zaustavi servis i potvrdi da skripta javi razumnu grešku umesto da pukne.
Sledeći tekst u serijalu: AI u bash skriptama — automatska analiza logova
Povezano:
- AI na Linux serveru — pregled celog serijala
- Zaštita API-ja — prethodni tekst
- OpenAI-kompatibilan API — struktura zahteva i odgovora
- Uvod u Python — osnove jezika
- Rad sa JSON-om u Pythonu — obrada odgovora
Comments
Post a Comment