Intervjuer: "Nemate ni jedan Agent projekat u CV-ju pa se usuđujete da aplicirate?" Odgovaram: "LangGraph4J, Function Calling ne računaju?" Intervjuer: "Grešio sam."
Lao Wang ima punu kosu, vedar je i samopouzdan, izgleda kao vatreni mladić koji je tek počeo da radi pre jedno-dve godine, ali zaista ima i autoritet ispitivača.
Ovo je moj prvi intervju i nemoguće je reći da nisam nervozan.
Ali unapred sam se dve nedelje međusobno ispitivao sa cimerom istog talasne dužine, i pod pritiskom Lao Wanga mislim da to mogu da izdržim.😄
"Vidim da nemate ni jedan Agent projekat u CV-ju, zar ne znate da je sada doba AI-ja?" Lao Wang je počeo da vrši pritisak čim je otvorio usta.

Nisam se nimalo preplašio: "Ova orkestracija radnih tokova urađena sa LangGraph4J+SpringAI baš to jeste, brate, pogledaj pažljivo."
"Ti momče, dobro podnosiš pritisak, samo sam ti testirao mentalitet." Lao Wang se odjednom smekšao, učinilo se kao da se između nas zagrejalo raspoloženje, i vazduh je postao delikatan~
"Brate, nastavi, ja sam prilično siguran u PaiAgent projekat, urađen jednim Vibe Coding-om, na GitHub-u ima skoro 200 zvezdica."
https://github.com/itwanger/PaiAgent

content
01,Čemu služi State u LangGraph4j?
"Prvo da pričamo o State-u, kako se State LangGraph4j koristi u vašem projektu?"
Rekao sam: "Brate, State u LangGraph4j-u je 'memorijski centar' celog radnog toka."
Možete ga zamisliti kao ranac sa podacima koji prolazi kroz sve čvorove — kada svaki čvor završi izvršenje, gura rezultat u taj ranac, a sledeći čvor iz ranca uzima izlaz prethodnog čvora.
U PaiAgent-u smo dizajnirali klasu WorkflowState sa nekoliko ključnih polja:
@Data
public class WorkflowState {
private String currentNodeId;
private Map<String, Object> globalContext = new HashMap<>();
private Map<String, NodeOutput> nodeOutputs = new HashMap<>();
private String status = "RUNNING";
private String errorMessage;
private Long startTime;
private String inputData;
}currentNodeId beleži do kog čvora se došlo u izvršenju, nodeOutputs čuva rezultate svakog čvora, a globalContext služi za smeštanje podataka koji se dele između čvorova.
Međutim, u stvarnoj implementaciji nismo direktno ugurali WorkflowState u LangGraph4j-ov StateGraph.
LangGraph4j zahteva upotrebu AgentState, čija je podrenderer zapravo Map<String, Object>. Zato smo u StateManager-u napravili sloj za konverziju — prilikom inicijalizacije se polja inputData, currentInput, nodeOutputs, status smeštaju u Mapu i predaju LangGraph4j-u:
public Map<String, Object> initializeState(String inputData) {
Map<String, Object> state = new HashMap<>();
state.put("inputData", inputData);
Map<String, Object> currentInput = new HashMap<>();
currentInput.put("input", inputData);
state.put("currentInput", currentInput);
state.put("nodeOutputs", new HashMap<>());
state.put("status", "RUNNING");
state.put("startTime", System.currentTimeMillis());
return state;
}Lao Wang je klimnuo glavom: "Kakav je onda odnos između WorkflowState i AgentState?"
Rekao sam: "WorkflowState je naš sopstveni poslovni model, zgodan za serijalizaciju i trajnost. AgentState je model stanja okvira LangGraph4j. StateManager je zadužen za konverziju između njih — pri inicijalizaciji WorkflowState se pretvara u Mapu za LangGraph4j, a nakon izvršenja se iz Mape ponovo izdvaja WorkflowState radi čuvanja zapisa o izvršenju."

Lao Wang je dodao: "A šta ako izvršenje čvora ne uspe, kako State to obrađuje?"
Rekao sam: "NodeAdapter ima logiku za obradu grešaka. Ako neki čvor baci izuzetak, status u State će se postaviti na FAILED, a errorMessage beleži konkretan sadržaj greške. Kada LangGraphWorkflowEngine primi konačno stanje, preko stateManager.isSuccessful(finalState) proverava statusno polje, samo SUCCESS ili RUNNING se računaju kao normalan završetak. Status FAILED će okinuti WORKFLOW_COMPLETE događaj sa informacijom o neuspehu, i taj zapis izvršenja će se upisati u bazu sa FAILED statusom, što olakšava naknadno otkrivanje problema."
"Ceo životni ciklus State-a je: inicijalizacija -> redosledno ažuriranje čvorova -> trajnost konačnog stanja. Svaka karika ima rezervu u slučaju problema."
02,Kako se izgrađuje grafikon između čvorova? Kako se prosleđuju parametri?
Lao Wang je nastavio: "Objasni proces izgradnje grafikona, šta tačno vaš GraphBuilder radi?"
Rekao sam: "GraphBuilder radi tri stvari: dodaje čvorove, dodaje grane i postavlja ulaz i izlaz."
Prvo se kreira jedan StateGraph<AgentState>, a zatim se prolazi kroz sve čvorove u konfiguraciji radnog toka i svaki se preko NodeAdapter-a adaptira u AsyncNodeAction i registruje u grafikon:
public CompiledGraph<AgentState> buildGraph(WorkflowConfig config,
Consumer<ExecutionEvent> eventCallback) throws Exception {
StateGraph<AgentState> graph = new StateGraph<>(AgentState::new);
addNodes(graph, config.getNodes(), eventCallback);
addEdges(graph, config.getEdges());
setEntryAndExit(graph, config.getNodes(), config.getEdges());
return graph.compile();
}Identifikacija ulaza i izlaza je posebna — nije hardkodirana, već se dinamički pronalazi na osnovu topologije grana. Čvor bez ulaznih grana je ulaz, a čvor bez izlaznih grana je izlaz:
private WorkflowNode findEntryNode(List<WorkflowNode> nodes, List<WorkflowEdge> edges) {
for (WorkflowNode node : nodes) {
boolean hasIncomingEdge = edges.stream()
.anyMatch(edge -> edge.getTarget().equals(node.getId()));
if (!hasIncomingEdge) return node;
}
return null;
}Nakon što se pronađe ulaz, dodaje se grana StateGraph.START -> entryNode; nakon što se pronađe izlaz, dodaje se grana exitNode -> StateGraph.END.

Lao Wang je pitao: "A šta je sa prenosom parametara? Podaci koje čvor A izbaci, kako ih čvor B dobija?"
Rekao sam: "Preko polja currentInput u State-u. U NodeAdapter-u postoji takva logika — nakon što se svaki čvor izvrši, izlaz se upisuje u nodeOutputs radi arhiviranja, i istovremeno se currentInput ažurira na izlaz tekućeg čvora:
newStateData.put("nodeOutputs", nodeOutputs);
newStateData.put("currentInput", output);
newStateData.put("currentNodeId", node.getId());currentInput koji sledeći čvor dobije je izlaz prethodnog čvora. Ako neki čvor mora da preskoči srednje čvorove i referencira podatke više u toku, iz nodeOutputs traži po ID-u čvora."

Lao Wang je ponovo pitao: "A šta ako je u pitanju prvi čvor? Šta ako nema uzvodnog čvora?"
Rekao sam: "currentInput prvog čvora se postavlja pri inicijalizaciji u StateManager-u, i to na {"input": "korisnikov originalni unos"}. Zato prvi čvor dobija korisnički unos."

Pored ovog lančanog prenosa, naš Prompt šablon takođe podržava {{variable}} sintaksu za zamenu promenljivih.
PromptTemplateService na osnovu konfiguracije inputParams razlikuje input (statična vrednost) i reference (referenca na izlaz uzvodnog čvora), i na taj način popunjava promenljive.

Na primer, ako je konfigurisano referenceNode: "node_llm1.analysis", traži se vrednost polja analysis u currentInput i ubacuje u šablon.
03,Koje tipove čvorova podržava?
Lao Wang je pitao: "Koliko tipova čvorova vaš sistem podržava?"
Rekao sam: "Trenutno podržava 8."
input i output su dva osnovna čvora, zadužena za ulaz i izlaz podataka. Među srednjim čvorovima za obradu ima 5 LLM čvorova — openai, deepseek, qwen, zhipu, aiping — koji svi nasleđuju istu osnovnu klasu AbstractLLMNodeExecutor. Tu je i jedan TTS čvor za sintezu govora.

Ovde postoji jedna dizajnerska lepota — šablon metod kod LLM čvorova. Mi smo ekstrakciju konfiguracije, zamenu šablona, API poziv i izgradnju izlaza, sve te zajedničke procese, potpuno enkapsulirali u AbstractLLMNodeExecutor, pa 5 LLM podklasa treba samo da implementiraju jednu metodu getNodeType():
public class OpenAINodeExecutor extends AbstractLLMNodeExecutor {
@Override
protected String getNodeType() { return "openai"; }
}Prethodno su 5 čvorova zajedno imala preko 800 linija koda, nakon refaktorisanja svaki ima oko 10 linija. NodeExecutorFactory koristi Spring-ov dependency injection da automatski prikupi sve implementacije NodeExecutor-a, registruje ih po getSupportedNodeType() u Map-u, i po tipu ih dohvata u toku izvršenja:
@Component
public class NodeExecutorFactory {
private final Map<String, NodeExecutor> executors = new HashMap<>();
@Autowired
public NodeExecutorFactory(List<NodeExecutor> executorList) {
for (NodeExecutor executor : executorList) {
executors.put(executor.getSupportedNodeType(), executor);
}
}
}Što se tiče LangGraph4j START i END čvorova, to nisu poslovni čvorovi, već virtuelni čvorovi koje okvir koristi da obeleži početak i kraj grafikona.
GraphBuilder pri postavljanju ulaza dodaje granu StateGraph.START -> entryNode, a pri postavljanju izlaza granu exitNode -> StateGraph.END, pa okvir zna odakle da počne izvršenje i gde da se završi.

Ovde postoji još jedan detalj dizajna — NodeAdapter adapter pattern. LangGraph4j zahteva da svaki čvor bude AsyncNodeAction<AgentState>, ali naši postojeći izvršioci čvorova koriste interfejs NodeExecutor.
Uloga NodeAdapter-a je da napravi most, tako da NodeExecutor.execute(node, input, callback) umota u asinhronu Lambda formu koju LangGraph4j zahteva.
Tako se postojeći NodeExecutor kod ne menja nijednom linijom, a može se integrisati u LangGraph4j okvir. Stari motor poziva NodeExecutor direktno preko DAG topološkog sortiranja, a novi motor ga poziva indirektno preko NodeAdapter-a — oba puta koriste isti skup izvršilaca.

Lao Wang je klimnuo glavom: "Ako bih želeo da dodam novi tip čvora, na primer čvor za pretragu, da li bi promene bile velike?"
Rekao sam: "Vrlo male. Implementiraj interfejs NodeExecutor, napiši klasu i registruj je u Spring kontejner. NodeExecutorFactory je automatski otkriva, NodeAdapter se automatski adaptira, GraphBuilder ne mora da menja nijednu liniju koda. To je prednost strategija patterna + fabričkog patterna — promene pri dodavanju novog tipa čvora su potpuno zatvorene unutar nove klase, bez ikakvog upliva u postojeći kod."
04,Da li se definicija alatnih pluginova poklapa sa Function Calling-om?
Lao Wang je promenio temu: "Kako definišete svoje alatne pluginove? Kakav je odnos sa OpenAI Function Calling-om?"
Rekao sam: "Definicija naših alatnih pluginova je u skladu sa specifikacijom OpenAI Function Calling-a."
U PaiAgent-u se alatni pluginovi implementiraju preko Spring AI interfejsa FunctionCallback. Uzmimo za primer LoadSkillReferenceFunction:
public class LoadSkillReferenceFunction implements FunctionCallback {
@Override
public String getName() { return "load_skill_reference"; }
@Override
public String getDescription() {
return "Učitaj sadržaj referentnog dokumenta za navedeni Skill...";
}
@Override
public String getInputTypeSchema() {
return """
{
"type": "object",
"properties": {
"skill_name": { "type": "string", "description": "Naziv Skill-a" },
"reference_name": { "type": "string", "description": "Naziv referentnog dokumenta" }
},
"required": ["skill_name", "reference_name"]
}
""";
}
@Override
public String call(String functionInput) {
// Raščlani parametre, učitaj referentni fajl
}
}getName() odgovara polju name Function Calling-a, getDescription() odgovara description, a getInputTypeSchema() vraća standardni JSON Schema, potpuno ista definicija kao OpenAIjev parameters.

Registrovanje u ChatClient je takođe direktno. Prilikom kreiranja ChatClient-a, ChatClientFactory u listu prosleđuje FunctionCallback:
builder.defaultFunctions(functions.toArray(new FunctionCallback[0]));Spring AI pri pozivanju velikog modela automatski serijalizuje opise ovih funkcija u format OpenAI Function Calling-a i šalje ih modelu.
Kada model vrati tool_calls, Spring AI automatski poziva odgovarajuću metodu call() i rezultat vraća modelu radi nastavka generisanja. U AbstractLLMNodeExecutor smo takođe dodali ograničenje maksimalnog broja iteracija, da sprečimo model da upadne u beskonačnu petlju poziva funkcija:
private static final int MAX_FUNCTION_ITERATIONS = 5;05,Koja je razlika između OpenAI kompatibilnosti i Response-a?
Lao Wang je nastavio: "Integrisali ste OpenAI, DeepSeek, Tongyi Qianwen i još nekoliko modela, njihovi API-ji se sigurno razlikuju? Kako ste ih ujednačili?"
Rekao sam: "Preko OpenAI kompatibilnog protokola."

Glavni domaći provajderi modela — DeepSeek, Tongyi Qianwen, Zhipu — u osnovi svi nude OpenAI kompatibilne API interfejse. To znači da je format zahteva /v1/chat/completions, sa istom strukturom polja messages, model, temperature u telu zahteva. Razlike su uglavnom u base_url i api_key.
U PaiAgent-u, ChatClientFactory koristi OpenAiApi + OpenAiChatModel za kreiranje klijenata:
private ChatModel createOpenAICompatibleModel(String apiUrl, String apiKey,
String model, Double temperature) {
OpenAiApi openAiApi = new OpenAiApi(apiUrl, apiKey);
OpenAiChatOptions options = OpenAiChatOptions.builder()
.model(model)
.temperature(temperature)
.build();
return new OpenAiChatModel(openAiApi, options);
}Bilo da prosledite https://api.openai.com/v1, https://api.deepseek.com/v1 ili https://dashscope.aliyuncs.com/compatible-mode/v1, sve prolazi kroz isti kod.
U switch-u fabričke metode, openai, deepseek, qwen — sva tri tipa vode na createOpenAICompatibleModel.
Lao Wang je nastavio: "A šta je sa Response-om? Da li je format koji svaki vraća potpuno isti?"
Rekao sam: "Većina polja je ista, na primer struktura choices[0].message.content je ista.
Ali neki detalji se razlikuju, na primer polje za statistiku tokena — neki se zove usage.prompt_tokens, neki usage.input_tokens. Takođe, kod streaming povratnog SSE formata, pojedini provajderi se razlikuju u enumeracionim vrednostima finish_reason."

"Spring AI nam pomaže da prikrijemo te razlike, u OpenAiChatModel radi standardizaciju. metadata.getUsage() koji izvučemo iz ChatResponse-a već je u jedinstvenom formatu i ne moramo sami da obrađujemo razlike između provajdera."
Lao Wang je nastavio: "Kako onda implementirate dinamičko prebacivanje modela u toku rada? Da prebacite bez restarta servisa?"
Rekao sam: "Da, potpuno dinamički. ChatClientFactory pri svakom pozivu kreira novu instancu new OpenAiApi(apiUrl, apiKey), nije Spring singleton. Svaki čvor može imati drugačiji apiUrl i model, na primer prvi čvor koristi DeepSeek za preliminarnu analizu, drugi čvor koristi GPT za finu obradu. Sve se definise u JSON-u konfiguracije radnog toka i runtime učitava konfiguraciju da dinamički kreira ChatClient. Vi na frontend drag-and-drop editoru promenite ime modela i pri sledećem izvršenju to stupa na snagu."
Prednost ovog dizajna je fleksibilnost, mana je što pri svakom zahtevu kreira novu instancu ChatClient-a, što nosi određeni overhead. Ali za ovakve niskofrekventne pozive radnog toka, taj overhead je potpuno prihvatljiv. Ako kasnije budete radili visokokonkurentni online inference, možda će biti potrebno dodati connection pool.
06,Kako osigurati da parametri prosleđeni velikom modelu potpuno odgovaraju formatu?
Lao Wang je postavio veoma praktično pitanje: "Parametri u konfiguraciji su različiti, kako garantujete da zahtev prosleđen velikom modelu neće prijaviti grešku zbog formata parametara?"
Rekao sam: "Tri linije odbrane."
Prva linija je validateResolvedConfig. Pre izvršenja čvora, prvo proveri da li su tri obavezna polja apiUrl, apiKey, model prazna:
private void validateResolvedConfig(LLMNodeConfig config) {
if (isBlank(config.getApiUrl()) || isBlank(config.getApiKey()) || isBlank(config.getModel())) {
throw new IllegalArgumentException(
String.format("%s čvor nema važeću konfiguraciju modela", getNodeType().toUpperCase()));
}
}Druga linija je mehanizam prioriteta globalne konfiguracije. U konfiguraciji čvora postoji polje configId — ako je popunjeno, iz baze se čita LLMGlobalConfig, i proverena globalna konfiguracija prepisuje konfiguraciju na nivou čvora. To izbegava rizik od grešaka zbog ručnog popunjavanja apiUrl-a i apiKey-a za svaki čvor. Samo kada globalna konfiguracija ne postoji, vraća se na konfiguraciju samog čvora.

Treća linija je rezerva za promenljive šablona u PromptTemplateService. Ako šablon sadrži {{variable}} ali se ne pronađe vrednost za odgovarajući parametar, ne prijavljuje grešku, već se zamenjuje praznim stringom.
Tako čak i ako uzvodni čvor ne izbaci očekivano polje, Prompt neće sadržati nerasčlanjene oznake {{}} — iako rezultat možda neće biti idealan, barem neće dovesti do toga da API velikog modela vrati 400.

Pored toga, polje temperature smo podrazumevano postavili na 0.7, a metoda trimString radi trim obradu svih string parametara, uklanjajući vodeće i prateće razmake, kako bi se sprečilo unošenje praznih karaktera pri kopiranju i lepljenju iz konfiguracionog interfejsa.
Lao Wang je pitao: "Da li ste ikada imali stvarni produkcijski problem uzrokovan formatom parametara?"
Rekao sam: "Jesmo. Jednom je korisnik na kraj apiUrl-a nalepio još jedan slash, pa je Spring AI pri spajanju putanje dobio dupli slash i odmah 404. Kasnije smo na osnovu trimString-a dodali logiku za uklanjanje završnog slasha iz URL-a. Još jednom su se u apiKey uvukli znakovi za novi red, koji se golim okom ne vide, ali su u zaglavlju zahteva dodali \n, pa je server odmah odbio autentifikaciju."
07,Kako se osigurava ispravnost TTS parametara?
Lao Wang je pitao: "Pravili ste i TTS sintezu govora, kako se proveravaju parametri kao što su ton glasa i tekst?"
Rekao sam: "TTS ima puno zamki, uložili smo trud u ispravnost parametara."
Prvo o glasu. Koristimo Alibaba Bailian qwen3-tts-flash model, koji podržava glasove iz enumeracione liste — Cherry, Ethan, Serena i tako dalje.

Korisnik na frontendu bira kinesko ili englesko ime kao string, a na backendu se to mora pretvoriti u enumeracioni tip SDK-a. Napisali smo metodu convertVoice koja radi konverziju — ako se prosledi nepostojeće ime glasa, ne prijavljuje grešku direktno, već se degradira na podrazumevani CHERRY:
private AudioParameters.Voice convertVoice(String voiceStr) {
try {
return AudioParameters.Voice.valueOf(voiceStr.toUpperCase());
} catch (IllegalArgumentException e) {
log.warn("Nepoznat glas: {}, koristi podrazumevani CHERRY", voiceStr);
return AudioParameters.Voice.CHERRY;
}
}Zatim o tekstu. Bailian TTS API ima ograničenje dužine za jedan unos — UTF-8 kodiranje ne sme da pređe 600 bajtova. Deo kineskog teksta po tri bajta po karakteru, 600 bajtova je oko 200 kineskih znakova.
Postavili smo gornju granicu MAX_TTS_INPUT_LENGTH = 400 znakova, a zatim u metodi splitText radimo segmentaciju:
private List<String> splitText(String text, int maxLength) {
// ...
while (end > start) {
String candidate = text.substring(start, end);
int byteLength = candidate.getBytes(StandardCharsets.UTF_8).length;
if (byteLength <= 600) {
// Pokušaj prelom rečenice kod znakova interpunkcije
int lastPunctuation = findLastPunctuation(text, start, end);
if (lastPunctuation > start) {
end = lastPunctuation + 1;
}
chunks.add(candidate);
break;
}
end -= 10; // Ako je predugo, smanji
}
}Logika segmentacije ima dva ključna postupka: prvo, na osnovu broja UTF-8 bajtova a ne broja znakova se ocenjuje da li je prekoračena granica, jer pri mešanju kineskog i engleskog razlika između broja znakova i broja bajtova jako varira; drugo, prvenstveno se prelom rečenice radi kod znakova interpunkcije (tačka, uzvičnik, upitnik itd.), tako da je svaki fragment kompletna rečenica i ne dolazi do presecanja usred reči.
08,Kako TTS osigurava konzistentnost glasa i stabilnost parametara?
Lao Wang je nastavio: "Nakon segmentacije, više fragmenata zasebno poziva TTS API, kako osiguravate da je glas konačno sintetisanog audio-a ujednačen?"
Rekao sam: "Ovo pitanje pogađa suštinu. Suštinskih strategija ima tri."
Prva je fiksiranje parametara. Svi segmenti dele isti skup TTS parametara — isti model, voice, languageType. Ovi parametri su utvrđeni prilikom konfiguracije čvora i ne menjaju se zbog segmentacije. Svaki fragment pri API pozivu gradi iste parametre:
MultiModalConversationParam param = MultiModalConversationParam.builder()
.apiKey(apiKey)
.model(model)
.text(chunk)
.voice(voice)
.languageType(languageType)
.build();Jedino se menja polje text, voice i model su fiksni.

Drugi je paralelna obrada + uređeno spajanje. Koristimo CompletableFuture.supplyAsync za paralelno pozivanje više TTS zahteva, ali pri konačnom spajanju audio se nadovezuje po originalnom redosledu:
// Paralelni zahtevi
List<CompletableFuture<byte[]>> futures = new ArrayList<>();
for (int i = 0; i < textChunks.size(); i++) {
futures.add(CompletableFuture.supplyAsync(() -> { ... }));
}
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
// Po redosledu uzmi rezultate
for (CompletableFuture<byte[]> future : futures) {
audioChunks.add(future.get());
}Treći je spajanje u WAV formatu. Pri spajanju više audio fragmenata uzimamo 44-bajtni WAV zaglavlje prvog fragmenta kao zaglavlje konačnog fajla, naredni fragmenti nadovezuju samo deo sa podacima (preskaču svoje WAV zaglavlje), i na kraju ažuriramo fileSize i dataSize polja u zaglavlju fajla:
byte[] header = Arrays.copyOf(firstChunk, 44);
mergedStream.write(header);
for (byte[] chunk : audioChunks) {
if (chunk.length > 44) {
mergedStream.write(chunk, 44, chunk.length - 44);
}
}
// Ažuriraj fileSize i dataSize u WAV zaglavljuTako se osigurava ispravan format spojenog audio fajla i ujednačen glas. Konačni fajl se uploaduje na MinIO i vraća pristupačan URL.

Lao Wang je dodao: "A šta ako ne uspe TTS poziv za neki fragment?"
Rekao sam: "CompletableFuture će baciti RuntimeException, gornji sloj hvata i okida NODE_ERROR događaj, čitav radni tok se označava kao FAILED. Trenutno nema ponovnog pokušaja za pojedinačni fragment, to je tačka koja se može optimizovati, na primer dodati RetryTemplate, koji za neuspeh pojedinačnog fragmenta radi 3 pokušaja sa eksponencijalnim rastom intervala, pa povremeni mrežni trezdaji neće dovesti do pada celog TTS zadatka."
09,Skill i MCP logika u projektu
Lao Wang je pitao: "U CV-ju si naveo Skill mehanizam, ispričaj šta je to."
Rekao sam: "Skill je u PaiAgent-u mehanizam 'unapred postavljenih najboljih praksi'."
Možete to zamisliti ovako — svaki Skill je paket znanja iz određene stručne oblasti, na primer 'generacija scenarija za kratke videe', 'pisanje tehničkih članaka', 'klijentski razgovori'. To nije logika koda, već strukturirani Markdown fajl koji velikom modelu govori šta da radi u toj situaciji, koji šablon da koristi i koje primere da referencira.

Tehnička implementacija: SkillRegistry pri pokretanju aplikacije automatski učitava sve Skill-ove:
@PostConstruct
public void init() {
// Prvo učitaj sa classpath-a
int classpathLoaded = loadFromClasspath();
if (classpathLoaded > 0) return;
// Rezerva: fajl sistem
loadFromFileSystem();
}Ispod svakog Skill direktorijuma nalazi se glavni fajl SKILL.md, a može postojati i poddirektorijum reference/ sa referentnim dokumentima. SkillLoader je zadužen za raščlanjivanje YAML frontmatter-a radi izdvajanja name i description, dok se sadržaj tela koristi kao vodič.
Kada LLM čvor konfiguriše skillName, AbstractLLMNodeExecutor iz SkillRegistry učitava pun sadržaj odgovarajućeg Skill-a i sve reference fajlove, pakuje ih u sistemski prompt:
if (config.getSkillName() != null && !config.getSkillName().isBlank()) {
skill = skillRegistry.getSkill(config.getSkillName());
if (skill.isPresent()) {
skillReferences = skillRegistry.loadAllReferences(config.getSkillName());
}
}
String systemPrompt = buildSystemPrompt(skill, skillReferences);buildSystemPrompt poziva metodu getFullExecutionPrompt Skill-a, tako da se opis veštine, sadržaj vodiča i svi referentni dokumenti odjednom ubacuju u sistemski prompt. Tako veliki model prilikom generisanja odgovora ima potpuni kontekst stručnog znanja.
Lao Wang je nastavio: "Kakva je performansa učitavanja Skill-a? Da li se kod svakog zahteva čitaju fajlovi?"
Rekao sam: "Ne mora. SkillRegistry pri pokretanju aplikacije odjednom učitava sve Skill-ove u memoriju, u ConcurrentHashMap. Reference fajlovi se nakon prvog čitanja takođe keširaju. Naknadni zahtevi se direktno uzimaju iz memorije, bez fajl I/O. ConcurrentHashMap osigurava bezbednost u višenitnom okruženju, tako da pri paralelnom izvršavanju više radnih tokova nema konkurentnih problema."

10,Kakav je mehanizam postepenog referenciranja Skill-a?
Lao Wang je pitao: "Šta znači to postepeno referenciranje? Otkud veliki model zna koji Skill da koristi?"
Rekao sam: "Postupno otkrivanje (Progressive Disclosure) je ideja koju smo imali pri prvobitnom dizajnu Skill sistema."
Prvobitna zamisao je bila podeljena u tri faze:
Prva faza — u sistemski prompt se ubacuje samo sažetak Skill-a, odnosno naziv i opis. Veliki model vidi da se trenutni zadatak poklapa sa opisom nekog Skill-a i zaključuje da mu je potreban.
Druga faza — veliki model preko Function Calling-a poziva funkciju load_skill_detail i učitava pun sadržaj vodiča (telo SKILL.md).
Treća faza — na osnovu liste referentnih dokumenata pomenutih u vodiču, model poziva funkciju load_skill_reference i prema potrebi učitava konkretne šablone i primere.

Implementirali smo obe funkcije — LoadSkillDetailFunction i LoadSkillReferenceFunction, oba standardni FunctionCallback. Veliki model preko Function Calling-a sam odlučuje kada da učita i koji dokument.
Ali u stvarnoj upotrebi otkrili smo problem sa ovim pristupom — višekružni pozivi funkcija povećavaju latenciju i potrošnju tokena, a ponekad model 'zaboravi' da pozove ove funkcije. Zato je trenutna implementacija pojednostavljena i prešla je na direktno puno učitavanje:
// Direktno učitaj sve references, upakuj u Prompt
skillReferences = skillRegistry.loadAllReferences(config.getSkillName());
// Više nije potrebno registrovati funkcije, direktno upakuj sav sadržaj
// functions.add(new LoadSkillDetailFunction(skillRegistry));
// functions.add(new LoadSkillReferenceFunction(skillRegistry));U kodu možete videti zakomentarisano registrovanje funkcija. Trenutna strategija je da se pun sadržaj Skill-a i sve reference odjednom upakuju u sistemski prompt, žrtvuje se deo tokena za stabilniji rezultat izvršenja.
Lao Wang je pitao: "Kakvu onda svrhu ima postepeno referenciranje?"
Rekao sam: "Kada je obim Skill-ova mali, puno učitavanje nije problem. Ali ako jedan Skill ima desetine referentnih fajlova i desetine hiljada znakova sadržaja, trpanje svega u sistemski prompt će raspršiti kontekstni prozor. Tada se vidi vrednost postepenog referenciranja — učitava se samo onaj deo koji je potreban za trenutni zadatak, prema potrebi. Infrastruktura za pozive funkcija je već napisana, možemo se u svakom trenutku vratiti na nju."
11,Kako rešiti problem ograničenja kontekstnog prozora pri korišćenju Skill-a?
Lao Wang je postavio poslednje pitanje: "Malopre si rekao da se pun sadržaj Skill-a pakuje u sistemski prompt, šta ako kontekstni prozor nije dovoljan?"
Prvo o trenutnom stanju. Modeli koje koristimo uglavnom podržavaju kontekstni prozor od 128K ili veći.
SKILL.md jednog Skill-a plus nekoliko referenci obično ima oko 5000-15000 tokena, što je za 128K sasvim dovoljno. Dodamo li korisnički unos, podatke koji se prenose između čvorova i opise funkcija, ukupno zauzme 20-30K tokena, pa preostaje još mnogo prostora.
Ali ako scenario postane složen — na primer čvor je konfigurisan sa Skill-om, a korisnički unos je izuzetno dugačak (članak od deset hiljada znakova za prevođenje), ili lanac radnog toka jako dugačak sa puno podataka u State-u u svakom čvoru — kontekst može postati tight.

Naše trenutne strategije odgovora su nekoliko:
Prvo, sečenje izlaza. Izlaz svakog čvora zadržava samo ključna polja, ne prosleđuje se čitav odgovor LLM-a (uključujući metapodatke kao što je statistika tokena) nizvodnim čvorovima.
Drugo, reference fajlovi se upravljaju pojedinačno. Referentni dokumenti Skill-a se dele u više manjih fajlova, umesto u jedan veliki. Tako čak i ako se prebacimo nazad na postepeno učitavanje, možemo imati finu kontrolu.
Treće, keš mehanizam SkillRegistry. Koristi ConcurrentHashMap za keširanje već učitanog sadržaja referenci, čime se izbegava višestruko čitanje iz fajl sistema. Iako ne rešava direktno problem konteksta, smanjuje I/O overhead.
private final Map<String, Map<String, String>> referenceCache = new ConcurrentHashMap<>();Ako u budućnosti zaista naiđemo na scenario gde kontekst nije dovoljan, postoji nekoliko pravaca: prvo, prebacivanje na postepeno referenciranje, tako da model prema potrebi učitava reference; drugo, sažimanje sadržaja Skill-a, uz zadržavanje samo pasusa relevantnih za trenutni zadatak; treće, uvođenje RAG-a, vektorizacija sadržaja Skill-a, pri čemu pretraga vraća samo najrelevantnije fragmente. Iskreno, trenutni prozor od 128K je za većinu scenarios radnih tokova sasvim dovoljan.
Lao Wang je nakon što je saslušao ćutao nekoliko sekundi: "Kada možeš da počneš da radiš?"
Rekao sam: "Sutra, idem da pripremim lepu odeću — ne, idemo od ponedeljka, idem da traktiram kolege ručkom da proslavimo🎉"
