Kako smo spojili LLM na vlastitu bazu (bez RAG-a)
Priča o tome kako Bajs — analitika za Nextbike Zagreb — razumije pitanja na hrvatskom i vraća točne brojeve iz baze, bez izmišljanja.
- Što je problem
- Što nismo htjeli
- Prva verzija: atributi na stranicama
- Druga verzija:
[LlmTool]na metodama - Semantički tipovi parametara (svih 45)
- ToolRegistry — refleksija na startupu
- Izvršavanje: ToolExecutor
- Koji LLM koristimo i zašto
- Što je zapravo "tool calling"
- System prompt (pravi, iz koda)
- Tri faze: Sonnet → Haiku → Flash
- Multi-round loop: planer i odgovaratelj
- "Pa ne zna ni zbrojiti" — Calculate tool
- Gdje smo sada
- Što je ostalo
- Recept za tvoj projekt
Što je problem
Bajs (bajs.informacija.hr) ima hrpu analitike oko bike-sharinga: broj vožnji, prosjeci po satu, najprazne stanice, anomalije, heatmape. Sve je to dostupno kroz desetak web stranica punih grafova i tablica.
Problem: korisnik koji želi znati "koliko je bilo vožnji prošli utorak" mora znati gdje kliknuti, otvoriti pravu stranicu, naći pravu tablicu, očitati broj. A netko tko prvi put dolazi na sajt se izgubi.
Htjeli smo da samo upiše pitanje i dobije odgovor. Bez klikanja.
Što nismo htjeli
- RAG (Retrieval-Augmented Generation): skeniraj dokumente, ubaci u vektorsku bazu, napuni prompt relevantnim tekstom. To radi za dokumentaciju, ne za strukturirane podatke.
- Prompt s gotovim statistikama: "tu je sažetak za danas, odgovori". LLM bi često promašio, a sažetak bi morali sami svake minute ažurirati.
- Da LLM sam piše SQL: opasno i sporo, a usto naša baza uopće nije SQL.
Htjeli smo da LLM zove naše već postojeće analitičke funkcije. One su testirane, znamo da vraćaju točne brojeve, a i web ih koristi direktno. DRY.
Prva verzija: atributi na stranicama
Prvi pokušaj je bio naivniji. Bajs već ima desetak analitičkih stranica koje rade drilldown u podatke — svaka je specijalizirana za jedan rez (aktivnost po satima, top stanice, detalji rute, anomalije po danu). Označili smo ih atributima (naslov, opis, podnaslov, tagovi), pa je LLM iz tih meta-podataka birao koja stranica najbolje odgovara upitu. Onda bismo tu stranicu renderirali i LLM-u dali tekst sadržaja kao kontekst.
Problemi:
- Stranica miješa prezentaciju i podatke. LLM dobije HTML pun bojica i ikona, mora iz toga iščupati brojeve. Previše šuma.
- Granularnost je premalena. Jedna stranica = jedan odgovor. Ne može spojiti dvije stvari ("usporedi prošli i ovaj tjedan" = dvije stranice, nema spajanja).
- Teško je dodati parametre. Ako korisnik pita za konkretnu stanicu ili period, URL mora imati query parametre, a LLM ne zna što je dozvoljeno.
Zaključak: trebamo metode, ne stranice.
Druga verzija: [LlmTool] atributi na metodama
Sav analitički kod u Bajs-u već je u jednoj klasi: StatsCalculator. To je partial class raspoređena po desetak fileova (StatsCalculator.Reports.cs, StatsCalculator.Stations.cs, itd.) i ima metode poput GetTopStations, GetLongestTrips, GetPeakHour.
Dodali smo jedan atribut:
[AttributeUsage(AttributeTargets.Method)]
public class LlmToolAttribute : Attribute
{
public string Description { get; }
public LlmToolAttribute(string description) => Description = description;
}
Namjerno minimalno — samo opis. Naziv metode je ujedno i naziv toola.
Primjer:
[LlmTool("Najdulje vožnje po trajanju. " +
"Koristi za 'najdulje vožnje', 'najdulja vožnja ovaj tjedan'.")]
public LongestTripsResult GetLongestTrips(string period, int limit)
{
// postojeća implementacija, nepromijenjena
}
Nula novog koda. Samo smo dodali atribut iznad metoda koje su već postojale i radile za web.
Semantički tipovi parametara
Metoda GetLongestTrips prima period i limit. Ali LLM ne zna što je "period" — je li to broj dana? ISO datum? Enum?
Ovo je bio ključan dio dizajna. Napravili smo [LlmParam] atribut s semantičkim tipom:
public enum SemanticType
{
Period, // "7d", "today", "this_week", "prev_week"...
Date, // "2026-04-08"
DateRange, // "2026-04-01..2026-04-08"
StationNumber, // int, npr 21331
StationName, // "Bundek", "Jarun"
Count, // koliko redaka vratiti
Latitude,
Longitude,
DurationMinutes,
BikeCount,
TripCount,
HourOfDay,
DayOfWeek,
AreaName,
// ...45 tipova ukupno
}
public class LlmParamAttribute : Attribute
{
public SemanticType Type { get; }
public string Description { get; }
public bool Required { get; }
}
Sad metoda izgleda ovako:
[LlmTool("Najdulje vožnje po trajanju. " +
"Koristi za 'najdulje vožnje', 'najdulja vožnja ovaj tjedan'.")]
public LongestTripsResult GetLongestTrips(
[LlmParam(SemanticType.Period, "Vremenski period")]
string period,
[LlmParam(SemanticType.Count, "Koliko vožnji prikazati")]
int limit)
Zašto to pomaže:
- LLM zna format. U system promptu kažemo što svaki
SemanticTypeznači.Period= jedan od"7d / 14d / today / yesterday / this_week / prev_week / 2026-04-01". LLM ne izmišlja formate. - Validacija je centralna. Kad izvršavamo tool call, znamo što svaki parametar mora biti, možemo parse-ati i javiti grešku prije nego pozovemo metodu.
- Opisi ostaju u kodu. Nema zasebnog JSON schema file-a koji bi se razilazio od implementacije.
Ima i [LlmFixedParam] za parametre koje LLM ne smije dirati (npr. injektirani servisi ili fiksne vrijednosti).
ToolRegistry — refleksija na startupu
Pri pokretanju aplikacije, ToolRegistry skenira klasu StatsCalculator refleksijom, pronalazi sve metode s [LlmTool] atributom, čita njihove parametre i gradi interni registar:
public ToolRegistry(params Type[] typesToScan)
{
foreach (Type type in typesToScan)
{
foreach (MethodInfo method in type.GetMethods(...))
{
LlmToolAttribute toolAttr = method.GetCustomAttribute<LlmToolAttribute>();
if (toolAttr == null) continue;
// pokupi opis metode + sve [LlmParam] parametre s tipovima
// spremi u _tools dictionary
}
}
Log.Info("ToolRegistry: registered " + _tools.Count + " tools");
}
Registracija u DI kontejneru:
builder.Services.AddSingleton<ToolRegistry>(
new ToolRegistry(typeof(StatsCalculator)));
Registry onda ima dvije metode za eksport:
ToTextManifest()— kratki tekstualni popis toolova, ide u system promptToJsonSchema()— JSON shema sa svim parametrima i tipovima, za strukturirani tool calling
Izvršavanje: ToolExecutor
ToolExecutor prima JSON plan od LLM-a, parsira naziv toola i argumente, pronalazi metodu u registru, konvertira JSON argumente u pravi .NET tip (uz pomoć semantičkog tipa), pa zove metodu preko refleksije. Rezultat serijalizira natrag u JSON.
// LLM vraća:
{
"tools": [
{ "name": "GetTopStations", "args": { "period": "7d", "limit": 5 } }
]
}
// ToolExecutor:
// 1. Pronađi GetTopStations u registry
// 2. Parse period="7d" kao SemanticType.Period → string "7d"
// 3. Parse limit=5 kao SemanticType.Count → int 5
// 4. statsCalculator.GetTopStations("7d", 5)
// 5. Serialize rezultat → JSON
Koji LLM koristimo i zašto
Ovo je sekcija gdje je najviše iznenađenja. Krenuli smo s "naravno, Claude" i završili negdje potpuno drugdje.
Što je zapravo "tool calling"
Da cijeli ovaj pattern radi, LLM mora jedno: pročitati opis toolova, razumjeti što koji radi, odabrati pravi za korisnikovo pitanje i vratiti valjani JSON s argumentima. To je sve. To je "tool calling" kao LLM sposobnost.
Postoje dva načina da to izvedeš:
Native tool_use API. Anthropic, OpenAI, Google i Mistral svi imaju strukturiran endpoint gdje pored prompta pošalješ tools niz sa schemom svakog toola. LLM u odgovoru vraća posebne tool_use blokove koje ti SDK parsira. Elegantno, ali vezano uz provider-a — svaki ima svoj format.
Plain JSON-in-text prompting. Staviš schemu toolova u system prompt, kažeš "vrati mi JSON ovog oblika", i parsiraš tekst sam. Radi s bilo kojim LLM-om koji može pratiti upute.
Odabrali smo drugi. Jednostavnije, bez provider lock-ina, i ispostavilo se da su moderni modeli toliko dobri u structured outputu da native API gotovo ništa ne dobija. Naš poziv prema Anthropic-u izgleda ovako — primijeti da nema tools parametra, samo system + user:
string requestBody = JsonSerializer.Serialize(new
{
model = _model,
max_tokens = 500,
system = systemPrompt, // schema tool-ova je unutar njega
messages = new[] { new { role = "user", content = userMessage } }
});
HttpRequestMessage request = new HttpRequestMessage(
HttpMethod.Post, "https://api.anthropic.com/v1/messages");
request.Headers.Add("x-api-key", _apiKey);
request.Headers.Add("anthropic-version", "2023-06-01");
LLM vrati običan tekstualni content koji počinje s {"tools":[...]}. Mi parsiramo kao običan JSON. Nula SDK-ova, nula provider-specific gluposti. Isti wrapper radi za Claude, Gemini, GPT, Mistral, Qwen — sve što OpenRouter nudi.
System prompt (pravi, iz koda)
Već zvuči apstraktno, evo konkretno. Ovo je doslovni planning system prompt iz ChatService.cs (skraćeno za čitljivost):
"Ti si planer za BAJS Zagreb bike-sharing analitički sustav.
Dobivaš korisničko pitanje i popis dostupnih alata.
Tvoj zadatak: vrati SAMO JSON s listom alata koje trebaš pozvati
da odgovoriš na pitanje.
DANAŠNJI DATUM: 2026-04-10 (petak)
Kad korisnik kaže 'jučer', 'prošli tjedan', 'ovaj mjesec' itd.,
izračunaj točan datum iz gornjeg datuma.
Trenutna godina je 2026. NIKAD ne koristi 2024 ili 2025.
Format odgovora (SAMO JSON, bez teksta):
{"tools":[{"name":"ImeAlata","args":{"param1":"value1"}}]}
Pravila:
- Koristi SAMO alate iz popisa ispod
- Ako pitanje ne zahtijeva podatke (npr. pozdrav), vrati {"tools":[]}
- Za period parametre koristi: 7d, 14d, 28d, 30d, 90d,
this_week, prev_week, today, yesterday
- Ako korisnik ne navede period, koristi 7d kao default
- Maksimalno 5 alata po planu
- UVIJEK pokušaj koristiti alat, čak i ako nisi siguran —
bolje dati djelomičan odgovor nego nikakav
- Za BILO KAKVO zbrajanje, prosjek ili usporedbu brojeva
koristi Calculate alat — NIKAD ne računaj sam
- Odaberi alate isključivo prema njihovim opisima ispod
- Kad korisnik pita za stanicu, nikad nemoj pretpostaviti da
je to točan naziv — prvo pronađi stanicu pretragom
Dostupni alati:
[... ovdje se dolijepi JSON schema svih 26 toolova ...]"
Par stvari iz tog prompta su bitne i dolaze iz čiste patnje:
- Datum injectaš pri svakom pozivu. LLM ne zna što je "danas" — ako mu ne kažeš, koristit će trenutak treninga. Bili smo na podacima 2026. kad je model vraćao "prošlog tjedna" kao datume iz 2024. Fix je jednostavan: doslovno staviš "DANAŠNJI DATUM: X" u prompt.
- Rečenica "NIKAD ne računaj sam". Bez te rečenice, Calculate tool bi se slabije koristio — LLM bi sam zbrajao i gubio točnost. Kasnije više o tome.
- "UVIJEK pokušaj koristiti alat". Bez toga je LLM znao odgovarati "nažalost ne mogu ti reći" iako je u schemi jasno imao tool za taj slučaj. Previše je defanzivan po defaultu.
- Warning o nazivima stanica. Korisnik kaže "Bundek", to može biti Bundek OŠ, Bundek jezero, Bundek park. LLM je znao pretpostaviti točan naziv i zvati tool s krivim argumentom. Sad prvo traži pretragu.
- "Odaberi alate isključivo prema njihovim opisima". Ovo je bila prva velika lekcija. Prve verzije prompta su imale hardkodiran mapping — tipa "za pitanja o najdužim vožnjama koristi GetLongestTrips, za top stanice koristi GetTopStations". Radilo je, ali je bila pomoć: svaki put kad dodaš novi tool, moraš ažurirati i prompt; svaki put kad preimenuješ tool, prompt je krivi; i najgore — kad je mapping imao grešku, LLM je slijepo pratio prompt umjesto da pogleda schemu. Kasnije smo to religijom zabranili i forsirali pravilo "odaberi isključivo prema opisima". Od tog trenutka dodavanje novog toola znači samo dodati
[LlmTool("opis...")]na metodu — prompt ostaje isti, LLM sam pronađe.
Svako od ovih pravila je napisano nakon što je neki model zeznuo na benchmark pitanjima. Sistem prompt raste s vremenom, kao lista "nikad više ovako".
Tri faze: Sonnet → Haiku → Flash
Pošto radimo plain JSON prompting, možemo trivijalno mijenjati model. Jedan config field, nikakav rebuild. Evo kroz što smo prošli:
Faza 1 — Claude Sonnet direktno. Kvalitetno, radi iz prve. Ali skupo i sporo za ovo što radimo. Pitanje prosječno 3-4 sekunde latencije, cijena po pitanju visoka. Za interaktivno sučelje gdje korisnik očekuje odgovor "odmah", Sonnet nije pravi fit. Za složene agente da, za analitička pitanja ne.
Faza 2 — Claude Haiku. Puno brži, jeftiniji, kvaliteta odlična za tooling. Činilo se kao zlatna sredina. Ali onda smo pomnožili cijenu s brojem rundi po pitanju: svaki round šalje cijelu tool schemu od 25+ toolova (input tokens), plus continuation rezultate. Prosječno pitanje s 4-5 rundi je izlazilo oko $0.50. Deset pitanja dnevno = $5, sto pitanja = $50 — neodrživo za mali projekt.
Faza 3 — Google Gemini 2.0 Flash (preko OpenRouter). Ovdje se dogodilo iznenađenje. Gemini Flash je:
- Brutalno dobar u tool calling-u. Google ga je očito jako tunirao na function calling scenariju. Iako koristimo plain JSON prompting (ne native API), ta sposobnost se prenosi — Flash gotovo nikad ne izmišlja tool imena, ne griješi JSON syntax, i dobro razumije koji tool za koji upit.
- Brz. Odgovor u 1-2 sekunde, često brže nego Haiku.
- Smiješno jeftin. Isto ono pitanje koje je Haiku koštalo ~$0.50 na Flashu košta oko $0.01. To je 50x razlike.
Ostali smo na Flashu kao defaultu, s Claudeom kao fallback-om ako OpenRouter padne. Pluggability koju smo dobili besplatno odlukom da radimo JSON-in-text umjesto native tool_use isplatila se kad smo htjeli mijenjati provider.
Cijeli wrapper je interface od par redaka:
public interface ILlmProvider
{
LlmResult Complete(string systemPrompt, string userMessage);
}
// Registracija ovisno o configu:
builder.Services.AddSingleton<ILlmProvider>(sp => {
string provider = config["Search:Provider"];
return provider switch
{
"anthropic" => new AnthropicLlmProvider(...),
"openrouter" => new OpenRouterLlmProvider(...),
_ => throw new Exception("Unknown provider")
};
});
Svaki provider implementira Complete(string systemPrompt, string userMessage) i to je to. ChatService ne zna ni ne mari koji model je ispod.
Multi-round loop: planer i odgovaratelj
Ovo je bilo zahtjevnije. Jedno pitanje ne mora biti jedan tool call. Npr:
"Usporedi koliko je bilo vožnji prošli i ovaj tjedan na Bundeku"
Tu treba:
GetStationDetail("Bundek")→ dobijemo stanicuGetTripCountRange(stationNumber=X, "prev_week")GetTripCountRange(stationNumber=X, "this_week")
LLM mora planirati redoslijed, gledati rezultate međukoraka, pa odlučiti što dalje.
Rješenje: dva različita system prompta i loop do 10 rundi.
Runda 1 — planiranje
LLM dobije korisničko pitanje + JSON schema svih toolova + instrukciju: "Vrati JSON s listom toolova koje treba pozvati sada. Maksimalno 5 po rundi. Ako više ne treba ništa, vrati prazno."
{
"tools": [
{ "name": "GetStationDetail", "args": { "name": "Bundek" } }
]
}
Izvršimo tool, dobijemo rezultat, dodamo u allResults.
Runda 2+ — continuation
Sad LLM dobije: pitanje + dosadašnji rezultati + instrukciju "trebaju li još toolovi ili je dovoljno?"
{
"tools": [
{ "name": "GetTripCountRange", "args": { "stationNumber": 21331, "period": "prev_week" } },
{ "name": "GetTripCountRange", "args": { "stationNumber": 21331, "period": "this_week" } }
]
}
Loop se prekida kad:
- LLM vrati
{"tools": []}(nema više što pozvati) - Svi toolovi u ovoj rundi su duplikati prethodnih poziva (stuck detection)
- Dosegli smo 10 rundi
Zadnji korak — odgovor
Novi LLM poziv s odgovoreći promptom: "Na temelju ovih rezultata (JSON), formuliraj prirodni odgovor na hrvatskom. Koristi samo brojeve iz podataka. Ne spominji toolove ni API-je."
"Pa ne zna ni zbrojiti" — Calculate tool
Jedan smiješan trenutak iz razvoja: LLM je znao dohvatiti brojeve, ali kad bi trebao njih zbrojiti ili izračunati prosjek, često je promašivao. Znao je vratiti tekst tipa "ukupno oko 1500 vožnji" kad je stvarnost bila 1823.
Rješenje: dodali smo kalkulator kao tool.
[LlmTool("Kalkulator — zbrajanje, prosjek, min, max niza brojeva. " +
"UVIJEK koristi ovaj alat umjesto ručnog računanja.")]
public CalculateResult Calculate(
[LlmParam(SemanticType.None, "Operacija: 'sum', 'avg', 'min', 'max', 'count'")]
string operation,
[LlmParam(SemanticType.None, "Niz brojeva")]
double[] values)
Sad LLM, umjesto da sam zbraja, prvo pozove GetDailySnapshotAggregates za period, pa onda Calculate(operation="sum", values=[...]). Točni brojevi, svaki put.
Gdje smo sada
- 26
[LlmTool]metoda uStatsCalculator: stanice, rute, vožnje, anomalije, geocoding. - 45 semantičkih tipova za parametre.
- Pluggable LLM provider, default Gemini 2.0 Flash preko OpenRouter, fallback na Claude direktno.
- Multi-round loop do 10 rundi, duplicate detection.
- Debug UI na
/chatlab— pokazuje svaki round, tool calls, JSON rezultate, ukupni token count i cijenu. - Benchmark od 120 pitanja kojim mjerimo kvalitetu modela.
- System prompt je na hrvatskom, odgovor također. Korisnici pišu prirodno, dobivaju prirodan odgovor.
Što je ostalo
- Cost problem — svaki round šalje cijelu tool schemu. Kod 25+ toolova to nije trivijalno. Razmišljamo o reduced schema u continuation rundama (samo shema za tool-ove koji su vjerojatno sljedeći), ili composite toolovima za česte kombinacije.
- Weather korelacije — LLM još ne zna spojiti "kiša" s padom broja vožnji, jer weather tool nije dovoljno integriran.
- Area / zone queries — "koliko u centru" trenutno ne radi dobro, fali nam pojam "zona".
- Per-station time-of-day — pitanja tipa "kad je Bundek najprazniji" još treba dodatnih toolova.
Ako želiš ovo primijeniti kod sebe
Recept je iznenađujuće jednostavan:
- Pronađi svoj
StatsCalculator. Klasu (ili nekoliko klasa) gdje su metode koje računaju stvari iz tvoje baze. - Napiši mali
[LlmTool]atribut. 10 linija koda. - Napiši
[LlmParam]s enumom semantičkih tipova koje koristiš (datumi, ID-evi, enumi, brojači). Može početi s 5 tipova, dodavat ćeš. - Označi postojeće metode. Ne mijenjaš implementaciju, samo dodaš atribute.
- Napravi
ToolRegistrykoji refleksijom pokupi sve označene metode. - Napravi LLM wrapper (interface + jedan provider za start, npr. OpenRouter).
- Multi-round loop — plan prompt, continuation prompt, answer prompt. Tri stringa.
- Debug UI — bitno za razvoj, bez toga se slijepo iterira.
Bez RAG-a. Bez vektorskih baza. Bez LangChaina. Čist C# i refleksija.
Cijeli pattern stane u par stotina linija koda: atribut, parametarski atribut, enum semantičkih tipova, registry koji refleksijom skenira klasu, executor koji zove metode, LLM wrapper i multi-round orkestrator. Implementaciju ne objavljujemo kao cjelinu, ali kroz ovaj tekst su ispričani svi bitni dijelovi — dovoljno da složiš svoju verziju za par sati.