Канвеер LangGraph, які трымае памяць пра кодавую базу для AI-агентаў дакладнай
Будуем канвеер LangGraph, які піша файлы ведаў для кожнага сэрвісу, правярае іх праз Ragas і DeepEval, трасіруе запускі ў Langfuse і працуе таксама лакальна на Ollama.
Крыніцы: 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 для каманд, якія не можуць адправляць код у хмарныя мадэлі, гіт-хук, які ўсё гэта з’едынае, а таксама аналіз можлівых спосабоў неудачы.
Проблема
Калі калькуляр пачынае віконванне задання ў рэпазітарыі, яго не ведае, што оба яго варыянты ёсць паслабнымі. Ён можа прачытаць усё, чаго яно толькі можа, але гэта спалюе токены на файлах, якія не стосуюцца самай задачы. Але ён таксама можа вікорыстоўваць метод адшуку на базе эмбедынгаў, які вярта семантычна схожыя фрагменты, але не дае чыткай адпаведзі на пытанні, хто вызывае шта і хто ёсць власнікам модуля.
Альтэрнатыва, якая працюе, — гэта калькі структураваных файлаў, по аднаму на кожную службу, у якіх паказана, што яна робіць і чаму ў яе ёсць патрэба, якія ствараюцца аднойчы і автаматычна апцэнтруюцца. Агент-оркестратар абмовляецца з індэксам, выбірае два-тры файлы, якія ўзмацнююць задачу, і пасля перадае толькі іх падагенту. Гэта таксама стосуецца розумнага выкарыстання токенаў, як і прэдставлення знанняў.
Складнасць не ў фармаце файлаў. 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/<name>/ дастаткова блізько трох радків. У велікіх рэпазіторыях з історычнім кодом потрыбна окрэма карта власносты, якая створуецца шляхом аналізу файлу 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 не можа ведаць, чы розпаведвае ён правду. Нічога ў гэтым фармаце не заважае LLM пісаць абавярную, добра сфарматаваную, але некорэктную характэрыстыку службы. Калі прыхіляцца да гэтага этапу, сістэма можа таямна паспяліць агенту неправільна разумець свой сабэт кода, пры тым выглядаючы абсалютна надзеянна, як і правільны варыянт.
Ragas вычысляе балан, а DeepEval — крытэрыя пас/фейл, таму што яны рашаю разныя задачы. 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 пасля двух версыйяў, калі працэўна прыйнятыя заявы пра залежнасці былі часткова некальканосныя. Команды, якія выбіраюць гэты прагмат, должны спакоўвацца на тое, што ў iх пайдзе адзін тыдзень на аналіз паказаных колькасцей хыбна-паазывных і хыбна-непаазывных рэзультатаў, прычаму яны зможуць давераць гэтым цыфрам.
Шаг 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 прыбліжна удвое вышэ, адколькі кожны праект оцінюецца па своему варыянту коду. Цэе недорога цена за тое, каб не падаваць некорэктную дакументацыю агенту.
needs_review у машыне стаўасоў.Што будаваць далей
У настоўчым часе механізм eval ацэнюе ступень адпаведнасці толькі калі напісана прачырнёвка, і ніколі не пераглядае ўжо опублікованы файл. Наступны логічны крок – планавана перэацэнка всіх элементаў пакета на аднойчыны існуючым кодам, каб выявіць файлы-канцэпты, якіе былі правільныя ў момент стварэння, але пасля трох карэктур сталі некорэктнымі, пры чым не была запускана новая версія. Гэтага ўсё яшчэ не зроблена.
Праграмы ацэнкі – гэта цыфры, якія кожны налаштоввае по-свойму, і майжэ ніхто іх не публікуе; таму, калі вы створыце ўльтраподобны прыём, сачыце за тым, на якім роўні будзе ваша ацэнка, і чы працюе яна ўсё тое ж пасля першых калькі недзеў.