Kurs Python · Moduł 10: AI i modele językowe

ReAct Agents - Reasoning and Acting

11 min czytania
W tej lekcji7

Witaj! Darwin tutaj. Zapytaj zwykły model: "Jaka jest pogoda w Serengeti i jakie zwierzęta tam żyją?". Odpowie gładko, ale pogodę zmyśli, bo nie ma dostępu do żadnej stacji meteo. W poprzednich lekcjach daliśmy modelowi narzędzia. Teraz nauczymy go z nich korzystać krok po kroku, jak tropiciel, który najpierw myśli, potem idzie po śladach, a na końcu ocenia, co znalazł.

ReAct (Reasoning and Acting) to wzorzec projektowy dla agentów AI, który łączy rozumowanie z podejmowaniem akcji. Agent na przemian "myśli" (reasoning) i "działa" (acting), co prowadzi do lepszych rezultatów! Wzorzec opisali badacze z Princeton i Google w pracy "ReAct: Synergizing Reasoning and Acting in Language Models" z 2022 roku.

Czym jest ReAct?

ReAct to paradygmat, w którym agent AI:

  1. Thought - analizuje sytuację, planuje
  2. Action - wykonuje akcję (np. wywołuje narzędzie)
  3. Observation - obserwuje wynik akcji
  4. Repeat - powtarza cykl aż do rozwiązania

Pętla kręci się, aż agent uzna, że ma dość informacji. Na dole schematu widać zapis jednej wyprawy:

1┌─────────────────────────────────────────────────────────┐
2│                    ReAct Loop                           │
3├─────────────────────────────────────────────────────────┤
4│                                                         │
5│  ┌──────────┐    ┌──────────┐    ┌─────────────┐       │
6│  │ Thought  │───▶│  Action  │───▶│ Observation │       │
7│  │ (Myśl)   │    │ (Działaj)│    │ (Obserwuj)  │       │
8│  └──────────┘    └──────────┘    └──────┬──────┘       │
9│       ▲                                  │              │
10│       └──────────────────────────────────┘              │
11│                    (Repeat)                             │
12│                                                         │
13│  Przykład:                                              │
14│  Thought: Muszę sprawdzić pogodę w Serengeti           │
15│  Action: get_weather("Serengeti")                       │
16│  Observation: Temperatura 28°C, słonecznie              │
17│  Thought: Teraz mogę odpowiedzieć użytkownikowi        │
18│  Action: final_answer("Pogoda w Serengeti...")          │
19│                                                         │
20└─────────────────────────────────────────────────────────┘

Kluczowa jest Observation: model nie zgaduje wyniku akcji, tylko dostaje prawdziwą odpowiedź narzędzia.

Podstawowa implementacja ReAct

Klasa ReActAgent przechowuje słownik narzędzi, limit iteracji i historię kroków. System prompt wstawia opisy narzędzi, biorąc je z docstringów funkcji (func.__doc__), i uczy model formatu Thought/Action/Answer:

1from openai import OpenAI
2from typing import Callable
3import json
4import re
5
6client = OpenAI()
7
8class ReActAgent:
9    """Agent implementujący wzorzec ReAct."""
10
11    def __init__(self, tools: dict[str, Callable], max_iterations: int = 10):
12        self.tools = tools
13        self.max_iterations = max_iterations
14        self.history: list[str] = []
15
16    def _create_system_prompt(self) -> str:
17        """Tworzy system prompt z opisem narzędzi."""
18        tool_descriptions = "\n".join([
19            f"- {name}: {func.__doc__}"
20            for name, func in self.tools.items()
21        ])
22
23        return f"""Jesteś pomocnym asystentem Safari, który rozwiązuje problemy krok po kroku.
24
25Dostępne narzędzia:
26{tool_descriptions}
27
28Używaj formatu:
29Thought: [twoje rozumowanie o tym, co robić dalej]
30Action: [nazwa_narzędzia(argumenty)]
31
32Gdy masz wystarczające informacje, aby odpowiedzieć:
33Thought: [rozumowanie końcowe]
34Answer: [twoja odpowiedź dla użytkownika]
35
36Zawsze myśl przed działaniem. Analizuj obserwacje zanim podejmiesz kolejny krok."""

Limit max_iterations to bezpiecznik: bez niego agent, który się zapętli, zużywałby tokeny bez końca.

Parser wyciąga trzy fragmenty wyrażeniami regularnymi z modułu re. Flaga re.DOTALL pozwala kropce obejmować także znaki nowej linii:

1    def _parse_response(self, response: str) -> tuple[str, str, str]:
2        """Parsuje odpowiedź na thought, action i answer."""
3        thought_match = re.search(r'Thought:\s*(.+?)(?=Action:|Answer:|$)', response, re.DOTALL)
4        action_match = re.search(r'Action:\s*(.+?)(?=Thought:|Observation:|Answer:|$)', response, re.DOTALL)
5        answer_match = re.search(r'Answer:\s*(.+)', response, re.DOTALL)
6
7        thought = thought_match.group(1).strip() if thought_match else ""
8        action = action_match.group(1).strip() if action_match else ""
9        answer = answer_match.group(1).strip() if answer_match else ""
10
11        return thought, action, answer

Brak dopasowania nie rzuca wyjątku, tylko zwraca pusty napis.

Wykonanie akcji zamienia tekst w stylu get_weather("Serengeti") na wywołanie prawdziwej funkcji:

1    def _execute_action(self, action_str: str) -> str:
2        """Wykonuje akcję i zwraca obserwację."""
3        # Parsuj nazwę funkcji i argumenty
4        match = re.match(r'(\w+)\((.*)\)', action_str)
5        if not match:
6            return f"Błąd: Nieprawidłowy format akcji: {action_str}"
7
8        func_name = match.group(1)
9        args_str = match.group(2)
10
11        if func_name not in self.tools:
12            return f"Błąd: Nieznane narzędzie: {func_name}"
13
14        try:
15            # Proste parsowanie argumentów
16            if args_str:
17                args = [arg.strip().strip('"').strip("'") for arg in args_str.split(',')]
18                result = self.tools[func_name](*args)
19            else:
20                result = self.tools[func_name]()
21            return str(result)
22        except Exception as e:
23            return f"Błąd wykonania: {str(e)}"

Każdy błąd wraca do modelu jako tekst obserwacji, zamiast przerywać program. Parsowanie argumentów przez split(',') jest celowo proste i zepsuje się na przecinku wewnątrz napisu - w produkcji lepiej użyć natywnego function calling, który pokażę niżej.

Metoda run spina wszystko w pętlę:

1    def run(self, query: str) -> str:
2        """Uruchamia agenta ReAct."""
3        self.history = [f"User: {query}"]
4
5        for i in range(self.max_iterations):
6            # Buduj kontekst
7            context = "\n".join(self.history)
8
9            # Wywołaj LLM
10            response = client.chat.completions.create(
11                model="gpt-4.1-mini",
12                messages=[
13                    {"role": "system", "content": self._create_system_prompt()},
14                    {"role": "user", "content": context}
15                ],
16                temperature=0
17            )
18
19            llm_response = response.choices[0].message.content
20            thought, action, answer = self._parse_response(llm_response)
21
22            # Zapisz thought
23            if thought:
24                self.history.append(f"Thought: {thought}")
25                print(f"Thought: {thought}")
26
27            # Jeśli jest odpowiedź końcowa
28            if answer:
29                print(f"Answer: {answer}")
30                return answer
31
32            # Wykonaj akcję
33            if action:
34                self.history.append(f"Action: {action}")
35                print(f"Action: {action}")
36
37                observation = self._execute_action(action)
38                self.history.append(f"Observation: {observation}")
39                print(f"Observation: {observation}")
40
41        return "Przekroczono limit iteracji bez znalezienia odpowiedzi."

Każdy obrót dopisuje myśl, akcję i obserwację do history, a przy następnym wywołaniu model dostaje całą historię. temperature=0 daje możliwie powtarzalne decyzje, dlatego wybrałem gpt-4.1-mini - modele rozumujące, takie jak gpt-5-mini, przyjmują tylko domyślną temperaturę.

Przykład użycia ReAct

Narzędzia to zwykłe funkcje z docstringami. Tu zwracają dane na sztywno, w prawdziwej aplikacji wywołałyby API pogodowe czy bazę gatunków:

1# Definicja narzędzi
2def get_weather(location: str) -> str:
3    """Pobiera aktualną pogodę dla danej lokalizacji."""
4    weather_data = {
5        "Serengeti": "28°C, słonecznie, wilgotność 45%",
6        "Kilimandżaro": "5°C, pochmurnie, śnieg na szczycie",
7        "Nairobi": "22°C, częściowe zachmurzenie"
8    }
9    return weather_data.get(location, f"Brak danych pogodowych dla {location}")
10
11def search_animals(query: str) -> str:
12    """Wyszukuje informacje o zwierzętach Safari."""
13    animals = {
14        "lew": "Panthera leo - największy kot Afryki, żyje w stadach",
15        "słoń": "Loxodonta africana - największe zwierzę lądowe, żyje 60-70 lat",
16        "żyrafa": "Giraffa camelopardalis - najwyższe zwierzę świata, do 5.5m wysokości"
17    }
18    for key, value in animals.items():
19        if key in query.lower():
20            return value
21    return f"Nie znaleziono informacji o: {query}"
22
23def calculate_distance(from_loc: str, to_loc: str) -> str:
24    """Oblicza odległość między lokalizacjami Safari."""
25    distances = {
26        ("Nairobi", "Serengeti"): 350,
27        ("Serengeti", "Kilimandżaro"): 280,
28        ("Nairobi", "Kilimandżaro"): 200
29    }
30    key = (from_loc, to_loc)
31    reverse_key = (to_loc, from_loc)
32
33    if key in distances:
34        return f"{distances[key]} km"
35    elif reverse_key in distances:
36        return f"{distances[reverse_key]} km"
37    return f"Nieznana odległość między {from_loc} a {to_loc}"

Docstring nie jest ozdobą: trafia do system promptu i to na jego podstawie model decyduje, którego narzędzia użyć.

Teraz rejestrujemy narzędzia i wysyłamy agenta w teren:

1# Utworzenie agenta
2tools = {
3    "get_weather": get_weather,
4    "search_animals": search_animals,
5    "calculate_distance": calculate_distance
6}
7
8agent = ReActAgent(tools=tools)
9
10# Uruchomienie
11result = agent.run("Jaka jest pogoda w Serengeti i jakie zwierzęta tam żyją?")
12print(f"\nKońcowa odpowiedź: {result}")

Na to pytanie agent zwykle wykona dwie akcje - get_weather i search_animals - zanim zwróci Answer.

ReAct z LangChain

LangChain ma gotowego agenta ReAct, więc pętli nie piszesz sam: dekorator @tool zamienia funkcję w narzędzie, a opis bierze z docstringu:

1from langchain.agents import create_react_agent, AgentExecutor
2from langchain_openai import ChatOpenAI
3from langchain.tools import tool
4from langchain import hub
5
6# Narzędzia jako tools LangChain
7@tool
8def safari_weather(location: str) -> str:
9    """Pobiera pogodę dla lokalizacji na Safari."""
10    return f"Pogoda w {location}: 26°C, słonecznie"
11
12@tool
13def animal_info(animal_name: str) -> str:
14    """Wyszukuje informacje o zwierzęciu Safari."""
15    return f"Informacje o {animal_name}: Wspaniałe zwierzę Afryki!"
16
17@tool
18def safari_distance(from_location: str, to_location: str) -> str:
19    """Oblicza odległość między punktami Safari."""
20    return f"Odległość z {from_location} do {to_location}: 150 km"

Teraz łączymy model, gotowy prompt ReAct z LangChain Hub i narzędzia, a AgentExecutor prowadzi całą pętlę:

1# LLM i prompt
2llm = ChatOpenAI(model="gpt-4.1-mini", temperature=0)
3prompt = hub.pull("hwchase17/react")
4
5# Agent ReAct
6tools = [safari_weather, animal_info, safari_distance]
7agent = create_react_agent(llm, tools, prompt)
8
9# Executor
10agent_executor = AgentExecutor(
11    agent=agent,
12    tools=tools,
13    verbose=True,
14    handle_parsing_errors=True,
15    max_iterations=10
16)
17
18# Uruchomienie
19result = agent_executor.invoke({
20    "input": "Sprawdź pogodę w Serengeti i powiedz mi o lwach"
21})
22print(result["output"])

handle_parsing_errors=True odsyła modelowi błąd formatu, zamiast przerywać działanie, a verbose=True wypisuje każdy krok. Uwaga na wersje: ten kod działa w LangChain 0.3. W LangChain 1.0 create_react_agent, AgentExecutor i hub przeniesiono do pakietu langchain-classic (importujesz je z langchain_classic), a zalecanym następcą jest create_agent z langchain.agents.

ReAct z OpenAI Function Calling

Nowoczesne modele mają wbudowane wywoływanie narzędzi, więc nie trzeba parsować tekstu. Narzędzie opisujemy schematem JSON, a funkcja wykonawcza dostaje gotowe argumenty:

1from openai import OpenAI
2import json
3
4client = OpenAI()
5
6# Definicja tools dla OpenAI
7tools = [
8    {
9        "type": "function",
10        "function": {
11            "name": "get_safari_info",
12            "description": "Pobiera informacje o Safari",
13            "parameters": {
14                "type": "object",
15                "properties": {
16                    "topic": {
17                        "type": "string",
18                        "description": "Temat: weather, animals, locations"
19                    },
20                    "query": {
21                        "type": "string",
22                        "description": "Szczegółowe zapytanie"
23                    }
24                },
25                "required": ["topic", "query"]
26            }
27        }
28    }
29]
30
31def execute_safari_tool(topic: str, query: str) -> str:
32    """Wykonuje narzędzie Safari."""
33    if topic == "weather":
34        return f"Pogoda dla {query}: 28°C, słonecznie"
35    elif topic == "animals":
36        return f"Informacje o {query}: Fascynujące zwierzę Safari!"
37    elif topic == "locations":
38        return f"Lokalizacja {query}: Popularne miejsce na Safari"
39    return "Nieznany temat"

Pole description pełni tę samą rolę co docstring, a required mówi, których argumentów model nie może pominąć.

Pętla wygląda znajomo, tylko zamiast wyrażeń regularnych czytamy message.tool_calls:

1def react_with_functions(query: str) -> str:
2    """ReAct używając OpenAI function calling."""
3    messages = [
4        {
5            "role": "system",
6            "content": "Jesteś ekspertem Safari. Odpowiadaj krok po kroku, używając dostępnych narzędzi."
7        },
8        {"role": "user", "content": query}
9    ]
10
11    for _ in range(5):  # Max iterations
12        response = client.chat.completions.create(
13            model="gpt-5-mini",
14            messages=messages,
15            tools=tools,
16            tool_choice="auto"
17        )
18
19        message = response.choices[0].message
20        messages.append(message)
21
22        # Sprawdź czy są tool calls
23        if message.tool_calls:
24            for tool_call in message.tool_calls:
25                args = json.loads(tool_call.function.arguments)
26                result = execute_safari_tool(**args)
27
28                messages.append({
29                    "role": "tool",
30                    "tool_call_id": tool_call.id,
31                    "content": result
32                })
33        else:
34            # Brak tool calls = finalna odpowiedź
35            return message.content
36
37    return "Przekroczono limit iteracji"
38
39# Użycie
40answer = react_with_functions("Jaka pogoda panuje w Serengeti?")
41print(answer)

Odpowiedź modelu z tool_calls trzeba dopisać do messages przed wynikami, a każdy wynik łączy się z wywołaniem przez tool_call_id. tool_choice="auto" pozwala modelowi zdecydować, czy potrzebuje narzędzia. Myśl nie jest tu wypisywana jako Thought, ale cykl akcja i obserwacja pozostaje ten sam. Do nowych projektów polecam właśnie tę wersję, bo jest najodporniejsza na błędy formatu.

Zaawansowany ReAct z pamięcią

Agent może też oceniać własne postępy. Najpierw struktura na jeden krok - @dataclass generuje konstruktor, a field(default_factory=datetime.now) wstawia czas utworzenia:

1from dataclasses import dataclass, field
2from typing import Optional
3from datetime import datetime
4
5@dataclass
6class ThoughtStep:
7    """Krok rozumowania agenta."""
8    thought: str
9    action: Optional[str] = None
10    observation: Optional[str] = None
11    timestamp: datetime = field(default_factory=datetime.now)

Optional[str] oznacza, że akcja i obserwacja mogą być puste, na przykład przy ostatniej myśli.

Agent z refleksją co kilka kroków prosi model o ocenę dotychczasowej trasy:

1class AdvancedReActAgent:
2    """Zaawansowany agent ReAct z pamięcią i refleksją."""
3
4    def __init__(self, tools: dict, model: str = "gpt-5-mini"):
5        self.tools = tools
6        self.model = model
7        self.client = OpenAI()
8        self.thought_history: list[ThoughtStep] = []
9        self.long_term_memory: list[str] = []
10
11    def reflect(self) -> str:
12        """Refleksja nad dotychczasowymi krokami."""
13        if len(self.thought_history) < 2:
14            return ""
15
16        steps_summary = "\n".join([
17            f"- Thought: {s.thought}, Action: {s.action}, Result: {s.observation}"
18            for s in self.thought_history[-3:]
19        ])
20
21        response = self.client.chat.completions.create(
22            model=self.model,
23            messages=[
24                {
25                    "role": "system",
26                    "content": "Przeanalizuj dotychczasowe kroki i zasugeruj ulepszenia."
27                },
28                {
29                    "role": "user",
30                    "content": f"Dotychczasowe kroki:\n{steps_summary}"
31                }
32            ],
33            max_completion_tokens=1000
34        )
35
36        return response.choices[0].message.content
37
38    def should_reflect(self) -> bool:
39        """Decyduje czy agent powinien przeprowadzić refleksję."""
40        # Refleksja co 3 kroki lub gdy ostatnia akcja nie powiodła się
41        if len(self.thought_history) % 3 == 0 and len(self.thought_history) > 0:
42            return True
43        if self.thought_history and "błąd" in (self.thought_history[-1].observation or "").lower():
44            return True
45        return False
46
47    def run(self, query: str) -> str:
48        """Uruchamia agenta z refleksją."""
49        # ... implementacja podobna do podstawowej wersji
50        # ale z dodaną refleksją
51
52        for i in range(10):
53            if self.should_reflect():
54                reflection = self.reflect()
55                print(f"Refleksja: {reflection}")
56
57            # ... reszta logiki ReAct
58
59        return "Odpowiedź"

Metoda run to szkielet do uzupełnienia w ćwiczeniu. W reflect użyłem max_completion_tokens: modele rozumujące nie przyjmują starszego max_tokens, a w nowym limicie mieszczą się też ukryte tokeny rozumowania, więc nie ustawiaj go zbyt nisko.

Wzorce ReAct

Podstawową pętlę rozbudowuje się na kilka sposobów. Docstring na górze to mapa wariantów, a klasa pod nim szkicuje wariant z planowaniem:

1"""
2Popularne wzorce używane w agentach ReAct:
3
41. BASIC ReAct
5   Thought -> Action -> Observation -> Repeat
6
72. ReAct with Reflection
8   Thought -> Action -> Observation -> Reflect -> Repeat
9
103. ReAct with Planning
11   Plan -> Thought -> Action -> Observation -> Replan -> Repeat
12
134. ReAct with Self-Consistency
14   Multiple ReAct traces -> Vote on best answer
15
165. ReAct with Retrieval (RAG-ReAct)
17   Thought -> Retrieve -> Augment -> Action -> Observation
18"""
19
20# Przykład ReAct with Planning
21class PlanningReActAgent:
22    def __init__(self, tools: dict):
23        self.tools = tools
24        self.plan: list[str] = []
25        self.current_step = 0
26
27    def create_plan(self, query: str) -> list[str]:
28        """Tworzy plan działania przed rozpoczęciem."""
29        # LLM tworzy plan kroków
30        response = client.chat.completions.create(
31            model="gpt-5-mini",
32            messages=[
33                {
34                    "role": "system",
35                    "content": "Stwórz plan kroków do rozwiązania zadania. Każdy krok w nowej linii."
36                },
37                {"role": "user", "content": query}
38            ]
39        )
40        plan = response.choices[0].message.content.split("\n")
41        return [step.strip() for step in plan if step.strip()]
42
43    def replan(self, remaining_steps: list[str], new_info: str) -> list[str]:
44        """Aktualizuje plan na podstawie nowych informacji."""
45        # LLM może zmodyfikować pozostałe kroki
46        return remaining_steps  # lub zmodyfikowane kroki

Self-consistency uruchamia kilka niezależnych przebiegów i wybiera odpowiedź, na którą wskazuje większość. Moja rada: zaczynaj od podstawowego ReAct i dokładaj warianty dopiero wtedy, gdy widzisz konkretny problem.

ReAct to potężny wzorzec dla agentów AI. Łączy rozumowanie z działaniem, co prowadzi do bardziej przemyślanych i dokładnych odpowiedzi. W następnym module poznasz jeszcze bardziej zaawansowane techniki - RAG i systemy Multi-Agent!

Pamiętaj: dobry agent jak dobry tropiciel - najpierw myśli, potem sprawdza ślady, a odpowiada dopiero wtedy, gdy zobaczył je na własne oczy.

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Czym jest wzorzec ReAct w kontekście agentów AI?

Zadania praktyczne w grze

  • Układanie w pionie

    Ułóż kroki pętli ReAct w prawidłowej kolejności:

  • Układanie w pionie

    Ułóż kroki pętli ReAct w prawidłowej kolejności:

  • Edytor kodu

    Stwórz klasę ReActAgent z metodami run() i execute_action().

  • Klikanie w kolejności

    Kliknij w przyciski, aby stworzyć agenta ReAct z LangChain:

  • Układanie w pionie

    Ułóż kroki ReAct z refleksją:

  • Edytor kodu

    Zaimplementuj agenta z narzędziami: get_weather, search_animals, calculate_distance.

Przydatne artykuły