Pętla agenta (ang. agent loop) toforalbowhile, w którym wywołujesz model z narzędziami, wykonujesz zwróconetool_calls, dopisujesz wyniki do historii i powtarzasz, aż model odpowie tekstem zamiast prosić o kolejne narzędzie. Każda taka pętla potrzebuje twardych limitów: maksymalnej liczby kroków i budżetu tokenów, bo historia rośnie z każdym krokiem. Po przekroczeniu limitu zakończ pętlę kontrolowanie, na przykład ostatnim wywołaniem ztool_choice="none".
W części o tool callingu w OpenAI API model poprosił o narzędzia, my je wykonaliśmy, a on napisał odpowiedź. To jeden obieg. Problem w tym, że prawdziwe pytania często wymagają kilku kroków: najpierw sprawdzasz jedną rzecz, potem na jej podstawie drugą. Agent to dokładnie ten sam mechanizm powtarzany w pętli, aż model uzna, że ma komplet informacji. W angielskojęzycznej dokumentacji nazywa się to agent loop albo agentic loop.
Pętla jest prosta do napisania i łatwa do napisania źle. Bez limitów jeden błąd w opisie narzędzia potrafi zakończyć się dziesiątkami zapytań i zużytym budżetem. Dlatego większość tego artykułu dotyczy nie samej pętli, tylko tego, kiedy i jak ją zatrzymać. Korzystamy z narzędzi get_price i check_stock oraz funkcji run_tool z poprzedniej części, więc zacznij od niej.
Czym pętla agenta (agent loop) różni się od jednego wywołania z narzędziem?
W jednym wywołaniu z narzędziem to Ty z góry wiesz, że będą dwa zapytania do modelu: pierwsze z pytaniem, drugie z wynikiem. W agencie liczbę kroków wyznacza model. Raz wystarczy jedno narzędzie, innym razem trzy po kolei, bo wynik pierwszego wpływa na to, o co zapyta drugi.
| Cecha | Jedno wywołanie z narzędziem | Pętla agenta |
|---|---|---|
| Liczba zapytań do modelu | Stała, zwykle dwa | Zmienna, zależy od modelu |
| Kto decyduje o zakończeniu | Twój kod | Model (albo limit) |
| Ryzyko zapętlenia | Brak | Realne, potrzebne limity |
| Koszt | Łatwy do oszacowania | Trzeba go ograniczać |
Jak wygląda pętla agenta w Pythonie?
Poniżej kompletna pętla. Ma prosty kształt: w każdym kroku pytasz model, a jeśli nie poprosił o narzędzia, to znaczy, że skończył. W przeciwnym razie wykonujesz narzędzia i wracasz na początek.
from dataclasses import dataclass
from openai import OpenAI
SYSTEM_PROMPT = (
"Jesteś asystentem sklepu. Używaj narzędzi, żeby sprawdzać ceny i stany magazynowe. "
"Nie zgaduj. Gdy masz komplet informacji, odpowiedz użytkownikowi po polsku."
)
@dataclass
class AgentLimits:
max_steps: int = 6
max_tokens: int = 20_000
@dataclass
class AgentResult:
answer: str
steps: int
tokens: int
stop_reason: str # "done", "max_steps" albo "max_tokens"
def run_agent(
client: OpenAI,
question: str,
limits: AgentLimits = AgentLimits(),
model: str = "gpt-4o-mini",
) -> AgentResult:
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": question},
]
tokens_used = 0
seen_calls: set[tuple[str, str]] = set()
for step in range(1, limits.max_steps + 1):
if tokens_used >= limits.max_tokens:
return finish(client, messages, model, step - 1, tokens_used, "max_tokens")
response = client.chat.completions.create(
model=model, messages=messages, tools=TOOLS
)
tokens_used += response.usage.total_tokens
message = response.choices[0].message
if not message.tool_calls:
return AgentResult(message.content, step, tokens_used, "done")
messages.append(message)
for call in message.tool_calls:
key = (call.function.name, call.function.arguments)
if key in seen_calls:
result = "To wywołanie już było. Użyj innych argumentów albo odpowiedz użytkownikowi."
else:
seen_calls.add(key)
result = run_tool(call.function.name, call.function.arguments)
messages.append(
{"role": "tool", "tool_call_id": call.id, "content": result}
)
return finish(client, messages, model, limits.max_steps, tokens_used, "max_steps")
TOOLS i run_tool pochodzą z poprzedniej części. Funkcja finish jest opisana niżej. Zwróć uwagę, że wynik to nie sam tekst, tylko obiekt AgentResult z liczbą kroków, zużyciem tokenów i powodem zatrzymania. Dzięki temu wiesz nie tylko, co agent odpowiedział, ale też dlaczego skończył.
Kiedy pętla agenta powinna się zatrzymać?
Warunków zakończenia jest kilka i każdy z nich jest potrzebny:
| Powód zatrzymania | Jak go wykryć | Co zrobić |
|---|---|---|
| Model skończył | Odpowiedź bez tool_calls | Zwróć message.content. To normalne zakończenie. |
| Za dużo kroków | Licznik pętli dobił do max_steps | Zakończ kontrolowanie (patrz niżej). |
| Za dużo tokenów | Suma z response.usage przekroczyła budżet | Zakończ kontrolowanie, zanim wydasz więcej. |
| Zapętlenie | Te same narzędzie i argumenty po raz drugi | Nie wykonuj ponownie, podpowiedz modelowi, żeby zmienił podejście. |
Bez żadnego z tych zabezpieczeń agent, któremu narzędzie ciągle zwraca „nie znaleziono”, może w nieskończoność próbować tego samego.
Dlaczego limit tokenów jest równie ważny jak limit kroków?
Bo koszt kroku nie jest stały. API nie ma pamięci, więc w każdym kroku wysyłasz całą dotychczasową historię: wiadomość systemową, pytanie, wszystkie poprzednie wywołania i ich wyniki. Im dalej w pętli, tym większe zapytanie. Pokazuje to prosty rachunek na przykładowych liczbach: wiadomość systemowa i pytanie zajmują razem 300 tokenów, a każdy krok dokłada 250 tokenów (wywołanie i wynik).
| Krok | Tokenów wysyłanych do modelu |
|---|---|
| 1 | 300 |
| 2 | 550 |
| 3 | 800 |
| 4 | 1050 |
| 5 | 1300 |
| 6 | 1550 |
| Razem | 5550 |
Gdyby historia nie rosła, sześć kroków kosztowałoby 1800 tokenów wejściowych. Przy rosnącej historii to 5550, czyli trzy razy więcej. Dłuższe pętle drożeją więc szybciej niż liniowo. Dlatego sumujemy response.usage.total_tokens po każdym kroku i przerywamy po przekroczeniu budżetu. Jak działa okno kontekstu i skąd te liczby, wyjaśniamy w artykule o tokenach, kontekście i kosztach w API OpenAI.
Jaki limit kroków ustawić na start?
Zacznij od max_steps=4 albo 5 i podnoś go dopiero wtedy, gdy realne pytania tego wymagają. Prosty agent z dwoma narzędziami rzadko potrzebuje więcej niż kilku kroków, a niski limit szybciej ujawnia błędy w opisach narzędzi.
Co zrobić, gdy limit zostanie przekroczony?
Najgorsze, co możesz zrobić, to rzucić wyjątek i pokazać użytkownikowi pustą stronę albo błąd. Lepszy sposób to kontrolowane zakończenie: zrób jeszcze jedno wywołanie z tool_choice="none", które zabrania modelowi używać narzędzi i zmusza go do napisania odpowiedzi na podstawie tego, co już wie.
def finish(client, messages, model, steps, tokens_used, reason) -> AgentResult:
messages.append(
{
"role": "user",
"content": "Limit kroków wyczerpany. Odpowiedz na podstawie tego, co wiesz.",
}
)
response = client.chat.completions.create(
model=model, messages=messages, tools=TOOLS, tool_choice="none"
)
tokens_used += response.usage.total_tokens
return AgentResult(response.choices[0].message.content, steps, tokens_used, reason)
Użytkownik dostaje częściową, ale sensowną odpowiedź, a Ty w polu stop_reason widzisz, że agent nie skończył sam. Pamiętaj, że to wywołanie też kosztuje, więc budżet tokenów traktuj jako limit zasadniczy, nie co do jednego tokenu.
Dlaczego warto zwracać powód zatrzymania, a nie tylko odpowiedź?
Loguj stop_reason przy każdym uruchomieniu. Jeśli duża część odpowiedzi kończy się na max_steps, to sygnał, że opis narzędzia jest niejasny albo pytania są za szerokie, a nie, że trzeba podnieść limit. Podnoszenie limitu bez zrozumienia przyczyny tylko zwiększa rachunek.
Jak wykryć agenta, który kręci się w kółko?
W kodzie zapisujemy w zbiorze seen_calls pary (nazwa narzędzia, argumenty). Jeśli model prosi o dokładnie to samo po raz drugi, nie wykonujemy narzędzia ponownie, tylko odsyłamy komunikat z podpowiedzią. Model dostaje informację, że ten ślad już sprawdził, i zwykle zmienia podejście albo kończy odpowiedzią.
To proste zabezpieczenie, które nie zastępuje limitu kroków. Zapętlenie bywa też subtelniejsze: model może prosić o to samo z minimalnie innymi argumentami. Wtedy łapie je dopiero max_steps.
Co z błędami narzędzi i timeoutami?
Błędy dzielą się na dwa rodzaje i każdy obsługujemy inaczej:
| Rodzaj błędu | Przykład | Obsługa |
|---|---|---|
| Błąd, który model może naprawić | Złe argumenty, nieznany produkt | Zwróć tekst jako wynik narzędzia (robi to run_tool). |
| Błąd, którego model nie naprawi | Brak połączenia z bazą, wyjątek w kodzie | Przerwij pętlę, zaloguj błąd i pokaż użytkownikowi komunikat. |
Dla wywołań samego API ustaw timeout i liczbę ponowień w kliencie, żeby jedno zawieszone zapytanie nie blokowało całego agenta:
client = OpenAI(timeout=30, max_retries=2)
Ponowienia przy błędach sieciowych klient robi sam, więc w pętli nie musisz pisać własnej obsługi.
Jak przetestować pętlę agenta bez prawdziwego API?
Pętla ma ciekawą właściwość: jej zachowanie zależy od odpowiedzi modelu, a te możesz podstawić. W testach zastąp klienta atrapą, która zwraca z góry ustalone odpowiedzi: raz wywołanie narzędzia, raz tekst. Dzięki temu sprawdzisz bez kosztów i bez losowości, czy:
- agent kończy po normalnym zakończeniu modelu,
- po przekroczeniu
max_stepszwracastop_reason == "max_steps"i wykonuje ostatnie wywołanie ztool_choice="none", - powtórzone wywołanie nie uruchamia narzędzia po raz drugi,
- budżet tokenów faktycznie przerywa pętlę.
Jak zbudować taką atrapę i użyć unittest.mock albo pytesta, pokazujemy w artykule o testowaniu kodu z API LLM.
Co dalej?
Masz działającą pętlę z limitami. W następnej części zbudujemy agenta z narzędziami wyszukiwania i odczytu dokumentów, czyli małego asystenta, który sam wybiera, gdzie szukać odpowiedzi. Jeśli interesuje Cię, jak wyszukiwanie po znaczeniu działa pod spodem, zajrzyj do artykułu o wyszukiwarce semantycznej w Pythonie.


