Шесть 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
Шаги
-
Нарезать документы на чанки, посчитать эмбеддинги, сложить в Pinecone.
Резать по структуре документа (заголовки, абзацы, списки), а не по фиксированному числу символов. Ориентир: 300-800 токенов на чанк, перекрытие 10-20%. У каждого чанка: стабильный id, doc_id, заголовок раздела, url, дата обновления, права доступа. Полезный приём: перед эмбеддингом дописывать в начало чанка название документа и раздела. Иначе фрагмент «увеличьте таймаут до 30 секунд» теряет контекст: таймаут чего?
-
Построить BM25-индекс по тому же корпусу.
Тот же набор чанков и те же id, что в векторном индексе, иначе слияние на шаге 3 ломается. BM25 ловит то, что эмбеддинги размывают: коды ошибок, артикулы, имена, аббревиатуры, номера тикетов. Для русского и казахского нужна нормализация (нижний регистр, стемминг или лемматизация), иначе «договор» и «договора» становятся разными токенами.
-
Запустить оба ретривера и слить результаты через Reciprocal Rank Fusion.
Каждый ретривер возвращает top-N (около 50). RRF складывает не скоры (у dense и BM25 несравнимые шкалы), а позиции:
score(d) = Σ 1 / (k + rank_i(d)), обычноk = 60. Выигрывает документ, который стоит высоко в обоих списках. Нормализовать скоры не нужно, в этом вся прелесть метода. -
Переранжировать объединённый список через Cohere Rerank и оставить top-k.
Реранкер (cross-encoder) читает запрос и чанк вместе, поэтому точнее эмбеддингов, но медленнее и дороже. Подавать ему только кандидатов после RRF (20-50 штук), в контекст модели оставлять 5-10. Это главный рычаг качества: retrieval отвечает за полноту, реранкер за точность.
-
Сгенерировать ответ с 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
Шаги
-
Передать модели схему и вопрос пользователя.
Не голый DDL, а схему со смыслом: комментарии к таблицам и колонкам, внешние ключи, примеры значений для enum-подобных колонок (
status: 'paid' | 'refunded'), бизнес-определения («выручка = сумма paid минус refunded»), 5-10 пар «вопрос → эталонный SQL». Если таблиц десятки, сначала schema linking: отдельным шагом выбрать релевантные таблицы и передать только их. -
Разобрать сгенерированный SQL через sqlglot и заблокировать всё деструктивное.
Работать с деревом разбора (AST), а не с регулярками. Allowlist, а не denylist: разрешён ровно один statement, и это SELECT (включая WITH и UNION). Плюс обход дерева: нет INSERT/UPDATE/DELETE внутри CTE (в Postgres бывают data-modifying CTE), только разрешённые таблицы и колонки, нет опасных функций (
pg_sleep,pg_read_file,dblinkи подобных), принудительный LIMIT. -
Сначала исполнить read-only на изолированной реплике.
Парсер это первая линия защиты, но гарантию даёт только база: отдельная роль с правом SELECT на нужные таблицы, транзакция READ ONLY,
statement_timeout, реплика вместо primary. Для мультиарендных данных Row Level Security, чтобы запрос физически не видел чужие строки. Перед исполнением EXPLAIN: отсечь планы с огромной оценкой стоимости. -
Приложить оценку уверенности и спрашивать подтверждение при низкой.
Источники сигнала: self-consistency (сгенерировать 3-5 вариантов SQL и сравнить результаты: совпали, значит уверенность выше), ошибки валидации и число повторных попыток, неоднозначность в самом вопросе («за прошлый месяц» в какой таймзоне?). Ниже порога не исполнять молча, а показать SQL с допущениями или задать уточняющий вопрос.
-
Вернуть результат вместе с 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, а не весов); если нет хотя бы нескольких сотен проверенных примеров. Когда нужна: стабильный формат вывода, специфичный стиль, узкая классификация или извлечение, перенос поведения большой модели в маленькую и дешёвую (дистилляция).
Шаги
-
Собрать чистый instruction-датасет в своём домене.
Качество важнее объёма: несколько сотен или тысяч проверенных примеров лучше десятков тысяч шумных. Единый формат (chat template целевой модели), дедупликация, баланс по типам задач, отложенная eval-выборка без пересечений с train (включая почти-дубли). Хороший источник: реальные логи из проекта 04 плюс ручная проверка. Синтетика от сильной модели допустима, если её кто-то проверил.
-
Загрузить базовую модель в 4-bit через bitsandbytes.
Это QLoRA: веса базы квантуются в NF4 и заморожены, вычисления идут в bf16, обучаются только адаптеры. Модель на 7-8B параметров в таком режиме помещается на одну GPU с 24 GB. Не хватает памяти: gradient checkpointing.
-
Подключить 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-слои, это обычно поднимает качество. -
Обучать, отслеживая 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 считать только по токенам ответа, токены инструкции маскировать.
-
Слить адаптеры с базой и сравнить с базовой моделью.
Сливать адаптер в базу, загруженную в 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
Шаги
-
Вытащить реальные промпты и ответы из продакшн-логов.
Синтетические тесты проверяют то, что ты придумал; логи показывают, что реально спрашивают. Сначала вычистить PII. Вместе с промптом логировать: версию модели и промпта, переданный контекст, сигналы пользователя (дизлайк, повторный вопрос, ручная правка ответа), латентность, токены. DuckDB читает Parquet и JSONL напрямую, без загрузки в базу.
-
Кластеризовать, чтобы найти случаи, которые действительно важны.
Эмбеддинги запросов → кластеризация (k-means или HDBSCAN) → модель даёт кластерам человеческие названия. Дальше стратифицированная выборка: каждый крупный кластер представлен; редкие, но дорогие случаи (деньги, юридическое, удаление данных) взвешены вверх; все известные провалы попадают в набор обязательно. Цель: покрыть типы запросов, а не повторить самые частые.
-
Сгенерировать эталонные ответы моделью, затем проверить вручную.
Модель пишет черновик ожидаемого ответа, человек правит и подтверждает: именно ручной проход превращает набор в golden set. Для открытых ответов эталон не текст, а рубрика: какие факты обязаны быть, чего быть не должно, какой формат. Streamlit здесь как интерфейс разметки: запрос, черновик, кнопки «принять / исправить / отклонить».
-
Автоматически оценивать каждую новую версию модели на этом наборе.
Детерминированные проверки везде, где можно: валидный JSON, совпадение полей, exact match, наличие обязательных фактов. LLM-as-judge для остального: судья получает рубрику и выставляет оценку с обоснованием. Судью калибруют: на 50-100 примерах сравнить его оценки с человеческими. Известные перекосы судей: любят длинные ответы, первую позицию при попарном сравнении и ответы своей же модели. Для ночных прогонов, где латентность не важна, подходит Message Batches API: дешевле обычных вызовов.
-
Ловить регрессии до того, как они дойдут до пользователей.
Сравнивать по кластерам, а не только средний балл: +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 · любой провайдер эмбеддингов
Шаги
-
Считать эмбеддинг каждого входящего промпта до вызова модели.
Перед эмбеддингом нормализовать текст (обрезать пробелы, регистр). Эмбеддинг стоит денег и десятков миллисекунд на каждый запрос, включая промахи, поэтому слой окупается только при достаточной доле попаданий. Перед семантическим слоем поставить дешёвый точный кэш по хэшу: идентичные запросы не должны доходить даже до эмбеддинга.
-
Искать в кэше семантически похожий прошлый промпт.
Ближайший сосед по косинусной близости через ANN-индекс (HNSW в Redis или FAISS в памяти). Искать только внутри «пространства» запроса: ключ включает модель, версию системного промпта, параметры генерации, набор инструментов, а для персональных ответов ещё пользователя или арендатора. Иначе пользователь A получит ответ, сгенерированный для пользователя B.
-
Если близость выше порога, вернуть ответ из кэша.
Порог это главный параметр проекта, и он зависит от модели эмбеддингов. Стартовать консервативно (0.95 и выше) и подбирать на размеченных парах «одно и то же / разное». Опасные почти-дубли, на которых эмбеддинги дают высокую близость при разном смысле: отрицание («как отменить подписку» и «как не дать подписке отмениться»), числа и даты («отчёт за март» и «отчёт за май»), сущности («налоги для ИП» и «налоги для ТОО»).
-
При промахе вызвать модель и сохранить эмбеддинг вместе с ответом.
Сохранять с TTL по типу данных: справочное живёт дольше, чем ответы про цены и статусы. Не кэшировать: ответы с ошибкой, отказы, ответы, зависящие от текущего времени или от предыдущих сообщений диалога. Кэшируются в основном одиночные самодостаточные вопросы, многоходовой контекст почти никогда не совпадает.
-
Выводить 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»: сначала самое простое работающее решение, сложность добавлять только когда она окупается.
Шаги
-
Описать граф агента с узлами plan, act и reflect.
Состояние это типизированный объект: цель, план, история шагов, результаты инструментов, счётчики. plan разбивает цель на шаги. act выбирает и вызывает инструмент. reflect сравнивает результат с целью и решает: дальше, перепланировать, завершить или эскалировать человеку. Переходы условные, но с жёсткими ограничителями: максимум итераций, бюджет токенов и денег, таймаут на весь прогон. Без них первый же цикл «попробую ещё раз» сожжёт бюджет.
-
Зарегистрировать каждый инструмент с типизированной схемой входа.
JSON Schema (из Pydantic или Go-структур) и валидация до исполнения. В Claude API strict tool use гарантирует, что аргументы вызова соответствуют схеме. Ошибки возвращать модели структурированно («поле date должно быть в формате YYYY-MM-DD»), чтобы она могла исправиться. Описание инструмента это тоже промпт: когда использовать, когда нет, что возвращает. Лучше пять чётких инструментов, чем тридцать пересекающихся. Инструменты с побочными эффектами делать идемпотентными (ключ идемпотентности на каждый вызов): ретраи неизбежны.
-
Сохранять память в Redis между каждым шагом.
Два вида памяти. Состояние текущего прогона: checkpoint после каждого шага, чтобы после падения воркера продолжить с того же места, а не с начала. Долговременная: факты между сессиями. В LangGraph это checkpointer с бэкендом Redis или Postgres. Redis для горячего состояния с TTL, Postgres для долговечной истории и аудита.
-
Добавить контрольную точку с участием человека для рискованных действий.
Разделить инструменты по риску: чтение (свободно), обратимая запись (логировать), необратимое или внешнее действие (оплата, отправка письма, удаление, деплой): пауза и подтверждение. Граф останавливается, состояние сохраняется, человеку уходит запрос с конкретикой (что, с какими параметрами, почему), после ответа граф продолжает с той же точки. В LangGraph это interrupt вместе с checkpointer. Очередь вроде Celery нужна потому, что прогон может ждать человека часами и не должен держать HTTP-запрос.
-
Логировать каждый шаг, чтобы воспроизводить и отлаживать сбои.
На каждый шаг: входное состояние, промпт и ответ модели, токены и стоимость, вызовы инструментов с аргументами и результатами, латентность, решение 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.
Как проекты складываются в систему
Порядок, в котором их стоит строить:
- 01 RAG. База: на нём учишься retrieval и ответам с опорой на источники.
- 04 Evals. Сразу после первого работающего пайплайна, чтобы дальше улучшать по цифрам, а не по ощущениям.
- 02 Text-to-SQL. Второй вид retrieval (структурированные данные) и культура guardrails.
- 05 Semantic cache. Инфраструктурный, быстрый, сразу показывает деньги.
- 06 Агент. Собирает 01 и 02 как инструменты и без evals неуправляем.
- 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.
Что взято из оригинала: названия проектов, стеки и пять шагов каждого проекта (шаги переведены и раскрыты).
Что добавлено: пояснения к шагам, ключевые идеи, код, подводные камни, метрики, разделы «На моём стеке», схема системы, порядок изучения, глоссарий.
Дальше читать:
- Claude API: Citations
- Claude API: Prompt caching
- Claude API: Embeddings
- Claude API: Structured outputs
- Claude API: Strict tool use
- Claude API: Batch processing
- Anthropic: Building effective agents
Собрано в октябре 2026. Код помечен как эскиз там, где API библиотек меняется между версиями.