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. Multi-round loop: planer i odgovaratelj
  10. "Pa ne zna ni zbrojiti" — Calculate tool
  11. Gdje smo sada
  12. Što je ostalo
  13. 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

Napravili smo pluggabilan LLM wrapper s dva providera:

Išli smo kroz tri faze:

  1. 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).
  2. 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.
  3. 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:

  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.