Сквозные проекты
Четыре проекта, которые складываются в одну систему и одно портфолио: RAG по документации на GoJava и pgvector, eval-набор с CI, агент с MCP-сервером, guardrails и red-team, продакшн-обвязка с трейсингом, fallback, кэшем, SLO и дашбордом стоимости. План на 20 недель, привязанный к модулям курса, с критериями готовности и тем, что рассказывать на собеседовании. Последние три недели — капстоун, который доводит эту систему до продакшн-агента.
Итоговая система
Идея простая: вместо четырёх учебных репозиториев — одна система, которая растёт неделя за неделей. К концу у вас есть сервис, который отвечает на вопросы по документации с цитатами, измеряет своё качество на каждом PR, доступен агентам через MCP, проверен набором атак и выглядит как продакшн: трейсы, fallback, кэш, SLO, откат флагом, дашборд стоимости. Именно такой проект проходит deep dive на собеседовании: в нём есть решения, цифры и ошибки. В капстоуне (модуль 15) эта же система становится продакшн-агентом с приёмкой по метрикам и защитой design doc.
Выбор корпуса. Возьмите документацию, которую вы знаете и которая публично доступна: документация Go (go.dev/doc и спецификация)документация Java и Spring (docs.oracle.com, спецификация языка, Spring Boot Reference), Kubernetes, PostgreSQL или внутренняя документация вашей компании, если её можно использовать. Нужно 500–5 000 страниц: достаточно, чтобы retrieval был нетривиальным, и мало, чтобы индексация стоила копейки.
Архитектура итоговой системы
Цвет рамки — проект, в котором компонент появляется: A · RAG, B · evals, C · агент, MCP и безопасность, D · продакшн.
| Проект | Недели | Модули курса | Результат |
|---|---|---|---|
| A · RAG на GoJava + pgvector | 1–6, 14 | 0–5, 11 | Сервис /ask со стримингом и цитатами; ingestion с PII-редакцией и версиями корпуса |
| B · Eval-набор | 6, 10 | 4, 5, 8 | Golden set, judge с κ, bootstrap-гейт в CI |
| C · Агент, MCP и безопасность | 7–9, 11 | 6, 7, 8 | MCP-сервер на GoJava, свой агент с компакцией, guardrails, red-team, eval по траекториям |
| D · Продакшнизация | 11–13 | 9, 10 | Трейсы, шлюз, fallback, кэш, SLO, флаги, дрейф, дашборд стоимости |
| Финиш | 15–17 | 12, 13, 14 | README, демо, нагрузочный тест, ADR и постмортем, рассказ для собеседования |
| Капстоун | 18–20 | 15 | Продакшн-агент с приёмкой по метрикам и защитой design doc |
Модуль 17 «SDD и OpenSpec» рекомендуется после модуля 4. Его теория и локальный проект Access Requests добавляют 12–14 часов (примерно 1–2 недели) к показанному ниже базовому плану на 20 недель; с ними маршрут занимает ориентировочно 21–22 недели. Номера недель диаграммы относятся к базовому плану.
После лабораторной проведите одно небольшое изменение проекта A через полный цикл: например, явный отказ при отсутствии доступных источников. Зафиксируйте baseline, proposal и дельту, согласуйте критерии, реализуйте, проверьте тестами и evals, обновите основные спеки и архивируйте change. Критерии и материалы практики.
План по неделям
Нагрузка — 8–10 часов в неделю вместе с чтением модулей: задания проектов и есть практика модулей, их часы уже входят в оценку курса. Модуль 16 (мультимодальность и голос) — факультатив, его можно пройти в любой момент после модуля 9. Если отстаёте, не пропускайте недели 6, 9 и 10 — golden set, red-team и гейт evals: без них остальное нечем доказать ни на собеседовании, ни на защите капстоуна. Проще урезать D до трейсинга, SLO и дашборда.
Диаграмма Ганта: 20 недель
Клик по неделе (столбцу или кнопке) показывает задачи, модули и критерий готовности. Ромбы — ключевые вехи для портфолио.
A · RAG по документации на Go + pgvector
A · RAG по документации на Java + pgvector
Цель. Сервис, который индексирует документацию и отвечает на вопросы по ней с цитатами, стримингом и честным «не знаю», если в документации ответа нет. Всё на GoJava 21 и Spring Boot, хранилище — Postgres с pgvector: одна база для чанков, метаданных, полнотекстового индекса и позже кэша. Проект растёт вместе с модулями 0–5: сначала каркас и вызов модели, потом промпты как код, потом retrieval. В неделю 14 к нему возвращаетесь с модулем 11: PDF, PII-редакция, дедупликация и версии корпуса.
Схема проекта A
Структура репозитория
docrag/ # один Spring Boot jar, режимы — профили (ingest, server, позже mcp, agent)
├── pom.xml # Java 21, Spring Boot 3.5, anthropic-java, pgvector-java, Flyway, Testcontainers
├── src/main/java/dev/docrag/
│ ├── DocragApplication.java
│ ├── ingest/IngestRunner.java # профиль ingest: загрузить корпус, порезать, заэмбеддить, записать
│ ├── loader/ # Markdown/HTML, с недели 14 — PDF и таблицы
│ ├── redact/ # PII-редакция перед индексом и логами (неделя 14)
│ ├── chunker/ # структурный чанкинг
│ ├── embed/ # клиент эмбеддингов: батчи, ретраи, rate limit
│ ├── store/ # JdbcClient + pgvector-java, upsert чанков, hybrid search (SQL ниже)
│ ├── retrieve/ # RRF, rerank, порог уверенности
│ ├── generate/ # промпт, вызов Claude, стриминг, проверка цитат
│ └── web/ # контроллеры: /ask (SseEmitter), /search; фильтры (timeout, request id)
├── src/main/resources/
│ ├── application.yml # /actuator/health вместо /healthz
│ ├── db/migration/V1__chunks.sql # Flyway
│ └── prompts/answer.v1.txt # промпт версионирован как код
├── src/test/java/dev/docrag/ # ChunkerTest (@ParameterizedTest), StoreIT (Testcontainers + pgvector)
├── docker-compose.yml # postgres с pgvector
├── Makefile # make up / ingest / run / test / eval — обёртки над mvn и docker compose
├── AGENTS.md # контекст и запреты для coding agent (модуль 4)
└── README.md
docrag/
├── cmd/
│ ├── ingest/main.go # CLI: загрузить корпус, порезать, заэмбеддить, записать
│ └── server/main.go # HTTP API: /ask (SSE), /search, /healthz
├── internal/
│ ├── loader/ # Markdown/HTML, с недели 14 — PDF и таблицы
│ ├── redact/ # PII-редакция перед индексом и логами (неделя 14)
│ ├── chunker/ # структурный чанкинг + chunker_test.go (табличные тесты)
│ ├── embed/ # клиент эмбеддингов: батчи, ретраи, rate limit
│ ├── store/ # pgx, миграции, upsert чанков, hybrid search (SQL ниже)
│ ├── retrieve/ # RRF, rerank, порог уверенности
│ ├── generate/ # промпт, вызов Claude, стриминг, проверка цитат
│ └── httpapi/ # хендлеры, SSE, middleware (timeout, request id)
├── migrations/001_chunks.sql
├── prompts/answer.v1.txt # промпт версионирован как код
├── docker-compose.yml # postgres с pgvector
├── Makefile # make up / ingest / run / test / eval
├── AGENTS.md # контекст и запреты для coding agent (модуль 4)
└── README.md
Ключевой код
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE chunks (
id bigserial PRIMARY KEY,
doc_url text NOT NULL,
ord int NOT NULL,
heading text NOT NULL DEFAULT '',
content text NOT NULL,
tokens int NOT NULL,
embedding vector(1024) NOT NULL,
tsv tsvector GENERATED ALWAYS AS
(to_tsvector('simple', heading || ' ' || content)) STORED,
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (doc_url, ord)
);
CREATE INDEX chunks_hnsw ON chunks USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
CREATE INDEX chunks_tsv ON chunks USING gin (tsv);
-- Гибридный поиск: вектор и полнотекст параллельно, слияние RRF (k = 60).
-- $1 — эмбеддинг запроса, $2 — текст запроса, $3 — сколько вернуть.
WITH v AS (
SELECT id, row_number() OVER (ORDER BY embedding <=> $1) AS r
FROM chunks ORDER BY embedding <=> $1 LIMIT 40
), k AS (
SELECT id, row_number() OVER (ORDER BY ts_rank_cd(tsv, q) DESC) AS r
FROM chunks, plainto_tsquery('simple', $2) q
WHERE tsv @@ q
ORDER BY ts_rank_cd(tsv, q) DESC LIMIT 40
)
SELECT c.id, c.doc_url, c.heading, c.content,
coalesce(1.0 / (60 + v.r), 0) + coalesce(1.0 / (60 + k.r), 0) AS score
FROM v FULL OUTER JOIN k USING (id)
JOIN chunks c USING (id)
ORDER BY score DESC
LIMIT $3;// POST /ask {"q": "..."} → text/event-stream: sources, delta*, done | error
@RestController
class AskController {
private static final Logger log = LoggerFactory.getLogger(AskController.class);
record AskRequest(@NotBlank @Size(max = 8192) String q) {}
private final Retriever retriever;
private final AnthropicClient llm;
private final String systemPrompt;
private final double minScore;
AskController(Retriever retriever, AnthropicClient llm, Prompts prompts,
@Value("${docrag.min-score}") double minScore) {
this.retriever = retriever;
this.llm = llm;
this.systemPrompt = prompts.system();
this.minScore = minScore;
}
@PostMapping(path = "/ask", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
SseEmitter ask(@Valid @RequestBody AskRequest req, HttpServletResponse resp) {
List<Hit> hits;
try {
hits = retriever.search(req.q(), 8);
} catch (DataAccessException e) {
log.error("search", e);
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "search failed");
}
resp.setHeader("X-Accel-Buffering", "no"); // иначе nginx буферизует стрим
SseEmitter emitter = new SseEmitter(0L); // таймаут стрима — наша забота, не Spring
Thread.startVirtualThread(() -> stream(emitter, req.q(), hits));
return emitter;
}
private void stream(SseEmitter emitter, String q, List<Hit> hits) {
try {
emitter.send(SseEmitter.event().name("sources").data(hits));
if (hits.isEmpty() || hits.get(0).score() < minScore) {
emitter.send(SseEmitter.event().name("done")
.data(Map.of("answer", "В документации нет ответа на этот вопрос.")));
emitter.complete();
return; // не генерируем без источников
}
MessageCreateParams params = MessageCreateParams.builder()
.model("claude-sonnet-5")
.maxTokens(2048)
.systemOfTextBlockParams(List.of(TextBlockParam.builder()
.text(systemPrompt) // стабильный префикс → prompt caching
.cacheControl(CacheControlEphemeral.builder().build())
.build()))
.addUserMessage(Prompts.build(q, hits))
.build();
// Клиент ушёл → send() бросает IOException → try-with-resources закрывает стрим к модели.
try (StreamResponse<RawMessageStreamEvent> stream = llm.messages().createStreaming(params)) {
for (Iterator<RawMessageStreamEvent> it = stream.stream().iterator(); it.hasNext(); ) {
Optional<String> text = it.next().contentBlockDelta()
.flatMap(d -> d.delta().text())
.map(TextDelta::text);
if (text.isPresent()) {
emitter.send(SseEmitter.event().name("delta").data(Map.of("text", text.get())));
}
}
}
emitter.send(SseEmitter.event().name("done").data(Map.of()));
emitter.complete();
} catch (IOException e) {
emitter.completeWithError(e); // клиент ушёл: писать некуда
} catch (RuntimeException e) { // ошибка API, в том числе посреди стрима
log.error("llm stream", e);
try {
emitter.send(SseEmitter.event().name("error").data(Map.of("error", "generation failed")));
emitter.complete();
} catch (IOException ex) {
emitter.completeWithError(ex);
}
}
}
}// POST /ask {"q": "..."} → text/event-stream: sources, delta*, done | error
func (s *Server) handleAsk(w http.ResponseWriter, r *http.Request) {
var req struct{ Q string `json:"q"` }
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&req); err != nil || req.Q == "" {
http.Error(w, `{"error":"q required"}`, http.StatusBadRequest)
return
}
ctx := r.Context() // клиент ушёл → ctx отменён → стрим к модели закрывается
hits, err := s.retriever.Search(ctx, req.Q, 8)
if err != nil {
s.log.ErrorContext(ctx, "search", "err", err)
http.Error(w, `{"error":"search failed"}`, http.StatusBadGateway)
return
}
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no") // иначе nginx буферизует стрим
send := func(event string, v any) {
b, _ := json.Marshal(v)
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, b)
flusher.Flush()
}
send("sources", hits)
if len(hits) == 0 || hits[0].Score < s.minScore {
send("done", map[string]string{"answer": "В документации нет ответа на этот вопрос."})
return // не генерируем без источников
}
stream := s.llm.Messages.NewStreaming(ctx, anthropic.MessageNewParams{
Model: "claude-sonnet-5",
MaxTokens: 2048,
System: []anthropic.TextBlockParam{{
Text: s.systemPrompt, // стабильный префикс → prompt caching
CacheControl: anthropic.NewCacheControlEphemeralParam(),
}},
Messages: []anthropic.MessageParam{
anthropic.NewUserMessage(anthropic.NewTextBlock(buildPrompt(req.Q, hits))),
},
})
defer stream.Close()
for stream.Next() {
if d, ok := stream.Current().AsAny().(anthropic.ContentBlockDeltaEvent); ok {
if t, ok := d.Delta.AsAny().(anthropic.TextDelta); ok {
send("delta", map[string]string{"text": t.Text})
}
}
}
if err := stream.Err(); err != nil {
s.log.ErrorContext(ctx, "llm stream", "err", err)
send("error", map[string]string{"error": "generation failed"})
return
}
send("done", map[string]any{})
}Этапы и критерии готовности
Неделя 1 · Каркас и первый вызов
Неделя 2 · LLM-клиент и SSE
Неделя 3 · Промпт как код
Неделя 4 · Ingest и coding agent
Неделя 5 · Поиск и цитаты
Неделя 6 · Улучшения по evals (вместе с B)
Неделя 14 · Ingestion v2: PDF, PII, версии (модуль 11, задания 1–2)
Необязательные расширения
Метрики для README
- Размер корпуса: документов, чанков, токенов; время и стоимость полной индексации.
- Recall@5 и MRR на golden set (из проекта B) для baseline и лучшей конфигурации.
- Латентность: поиск p50/p95, TTFT p50/p95, полный ответ p95.
- Стоимость одного ответа (вход, выход, cache read) и проекция на 1 000 вопросов в день.
- Доля ответов «не знаю» на вопросах без ответа в документации (должна быть высокой).
- PII: полнота редакции на синтетическом наборе и ноль канареек в индексе и логах.
Что рассказывать на собеседовании
- Почему pgvector, а не отдельная векторная БД: объём, транзакционность, фильтры и полнотекст в одном запросе; при каком объёме вы бы переехали.
- Как выбрали размер чанка — экспериментом с цифрами, а не по статье.
- Как устроен стриминг и что происходит при обрыве соединения (context, отмена запроса к модели
IOExceptionизSseEmitter, закрытиеStreamResponseи отмена запроса к модели). - Самая большая ошибка — обычно чанкинг или пропущенные документы при парсинге — и как её нашли.
B · Eval-набор и CI
Цель. Превратить «вроде работает» в числа. Golden set на 50–100 вопросов, метрики retrieval и генерации, LLM-judge, откалиброванный по вашей разметке, отчёт и гейт в CI, который блокирует PR с регрессией. Evals пишутся на Python, потому что там удобнее анализ (pandas, ноутбуки), и обращаются к GoJava-сервису по HTTP — как настоящий внешний клиент. Проект идёт в два захода: в неделю 6, сразу после RAG, — golden set и метрики retrieval (модуль 5), чтобы эксперименты с поиском мерились числами; в неделю 10, с модулем 8, — judge, статистика и гейт в CI.
Схема проекта B
Структура
docrag/evals/
├── data/
│ ├── golden.jsonl # {id, question, expected_urls[], key_facts[], tags[], answerable}
│ └── judge_labels.jsonl # ваша ручная разметка для калибровки судьи
├── evals/
│ ├── run.py # прогон вопросов через /search и /ask, кэш ответов по хэшу
│ ├── retrieval.py # recall@k, MRR, nDCG
│ ├── judge.py # faithfulness и correctness через Claude, structured output
│ ├── calibrate.py # согласие судьи с вашей разметкой (accuracy, κ, TPR/TNR)
│ └── report.py # Markdown-отчёт + сравнение с baseline
├── baseline.json # метрики main-ветки
├── pyproject.toml
└── ../.github/workflows/evals.yml
Ключевой код
import json, sys, statistics, httpx
def recall_at_k(got: list[str], expected: set[str], k: int) -> float:
return len(set(got[:k]) & expected) / len(expected) if expected else 1.0
def mrr(got: list[str], expected: set[str]) -> float:
return next((1 / (i + 1) for i, u in enumerate(got) if u in expected), 0.0)
def run(base_url: str, path: str = "data/golden.jsonl") -> dict:
rows = [json.loads(l) for l in open(path, encoding="utf-8")]
r5, rr = [], []
with httpx.Client(base_url=base_url, timeout=30) as c:
for row in rows:
if not row["answerable"]:
continue # вопросы без ответа проверяются отдельно: ждём «не знаю»
resp = c.get("/search", params={"q": row["question"], "k": 10})
resp.raise_for_status()
urls = [h["doc_url"] for h in resp.json()]
exp = set(row["expected_urls"])
r5.append(recall_at_k(urls, exp, 5)); rr.append(mrr(urls, exp))
return {"recall@5": statistics.mean(r5), "mrr": statistics.mean(rr), "n": len(r5)}
def gate(cur: dict, base: dict, tol: float = 0.02) -> list[str]:
return [f"{m}: {cur[m]:.3f} < {base[m]:.3f} - {tol}"
for m in ("recall@5", "mrr") if cur[m] < base[m] - tol]
if __name__ == "__main__":
cur = run(sys.argv[1])
fails = gate(cur, json.load(open("baseline.json")))
print(json.dumps(cur, indent=2))
if fails:
print("REGRESSION:\n" + "\n".join(fails)); sys.exit(1)# .github/workflows/evals.yml — smoke-набор на каждый PR, полный — ночью
name: evals
on:
pull_request:
schedule: [{ cron: "0 3 * * *" }]
jobs:
evals:
runs-on: ubuntu-latest
services:
postgres:
image: pgvector/pgvector:pg17
env: { POSTGRES_PASSWORD: postgres }
ports: ["5432:5432"]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-java@v5
with: { distribution: temurin, java-version: "21", cache: maven }
- uses: actions/setup-python@v7
with: { python-version: "3.12" }
- run: make ingest-fixture # маленький зафиксированный корпус, эмбеддинги из кэша
- run: make run-bg
- run: pip install -e evals
- run: python -m evals.run http://localhost:8080 ${{ github.event_name == 'schedule' && '--full' || '--smoke' }}
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}# .github/workflows/evals.yml — smoke-набор на каждый PR, полный — ночью
name: evals
on:
pull_request:
schedule: [{ cron: "0 3 * * *" }]
jobs:
evals:
runs-on: ubuntu-latest
services:
postgres:
image: pgvector/pgvector:pg17
env: { POSTGRES_PASSWORD: postgres }
ports: ["5432:5432"]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with: { go-version: "1.23" }
- uses: actions/setup-python@v7
with: { python-version: "3.12" }
- run: make ingest-fixture # маленький зафиксированный корпус, эмбеддинги из кэша
- run: make run-bg
- run: pip install -e evals
- run: python -m evals.run http://localhost:8080 ${{ github.event_name == 'schedule' && '--full' || '--smoke' }}
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}Полный workflow с baseline-артефактом из main, повторами и комментарием в PR — m08.html#ci.
Этапы и критерии готовности
Неделя 6 · Golden set и retrieval-метрики
Неделя 10 · Judge, статистика, CI (модуль 8, задания 2–3)
Метрики для README
- Размер и состав golden set (сколько вопросов каждого типа, откуда взяты).
- Таблица экспериментов с baseline и лучшей конфигурацией.
- Согласие judge с человеческой разметкой (κ, TPR, TNR) и на скольких примерах оно измерено.
- Стоимость и время прогона smoke и полного набора.
Что рассказывать на собеседовании
- Откуда взялись вопросы и как вы избежали набора, «подогнанного» под систему.
- Почему бинарные критерии судьи вместо шкалы 1–10 и как калибровали.
- Пример, где метрика поймала регрессию, которую вы бы не заметили глазами.
- Как сделали CI нефлаки: пороги с допуском, кэш ответов, smoke против полного прогона.
C · Агент, MCP-сервер и безопасность
Цель. Дать агентам доступ к вашей системе через стандартный протокол. MCP-сервер на GoJava с инструментами поиска по документации, чтения чанка целиком и чтения из БД (только чтение, allowlist запросов), подключённый к Claude Code и Claude Desktop. Плюс собственный агентный цикл на GoJava поверх тех же инструментов — чтобы показать, что вы понимаете, что происходит внутри клиента, — с лимитами, компакцией и заметками для длинных задач. Затем, с модулем 7, агент получает threat model, guardrails и набор атак с метрикой ASR (attack success rate, доля успешных атак), а с модулем 8 — eval по траекториям.
Схема проекта C
Структура
docrag/src/main/java/dev/docrag/
├── mcp/
│ ├── McpConfig.java # профиль mcp: stdio по умолчанию, Streamable HTTP — отдельным профилем
│ └── DocTools.java # @Tool: search_docs, get_chunk, db_query (read-only, allowlist)
├── agent/ # профиль agent: свой цикл tool use — лимиты, параллельные вызовы, is_error, компакция, заметки
└── guard/ # входные и выходные guardrails, фильтр ссылок (неделя 9)
docrag/src/main/resources/application-mcp.yml
docrag/src/test/java/dev/docrag/mcp/DocToolsTest.java # вызовы через in-memory транспорт MCP SDK
docrag/evals/agent_tasks.jsonl # задачи для агента: целевое состояние, required/forbidden инструменты
docrag/evals/attacks.jsonl # red-team: прямые и непрямые инъекции, эксфильтрация, excessive agency
docrag/
├── cmd/
│ ├── docs-mcp/main.go # MCP-сервер: stdio по умолчанию, --http для Streamable HTTP
│ └── agent/main.go # свой агентный цикл: Claude + те же инструменты
├── internal/
│ ├── mcptools/ # search_docs, get_chunk, db_query (read-only, allowlist)
│ │ └── tools_test.go # вызовы через in-memory транспорт
│ ├── agent/ # цикл tool use: лимиты, параллельные вызовы, is_error, компакция, заметки
│ └── guard/ # входные и выходные guardrails, фильтр ссылок (неделя 9)
├── evals/agent_tasks.jsonl # задачи для агента: целевое состояние, required/forbidden инструменты
└── evals/attacks.jsonl # red-team: прямые и непрямые инъекции, эксфильтрация, excessive agency
Ключевой код
// mcp/McpConfig.java + mcp/DocTools.java — MCP-сервер на Spring AI
// (стартер spring-ai-starter-mcp-server поверх официального MCP Java SDK).
// application-mcp.yml:
// spring.ai.mcp.server: { enabled: true, name: docrag, version: 0.3.0, stdio: true }
// spring.main: { web-application-type: none, banner-mode: off }
// logging: { pattern.console: "", file.name: docs-mcp.log }
// # stdout занят JSON-RPC: баннер и любой лишний вывод ломают протокол
package dev.docrag.mcp;
import dev.docrag.retrieve.Hit;
import dev.docrag.retrieve.Retriever;
import java.util.List;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.ai.tool.method.MethodToolCallbackProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
@Configuration
@Profile("mcp")
class McpConfig {
@Bean
ToolCallbackProvider docTools(DocTools tools) {
return MethodToolCallbackProvider.builder().toolObjects(tools).build();
}
}
@Component
@Profile("mcp")
class DocTools {
public record SearchOut(List<Hit> results) {}
private final Retriever retriever;
DocTools(Retriever retriever) {
this.retriever = retriever;
}
@Tool(name = "search_docs", description = "Гибридный поиск по документации. Возвращает фрагменты "
+ "с URL и скором. Используй перед ответом на вопрос о документации.")
public SearchOut searchDocs(
@ToolParam(description = "вопрос или ключевые слова на естественном языке") String query,
@ToolParam(description = "сколько фрагментов вернуть, 1-10, по умолчанию 5", required = false) Integer k) {
int n = (k == null || k <= 0 || k > 10) ? 5 : k;
// Исключение не роняет сервер: клиент получит результат с isError.
return new SearchOut(retriever.search(query, n));
}
}
// cmd/docs-mcp/main.go — MCP-сервер на официальном Go SDK.
package main
import (
"context"
"log"
"os"
"github.com/modelcontextprotocol/go-sdk/mcp"
"example.com/docrag/internal/retrieve"
)
type SearchIn struct {
Query string `json:"query" jsonschema:"вопрос или ключевые слова на естественном языке"`
K int `json:"k,omitempty" jsonschema:"сколько фрагментов вернуть, 1-10, по умолчанию 5"`
}
type SearchOut struct {
Results []retrieve.Hit `json:"results"`
}
func main() {
log.SetOutput(os.Stderr) // stdout занят JSON-RPC: любой лишний вывод ломает протокол
r, err := retrieve.New(context.Background(), os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatal(err)
}
server := mcp.NewServer(&mcp.Implementation{Name: "docrag", Version: "0.3.0"}, nil)
mcp.AddTool(server, &mcp.Tool{
Name: "search_docs",
Description: "Гибридный поиск по документации. Возвращает фрагменты с URL и скором. Используй перед ответом на вопрос о документации.",
}, func(ctx context.Context, _ *mcp.CallToolRequest, in SearchIn) (*mcp.CallToolResult, SearchOut, error) {
if in.K <= 0 || in.K > 10 {
in.K = 5
}
hits, err := r.Search(ctx, in.Query, in.K)
if err != nil {
return nil, SearchOut{}, err // SDK вернёт клиенту результат с isError
}
return nil, SearchOut{Results: hits}, nil
})
if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
log.Fatal(err)
}
}
# Подключение к Claude Code (stdio): jar запускается как подпроцесс в профиле mcp
mvn -q package -DskipTests
claude mcp add docrag --env SPRING_DATASOURCE_URL=jdbc:postgresql://localhost/docrag \
-- java -jar target/docrag.jar --spring.profiles.active=mcp
# Отладка без клиента: MCP Inspector показывает инструменты и позволяет вызвать их руками
npx @modelcontextprotocol/inspector java -jar target/docrag.jar --spring.profiles.active=mcp
# Подключение к Claude Code (stdio): бинарник запускается как подпроцесс
go build -o bin/docs-mcp ./cmd/docs-mcp
claude mcp add docrag --env DATABASE_URL=postgres://localhost/docrag -- ./bin/docs-mcp
# Отладка без клиента: MCP Inspector показывает инструменты и позволяет вызвать их руками
npx @modelcontextprotocol/inspector ./bin/docs-mcp
Для Claude Desktop сервер прописывается в его конфигурационном файле в секции mcpServers (команда, аргументы, переменные окружения); путь к файлу и формат смотрите в документации MCP. Для удалённого доступа тот же сервер отдаётся через Streamable HTTP-хендлер из SDKтранспорт стартера (WebMVC или WebFlux) за вашей аутентификацией.
db_query принимает имя заранее описанного запроса и параметры ({"name": "docs_updated_since", "args": {"days": 7}}), выполняется под ролью только на чтение с statement_timeout. Если хочется свободного SQL — это разбор 3 из модуля 12 со всеми его защитами.Этапы и критерии готовности
Неделя 7 · MCP-сервер
Неделя 8 · Клиенты, свой агент и длинные задачи
Неделя 9 · Guardrails и red-team (модуль 7)
Неделя 11 · Траектории и атаки в CI (модуль 8, задание 4)
Метрики для README
- Success rate агента на наборе задач, среднее число шагов и токенов, стоимость задачи.
- Сравнение: агент с инструментами против одного вызова RAG на тех же вопросах (качество, латентность, цена).
- Латентность инструментов p95.
- ASR до и после защит, по категориям атак, и сколько атак в наборе.
Что рассказывать на собеседовании
- Почему MCP, а не просто HTTP API: один сервер для любого клиента, обнаружение инструментов, стандартные транспорты.
- Как писали описания инструментов и как это повлияло на success rate (описание — это промпт).
- Когда агент хуже простого RAG: цифры из сравнения.
- Безопасность: allowlist запросов, роль только на чтение, почему никакого свободного SQL; какая нога lethal trifecta разорвана архитектурно и какой ASR остался.
D · Продакшнизация
Цель. Превратить демо в сервис, за который не стыдно на дежурстве: видно каждый шаг каждого запроса, отказ провайдера не роняет продукт, повторяющиеся вопросы не стоят денег, стоимость видна на дашборде в реальном времени. С модулем 9 сюда же входят SLO с burn-rate алертами, фича-флаги для промптов и моделей с откатом без деплоя и мониторинг дрейфа; с модулем 10 — по желанию open-weight модель за тем же шлюзом.
Схема проекта D
Структура
docrag/src/main/java/dev/docrag/
├── llm/
│ ├── LlmClient.java # generate/stream поверх SDK, спаны и метрики
│ ├── FallbackChain.java # цепочка моделей, circuit breaker на каждую, fallback до первого байта
│ ├── Breakers.java # Resilience4j CircuitBreakerRegistry: свой breaker на модель
│ ├── Router.java # роутинг по сложности, порог во флаге
│ └── Cost.java # цены моделей, расчёт $ по usage
├── flags/ # реестр версий промптов и моделей, доли трафика, kill switch
├── cache/
│ ├── ExactCache.java # Redis (Spring Data Redis): sha256(model + prompt version + нормализованный вопрос)
│ └── SemanticCache.java # pgvector по эмбеддингам вопросов, порог сходства, TTL
├── telemetry/
│ ├── OtelConfig.java # OpenTelemetry SDK (или Micrometer Tracing + OTLP), экспорт в collector
│ └── GenAi.java # хелперы атрибутов gen_ai.*
deploy/
├── docker-compose.obs.yml # otel-collector, Tempo/Jaeger, Prometheus, Grafana (или Langfuse)
├── slo/ # SLO, recording rules, multiwindow burn-rate алерты
└── grafana/dashboards/cost.json
docrag/internal/
├── llm/
│ ├── client.go # интерфейс Generate/Stream поверх SDK
│ ├── fallback.go # цепочка моделей, circuit breaker на каждую, fallback до первого байта
│ ├── breaker.go # или sony/gobreaker
│ ├── router.go # роутинг по сложности, порог во флаге
│ └── cost.go # цены моделей, расчёт $ по usage
├── flags/ # реестр версий промптов и моделей, доли трафика, kill switch
├── cache/
│ ├── exact.go # Redis: sha256(model + prompt version + нормализованный вопрос)
│ └── semantic.go # pgvector по эмбеддингам вопросов, порог сходства, TTL
├── telemetry/
│ ├── otel.go # TracerProvider, MeterProvider, OTLP-экспорт
│ └── genai.go # хелперы атрибутов gen_ai.*
deploy/
├── docker-compose.obs.yml # otel-collector, Tempo/Jaeger, Prometheus, Grafana (или Langfuse)
├── slo/ # SLO, recording rules, multiwindow burn-rate алерты
└── grafana/dashboards/cost.json
Ключевой код
// Спан на вызов модели с атрибутами по семантическим конвенциям OpenTelemetry GenAI.
public final class LlmClient {
private static final Tracer TRACER = GlobalOpenTelemetry.getTracer("docrag/llm");
private static final AttributeKey<String> MODEL = AttributeKey.stringKey("model");
private static final AttributeKey<Boolean> ERROR = AttributeKey.booleanKey("error");
private final AnthropicClient sdk;
private final DoubleHistogram latency; // meter.histogramBuilder("llm.latency").setUnit("s").build()
private final DoubleCounter costUsd; // meter.counterBuilder("llm.cost").ofDoubles().setUnit("USD").build()
private final Prices prices;
private final String promptVersion;
// конструктор опущен
public Message generate(MessageCreateParams p) {
String model = p.model().asString();
Span span = TRACER.spanBuilder("chat " + model).setSpanKind(SpanKind.CLIENT).startSpan();
try (Scope ignored = span.makeCurrent()) {
span.setAttribute("gen_ai.operation.name", "chat");
span.setAttribute("gen_ai.provider.name", "anthropic");
span.setAttribute("gen_ai.request.model", model);
span.setAttribute("gen_ai.request.max_tokens", p.maxTokens());
span.setAttribute("app.prompt_version", promptVersion);
long start = System.nanoTime();
Message msg;
try {
msg = sdk.messages().create(p);
} catch (RuntimeException e) {
latency.record(secondsSince(start), Attributes.of(MODEL, model, ERROR, true));
span.recordException(e);
span.setStatus(StatusCode.ERROR, "llm call failed");
throw e;
}
latency.record(secondsSince(start), Attributes.of(MODEL, model, ERROR, false));
Usage u = msg.usage();
long cacheRead = u.cacheReadInputTokens().orElse(0L);
double usd = prices.cost(model, u.inputTokens(), cacheRead, u.outputTokens());
span.setAttribute("gen_ai.usage.input_tokens", u.inputTokens());
span.setAttribute("gen_ai.usage.output_tokens", u.outputTokens());
span.setAttribute("app.cache_read_input_tokens", cacheRead);
span.setAttribute(AttributeKey.stringArrayKey("gen_ai.response.finish_reasons"),
List.of(msg.stopReason().map(StopReason::asString).orElse("")));
span.setAttribute("app.cost_usd", usd);
costUsd.add(usd, Attributes.of(MODEL, model));
return msg;
} finally {
span.end();
}
}
private static double secondsSince(long startNanos) {
return (System.nanoTime() - startNanos) / 1e9;
}
}
// Спан на вызов модели с атрибутами по семантическим конвенциям OpenTelemetry GenAI.
var tracer = otel.Tracer("docrag/llm")
func (c *Client) Generate(ctx context.Context, p anthropic.MessageNewParams) (*anthropic.Message, error) {
ctx, span := tracer.Start(ctx, "chat "+string(p.Model), trace.WithSpanKind(trace.SpanKindClient))
defer span.End()
span.SetAttributes(
attribute.String("gen_ai.operation.name", "chat"),
attribute.String("gen_ai.provider.name", "anthropic"),
attribute.String("gen_ai.request.model", string(p.Model)),
attribute.Int64("gen_ai.request.max_tokens", p.MaxTokens),
attribute.String("app.prompt_version", c.promptVersion),
)
start := time.Now()
msg, err := c.sdk.Messages.New(ctx, p)
c.latency.Record(ctx, time.Since(start).Seconds(),
metric.WithAttributes(attribute.String("model", string(p.Model)), attribute.Bool("error", err != nil)))
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "llm call failed")
return nil, err
}
u := msg.Usage
usd := c.prices.Cost(p.Model, u.InputTokens, u.CacheReadInputTokens, u.OutputTokens)
span.SetAttributes(
attribute.Int64("gen_ai.usage.input_tokens", u.InputTokens),
attribute.Int64("gen_ai.usage.output_tokens", u.OutputTokens),
attribute.Int64("app.cache_read_input_tokens", u.CacheReadInputTokens),
attribute.StringSlice("gen_ai.response.finish_reasons", []string{string(msg.StopReason)}),
attribute.Float64("app.cost_usd", usd),
)
c.costUSD.Add(ctx, usd, metric.WithAttributes(attribute.String("model", string(p.Model))))
return msg, nil
}
Имена атрибутов gen_ai.* берите из текущей версии семантических конвенций OpenTelemetry для GenAI (они ещё развиваются); собственные поля помечайте префиксом приложения, как app.cost_usd.
Этапы и критерии готовности
Неделя 11 · Трейсинг
Неделя 12 · Надёжность и шлюз
Неделя 13 · Кэш, SLO и дрейф
Метрики для README
- Скриншот дашборда и трейса.
- Hit rate точного и семантического кэша на реалистичной нагрузке и сколько это экономит в $.
- Поведение при отказе провайдера: доля успешных ответов и латентность в режиме fallback.
- Нагрузочный тест (k6 или vegeta): RPS, p95, где узкое место.
- SLO и остаток error budget; время отката версии промпта флагом.
Что рассказывать на собеседовании
- Почему fallback только до первого байта и что видит пользователь в каждом режиме отказа.
- Как выбрали порог семантического кэша и почему он опасен без eval.
- Что показали трейсы: конкретный найденный через них баг.
- Из чего складывается стоимость запроса и какой рычаг дал больше всего экономии.
- Как дежурный узнаёт о деградации качества и откатывает её без автора системы.
Финиш: README, демо, рассказ
Недели 15–17 идут параллельно с модулями 12–14 и превращают систему в портфолио. С модулем 12 вы разбираете свою систему по скелету system design и упаковываете её; с модулем 13 записываете ключевые решения в ADR и пишете постмортем на реальный сбой; с модулем 14 готовите рассказ и проходите мок-интервью. Ревьюер портфолио тратит на репозиторий 2–5 минут, поэтому README — главный артефакт, а код — доказательство, что README не врёт.
# docrag — RAG по документации на Java + pgvector
Одна строка: что это и для кого. GIF или ссылка на 2-минутное демо.
## Результаты (первым разделом — цифры)
| конфигурация | recall@5 | faithfulness | TTFT p95 | $/1K вопросов |
| baseline (вектор) | 0.62 | 0.84 | 1.4 s | 4.10 |
| гибрид + rerank | 0.87 | 0.91 | 1.2 s | 3.20 |
Golden set: 96 вопросов (как собран), judge откалиброван на 120 ответах (κ = 0.71).
## Архитектура схема + 5 строк
## Запуск make up && make ingest && make run
## Ключевые решения «X вместо Y, потому что…» × 5
## Надёжность режимы отказа и поведение, chaos-тест, SLO и откат флагом
## Безопасность threat model, ASR до и после защит, что разорвано архитектурно
## Стоимость из чего складывается запрос, кэш, проекция на нагрузку
## Ограничения и дальше честно
# docrag — RAG по документации на Go + pgvector
Одна строка: что это и для кого. GIF или ссылка на 2-минутное демо.
## Результаты (первым разделом — цифры)
| конфигурация | recall@5 | faithfulness | TTFT p95 | $/1K вопросов |
| baseline (вектор) | 0.62 | 0.84 | 1.4 s | 4.10 |
| гибрид + rerank | 0.87 | 0.91 | 1.2 s | 3.20 |
Golden set: 96 вопросов (как собран), judge откалиброван на 120 ответах (κ = 0.71).
## Архитектура схема + 5 строк
## Запуск make up && make ingest && make run
## Ключевые решения «X вместо Y, потому что…» × 5
## Надёжность режимы отказа и поведение, chaos-тест, SLO и откат флагом
## Безопасность threat model, ASR до и после защит, что разорвано архитектурно
## Стоимость из чего складывается запрос, кэш, проекция на нагрузку
## Ограничения и дальше честно
Демо
Видео на 2 минуты: вопрос → стрим с цитатами → трейс в Tempo/Langfuse → тот же вопрос из Claude Code через MCP → дашборд стоимости. Без монтажа, но с подготовленным сценарием.
Нагрузочный тест
k6 или vegeta против /search и /ask с фейковым провайдером (чтобы не платить): где узкое место — пул соединений, эмбеддер, Postgres?
Пост
Статья на 1 500 слов «что я узнал, строя RAG на GoJava»: 3 решения, 3 ошибки, таблица метрик. Это ещё и готовый рассказ для собеседования.
Рассказ
Прогоните проект через конструктор рассказа в модуле 14 и через вопросы deep dive из этого раздела.
Этапы и критерии готовности
Неделя 15 · Упаковка и system design (модуль 12)
Неделя 16 · Решения и инциденты (модуль 13)
Неделя 17 · Рассказ и собеседование (модуль 14)
Итоговое задание: переход в капстоун
Проекты A–D и финиш дают портфолио, с которым уже можно идти на собеседование. Следующий шаг — капстоун: за три недели эта же система становится продакшн-агентом с действиями, приёмкой по метрикам и защитой design doc. Критерии приёмки и пороги — в модуле 15; здесь — только подготовка к нему.
Система A–D готова стать основой капстоуна
Критерии готовности: текущие цифры системы зафиксированы как точка «до», предметная область капстоуна выбрана, а разрывы между тем, что есть, и критериями приёмки перечислены и оценены в часах.
Самопроверка
Почему golden set появляется уже на неделе 6, хотя модуль Evals — восьмой?
Все последующие изменения (rerank, чанкинг, fallback на другую модель, семантический кэш, guardrails) нужно оценивать числами. Для метрик retrieval хватает модуля 5, поэтому набор собирается сразу после RAG, а judge, статистика и гейт в CI добавляются с модулем 8. Без набора вы не знаете, помогло ли изменение, и на собеседовании нечего показать, кроме «стало лучше».
Почему red-team встроен в проект C, а не вынесен в отдельный проект?
Атаки направлены на конкретную систему: агента с инструментами и данными из A и C. Набор атак — это eval-датасет, который живёт рядом с агентными задачами и гоняется тем же CI. Отдельный проект создал бы иллюзию, что безопасность можно «доделать потом», а не проверять на каждом PR.
Почему в проекте A достаточно pgvector?
Корпус 500–5 000 страниц — это десятки тысяч чанков: HNSW в Postgres отвечает за миллисекунды, а фильтры, полнотекст, метаданные и кэш живут в одной транзакционной базе. Отдельная векторная БД добавила бы эксплуатационную сложность без выигрыша на таком объёме.
Что сломается, если MCP-сервер на stdio пишет логи в stdout?
stdout — канал JSON-RPC; любая лишняя строка будет воспринята клиентом как невалидное сообщение, и соединение сломается. Логи — только в stderr или файл.
Почему инструмент db_query принимает имя запроса, а не SQL?
Чтобы поверхность атаки была конечной: агент может выполнить только заранее описанные и проверенные запросы с параметрами. Свободный SQL требует AST-валидации, проверки прав, EXPLAIN-гейта и других защит, которые в этом проекте не нужны.
Как подобрать порог семантического кэша?
На golden set: для пар вопросов с одинаковым и разным правильным ответом посчитать сходство эмбеддингов и выбрать порог, при котором нет ни одного неверного хита (или их доля ниже допустимой). Затем мониторить в проде: выборочно проверять хиты судьёй.
Какие атрибуты должны быть у спана вызова модели?
Модель, провайдер, операция, max_tokens, токены входа/выхода и cache read, finish reason, стоимость, версия промпта; плюс статус ошибки. Этого достаточно, чтобы построить дашборд стоимости и найти причину плохого ответа.
Вопросы с собеседований по проектам
Расскажите про архитектуру вашего RAG-проекта. Почему такие решения?
Три-пять компонентов с причинами: структурный чанкинг (эксперимент показал +N recall против фиксированного размера), pgvector (объём, фильтры и полнотекст в одном запросе; переезд при десятках миллионов векторов или высоком QPS), гибрид + RRF (идентификаторы в документации), порог уверенности (не генерируем без источников). Каждое решение — с альтернативой и цифрой.
Как вы поняли, что система работает хорошо?
Golden set из N вопросов с источниками: какие типы, как собран. Retrieval: recall@5 X → Y. Генерация: faithfulness Z по судье, откалиброванному на моей разметке (согласие W%). Гейт в CI. И ограничения: набор маленький, различия меньше 2–3 п.п. считаю шумом.
Что было самой большой ошибкой в проекте?
Конкретный случай с тем, как нашли и какие цифры до и после: например, чанкинг по символам рвал код-блоки, recall был 0.48; нашёл через разбор худших примеров в отчёте evals; структурный чанкинг дал 0.7+. Вывод: сначала смотреть на данные, потом тюнить модель.
Что произойдёт с вашим сервисом, если Anthropic API недоступен 10 минут?
Circuit breaker откроется после серии ошибок, запросы пойдут на резервную модель; если недоступно всё — API отдаёт найденные фрагменты с сообщением, что генерация временно недоступна. Кэш отвечает на популярные вопросы. Это покрыто chaos-тестом с фейковым провайдером; на дашборде видна доля fallback.
Сколько стоит ваш сервис и как вы это посчитали?
Из трейсов: средние токены входа/выхода и доля cache read, умножить на цены. Например, $0.003 за вопрос; при 1 000 вопросах в день ≈ $90 в месяц. Главные рычаги: prompt caching системного промпта, меньше чанков после rerank, кэш ответов. Индексация — разовая стоимость эмбеддингов.
Зачем вам MCP, если есть HTTP API?
HTTP API — для моего фронтенда и интеграций, которые я пишу сам. MCP — стандартный способ дать инструменты любому агентному клиенту (Claude Code, Desktop, IDE) без написания интеграции под каждый: клиент сам обнаруживает инструменты и их схемы. Цена — ещё один интерфейс для поддержки и отдельная модель безопасности (кто может подключиться, что могут инструменты).
Как вы проверяли, что агента нельзя заставить слить данные через документ или результат инструмента?
Начал с threat model: какие приватные данные видит агент, откуда приходит недоверенный текст, какие есть каналы наружу. Затем набор из N атак как eval-датасет: прямые и непрямые инъекции через документы корпуса и результаты инструментов, утечка system prompt, эксфильтрация через markdown-картинки. ASR до защит — X%, после — Y%, с интервалом. Главное снижение дала не инструкция в промпте, а архитектура: инструменты только на чтение и с правами пользователя, фильтр ссылок на внешние домены на выходе. Набор гоняется в CI, новая успешная атака валит PR.
На что смотрит интервьюер: есть ли измерение, а не «добавили guardrails»; понимает ли кандидат, что промптом инъекцию не закрыть; какие ноги lethal trifecta разорваны детерминированно.
Как бы вы масштабировали систему до 10M документов и 100 RPS?
Посчитать: 10M документов ≈ 80M чанков, при d=1024 ≈ 330 ГБ float32 — не влезает в один Postgres; квантизация (halfvec/int8/binary в pgvector) или выделенная векторная БД с шардированием. Ingest — очередь и воркеры. Генерация: 100 RPS упираются в rate limits провайдера — тиры, несколько провайдеров через gateway, кэш. Эмбеддинг запросов — свой сервис рядом с поиском. Evals остаются тем же гейтом.
Источники
- pgvector и pgvector-gopgvector-java
- Anthropic Go SDK
- Anthropic Java SDK и Spring Boot Reference
- Документация Anthropic: streaming, prompt caching, structured outputs, batch
- MCP Go SDKMCP Java SDK, Spring AI MCP Server и спецификация MCP
- Claude Code: подключение MCP-серверов
- OpenTelemetry: семантические конвенции GenAI и OpenTelemetry GoOpenTelemetry Java
- Langfuse: документация
- Hamel Husain: Your AI product needs evals
- Anthropic: Contextual Retrieval