HR / EN
Case study · C# · LLM tooling

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.

Na ovoj stranici
  1. Što je problem
  2. Što nismo htjeli
  3. Prva verzija: atributi na stranicama
  4. Druga verzija: [LlmTool] na metodama
  5. Semantički tipovi parametara (svih 45)
  6. ToolRegistry — refleksija na startupu
  7. Izvršavanje: ToolExecutor
  8. Koji LLM koristimo i zašto
  9. Što je zapravo "tool calling"
  10. System prompt (pravi, iz koda)
  11. Tri faze: Sonnet → Haiku → Flash
  12. Multi-round loop: planer i odgovaratelj
  13. "Pa ne zna ni zbrojiti" — Calculate tool
  14. Gdje smo sada
  15. Što je ostalo
  16. 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

Mala digresija o bazi: cijela analitika Bajs-a radi nad vlastitim, strogo binarnim formatom dizajniranim s dva cilja — da se bilo koji podatak dohvaća u milisekundama i da memorijski footprint ostane izrazito malen. Sve sjedi u RAM-u, strukturama koje su već u obliku pogodnom za upite (bez JSON-a, bez deserializacije po upitu). To je priča za zasebni članak, ali bitno je znati ovdje: analitičke funkcije ne rade SQL, one direktno prelaze po memoriji — i zato je LLM-u smisleno pozivati metode, a ne pisati upite.

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:

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:

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:

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:

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:

Realnost iz prakse: od otkad smo na Flashu, cijena više nije faktor. Haiku: $50 za 100 pitanja. Flash: $1 za 100 pitanja. Razlika nije u kvaliteti — oba modela prolaze gotovo sva pitanja iz našeg benchmarka. Razlika je u tome što je Google, koji drži cijeli Gemini stack in-house, mogao ići agresivno niski s cijenom tool use scenarija. Anthropic nema taj luksuz.

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:

  1. GetStationDetail("Bundek") → dobijemo stanicu
  2. GetTripCountRange(stationNumber=X, "prev_week")
  3. 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:

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.

Moralna pouka: dali smo AI-u kalkulator kao djetetu. I radi.

Gdje smo sada

Što je ostalo


Ako želiš ovo primijeniti kod sebe

Recept je iznenađujuće jednostavan:

  1. Pronađi svoj StatsCalculator. Klasu (ili nekoliko klasa) gdje su metode koje računaju stvari iz tvoje baze.
  2. Napiši mali [LlmTool] atribut. 10 linija koda.
  3. 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š.
  4. Označi postojeće metode. Ne mijenjaš implementaciju, samo dodaš atribute.
  5. Napravi ToolRegistry koji refleksijom pokupi sve označene metode.
  6. Napravi LLM wrapper (interface + jedan provider za start, npr. OpenRouter).
  7. Multi-round loop — plan prompt, continuation prompt, answer prompt. Tri stringa.
  8. 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.