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

Пайплайн LangGraph, який зберігає точну інформацію про кодову базу для штучних інтелектуальних агентів

Створіть пайплайн LangGraph, який генерує файли знань для кожного сервісу, фільтрує їх за допомогою Ragas та DeepEval, відстежує виконання у Langfuse та також працює локально на Ollama.

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

Джерела: Codebase Memory for AI Agents: A LangGraph Pipeline That Actually Stays Accurate

Кожен, хто використовував агентів для обробки коду у великому репозиторії, стикається з одним питанням: як агент може ознайомитися з кодовою базою, не перечитуючи кожен файл для кожного завдання? Нижче наведена третя спроба його вирішення. У перших двох спробах використовувався підхід RAG на основі ембеддингів для документації. Обидва підходи зазнали невдачі вже протягом місяця, оскільки документація відставала від кожної зміни в коді. Ця третя версія справляється навіть з командою, яка робить тридцять–сорок комітів на день.

Це не огляд чиєгось формату файлів. Йдеться про сам процес: це граф станів LangGraph, який стежить за репозиторієм, створює структурований файл знань для кожного сервісу, оцінює точність власних результатів перед тим, як їх прочитає агент, та відстежує кожну операцію, щоб витрати були видимими. Також враховуються невдачі з першої спроби.

Що включається: чому проблеми з бюджетом токенів у сценаріях кодування з кількома агентами насправді є проблемою застарілої інформації, повна структура LangGraph (схема стану, вузли, умовні ребра, функція збереження стану в DynamoDB), можливість цитування в Amazon Bedrock з Claude Haiku 4.5, механізм оцінки, створений на основі Ragas та DeepEval, засоби для спостереження за роботою за допомогою самостійно розгорнутого Langfuse, повністю локальна версія Ollama для команд, які не можуть надсилати код до хмарних моделей, git-хук, який об’єднує всі ці елементи, та аналіз можливих способів збою.

Проблема

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

Альтернативою, яка працює, є невелика кількість ретельно підготовлених, структурованих файлів — по одному на кожен сервіс, у яких вказано, що він робить та від чого залежить; ці файли створюються один раз та автоматично оновлюються. Агент-оркестратор звертається до індексу, вибирає два-три файли, які є релевантними для завдання, та передає лише їх підагенту. Це стосується як розумного використання токенів, так і представлення знань.

Проблема не у форматі файлу. Markdown разом із YAML-фронтметером не є чимось новим. Складність полягає у механізмах, які запобігають тому, щоб ці файли ставали застарілими через зміни коду, який вони описують.

Архітектура

                       git push / commit
                               |
                               v
                  +-------------------------+
                  |   scan_diff (node)      |
                  |   git diff --name-only  |
                  |   scope to changed pkgs |
                  +-------------------------+
                               |
                               v
                  +-------------------------+
                  |   draft_concept (node)  |
                  |   Bedrock Claude Haiku  |
                  |   one file per service  |
                  +-------------------------+
                               |
                               v
                  +-------------------------+
                  |   add_citations (node)  |
                  |   link to runbooks/PRs  |
                  +-------------------------+
                               |
                               v
                  +-------------------------+
                  |   eval_gate (node)      |
                  |   Ragas faithfulness    |
                  |   DeepEval assert_test  |
                  +-------------------------+
                          pass |  | fail
                               |  +----> re-draft (max 2 retries) --> human review queue
                               v
                  +-------------------------+
                  |   relink (node)         |
                  |   fix cross-references  |
                  +-------------------------+
                               |
                               v
                  +-------------------------+
                  |   publish (node)        |
                  |   commit bundle to repo |
                  +-------------------------+

        every node emits a Langfuse trace
        every super-step checkpoints to DynamoDB

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

Стек та причина наявності кожної його частини

+------------------+---------------------------+--------------------------------------+
| Layer            | Tool                      | Why this one                          |
+------------------+---------------------------+--------------------------------------+
| Orchestration    | LangGraph                 | Explicit state, conditional retries,  |
|                  |                           | resumable from checkpoint             |
| Draft model      | AWS Bedrock, Claude       | Cheap enough to run on every commit,  |
|                  | Haiku 4.5                 | fast enough not to block CI            |
| Checkpoint store | DynamoDB                  | Single-digit ms reads, TTL for expiry, |
|                  | (langgraph-checkpoint-aws)| survives a crashed worker mid-run      |
| Eval, faithfulness| Ragas                    | Reference-free scoring of a generated  |
|                  |                           | file against the actual code diff     |
| Eval, CI gate    | DeepEval                  | pytest-native, hard-fails a bad draft  |
|                  |                           | instead of silently publishing it     |
| Observability    | Langfuse (self-hosted)   | Per-node token/cost/latency traces,    |
|                  |                           | MIT licensed, own your own data        |
| Local alternative| Ollama (llama3.1:8b,     | Same pipeline, zero cloud calls, for   |
|                  | nomic-embed-text)         | air-gapped or cost-sensitive teams     |
+------------------+---------------------------+--------------------------------------+

Крок 1: схема стану

У LangGraph усе починається з форми стану, яка передається між вузлами. Якщо це зробити неправильно, кожен наступний вузол буде змушений здогадуватися, що створив попередній вузол.

from typing import TypedDict, Literal
from langgraph.graph import StateGraph, END
class ConceptDraft(TypedDict):
    path: str              # e.g. "services/billing-service.md"
    concept_type: str      # "Service", "Runbook", "API Endpoint", ...
    title: str
    body: str              # the drafted markdown body
    citations: list[str]
    faithfulness_score: float
    retry_count: int
class PipelineState(TypedDict):
    repo_root: str
    commit_sha: str
    changed_files: list[str]
    drafts: list[ConceptDraft]
    failed_drafts: list[ConceptDraft]
    status: Literal["scanning", "drafting", "evaluating", "publishing", "done", "needs_review"]

Там немає нічого складного. Важливим є поле retry_count, адже воно запобігає тому, щоб поганий проект безкінечно намагався пройти перевірку.

Крок 2: обмежити сканування лише різницею

Сканування всього репозиторію після кожної зміни робить такі пайплайни занадто дорогими для постійної роботи. Обмеження сканування лише різницею робить їх достатньо дешевими для кожної зміни, а не лише для щоденного виконання.

import subprocess
def scan_diff(state: PipelineState) -> PipelineState:
    result = subprocess.run(
        ["git", "diff", "--name-only", f"{state['commit_sha']}~1", state["commit_sha"]],
        cwd=state["repo_root"], capture_output=True, text=True, check=True
    )
    changed = [f for f in result.stdout.splitlines() if f.endswith((".py", ".go", ".ts"))]
    # map file paths to the service/package that owns them
    packages = sorted({f.split("/")[1] for f in changed if "/" in f})
    state["changed_files"] = packages
    state["status"] = "drafting"
    return state

Призначення файлів до пакетів — це єдиний аспект, який справді є унікальним для кожного репозиторію. Монорепозиторій із організованою структурою services// потребує приблизно трьох рядків. Великий репозиторій зі старими проектами вимагає окремої карти власності, ідеально — шляхом аналізу файлу CODEOWNERS, а не припущень на основі шляхів.

Крок 3: розробка за допомогою Bedrock

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

import boto3
import json
bedrock = boto3.client("bedrock-runtime", region_name="us-east-1")
MODEL_ID = "us.anthropic.claude-haiku-4-5-20251001-v1:0"  # cross-region inference profile
DRAFT_PROMPT = """You are documenting a software service for an AI coding agent's memory.
Given the file contents below, write a concept file with:
- A one-paragraph Responsibilities section
- A Dependencies section listing what this service calls and what calls it
- Nothing else. Do not invent dependencies you cannot see in the code.
Files:
{file_contents}
"""
def draft_concept(state: PipelineState) -> PipelineState:
    drafts = []
    for package in state["changed_files"]:
        contents = read_package_source(state["repo_root"], package)
        response = bedrock.invoke_model(
            modelId=MODEL_ID,
            body=json.dumps({
                "anthropic_version": "bedrock-2023-05-31",
                "max_tokens": 800,
                "messages": [{"role": "user", "content": DRAFT_PROMPT.format(file_contents=contents)}]
            })
        )
        body = json.loads(response["body"].read())
        text = body["content"][0]["text"]
        drafts.append(ConceptDraft(
            path=f"services/{package}.md",
            concept_type="Service",
            title=package,
            body=text,
            citations=[],
            faithfulness_score=0.0,
            retry_count=0
        ))
    state["drafts"] = drafts
    state["status"] = "evaluating"
    return state

Одна інструкція в цьому запиті справді допомагає: не вигадувати залежності, яких немає в коді. Якщо залишити все як є, Haiku буде впевнено описувати залежність від сервісу, якого насправді немає. Кажуть, що це одне речення знизило частоту виникнення хибних описів залежностей з приблизно одного файлу на шість до майже нуля під час тестування. Не сприймайте це як універсальний закон — це залежить від кількості інформації, яка надається у запиті.

Крок 4: етап оцінки, який більшість ігнорує

Файл у форматі Markdown із полем типу frontmatter не може знати, чи говорить він правду. Жоден елемент цього формату не заважає штучному інтелекту створювати впевнений, добре сформатований, але хибний опис сервісу. Якщо пропустити цей етап, система може тихо спотворити розуміння агентом власного кодового базису, при цьому виглядаючи так само надійно, як і правильний варіант.

Ragas обчислює оцінку вірності, тобто наскільки добре чернетка ґрунтується на справжньому джерелі, без необхідності використання ручної контрольної відповіді. DeepEval перетворює це число на показник, за яким може діяти система CI.

from ragas.metrics import faithfulness
from ragas import evaluate
from datasets import Dataset
def score_faithfulness(draft: ConceptDraft, source_contents: str) -> float:
    eval_dataset = Dataset.from_dict({
        "question": ["Describe this service's responsibilities and dependencies."],
        "answer": [draft["body"]],
        "contexts": [[source_contents]],
    })
    result = evaluate(eval_dataset, metrics=[faithfulness])
    return result["faithfulness"][0]
from deepeval import assert_test
from deepeval.test_case import LLMTestCase
from deepeval.metrics import FaithfulnessMetric
def eval_gate(state: PipelineState) -> PipelineState:
    passed, failed = [], []
    for draft in state["drafts"]:
        source = read_package_source(state["repo_root"], draft["title"])
        score = score_faithfulness(draft, source)
        draft["faithfulness_score"] = score
        test_case = LLMTestCase(
            input="Describe this service.",
            actual_output=draft["body"],
            retrieval_context=[source]
        )
        metric = FaithfulnessMetric(threshold=0.75)
        try:
            assert_test(test_case, [metric])
            passed.append(draft)
        except AssertionError:
            draft["retry_count"] += 1
            failed.append(draft)
    state["drafts"] = passed
    state["failed_drafts"] = failed
    state["status"] = "publishing" if not failed else "needs_review"
    return state

Поріг у 0,75 є лише вихідною точкою, а не законом. Його підвищили з 0,6 після двох чернеток, коли пройшли заявки про залежності, які мали незначні помилки. Команда, яка використовує цей показник, повинна очікувати, що їй знадобиться тиждень на аналіз своїх показників хибно-позитивних та хибно-негативних результатів, перш ніж вона зможе довіряти цим цифрам.

Крок 5: підключення графа за допомогою контрольних точок DynamoDB

from langgraph_checkpoint_aws import DynamoDBSaver
checkpointer = DynamoDBSaver(
    table_name="codebase-memory-checkpoints",
    ttl_seconds=60 * 60 * 24 * 7  # expire checkpoints after a week
)
graph = StateGraph(PipelineState)
graph.add_node("scan_diff", scan_diff)
graph.add_node("draft_concept", draft_concept)
graph.add_node("eval_gate", eval_gate)
graph.add_node("relink", relink)
graph.add_node("publish", publish)
graph.set_entry_point("scan_diff")
graph.add_edge("scan_diff", "draft_concept")
graph.add_edge("draft_concept", "eval_gate")
graph.add_conditional_edges(
    "eval_gate",
    lambda s: "relink" if s["status"] == "publishing" else END,
    {"relink": "relink", END: END}
)
graph.add_edge("relink", "publish")
graph.add_edge("publish", END)
pipeline = graph.compile(checkpointer=checkpointer)

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

Крок 6: перегляд витрат

У пайплайн не було довіри, поки не стало видно кількість використаних токенів та тривалість роботи кожного вузла. Декоратор @observe() від Langfuse дозволив це зробити за п’ять хвилин, а не шляхом повної переробки.

from langfuse.decorators import observe
@observe()
def draft_concept(state: PipelineState) -> PipelineState:
    # same body as above, now traced automatically
    ...

Для самостійного розміщення потрібен лише файл docker-compose, що є обов’язковим, якщо ваш код, навіть різниці у запитах, має залишатися в межах вашої мережі:

git clone https://github.com/langfuse/langfuse.git
cd langfuse
docker compose up -d

Після того, як ClickHouse придбав цей проект, самостійно розгорнутий Langfuse зберігає записи як у Postgres, так і в ClickHouse, і самостійне розгортання залишається настільки ж надійним, як і раніше. Варто це підтвердити перед впровадженням інструментів моніторингу в продакшн, адже цілком логічно запитувати, чи не буде інструмент відмінений або зроблений платним.

Панель керування відображає три показники: вартість токена за кожен коміт, розподіл точності з часом та кількість спроб повторного виконання. Повільне зниження рівня точності свідчить про проблеми в логіці обробки даних, а не про погіршення якості моделі. Коли кількість спроб повторного виконання перевищує приблизно 10%, причиною завжди є запит, а не сама модель.

Локальна версія без хмари

Деякі команди навіть не можуть надсилати відмінності до розгорнутої моделі. Достатньо замінити вузол проектування на Ollama, і та сама структура буде працювати без жодних змін у архітектурі.

import ollama
def draft_concept_local(state: PipelineState) -> PipelineState:
    drafts = []
    for package in state["changed_files"]:
        contents = read_package_source(state["repo_root"], package)
        response = ollama.chat(
            model="llama3.1:8b",
            messages=[{"role": "user", "content": DRAFT_PROMPT.format(file_contents=contents)}]
        )
        drafts.append(build_draft(package, response["message"]["content"]))
    state["drafts"] = drafts
    return state

Для пошуку за допомогою вбудованих моделей у опублікованому пакеті, що стає корисним, коли кількість файлів-концептів перевищує сто, і самого index.md вже недостатньо, nomic-embed-text через Ollama — це модель, яку вже використовують більшість локальних систем RAG, тому немає потреби обґрунтовувати додавання нової залежності.

+------------------+------------------------+---------------------------+
| Component        | Cloud version          | Local version             |
+------------------+------------------------+---------------------------+
| Draft model      | Bedrock Claude Haiku   | Ollama llama3.1:8b        |
| Embeddings       | Bedrock Titan Embed    | Ollama nomic-embed-text   |
| Checkpoint store | DynamoDB               | SQLite (LangGraph built-in)|
| Observability    | Langfuse (self-hosted) | Langfuse (self-hosted)    |
+------------------+------------------------+---------------------------+

У тестах на тих самих вихідних файлах точність llama3.1:8b була приблизно на 10–15 пунктів нижчою, ніж у Haiku 4.5, переважно через те, що ця модель суворіше обробляє описи залежностей. Саме через цю різницю існує механізм оцінки незалежно від того, яка модель виконує завдання. Ви ж не хочете дізнаватися про різницю в якості через три тижні від заплутаного агента.

Git hook

#!/usr/bin/env bash
# .git/hooks/post-commit
set -euo pipefail

COMMIT_SHA=$(git rev-parse HEAD)
python -m codebase_memory.pipeline --commit "$COMMIT_SHA" --repo-root "$(git rev-parse --show-toplevel)"

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

Перевірка реальності у продакшені

Ось що варто знати перед тим, як це створювати.

  • Витрати реальні, але незначні. Для репозиторію з приблизно 40 комітами на день, які стосуються по два-три пакети кожен, вартість перевірки через Haiku draft становить кілька десятків доларів на місяць, а перевірка eval приблизно вдвічі більша, оскільки кожен проєкт оцінюється порівняно з його вихідним кодом. Це недорога ціна за те, щоб не надавати агенту неправильну документацію.
  • Затримка — це той компроміс, про який ніхто не згадує. Повний цикл сканування, створення чернетки, оцінки та публікації для одного коміту з пакетом у середовищі тестування займає від 15 до 25 секунд. Це підходить для асинхронної роботи CI, але не підходить як синхронна процедура під час комітування, оскільки помилка у першій версії залишиться непоміченою до того, як вона потрапить у фонову роботу.
  • Іноді система блокує правильну чернетку. Оцінка точності є недосконалою, тож точна, але стисла чернетка може опинитися нижче необхідного рівня. Усе, що провалюється двічі, потрапляє до черги для людського перегляду та не видаляється безслідно; це і є статус needs_review у машині станів.
  • Не варто розробляти цю систему без кількох агентів у вашому власному кодбазі. Уся користь походить від агентів, які обробляють пакети. Версія, створена лише для вбудовування, залишалася невикористаною протягом двох місяців, оскільки ніхто її не читав.
  • Відповідність пакету та файлу не буде передаватися коректно. Усе інше можна майже як скопіювати та вставити. Саме цю функцію доведеться переписати під власний формат, і для цього потрібен час, а не одна рядок коду.
  • Що будувати далі

    Наразі механізм оцінки враховує точність лише тоді, коли створюється чернетка, і ніколи не перевіряє опублікований файл. Наступним логічним кроком є планова повторна оцінка всього набору на основі сучасного вихідного коду, щоб виявити концепт-файли, які були точними на момент створення, але почали давати помилки після трьох оптимізацій, без автоматичного створення нової чернетки. Цього ще не було реалізовано.

    Пороги оцінки — це числа, які кожен налаштовує по-різному, і майже ніхто їх не публікує, тож якщо ви створите щось подібне, відстежуйте значення для себе та чи залишаються вони стабільними протягом перших кількох тижнів.