Tool calling w OpenAI API polega na tym, że model nie wykonuje Twojej funkcji, tylko zwraca jej nazwę i argumenty w formacie JSON, a Ty wykonujesz ją w Pythonie. Opisujesz narzędzie schematem (najwygodniej wygenerowanym z modelu Pydantic), odczytujesztool_callsz odpowiedzi i odsyłasz wynik wiadomością z rolątooli właściwymtool_call_id. Zanim użyjesz argumentów, zwaliduj je w Pydantic, bo model potrafi podać pole o złej nazwie lub typie.
Model językowy sam z siebie wie tylko to, czego nauczył się podczas treningu. Nie sprawdzi dzisiejszej ceny w Twoim sklepie, nie zajrzy do bazy ani nie wyśle maila. Żeby to zmienić, dajesz mu narzędzia: funkcje z Twojego kodu, które model może „poprosić” o wywołanie. Ten mechanizm nazywa się tool calling (w starszej nomenklaturze OpenAI: function calling) i jest podstawą każdego agenta.
To pierwsza część cyklu o agentach w Pythonie. Zakładam, że umiesz już wywołać API OpenAI i policzyć tokeny, a w kolejnych częściach zbudujemy na tym pętlę agenta, a potem serwer i klienta MCP. Do opisu narzędzi użyjemy Pydantica, więc jeśli go jeszcze nie znasz, zajrzyj do tego artykułu przed lekturą.
Czym jest tool calling i kto naprawdę wykonuje funkcję?
Najważniejsza rzecz do zapamiętania: model niczego nie uruchamia. Gdy przekażesz mu opis narzędzi, może zamiast tekstu odpowiedzieć strukturą: „chcę wywołać funkcję get_price z argumentem product="klawiatura"”. Funkcję wykonujesz Ty, po swojej stronie, a wynik odsyłasz modelowi, który dopiero wtedy formułuje odpowiedź dla użytkownika.
Cały obieg wygląda tak:
| Krok | Kto działa | Co się dzieje |
|---|---|---|
| 1 | Twój kod | Wysyłasz pytanie użytkownika wraz z opisem dostępnych narzędzi. |
| 2 | Model | Zwraca tool_calls: nazwę funkcji i argumenty jako tekst JSON. |
| 3 | Twój kod | Parsujesz i sprawdzasz argumenty, a potem wykonujesz funkcję. |
| 4 | Twój kod | Odsyłasz wynik wiadomością z rolą tool. |
| 5 | Model | Na podstawie wyniku pisze końcową odpowiedź. |
Dzięki temu to Ty decydujesz, co wolno wykonać. Model może tylko o coś poprosić. Do tej zasady wrócimy w części o uprawnieniach i zatwierdzaniu akcji.
Jak opisać narzędzie schematem z Pydantic?
Model wybiera narzędzie na podstawie jego nazwy, opisu i schematu parametrów. Schemat to JSON Schema, a Pydantic potrafi wygenerować go z klasy, więc nie musisz pisać go ręcznie. Zdefiniujmy dwa proste narzędzia dla sklepu internetowego:
from pydantic import BaseModel, Field
class GetPrice(BaseModel):
"""Zwraca cenę produktu w złotych."""
product: str = Field(description="Nazwa produktu, np. 'klawiatura'")
class CheckStock(BaseModel):
"""Sprawdza, ile sztuk produktu jest w magazynie."""
product: str = Field(description="Nazwa produktu, np. 'klawiatura'")
PRICES = {"klawiatura": 249.0, "mysz": 129.0}
STOCK = {"klawiatura": 12, "mysz": 0}
def get_price(args: GetPrice) -> str:
price = PRICES.get(args.product)
return f"{price} zł" if price is not None else "Nie znaleziono produktu."
def check_stock(args: CheckStock) -> str:
return f"{STOCK.get(args.product, 0)} szt."
Teraz zamieniamy klasy na definicje w formacie, którego oczekuje API. Rejestr TOOLS_MODELS łączy nazwę narzędzia z jego modelem i funkcją, dzięki czemu później nie potrzebujesz długiego if:
TOOLS_MODELS = {
"get_price": (GetPrice, get_price),
"check_stock": (CheckStock, check_stock),
}
def to_tool_definition(name: str, model: type[BaseModel]) -> dict:
return {
"type": "function",
"function": {
"name": name,
"description": model.__doc__,
"parameters": model.model_json_schema(),
},
}
TOOLS = [to_tool_definition(name, model) for name, (model, _) in TOOLS_MODELS.items()]
Opis (description) to nie ozdoba. To jedyna wskazówka, po której model rozpozna, kiedy dane narzędzie jest właściwe, więc pisz go tak jak krótką instrukcję dla nowego współpracownika. To samo dotyczy opisów pól w Field(description=...).
Dlaczego opis narzędzia traktować jak prompt?
Traktuj description jak instrukcję, a nie komentarz do kodu. Dopisz, kiedy narzędzia nie używać (np. „nie używaj do pytań o dostawę”) i co zwraca w przypadku braku wyniku. Dwa narzędzia o podobnych opisach to najczęstsza przyczyna wyboru złego z nich.
Jak wywołać model z narzędziami i odczytać tool_calls?
Narzędzia przekazujesz w parametrze tools. Jeśli model uzna, że potrzebuje któregoś z nich, w message.tool_calls dostaniesz listę wywołań, a message.content będzie puste:
from openai import OpenAI
client = OpenAI()
messages = [{"role": "user", "content": "Ile kosztuje klawiatura i czy jest na stanie?"}]
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=TOOLS,
)
message = response.choices[0].message
for call in message.tool_calls or []:
print(call.id, call.function.name, call.function.arguments)
Każde wywołanie ma trzy ważne pola:
| Pole | Co zawiera |
|---|---|
call.id | Identyfikator wywołania, którego użyjesz przy odsyłaniu wyniku. |
call.function.name | Nazwa narzędzia, np. get_price. |
call.function.arguments | Argumenty jako tekst JSON, a nie słownik. Trzeba go sparsować. |
Jeśli model odpowiedział bez narzędzi, tool_calls ma wartość None. Dlatego w pętli stoi or [], a w prawdziwym kodzie sprawdzasz tę sytuację osobno i zwracasz message.content.
Jak zwalidować argumenty od modelu?
Argumenty przychodzą jako tekst wygenerowany przez model. Zwykle są poprawne, ale nie ma gwarancji: pole może mieć inną nazwę, zły typ albo go brakować. Zamiast ślepo robić json.loads i przekazywać wynik do funkcji, zwaliduj go tym samym modelem Pydantic, z którego powstał schemat:
from pydantic import ValidationError
def run_tool(name: str, raw_arguments: str) -> str:
if name not in TOOLS_MODELS:
return f"Błąd: nie ma narzędzia {name}."
model, function = TOOLS_MODELS[name]
try:
args = model.model_validate_json(raw_arguments)
except ValidationError as error:
return f"Błąd argumentów: {error}"
return function(args)
Zwróć uwagę, że przy błędzie nie rzucamy wyjątku, tylko zwracamy tekst z opisem problemu. Ten tekst trafi do modelu jako wynik narzędzia, więc model zobaczy, co poszło nie tak, i może spróbować ponownie z poprawnymi argumentami. Wyjątek zatrzymałby cały program, a zwrócony błąd pozwala modelowi się poprawić. W pętli agenta wykorzystamy to w następnej części cyklu.
Czy błąd narzędzia zwracać jako wynik, czy rzucać wyjątek?
Błąd walidacji lub brak danych zwracaj modelowi jako tekst, zamiast przerywać program. Model zwykle potrafi go zinterpretować i poprawić zapytanie. Wyjątek zostaw na naprawdę niespodziewane awarie (np. brak dostępu do bazy), których model naprawić nie może.
Jak odesłać wynik narzędzia do modelu?
To miejsce, w którym najczęściej pojawia się błąd 400. Reguły są dwie i obie muszą być spełnione:
- W liście
messagesmusi znaleźć się wiadomośćassistantzawierającatool_calls(czyli odpowiedź modelu, którą właśnie dostałeś). - Zaraz po niej musi pojawić się osobna wiadomość z rolą
tooldla każdegotool_call_id, z wynikiem w polucontentjako tekstem.
messages.append(message) # odpowiedź modelu z tool_calls
for call in message.tool_calls:
result = run_tool(call.function.name, call.function.arguments)
messages.append(
{
"role": "tool",
"tool_call_id": call.id,
"content": result,
}
)
final = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=TOOLS,
)
print(final.choices[0].message.content)
Jeśli którąś z tych zasad złamiesz, API odrzuci zapytanie komunikatem, że po wiadomości assistant z tool_calls muszą następować wiadomości tool odpowiadające na każdy tool_call_id. Najczęstsze przyczyny to:
| Objaw | Przyczyna | Co zrobić |
|---|---|---|
Błąd 400 o brakującej odpowiedzi na tool_call_id | Odesłałeś wynik tylko dla części wywołań albo pominąłeś wiadomość assistant z tool_calls. | Dodaj message do messages i odpowiedz na każde wywołanie. |
Błąd o niepasującym tool_call_id | Wpisałeś id ręcznie albo użyłeś id z innego zapytania. | Zawsze bierz call.id z bieżącej odpowiedzi. |
Błąd typu content | Przekazałeś słownik, liczbę lub None. | Zamień wynik na tekst, np. json.dumps(wynik, ensure_ascii=False). |
| Model powtarza to samo wywołanie | W historii brakuje wyniku poprzedniego wywołania. | Sprawdź, czy wiadomość tool trafiła do messages przed kolejnym zapytaniem. |
Pamiętaj też, że API nie ma pamięci. Wiadomości assistant i tool dopisujesz do tej samej listy, którą wysyłasz za każdym razem, więc historia rośnie i zjada okno kontekstu, o czym była mowa w artykule o tokenach i kosztach.
Co się dzieje, gdy model wywoła kilka narzędzi naraz?
W przykładzie z pytaniem „Ile kosztuje klawiatura i czy jest na stanie?” model może poprosić o get_price i check_stock w jednej odpowiedzi. Domyślnie API na to pozwala, więc tool_calls zawiera wtedy dwa elementy i musisz odpowiedzieć na oba, każdy własnym tool_call_id. Pętla for z poprzedniej sekcji już to robi.
Jeśli Twoje narzędzia nie mogą działać równolegle, na przykład zapisują do tego samego pliku, wyłącz to parametrem parallel_tool_calls=False. Wtedy model zgłosi najwyżej jedno wywołanie na odpowiedź.
Jak wymusić albo zablokować użycie narzędzia?
Domyślnie model sam decyduje, czy narzędzie jest potrzebne. Parametr tool_choice pozwala to zmienić:
Wartość tool_choice | Efekt |
|---|---|
"auto" (domyślnie) | Model sam wybiera: odpowiada tekstem albo wywołuje narzędzie. |
"none" | Model nie wywoła żadnego narzędzia, nawet jeśli je przekazałeś. |
"required" | Model musi wywołać co najmniej jedno narzędzie. |
{"type": "function", "function": {"name": "get_price"}} | Model musi wywołać dokładnie to narzędzie. |
"none" przydaje się na przykład w ostatnim kroku rozmowy, gdy chcesz już tylko tekstowej odpowiedzi. Wymuszanie konkretnego narzędzia sprawdza się, gdy używasz tool callingu do wyciągania danych o znanej strukturze, a nie do prowadzenia rozmowy.
Dlaczego model czasem nie wywołuje mojego narzędzia?
Najczęściej winny jest opis, a nie kod. Sprawdź kolejno:
- Czy opis mówi, do czego służy narzędzie? „Pobiera dane” nic nie znaczy. „Zwraca cenę produktu w złotych na podstawie jego nazwy” już tak.
- Czy opisy pól są konkretne? Przykład wartości (
np. 'klawiatura') pomaga bardziej niż samostring. - Czy narzędzi nie jest za dużo? Im dłuższa lista, tym łatwiej o pomyłkę. Przekazuj tylko te, które mają sens w danym kroku.
- Czy pytanie rzeczywiście wymaga narzędzia? Jeśli model zna odpowiedź z treningu, może jej udzielić bez wywołania. Wtedy pomaga
tool_choice="required"albo wskazówka w wiadomościsystem.
Warto też wiedzieć, że OpenAI oferuje nowsze Responses API, które ma analogiczny mechanizm z innymi nazwami pól. W tym cyklu trzymamy się chat.completions, spójnie z poprzednimi artykułami, bo zasady działania są te same.
Co dalej?
Masz już jeden obieg: pytanie, wywołanie narzędzia, wynik, odpowiedź. Agent to ten sam mechanizm powtarzany w pętli, aż model uzna, że ma wystarczająco dużo informacji. W kolejnej części napiszemy pętlę agenta w Pythonie od zera, razem z limitem kroków i kosztów, który chroni przed nieskończonym wywoływaniem narzędzi. Jeśli chcesz mieć kod, który łatwo przetestować bez prawdziwego API, zobacz też testowanie kodu z API LLM, a o walidacji danych w aplikacjach webowych przeczytasz w artykule o Pydantic w FastAPI.


