Klepa AI
Установить
Часть 2 · Агент в вашем проекте · Урок 3

Память проекта: что агенту знать заранее и как это хранить

Модель ничего не помнит между сессиями. Всё, что агент должен знать о проекте, надо где-то хранить и вовремя ему подавать. Разберём, что хранить, как раскладывать и как доставать без лишних токенов.

~50 минут 4 интерактива 3 песочницы с проверками Примеры: JS · TS · PHP · Python

После урока вы сможете

  • объяснить, зачем агенту внешняя память и чем она отличается от окна контекста;
  • разложить знания проекта на заметки: правила, решения, справку, грабли;
  • выбрать способ выдачи знаний: всё сразу, оглавление или поиск;
  • написать простой поиск по заметкам с бюджетом токенов;
  • не дать устаревшей заметке сломать работу агента.
1 Аналогия

Библиотекарь с каталогом

Представьте

Новому сотруднику не читают вслух всю вики компании. Ему дают оглавление и говорят: «Нужно что-то — найди по каталогу». На столе лежат только книги для текущей задачи.

Как библиотекарь находит ответ

схема
Вопрос«Как выкатить бэкенд?»
→
Каталогоглавление: 40 заметок, по строке на каждую
→
Полкаберём 2 подходящие заметки
→
Столтолько они попадают в контекст
На столе пусто.
Блокнот и вики новичка= заметки памяти агента
Оглавление вики= индекс (одна строка на заметку)
Библиотекарь= поиск нужного по задаче
Рабочий стол= окно контекста
Устаревшая страница вики= заметка, которая разошлась с кодом

Главная мысль: хорошая память — это не «запомнить всё». Это быстро найти нужное и не тащить лишнее. Каждая лишняя заметка на столе стоит денег и мешает модели заметить главное.

2 Объяснение

От простого к сложному

Уровень 1 · Суть

Внешняя память — это папка с текстом и правило, как её читать

Из урока 1 вы знаете: модель не хранит состояние. Закрыли сессию — всё забыто. Окно контекста ограничено. Значит, знания о проекте надо держать снаружи, в обычных файлах. Это и называют Second Brain (второй мозг).

Схема простая. Перед задачей harness читает оглавление, выбирает подходящие заметки и кладёт их в контекст. После задачи агент дописывает новое знание.

// Перед задачей
const index = read("memory/INDEX.md");            // 40 строк: имя + описание
const picked = choose(index, task);              // 2–3 заметки по теме задачи
context.push(...picked.map(name => read(`memory/${name}.md`)));

// После задачи
if (learnedSomethingNew) write("memory/new-fact.md", note);
Что важноПамять — не замена коду и документации. Это короткие подсказки: где лежит правда, какие решения уже приняты, на какие грабли уже наступали.
Уровень 2 · Детали

Что хранить и как раскладывать

Пять видов знаний, которые окупаются почти в любом проекте:

  • Правила проекта. «Деньги храним в копейках, целым числом». «Миграции только через Doctrine».
  • Архитектурные решения. Формат ADR: контекст, решение, последствия. Объясняет «почему так», чтобы агент не «улучшил» обратно.
  • Справка. Как запустить проект, тесты, деплой. Команды, которые агент выполнит сам.
  • Продуктовый контекст. Кто пользователи, что для бизнеса важно, какие слова нельзя писать клиентам.
  • Грабли. Инциденты и их причины: «перезапуск воркера руками обрывает очередь».

Как раскладывать. Одна мысль — один маленький файл. В начале файла — frontmatter: имя, однострочное описание, тип. Отдельный файл-оглавление собирает по строке на каждую заметку. Заметки ссылаются друг на друга через [[имя]].

---
name: money-in-kopecks
description: Деньги храним целым числом в копейках, никогда не float
type: rule
updated: 2026-09-14
---
Все суммы в БД и коде — integer в копейках (1 BYN = 100).
Float теряет точность: 0.1 + 0.2 != 0.3.
Связано: [[adr-003-money-type]], [[incident-refund-rounding]]

Три способа выдачи знаний агенту:

  • Всё сразу. Склеить все заметки в системный промпт. Просто, но дорого. При росте базы важное тонет в шуме.
  • Оглавление и подгрузка. Агент видит только индекс и сам открывает нужные файлы. Дёшево, точно, но требует хороших описаний.
  • Поиск. Программа находит заметки по тексту задачи и подкладывает их. Работает на больших базах. Может промахнуться.
Пример структуры каталога памяти — как это выглядит на диске
memory/
├── INDEX.md                      ← оглавление: по строке на заметку
├── rules/
│   ├── money-in-kopecks.md
│   └── migrations-only-doctrine.md
├── decisions/
│   ├── adr-001-symfony-messenger.md
│   └── adr-003-money-type.md
├── howto/
│   ├── run-tests.md
│   └── deploy-backend.md
├── product/
│   └── never-mention-fees.md
└── incidents/
    └── 2026-08-worker-restart.md
# INDEX.md
- [money-in-kopecks](rules/money-in-kopecks.md) — деньги целым числом в копейках
- [deploy-backend](howto/deploy-backend.md) — выкатка только через make up
- [adr-001-symfony-messenger](decisions/adr-001-symfony-messenger.md) — очереди на Messenger, не на cron
- [2026-08-worker-restart](incidents/2026-08-worker-restart.md) — ручной рестарт воркера обрывает очередь
Пример ADR — контекст, решение, последствия
# ADR-003. Тип для денежных сумм

Статус: принято, 2026-03-02

## Контекст
Суммы хранились во float. При возвратах накопились расхождения в 1 копейку.

## Решение
Храним суммы целым числом в копейках (BIGINT). В коде — value object Money.

## Последствия
+ Нет ошибок округления.
- При выводе нужно делить на 100. Все старые колонки переводим миграцией.
- Внешние API, которые отдают float, переводим в копейки на входе.
AGENTS.md, который отсылает к памяти — точка входа для агента

Файл правил (урок 2) остаётся коротким. Он не пересказывает всё, а говорит, где искать.

# Правила для агента

- Перед задачей прочитай memory/INDEX.md и открой заметки по теме.
- Заметка — подсказка, а не правда. Перед действием сверь её с кодом.
- Если заметка разошлась с кодом — прав код. Исправь заметку и обнови дату.
- Новое знание (решение, грабли) — отдельной заметкой и строкой в INDEX.md.
- Как запускать тесты и деплой — docs/howto/*.md, не выдумывай команды.
Уровень 3 · Усложнение

Поиск по смыслу, устаревание и иерархия правды

Поиск по словам находит заметки, где есть слова из запроса. Простой подсчёт совпадений работает плохо: частые слова вроде «код» есть везде. Алгоритм BM25 даёт больший вес редким словам и не любит слишком длинные документы.

Поиск по смыслу решает проблему синонимов. «Деплой» и «выкатка» — разные слова, но смысл один. Текст превращают в эмбеддинг: координаты смысла. Представьте карту, где похожие по смыслу фразы лежат рядом. Поиск — это «найди точки рядом с вопросом». Схему «найти, подложить в контекст, ответить» называют RAG.

Устаревание — главная беда любой базы знаний. Код меняют каждый день, а заметки — редко. Правила гигиены:

  • в заметке есть дата обновления;
  • перед действием агент сверяет заметку с кодом;
  • нашёл расхождение — исправил заметку сразу, а не завёл вторую рядом;
  • противоречивые заметки — это баг, его чинят.
Иерархия источников правдыКогда источники спорят, побеждает старший: прямое указание человека в задаче, потом код и тесты, потом спецификации (урок 5), потом документация, и только потом заметки памяти. Память лишь подсказывает, где смотреть.
BM25 и косинусная близость без страха — для любопытных

BM25 для каждого слова запроса считает два множителя и складывает результат по всем словам:

  • IDF (редкость слова): чем в меньшем числе заметок встречается слово, тем оно ценнее. «Doctrine» ценнее, чем «проект».
  • TF с насыщением: второе упоминание слова в заметке добавляет меньше, чем первое, десятое — почти ничего. Длинные заметки слегка штрафуются.
score(док, запрос) = Σ IDF(слово) · tf·(k+1) / (tf + k·(1 − b + b·длина/средняя_длина))
обычно k ≈ 1.2, b ≈ 0.75

близость(a, b) = (a · b) / (|a| · |b|)   ← косинус угла между векторами смысла, от −1 до 1

Эмбеддинги считает отдельная модель. Векторы хранят в базе с поиском ближайших соседей (например, расширение pgvector для Postgres). На старте проекта часто хватает обычного поиска по словам и хорошего оглавления.

Простой поиск по заметкам в коде — JS, TS, PHP, Python

Подсчёт совпавших слов без стоп-слов. Не BM25, но для базы из сотни заметок уже полезно.

import { readdirSync, readFileSync } from "node:fs";

const STOP = new Set(["и", "в", "на", "как", "что", "по", "с", "для", "не"]);
const words = s => s.toLowerCase().match(/[a-zа-яё0-9]+/g)?.filter(w => !STOP.has(w)) ?? [];

function search(dir, query, limit = 3) {
  const q = new Set(words(query));
  return readdirSync(dir)
    .filter(f => f.endsWith(".md"))
    .map(f => {
      const text = readFileSync(`${dir}/${f}`, "utf8");
      const score = new Set(words(text).filter(w => q.has(w))).size;
      return { file: f, score };
    })
    .filter(r => r.score > 0)
    .sort((a, b) => b.score - a.score)
    .slice(0, limit);
}

console.log(search("memory", "как выкатить бэкенд на прод"));
import { readdirSync, readFileSync } from "node:fs";

interface Hit { file: string; score: number }

const STOP = new Set(["и", "в", "на", "как", "что", "по", "с", "для", "не"]);
const words = (s: string): string[] =>
  (s.toLowerCase().match(/[a-zа-яё0-9]+/g) ?? []).filter(w => !STOP.has(w));

export function search(dir: string, query: string, limit = 3): Hit[] {
  const q = new Set(words(query));
  return readdirSync(dir)
    .filter(f => f.endsWith(".md"))
    .map((file): Hit => {
      const text = readFileSync(`${dir}/${file}`, "utf8");
      return { file, score: new Set(words(text).filter(w => q.has(w))).size };
    })
    .filter(h => h.score > 0)
    .sort((a, b) => b.score - a.score)
    .slice(0, limit);
}
<?php
const STOP = ['и', 'в', 'на', 'как', 'что', 'по', 'с', 'для', 'не'];

function words(string $s): array
{
    preg_match_all('/[a-zа-яё0-9]+/u', mb_strtolower($s), $m);
    return array_values(array_diff($m[0], STOP));
}

function search(string $dir, string $query, int $limit = 3): array
{
    $q = array_unique(words($query));
    $hits = [];
    foreach (glob($dir . '/*.md') as $file) {
        $score = count(array_intersect($q, array_unique(words(file_get_contents($file)))));
        if ($score > 0) {
            $hits[] = ['file' => basename($file), 'score' => $score];
        }
    }
    usort($hits, fn ($a, $b) => $b['score'] <=> $a['score']);
    return array_slice($hits, 0, $limit);
}

print_r(search(__DIR__ . '/memory', 'как выкатить бэкенд на прод'));
import re
from pathlib import Path

STOP = {"и", "в", "на", "как", "что", "по", "с", "для", "не"}

def words(s: str) -> list[str]:
    return [w for w in re.findall(r"[a-zа-яё0-9]+", s.lower()) if w not in STOP]

def search(folder: str, query: str, limit: int = 3) -> list[tuple[str, int]]:
    q = set(words(query))
    hits = []
    for path in Path(folder).glob("*.md"):
        score = len(q & set(words(path.read_text(encoding="utf-8"))))
        if score:
            hits.append((path.name, score))
    return sorted(hits, key=lambda h: -h[1])[:limit]

print(search("memory", "как выкатить бэкенд на прод"))
3 Интерактив

Память вымышленного проекта Shopik

Shopik — бэкенд небольшого интернет-магазина на Symfony. У него есть база из 16 заметок. На ней и поработаем.

Карта знаний

интерактив 1 из 4

Нажмите на заметку. Увидите её текст, на кого она ссылается и кто ссылается на неё. Так агент переходит от одной заметки к соседним.

выбрана она ссылается ссылаются на неё
Выберите заметку.

Поиск и бюджет контекста

интерактив 2 из 4

Введите вопрос. Поиск упрощённый, похож на BM25: редкие слова весят больше. Окончания грубо обрезаются до 5 букв. Ползунок задаёт, сколько токенов мы готовы отдать памяти. Что не влезло — агент не увидит.

0заметок в контексте
0токенов занято
0токенов, если грузить всё
    Почему «деплой» ничего не нашёл — откройте после пробы

    В заметках написано «выкатка» и «make up», слова «деплой» нет. Поиск по словам не знает, что это синонимы. Варианты лечения: добавить синонимы в описание заметки, держать хорошее оглавление, чтобы агент выбирал сам, или включить поиск по смыслу (эмбеддинги).

    Заметка говорит одно, код — другое

    интерактив 3 из 4

    Задача агенту: «Добавь поле deliveryDate в ответ GET /api/orders/{id}». Заметка написана полгода назад. Переключите режим и пройдите по шагам.

    Память: orders-serialization.md

    Код проекта

      Шаг 0

      Всё сразу, оглавление или поиск

      интерактив 4 из 4

      Пять вопросов агенту по базе Shopik на 60 заметок. Выберите стратегию и посмотрите, сколько токенов она съела и сколько ответов верны. Цифры учебные, но соотношение типичное.

      ВопросРезультатПочему

      4 Практика

      Песочницы с автопроверкой

      Код выполняется прямо в браузере. Нажмите «Запустить» или Ctrl+Enter. Под выводом появятся проверки, как в юнит-тестах.

      Песочница 1. Разберите заметку

      JS

      Напишите parseNote(md). Если файл начинается со строки ---, до следующей --- идут строки ключ: значение. Значение — всё после первого двоеточия, без пробелов по краям. Остальное — тело. Из тела достаньте ссылки [[имя]]: без повторов, в порядке появления. Нет frontmatter — meta пустой объект, тело — весь текст.

      Песочница 2. Поиск с бюджетом токенов

      JS

      Напишите search(notes, query, budget). Заметки и стоп-слова уже загружены: переменные NOTES и STOP, функция tokensOf(note) оценивает размер заметки. Правила:

      1. Слова: только буквы и цифры, в нижнем регистре, без стоп-слов.
      2. Очки заметки — сколько разных слов запроса встречается в её name, description или body.
      3. Заметки с нулём очков не берём. Сортировка по очкам убыванием, при равенстве — по имени.
      4. Идём по списку и берём заметку, если она влезает в оставшийся бюджет. Не влезла — пропускаем и пробуем следующую.
      5. Вернуть массив имён.
      Что посмотреть в заметках — данные песочницы

      В NOTES восемь заметок: deploy-backend, worker-restart, run-tests, money-in-kopecks, adr-001-messenger, docker-local, never-mention-fees, refund-rounding. Напечатайте console.log(NOTES), чтобы увидеть всё.

      Песочница 3. Проверка ADR перед сохранением

      JS

      Агент пишет новый ADR. Прежде чем сохранить, harness проверяет формат. Напишите checkAdr(md): вернуть список недостающих частей. Нужны строка Статус: и разделы ## Контекст, ## Решение, ## Последствия. Раздел считается пустым, если под заголовком нет текста до следующего заголовка — тогда ошибка "пусто: Решение".

      Проверьте себя

      Зачем агенту внешняя память, если у модели огромное окно контекста?

      Окно живёт один запрос. Новая сессия начинается с нуля. Память в файлах переживает сессии, а выдача по задаче экономит токены.

      Заметка говорит «сериализатор — JMS», а в коде Symfony Serializer. Что должен сделать агент?

      Код — источник правды выше памяти. Расхождение — баг заметки. Его чинят сразу, а не копят вторую заметку рядом.

      Почему плохо просто склеить все 300 заметок в системный промпт?

      Помните урок 1: история отправляется на каждом шаге, и эффект «потерянной середины». Оглавление или поиск дают те же знания за долю токенов.

      Что такое эмбеддинг простыми словами?

      Поэтому поиск по эмбеддингам находит «выкатку» по запросу «деплой»: слова разные, смысл рядом.

      Задание на своём проекте — 30 минут
      1. Создайте в репозитории папку memory/ (или docs/agent/).
      2. Напишите пять заметок с frontmatter: как запустить проект, как запустить тесты, одно правило, одно архитектурное решение в формате ADR, одни грабли из вашей практики.
      3. Соберите INDEX.md: по строке на заметку, с хорошим описанием. Проверьте: по одной строке понятно, когда открывать заметку?
      4. Добавьте в файл правил агента три строки: «прочитай INDEX.md», «сверяй заметку с кодом», «нашёл расхождение — исправь заметку».
      5. Дайте агенту задачу, где пригодится одна из заметок. Посмотрите, открыл ли он её.
      5 Чек-лист

      Отметьте, что усвоили

      • Понимаю, зачем агенту внешняя память и почему окна контекста недостаточно.
      • Знаю пять видов знаний: правила, решения (ADR), справка, продуктовый контекст, грабли.
      • Умею писать заметку: одна мысль, frontmatter, ссылки на соседей.
      • Могу сравнить три способа выдачи: всё сразу, оглавление, поиск.
      • Понимаю разницу между поиском по словам и по смыслу (эмбеддинги).
      • Знаю иерархию правды: человек, код и тесты, спецификации, документация, память.
      • Решил песочницы: разбор заметки и поиск с бюджетом.
      • Знаю, что делать с устаревшей заметкой: исправить сразу и обновить дату.
      Урок пройденТеперь у агента есть память, которая не врёт. Дальше подключим агента к вашим системам через MCP и поставим хуки.