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
- 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
Napravili smo pluggabilan LLM wrapper s dva providera:
- Anthropic (direktan API na
api.anthropic.com) - OpenRouter (API na
openrouter.ai, daje pristup svim modelima — Google, OpenAI, Mistral, Cohere...)
Išli smo kroz tri faze:
- Claude Sonnet direktno. Radilo je, ali overkill — skupo, sporo, a u nekim slučajevima čak i griješio (možda zato što je pre-pametan i sam sebi komplicira).
- Claude Haiku. Jeftinije i brže, kvalitetno, ali tool schema od 25+ toolova po svakom roundu je brzo napuhivala cijenu — 5+ roundova = ~$0.50 po pitanju.
- Google Gemini 2.0 Flash (preko OpenRouter). Ispalo je brutalno dobro za tooling. Nevjerovatno jeftino (red veličine manje od Haiku-a), brzo (odgovor u par sekundi), i u našem benchmarku (120 pitanja) praktički nikad ne promaši izbor toola. Ostali smo na njemu.
Wrapper izgleda ovako:
public interface ILlmProvider
{
LlmResponse Complete(string systemPrompt, string userMessage, int maxTokens);
}
// 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")
};
});
Možemo u sekundi switchati model u appsettings.json bez rebuilda. Korisno za benchmark.
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.