Архив

Шесть AI-проектов: полевой справочник

Что строить, как строить и где оно сломается. Личная шпаргалка, расширенная под Go, PostgreSQL, Redis и Kafka.

Оригинал даёт по пять шагов на проект. Здесь каждый шаг раскрыт: зачем он нужен, какие решения внутри, где обычно ломается, как проверить, что работает, и как перенести это на мой стек. Факты о Claude API сверены с официальной документацией в октябре 2026.

Одна мысль на все шесть проектов: модель предлагает, детерминированный код проверяет. В каждом проекте после модели стоит валидатор, который ей не доверяет: в RAG это проверка цитат, в Text-to-SQL парсер и права базы, в LoRA бенчмарк против базовой модели, в evals гейт регрессий, в кэше порог близости, в агенте типизированные схемы и подтверждение человеком. Если в твоей системе такого слоя нет, она работает на удаче.

Обзор

№ Проект Чему учит Сложность Главный риск
01 Hybrid Search RAG retrieval, ранжирование, ответы с опорой на источник средняя уверенный ответ без опоры на документы
02 Text-to-SQL with Guardrails безопасное исполнение кода, который написала модель средняя деструктивный или неподъёмный запрос
03 LoRA Fine-Tuning дообучение, датасеты, работа с GPU высокая переобучение и потеря общих способностей
04 Eval Dataset Generator измерение качества и регрессий средняя средний балл прячет провал в важном сегменте
05 Semantic Caching Layer стоимость и латентность низкая ложное попадание: чужой или неподходящий ответ
06 Agent Orchestration state machines, инструменты, надёжность высокая циклы, взрыв стоимости, необратимые действия

§ 1. Hybrid Search RAG over Internal Docs

Суть: ответы по внутренней документации с опорой на источники. Два ретривера (по смыслу и по ключевым словам) закрывают слабые места друг друга, реранкер выбирает лучшее, модель отвечает только по найденному.

Стек оригинала: Python · LangChain · Claude API · Pinecone · rank_bm25 · Cohere Rerank · FastAPI

На моём стеке: Go · PostgreSQL + pgvector · Postgres full-text search · Claude API (Go SDK) · реранкер по API

Шаги

  1. Нарезать документы на чанки, посчитать эмбеддинги, сложить в Pinecone.

    Резать по структуре документа (заголовки, абзацы, списки), а не по фиксированному числу символов. Ориентир: 300-800 токенов на чанк, перекрытие 10-20%. У каждого чанка: стабильный id, doc_id, заголовок раздела, url, дата обновления, права доступа. Полезный приём: перед эмбеддингом дописывать в начало чанка название документа и раздела. Иначе фрагмент «увеличьте таймаут до 30 секунд» теряет контекст: таймаут чего?

  2. Построить BM25-индекс по тому же корпусу.

    Тот же набор чанков и те же id, что в векторном индексе, иначе слияние на шаге 3 ломается. BM25 ловит то, что эмбеддинги размывают: коды ошибок, артикулы, имена, аббревиатуры, номера тикетов. Для русского и казахского нужна нормализация (нижний регистр, стемминг или лемматизация), иначе «договор» и «договора» становятся разными токенами.

  3. Запустить оба ретривера и слить результаты через Reciprocal Rank Fusion.

    Каждый ретривер возвращает top-N (около 50). RRF складывает не скоры (у dense и BM25 несравнимые шкалы), а позиции: score(d) = Σ 1 / (k + rank_i(d)), обычно k = 60. Выигрывает документ, который стоит высоко в обоих списках. Нормализовать скоры не нужно, в этом вся прелесть метода.

  4. Переранжировать объединённый список через Cohere Rerank и оставить top-k.

    Реранкер (cross-encoder) читает запрос и чанк вместе, поэтому точнее эмбеддингов, но медленнее и дороже. Подавать ему только кандидатов после RRF (20-50 штук), в контекст модели оставлять 5-10. Это главный рычаг качества: retrieval отвечает за полноту, реранкер за точность.

  5. Сгенерировать ответ с inline-цитатами и проверить, что каждое утверждение опирается на источник.

    Модели разрешено отвечать только по переданным чанкам, а если опоры нет, честно говорить «в документации этого нет». В Claude API есть нативная функция Citations: чанки передаются как документы с citations.enabled, ответ приходит блоками со ссылками на конкретные места источников. API гарантирует, что ссылки указывают на реально переданные документы, а процитированный текст не тарифицируется как output. Для RAG-чанков подходят custom content documents: API не режет их дальше. Ограничение: Citations нельзя совмещать со structured outputs. Проверка «утверждение действительно следует из источника» остаётся за тобой: выборочно через LLM-судью.

Ключевые идеи

  • Dense и sparse ошибаются в разных местах: эмбеддинги понимают синонимы, но путают коды и имена; BM25 наоборот. Гибрид дешевле, чем выжимать максимум из одного.
  • Сначала полнота, потом точность: широкий retrieval, узкий rerank.
  • Права доступа фильтруются до retrieval (фильтр по метаданным), а не после генерации.
  • Меньше контекста часто лучше: 6 хороших чанков бьют 30 средних.

Код

RRF на Python
def rrf(rankings: list[list[str]], k: int = 60) -> list[tuple[str, float]]:
    scores: dict[str, float] = {}
    for ranking in rankings:
        for rank, doc_id in enumerate(ranking, start=1):
            scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
    return sorted(scores.items(), key=lambda x: x[1], reverse=True)

candidates = rrf([dense_ids, bm25_ids])[:50]   # дальше в реранкер
Гибридный поиск одним запросом в Postgres
-- chunks: id, content, embedding vector(...), tsv tsvector
-- tsv: GENERATED ALWAYS AS (to_tsvector('russian', content)) STORED + GIN-индекс
-- $1: эмбеддинг запроса, $2: текст запроса
WITH semantic AS (
  SELECT id, row_number() OVER (ORDER BY embedding <=> $1) AS rnk
  FROM chunks
  ORDER BY embedding <=> $1
  LIMIT 50
),
keyword AS (
  SELECT id, row_number() OVER (ORDER BY ts_rank_cd(tsv, q) DESC) AS rnk
  FROM chunks, websearch_to_tsquery('russian', $2) AS q
  WHERE tsv @@ q
  ORDER BY ts_rank_cd(tsv, q) DESC
  LIMIT 50
)
SELECT COALESCE(s.id, k.id) AS id,
       COALESCE(1.0 / (60 + s.rnk), 0) + COALESCE(1.0 / (60 + k.rnk), 0) AS rrf_score
FROM semantic s
FULL OUTER JOIN keyword k ON s.id = k.id
ORDER BY rrf_score DESC
LIMIT 30;

Подводные камни

  • Индексы рассинхронизировались: документ обновился в векторном индексе, но не в BM25. Нужен один pipeline загрузки и один источник правды для чанков.
  • rank_bm25 держит индекс в памяти и не умеет инкрементальных обновлений: для прототипа нормально, для продакшна нет.
  • Чанки без контекста: «см. выше», таблица, разрезанная пополам, код без описания.
  • Устаревшие документы выигрывают у новых. Учитывать дату обновления или помечать устаревшее.
  • Встроенный ранжир Postgres (ts_rank_cd) это не BM25. Для настоящего BM25 есть расширения (например, pg_search от ParadeDB) или отдельный поисковый движок.
  • Конфигурации russian в Postgres нет для казахского: для смешанного корпуса simple плюс своя нормализация.

Как понять, что работает

  • Golden set из 50-100 реальных вопросов с известными правильными чанками (собирается проектом 04).
  • Retrieval: recall@k (попал ли нужный чанк в top-k), MRR, nDCG. Мерить отдельно dense, BM25, hybrid и hybrid + rerank, чтобы видеть вклад каждого слоя.
  • Генерация: faithfulness (всё ли подтверждено источниками), релевантность ответа, доля честных «не знаю» на вопросах, ответа на которые в корпусе нет.

На моём стеке

Всё, кроме реранкера, живёт в одном Postgres: pgvector для векторов, tsvector + GIN для ключевых слов, RRF одним SQL-запросом. Меньше движущихся частей, и проблема рассинхрона индексов исчезает: обновление чанка и обоих индексов происходит в одной транзакции. Anthropic не выпускает собственную embedding-модель и в документации указывает на Voyage AI (у них же есть реранкеры), но провайдера стоит выбирать по своему языку и домену, прогнав golden set. Go SDK для Claude поддерживает Citations.

§ 2. Text-to-SQL with Guardrails

Суть: вопрос на естественном языке превращается в SQL и результат. Модель пишет запрос, но разрешение на исполнение дают только детерминированный код и права базы данных.

Стек оригинала: Python · Claude API · SQLAlchemy · Postgres · sqlglot · Pydantic · FastAPI

На моём стеке: Go · pg_query_go · pgx · read-only роль на реплике · Claude API со structured outputs

Шаги

  1. Передать модели схему и вопрос пользователя.

    Не голый DDL, а схему со смыслом: комментарии к таблицам и колонкам, внешние ключи, примеры значений для enum-подобных колонок (status: 'paid' | 'refunded'), бизнес-определения («выручка = сумма paid минус refunded»), 5-10 пар «вопрос → эталонный SQL». Если таблиц десятки, сначала schema linking: отдельным шагом выбрать релевантные таблицы и передать только их.

  2. Разобрать сгенерированный SQL через sqlglot и заблокировать всё деструктивное.

    Работать с деревом разбора (AST), а не с регулярками. Allowlist, а не denylist: разрешён ровно один statement, и это SELECT (включая WITH и UNION). Плюс обход дерева: нет INSERT/UPDATE/DELETE внутри CTE (в Postgres бывают data-modifying CTE), только разрешённые таблицы и колонки, нет опасных функций (pg_sleep, pg_read_file, dblink и подобных), принудительный LIMIT.

  3. Сначала исполнить read-only на изолированной реплике.

    Парсер это первая линия защиты, но гарантию даёт только база: отдельная роль с правом SELECT на нужные таблицы, транзакция READ ONLY, statement_timeout, реплика вместо primary. Для мультиарендных данных Row Level Security, чтобы запрос физически не видел чужие строки. Перед исполнением EXPLAIN: отсечь планы с огромной оценкой стоимости.

  4. Приложить оценку уверенности и спрашивать подтверждение при низкой.

    Источники сигнала: self-consistency (сгенерировать 3-5 вариантов SQL и сравнить результаты: совпали, значит уверенность выше), ошибки валидации и число повторных попыток, неоднозначность в самом вопросе («за прошлый месяц» в какой таймзоне?). Ниже порога не исполнять молча, а показать SQL с допущениями или задать уточняющий вопрос.

  5. Вернуть результат вместе с SQL, чтобы пользователь мог проверить.

    Ответ содержит данные, SQL, объяснение простыми словами и список допущений («учитывал только заказы со статусом paid»). Пользователь, который видит SQL, ловит ошибки, которые не поймает ни один парсер.

Ключевые идеи

  • Defense in depth: промпт, парсер, права базы, таймауты. Каждый слой можно пробить, все вместе почти нельзя.
  • Самая частая ошибка не опасная, а неправильная: синтаксически корректный запрос с неверной гранулярностью, дублями из JOIN или не тем фильтром. От неё защищают описание схемы и eval-набор, а не guardrails.
  • Просить модель вернуть структуру {sql, assumptions, needs_clarification}, а не текст с SQL внутри.

Код

Guard на sqlglot (эскиз, сверить с версией sqlglot)
import sqlglot
from sqlglot import exp

ALLOWED_TABLES = {"orders", "customers", "products"}
READ_ROOTS = (exp.Select, exp.Union, exp.Intersect, exp.Except)
WRITE_NODES = (exp.Insert, exp.Update, exp.Delete, exp.Drop, exp.Create, exp.Command)

def guard(sql: str) -> exp.Expression:
    statements = sqlglot.parse(sql, read="postgres")
    if len(statements) != 1 or statements[0] is None:
        raise ValueError("нужен ровно один statement")
    tree = statements[0]
    if not isinstance(tree, READ_ROOTS):
        raise ValueError("разрешён только SELECT")
    if next(tree.find_all(*WRITE_NODES), None) is not None:
        raise ValueError("запись внутри запроса")
    cte_names = {cte.alias_or_name for cte in tree.find_all(exp.CTE)}
    for table in tree.find_all(exp.Table):
        if table.name not in ALLOWED_TABLES | cte_names:
            raise ValueError(f"таблица вне allowlist: {table.name}")
    return tree
Роль только для чтения в Postgres
CREATE ROLE nl2sql_reader LOGIN;          -- пароль через secrets
GRANT USAGE ON SCHEMA analytics TO nl2sql_reader;
GRANT SELECT ON analytics.orders, analytics.customers, analytics.products TO nl2sql_reader;
ALTER ROLE nl2sql_reader SET default_transaction_read_only = on;
ALTER ROLE nl2sql_reader SET statement_timeout = '5s';

Подводные камни

  • Prompt injection через данные: значения из таблиц попадают в контекст модели при повторных итерациях и могут содержать инструкции.
  • Персональные данные: колонки с PII исключать и из схемы, которую видит модель, и из allowlist.
  • Дорогие запросы: декартово произведение без WHERE безопасно и валидно, но кладёт реплику. Отсюда таймаут и EXPLAIN.
  • Диалект: модель пишет синтаксис MySQL для Postgres. Явно указывать диалект в промпте; sqlglot умеет транспилировать, но это костыль.

Как понять, что работает

  • Execution accuracy на наборе «вопрос → эталонный SQL»: сравнивать результаты, а не текст запросов (один и тот же ответ можно получить разными SQL).
  • Доля заблокированных запросов и сколько из них заблокированы зря.
  • Доля ответов, где пользователь правил SQL или переспрашивал.

На моём стеке

pg_query_go использует настоящий парсер Postgres, поэтому дерево разбора совпадает с тем, как запрос поймёт сама база: для одного диалекта это надёжнее sqlglot. Pydantic заменяют Go-структуры и JSON Schema для structured outputs или strict tool use в Claude API. В pgx транзакцию открывать с pgx.TxOptions{AccessMode: pgx.ReadOnly} поверх роли с default_transaction_read_only: два независимых замка.

§ 3. LoRA Fine-Tuning Pipeline

Суть: дообучить открытую модель под свой домен, обучая обычно меньше 1% параметров, на одной GPU. Нужно, когда промптинг и RAG не дают нужного поведения, формата или цены.

Стек оригинала: Python · PyTorch · Hugging Face · PEFT · bitsandbytes · Weights & Biases

На моём стеке: обучение остаётся на Python (экосистема там) · сервинг через vLLM за Go-сервисом

Когда LoRA не нужна: если задача решается few-shot промптом; если нужно добавить знания, которые меняются (это задача RAG, а не весов); если нет хотя бы нескольких сотен проверенных примеров. Когда нужна: стабильный формат вывода, специфичный стиль, узкая классификация или извлечение, перенос поведения большой модели в маленькую и дешёвую (дистилляция).

Шаги

  1. Собрать чистый instruction-датасет в своём домене.

    Качество важнее объёма: несколько сотен или тысяч проверенных примеров лучше десятков тысяч шумных. Единый формат (chat template целевой модели), дедупликация, баланс по типам задач, отложенная eval-выборка без пересечений с train (включая почти-дубли). Хороший источник: реальные логи из проекта 04 плюс ручная проверка. Синтетика от сильной модели допустима, если её кто-то проверил.

  2. Загрузить базовую модель в 4-bit через bitsandbytes.

    Это QLoRA: веса базы квантуются в NF4 и заморожены, вычисления идут в bf16, обучаются только адаптеры. Модель на 7-8B параметров в таком режиме помещается на одну GPU с 24 GB. Не хватает памяти: gradient checkpointing.

  3. Подключить LoRA-адаптеры через PEFT, выбрать rank и alpha.

    LoRA не трогает исходную матрицу W, а добавляет низкоранговую поправку: W' = W + (α / r) · B·A, где B размером d×r и A размером r×k маленькие. r (ранг) это ёмкость адаптера: 8-16 для стиля и формата, 32-64 для задач посложнее. alpha масштабирует вклад, частая эвристика alpha = r или alpha = 2r. target_modules: проекции внимания (q, k, v, o), часто ещё MLP-слои, это обычно поднимает качество.

  4. Обучать, отслеживая loss и метрики в W&B.

    Логировать train loss, eval loss, learning rate и, главное, метрику самой задачи на eval-наборе каждые N шагов. Ориентиры для LoRA: learning rate порядка 1e-4 ... 2e-4, 1-3 эпохи. Eval loss растёт при падающем train loss: переобучение, берём лучший чекпойнт. Loss считать только по токенам ответа, токены инструкции маскировать.

  5. Слить адаптеры с базой и сравнить с базовой моделью.

    Сливать адаптер в базу, загруженную в bf16/fp16, а не в 4-bit: слияние в квантованную модель даёт ошибки. Сравнивать три варианта: база как есть, база с лучшим промптом, дообученная модель. Плюс общий бенчмарк на регрессии: узкое дообучение может ухудшить общие способности (catastrophic forgetting).

Ключевые идеи

  • Fine-tuning меняет поведение, а не знания. Факты, которые устаревают, держать в RAG.
  • Честное сравнение только с лучшей промптовой версией базы, а не с базой без промпта.
  • Адаптер маленький (мегабайты), база одна: можно держать несколько адаптеров под разные задачи.

Код

QLoRA-конфигурация (эскиз, имена модулей зависят от архитектуры)
import torch
from transformers import AutoModelForCausalLM, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training

bnb = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_use_double_quant=True,
    bnb_4bit_compute_dtype=torch.bfloat16,
)
model = AutoModelForCausalLM.from_pretrained(
    BASE_MODEL, quantization_config=bnb, device_map="auto"
)
model = prepare_model_for_kbit_training(model)

lora = LoraConfig(
    r=16,
    lora_alpha=32,
    lora_dropout=0.05,
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
    task_type="CAUSAL_LM",
)
model = get_peft_model(model, lora)
model.print_trainable_parameters()   # ожидаемо меньше 1% параметров
Слияние адаптера с базой
from peft import PeftModel

# базу грузим в bf16, не в 4-bit (в старых версиях transformers параметр torch_dtype)
base = AutoModelForCausalLM.from_pretrained(BASE_MODEL, dtype=torch.bfloat16)
merged = PeftModel.from_pretrained(base, ADAPTER_DIR).merge_and_unload()
merged.save_pretrained("merged-model")

Подводные камни

  • Chat template при обучении и при инференсе не совпадает: на проде модель внезапно «глупеет».
  • Утечка eval в train: красивые метрики и ноль пользы.
  • Лицензия базовой модели: не все открытые веса разрешают коммерческое использование.
  • Большой ранг на маленьком датасете: переобучение наступает быстрее, чем кажется.

Как понять, что работает

  • Метрика задачи (exact match, F1, валидность JSON, LLM-судья по рубрике) на отложенном наборе выше, чем у базы с лучшим промптом.
  • Общие бенчмарки не просели.
  • Посчитаны латентность и стоимость инференса: выигрыш по качеству не съеден ценой хостинга.

На моём стеке

vLLM умеет обслуживать несколько LoRA-адаптеров поверх одной базовой модели (multi-LoRA) и отдаёт OpenAI-совместимый HTTP API, к которому Go-сервис ходит обычным клиентом. Один GPU-инстанс на несколько узких задач. Этот проект про открытые модели: поведение Claude настраивается промптами, примерами и инструментами.

§ 4. Automated Eval Dataset Generator

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

Стек оригинала: Python · Claude API · Pandas · Pydantic · DuckDB · Streamlit

На моём стеке: Kafka → Parquet в Object Storage · DuckDB поверх Parquet · runner на Go в CI · результаты в Postgres

Шаги

  1. Вытащить реальные промпты и ответы из продакшн-логов.

    Синтетические тесты проверяют то, что ты придумал; логи показывают, что реально спрашивают. Сначала вычистить PII. Вместе с промптом логировать: версию модели и промпта, переданный контекст, сигналы пользователя (дизлайк, повторный вопрос, ручная правка ответа), латентность, токены. DuckDB читает Parquet и JSONL напрямую, без загрузки в базу.

  2. Кластеризовать, чтобы найти случаи, которые действительно важны.

    Эмбеддинги запросов → кластеризация (k-means или HDBSCAN) → модель даёт кластерам человеческие названия. Дальше стратифицированная выборка: каждый крупный кластер представлен; редкие, но дорогие случаи (деньги, юридическое, удаление данных) взвешены вверх; все известные провалы попадают в набор обязательно. Цель: покрыть типы запросов, а не повторить самые частые.

  3. Сгенерировать эталонные ответы моделью, затем проверить вручную.

    Модель пишет черновик ожидаемого ответа, человек правит и подтверждает: именно ручной проход превращает набор в golden set. Для открытых ответов эталон не текст, а рубрика: какие факты обязаны быть, чего быть не должно, какой формат. Streamlit здесь как интерфейс разметки: запрос, черновик, кнопки «принять / исправить / отклонить».

  4. Автоматически оценивать каждую новую версию модели на этом наборе.

    Детерминированные проверки везде, где можно: валидный JSON, совпадение полей, exact match, наличие обязательных фактов. LLM-as-judge для остального: судья получает рубрику и выставляет оценку с обоснованием. Судью калибруют: на 50-100 примерах сравнить его оценки с человеческими. Известные перекосы судей: любят длинные ответы, первую позицию при попарном сравнении и ответы своей же модели. Для ночных прогонов, где латентность не важна, подходит Message Batches API: дешевле обычных вызовов.

  5. Ловить регрессии до того, как они дойдут до пользователей.

    Сравнивать по кластерам, а не только средний балл: +2% в среднем может прятать −15% в одном важном кластере. Учитывать шум: несколько прогонов или доверительный интервал, порог регрессии больше шума. Подключить как гейт в CI: PR с изменением промпта не мержится, если важный кластер просел.

Ключевые идеи

  • Набор живой: каждый инцидент из продакшна становится тестом, как регрессионный тест после бага.
  • Версионировать набор как код: релизы сравниваются по замороженной версии.
  • Набор не должен попадать в промпты и few-shot примеры, иначе меряешь память, а не качество.

Код

Схема eval-кейса (Pydantic)
from typing import Literal
from pydantic import BaseModel

class EvalCase(BaseModel):
    id: str
    cluster: str
    input: str
    expected: str | None = None
    rubric: list[str] = []      # что обязано быть в ответе
    must_not: list[str] = []    # чего быть не должно
    source: Literal["prod_log", "incident", "synthetic"]
    reviewed_by: str | None = None
Срез логов в DuckDB
SELECT prompt_version,
       count(*)                                              AS requests,
       avg(CASE WHEN feedback = 'down' THEN 1 ELSE 0 END)    AS dislike_rate,
       quantile_cont(latency_ms, 0.95)                       AS p95_ms
FROM read_parquet('logs/*.parquet')
WHERE ts >= now() - INTERVAL 7 DAY
GROUP BY prompt_version
ORDER BY prompt_version;

Подводные камни

  • Набор только из самых частых запросов: высокий балл и ноль информации о краях.
  • Непроверенные эталоны от модели: система оценивается против собственных ошибок.
  • Судья той же модели, что и тестируемая: завышенные оценки.
  • Набор устарел: продукт поменялся, тесты проверяют старое поведение.

Как понять, что работает

  • Гейт ловит известную регрессию: специально откатить хорошую версию промпта и убедиться, что CI покраснел.
  • Согласие судьи с людьми измерено и приемлемо.
  • Новые инциденты регулярно попадают в набор, а не только «когда-нибудь».

На моём стеке

Логи и так идут через Kafka: sink в Parquet в S3-совместимое Object Storage Yandex Cloud, DuckDB читает прямо оттуда через расширение httpfs. Runner на Go: параллельный прогон кейсов через Claude API с ограничением конкурентности (errgroup.SetLimit), результаты и история прогонов в Postgres. У DuckDB есть Go-драйвер, если анализ нужен внутри сервиса.

§ 5. Semantic Caching Layer

Суть: не вызывать модель, если похожий вопрос уже задавали. Слой перед LLM, который по смысловой близости отдаёт сохранённый ответ: экономит деньги и сотни миллисекунд ценой риска отдать неподходящий ответ.

Стек оригинала: Python · FastAPI · Redis · OpenAI embeddings · FAISS · Claude API

На моём стеке: Go-middleware перед LLM-клиентом · Redis с векторным поиском или pgvector · любой провайдер эмбеддингов

Шаги

  1. Считать эмбеддинг каждого входящего промпта до вызова модели.

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

  2. Искать в кэше семантически похожий прошлый промпт.

    Ближайший сосед по косинусной близости через ANN-индекс (HNSW в Redis или FAISS в памяти). Искать только внутри «пространства» запроса: ключ включает модель, версию системного промпта, параметры генерации, набор инструментов, а для персональных ответов ещё пользователя или арендатора. Иначе пользователь A получит ответ, сгенерированный для пользователя B.

  3. Если близость выше порога, вернуть ответ из кэша.

    Порог это главный параметр проекта, и он зависит от модели эмбеддингов. Стартовать консервативно (0.95 и выше) и подбирать на размеченных парах «одно и то же / разное». Опасные почти-дубли, на которых эмбеддинги дают высокую близость при разном смысле: отрицание («как отменить подписку» и «как не дать подписке отмениться»), числа и даты («отчёт за март» и «отчёт за май»), сущности («налоги для ИП» и «налоги для ТОО»).

  4. При промахе вызвать модель и сохранить эмбеддинг вместе с ответом.

    Сохранять с TTL по типу данных: справочное живёт дольше, чем ответы про цены и статусы. Не кэшировать: ответы с ошибкой, отказы, ответы, зависящие от текущего времени или от предыдущих сообщений диалога. Кэшируются в основном одиночные самодостаточные вопросы, многоходовой контекст почти никогда не совпадает.

  5. Выводить hit rate и сэкономленные деньги на простой дашборд.

    Метрики: hit rate (точный и семантический отдельно), латентность p50/p95 на hit и на miss, экономия = попадания × средняя стоимость вызова − стоимость всех эмбеддингов. И главное, чего нет в оригинале: false hit rate. Выборочно отправлять попадания на проверку (человеку или LLM-судье: подходит ли кэшированный ответ к новому вопросу).

Semantic cache и prompt caching это разные вещи. Prompt caching в Claude API кэширует префикс промпта (инструменты, системный промпт, длинные документы) на стороне провайдера: модель всё равно вызывается, но повторный префикс читается в разы дешевле и быстрее. Кэш живёт 5 минут по умолчанию (есть вариант на 1 час) и требует точного совпадения префикса. Semantic cache пропускает вызов модели целиком, но может ошибиться. Они не конкурируют: ставить оба.

Ключевые идеи

  • Кэш без scope это утечка данных между пользователями.
  • Высокий hit rate без известного false hit rate ничего не значит.
  • Версия системного промпта в ключе кэша решает проблему инвалидации при изменении промпта; TTL решает проблему устаревших данных.

Код

Логика слоя (псевдокод)
def cached_completion(req):
    scope = (req.model, req.system_prompt_version, req.tenant_id)
    norm = normalize(req.prompt)

    if hit := exact_cache.get(hash((scope, norm))):
        return hit                                    # 1. точное совпадение

    vec = embed(norm)
    match = vector_index.nearest(vec, scope=scope)
    if match and match.similarity >= THRESHOLD:
        metrics.semantic_hit(match.similarity)
        return match.response                         # 2. смысловое совпадение

    resp = llm.complete(req)                          # 3. промах
    if is_cacheable(req, resp):
        ttl = ttl_for(req)
        vector_index.add(vec, resp, scope=scope, ttl=ttl)
        exact_cache.set(hash((scope, norm)), resp, ttl=ttl)
    return resp

Подводные камни

  • FAISS в памяти процесса не разделяется между репликами и теряется при рестарте.
  • Поменяли документацию, а кэш продолжает отдавать старые ответы: нужен TTL или явная инвалидация по событию.
  • Одновременные одинаковые промахи: десять запросов подряд, десять вызовов модели.
  • Порог подобран «на глаз» на пяти примерах.

Как понять, что работает

  • False hit rate на выборке попаданий измерен и приемлем.
  • Экономия положительная после вычета стоимости эмбеддингов.
  • Латентность на hit заметно ниже, чем на miss, а латентность на miss выросла незначительно.

На моём стеке

Почти идеальный проект под Go: это middleware в HTTP-клиенте к LLM. Redis уже в стеке, и в нём есть векторный поиск с HNSW-индексом; альтернатива: таблица в Postgres с pgvector. Одновременные одинаковые промахи схлопывает golang.org/x/sync/singleflight: модель вызывается один раз, остальные ждут тот же результат. Метрики hit/miss/false hit экспортировать туда же, где живут остальные метрики сервиса.

§ 6. Agent Orchestration System

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

Стек оригинала: Python · LangGraph · Claude API · Redis · Postgres · FastAPI · Celery

На моём стеке: Go · явная state machine или Temporal вместо LangGraph + Celery · Redis для горячего состояния · Postgres для истории · Kafka для событий

Начинать проще. Многие задачи решает один цикл «модель + инструменты» или фиксированный workflow (цепочка шагов, маршрутизация). Агентный граф нужен, когда путь к цели заранее неизвестен. Ровно об этом статья Anthropic «Building effective agents»: сначала самое простое работающее решение, сложность добавлять только когда она окупается.

Шаги

  1. Описать граф агента с узлами plan, act и reflect.

    Состояние это типизированный объект: цель, план, история шагов, результаты инструментов, счётчики. plan разбивает цель на шаги. act выбирает и вызывает инструмент. reflect сравнивает результат с целью и решает: дальше, перепланировать, завершить или эскалировать человеку. Переходы условные, но с жёсткими ограничителями: максимум итераций, бюджет токенов и денег, таймаут на весь прогон. Без них первый же цикл «попробую ещё раз» сожжёт бюджет.

  2. Зарегистрировать каждый инструмент с типизированной схемой входа.

    JSON Schema (из Pydantic или Go-структур) и валидация до исполнения. В Claude API strict tool use гарантирует, что аргументы вызова соответствуют схеме. Ошибки возвращать модели структурированно («поле date должно быть в формате YYYY-MM-DD»), чтобы она могла исправиться. Описание инструмента это тоже промпт: когда использовать, когда нет, что возвращает. Лучше пять чётких инструментов, чем тридцать пересекающихся. Инструменты с побочными эффектами делать идемпотентными (ключ идемпотентности на каждый вызов): ретраи неизбежны.

  3. Сохранять память в Redis между каждым шагом.

    Два вида памяти. Состояние текущего прогона: checkpoint после каждого шага, чтобы после падения воркера продолжить с того же места, а не с начала. Долговременная: факты между сессиями. В LangGraph это checkpointer с бэкендом Redis или Postgres. Redis для горячего состояния с TTL, Postgres для долговечной истории и аудита.

  4. Добавить контрольную точку с участием человека для рискованных действий.

    Разделить инструменты по риску: чтение (свободно), обратимая запись (логировать), необратимое или внешнее действие (оплата, отправка письма, удаление, деплой): пауза и подтверждение. Граф останавливается, состояние сохраняется, человеку уходит запрос с конкретикой (что, с какими параметрами, почему), после ответа граф продолжает с той же точки. В LangGraph это interrupt вместе с checkpointer. Очередь вроде Celery нужна потому, что прогон может ждать человека часами и не должен держать HTTP-запрос.

  5. Логировать каждый шаг, чтобы воспроизводить и отлаживать сбои.

    На каждый шаг: входное состояние, промпт и ответ модели, токены и стоимость, вызовы инструментов с аргументами и результатами, латентность, решение reflect. Replay: восстановить состояние из checkpoint и перезапустить с записанными ответами инструментов, иначе внешний мир уже другой и баг не воспроизводится. Стандарт трейсинга: OpenTelemetry, один trace на прогон, span на шаг.

Ключевые идеи

  • Результаты инструментов это данные, а не инструкции: веб-страница или письмо с текстом «игнорируй предыдущие указания» не должны менять поведение агента.
  • Ошибки умножаются: 95% надёжности на шаг дают около 60% на десяти шагах. Меньше шагов, проверки между ними.
  • Мультиагентность ради мультиагентности добавляет координацию и точки отказа, а не качество.

Код

Скелет оркестратора на Go (эскиз)
type Step string

const (
	StepPlan    Step = "plan"
	StepAct     Step = "act"
	StepReflect Step = "reflect"
	StepApprove Step = "await_approval"
	StepDone    Step = "done"
)

type RunState struct {
	RunID     string
	Goal      string
	Plan      []string
	History   []StepRecord
	Iteration int
	SpentUSD  float64
	Next      Step
}

func (o *Orchestrator) Run(ctx context.Context, s *RunState) error {
	for s.Next != StepDone && s.Next != StepApprove {
		if s.Iteration >= o.MaxIterations || s.SpentUSD >= o.BudgetUSD {
			return ErrBudgetExceeded
		}
		var err error
		switch s.Next {
		case StepPlan:
			err = o.plan(ctx, s)
		case StepAct:
			err = o.act(ctx, s) // рискованный инструмент ставит s.Next = StepApprove
		case StepReflect:
			err = o.reflect(ctx, s)
		}
		if err != nil {
			return err
		}
		s.Iteration++
		if err := o.store.Checkpoint(ctx, s); err != nil { // после каждого шага
			return err
		}
	}
	return nil
}

Подводные камни

  • Бесконечные циклы и взрыв стоимости при отсутствии лимитов.
  • Много инструментов с похожими описаниями: модель путается в выборе.
  • Неидемпотентный инструмент плюс ретрай: двойная оплата, два письма.
  • Replay без записанных ответов инструментов не воспроизводит баг.

Как понять, что работает

  • Task success rate на наборе сценариев с ожидаемым результатом (это проект 04, только для агентов).
  • Среднее число шагов и стоимость на задачу.
  • Доля прогонов, упёршихся в лимит.
  • Доля эскалаций к человеку и сколько из них были действительно нужны.

На моём стеке

Temporal (у него родной Go SDK) закрывает шаги 3-5 из коробки: durable execution, ретраи, ожидание сигнала от человека хоть днями, полная история событий для replay. LangGraph + Celery + Redis checkpointer по сути собирают то же самое вручную. Kafka несёт события шагов в аналитику и аудит. Инструменты агента: RAG из проекта 01 и Text-to-SQL из проекта 02.

Как проекты складываются в систему

запрос 05 semantic cache hit ответ miss 06 агент инструменты: 01 RAG 02 Text-to-SQL модель: Claude 03 LoRA-модель для узких задач логи 04 evals гейт в CI на каждое изменение

Порядок, в котором их стоит строить:

  1. 01 RAG. База: на нём учишься retrieval и ответам с опорой на источники.
  2. 04 Evals. Сразу после первого работающего пайплайна, чтобы дальше улучшать по цифрам, а не по ощущениям.
  3. 02 Text-to-SQL. Второй вид retrieval (структурированные данные) и культура guardrails.
  4. 05 Semantic cache. Инфраструктурный, быстрый, сразу показывает деньги.
  5. 06 Агент. Собирает 01 и 02 как инструменты и без evals неуправляем.
  6. 03 LoRA. Отдельный навык (ML и GPU). Имеет смысл, когда из логов накопились данные и доказано, что промптинга не хватает.

Сквозные правила

  • Модель предлагает, код проверяет.
  • Сначала измерение, потом оптимизация.
  • Логируй всё, что нужно для воспроизведения: версию модели, версию промпта, переданный контекст, ответы инструментов.
  • Считай стоимость одного запроса с первого дня.
  • Права доступа и scope применяются до модели, а не после.
  • Начинай с самого простого варианта и усложняй, только когда цифры говорят, что пора.

Глоссарий

Embedding
вектор, отражающий смысл текста; близкие по смыслу тексты дают близкие векторы.
Dense / sparse retrieval
поиск по эмбеддингам / поиск по совпадению слов (BM25).
BM25
формула ранжирования по ключевым словам: учитывает частоту слова в документе, его редкость в корпусе и длину документа.
RRF (Reciprocal Rank Fusion)
слияние нескольких ранжированных списков по позициям: Σ 1 / (k + rank).
Reranker / cross-encoder
модель, которая оценивает пару «запрос + документ» целиком; точнее эмбеддингов, но медленнее.
Recall@k
доля вопросов, для которых нужный документ попал в первые k результатов.
MRR, nDCG
метрики ранжирования, учитывающие, на какой позиции стоит правильный ответ.
Faithfulness (groundedness)
все ли утверждения ответа подтверждены источниками.
AST
дерево разбора запроса; позволяет проверять структуру, а не текст.
Schema linking
выбор релевантных таблиц и колонок до генерации SQL.
Execution accuracy
совпадение результата сгенерированного запроса с результатом эталонного.
LoRA
дообучение через низкоранговые добавки к весам при замороженной базе.
QLoRA
LoRA поверх базы, квантованной в 4 бита.
NF4
4-битный формат квантования, рассчитанный на нормально распределённые веса.
Rank (r) и alpha
ёмкость адаптера и масштаб его вклада.
Catastrophic forgetting
потеря общих способностей модели после узкого дообучения.
Golden set
проверенный человеком набор входов с эталонами или рубриками.
LLM-as-judge
оценка ответа другой моделью по рубрике.
Cosine similarity
косинус угла между векторами, мера смысловой близости.
HNSW
графовый индекс для приближённого поиска ближайших соседей, стандарт векторных баз.
Prompt caching
кэш префикса промпта на стороне провайдера; модель вызывается, но повтор дешевле и быстрее.
Checkpointer
механизм сохранения состояния агента после каждого шага.
Human-in-the-loop (HITL)
пауза в работе системы до решения человека.
Idempotency key
ключ, по которому повторный вызов не выполняет действие второй раз.
Durable execution
исполнение, при котором прогресс workflow переживает падения процессов (Temporal).

Источник

Оригинал: карусель из шести карточек: Hybrid Search RAG over Internal Docs, Text-to-SQL with Guardrails, LoRA Fine-Tuning Pipeline, Automated Eval Dataset Generator, Semantic Caching Layer, Agent Orchestration System. Автор: Bashiri Smith, Baswe AI.

Что взято из оригинала: названия проектов, стеки и пять шагов каждого проекта (шаги переведены и раскрыты).

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

Дальше читать:

Собрано в октябре 2026. Код помечен как эскиз там, где API библиотек меняется между версиями.

Вернуться в архив