Strona główna / Artykuły

Inżynieria harnessingu: środowisko działania, które przekształca LLM w agenta

To, co naprawdę sprawia, że agent do programowania funkcjonuje: pętla, mechanizm wysyłania narzędzi zdolny przetrwać błędne wywołania oraz funkcje do udzielania/prośby o/denegowania uprawnień, przedstawione przy użyciu około 600 linijek prostego kodu w Pythonie.

Wydawca: REACTAPP.TOP13 min czytania2950 słów

Źródła: Harness Engineering: Build the Runtime That Turns an LLM Into an Agent (Part 1/3)

Gdy agent kodujący działa niewłaściwie, zwykle winą obarczany jest model. Częściej przyczyna leży w narzędziu obsługującym, programie, który go otacza.

Każdy, kto korzystał z API do uzupełniania tekstu w czacie, zna łatwą część: wysyłanie wiadomości, otrzymywanie odpowiedzi, ewentualne dodawanie wywołań funkcji. Trudniejsza jest część polegająca na stworzeniu agenta, który może znajdować się w repozytorium, edytować pliki, uruchamiać testy i naprawiać błędy. Narzędzie obsługujące pokrywa tę różnicę. Jest to środowisko działania modelu: narzędzia, uprawnienia, izolacja środowiskowa, obsługa kontekstu, zarządzanie pamięcią i naprawa błędów. Model robi tylko to – przyjmuje tekst i wyprowadza tekst. Narzędzie obsługujące to program, który powtarza ten cykl, uruchamia narzędzia, zapewnia bezpieczeństwo i komunikuje się z użytkownikiem w terminalu. Jeśli model to silnik, to narzędzie obsługujące to sam samochód.

Sam model językowy nie może otworzyć twoich plików, uruchomić pytest, edytować kodu, przypomnieć sobie rozmowę z zeszłego wtorku ani powstrzymać się od wpisania rm -rf /. Jedyne, co potrafi, to wysłać następne wiadomość. Wszystko inne wymaga dodatkowych działań:

  • Pętla: wielokrotne wywoływanie modelu, aż przestanie prosić o narzędzia.
  • Narzędzia: środki do działania (shell, pliki, edycje).
  • Prawa dostępu: pytanie przed podejmowaniem ryzykownych działań oraz blokowanie najgroźniejszych z nich.
  • Sandbox: zapewnienie, by niebezpieczne działania zawodziły na poziomie systemu operacyjnego.
  • Kontekst: utrzymywanie rozmowy w rozsądnych rozmiarach, aby można ją było przesłać.
  • Pamięć: umiejętności, lista zadań do wykonania, plik sesji, który można wznowić.
  • Podagenty: eksploracja bazy kodu bez przepełniania głównej rozmowy.

Ten tekst omawia pierwsze trzy elementy, które przekształcają chatbota w agenta. Pozostałe elementy, takie jak sandboxing, zarządzanie kontekstem, pamięć i podagenty, stanowią dodatkowe warstwy na tym samym „szkielecie”.

Chatbot versus agent

Asystent czatowy dokonuje jednej podróży: pytanie wpływa, odpowiedź wychodzi. Narzędzia takie jak Claude Code lub tryb agenta w Cursorze działają inaczej. Gdy powiesz „przeprojektuj ten moduł”, one czytają pliki, uruchamiają testy, edytują kod, ponownie uruchamiają testy i naprawiają wszystko, co uszkodzili. Przyczyną nie jest bardziej zaawansowany model – to program otaczający model wie, jak pętlić, wywoływać narzędzia i kontynuować pracę, gdy jakiś krok się nie powiedzie.

Ludzie zaczęli nazywać to inżynierią „harness”, co to dość okazały termin dla prostej idei: Claude Code, Cursor i Codex są lepszymi narzędziami właśnie dlatego, że są lepszymi modelami.

Przykład jest bardziej pomocny niż definicje. Wpisujesz: „Popraw nieprzechodzący test w test_parser.py”. Oto, co następuje:

Turn 1:  LLM reads the test file         → bash("cat test_parser.py")
Turn 2:  LLM runs the test to see error  → bash("pytest test_parser.py")
Turn 3:  LLM reads the source file       → read_file("parser.py")
Turn 4:  LLM edits the bug               → str_replace("parser.py", old, new)
Turn 5:  LLM re-runs the test            → bash("pytest test_parser.py")
Turn 6:  Tests pass → LLM says "Done! Fixed the off-by-one error in line 42."

Sześć rund i sześć decyzji, a nikt nie kliknął niczego po pierwszym poleceniu. To dowód na to, że system dobrze pełni swoją funkcję.

Słownictwo

W tekście powtarza się kilka terminów.

  • LLM: funkcja przekształcająca listę wiadomości w kolejną wiadomość. Nie pamięta wcześniejszych wywołań API. Jeśli chcesz, aby wydawało się, że pamięta, wysyłasz ponownie historię rozmowy.
  • Token: jednostka tekstu, którą liczy model – odpowiada mniej więcej trzem czwartym angielskiego słowa. „Hello world” to około dwóch tokenów, a plik w Pythonie składający się z 100 linii ma około 500 tokenów.
  • Okno kontekstowe: liczba tokenów, które model może jednocześnie przetwarzać. Wartości około 128K dla modeli typu GPT-4 oraz około 200K dla Claude wydają się ogromne, dopóki agent 40 razy nie wklei do czatu wyników wykonywania polecenia grep -r.
  • Wywołanie narzędzia: model nie uruchamia twojego kodu. Tworzy żądanie w formie {"name": "bash", "arguments": {"command": "ls"}}, a następnie twój Python uruchamia ls i zwraca wynik.
  • Bez stanu: każde call_llm(messages) działa niezależnie, a lista messages pełni rolę pamięci.

Nie jest potrzebna wiedza z zakresu uczenia maszynowego. Wystarczy podstawowa znajomość Pythona (funkcje, pętle, słowniki, try/except), niewielkie doświadczenie z API w stylu OpenAI oraz najczęściej używane polecenia w wierszu poleceń (ls, grep, cat).

Implementacja referencyjna

Przykładowy agent składa się z około 600 linii kodu w Pythonie, bez użycia żadnych frameworków, i wykorzystuje cztery biblioteki: openai, rich, prompt-toolkit oraz pyyaml. Poniższe fragmenty to rzeczywisty kod, a nie pseudokod, i pod koniec powinieneś rozpoznać te same elementy w Claude Code lub Cursorze. Aby móc za nimi podążać, sklonuj kopię projektu i zainstaluj go w trybie edytowalnym:

git clone https://github.com/shrinidhi-mahishi/coding-harness.git
cd coding-harness
pip install -e .

Każdy punkt końcowy kompatybilny z OpenAI zadziała: OpenRouter, lokalny serwer, sam OpenAI. Ustawienia umieść w ~/.agents/env:

BASE_URL=https://openrouter.ai/api/v1
API_KEY=sk-or-...
MODEL=deepseek/deepseek-v4-flash

Prawdziwe zmienne środowiskowe przytłaczają zawartość tego pliku. Polecenie coding-harness, zadeklarowane w pyproject.toml jako punkt wejścia do coding_harness.agent:main, uruchamia sesję terminalową. Wpisujesz dane, a wewnętrzny pętla działa, dopóki agent nie ma już żadnych pozostałych wywołań narzędzi.

Cały projekt składa się z siedmiu elementów: pętli agenta („bicie serca”), narzędzi („ręce”), uprawnień („kto może co robić”), sandboxu (bezpieczeństwo zapewniane przez jądro), inżynierii kontekstu („czyste okno”), umiejętności, zadań do wykonania oraz sesji („pamięć”), a także podagentów (wykorzystanie kontekstu w miejscu, które można usunąć). Tutaj omówiono tylko pierwsze trzy.

Pliki, które są istotne:

coding_harness/agent.py         # two nested loops
coding_harness/llm.py           # one API call + the system prompt
coding_harness/tools.py         # tool functions + defensive execute()
coding_harness/permissions.py   # allow / ask / deny
coding_harness/config.py        # BASE_URL, API_KEY, MODEL

Nie ma bazy danych, serwera internetowego ani biblioteki do orkiestracji. „Framework” składa się z dwóch pętli while True oraz słownika zawierającego funkcje narzędzi. Podczas pierwszego przeglądania przejrzyj pliki config.py, llm.py, agent.py, tools.py i permissions.py w podanej kolejności.

1. Pętla agenta

Claude Code, Cursor i Codex wykorzystują tę samą pętlę:

  1. Wysyłaj rozmowę do modelu.
  2. Jeśli model poprosi o użycie narzędzia, uruchom je.
  3. Dodaj wynik do rozmowy i wróć do kroku 1.
  4. Gdy nie ma już żadnych wywołań narzędzi, praca jest zakończona i pokazana zostaje odpowiedź.

Chatbot wykonuje jedną rundę komunikacji, natomiast agent kontynuuje działanie aż do zakończenia zadania. To jest cała różnica, a większość pozostałej pracy inżynieryjnej polega na zapobieganiu błędom w pętli.

Jedna kwestia myli nowicjuszy: modele nie mają stanu. Nie pamiętają poprzedniej tury, więc każde wezwanie API zawiera całą rozmowę. Komunikaty wyglądają w ten sposób:

messages = [
    {"role": "system",    "content": "You are a coding agent..."},
    {"role": "user",      "content": "Fix the failing test"},
    {"role": "assistant", "content": "I'll start by reading the test file."},
    {"role": "tool",      "content": "... contents of test file ..."},
]

Istnieją cztery role. Rola systemu przechowuje stałe instrukcje, użytkownikiem jesteś ty, asystentem jest model, a narzędzie przekazuje wynik zwrócony przez narzędzie. Model otrzymuje listę i generuje następny wpis, a ta lista jest jego jedyną „pamięcią”. Nie istnieje żadne ukryte drugie miejsce przechowywania, więc jeśli lista jest błędna, agent również jest błędny.

Zaangażowane są dwa pętle. Wewnętrzna obejmuje jeden ruch użytkownika oraz tyle ruchów modelu, ile to konieczne. Zewnętrzna po prostu czeka na dane wejściowe.

To jest wewnętrzna pętla w uproszczonej formie. Prawdziwy plik usuwa również stare wyniki narzędzi, dodaje świeże przypomnienie na koniec każdej prośby, usuwa pliki tymczasowe i może skompresować historię.

while True:
    message, usage = call_llm(messages)

    messages.append(message.model_dump(exclude_none=True))
        if message.content:
            ui.agent(message.content)
        # No tool calls? The agent is done thinking.
        if not message.tool_calls:
            break
        # Execute each tool and feed the result back
        for tool_call in message.tool_calls:
            args, result = execute(tool_call)
            ui.tool(tool_call.function.name, args, result)
            messages.append({
                "role": "tool",
                "tool_call_id": tool_call.id,
                "content": result,
            })

Kod znajduje się w pliku agent.py. Funkcja call_llm(messages) wysyła pełną historię, a model zwraca tekst, wywołania narzędzi lub oba te elementy. Funkcja model_dump() jedynie przekształca obiekt API w słownik, który można zapisać. Gdy nie ma wywołań narzędzi, pętla się zamyka. W przeciwnym razie wykonywane są te wywołania, pokazywane są wyniki, a następnie pętla powtarza się, dzięki czemu model może zobaczyć, co się stało, i zdecydować, czy kontynuować.

Zewnętrzna pętla jest jeszcze mniejsza:

while True:
    user_input = ui.ask()
    if not user_input:
        break

    messages.append({"role": "user", "content": user_input})
        # ... inner agent loop runs here ...

Twój tekst trafia na listę, wewnętrzny pętla działa aż agent skończy pracę, a następnie program ponownie czeka. Nie ma tu maszyny stanowej – tylko dwa nawarstwione instrukcje while True, takie same jak w Claude Code i Cursor. Model mówi lub prosi o narzędzia, wyniki ich użycia są przekazywane z powrotem do messages, a cykl się powtarza.

2. Narzędzia: ręce dla modelu

Bez narzędzi pętla to po prostu chatbot z dodatkowymi krokami. Narzędzia umożliwiają modelowi wykonywanie zadań: czytanie plików, uruchamianie poleceń, pisanie kodu, wyszukiwanie w repozytorium.

Wyobraź sobie, że prosisz utalentowaną osobę o naprawę błędu, podczas gdy siedzi ona w zamkniętym pomieszczeniu bez komputera. Może myśleć, ale nie może niczego dotknąć. Narzędzia to drzwi.

Wiele tutoriali pomija jeden istotny fakt: narzędzia nigdy nie są wykonywane przez sam model. Model generuje jedynie żądanie w stylu „wykonaj ten polecenie”, a to otaczający kod w Pythonie wykonuje pracę i zwraca wynik. Model nie otrzymuje konsoli, tylko formularza.

Narzędzie składa się z czterech elementów: nazwy (bash, read_file), opisu wskazującego modelowi, kiedy je używać, schematu JSON opisującego argumenty wraz z wymaganymi polami i typami, oraz funkcji w Pythonie, która wykonuje pracę.

bash jest zarówno najpotężniejszym narzędziem, jak i najbardziej niepokojącym. Oto jego treść z pliku tools.py:

def bash(command: str) -> str:
    """Run a shell command and return its combined stdout and stderr."""
    try:
        result = sandbox.run(command)
    except subprocess.TimeoutExpired as expired:
        return (
            f"Timed out after {expired.timeout}s and was killed. "
            "Narrow it down - search inside the working directory rather than /."
        )
    return (result.stdout + result.stderr) or "(no output)"

To, co widzi model, to schemat:

{
    "type": "function",
    "function": {
        "name": "bash",
        "description": "Run a shell command and return its combined stdout and stderr.",
        "parameters": {
            "type": "object",
            "properties": {
                "command": {
                    "type": "string",
                    "description": "The shell command to run",
                }
            },
            "required": ["command"],
        },
    },
}

Gdy model chce listę katalogów, wysyła {"name": "bash", "arguments": {"command": "ls -la src/"}}. Kod go uruchamia, zwraca wynik, a model wybiera następny krok.

Agent ma siedem narzędzi, z których każde to funkcja wraz ze schematem:

  • bash uruchamia polecenie w shellu przez sandbox.
  • read_file odczytuje plik.
  • write_file tworzy lub zastępuje istniejący plik.
  • str_replace dokonuje precyzyjnej edycji, zastępując jedną ciąg znaków innym.
  • read_skill ładuje dokument instrukcji SKILL.md.
  • write_todos zastępuje całą listę zadań do wykonania.
  • task uruchamia podagenta i zwraca tylko jego ostateczną odpowiedź.

Funkcja bash jest jedynie cienką otoczką: uruchamia sandbox.run z 60-sekundowym limitem czasu i zwraca stdout oraz stderr, albo komunikat o przekroczeniu czasu, sugerujący wyszukiwanie w katalogu projektu, a nie w całym katalogu /.

Traktuj wywołania narzędzi jak niepewne dane wejściowe

Wiele postów typu „stwórz agenta w 20 liniach” pomija tę część. Wywołanie narzędzia to tekst napisany przez model, więc model zagrożeń odpowiada formularzowi internetowemu. Nazwa może nie istnieć, argumenty mogą nie być prawidłowym JSON, a typy mogą być błędne. Każda z tych sytuacji może zakończyć sesję, jeśli kod nie jest odpowiednio przygotowany.

Modele tworzą iluzje – wymyślają nazwy funkcji i wytwarzają błędne JSON. Awaria zmusza użytkownika do rozpoczęcia wszystkiego od nowa. Jeśli uda ci się zidentyfikować problem i zwrócić czytelny błąd, model często spróbuje ponownie i robi to zaskakująco dobrze, pod warunkiem, że przekażesz mu błąd, a nie ślad wywołań.

execute(), mechanizm obronny znajdujący się również w pliku tools.py, działa zgodnie z następującym porządkiem:

  1. Analizuje plik JSON; jeśli jest uszkodzony, zwraca błąd informujący, że argumenty nie są prawidłowym JSON.
  2. Szuka nazwy narzędzia; jeśli nie jest ona znana, wyświetla listę dostępnych narzędzi.
  3. Konsultuje funkcję permissions.check() w celu uzyskania decyzji o odrzuceniu, zapytaniu lub zatwierdzeniu.
  4. Wywołuje funkcję w Pythonie.
  5. Lapuje błąd TypeError oraz wszelkie inne wyjątki, zwracając łańcuch tekstowy „Error: ...” i nigdy informację o śladzie błędu.

Krótko mówiąc, kieruje wywołanie narzędzia do funkcji, nie powodując przy tym przerwania sesji.

def execute(tool_call):
    name = tool_call.function.name

# The model might send broken JSON
    try:
        args = json.loads(tool_call.function.arguments)
    except json.JSONDecodeError as broken:
        return {}, f"Error: arguments were not valid JSON ({broken})."
    # The model might hallucinate a tool name
    if name not in TOOLS:
        return args, f"Error: no tool named '{name}'. Available: {', '.join(TOOLS)}."
    # Run through the permission layer, then call the function
    try:
        action, reason = check(name, args)
        if action == "deny":
            return args, f"Blocked by policy: {reason}"
        if action == "ask" and not ui.approve(reason):
            return args, "The user denied this tool call."
        return args, TOOLS[name](**args)
    except TypeError as mismatch:
        return args, f"Error: wrong arguments for {name} ({mismatch})."
    except Exception as failure:
        return args, f"Error: {name} failed - {type(failure).__name__}: {failure}"

Załóżmy, że model wymyśla narzędzie o nazwie search_code. JSON jest w porządku, ale nazwa ta nie znajduje się w liście TOOLS, więc dysponent odpowiada, że takiego narzędzia nie istnieje, i wymienia dostępne: bash, read_file, write_file, str_replace, read_skill, write_todos oraz task. W następnej turze model używa bash z poleceniem grep -r „auth” .. Nic się nie zawaliło, co jest właśnie celem: błędne wywołanie narzędzia nigdy nie powinno przerwać sesji. Błędy to po prostu dodatkowy wprowadzony danych.

3. Uprawnienia: kto może co robić

Gdy dostępny jest bash, agent okazuje się przydatny, ale to właśnie dzięki niemu można wykonać polecenia takie jak rm -rf / lub wysłać poufne dane gdzieś indziej. Jeśli agent może uruchomić cokolwiek, może usuwać pliki, wysyłać niepotrzebne dane lub nawiązywać połączenia z zewnętrznymi serwerami. Jeśli musisz zatwierdzać każde polecenie, całe popołudnie spędzisz na akceptowaniu prostych poleceń typu ls. Potrzebna jest więc droga pośrednia.

Odpowiedź brzmi: trzy poziomy, podobnie jak sygnalizacja świetlna:

Poziom Znaczenie Przykłady
allow działa bez żadnych komunikatów ls, grep, cat, git status, pytest
ask czeka na Twoją decyzję mkdir, python script.py, npm install
deny zablokowane, nawet jeśli się zgodzisz rm, sudo, curl, wget, git push

Reguły to wzory globowe, w których * odpowiada wszystkiemu, a zwycięża ostatnia pasująca reguła. Z pliku permissions.py:

BASH_RULES = {
    "*":                   "ask",     # default: ask for everything
    # read-only: let them through
    "ls*":                 "allow",
    "cat *":               "allow",
    "grep *":              "allow",
    "git status*":         "allow",
    "git diff*":           "allow",
    "pytest*":             "allow",
    # risky: never, even if the user says yes
    "rm *":                "deny",
    "sudo *":              "deny",
    "curl *":              "deny",
    "git push*":           "deny",
}

Wpis * ustawia domyślną politykę na pytanie. Polecenia tylko do odczytu są dopuszczone, następnie te niebezpieczne są całkowicie zablokowane, a wszystko inne, takie jak python script.py lub npm install, pozostaje w trybie pytania.

Model będzie próbował stosować sprytnych sztuczek. Polecenie takie jak grep foo | rm -rf / zaczyna się nieszkodliwie, ale kończy katastrofalnie. Dlatego narzędzie sprawdzające dzieli takie polecenia na części przy użyciu |, ; i &&, uwzględniając jednocześnie cudzysłowy, i stosuje najściślejszą decyzję opartą na każdej z tych części.

def decide(command):
    """Rate every part of a compound command; the strictest verdict wins."""
    verdicts = []
    for part in split_command(command):
        action = "ask"
        for pattern, rule in BASH_RULES.items():
            if fnmatch(part, pattern):
                action = rule
        verdicts.append(action)

    for strictest in ("deny", "ask"):
            if strictest in verdicts:
                return strictest
        return "allow"

W praktyce polecenie grep foo | rm -rf / jest odrzucane, ponieważ rm jest zabronione. Polecenie ls && mkdir test wymaga zatwierdzenia, gdyż mkdir nie znajduje się na liście dozwolonych działań. Polecenie cat file.py | grep TODO jest dozwolone, ponieważ obie części są tylko do odczytu.

Prawa dostępu i sandboxing pełnią różne funkcje. Prawa dostępu dotyczą doświadczenia użytkownika: czy warto przerywać mu pracę w tym celu? Nie stanowią one zabezpieczenia, ponieważ sprytny skrypt może wyglądać jak ls, a w rzeczywistości być czymś innym. Sandboxing natomiast jest formą zabezpieczenia, ponieważ określa, czy dana akcja jest w ogóle fizycznie dozwolona. Prawdziwy agent potrzebuje obu tych elementów.

Jakie to daje możliwości

Pętle, narzędzia obronne oraz prawa dostępu już razem tworzą funkcjonalnego agenta do programowania. Może on czytać repozytorium, uruchamiać testy, edytować pliki, pytać przed wykonaniem nieodpowiedniej akcji oraz odbudowywać się po błędnej próbie wywołania narzędzia.

Wciąż nie jest to bezpieczne, ponieważ zdeterminowany model może nadal wyrządzić szkodę. Nie jest też efektywne, gdyż okno kontekstowe szybko się wypełnia. Ponadto nie posiada pamięci, więc każda sesja zaczyna się od zera. Naturalnymi kolejnymi krokami są sandbox na poziomie systemu operacyjnego, który sprawia, że destrukcyjne działania zawodzą w jądrze, oraz złożone zarządzanie kontekstem, które umożliwia długotrwałe sesje. Są to te same elementy, których używają agenci produkcyjne, bez tajemniczości.