Przejdź do treści
Marcin Baniowski Connecting people with machines
← Wróć do notatek

Od promptu do tokena — jak działa agent LLM od środka

W czasie eksperymentów z agentami kodującymi i lokalnymi modelami typu Qwen Coder zacząłem się zastanawiać, co tak naprawdę dzieje się między agentem a modelem na poziomie protokołu i kodu. Przy okazji chciałem odświeżyć Go — wbrew trendowi nakazującemu "odpuszczenie" kodu na korzyść pisania promptów — i tak powstał działający prototyp agenta.

Co w kablu piszczy, czyli rzut okiem na protokół

Cała komunikacja agenta z modelem to zwykły HTTP. Jako przykład weźmy Ollamę z modelem Qwen Coder.

Klasyczny HTTP POST na /v1/chat/completions

{
  "model": "qwen3-coder",
  "stream": true,
  "messages": [
    {"role": "system", "content": "You are a coding assistant..."},
    {"role": "user",   "content": "What does the main.go file do?"}
  ],
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "read_file",
        "description": "...",
        "parameters": {...}
      }
    }
  ]
}

"Magia" z pojawiającymi się w miarę myślenia tokenami ukryta jest w polu stream: true. Serwer odpowiada Content-Type: text/event-stream i nie zamyka body — tokeny przychodzą partiami jako pary event: + data::

event: content_block_delta
data: {"delta": {"type": "text_delta", "text": "Hello"}}

event: content_block_delta
data: {"delta": {"type": "text_delta", "text": " world!"}}

event: message_stop
data: {}

Większość eventów to text_delta — fragmenty odpowiedzi. Ale co gdy model zamiast tekstu chce coś zrobić?

Tool call — model prosi, agent wykonuje

Nie ma tu żadnego specjalnego protokołu — model po prostu generuje JSON zamiast tekstu:


1. Agent → model   POST z tools[]
2. Model → agent   blok tool_call: { name: "read_file", arguments: {"path": "main.go"} }
3. Agent           wykonuje narzędzie (czyta plik)
4. Agent → model   nowy POST z tą samą historią + wiadomość role: "tool" z wynikiem
5. Model → agent   odpowiedź tekstowa na podstawie zawartości pliku
{
  "role": "tool",
  "tool_call_id": "call_01abc",
  "content": "package main\n\nfunc main() {..."
}

W każdym requeście wysyłamy pole tools[] z pełną listą dostępnych narzędzi i ich schematami (JSON Schema). Model nie ma dostępu do żadnego "rejestru" — widzi tylko to co mu wyślemy. Jeśli agent obsługuje MCP, narzędzia z serwerów MCP są doklejane do tej samej listy — model nie wie, że część z nich pochodzi z zewnątrz.

  • Argument narzędzia może przyjść fragmentami przez SSE (input_json_delta) — składamy go jako string, parsujemy dopiero po content_block_stop
  • Model może zażądać kilku narzędzi na raz (parallel tool calls) — agent wykonuje je wszystkie, odsyła wszystkie wyniki, dopiero potem model kontynuuje
  • Dlaczego model "wie" kiedy wywołać narzędzie? Zależy od system promptu i treningu — nie od żadnego protokołu po stronie agenta

Implementacja w kodzie

Na poziomie kodu idea jest prosta — pętla while z klientem HTTP, który wysyła historię rozmowy, dostaje stream tokenów, używa narzędzi i od nowa.

user input → messages[] → Chat() → <-chan Event → terminal output
                              ↑                        |
                              └── tool results ────────┘

W miarę implementacji sprawa staje się bardziej złożona — kod powinien być czytelny, logicznie podzielony na pliki i moduły. I tak na przykład dostawcy LLM różnie wystawiają swoje modele, Anthropic ma /v1/messages i własny format SSE, OpenAI/Ollama używa /v1/chat/completions, szczegóły trzeba ukryć za wspólnym interfejsem. Z pomocą przychodzi Factory Pattern zwracający gotową instancję w zależności od wybranego dostawcy.

Istota logiki agenta

W kodzie odpowiedzialność za przebieg wątku rozłożona jest na trzy pliki, każdy z jasno określoną rolą:

  • cmd/root.gorunREPL() — composition root. Tu łączymy wszystko: parsowanie flag, ładowanie configu, tworzenie providera, budowanie rejestru narzędzi, podłączenie MCP serwerów. Nie ma tu logiki biznesowej, a jedynie łączenie zależności wstrzykiwanych do agenta.

  • agent.goRun() — pętla REPL. Czyta input od użytkownika, steruje wywołanymi komendami (/compact, /model, /provider itd.), wywołuje turn() na każde zapytanie, zarządza sesją (zapis, resume, tworzenie tytułu), auto-compact gdy context window > 80%.

  • turn.goturn() — jeden cykl agenta. Buduje ChatRequest, wywołuje Chat(), przekazuje stream do consumeStream(), obsługuje agentic loop (tool_use → wykonaj → powtórz). Obsługa Ctrl+C (SIGINT → cancel context → przerwanie cyklu).

  • stream.goconsumeStream() — konsumpcja kanału eventów z providera. Czyta <-chan Event, drukuje tokeny na terminal, akumuluje tool calls, filtruje bloki <think>, podświetla kod, pilnuje idle timeout.

Go i kanały — streaming bez blokowania

Odpowiedź HTTP od modelu może trwać kilkanaście sekund — jak nie zablokować reszty programu? Chat() zwraca <-chan Event natychmiast, a parsowanie SSE dzieje się w goroutine w tle. Kanał jest buforowany (64 elementy), więc provider nie blokuje na odbiorcy.

// Provider interface — jeden kontrakt dla wszystkich providerów
type Provider interface {
    Chat(ctx context.Context, req ChatRequest) (<-chan Event, error)
}

// Wewnątrz Anthropic.Chat():
ch := make(chan Event, 64)       // buforowany — nie blokujemy na agencie
go a.streamResponse(ctx, resp.Body, ch)
return ch, nil                   // zwracamy natychmiast, goroutine chodzi w tle

Kanał jest jednokierunkowy — provider pisze, agent czyta. Jeśli użytkownik naciśnie Ctrl+C, context.WithCancel anuluje context, zamyka HTTP body i goroutine kończy się naturalnie. Na wypadek gdyby model po prostu przestał odpowiadać, consumeStream pilnuje idle timeout przez time.NewTimer. Goroutines są tanie, kanały to wbudowany mechanizm synchronizacji — Go tutaj po prostu pasuje.

// Agent konsumuje stream
for event := range ch {
    switch event.Type {
    case provider.EventTextDelta:
        fmt.Print(event.Text)      // drukujemy token w czasie rzeczywistym
    case provider.EventToolUseStart:
        // model chce narzędzia — zapamiętujemy
    case provider.EventDone:
        // koniec, mamy usage stats
    }
}

Agentic loop — pełny cykl konwersacji

Mechanizm jest prosty: agent wysyła Chat() z historią i listą narzędzi, konsumuje stream odpowiedzi i patrzy co dostał. Jeśli model zwrócił tekst — cykl się kończy, wynik leci na terminal. Ale jeśli w odpowiedzi pojawił się blok tool_use, to znaczy że model potrzebuje czegoś z zewnątrz — chce przeczytać plik, sprawdzić strukturę katalogów, odpytać MCP serwer. Agent wykonuje żądane narzędzie, dokłada wynik do messages[] i wywołuje Chat() od nowa. Model widzi teraz dłuższą historię — swoją poprzednią odpowiedź plus rezultat narzędzia — i decyduje co dalej. Może odpowiedzieć, może poprosić o kolejne narzędzie.

W kodzie to dosłownie for {} w turn.go:

for {
    req := provider.ChatRequest{
        System:   a.system,
        Messages: a.messages,
        Tools:    a.toolDefs(),
    }
    ch, err := a.provider.Chat(turnCtx, req)
    // ...
    result := a.consumeStream(ch, spin)

    // budujemy wiadomość asystenta z tekstem i/lub tool calls
    a.messages = append(a.messages, assistantMsg)

    // brak tool calls → koniec cyklu
    if len(result.toolCalls) == 0 {
        return nil
    }

    // są tool calls → wykonaj i wróć na górę pętli
    for _, tc := range result.toolCalls {
        a.executeTool(turnCtx, tc)
    }
}

Każda iteracja to osobny request HTTP z pełną historią konwersacji. Historia rośnie — messages[] to akumulowana pamięć, w której model widzi cały przebieg cyklu. Co się dzieje po wykonaniu narzędzia? executeTool() wywołuje je przez rejestr i dokłada wynik do historii:

result, err := a.registry.Execute(ctx, tc.name, params)
// ...
a.appendToolResult(tc.id, result, false)

Gdzie appendToolResult to po prostu:

func (a *Agent) appendToolResult(toolUseID, content string, isError bool) {
    msg := provider.NewToolResultMessage(toolUseID, content, isError)
    a.messages = append(a.messages, msg)
}

Przy następnej iteracji pętli model widzi tę wiadomość i decyduje — odpowiedzieć, czy poprosić o kolejne narzędzie.

Pętla wykonuje się aż model przestanie prosić o narzędzia. Przy dłuższych sesjach przepełnieniu kontekstu zapobiega kompresja (/compact ręcznie, albo automatycznie gdy przekroczymy 80% okna). Model może też w jednym przebiegu zażądać kilku narzędzi naraz (parallel tool calls) — consumeStream() akumuluje je wszystkie w tablicy calls, agent wykonuje po kolei, a wyniki lecą do modelu w jednym zapytaniu.


Jak to wygląda w praktyce

Powyższy proces ilustują logi zapisywane w ~/.go-agent/agent.log. Np. dla zapytania: "opisz plik @main.go":

# startup: config + MCP
time=2026-03-28T17:45:16.248+01:00 level=DEBUG msg="config loaded" provider=ollama
time=2026-03-28T17:45:16.248+01:00 level=INFO  msg="mcp connecting" server=context7
time=2026-03-28T17:45:17.380+01:00 level=INFO  msg="mcp ready" servers=2 tools=3

# cykl zaczyna się — 1 wiadomość w historii (samo zapytanie), 9 narzędzi w tools[]
time=2026-03-28T17:45:33.588+01:00 level=DEBUG msg="turn start" messages=1 query="opisz plik @main.go"
time=2026-03-28T17:45:33.610+01:00 level=INFO  msg="[flow] → request" provider=ollama messages=1 tools=9

# model odpowiada po ~8s: chce wywołać read_file z argumentem {"path":"main.go"}
time=2026-03-28T17:45:41.352+01:00 level=INFO  msg="[flow] ← tool_call" name=read_file id=ollama_1
time=2026-03-28T17:45:41.353+01:00 level=INFO  msg="[flow]   input" json="{\"path\":\"main.go\"}"

# agent wykonuje narzędzie — plik ma 11 linii
time=2026-03-28T17:45:41.371+01:00 level=DEBUG msg="tool call" name=read_file path=main.go
time=2026-03-28T17:45:41.371+01:00 level=INFO  msg="[flow]   executing" name=read_file
time=2026-03-28T17:45:41.463+01:00 level=DEBUG msg="tool done" name=read_file lines=11 preview="package main"
time=2026-03-28T17:45:41.463+01:00 level=INFO  msg="[flow]   result" name=read_file length=122

# wynik trafia do messages[] i agent wysyła drugi request — teraz 3 wiadomości: zapytanie + odpowiedź modelu + wynik narzędzia
time=2026-03-28T17:45:41.463+01:00 level=INFO  msg="[flow] → result" tool_id=ollama_1 is_error=false
time=2026-03-28T17:45:41.502+01:00 level=INFO  msg="[flow] → request" provider=ollama messages=3 tools=9

# model odpowiada tekstem — cykl skończony: 1 tool call, 6444 tokenów wejścia
time=2026-03-28T17:45:46.876+01:00 level=DEBUG msg="turn done" tool_calls=1 in=6444 out=280
time=2026-03-28T17:45:46.876+01:00 level=INFO  msg="[flow] ← response" chars=720

Cała reszta

Powyższe sekcje opisują trzon — protokół, streaming, pętlę agenta. Jednak dla nawet podstawowego prototypu potrzeba znacznie więcej:

Providery i przełączanie w runtime. Agent obsługuje Anthropic, OpenAI i Ollamę za wspólnym interfejsem. Providera i model można zmienić w trakcie sesji (/provider, /model) — bez restartu, bez utraty historii.

Sesje. Każda rozmowa zapisywana jest jako plik JSONL z metadanymi (katalog roboczy, provider, tytuł wygenerowany przez LLM). Sesję można wznowić (/resume) — agent odtwarza historię i wyświetla kompaktowe podsumowanie poprzednich kroków.

Kompresja kontekstu. Przy dłuższych sesjach messages[] rośnie aż zaczyna wypełniać context window. Gdy przekroczy 80%, agent automatycznie prosi model o streszczenie starszych wiadomości — pilnując przy tym żeby nie rozdzielić par tool_use/tool_result. Jest również możliwość wywołanie ręcznego przez komendę /compact.

Potwierdzenie wykonania. Potencjalnie destrukcyjne narzędzia (bash, edit, write_file) wymagają potwierdzenia od użytkownika zanim agent je wykona. Jedno naciśnięcie klawisza: y / n / a (approve all).

Filtrowanie <think>. Niektóre modele (np. Qwen Coder) generują bloki <think>...</think> z wewnętrznym tokiem rozumowania. Agent zapisuje je w historii, ale nie wyświetla na terminalu.

TUI. Warstwa prezentacji oparta na Bubble Tea — wielolinijkowy input, podświetlanie składni (Chroma), renderowanie markdowna (Glamour), interaktywny picker do wyboru modeli i sesji, spinner z paskiem statusu.

MCP, skills, AGENT.md. Agent może podłączyć zewnętrzne serwery narzędzi przez MCP, załadować gotowe zestawy instrukcji (skills) z plików SKILL.md, i wstrzyknąć kontekst projektowy z AGENT.md do system promptu.


Kod projektu dostępny na Githubie github.com/baniol/go-agent