Головна / Статті

Внутрішні механізми контексту та пам’яті Claude Code, перебудовані як агент LangGraph

Як Claude Code створює виклики API, запускає субагентів та стискає довгі сеанси, а потім ті самі ідеї перебудовані в LangGraph за допомогою чекпоїнтерів та файлів пам’яті.

Видавець: REACTAPP.TOP11 хв читання2610 слів

Джерела: Scaling Agent Harnesses for Long-Running Sessions

Деталі витоку коду Claude Code v2.1.87 показують, як цей інструмент керує контекстом та пам’яттю під час тривалих сеансів програмування. Він вирішує типові проблеми довготривалих агентів: контекст, який перевищує обсяг моделі, результати роботи інструментів, які неможливо передбачити, відтворення пам’яті з попередніх сеансів у правильний момент та збереження інструкцій проекту після їх стиснення. Далі описується механізм та потім створюється спрощена версія в LangGraph із вбудованими інструментами для перевірки стану.

Як Claude Code керує контекстом

Запит API складається з трьох частин. Дві з них — системне повідомлення (ідентифікатор та контекст Git) та масив інструментів (JSON-схеми для Bash, Read, Edit, Skill, Agent та MCP) — залишаються поза масивом повідомлень, тож процес стиснення їх не торкається. Решта потрапляє до масиву повідомлень у вигляді записів типу user-role, обгорнутих у теги <system-reminder>: файли з інструкціями знаходяться у верхній частині, поточна розмова та результати роботи інструментів — у середній, а записи про додатки (спогади, списки навичок та агентів, інструкції MCP, результати виконання хуків) додаються після кожного етапу використання інструментів. Коли вікно заповнюється, процес стиснення об’єднує всі повідомлення та відновлює важливі додатки з їхніх початкових джерел. Далі йдуть цикл запитів, підагенти та процес стиснення.

Цикл запитів

Набір повідомлення запускає цикл, у якому кожна ітерація — це окремий виклик API, і він триває до тих пір, поки модель не дасть відповідь без жодних викликів інструментів.

Перед початком циклу фоновий виклик Sonnet збирає до п’яти відповідних файлів пам’яті, використовуючи лише текст запитання користувача, оскільки надсилання всієї розмови коштувало б занадто дорого. Результати не використовуються відразу; вони з’являються після виконання інструментів, тому перший виклик API у турі ніколи їх не включає.

Кожна ітерація очищує масив повідомлень у пам’яті перед тим, як він надійде до API. Спочатку список скорочується до елементів, що знаходяться після останньої межі компактування, причому повний файл JSONL на диску не змінюється. Потім будь-який результат інструменту занадто великого розміру записується у власний файл, а замість нього вставляється 2-кБ-ний прев’ю. Якщо після цього кількість токенів все ще перевищує приблизно 93% вікна контексту, відбувається компактування.

Відповідь може бути або простим текстом, який завершує раунд, або одним або кількома викликами інструментів, які виконуються та змушують процес продовжитися. Інструменти поділяються на групи: загальні, такі як Bash, Read, Edit та Grep; інструмент Skill, який додає до розмови повний файл SKILL.md; інструмент Agent, який запускає ізольований підагента, що повертає один результат; та ToolSearch, який отримує схему інструментів для наступної ітерації. Після завершення кожного раунду інструментів додаються додатки зі змінами для списків навичок, списків агентів, інструкцій MCP та будь-яких завершених операцій попереднього завантаження пам’яті, причому відправляється лише той матеріал, який змінився з минулого проходу.

Підагенти

Виклик інструменту Agent змушує Claude Code створити ізольований контекст у тому самому процесі. Підагент починає роботу з копією розмови батьківського агента, отримує власні інструменти та системний запит, працює із вимкненим механізмом обробки думок для зниження витрат на токени та виконує точно такий самий код циклу запитів самостійно. Агенти типу Explore та Plan також втрачають доступ до CLAUDE.md та git status з цієї копії, що дозволяє заощадити додаткові токени.

Лише остаточне повідомлення про результат надсилається назад до батьківського агента. Виклики інструментів, процеси міркувань та будь-яка компактизація даних усередині підагента записуються у відокремлений запис subagents/agent-{id}.jsonl, який батьківський агент ніколи не відкриває. Таким чином витрати залишаються ізольованими: десятки внутрішніх викликів інструментів можуть призвести до одного повідомлення в контексті батьківського агента.

Компактизація

У коді існує мікрокомпактний механізм для скорочення результатів старих інструментів перед повною компактацією, але він вимкнений та не враховується тут. Сама процедура компактації передбачає дві стратегії, причому друга використовується як альтернатива, коли перша недоступна. Перша стратегія негайно замінює її на підготовлений файл пам’яті сесії, тож компактація не потребує окремого виклику API; фоновий агент поступово складає цей файл за допомогою окремих викликів, причому цей шлях наразі заблокований через функціональні бар’єри. Друга стратегія, яка зараз використовується, — це повна компактація LLM. Агент, створений шляхом розгалуження, отримує поточні повідомлення та структурований запит на компактацію та скорочує розмову до приблизно п’яти тисяч токенів. Найновіші повідомлення зберігаються без змін поруч із цим оглядом, щоб модель все ще мала свій безпосередній робочий контекст, а всі старіші елементи замінюються на огляд.

Далі перебудовуються всі вкладення, які знаходилися у messages[] та були скорочені під час обробки. Списки агентів, назви інструментів із відкладеною обробкою та інструкції MCP знову оголошуються повністю, а функції початку сеансу виконуються ще раз. Контент навичок, який був викликаний, повертається з пам’яті, обмеженої приблизно 25 000 токенами, а п’ять найнещодавніше відкритих файлів знову читаються з диска. Системний промпт, tools[], файл Claude.md та контекст git ніколи не знаходилися у messages[], тому процес компактування не може вплинути на них; їхні кеш-зберігачі просто очищуються, щоб у наступному кроці отримати свіжі значення.

Є одна річ, яку навмисно не враховано: каталог навичок не перебудовується, що дозволяє заощадити приблизно 4 000 токенів. Негативним наслідком є те, що після стиснення у моделі немає способу знайти навички, якими вона ще не користувалася, якщо тільки Claude.md їх не перелічує. Стиснення також призводить до втрати деталей, оскільки попередній огляд об’єднується з усім іншим, і після третього проходження первісні факти були стиснуті у три рази. Нічого насправді не зникає, адже файл JSONL з можливістю лише додавання даних на диску зберігає всю історію, а маркер межі стиснення (parentUuid: null) вказує завантажувачу, звідки почати відновлення.

Спрощена версія LangGraph

За допомогою LangGraph можна створити меншу версію тієї самої архітектури. Він вже містить інструменти для зберігання даних у форматі чекпоїнтів та функцію RemoveMessage для видалення повідомлень. Механізм крос-сесійної пам’яті працює за тим самим принципом, що й у Claude Code – щоразу читаються файли у форматі markdown з диска. Логіку стиснення даних потрібно написати окремо.

У діаграмі відповідностей кожна стрілка з’єднує поняття з Claude Code зліва з його еквівалентом у LangGraph справа. Крайка, позначена як «покращення», вказує на те, що LangGraph може зберігати інструкції поза межами масиву messages[], тому процес стиснення їх не торкається.

Граф складається з п’яти вузлів. Пунктирні краї, що виходять від call_model, є умовними: вони передають виклики інструментів назад через tools, а текстові відповіді — до post_turn. У функції load_context файли пам’яті (.agent/memory/*.md) читаються з диска, а потім включаються до SystemMessage у call_model. Це запобігає їхньому потраплянню до списку messages[], тож вони залишаються недоторканими під час компактації.

Створення графа

Вузли виконуються по черзі, і після call_model умовна гілка або циклічно використовує інструменти, або переходить до post_turn. Показник стану SQLite зберігає всю інформацію про стан під час перезапуску процесу.

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()

Зв’язок між двома графіками знаходиться всередині інструменту 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

Як тільки модель викликає run_subagent, основний вузол tools виконує цю функцію, що запускає граф субагента, який окремо виконує власний цикл call_model та tools. Назад повертається лише кінцева відповідь у вигляді рядка, тому основний граф сприймає це як звичайний результат інструменту. У субагента немає інструкцій, файлів пам’яті чи механізмів перевірки, тому його контекст є простим, і кожен виклик є автономним. У зразку є один тип субагента. Claude Code на час виконання обирає серед кількох варіантів (Explore для пошуку, Plan для планування, універсальний тип із усіма інструментами та плагін-агенти на кшталт vercel:ai-architect), кожен з яких має власні інструменти, дозволи та системні запити.

Запити

Це спрощені версії, які використовуються під час реплікації LangGraph, тоді як справжні запити Claude Code є більш детальними.

Власний компактний промпт Claude Code значно більший — він складається з дев’яти розділів, зони для нотаток <analysis>, вступної фрази „CRITICAL: Respond with TEXT ONLY“ та блоку з прикладом формату. У наведеній нижче скороченій версії збережено основні елементи:

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.

У коді спочатку розділяється розмова, причому функція trim_messages визначає останню частину, яку потрібно зберегти. Лише старіша частина надсилається до моделі з цим запитом, і ця модель бачить усе (повідомлення користувача, відповіді асистента, результати інструментів) та стискає їх. Повна реалізація наведена у розділі зі стисненням нижче.

Запит на оновлення пам’яті сесії в Claude Code використовує шаблон із десятьма розділами з обмеженням кількості токенів у кожному розділі, підстановкою {{variableName}} та суворими правилами щодо збереження заголовків розділів. Спрощена версія передає основну ідею:

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

Шаблон створення додає розділи для робочого процесу, документації кодової бази, висновків, ключових результатів та щоденника роботи, обмежує кількість символів у кожному розділі до 2 000, а у всьому файлі — до 12 000, та передбачає використання фонового агента для редагування розділів за допомогою FileEdit без переписування всього файлу.

Стан: інструкції поза повідомленнями

Під час кожного виклику API інструкції та дані пам’яті завантажуються з диска та поміщаються у SystemMessage, як це робить prependUserContext() у Claude Code. Вони не зберігаються у arrays messages[], тому під час стиснення вони ігноруються. Контент викликаних навичок та пам’ять сеансу зберігаються у полях стану, які залишаються після стиснення:

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).

Компактування: узагальнювати старі, зберігати нові

Дизайн відповідає принципам Claude Code. Нові повідомлення залишаються недоторканими, оскільки модель щойно працювала з ними, а узагальнюються лише старіші.

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}

Результатом є підсумок, потім останні збережені повідомлення, а далі знову введені навички; це не просто один підсумок, який замінює все. Виклик RemoveMessage створює нову точку контролю без старих повідомлень, тоді як попередні точки контролю залишаються в базі даних для відновлення, так само як JSONL у Claude Code зберігає записи до стиснення.

Пам’ять: на основі файлів, читається щоразу

У прикладі відтворюється той спосіб, яким це насправді робить Claude Code. Файли пам’яті знаходяться на диску у форматі .agent/memory/*.md, call_model читає їх щоразу, і вони ніколи не потрапляють до списку messages[]. Оскільки їх знову читають з диска, а не створюють знову на основі розмови, стиснення не може їх втратити.

# 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 витрачає окремий виклик Sonnet за кожен хід на вибір відповідних файлів пам’яті — до 5 з потенційно багатьох: швидка модель отримує запит та список назв та описів файлів, а потім повертає найкращі результати. У прикладі завантажуються всі файли, що полегшує роботу, але не дозволяє масштабуватися. У продакшн-системах із багатьма джерелами пам’яті слід дотримуватися початкового підходу: легкий виклик LLM із запитом та індексом пам’яті визначає, що потрібно завантажити.

Навички: інструмент із поступовим розкриттям інформації

@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}")

Вкажіть критичні навички у файлах інструкцій, щоб вони збереглися під час стиснення. Каталог навичок є зручністю, а не обов’язковою вимогою.

Що зберігається після сеансу

Уявіть сеанс, під час якого користувач ставить кілька запитань, ініціює стиснення, а потім повертається пізніше. Збережені дані агента виглядають так:

.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)

Для тієї ж роботи Claude Code зберігає більше даних, оскільки він використовує транскрипт у форматі JSONL, який можна лише додавати до нього та який ніколи не видаляється, а також зберігає окремі файли для кожного підагента та компактну резервну копію:

~/.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)

Чекпоїнтер LangGraph зберігає знімки стану замість журналу подій, тому обсяг використання менший. Попередні чекпоїнти залишаються та можуть бути переглянуті за допомогою graph.get_state_history(config) під час дебаггінгу чи відновлення.

Що охоплює репліка

Репліка LangGraph виконує основні завдання: завантаження інструкцій з диска, стиснення старіших повідомлень при збереженні новіших, зберігання результатів роботи інструментів у файлах та повторне читання пам’яті між сеансами. У продакшн-системі додається вибір пам’яті за допомогою LLM через швидкий виклик Sonnet, додаткові дані про навички та агентів, ізольовані підагенти, підключення від плагінів та обробка кешу запитів. Завдяки такій структурі це зручний посібник для кожного, хто створює власних агентів.