Strona główna / Artykuły

Wewnętrzne mechanizmy kontekstu i pamięci Claude Code, przebudowane jako agent LangGraph

Jak Claude Code tworzy wywołania API, uruchamia podagenty i kompresuje długie sesje, a następnie te same koncepcje są odbudowywane w LangGraph z użyciem mechanizmów checkpointingu i plików pamięci.

Wydawca: REACTAPP.TOP11 min czytania2610 słów

Źródła: Scaling Agent Harnesses for Long-Running Sessions

Informacje z wyciekłego kodu źródłowego Claude Code v2.1.87 pokazują, w jaki sposób narzędzie radzi sobie z kontekstem i pamięcią podczas długich sesji programistycznych. Porusza ono typowe problemy związane z długotrwałym działaniem agenta: kontekst przekraczający możliwości modelu, wyniki działania narzędzia, których nikt nie może przewidzieć, przywoływanie pamięci z wcześniejszych sesji we właściwym momencie oraz zachowanie niezmienionych instrukcji projektu po ich streszczeniu. Dalej opisano mechanizm działania, a następnie zbudowano uproszczoną wersję w LangGraph przy użyciu wbudowanych mechanizmów kontrolnych.

Jak Claude Code zarządza kontekstem

Zapytanie API składa się z trzech części. Dwie z nich – instrukcja systemu (identyfikator oraz kontekst Git) oraz tablica narzędzi (schematy JSON dla Bash, Read, Edit, Skill, Agent i MCP) – pozostają poza listą wiadomości, więc proces kompresji ich nie dotyka. Reszta trafia do tablicy wiadomości jako wpisy typu user-role umieszczone w tagach <system-reminder>: pliki z instrukcjami na samym szczycie, bieżąca rozmowa oraz wyniki użycia narzędzi pośrodku, a także wpisy dotyczące załączników (zapiski, listy umiejętności i agentów, instrukcje MCP, wyniki hooków) dodawane po każdej rundzie wykonywania narzędzi. Gdy okno się wypełni, proces kompresji skondensuje każdą wiadomość i odtworzy ważne załączniki z ich oryginalnych źródeł. Następnie przychodzi pętla zapytań, podagenty oraz proces kompresji.

Pętla zapytań

Wpisywanie wiadomości uruchamia pętlę, w której każda iteracja to pojedynczy wywołanie API, i trwa ona do chwili, gdy model udzieli odpowiedzi bez żadnych wywołań narzędzi.

Przed rozpoczęciem pętli tło wywołuje Sonnet, aby pobrać do pięciu odpowiednich plików pamięci, korzystając wyłącznie z tekstu pytania użytkownika, ponieważ wysłanie całej rozmowy byłoby zbyt kosztowne. Wyniki nie są używane od razu – pojawiają się po wykonaniu narzędzi, więc pierwsze wywołanie API w danej turze nigdy ich nie zawiera.

Każda iteracja przycinaje tablicę wiadomości w pamięci przed przesłaniem jej do API. Najpierw skraca listę do elementów znajdujących się po ostatniej granicy kompresji, przy czym pełny plik JSONL na dysku nie jest modyfikowany. Następnie wszelkie zbyt duże wyniki narzędzi zapisuje się do osobnego pliku, zastępując je 2-kilobajtowym wycinkiem treści. Jeśli po tym liczb tokenów nadal przekracza około 93% szerokości okna kontekstowego, następuje kompresja.

Odpowiedź może być albo zwykłym tekstem, który kończy tę rundę, albo jednym lub kilkoma wywołaniami narzędzi, które są wykonywane i powodują powrót do pętli. Narzędzia dzielą się na grupy: ogólne, takie jak Bash, Read, Edit i Grep; narzędzie Skill, które wprowadza do rozmowy pełny plik SKILL.md; narzędzie Agent, które uruchamia izolowanego podagenta, który zwraca jeden wynik; oraz ToolSearch, które pobiera schemat narzędzia przeznaczony do użycia w następnej iteracji. Po zakończeniu każdej rundy narzędziowo dodawane są załączniki typu delta dla list umiejętności, list agentów, instrukcji MCP oraz wszelkich wcześniej pobranych danych pamięci, a wyświetlany jest wyłącznie materiał, który uległ zmianie od poprzedniej rundy.

Podagenty

Użycie narzędzia Calling the Agent sprawia, że Claude Code tworzy izolowany kontekst w tym samym procesie. Subagent rozpoczyna pracę od kopii rozmowy rodzica, otrzymuje własne narzędzia oraz prompt systemowy, działa z wyłączonym procesem myślenia w celu obniżenia kosztu tokenów, a następnie samodzielnie wykonywa ten sam kod pętli zapytań. Agenty typu Explore and Plan tracą również plik CLAUDE.md oraz informacje z git status z tej kopii, co pozwala zaoszczędzić dodatkowe tokeny.

Tylko ostateczna wiadomość z wynikiem wraca do rodzica. Wywołania narzędzi, procesy rozumowania oraz wszelkie operacje kompresji wewnątrz subagenta są zapisywane w pliku transkrypcji sidechain, subagents/agent-{id}.jsonl, który rodzic nigdy nie otwiera. W ten sposób koszty pozostają izolowane: dziesiątki wewnętrznych wywołań narzędzi mogą skutkować tylko jedną wiadomością w kontekście rodzica.

Kompresja

W kodzie istnieje mikrokompaktowa warstwa przeznaczona do skracania starych wyników narzędzi przed pełną kompresją, ale jest ona wyłączona i poza zakresem działania w tym przypadku. Sama kompresja oferuje dwie strategie, przy czym druga służy jako rozwiązanie awaryjne, gdy pierwsza nie jest dostępna. Strategia pierwsza natychmiast zastępuje ją przygotowanym plikiem pamięci sesji, dzięki czemu kompresja nie wymaga żadnego osobnego wywołania API; agent w tle buduje ten plik krok po kroku poprzez oddzielne wywołania, a ta opcja jest obecnie wyłączona przez mechanizmy kontroli funkcji. Strategia druga, ta obecnie używana, polega na pełnej kompresji modelu LLM. Agent odrębny otrzymuje aktualne wiadomości oraz ustrukturyzowany prompt kompresyjny i sprowadza rozmowę do około pięciu tysięcy tokenów. Najnowsze wiadomości są zachowywane dosłownie obok tego streszczenia, aby model nadal miał dostęp do swojego bezpośredniego kontekstu pracy, natomiast wszystko starsze jest zastępowane streszczeniem.

Następnie wszystkie załączniki, które znajdowały się w messages[] i zostały skrócone, są ponownie tworzone. Lista agentów, nazwy narzędzi o odroczonym uruchomieniu oraz instrukcje MCP są ponownie publikowane w pełnej formie, a funkcje uruchamiane przy rozpoczęciu sesji działają ponownie. Treść umiejętności, która została wywołana, wraca z mapy pamięciowej ograniczonej do około 25 000 tokenów, a pięć najnowszych plików jest ponownie odczytywanych z dysku. Komenda systemu, tools[], plik Claude.md oraz kontekst git nie znajdowały się nigdy w messages[], więc kompresja nie może na nie wpłynąć; ich zapisane w pamięci cache są po prostu usuwane, aby w następnej rundzie uzyskano świeże wartości.

Celowo pominęto jedną rzecz: katalog umiejętności nie jest odbudowywany, co oszczędza około 4 000 tokenów. Skutkiem ubocznym jest to, że po skompresowaniu model nie ma sposobu na znalezienie umiejętności, których jeszcze nie użył, chyba że są one wymienione w Claude.md. Skompresowanie powoduje również utratę szczegółów, ponieważ wcześniejszy streszczenie jest łączone ze wszystkim innym, a po trzecim przetworzeniu oryginalne fakty są sprasowywane trzy razy. Nic tak naprawdę nie znika, ponieważ plik JSONL zapisywany wyłącznie na końcu na dysku przechowuje całą historię, a marker granicy kompresji (parentUuid: null) wskazuje ładowarce, od czego zacząć przy wznowieniu pracy.

Społeczszczona wersja LangGraph

Można zbudować mniejszą wersję tej samej architektury za pomocą LangGraph. Dostarcza on już narzędzia do zapisywania stanu w celu utrwalenia danych oraz funkcję RemoveMessage do usuwania wiadomości. Pamięć między sesjami działa według tej samej zasady co w Claude Code – co rundę odczytuje się pliki markdown z dysku. Logika kompresji to element, który trzeba jeszcze napisać.

Na diagramie mapowania każda strzałka łączy koncepcję z Claude Code po lewej stronie z jej odpowiednikiem w LangGraph po prawej. Oznaczona strzałka „poprawa” wskazuje, że LangGraph może przechowywać instrukcje poza strukturą messages[], dzięki czemu kompresja ich nie dotyka.

Graf składa się z pięciu węzłów. Linie przerywane wychodzące z call_model są warunkowe – przesyłają wywołania narzędzi z powrotem przez tools, a odpowiedzi tekstowe do post_turn. W funkcji load_context pliki pamięci (.agent/memory/*.md) są odczytywane z dysku, a następnie włączane do SystemMessage w call_model. Dzięki temu w ogóle nie pojawiają się w messages[], więc przechodzą przez proces kompresji bez żadnych zmian.

Budowa grafu

Węzły są wykonywane kolejno, a po call_model warunkowy rozgałęzienie albo przechodzi przez narzędzia, albo przechodzi do post_turn. Checkpointer SQLite przechowuje cały stan między ponownymi uruchomieniami procesu.

def build_graph():
    builder = StateGraph(AgentState)

    builder.add_node("load_context", load_context)
    builder.add_node("prune_and_compact", prune_and_compact)
    builder.add_node("call_model", call_model)
    builder.add_node("tools", ToolNode(TOOLS))
    builder.add_node("post_turn", post_turn)

    builder.add_edge(START, "load_context")
    builder.add_edge("load_context", "prune_and_compact")
    builder.add_edge("prune_and_compact", "call_model")
    builder.add_conditional_edges(
        "call_model", route_after_model,
        {"tools": "tools", "post_turn": "post_turn"},
    )
    builder.add_edge("tools", "call_model")
    builder.add_edge("post_turn", END)

    checkpointer = SqliteSaver(sqlite3.connect("checkpoints.sqlite"))
    return builder.compile(checkpointer=checkpointer)

# Subagent: separate StateGraph with own call_model + tools loop
subagent_builder = StateGraph(SubagentState)
subagent_builder.add_node("call_model", subagent_call_model)
subagent_builder.add_node("tools", ToolNode([bash, read_file, write_file]))
subagent_builder.add_edge(START, "call_model")
subagent_builder.add_conditional_edges("call_model", subagent_route,
    {"tools": "tools", "__end__": END})
subagent_builder.add_edge("tools", "call_model")
subagent_graph = subagent_builder.compile()

Łącznik pomiędzy tymi dwoma wykresami znajduje się w narzędziu run_subagent:

@tool
def run_subagent(task: str) -> str:
    """Run an isolated subagent for a specific task."""
    result = subagent_graph.invoke({"messages": [HumanMessage(content=task)]})
    return result["messages"][-1].content  # only final result

Gdy model wywoła funkcję run_subagent, główny węzeł tools uruchamia tę funkcję, co z kolei aktywuje graf subagenta, który osobno wykonuje swoje pętle call_model i tools. Z powrotem przychodzi tylko ostateczna odpowiedź w formie ciągu znaków, więc główny graf traktuje to jako zwykły wynik narzędzia. Subagent nie posiada żadnych instrukcji, plików pamięci ani mechanizmów weryfikacji, więc jego kontekst jest prosty, a każde wywołanie jest niezależne. Przykład obejmuje jeden typ subagenta. Claude Code na czas wykonywania wybiera spośród kilku opcji (Explore do wyszukiwania, Plan do planowania, typ uniwersalny z każdym narzędziem oraz agenci typu plugin, takie jak vercel:ai-architect), z których każdy ma własne narzędzia, uprawnienia i prompt systemowy.

Prompty

Są to uproszczone wersje używane w replikacji LangGraph, natomiast rzeczywiste prompty Claude Code są bardziej szczegółowe.

Własny zwięzły prompt Claude Code jest znacznie większy – składa się z dziewięciu sekcji, pola do notatek <analysis>, wprowadzenia „CRITICAL: Respond with TEXT ONLY” oraz bloku z przykładowym formatem. Poniższa wersja skrócona zachowuje najważniejsze elementy:

Your task is to create a detailed summary of the conversation so far,
paying close attention to the user's explicit requests and your previous actions.
This summary should capture technical details, code patterns, and decisions
that would be essential for continuing the work without losing context.

Before writing the summary, analyze the conversation chronologically in <analysis> tags.
Then provide the summary in <summary> tags with these sections:

1. Primary Request and Intent: The user's explicit requests in detail.
2. Key Technical Concepts: Important technologies, frameworks, and patterns discussed.
3. Files and Code Sections: Specific files examined, modified, or created, with paths
   and why each was important.
4. Errors and Fixes: Errors encountered, how they were fixed, and any user corrections
   or feedback (pay special attention to "don't do X" or "actually I meant Y").
5. Problem Solving: Problems solved and any ongoing troubleshooting.
6. Pending Tasks: Tasks explicitly requested but not yet completed.
7. Current Work: What was being worked on immediately before this summary,
   with specific file names and details from the most recent messages.

W kodzie najpierw dzieli się rozmowę, przy czym trim_messages określa ostatnią część, którą należy zachować. Tylko starsza część trafia do modelu z tym promptem, a model ten widzi ją w całości (wiadomości użytkownika, odpowiedzi asystenta, wyniki narzędzi) i ją skraca. Pełna implementacja znajduje się w sekcji dotyczącej kompresji niżej.

Prompt służący do aktualizacji pamięci sesji w Claude Code wykorzystuje szablon składający się z dziesięciu sekcji, z ograniczeniami liczby tokenów w każdej sekcji, podstawieniem {{variableName}} oraz ścisłymi zasadami dotyczącymi zachowania nagłówków sekcji. Uproszczona wersja oddaje istotę rozwiązania:

Based on the conversation above, update the session notes.

Update each section below with specific, actionable details from the conversation.
Write info-dense content: file paths, function names, error messages, exact commands.
Skip a section if there are no new insights to add. Do not add filler.
Keep each section under 500 words. If approaching the limit, cycle out less important
details while preserving the most critical information.
Always update Current State to reflect the most recent work.

Sections:
# Session Title
# Current State
# Task Specification
# Files and Functions
# Errors and Corrections
# Key Results
# Next Steps

Śablon produkcji dodaje sekcje dotyczące przepływu pracy, dokumentacji bazy kodu, wniosków, kluczowych wyników oraz dziennika zadań, ograniczając długość każdej sekcji do 2000 tokenów, a całego pliku do 12 000 tokenów. Ponadto umożliwia edycję sekcji za pomocą agenta tła przy użyciu FileEdit, bez konieczności przepisywania całego pliku.

Stan: instrukcje poza wiadomościami

Przy każdej wywołaniu API instrukcje i informacje są ładowane z dysku i umieszczane w SystemMessage, tak jak robi to prependUserContext() w Claude Code. Nie są przechowywane w messages[], dlatego kompresja je ignoruje. Treść wywołanych umiejętności oraz pamięć sesji znajdują się w polach stanu, które przetrwają kompresję:

class AgentState(MessagesState):
    summary: str                    # compaction summary
    instructions: str               # CLAUDE.md equivalent (never compacted)
    invoked_skills: dict[str, str]  # skill name → content (survives compaction)
    session_memory: str | None      # background summary for instant compaction

# Cross-session memory: .agent/memory/*.md files read from disk every turn.
# Survives compaction (re-read from disk, never in messages[]).
# Agent creates memories via write_file tool. For production at scale,
# use a lightweight LLM call to select relevant files (Claude Code's approach).

Kompresja: streszczanie starych, przechowywanie najnowszych

Projekt opiera się na modelu Claude Code. Najnowsze wiadomości pozostają nienaruszone, ponieważ model właśnie nimi pracował, a streszczane są tylko te starsze.

def compact(state: AgentState) -> dict:
    messages = state["messages"]

    # Keep recent messages (Claude Code uses calculateMessagesToKeepIndex)
    kept = trim_messages(
        messages, strategy="last",
        token_counter=count_tokens_approximately,
        max_tokens=COMPACT_THRESHOLD // 5,
        start_on="human", end_on=("human", "tool"),
    )
    kept_ids = {id(m) for m in kept}

    # Summarise only the OLDER messages
    to_summarise = [m for m in messages if id(m) not in kept_ids]
    if not to_summarise:
        return {}

    response = model.invoke(
        to_summarise + [HumanMessage(content=COMPACT_PROMPT)]
    )

    # Delete old, keep recent, inject summary + re-inject skills
    delete = [RemoveMessage(id=m.id) for m in to_summarise]
    summary = HumanMessage(content=f"[Summary]\n{response.content}")
    skill_msgs = [
        HumanMessage(content=f"[Skill: {n}]\n{c[:5000]}")
        for n, c in state.get("invoked_skills", {}).items()
    ]
    return {"messages": [*delete, summary, *skill_msgs], "summary": response.content}

Wynikiem jest streszczenie, następnie zachowane najnowsze wiadomości, potem ponownie wstrzyknięte umiejętności, a nie pojedyncze streszczenie zastępujące wszystko. Wywołanie RemoveMessage tworzy nowy punkt kontrolny bez starych wiadomości, podczas gdy wcześniejsze punkty kontrolne pozostają w bazie danych w celu odzyskania danych, tak samo jak JSONL w Claude Code zachowuje wpisy sprzed skompresowania.

Pamięć: oparta na plikach, odczytywana przy każdej turze

Przykład pokazuje, w jaki sposób faktycznie działa Claude Code. Pliki pamięci znajdują się na dysku pod katalogiem .agent/memory/*.md, call_model odczytuje je przy każdej turze, a one nigdy nie trafiają do zbioru messages[]. Ponieważ są odczytywane ponownie z dysku, a nie odbudowywane na podstawie rozmowy, skompresowanie nie może ich utracić.

# In call_model — read memory files from disk every turn
MEMORY_DIR = Path(".agent/memory")
if MEMORY_DIR.exists():
    for mem_file in MEMORY_DIR.glob("*.md"):
        content = mem_file.read_text()[:4096]
        system_parts.append(f"Memory ({mem_file.stem}):\n{content}")

# Agent creates memories by writing files:
# write_file(".agent/memory/user_prefs.md", "User prefers concise responses.")

Claude Code poświęca osobny wywołanie Sonnet na każdą turę na wybór odpowiednich plików pamięci, maksymalnie 5 z wielu możliwych: szybki model otrzymuje pytanie oraz listę nazw i opisów plików, a następnie zwraca najlepsze dopasowania. Przykład ten ładuje wszystkie pliki, co jest prostsze, ale nie pozwala na skalowanie. Konfiguracje produkcyjne z wieloma zasobami pamięci powinny postępować zgodnie z oryginałem: lekki wywołanie LLM z pytaniem i indeksem pamięci decyduje o tym, co ma zostać załadowane.

Umiejętności: narzędzie z stopniowym ujawnianiem informacji

@tool
def load_skill(skill_name: str) -> str:
    """Load a specialised skill prompt by name."""
    return SKILL_REGISTRY.get(skill_name, f"Unknown skill: {skill_name}")

W plikach instrukcji wymień kluczowe umiejętności, aby przetrwały kompresję. Katalog umiejętności jest wygodą, a nie wymogiem.

Co jest przechowywane po sesji

Przyjmijmy sytuację, w której użytkownik zadaje kilka pytań, uruchamia kompresję, a następnie wraca później. Przechowywane dane agenta wyglądają w ten sposób:

.agent-data/
  checkpoints.sqlite       ~5 MB   (all state snapshots, grows with session)
  tool-results/
    bash-84ffc64a.txt      15 KB   (large bash output persisted with preview)

.agent/
  memory/
    project_goal.md        206 B   (cross-session, survives compaction)
    user_prefs.md          125 B   (read from disk every turn)

Dla tej samej pracy Claude Code przechowuje więcej danych, ponieważ wykorzystuje jedynie do dodawania treści plik w formacie JSONL, który nigdy nie jest usuwany, a także przechowuje oddzielne pliki dla każdego podagenta oraz skompresowaną kopię zapasową:

~/.claude/projects/{cwd}/
  {sessionId}.jsonl        22 MB   (full transcript, never deleted)
  {sessionId}/
    tool-results/          12 files
    subagents/             96 files (subagent transcripts + compact backups)
    session-memory/        (disabled)
  history.jsonl            13k lines (prompt recall, UI only)
  memory/                  4 files (auto-memory)

Checkpointer w LangGraph przechowuje zrzuty stanu zamiast dziennika zdarzeń, dzięki czemu jego rozmiar jest mniejszy. Wcześniejsze punkty kontrolne pozostają i można je sprawdzić za pomocą graph.get_state_history(config) podczas debugowania lub przywracania stanu.

Co obejmuje replika

Replika LangGraph zajmuje się głównymi elementami pracy: ładowaniem instrukcji z dysku, kompresowaniem starszych wiadomości przy zachowaniu najnowszych, zapisywaniem wyników dużych narzędzi do plików oraz ponownym odczytywaniem pamięci między sesjami za każdym razem. System produkcyjny dodaje selekcję pamięci sterowaną LLM poprzez szybki wywołanie Sonnet, załączniki typu delta obejmujące umiejętności i agenty, izolowane podagenty, hooki z pluginów oraz obsługę cache promptów. Dzięki takiemu układowi projektu stanowi przydatny punkt odniesienia dla każdego, kto buduje własnego agenta.

Literatura pokrewna