Lokalny agent AI z Ollamą: wąskie narzędzia, weryfikacja i ograniczenia liczby kroków
Lokalny agent AI w Ollama, który charakteryzuje się wąskimi listami dozwolonych narzędzi, ograniczoną liczbą iteracji, strukturyzowanym wyjściem w formacie Pydantic, wyraźnym stanem oraz zestawem testowym przypadków niepowodzeń.
Źródła: I Built a Local AI Agent With Ollama — and the Hard Part Wasn’t the Model
Pierwsza wersja lokalnego agenta do faktur wygląda imponująco przez około pięć minut. Zadanie jest proste: pomóc właścicielowi małego biznesu w dochodzeniu w sprawie przeterminowanych faktur. Agent może wyszukać klientów, znaleźć nieopłacone faktury, określić, o ile one są przeterminowane, i przygotować przypomnienie. W próbnym zapytaniu poproszono go o znalezienie przeterminowanych faktur dla Acme Plumbing oraz przygotowanie przyjaznego przypomnienia. Odpowiada natychmiast, podając fakturę, saldę oraz dopracowany e-mail.
Problem polega na tym, że część informacji jest sfabrykowana. Klient istnieje, ale numer faktury nie. Kwota wydaje się prawdopodobna, ale jest błędna. Agent stworzył pozory udanego rezultatu, nie wykonując żadnej pracy.
Ten odcinek uczy najważniejszej lekcji dotyczącej tworzenia agentów: brzmiące inteligentnie odpowiedzi nie oznaczają niezawodności. Agent jest niezawodny, gdy każde istotne twierdzenie ma swoje źródło w kontrolowanej akcji lub wiarygodnym źle. Dalej przedstawiono ponowną budowę agenta do generowania faktur przy użyciu Ollamy i Pythona, z dodaniem wyraźnych narzędzi, mechanizmów walidacji oraz kompaktowego cyklu działania agenta. Inferencja odbywa się na lokalnym komputerze, ale te zasady odnoszą się niemal do każdego frameworku dla agentów.
Dlaczego Ollama
Ustawienie lokalne pozwala uniknąć wysyłania danych klientów do zewnętrznego dostawcy modeli podczas rozwoju aplikacji, a także umożliwia wymianę modeli otwartego oprogramowania bez konieczności ponownej budowy aplikacji. Ollama udostępnia modele lokalnie poprzez prostą API do czatowania. Obsługiwane jest również wywoływanie narzędzi, dzięki czemu model może poprosić o wykonanie funkcji zamiast udawać, że posiada odpowiedź. Proces konfiguracji jest krótki:
ollama pull qwen3
pip install ollama
Zwykła prośba o czat z Pythona wygląda w ten sposób:
from ollama import chat
response = chat(
model="qwen3",
messages=[
{"role": "user", "content": "Explain what an overdue invoice is."}
],
)print(response.message.content)
To daje bota do rozmów, a nie jeszcze agenta.
Bot odpowiada, agent działa krok po kroku
Słowo „agent” jest używane w szerokim znaczeniu. Praktyczną definicją jest model działający w pętli, mający dostęp do narzędzi, stanu oraz reguł określających, co może zrobić dalej. W przypadku zadania z fakturą pętla wygląda następująco:
- Użytkownik składa prośbę.
- Model decyduje, czy potrzebne jest jakieś narzędzie.
- Aplikacja weryfikuje prośbę o użycie narzędzia.
- Aplikacja uruchamia narzędzie.
- Wynik jest dodawany do rozmowy.
- Model prosi o inne narzędzie lub podaje ostateczną odpowiedź.
Model nigdy nie ma dostępu do bazy danych. Może jedynie poprosić aplikację o wywołanie narzędzia zdefiniowanego przez programistę. Ta granica jest ważna, ponieważ modele językowe dobrze radzą sobie z interpretacją intencji i generowaniem tekstu, natomiast kod aplikacji lepiej radzi sobie z uprawnieniami, zapytaniami, obliczeniami i regułami biznesowymi.
Najpierw proste narzędzia
Częstym pierwszym błędem jest użycie jednego potężnego narzędzia, na przykład manage_invoices, które może wyszukiwać, aktualizować, tworzyć projekty i wysyłać informacje. To utrudnia debugowanie i powoduje, że nadużycia stają się niebezpiecznie proste. Lepsze są małe narzędzia, z których każde pełni jedną funkcję:
find_customerprzekształca nazwę klienta na rzeczywisty identyfikator klienta.get_overdue_invoiceszwraca nieopłacone faktury dla danego identyfikatora.draft_remindergeneruje tekst, ale nic nie wysyła.send_reminderwykonuje czynność zewnętrzną i wymaga zatwierdzenia.
Społeczna warstwa danych lokalnych w uproszczonej formie dostarcza fakty:
CUSTOMERS = {
"acme plumbing": {
"customer_id": "cus_1042",
"name": "Acme Plumbing",
"email": "billing@example.com",
}
}
INVOICES = [
{
"invoice_id": "INV-2048",
"customer_id": "cus_1042",
"amount": 1850.00,
"days_overdue": 18,
}
]
def find_customer(name: str) -> dict:
customer = CUSTOMERS.get(name.strip().lower())
return customer or {"error": "customer_not_found"}
def get_overdue_invoices(customer_id: str) -> dict:
matches = [
invoice
for invoice in INVOICES
if invoice["customer_id"] == customer_id
and invoice["days_overdue"] > 0
]
return {"invoices": matches, "count": len(matches)}
Spójrz, czego te funkcje unikają robienia. Nigdy nie zgadują. Brakujący klient powoduje wyświetlenie wyraźnego błędu, a brak odpowiadających faktur skutkuje pustą listą.
Prawdziwe narzędzia, nie wyimaginowany dostęp
Ollama przyjmuje funkcje w Pythonie jako narzędzia, a nazwy, parametry oraz docstrings dostarczają modelowi informacji niezbędnych do wyboru odpowiedniej funkcji.
import json
from ollama import chat
def find_customer(name: str) -> dict:
"""Find a customer by business name and return its verified record."""
customer = CUSTOMERS.get(name.strip().lower())
return customer or {"error": "customer_not_found"}
def get_overdue_invoices(customer_id: str) -> dict:
"""Return overdue invoices for a verified customer ID."""
matches = [
invoice
for invoice in INVOICES
if invoice["customer_id"] == customer_id
and invoice["days_overdue"] > 0
]
return {"invoices": matches, "count": len(matches)}
TOOLS = {
"find_customer": find_customer,
"get_overdue_invoices": get_overdue_invoices,
}
Słownik rejestru to coś więcej niż tylko udogodnienie, ponieważ stanowi listę uprawnień. Nawet jeśli model poprosi o funkcję o nazwie delete_customer, aplikacja odrzuca tę prośbę, ponieważ takiego narzędzia nie ma w TOOLS.
Pętla agenta
To właśnie pętla przekształca wywoływanie narzędzi w działanie agenta. Zaczyna się od komendy systemowej:
SYSTEM_PROMPT = """
You are an invoice assistant.
Rules:
- Never invent a customer, invoice, email address, balance, or date.
- Use find_customer before requesting invoices.
- Only use customer IDs returned by tools.
- If a tool returns an error or no records, explain that clearly.
- You may draft communication, but you cannot send it.
"""
def run_agent(user_request: str) -> str:
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_request},
] for _ in range(6):
response = chat(
model="qwen3",
messages=messages,
tools=list(TOOLS.values()),
) messages.append(response.message) if not response.message.tool_calls:
return response.message.content for call in response.message.tool_calls:
name = call.function.name
arguments = call.function.arguments if name not in TOOLS:
result = {"error": "tool_not_allowed"}
else:
try:
result = TOOLS[name](**arguments)
except (TypeError, ValueError) as error:
result = {
"error": "invalid_tool_arguments",
"detail": str(error),
} messages.append(
{
"role": "tool",
"tool_name": name,
"content": json.dumps(result),
}
) return "I stopped because the task exceeded the maximum number of steps."
Limity sześciu iteracji są celowe, ponieważ agent nie powinien pracować w nieskończoność, ciągle ponownie rozważając ten sam problem. Pętla traktuje również wyniki narzędzi jako nowe dowody. Model najpierw rozwiązuje nazwę „Acme Plumbing”, otrzymuje z powrotem cus_1042, a dopiero potem żąda faktur dla tej zweryfikowanej tożsamości.
Lepszy prompt nie był rozwiązaniem
Halucynacje skłaniają programistę do dodania jeszcze jednego zdania do promptu systemowego, na przykład prośby o dokładność i unikanie wymyślania czegokolwiek. Taka instrukcja pomaga, ale nie stanowi kontroli. Niezawodne rozwiązanie przyszło z reorganizacji:
- Baza danych pozostaje źródłem prawdy.
- Model może żądać danych i nie może wymyślić rekordu, który wyglądałby na ważny.
- Nazwy narzędzi znajdują się na liście dozwolonych, a argumenty są weryfikowane.
- Pętla ma sztywny limit liczby kroków.
- Czytanie i zapis są rozdzielone.
- Wysłanie e-maila wymaga zatwierdzenia przez człowieka.
Wskazówki wpływają na zachowanie, a kod to egzekwuje.
Ustrukturyzowany wynik na granicy
Tekst swobodny nadaje się do przyjaznego e-maila, ale stanowi ryzyko, gdy inna część aplikacji przetwarza odpowiedź. Jeśli agent musi zwrócić ekran z recenzją wraz z danymi klienta, identyfikatorami faktur, łączną kwotą i wersją roboczą, model Pydantic może zdefiniować taki kontrakt:
from pydantic import BaseModel, Field
class ReminderReview(BaseModel):
customer_name: str
invoice_ids: list[str]
total_due: float = Field(ge=0)
draft_subject: str
draft_body: str
requires_approval: bool = True
Ollama przyjmuje schemat JSON za pośrednictwem swojego parametru format:
review_response = chat(
model="qwen3",
messages=messages,
format=ReminderReview.model_json_schema(),
)
review = ReminderReview.model_validate_json(
review_response.message.content
)
To gwarantuje, że odpowiedź ma oczekiwany kształt. Nie dowodzi to jednak, że wartości są prawdziwe, więc invoice_ids i total_due są nadal porównywane z rzeczywistymi wynikami narzędzia przed pojawieniem się przycisku zatwierdzenia. Walidacja schematu sprawdza, czy coś ma prawidłowy kształt. Walidacja biznesowa sprawdza, czy jest to dozwolone i prawdziwe. Agent produkcyjny potrzebuje obu tych elementów.
Stan to nie pamięć
Długie rozmowy ujawniają kolejny problem: model zapomina, który klient został wybrany, lub łączy stary rachunek z nowym zapytaniem. Przyczyną jest traktowanie całego transkryptu rozmowy jako stanu aplikacji. Rozmowa stanowi przydatny kontekst, ale krytyczny stan musi znajdować się w wyraźnej strukturze:
task_state = {
"customer_id": "cus_1042",
"verified_invoice_ids": ["INV-2048"],
"approved_actions": [],
}
Model może podsumowywać wydarzenia, jednak aplikacja posiada zweryfikowane identyfikatory oraz zatwierdzenia. Jeśli rozmowa zostanie skrócona, aby zmieścić się w oknie kontekstowym, te informacje pozostaną nietknięte.
Długość kontekstu wpływa również na wydajność, ponieważ im więcej informacji, tym więcej pamięci jest potrzebne i im dłużej trwa ich przetwarzanie. Zamiast przesyłać każdą wiadomość i wiersz z bazy danych bez ograniczeń, należy przekazać tylko najmniejszą ilość informacji niezbędną do podjęcia aktualnej decyzji.
Local nie oznacza bezpiecznego
Uruchamianie modelu za pomocą Ollamy umożliwia kontrolę nad procesem inferencji na poziomie lokalnym, ale nie zabezpiecza reszty aplikacji. Agent może nadal wyciekać dane do logów, wywoływać niebezpieczne wewnętrzne punkty końcowe, postępować zgodnie z złośliwymi instrukcjami ukrytymi w dokumencie lub działać z nadmiernymi uprawnieniami. Obowiązują standardowe zasady bezpieczeństwa backendu:
- Przypisz każdemu narzędziu tylko te uprawnienia, które jest potrzebne.
- Unikaj umieszczania tajemnic w promptach oraz kodzie źródłowym.
- Traktuj pobrany tekst jako dane niezaufane, nigdy jako instrukcje systemu.
- Zapisuj każde wezwanie narzędzia wraz z jego wynikiem, opóźnieniem i ostateczną decyzją.
- Usuń pola wrażliwe z wyników, zanim trafią one do kontekstu modelu.
- Wymagaj zatwierdzenia przed podjęciem działań zewnętrznych lub nieodwracalnych.
- Uczyń ważne operacje zapisu idempotentnymi, aby ponawiania nie powodowały ich duplikacji.
Model nigdy nie powinien stanowić granicy bezpieczeństwa.
Testowanie
Demony ścieżki sukcesu ukrywają większość błędów agenta, dlatego warto stworzyć mały zestaw do oceny. Przypadki obejmują znanego klienta z jedną opóźnioną fakturą i takiego, który nie ma żadnych faktur, nazwisko, które nie pasuje do nikogo, dwie podobne nazwy firm, próbę wysłania bez zatwierdzenia, instrukcję wprowadzoną w celu ominięcia zasad, błędnie sformułowany argument narzędzia oraz narzędzie, które się wyłącza z powodu czasu oczekiwania. Dla każdego przypadku należy odnotować oczekiwane narzędzia, dozwolone działania i ostateczny wynik, a także ponownie uruchomić cały zestaw w momencie zmiany modelu, instrukcji systemu lub opisów narzędzi.
Najbardziej przydatną miarą nie jest to, czy ostateczna odpowiedź brzmi dobrze, lecz to, czy agent postępował zgodnie z właściwą sekwencją zweryfikowanych kroków.
Wersja działająca
Po przeprojektowaniu oryginalna prośba pozostawia ślad w następujący sposób:
User request
→ find_customer(name="Acme Plumbing")
→ verified customer_id: cus_1042
→ get_overdue_invoices(customer_id="cus_1042")
→ verified invoice: INV-2048, $1,850, 18 days overdue
→ generate draft
→ wait for human approval
Jest mniej „magiczny” niż pierwsza demonstracja, ale o wiele bardziej przydatny. Nieistniejący klient zmusza agenta do zatrzymania się i poproszenia o wyjaśnienia. Żadne przeterminowane faktury nie są zgłaszane jako takie. Nieznana prośba o narzędzie jest odrzucana przez aplikację, a pętla, która działa zbyt długo, zatrzymuje się bezpiecznie. Nic z tego nie jest efektowne, ale wszystko to buduje zaufanie.
Ostatnia lekcja
Ollama ułatwia uruchamianie wydajnego lokalnego modelu, ale model jest tylko jedną z części. Prawdziwym elementem jest system wokół niego: pętla, kontrakty narzędzi, dane źródłowe, warstwa walidacji, maszyna stanowa, granice uprawnień oraz krok zatwierdzenia.
Dla początkującego agenta pomijaj dziesięć narzędzi oraz pełny dostęp do aplikacji. Zacznij od jednego rzeczywistego problemu i dwóch ograniczonych narzędzi tylko do odczytu, rejestruj każdą decyzję, dodawaj przypadki niepowodzeń i wprowadzaj dostęp do zapisu dopiero wtedy, gdy ścieżka odczytu będzie niezwykle niezawodna. Kluczową zmianą jest zastąpienie pytania o to, jak uczynić model bardziej autonomicznym, pytaniem o to, jak sprawić, by każdy krok był widoczny, weryfikowalny i bezpieczny. Autonomia stanowi dobry przykład demonstracyjny, natomiast kontrolowana autonomia tworzy użyteczny produkt.