POWRÓT_DO_BLOGA
AI & Automatyzacja 14 min

Tool calling w produkcji — niezawodne agenty AI z funkcjami zewnętrznymi

Tool calling to mechanizm, który zmienia LLM z chatbota w agenta zdolnego do działania — wywoływania API, query baz danych, uruchamiania kodu. Jak definiować narzędzia, obsługiwać błędy, wywoływać równolegle i monitorować w produkcji — z pełnym kodem.

Tool calling transformuje LLM z chatbota w agenta zdolnego do działania. Zamiast tylko odpowiadać na pytania, model może wywołać API, wykonać query do bazy danych, uruchomić kalkulację lub sprawdzić stan zewnętrznego systemu — i dopiero na podstawie tych danych sformułować odpowiedź. To mechanizm stojący za "inteligencją" nowoczesnych asystentów AI: zamiast halucynować brakujące fakty, model prosi o dane, które faktycznie istnieją.

Jak dokładnie działa? Model generuje JSON z nazwą funkcji i argumentami — ale nie wykonuje jej. Ty otrzymujesz ten JSON, wykonujesz funkcję własnym kodem (z odpowiednimi uprawnieniami, walidacją, logowaniem) i zwracasz wynik do modelu. Model widzi wynik i formułuje finalną odpowiedź. Dlatego tool calling jest bezpieczny — LLM nigdy nie ma bezpośredniego dostępu do Twoich systemów.

/// MECHANIZM TOOL CALLING

Cykl: User → LLM → Tool → LLM → Response

01
User message
Pytanie wymagające zewnętrznych danych lub akcji
02
LLM Decision
Model decyduje: odpowiedz bezpośrednio vs wywołaj narzędzie
03
Tool call JSON
{ name, arguments } — model proponuje, nie wykonuje
04
Execution
Twój kod wykonuje funkcję i zwraca wynik
05
LLM Synthesis
Model widzi wynik i formułuje finalną odpowiedź
+1
round trip per tool call
~200ms
overhead LLM na decyzję
≤10
max iteracji w pętli agenta
100%
Twój kod kontrols execution

Kluczowe zrozumienie: model nie "decyduje co wykonać" tylko raz. W złożonych agentach pętla (LLM → tool call → result → LLM) może się powtórzyć kilka razy. Ustaw limit iteracji (zwykle 10), żeby uniknąć nieskończonych pętli gdy model się "zapętla" między narzędziami.

Definicja narzędzi — JSON Schema i walidacja Pydantic

Jakość opisów narzędzi ma bezpośredni wpływ na to, czy model poprawnie je wywoła. Zbyt lakoniczny opis → model nie wie kiedy użyć narzędzia lub podaje złe argumenty. Zbyt ogólny → model używa narzędzia wszędzie, nawet gdy nie powinien. Pydantic eliminuje boilerplate przy definiowaniu JSON Schema i waliduje argumenty przed przekazaniem do handlera.

tools_definition.py
from pydantic import BaseModel, Fieldfrom openai import OpenAIimport jsonclient = OpenAI()# ─── Schematy wejścia (Pydantic → JSON Schema automatycznie) ─────────────────class SearchProductsInput(BaseModel):    query: str = Field(description="Fraza wyszukiwania produktów w katalogu")    max_results: int = Field(default=5, ge=1, le=20, description="Maks. liczba wyników")    category: str | None = Field(default=None, description="Filtr kategorii (opcjonalny)")class GetOrderStatusInput(BaseModel):    order_id: str = Field(description="ID zamówienia w formacie ORD-XXXXXX")# ─── Rejestr narzędzi (schemat + opis dla modelu) ────────────────────────────TOOLS = [    {        "type": "function",        "function": {            "name": "search_products",            "description": "Wyszukuje produkty w katalogu. Używaj gdy użytkownik pyta o dostępność lub porównanie produktów. NIE używaj do sprawdzania zamówień.",            "parameters": SearchProductsInput.model_json_schema(),        },    },    {        "type": "function",        "function": {            "name": "get_order_status",            "description": "Pobiera status zamówienia po ID. Używaj tylko gdy użytkownik podał numer zamówienia (ORD-XXXXXX).",            "parameters": GetOrderStatusInput.model_json_schema(),        },    },]# ─── Implementacje narzędzi (Twój kod backendowy) ────────────────────────────def search_products(query: str, max_results: int = 5, category: str | None = None) -> list[dict]:    # Tu: Elasticsearch / Algolia / SQL query    return [{"id": "P001", "name": f"Produkt: {query}", "price": 99.99, "stock": 12}]def get_order_status(order_id: str) -> dict:    # Tu: query do bazy zamówień    return {"order_id": order_id, "status": "shipped", "delivery": "2026-06-07"}TOOL_HANDLERS: dict = {"search_products": search_products, "get_order_status": get_order_status}# ─── Pętla agenta (max 10 iteracji) ──────────────────────────────────────────def run_agent(user_message: str) -> str:    messages = [{"role": "user", "content": user_message}]    for _ in range(10):        resp = client.chat.completions.create(            model="gpt-4o", messages=messages, tools=TOOLS, tool_choice="auto"        )        msg = resp.choices[0].message        if not msg.tool_calls:            return msg.content        messages.append(msg)        for tc in msg.tool_calls:            result = TOOL_HANDLERS[tc.function.name](**json.loads(tc.function.arguments))            messages.append({"role": "tool", "tool_call_id": tc.id, "content": json.dumps(result, ensure_ascii=False)})    return "Przekroczono limit iteracji agenta."

Trzy reguły dobrego opisu narzędzia: (1) powiedz kiedy używać i kiedy NIE — model potrzebuje kontrastu żeby dobrze decydować; (2) opisz format argumentów (np. "ORD-XXXXXX") — redukuje błędy parsowania; (3) jeden tool = jedna odpowiedzialność — nie łącz "szukaj i zamów" w jedną funkcję.

Wywołania równoległe — gdy model potrzebuje kilku narzędzi naraz

GPT-4o i Claude mogą w jednej odpowiedzi zwrócić listę wielu tool_calls. Jeśli wykonujesz je sekwencyjnie (jeden po drugim), tracisz czas bez potrzeby — niezależne wywołania można wykonać równolegle z asyncio.gather. Różnica przy 3 narzędziach o latency 500ms każde: sequential = 1.5s, parallel = ~500ms.

/// WZORCE WYWOŁAŃ NARZĘDZI

3 główne wzorce tool calling

Single tool
Query → Tool → Result
Proste zapytania, jedna operacja
Round trips+1
Latency~latency narzędzia
Parallel tools
Query → [Tool A || Tool B] → Results
Niezależne operacje — zbierz razem
Round trips+1
Latency~max(latencies)
Sequential chain
Query → Tool A → Tool B → Result
Wynik A potrzebny do wywołania B
Round trips+N
Latency~sum(latencies)
parallel_tools.py
import asyncioimport jsonfrom openai import AsyncOpenAIclient = AsyncOpenAI()async def execute_tool_async(tool_call, handlers: dict) -> dict:    handler = handlers[tool_call.function.name]    args = json.loads(tool_call.function.arguments)    # Jeśli handler async (httpx, asyncpg) — await; inaczej run_in_executor    if asyncio.iscoroutinefunction(handler):        result = await handler(**args)    else:        loop = asyncio.get_running_loop()        result = await loop.run_in_executor(None, lambda: handler(**args))    return {"role": "tool", "tool_call_id": tool_call.id, "content": json.dumps(result, ensure_ascii=False)}async def run_agent_parallel(user_message: str, tools: list, handlers: dict) -> str:    messages = [{"role": "user", "content": user_message}]    for _ in range(10):        resp = await client.chat.completions.create(            model="gpt-4o", messages=messages, tools=tools, tool_choice="auto"        )        msg = resp.choices[0].message        if not msg.tool_calls:            return msg.content        messages.append(msg)        tool_results = await asyncio.gather(            *[execute_tool_async(tc, handlers) for tc in msg.tool_calls]        )        messages.extend(tool_results)    return "Przekroczono limit iteracji."

Kiedy model decyduje o równoległości: model sam wykrywa niezależne zapytania. Możesz wymusić użycie narzędzi przez `tool_choice="required"`, ale nie możesz zmusić modelu do wywołania konkretnej pary — tylko kontrolujesz czy narzędzia są dostępne w ogóle.

Obsługa błędów — co gdy narzędzie zawodzi

Błędy w narzędziach dzielą się na trzy typy: retryable (chwilowy problem z siecią, timeout — warto ponowić z backoffem), permanent (złe argumenty, brak uprawnień — nie ponawiaj, zwróć czytelny komunikat dla modelu) i unexpected (bug w kodzie — loguj, zwróć ogólny błąd). Kluczowe: zawsze zwracaj coś do modelu przez pole `tool` — nigdy nie przerywaj pętli bez wyniku.

tool_error_handler.py
import jsonimport timeimport logginglogger = logging.getLogger(__name__)class ToolError(Exception):    def __init__(self, message: str, retryable: bool = False):        self.retryable = retryable        super().__init__(message)def execute_with_retry(tool_call, handlers: dict, max_retries: int = 2) -> str:    name = tool_call.function.name    args = json.loads(tool_call.function.arguments)    handler = handlers.get(name)    if handler is None:        return json.dumps({"error": f"Narzędzie '{name}' nie istnieje.", "code": "UNKNOWN_TOOL"})    for attempt in range(max_retries + 1):        try:            result = handler(**args)            return json.dumps(result, ensure_ascii=False)        except ToolError as e:            if not e.retryable or attempt == max_retries:                logger.error("Tool %s failed: %s", name, e)                return json.dumps({"error": str(e), "code": "TOOL_ERROR"})            time.sleep(2 ** attempt)        except Exception as e:            logger.exception("Unexpected error in tool %s", name)            return json.dumps({"error": "Wewnętrzny błąd narzędzia.", "code": "INTERNAL_ERROR"})    return json.dumps({"error": "Przekroczono limit prób.", "code": "MAX_RETRIES"})def run_agent_safe(messages: list, tools: list, handlers: dict, llm) -> str:    for _ in range(10):        resp = llm.chat.completions.create(model="gpt-4o", messages=messages, tools=tools, tool_choice="auto")        msg = resp.choices[0].message        if not msg.tool_calls:            return msg.content        messages.append(msg)        for tc in msg.tool_calls:            result = execute_with_retry(tc, handlers)            messages.append({"role": "tool", "tool_call_id": tc.id, "content": result})    return "Przekroczono limit iteracji agenta."

Ważne: gdy narzędzie zwróci błąd jako JSON z polem "error" — model zazwyczaj sam zdecyduje co zrobić: spróbuje z innymi argumentami, poprosi o wyjaśnienie lub przyzna że nie może wykonać zadania. Zaufaj mu, daj mu czytelny komunikat i nie wymuszaj zachowania w kodzie pętli.

Monitoring wywołań narzędzi w produkcji

Bez monitoringu nie wiesz które narzędzia są najwolniejsze, które najczęściej zawodzą i ile kosztuje sesja agenta w tokenach. Loguj każde wywołanie: nazwa narzędzia, latency, rozmiar argumentów i wyników, sukces/błąd. To wystarczy do identyfikacji bottlenecków i alertów.

tool_monitoring.py
import timeimport jsonimport loggingfrom dataclasses import dataclasslogger = logging.getLogger(__name__)@dataclassclass ToolMetric:    tool_name: str    success: bool    latency_ms: float    args_bytes: int    result_bytes: int    error: str | None = Nonedef monitored_call(tool_call, handlers: dict) -> tuple[str, ToolMetric]:    name = tool_call.function.name    args_json = tool_call.function.arguments    t0 = time.perf_counter()    try:        result = handlers[name](**json.loads(args_json))        result_json = json.dumps(result, ensure_ascii=False)        ms = (time.perf_counter() - t0) * 1000        metric = ToolMetric(name, True, round(ms, 1), len(args_json.encode()), len(result_json.encode()))        logger.info("tool=%s latency_ms=%.1f ok", name, ms)        return result_json, metric    except Exception as e:        err_json = json.dumps({"error": str(e)})        ms = (time.perf_counter() - t0) * 1000        metric = ToolMetric(name, False, round(ms, 1), len(args_json.encode()), len(err_json.encode()), str(e))        logger.error("tool=%s latency_ms=%.1f error=%s", name, ms, e)        return err_json, metric

Kluczowe metryki: latency p50/p95 per narzędzie (outliers wskazują na problemy z zależnościami), error rate per narzędzie (>5% = problem z argumentami lub zależnością), średnia liczba tool calls per sesja (zbyt wiele = model się "zapętla"), koszt tokenów per sesja.

Bezpieczeństwo — prompt injection i walidacja wejść

Użytkownicy mogą próbować manipulować modelem żeby wywołał narzędzie z niebezpiecznymi argumentami. Dwie warstwy obrony: walidacja argumentów (Pydantic sprawdzi typy i zakresy — model nie może podać ujemnego `max_results` jeśli schemat ma `ge=1`) i autoryzacja w handlerze (sprawdź czy zalogowany użytkownik ma uprawnienia — model nie wie kto jest zalogowany, Ty wiesz). Nigdy nie ufaj argumentom z tool call bez weryfikacji uprawnień.

WzorzecRound tripsKiedy używaćUwaga
Single tool+1Proste zadania: pobierz info, sprawdź statusNajprostszy do debugowania
Parallel tools+1Niezależne operacje równocześnieasyncio.gather — oszczędza latency
Sequential chain+NWynik A potrzebny do wywołania BKażde łącze = dodatkowy LLM call
Forced choice+1Gwarancja użycia konkretnego narzędziatool_choice z nazwą funkcji

---

Buduję agenty AI z tool calling dla firm — od prostych asystentów z dostępem do bazy produktów po złożone pipeline z wieloma narzędziami, logiką autoryzacji i monitoringiem produkcyjnym. Napisz do mnie — zaczynam od analizy Twoich przypadków użycia i projektu architektury narzędzi.

/// AUTHOR
Paweł Wiszniewski – AI & Web Engineer

Paweł Wiszniewski

Senior Full-Stack Engineer & AI Architect

8+ lat doświadczenia. Buduję systemy AI, automatyzacje i aplikacje webowe, które redukują koszty i zwiększają efektywność operacyjną firm.

Signal received?

Przerwij
Ciszę

Zainicjuj protokół. Nawiąż połączenie. Zbudujmy coś głośnego.

> OCZEKIWANIE_NA_SYGNAŁ...