Модуль 4

Инструменты

Python для GoJava-разработчика без вводного курса: соответствия конструкций, окружение на uv, pydantic, async. Затем рабочие инструменты AI engineer: FastAPI-обёртка со стримингом, pandas для анализа evals, SDK провайдеров, фреймворки (LangChain, LlamaIndex, LangGraph) с честным разбором минусов и Jupyter — где он уместен, а где нет. В конце — coding agents (Claude Code, Codex CLI, Cursor и другие): как работать с ними циклом «план → тесты → код → ревью», что писать в AGENTS.md и CLAUDE.md, как ограничить права и где они ошибаются.

≈ 8 часовPython + GoJava4 визуализациина момент написания: октябрь 2026

Зачем Go-разработчику Python

Зачем Java-разработчику Python

Прод-сервис вокруг LLM вполне можно и стоит писать на Java: вызов API, стриминг, лимиты, очереди, RAG на pgvector — всё это Spring Boot делает отлично, у Anthropic есть официальный Java SDK, а поверх него — LangChain4j и Spring AI. Но часть работы живёт в Python, и притворяться, что её нет, дорого:

Прод-сервис вокруг LLM вполне можно и стоит писать на Go: вызов API, стриминг, лимиты, очереди, RAG на pgvector — всё это Go делает отлично, и у Anthropic есть официальный Go SDK. Но часть работы живёт в Python, и притворяться, что её нет, дорого:

Рабочая стратегия: сервисы на Java и Spring, эксперименты, evals и анализ — на Python. Цель модуля — чтобы Python не тормозил вас: читать чужой код, писать скрипты и небольшие сервисы идиоматично, знать, где подвохи для человека с Java-рефлексами: типы, которые никто не проверяет без тайпчекера, исключения без throws в сигнатуре, кооперативная конкурентность вместо потоков и упаковка без Maven.

Рабочая стратегия: сервисы на Go, эксперименты, evals и анализ — на Python. Цель модуля — чтобы Python не тормозил вас: читать чужой код, писать скрипты и небольшие сервисы идиоматично, знать, где подвохи для человека с Go-рефлексами.

Шпаргалка Go ↔ Python

Шпаргалка Java ↔ Python

Фильтруйте по разделу или ищите по слову. Колонка «Подвох» — то, на чём спотыкаются именно GoJava-разработчики.

Переводчик GoJava → Python

Поиск работает по всем колонкам: попробуйте «error», «struct», «канал», «venv».

Поиск работает по всем колонкам: попробуйте «exception», «record», «stream», «venv».

РазделGoJavaPythonПодвох

Модули и пакеты

В Java пакет — это пространство имён и директория, а единица сборки — Maven-модуль с pom.xml. В Python модуль = файл .py, пакет = директория (исторически с __init__.py), а «проект» описывается pyproject.toml. Импорты разрешаются через sys.path — аналог classpath, но собранный в рантайме из текущей директории и окружения, а не из описания сборки. Отсюда классическая боль — «работает из корня, не работает из поддиректории». Лечится src-layout и установкой проекта в окружение в editable-режиме (uv делает это сам). Модификаторов доступа нет: _name — «приватное» по соглашению, но доступно всем, и компилятор этого не проверит.

В Go пакет = директория, модуль = go.mod. В Python модуль = файл .py, пакет = директория (исторически с __init__.py), а «проект» описывается pyproject.toml. Импорты разрешаются через sys.path, а не через путь модуля, поэтому классическая боль — «работает из корня, не работает из поддиректории». Лечится src-layout и установкой проекта в окружение в editable-режиме (uv делает это сам). Экспорт не по регистру, а по соглашению: _name — «приватное», но доступно всем.

Типы и dataclass vs struct

Типы: dataclass и pydantic vs records

Python динамически типизирован: аннотации (def f(x: int) -> str) интерпретатор не проверяет, они ближе к Javadoc, чем к javac. Проверку делает внешний тайпчекер (mypy, pyright), и в команде с Java-культурой его стоит включить в CI в строгом режиме, иначе ошибки, которые у вас ловит компилятор, всплывут в рантайме. @dataclass(frozen=True) — ближайший аналог record: поля, конструктор, __eq__, __hash__, __repr__. Но он не валидирует значения: User(age="десять") создастся спокойно, а в Java не скомпилировалось бы. Для данных с границы доверия (JSON от клиента, ответ модели) нужен pydantic: это record, Jackson и Bean Validation в одной библиотеке.

Python динамически типизирован, аннотации (def f(x: int) -> str) интерпретатор не проверяет. Проверку делает внешний тайпчекер (mypy, pyright), и в команде с Go-культурой его стоит включить в CI со строгим режимом. @dataclass — ближайший аналог struct: поля, конструктор, сравнение. Но он не валидирует значения: User(age="десять") создастся спокойно. Для данных с границы доверия (JSON от клиента, ответ модели) нужен pydantic.

Исключения vs error

Исключения: все unchecked

Исключения Java-разработчику привычны, но в Python все они unchecked: сигнатура функции не говорит, что она бросает, и никто не заставит обработать ошибку. Правила те же, что в хорошем Java-коде: ловить конкретные классы (except anthropic.RateLimitError), а не голый except Exception; не глотать молча; оборачивать с сохранением причины: raise MyError("…") from e (аналог new MyException("…", e), цепочка видна в traceback, как Caused by:). Ресурсы закрывает with (context manager) — это try-with-resources, а __enter__/__exit__ — аналог AutoCloseable.

Нет if err != nil, ошибки летят исключениями вверх по стеку, пока их не поймают. Для Go-разработчика это ощущается как паника, которую принято ловить. Правила хорошего тона: ловить конкретные классы (except anthropic.RateLimitError), а не голый except Exception; не глотать молча; оборачивать с сохранением причины: raise MyError("…") from e (аналог fmt.Errorf("…: %w", err), цепочка видна в traceback). Ресурсы закрывает with (context manager) — это defer, привязанный к блоку, а не к функции.

Окружение: uv, venv, pyproject

Главная боль Python для Java-разработчика — окружения. В Maven зависимости лежат в общем ~/.m2, но classpath у каждого проекта свой и собирается из pom.xml, а результат — самодостаточный jar. В Python пакеты ставятся в интерпретатор, поэтому на каждый проект нужен venv (virtual environment — отдельная директория с копией/ссылкой на интерпретатор и своими site-packages). Исторический зоопарк: pip, virtualenv, pip-tools, poetry, pipenv, conda, pyenv. В 2026 году разумный дефолт один — uv от Astral: менеджер версий Python (как SDKMAN), venv, зависимостей и lock-файла (как Maven) в одном быстром бинаре (он написан на Rust).

Главная боль Python для гоферов — окружения. В Go зависимости изолированы модулем и собираются в бинарь. В Python пакеты ставятся в интерпретатор, поэтому на каждый проект нужен venv (virtual environment — отдельная директория с копией/ссылкой на интерпретатор и своими site-packages). Исторический зоопарк: pip, virtualenv, pip-tools, poetry, pipenv, conda, pyenv. В 2026 году разумный дефолт один — uv от Astral: менеджер версий Python, venv, зависимостей и lock-файла в одном быстром бинаре (он написан на Rust).

Java / MavenuvЧто делает
Spring Initializr, mvn archetype:generateuv initСоздаёт pyproject.toml, .python-version
<dependency> в pom.xmluv add anthropic pandasДобавляет зависимость, обновляет uv.lock, ставит в .venv
BOM и dependencyManagement (у Gradle — gradle.lockfile)uv.lockТочные версии и хеши; коммитить
mvn exec:java, java -jaruv run python main.pyЗапуск в окружении проекта, синхронизирует его перед запуском
SDKMAN, JAVA_HOMEuv python install 3.13Ставит нужную версию интерпретатора
jbang app installuv tool install ruff / uvx ruffCLI-инструменты в изолированных окружениях
Spotless, Checkstyle, javac + Error Proneruff format, ruff check, mypy/pyrightФорматирование, линтер, типы
GouvЧто делает
go mod inituv initСоздаёт pyproject.toml, .python-version
go get pkguv add anthropic pandasДобавляет зависимость, обновляет uv.lock, ставит в .venv
go.sumuv.lockТочные версии и хеши; коммитить
go run .uv run python main.pyЗапуск в окружении проекта, синхронизирует его перед запуском
—uv python install 3.13Ставит нужную версию интерпретатора
go install tool@vuv tool install ruff / uvx ruffCLI-инструменты в изолированных окружениях
gofmt, go vetruff format, ruff check, mypy/pyrightФорматирование, линтер, типы
Скрипты без проекта: PEP 723

Для одноразового скрипта (прогнать eval, выгрузить данные) проект не нужен. Зависимости объявляются в комментарии в начале файла, и uv run script.py сам создаст временное окружение. Это ближе всего к опыту «один файл → go runJBang с //DEPS в шапке».

async/await vs goroutines

async/await vs virtual threads

Самое важное концептуальное различие. В Java 21 конкурентность снова прозрачна: любой блокирующий код можно запустить в виртуальном потоке, JVM сама снимает его с потока-носителя на блокирующем I/O, а потоки реально работают параллельно на всех ядрах. Ближайший Java-аналог asyncio — реактивный стек (WebFlux, Reactor): Mono/Flux так же «заражают» сигнатуры, а блокирующий вызов внутри так же ломает всё. В Python asyncio — кооперативная многозадачность в одном потоке:

Самое важное концептуальное различие. В Go конкурентность прозрачна: любая функция может быть запущена в горутине, рантайм сам переключает их на блокирующих вызовах, горутины реально работают параллельно на всех ядрах. В Python asyncio — кооперативная многозадачность в одном потоке:

import asyncio
import anthropic

client = anthropic.AsyncAnthropic()  # у SDK есть sync и async клиенты — в async-коде только async

async def classify(ticket: str) -> str:
    resp = await client.messages.create(
        model="claude-haiku-4-5",
        max_tokens=16,
        system="Ответь одним словом: billing, tech, refund или other.",
        messages=[{"role": "user", "content": ticket}],
    )
    return resp.content[0].text.strip()

async def main(tickets: list[str]) -> list[str | BaseException]:
    sem = asyncio.Semaphore(10)

    async def guarded(t: str) -> str:
        async with sem:
            return await classify(t)

    # gather(return_exceptions=True) ≈ invokeAll без fail-fast: ошибки возвращаются значениями
    return await asyncio.gather(*(guarded(t) for t in tickets), return_exceptions=True)

# Ошибка, которую ловят на ревью:
async def bad_handler() -> None:
    import time
    time.sleep(2)             # блокирует ВЕСЬ event loop на 2 с
    await asyncio.sleep(2)    # правильно: отдаёт управление другим задачам
    # синхронную библиотеку без async-версии — в поток:
    # result = await asyncio.to_thread(sync_call, arg)

if __name__ == "__main__":
    print(asyncio.run(main(["Не прошла оплата картой", "Приложение падает при входе"])))
import asyncio
import anthropic

client = anthropic.AsyncAnthropic()  # у SDK есть sync и async клиенты — в async-коде только async

async def classify(ticket: str) -> str:
    resp = await client.messages.create(
        model="claude-haiku-4-5",
        max_tokens=16,
        system="Ответь одним словом: billing, tech, refund или other.",
        messages=[{"role": "user", "content": ticket}],
    )
    return resp.content[0].text.strip()

async def main(tickets: list[str]) -> list[str | BaseException]:
    sem = asyncio.Semaphore(10)

    async def guarded(t: str) -> str:
        async with sem:
            return await classify(t)

    # gather(return_exceptions=True) ≈ errgroup без fail-fast: ошибки возвращаются значениями
    return await asyncio.gather(*(guarded(t) for t in tickets), return_exceptions=True)

# Ошибка, которую ловят на ревью:
async def bad_handler() -> None:
    import time
    time.sleep(2)             # блокирует ВЕСЬ event loop на 2 с
    await asyncio.sleep(2)    # правильно: отдаёт управление другим задачам
    # синхронную библиотеку без async-версии — в поток:
    # result = await asyncio.to_thread(sync_call, arg)

if __name__ == "__main__":
    print(asyncio.run(main(["Не прошла оплата картой", "Приложение падает при входе"])))
// То же на Java 21: никакой «окраски» функций, блокирующий вызов блокирует только свой виртуальный поток.
record Outcome(String label, Throwable error) {}

static List<Outcome> classifyAll(AnthropicClient client, List<String> tickets) {
    var sem = new Semaphore(10);
    List<Future<String>> futures;
    try (var exec = Executors.newVirtualThreadPerTaskExecutor()) { // close() ждёт все задачи
        futures = tickets.stream().map(t -> exec.submit(() -> {
            sem.acquire();
            try {
                Message resp = client.messages().create(MessageCreateParams.builder()
                        .model("claude-haiku-4-5")
                        .maxTokens(16)
                        .system("Ответь одним словом: billing, tech, refund или other.")
                        .addUserMessage(t)
                        .build());
                return resp.content().stream()
                        .flatMap(b -> b.text().stream())
                        .map(tb -> tb.text().strip())
                        .collect(Collectors.joining());
            } finally {
                sem.release();
            }
        })).toList();
    }
    // Без fail-fast, как gather(return_exceptions=True): ошибка — значение в списке.
    return futures.stream()
            .map(f -> f.state() == Future.State.SUCCESS
                    ? new Outcome(f.resultNow(), null)
                    : new Outcome(null, f.exceptionNow()))
            .toList();
}
// То же на Go: никакой «окраски» функций, блокирующий вызов блокирует только свою горутину.
func classifyAll(ctx context.Context, client *anthropic.Client, tickets []string) ([]string, []error) {
	out := make([]string, len(tickets))
	errs := make([]error, len(tickets))
	var g errgroup.Group
	g.SetLimit(10)
	for i, t := range tickets {
		g.Go(func() error {
			resp, err := client.Messages.New(ctx, anthropic.MessageNewParams{
				Model:     "claude-haiku-4-5",
				MaxTokens: 16,
				System:    []anthropic.TextBlockParam{{Text: "Ответь одним словом: billing, tech, refund или other."}},
				Messages:  []anthropic.MessageParam{anthropic.NewUserMessage(anthropic.NewTextBlock(t))},
			})
			if err != nil {
				errs[i] = err // без fail-fast, как gather(return_exceptions=True)
				return nil
			}
			for _, b := range resp.Content {
				if tb, ok := b.AsAny().(anthropic.TextBlock); ok {
					out[i] = strings.TrimSpace(tb.Text)
				}
			}
			return nil
		})
	}
	_ = g.Wait()
	return out, errs
}
Аналогии, которые работают

asyncio.TaskGroup (3.11+) ≈ StructuredTaskScope.ShutdownOnFailure: первая ошибка отменяет остальные задачи, выход из блока ждёт всех. asyncio.gather(..., return_exceptions=True) ≈ invokeAll с разбором каждого Future: ошибки — значения. asyncio.Semaphore ≈ java.util.concurrent.Semaphore. asyncio.timeout(s) ≈ future.get(timeout) или orTimeout у CompletableFuture, только область действия — блок async with. asyncio.to_thread ≈ отдать блокирующий вызов в executor. Главное отличие: на виртуальных потоках блокирующий вызов безопасен, в asyncio — нет.

Аналогии, которые работают

asyncio.TaskGroup (3.11+) ≈ errgroup.WithContext: первая ошибка отменяет остальные задачи, выход из блока ждёт всех. asyncio.gather(..., return_exceptions=True) ≈ errgroup, где горутины пишут ошибку в срез и возвращают nil. asyncio.Semaphore ≈ semaphore.Weighted с весом 1. asyncio.timeout(s) ≈ context.WithTimeout, только область действия — блок async with, а не переданный вниз объект.

Pydantic: типы на границе доверия

Pydantic — библиотека моделей данных с валидацией в рантайме. Для AI engineering это центральный инструмент: FastAPI строит на нём валидацию запросов и OpenAPI, а для LLM он делает три вещи — описывает схему ожидаемого ответа, генерирует из неё JSON Schema для structured outputs и валидирует то, что модель вернула. В Go ближайший аналог — struct с тегами json + validate плюс генерация схемы через invopop/jsonschema; в Python это одна библиотека.В Java ближайший аналог — record + Jackson + Bean Validation (@Size, @Min) плюс генерация схемы через victools jsonschema-generator; в Python это одна библиотека.

from typing import Literal
import anthropic
from pydantic import BaseModel, Field, ValidationError

class TicketTriage(BaseModel):
    category: Literal["billing", "tech", "refund", "other"]
    urgency: int = Field(ge=1, le=5, description="1 — не срочно, 5 — всё горит")
    summary: str = Field(max_length=200)
    needs_human: bool

client = anthropic.Anthropic()

def triage(ticket: str) -> TicketTriage:
    resp = client.messages.create(
        model="claude-sonnet-5",
        max_tokens=1024,
        messages=[{"role": "user", "content": f"Разбери тикет поддержки:\n\n{ticket}"}],
        # structured outputs: модель обязана вернуть JSON по схеме.
        # В старых версиях SDK параметр передаётся через extra_body={"output_config": ...}
        output_config={"format": {"type": "json_schema", "schema": TicketTriage.model_json_schema()}},
    )
    if resp.stop_reason == "max_tokens":
        raise RuntimeError("ответ обрезан, JSON неполный")
    raw = "".join(b.text for b in resp.content if b.type == "text")
    try:
        # схему гарантирует API, но бизнес-ограничения (ge/le, max_length) проверяем сами
        return TicketTriage.model_validate_json(raw)
    except ValidationError as e:
        raise ValueError(f"модель вернула невалидный triage: {e.error_count()} ошибок") from e
Подвохи

Скрипты для экспериментов

Типичный скрипт AI engineer: прочитать набор примеров, прогнать через модель конкурентно, сохранить результаты с метаданными (версия промпта, модель, токены, латентность) для анализа в pandas. Пример ниже самодостаточен: зависимости в PEP 723-заголовке, запуск uv run run_eval.py --prompt v3 cases.jsonl.

# /// script
# requires-python = ">=3.12"
# dependencies = ["anthropic>=0.60", "pandas>=2.2", "pyarrow"]
# ///
import argparse, asyncio, json, time
from pathlib import Path
import anthropic
import pandas as pd

PROMPTS = {
    "v2": "Классифицируй тикет. Ответь одним словом: billing, tech, refund, smalltalk.",
    "v3": Path("prompts/classify_v3.txt").read_text() if Path("prompts/classify_v3.txt").exists() else "",
}
client = anthropic.AsyncAnthropic(max_retries=4)  # ретраи 429/5xx с backoff — встроены в SDK

async def run_case(sem: asyncio.Semaphore, version: str, case: dict) -> dict:
    async with sem:
        t0 = time.perf_counter()
        try:
            resp = await client.messages.create(
                model="claude-haiku-4-5", max_tokens=16, system=PROMPTS[version],
                messages=[{"role": "user", "content": case["text"]}],
            )
            got = resp.content[0].text.strip().lower()
            usage = (resp.usage.input_tokens, resp.usage.output_tokens)
            err = None
        except anthropic.APIError as e:  # не роняем весь прогон из-за одного примера
            got, usage, err = None, (0, 0), type(e).__name__
    return {
        "example_id": case["id"], "category": case["category"], "prompt_version": version,
        "expected": case["label"], "got": got, "passed": int(got == case["label"]),
        "latency_ms": round((time.perf_counter() - t0) * 1000),
        "input_tokens": usage[0], "output_tokens": usage[1], "error": err,
    }

async def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("cases", type=Path)
    ap.add_argument("--prompt", required=True, choices=PROMPTS)
    ap.add_argument("--concurrency", type=int, default=8)
    args = ap.parse_args()
    cases = [json.loads(line) for line in args.cases.read_text().splitlines() if line.strip()]
    sem = asyncio.Semaphore(args.concurrency)
    rows = await asyncio.gather(*(run_case(sem, args.prompt, c) for c in cases))
    df = pd.DataFrame(rows)
    df.to_parquet(f"results_{args.prompt}_{int(time.time())}.parquet")
    ok = df[df.error.isna()]
    print(f"pass_rate={ok.passed.mean():.3f} n={len(ok)} errors={df.error.notna().sum()}")

if __name__ == "__main__":
    asyncio.run(main())

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

FastAPI: сервис-обёртка над LLM

FastAPI — де-факто стандарт для Python-сервисов вокруг моделей: async из коробки, валидация через pydantic, автоматическая OpenAPI-документация. Для Go-разработчика: это net/http + роутер + encoding/json + валидатор + swagger-генератор в одном, с объявлением через аннотации типов. Запускается ASGI-сервером (uvicorn) — это аналог http.Server.Для Java-разработчика: это Spring WebFlux + Jackson + Bean Validation + springdoc-openapi в одном, с маршрутами через декораторы (как @PostMapping) и валидацией по аннотациям типов вместо DI-контейнера и бинов. Запускается ASGI-сервером (uvicorn) — это аналог встроенного Netty или Tomcat.

import json
import anthropic
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field

app = FastAPI()
client = anthropic.AsyncAnthropic()  # один клиент на процесс: пул соединений переиспользуется

class AskRequest(BaseModel):
    question: str = Field(min_length=1, max_length=8000)  # граница доверия: режем заранее
    max_tokens: int = Field(default=1024, ge=1, le=4096)

def sse(event: str, data: dict) -> str:
    return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"

@app.post("/v1/ask/stream")
async def ask_stream(req: AskRequest, request: Request) -> StreamingResponse:
    async def events():
        try:
            async with client.messages.stream(
                model="claude-opus-5",
                max_tokens=req.max_tokens,
                system="Ты ассистент поддержки. Отвечай кратко.",
                messages=[{"role": "user", "content": req.question}],
            ) as stream:
                async for text in stream.text_stream:
                    if await request.is_disconnected():
                        return  # выход из async with закрывает соединение с провайдером
                    yield sse("delta", {"text": text})
                msg = await stream.get_final_message()
            yield sse("done", {
                "stop_reason": msg.stop_reason,
                "input_tokens": msg.usage.input_tokens,
                "output_tokens": msg.usage.output_tokens,
            })
        except anthropic.APIStatusError as e:  # в т.ч. ошибка, пришедшая посреди стрима
            yield sse("error", {"status": e.status_code})

    return StreamingResponse(events(), media_type="text/event-stream",
                             headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})

@app.get("/healthz")
async def healthz() -> dict:
    return {"ok": True}

# uv run uvicorn app:app --host 0.0.0.0 --port 8000 --workers 2
Как это ломается в проде

pandas для анализа evals

pandas — таблицы в памяти (DataFrame) с векторными операциями. Для Go-разработчика это «SQL поверх слайса структур без циклов»:Для Java-разработчика это «SQL поверх List<record> без циклов», как Stream API с Collectors.groupingBy, только короче и векторно: groupby — GROUP BY, merge — JOIN, pivot_table — сводная таблица, query — WHERE. Главная ментальная перестройка: не писать циклы по строкам (iterrows медленный и неидиоматичный), а оперировать колонками целиком.

Ниже — 72 результата eval-прогона классификатора тикетов: три версии промпта × четыре категории × 6 примеров. Сгруппируйте по-разному и посмотрите, как меняются выводы. Усы на графике pass rate — 95% доверительный интервал Уилсона.

Мини-анализ evals: как это выглядит в pandas

Выберите группировку и метрику. Справа внизу — эквивалентный pandas-код и то, что он напечатает.

Что стоит заметить на этих данных. По версиям v3 выглядит лучше всех, но в разрезе «версия × категория» видно, что v3 просела на billing (3 из 6 против 5 и 4 у v1 и v2) — при n = 6 на ячейку это может быть и шум, и реальная регрессия, интервалы огромные. v3 также медленнее и расходует больше выходных токенов. Решение «катим v3» требует больше данных по billing и оценки стоимости, а не одного числа в сводке. Именно это — работа AI engineer с evals. Здесь pandas — инструмент; методология целиком (golden dataset, LLM-as-a-judge, размер выборки, quality gate в CI) — в модуле 8.

import pandas as pd

df = pd.concat(pd.read_parquet(p) for p in ["results_v1.parquet", "results_v2.parquet", "results_v3.parquet"])
df = df[df.error.isna()]  # инфраструктурные ошибки — отдельно, не в качестве

# 1. Сводка по версиям: качество, латентность, стоимость
PRICE_IN, PRICE_OUT = 1 / 1e6, 5 / 1e6  # Haiku 4.5, $ за токен (на момент написания)
df["cost_usd"] = df.input_tokens * PRICE_IN + df.output_tokens * PRICE_OUT
summary = df.groupby("prompt_version").agg(
    pass_rate=("passed", "mean"),
    n=("passed", "size"),
    p50_ms=("latency_ms", "median"),
    p95_ms=("latency_ms", lambda s: s.quantile(0.95)),
    cost_per_1k=("cost_usd", lambda s: s.mean() * 1000),
)

# 2. Срезы: где именно выросло или упало
pivot = df.pivot_table(index="category", columns="prompt_version",
                       values="passed", aggfunc="mean").round(2)
pivot["delta_v3_v2"] = pivot["v3"] - pivot["v2"]

# 3. Парное сравнение на одних и тех же примерах: смотрим только расхождения
wide = df.pivot_table(index="example_id", columns="prompt_version", values="passed")
fixed = ((wide.v2 == 0) & (wide.v3 == 1)).sum()
broken = ((wide.v2 == 1) & (wide.v3 == 0)).sum()
print(f"v3 починил {fixed}, сломал {broken}")  # для McNemar важны только эти два числа

# 4. Провалы глазами: самое полезное действие в анализе evals
print(df.query("prompt_version == 'v3' and passed == 0")[["example_id", "category", "expected", "got"]])
SQL- и Stream-аналогии

df.groupby("v").agg(...) = SELECT v, avg(passed), count(*) … GROUP BY v = Collectors.groupingBy(Row::v, Collectors.averagingInt(Row::passed)). df.merge(other, on="example_id", how="left") = LEFT JOIN … USING (example_id). pivot_table = GROUP BY category, version, развёрнутый в колонки. Если привычнее SQL, есть DuckDB: duckdb.sql("select … from df") работает прямо по DataFrame. Для больших данных (десятки миллионов строк) смотрят в сторону Polars — колоночный движок на Rust с ленивым API, похожим на Stream API.

SQL-аналогии

df.groupby("v").agg(...) = SELECT v, avg(passed), count(*) … GROUP BY v. df.merge(other, on="example_id", how="left") = LEFT JOIN … USING (example_id). pivot_table = GROUP BY category, version, развёрнутый в колонки. Если привычнее SQL, есть DuckDB: duckdb.sql("select … from df") работает прямо по DataFrame. Для больших данных (десятки миллионов строк) смотрят в сторону Polars — колоночный движок на Rust с ленивым API.

SDK провайдеров

Официальные SDK Anthropic и OpenAI генерируются из OpenAPI-спецификаций и устроены похоже: типизированные запросы и ответы, sync и async клиенты, встроенные ретраи (у Anthropic по умолчанию 2 повтора на 408/409/429/5xx), таймауты, стриминг, пагинация. Всегда используйте их вместо ручного HTTP — кроме случаев, когда нужно понять протокол или SDK нет для вашего языка.

import anthropic

client = anthropic.Anthropic()  # ANTHROPIC_API_KEY из окружения

resp = client.messages.create(
    model="claude-opus-5",
    max_tokens=2048,                     # обязателен
    system="Отвечай по-русски, кратко.",  # system — параметр верхнего уровня, не роль
    messages=[{"role": "user", "content": "Чем HNSW лучше IVF?"}],
)
text = "".join(b.text for b in resp.content if b.type == "text")  # ответ — список блоков
print(resp.stop_reason, resp.usage.input_tokens, resp.usage.output_tokens)

# Стриминг с помощником, собирающим итоговое сообщение
with client.messages.stream(model="claude-opus-5", max_tokens=2048,
                            messages=[{"role": "user", "content": "Расскажи про BM25"}]) as s:
    for chunk in s.text_stream:
        print(chunk, end="", flush=True)
    final = s.get_final_message()

# Ошибки — иерархия исключений
try:
    client.messages.create(model="claude-opus-5", max_tokens=10, messages=[])
except anthropic.BadRequestError as e:      # 400 — не ретраить
    print("bad request:", e.message)
except anthropic.RateLimitError:            # 429 — SDK уже ретраил max_retries раз
    raise
except anthropic.APIStatusError as e:       # остальные HTTP-ошибки
    print(e.status_code)
from openai import OpenAI

client = OpenAI()  # OPENAI_API_KEY из окружения
MODEL = "..."      # актуальное имя модели — из документации OpenAI

# Responses API — основной интерфейс OpenAI; Chat Completions остаётся для совместимости
resp = client.responses.create(
    model=MODEL,
    instructions="Отвечай по-русски, кратко.",  # аналог system
    input="Чем HNSW лучше IVF?",
)
print(resp.output_text)                          # хелпер: склеенный текст ответа
print(resp.usage.input_tokens, resp.usage.output_tokens)

# Старый стиль, который встретится в чужом коде и во многих совместимых API (vLLM, Ollama):
chat = client.chat.completions.create(
    model=MODEL,
    messages=[{"role": "system", "content": "Кратко."},
              {"role": "user", "content": "Что такое BM25?"}],
)
print(chat.choices[0].message.content, chat.choices[0].finish_reason,
      chat.usage.prompt_tokens, chat.usage.completion_tokens)
АспектAnthropic MessagesOpenAI (Responses / Chat Completions)
System promptПараметр systeminstructions / сообщение с ролью system (developer)
max tokensОбязателенОпционален
ОтветСписок content blocks: text, tool_use, thinkingСписок output items / choices[0].message
Причина остановкиstop_reason: end_turn, max_tokens, tool_use, stop_sequence, pause_turn, refusalstatus + детали / finish_reason: stop, length, tool_calls, content_filter
Usageinput_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokensinput_tokens/output_tokens (Responses), prompt_tokens/completion_tokens (Chat)
Кэширование промптаЯвные cache_control breakpoints (до 4)В основном автоматическое по префиксу
СэмплингНа новейших моделях temperature/top_p удалены, управление через effortЗависит от модели: у reasoning-моделей часть параметров не поддерживается
OpenAI-совместимый API как lingua franca

Формат Chat Completions стал де-факто стандартом: его отдают vLLM, Ollama, многие облака и gateway'и. Соблазн — писать всё под него и менять только base_url. Цена — потеря специфичных возможностей (явный prompt caching, thinking-блоки, особенности tool use), а они часто и есть основная экономия. Разумный компромисс — собственный тонкий интерфейс в коде (Generate(ctx, req) (Resp, error)Resp generate(Req req)) с адаптером на каждого провайдера, а не «наименьший общий знаменатель» на уровне протокола.

LangChain, LlamaIndex, LangGraph

Фреймворки — самая спорная тема в прикладном AI. На собеседовании ждут не «я знаю LangChain», а взвешенного мнения: что он даёт, чем платите, когда брать.

LangChain

Общий слой абстракций: единый интерфейс chat-моделей разных провайдеров, шаблоны промптов, парсеры вывода, сотни интеграций (векторные БД, загрузчики документов, инструменты), композиция цепочек. Сильная сторона — скорость прототипа и интеграции. Слабая — много слоёв поверх простого HTTP-вызова.

LlamaIndex

Фокус на данных и RAG: коннекторы к источникам, парсинг документов, стратегии чанкинга, индексы, query engines, reranking, оценка retrieval. Хорош, чтобы за день собрать RAG (retrieval-augmented generation — ответ модели по найденным в вашей базе фрагментам; подробно в модуле 5) над корпусом и сравнить стратегии. Прод-логику retrieval часто потом переписывают под свои данные.

LangGraph

Низкоуровневая библиотека для агентов как графа состояний: узлы — шаги, рёбра — переходы (в т.ч. условные), общее состояние, checkpointing (сохранение состояния между шагами), human-in-the-loop, возобновление после сбоя. Меньше «магии», чем LangChain; по сути — движок конечного автомата с персистентностью.

Чем платите — список, который стоит уметь назвать:

Фреймворк или свой код?

Эвристика, а не закон. Двигайте сложность оркестрации (сколько шагов, ветвлений, состояния) и требования к контролю (прод-нагрузка, аудит, стоимость, отладка). Тип задачи уточняет, какой именно фреймворк.

А в Java?

Экосистема на JVM заметно богаче, чем у большинства языков, кроме Python. LangChain4j — не порт, а самостоятельная библиотека: единый интерфейс моделей, AiServices (Java-интерфейс с аннотациями превращается в вызов модели), tools через @Tool, RAG-конвейер, интеграции с векторными хранилищами. Spring AI — то же в стиле Spring: ChatClient, advisors (память, RAG, логирование как цепочка перехватчиков), tool calling, VectorStore, автоконфигурация и метрики Micrometer из коробки. Если команда живёт в Spring Boot, интеграции и наблюдаемость приходят почти бесплатно.

Цена та же, что у LangChain: промпты, собранные внутри advisors и AiServices, отставание от новых возможностей API, абстракция «chat model», которая прячет prompt caching, thinking и особенности tool use. Совет Anthropic работает и здесь: начните с официального SDK (anthropic-java) и своего агентного цикла на 100–200 строк (вы напишете его в модуле 6), а фреймворк берите точечно — загрузчики и сплиттеры документов LangChain4j, VectorStore Spring AI — и включите логирование запросов к модели с первого дня.

// LangChain4j: декларативный сервис поверх модели. Удобно, но промпт собирает фреймворк.
interface Triage {
    @SystemMessage("Классифицируй тикет: billing, tech, refund или other. Ответь одним словом.")
    String classify(@UserMessage String ticket);
}

ChatModel model = AnthropicChatModel.builder()
        .apiKey(System.getenv("ANTHROPIC_API_KEY"))
        .modelName("claude-haiku-4-5")
        .logRequests(true)  // с первого дня: видеть, что реально уходит в модель
        .logResponses(true)
        .build();
Triage triage = AiServices.create(Triage.class, model);
String label = triage.classify("Не прошла оплата картой");

// Spring AI: ChatClient из автоконфигурации (spring-ai-starter-model-anthropic).
String label2 = chatClient.prompt()
        .system("Классифицируй тикет: billing, tech, refund или other. Ответь одним словом.")
        .user("Не прошла оплата картой")
        .call()
        .content();
А в Go?

Экосистема меньше: есть langchaingo (порт LangChain от сообщества), Eino от CloudWeGo, Genkit с поддержкой Go. Но в Go культура «тонкие библиотеки + свой код» и так сильнее, и это совпадает с советом Anthropic: SDK провайдера, свой агентный цикл на 100–200 строк (вы напишете его в модуле 6), pgvector, OpenTelemetry. Агентный цикл — это for с switch resp.StopReason; граф состояний — таблица переходов и состояние в Postgres. Фреймворк здесь чаще мешает, чем помогает.

Jupyter: для экспериментов, не для прода

Jupyter notebook — документ из ячеек кода и текста, которые выполняются в живом процессе (kernel) по одной. Для работы с LLM это идеальная лаборатория: поправил промпт, перезапустил одну ячейку, посмотрел пять ответов рядом, построил график по результатам evals, записал выводы тут же. Состояние (загруженный датасет, клиент) живёт между ячейками, не нужно перезапускать всё.

Ровно это же делает его непригодным для прода:

Нормальный путь: ноутбук → код, который доказал ценность, выносится в модуль с функциями → скрипт с аргументами (PEP 723) → тесты и CI → при необходимости сервис (FastAPI или GoSpring Boot). Ноутбук остаётся как отчёт об эксперименте, импортирующий этот модуль.

От coding agent к управляемому изменению

После этого раздела пройдите модуль 17 «SDD и OpenSpec»: подготовка требований системного анализа, основные и дельта-спеки, приёмка и небольшой проект. Теория предшествует практике; 12–14 часов учитываются дополнительно к базовому маршруту.

Coding agents в работе AI-инженера

Coding agent — LLM в агентном цикле с инструментами разработчика: читать и искать по коду, править файлы, запускать команды (go testmvn verify, git diff), вызывать MCP-серверы. Это тот же цикл tool use из модуля 2, только «мир» агента — ваш репозиторий (как цикл устроен изнутри, разберём в модуле 6). Код — область, где агенты работают лучше всего: компилятор и тесты дают объективную обратную связь без человека. Отсюда главный принцип: доверие к изменению от агента строится на проверяемости, а не на уверенном тоне отчёта. То, что автоматически не проверяется (промпты, тексты, конкурентность, безопасность), требует evals и человека.

Классы инструментов

КлассПримеры (по состоянию на октябрь 2026)Когда брать
CLIClaude Code, OpenAI Codex CLI (open source), Gemini CLI (с 18 июня 2026 для личных аккаунтов Google, бесплатных и с подпиской AI Pro/Ultra, заменён на Antigravity CLI; с корпоративной лицензией Code Assist и по платному API-ключу работает)Основной инструмент бэкендера: тот же shell и make, есть неинтерактивный режим для CI (claude -p, codex exec)
IDECursor, GitHub Copilot agent mode, JetBrains AI Assistant (Junie и подключаемые агенты по протоколу ACP)Точечные правки под постоянным контролем, ревью диффа в редакторе
ОблачныеClaude Code on the web, Codex Cloud, Copilot cloud agent, Cursor Cloud Agents, Jules, DevinЧётко поставленные задачи, которые можно запускать параллельно; результат — PR на ревью

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

Рабочий цикл

  1. Исследование без права правок (в Claude Code — plan mode, --permission-mode plan). Большое исследование — субагенту, чтобы не засорять основной контекст.
  2. План письменно: файлы, функции, тесты, чего не трогать. Правка плана стоит одного сообщения, правка 600 строк диффа — часа.
  3. Тесты сначала: агент пишет табличный тестпараметризованный тест (@ParameterizedTest), запускает, тест падает по правильной причине. Тесты коммитятся отдельно, до реализации.
  4. Реализация маленькими шагами: «сделай так, чтобы тесты прошли; тесты не меняй». Коммит после зелёных go vet и go test -raceзелёного mvn verify (компиляция, линтеры, тесты).
  5. Ревью диффа: сначала git diff --stat (не вышел ли за план), потом отдельно дифф *_test.go*Test.java (не ослаблены ли проверки), потом всё. Агент пишет, человек отвечает за мерж.
Аналогия из JavaТесты до кода для агента — то же, что интерфейс до реализации: контракт фиксируется раньше, чем появится код, под который его захочется подогнать. А mvn verify в цикле агента — компилятор для поведения: модель не нужно уговаривать, ей достаточно красного отчёта surefire.
Аналогия из GoТесты до кода для агента — то же, что интерфейс до реализации: контракт фиксируется раньше, чем появится код, под который его захочется подогнать. А go test в цикле агента — компилятор для поведения: модель не нужно уговаривать, ей достаточно красного вывода.

Настройка репозитория: AGENTS.md, права, хуки

Файл инструкций агент загружает в контекст в начале каждой сессии. AGENTS.md — открытый формат (обычный Markdown), его читают Codex, Cursor, Copilot и большинство других агентов; вложенные файлы в подкаталогах уточняют правила, приоритет у ближайшего. Claude Code читает CLAUDE.md (а AGENTS.md — если CLAUDE.md нет); для команды со смешанными инструментами удобно держать правила в AGENTS.md, а в CLAUDE.md написать строку @AGENTS.md и специфику Claude Code. Пишите только то, чего агент не выведет из кода: команды проверки, карту, неочевидные конвенции, запреты, критерий «готово». Каждая строка едет в контекст каждой сессии — это «всегда загруженная» часть контекста (модуль 6), документация Claude Code советует держаться в пределах ~200 строк.

Но инструкция — не гарантия: документация Claude Code прямо называет CLAUDE.md контекстом, а не принудительной конфигурацией. То, что должно выполняться всегда, живёт в двух механизмах вне модели. Права: режим решает, спрашивать ли человека (в Claude Code — от Manual с подтверждениями до auto, где действия проверяет модель-классификатор, и bypassPermissions — только в изолированном контейнере), правила allow/ask/deny уточняют его, а sandbox средствами ОС ограничивает, до каких файлов и доменов вообще дотянутся shell-команды. Правила для команд хрупкие (вариация синтаксиса их обходит), поэтому граница безопасности — sandbox или контейнер. Хуки: ваши команды на событиях агента; PreToolUse с кодом выхода 2 блокирует вызов инструмента, Stop с кодом 2 не даёт агенту закончить ход и возвращает ему stderr.

# docrag — RAG по документации (Java 21, Spring Boot, Postgres + pgvector)

## Команды
- `docker compose up -d db` — Postgres для интеграционных тестов (или Testcontainers)
- `make check` — ./mvnw -q verify: spotless:check, компиляция с Error Prone, тесты; запускай перед «готово»
- `make eval-smoke` — ~40 вопросов из golden set; обязателен при правке prompts/ и src/main/java/docrag/generate/

## Структура и конвенции
- docrag.retrieve — гибридный поиск; docrag.generate — промпт и вызов Claude; prompts/ — версии промптов
- Ошибки: доменные исключения с причиной (new XxxException("...", e)); логи только SLF4J; имена моделей — из generate/Models.java

## Нельзя
- Менять evals/data/ (эталон) и применённые migrations/*.sql (Flyway); ослаблять тесты, ставить @Disabled
- Добавлять зависимости в pom.xml без вопроса, читать .env, ходить в прод

## Готово, когда
- make check зелёный; при правке prompts/ — eval-smoke не хуже baseline; дифф ≤ 300 строк
{
  "permissions": {
    "allow": ["Bash(make check)", "Bash(./mvnw test *)", "Bash(git diff *)"],
    "ask":   ["Bash(git push *)", "Edit(/pom.xml)", "Edit(/migrations/**)"],
    "deny":  ["Read(./.env)", "Edit(/evals/data/**)", "Bash(kubectl *)"]
  },
  "sandbox": {
    "enabled": true,
    "filesystem": { "denyRead": ["./.env", "~/.ssh", "~/.aws", "~/.kube"] },
    "network": { "allowedDomains": ["repo.maven.apache.org"] }
  },
  "hooks": {
    "Stop": [{ "hooks": [{ "type": "command", "timeout": 900,
      "command": "cd \"$CLAUDE_PROJECT_DIR\" && java -jar tools/agentgate.jar" }] }]
  }
}
# docrag — RAG по документации (Go, Postgres + pgvector)

## Команды
- `docker compose up -d db` — Postgres для интеграционных тестов
- `make check` — gofmt -l, go vet, go test -race ./...; запускай перед «готово»
- `make eval-smoke` — ~40 вопросов из golden set; обязателен при правке prompts/ и internal/generate/

## Структура и конвенции
- internal/retrieve — гибридный поиск; internal/generate — промпт и вызов Claude; prompts/ — версии промптов
- Ошибки: fmt.Errorf("...: %w", err); логи только slog; имена моделей — из internal/generate/models.go

## Нельзя
- Менять evals/data/ (эталон) и применённые migrations/*.sql; ослаблять или пропускать тесты
- Добавлять зависимости без вопроса, читать .env, ходить в прод

## Готово, когда
- make check зелёный; при правке prompts/ — eval-smoke не хуже baseline; дифф ≤ 300 строк
{
  "permissions": {
    "allow": ["Bash(make check)", "Bash(go test *)", "Bash(git diff *)"],
    "ask":   ["Bash(git push *)", "Bash(go get *)", "Edit(/migrations/**)"],
    "deny":  ["Read(./.env)", "Edit(/evals/data/**)", "Bash(kubectl *)"]
  },
  "sandbox": {
    "enabled": true,
    "filesystem": { "denyRead": ["./.env", "~/.ssh", "~/.aws", "~/.kube"] },
    "network": { "allowedDomains": ["proxy.golang.org", "sum.golang.org"] }
  },
  "hooks": {
    "Stop": [{ "hooks": [{ "type": "command", "timeout": 900,
      "command": "cd \"$CLAUDE_PROJECT_DIR\" && go run ./cmd/agentgate" }] }]
  }
}

Stop-хук agentgate превращает «готово, когда» из AGENTS.md в код: смотрит, что изменилось относительно main, и решает, какие проверки обязательны.

// plan по изменениям возвращает обязательные проверки (цели Makefile — те же,
// что перечислены в AGENTS.md) и нарушения, которые агент должен откатить.
public static Plan plan(List<Change> cs) {
    boolean javaCode = false, prompts = false;
    List<String> violations = new ArrayList<>();
    for (Change c : cs) {
        String p = c.path();
        if (p.startsWith("evals/data/")) {
            violations.add(p + ": эталон evals меняет человек отдельным PR, а не агент вместе с кодом");
        } else if (p.startsWith("migrations/") && c.status() != 'A') {
            violations.add(p + ": применённую миграцию не правят, добавьте новую");
        } else if (p.startsWith("prompts/")) {
            prompts = true;
        } else if (p.endsWith(".java") || p.equals("pom.xml") || p.endsWith("/pom.xml")) {
            javaCode = true;
        }
    }
    List<List<String>> checks = new ArrayList<>();
    if (javaCode) {
        checks.add(List.of("make", "check")); // mvn verify: компиляция, линтеры, тесты
    }
    if (prompts) {
        checks.add(List.of("make", "eval-smoke")); // промпт не проверить юнит-тестом
    }
    return new Plan(checks, violations);
}
// Plan по изменениям возвращает обязательные проверки (цели Makefile — те же,
// что перечислены в AGENTS.md) и нарушения, которые агент должен откатить.
func Plan(cs []Change) (checks [][]string, violations []string) {
	var goCode, prompts bool
	for _, c := range cs {
		switch {
		case strings.HasPrefix(c.Path, "evals/data/"):
			violations = append(violations, c.Path+": эталон evals меняет человек отдельным PR, а не агент вместе с кодом")
		case strings.HasPrefix(c.Path, "migrations/") && c.Status != 'A':
			violations = append(violations, c.Path+": применённую миграцию не правят, добавьте новую")
		case strings.HasPrefix(c.Path, "prompts/"):
			prompts = true
		case strings.HasSuffix(c.Path, ".go"), c.Path == "go.mod", c.Path == "go.sum":
			goCode = true
		}
	}
	if goCode {
		checks = append(checks, []string{"make", "check"}) // gofmt -l, go vet, go test -race
	}
	if prompts {
		checks = append(checks, []string{"make", "eval-smoke"}) // промпт не проверить go test
	}
	return checks, violations
}

Полная версия (запуск git и проверок, коды выхода 0/1/2, табличные тестыпараметризованные JUnit-тесты на fake-раннере) — в examples/m04-toolsexamples/java/m04-tools репозитория курса. Нарушения и упавшие проверки хук пишет в stderr с хвостом лога, и агент продолжает работу. От зацикливания Claude Code защищён сам: после 8 продолжений от Stop-хуков подряд он завершает ход (счётчик сбрасывается при каждом вызове инструмента; порог меняет CLAUDE_CODE_STOP_HOOK_BLOCK_CAP, по состоянию на октябрь 2026). make eval-smoke ходит в API модели с ключом, поэтому его запускает хук (хуки работают вне sandbox), а не агент из своего shell.

Безопасность

Типичные провалы и как их ловить

ПровалКак ловить
Огромный дифф «заодно отрефакторил»План со списком файлов; лимит ~300 строк в AGENTS.md; git diff --stat первым делом
Тесты подогнаны под код: изменён assert, t.Skip@Disabled, удалён «нестабильный» тестТесты отдельным коммитом до кода; дифф *_test.go*Test.java отдельно; сломайте код руками — тест обязан упасть
Галлюцинированный API: несуществующий метод, устаревший параметр, выдуманное имя модели или пакетаМетоды ловит компилятор GoJava (в Python нужен mypy/pyright в цикле). Имена моделей и параметры — константы с правилом в AGENTS.md. Новые строки go.modpom.xml — глазами: модели «вспоминают» несуществующие пакеты (Spracklen et al., 2025), а атакующий может их зарегистрировать (slopsquatting)
Дрейф от задачи: правит соседнее, меняет CI, «чтобы прошло»«Что не трогать» в плане; CODEOWNERS на CI и эталон evals
«Готово» без проверкиStop-хук, который сам запускает проверки; финальный судья — CI
Деградация длинной сессии (context rot)Одна задача — одна сессия; после двух неудачных поправок — сброс и новый запрос

Ощущение скорости — не метрика: в исследовании METR (2025) опытные разработчики с AI-инструментами работали на 19% дольше, хотя считали, что ускорились. Меряйте своё: время до мержа и долю PR от агента, которые пришлось переделать.

Специфика AI-инженерии

Насколько доверять изменению от агента

Опишите дифф, который принёс агент: размер, что затронуто, какие проверки пройдены и в каком режиме он работал. Справа — что повышает риск, слева — что его снижает, внизу — итог и что сделать до мержа. Веса — эвристика курса, а не стандарт индустрии: важна структура решения, а не точные числа.

Затронуто:
Проверки:

Обратите внимание: правка промпта без evals и эталон evals в одном диффе с кодом — блокеры при любом числе ревьюеров, а ревью 1 200 строк снижает риск втрое слабее, чем ревью 150. Командные правила работы с агентами (что можно отдавать, как ревьюить) — в модуле 13.

Как это звучит на собеседовании«Агент пишет — я отвечаю за мерж. Доверяю не агенту, а проверкам: тесты до кода, зелёный CI, evals для всего, что трогает модель, дифф, который реально прочитать. Права минимальные, прода нет, а обязательные правила живут в хуках и CI, а не в AGENTS.md».

Практика

Задание 1: Python-окружение и eval-скрипт

Поставьте uv, создайте проект, напишите скрипт прогона классификатора (как в разделе «Скрипты») на 60–100 своих примерах для двух версий промпта. Сохраните результаты в parquet.

Задание 2: FastAPI vs GoSpring Boot

Реализуйте один и тот же эндпоинт стриминга ответа (SSE) на FastAPI и на Go net/http (код из модуля 0)Spring Boot (SseEmitter на virtual threads или WebFlux, код из модуля 0). Нагрузите оба 100 одновременными стримами (например, k6 или hey против мок-сервера провайдера, отдающего токены с задержкой).

Задание 3: фреймворк на вкус

Соберите наивный RAG над 20 документами (эмбеддинги + косинусная близость + ответ модели по найденному; как делать RAG хорошо — модуль 5) двумя способами: на LlamaIndex (или LangChain) и на «голом» SDK. Включите трейсинг или логирование запросов и найдите все промпты, которые фреймворк отправил в модель.

Задание 4: coding agent для проекта A

Настройте coding agent (Claude Code, Codex CLI или другой — критерии те же) для своего репозитория docrag из проекта A и сделайте с ним одну фичу полным циклом. Примеры фич: параметр min_score в /ask; кэш эмбеддингов запросов в Postgres; фильтр /search по разделу документации.

Самопроверка

Почему time.sleep(2) внутри async def-хендлера FastAPI — серьёзная ошибка, а Thread.sleep в контроллере Spring на виртуальных потоках — нет?

asyncio кооперативный и однопоточный: переключение задач происходит только на await. Синхронный sleep блокирует event loop, и все остальные запросы этого воркера ждут. В Java с spring.threads.virtual.enabled=true каждый запрос — свой виртуальный поток: sleep или блокирующий I/O паркуют только его, а поток-носитель берёт другие задачи. Оговорка Java 21 — pinning: блокировка внутри synchronized держит носитель (в Java 24 это исправлено).

Почему time.sleep(2) внутри async def-хендлера FastAPI — серьёзная ошибка, а в Go time.Sleep в хендлере — нет?

asyncio кооперативный и однопоточный: переключение задач происходит только на await. Синхронный sleep блокирует event loop, и все остальные запросы этого воркера ждут. В Go рантайм паркует только текущую горутину, остальные продолжают работать на других потоках.

Чем dataclass отличается от pydantic BaseModel и когда что брать?

dataclass — контейнер с генерацией конструктора, без валидации значений в рантайме. BaseModel валидирует и приводит типы, парсит JSON, генерирует JSON Schema. Для внутренних структур — dataclass (быстро, просто), для данных с границы доверия (HTTP-запросы, ответы модели, конфиги) — pydantic.

Какой аналог new MyException("…", cause) в Python?

raise MyError("…") from cause. Исходное исключение сохраняется в __cause__ и печатается в traceback, как Caused by: в Java. Ловят по классу в except; обхода цепочки вроде getCause() в цикле из коробки нет — обычно ловят на нужном уровне.

Какой аналог fmt.Errorf("…: %w", err) в Python?

raise NewError("…") from err. Исходное исключение сохраняется в __cause__ и печатается в traceback. Проверка типа в цепочке делается через except по классу, аналога errors.Is по всей цепочке из коробки нет — обычно ловят на нужном уровне.

Зачем нужен venv и почему uv его не отменяет?

Python ставит пакеты в окружение интерпретатора, и без изоляции проекты конфликтуют версиями зависимостей. venv — отдельное окружение на проект. uv не отменяет venv, а управляет им: создаёт .venv, синхронизирует с lock-файлом, ставит нужную версию Python.

Что делает df.pivot_table(index="category", columns="prompt_version", values="passed", aggfunc="mean")?

Группирует по паре (категория, версия), считает среднее passed (то есть pass rate) и раскладывает версии по колонкам: строки — категории, колонки — версии, в ячейках — pass rate. Аналог GROUP BY по двум полям с последующим разворотом.

Почему при сравнении двух промптов на одном наборе полезно смотреть на «починенные» и «сломанные» примеры, а не только на разницу pass rate?

Одинаковая разница может получиться из «починил 5, сломал 0» и из «починил 25, сломал 20». Второй случай — нестабильный промпт, который меняет поведение на половине набора. Парный тест McNemar использует только эти два числа и чувствительнее сравнения двух независимых долей.

Назовите три конкретных минуса LangChain в проде.

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

Что не так с ноутбуком, который «работает у меня»?

Ячейки могли выполняться не по порядку, а часть состояния — прийти из удалённых ячеек. Проверка: Restart & Run All. Для воспроизводимости код выносится в модули, ноутбук только импортирует и визуализирует.

В AGENTS.md написано «не меняй evals/data/». Почему этого недостаточно и что сделать?

Файл инструкций — это контекст, а не принудительная конфигурация (так прямо сказано в документации Claude Code). В длинной сессии, при противоречивых указаниях или под prompt injection модель может нарушить любой пункт. Гарантию дают механизмы вне модели: deny-правило Edit(/evals/data/**), хук, который блокирует завершение при изменённом эталоне, CODEOWNERS и проверка в CI. Строку в AGENTS.md оставляют как объяснение «почему», чтобы агент не тратил шаги на попытки.

Агент изменил prompts/answer.v2.txt, make check зелёный. Можно мержить?

Нет. go testmvn verify проверяет код, а не поведение модели: промпт меняет ответы так, что ни один юнит-тест этого не увидит. Нужен прогон evals (smoke-набор до мержа, полный в CI) со сравнением с baseline, ревью диффа промпта человеком, а после мержа — canary. Это правило для всего, что влияет на вход и выход модели: сборки контекста, параметров вызова, retrieval.

Чем режим разрешений агента отличается от sandbox и почему нужны оба?

Режим решает, спрашивать ли человека перед действием (Manual, acceptEdits, auto и т. д.). Sandbox решает, до чего действие может дотянуться: файлы, сеть, процессы — на уровне ОС или контейнера. Подтверждения устают и ошибаются, классификатор вероятностен, а правила для команд обходятся вариациями синтаксиса. Sandbox ограничивает ущерб, даже если решение пропустить действие было неверным. bypassPermissions без внешней изоляции снимает оба уровня сразу.

Вопросы с собеседований

Вы бы взяли LangChain для нового продукта?

Зависит от стадии и формы задачи, и я бы так и ответил, с критериями. Для прототипа или внутреннего инструмента с кучей интеграций — возможно, это ускоряет первую неделю. Для прод-системы с жёсткими требованиями к стоимости, латентности и отладке я начну с SDK провайдера и тонкого своего слоя: агентный цикл и RAG-пайплайн — это сотни строк, зато я вижу каждый промпт, могу использовать prompt caching и новые возможности API в день релиза, а трейсинг и evals настраиваю под себя. Для агента со сложным состоянием, human-in-the-loop и долгими сессиями рассмотрю LangGraph как движок персистентного графа, а не как набор готовых агентов. Главный аргумент против «всё на фреймворке» — скрытые промпты и отладка; главный аргумент «за» — не писать двадцатый загрузчик PDF. Совет Anthropic в «Building effective agents»: начинать с прямых вызовов API.

Почему FastAPI-сервис с LLM под нагрузкой отвечает всё медленнее, хотя провайдер отвечает стабильно?

Первое, что проверю: блокирующие вызовы в async-хендлерах — синхронный клиент LLM или БД, requests, тяжёлый CPU (токенизация, парсинг JSON мегабайтных ответов). Они блокируют event loop, и латентность всех запросов воркера растёт. Диагностика — debug-режим asyncio (предупреждения о медленных колбэках), py-spy. Второе: клиент создаётся на каждый запрос — нет переиспользования соединений. Третье: лимиты пула соединений HTTP-клиента меньше, чем одновременных стримов. Четвёртое: один воркер на CPU-bound работе. Лечение: async-клиенты везде, asyncio.to_thread для синхронных библиотек, пул процессов для CPU, несколько воркеров uvicorn, синглтон клиента с настроенным пулом.

Как вы анализируете результаты eval-прогона? Покажите на pandas.

Загружаю результаты в DataFrame, отделяю инфраструктурные ошибки. Сводка по версиям через groupby().agg(): pass rate, n, p50/p95 латентности, стоимость. Срезы через pivot_table по категории и версии — ищу, где регрессия прячется за общим ростом. Парное сравнение по example_id: сколько починено и сколько сломано, McNemar для значимости. Доверительные интервалы (Уилсон или bootstrap), решение по нижней границе. И обязательно глазами смотрю на провалы — df.query("passed == 0") — потому что метрика говорит «сколько», а не «почему». Ошибки judge-модели тоже проверяю выборочно.

Сервис на Java или на Python? Как бы вы выбрали для LLM-продукта?

Сервисный слой — на том, на чём сильна команда и инфраструктура: вызов API, стриминг, лимиты, очереди — это I/O, и Java 21 с виртуальными потоками даёт простую конкурентность без реактивщины, строгую типизацию, зрелый Spring-стек, наблюдаемость через Micrometer и OpenTelemetry и официальный SDK. Python нужен там, где экосистема: evals, анализ, fine-tuning, локальные модели, сложный парсинг документов. Частая здравая архитектура — сервисы на Spring Boot плюс Python-воркеры или офлайн-пайплайны, связь через очередь или HTTP. Против Python-сервиса по умолчанию: GIL и async-подвохи, упаковка и зависимости. За: скорость итерации, если команда — ML/DS. Против Java в отдельных случаях: холодный старт и память для serverless (лечится CDS, AOT, GraalVM native image). Выбор фиксирую в ADR, а не спорю о вкусах.

Сервис на Go или на Python? Как бы вы выбрали для LLM-продукта?

Сервисный слой — на том, на чём сильна команда и инфраструктура: вызов API, стриминг, лимиты, очереди — это I/O, и Go здесь даёт простую конкурентность, предсказуемую память, один бинарь и строгую типизацию. Python нужен там, где экосистема: evals, анализ, fine-tuning, локальные модели, сложный парсинг документов. Частая здравая архитектура — Go-сервисы плюс Python-воркеры или офлайн-пайплайны, связь через очередь или HTTP. Против Python-сервиса по умолчанию: GIL и async-подвохи, упаковка и зависимости. За: скорость итерации, если команда — ML/DS. Выбор фиксирую в ADR, а не спорю о вкусах.

Зачем абстрагировать провайдера LLM и как не переусердствовать?

Нужно для fallback между моделями и провайдерами, A/B-тестов, тестов с фейковым клиентом и защиты от vendor lock-in. Переусердствовать — значит свести всё к наименьшему общему знаменателю и потерять prompt caching, thinking, особенности tool use, которые дают основную экономию и качество. Решение: узкий интерфейс под задачи продукта (генерация, стриминг, tool-цикл) с явными опциями, которые адаптер провайдера поддерживает или игнорирует; провайдер-специфичные возможности доступны через расширения. И evals на каждого провайдера — переключение без них опасно, промпты не переносятся один в один.

Чем asyncio отличается от виртуальных потоков Java, и что вы бы сказали, если бы вас спросили, «что лучше для LLM-сервиса»?

asyncio — кооперативная многозадачность в одном потоке, переключение только в await, функции «окрашены» (async заражает стек), CPU-параллелизма нет из-за GIL. Виртуальные потоки — планировщик M:N в JVM поверх пула носителей на всех ядрах: обычный блокирующий код без окраски, блокирующий I/O паркует только свой поток. Для I/O-bound нагрузки LLM-сервиса оба справляются с тысячами одновременных запросов. Разница в надёжности: в Python одна синхронная библиотека способна положить воркер, и это надо контролировать ревью и линтерами. В Java 21 похожий, но более узкий класс ошибок — pinning (блокировка внутри synchronized, нативные вызовы); его видно в JFR по событию jdk.VirtualThreadPinned, а в Java 24 synchronized больше не пинит. Для CPU-частей (токенизация, обработка документов) у JVM преимущество без дополнительных процессов.

Чем asyncio отличается от горутин, и что вы бы сказали, если бы вас спросили, «что лучше для LLM-сервиса»?

asyncio — кооперативная многозадачность в одном потоке, переключение только в await, функции «окрашены» (async заражает стек), CPU-параллелизма нет из-за GIL. Горутины — вытесняющий планировщик M:N на всех ядрах, любая функция может быть конкурентной, блокирующий вызов не мешает другим. Для I/O-bound нагрузки LLM-сервиса оба справляются с тысячами одновременных запросов. Разница в надёжности: в Python одна синхронная библиотека способна положить воркер, и это надо контролировать ревью и линтерами; в Go такой класс ошибок отсутствует. Для CPU-частей (токенизация, обработка документов) у Go преимущество без дополнительных процессов.

Как вы используете Jupyter в работе и как код из ноутбука попадает в прод?

Ноутбук — для исследований: итерации над промптами на небольших выборках, анализ evals, графики, разбор провалов. Правила: выводы очищаются перед коммитом (nbstripout) или используется jupytext/marimo, секреты только из окружения, данные с ПДн не сохраняются в выводах. Как только код нужен повторно, он переезжает в модуль с тестами, ноутбук его импортирует. Прод-код — модули, скрипты с CLI и сервисы, с CI, типами и наблюдаемостью. Ноутбук никогда не запускается «по крону».

Как вы работаете с coding agents и как контролируете качество кода, который они пишут?

Агент для меня — исполнитель с быстрыми руками, а ответственность за мерж на мне. Цикл: исследование в режиме только чтения, письменный план с перечнем файлов и тем, чего не трогать, тесты до кода отдельным коммитом, реализация маленькими шагами с зелёными go build, go vet, go test -raceзелёным mvn verify после каждого, ревью диффа: сначала --stat, потом тесты отдельно, потом всё. В репозитории лежит короткий AGENTS.md: команды, конвенции, запреты, критерий готовности. Всё, что обязано выполняться, вынесено в права, хуки и CI: агент не может завершить работу с красными проверками или с правкой промпта без evals. Не отдаю агенту целиком то, что плохо проверяется автоматически: конкурентность, безопасность, миграции данных. Там он максимум предлагает, а решение и ревью — построчно мои. Полезная метрика для себя — доля PR от агента, которые пришлось переделывать или откатывать. Если она растёт, значит, я отдаю слишком крупные задачи.

На что смотрит интервьюер: зрелую позицию между «AI пишет всё» и «не пользуюсь». Ключевое — доверие через проверяемость (тесты до кода, CI, evals), маленькие диффы, явное владение результатом и понимание, где агент систематически ошибается. Плюс, если кандидат называет конкретные механизмы (хуки, deny-правила, sandbox), а не только «я внимательно смотрю».

Команда из 10 человек начинает пользоваться coding agents. Что вы настроите по безопасности в первый день?

Исхожу из того, что агент может быть скомпрометирован через текст, который он читает: README зависимостей, issues, комментарии в PR, веб-страницы, описания MCP-инструментов. Значит, разрываю lethal trifecta и ограничиваю ущерб, а не надеюсь на промпт. Первое: никаких прод-доступов в окружении агента — ни прод-БД, ни kube-контекста прода, ни облачных admin-ключей; для данных — read-only реплика или обезличенный дамп. Второе: секреты — deny-правила на .env и каталоги с ключами, отдельные dev-ключи с лимитом расходов, короткоживущие токены со scope на один репозиторий, особенно у облачных агентов. Третье: sandbox или контейнер для shell-команд, сеть через allowlist (реестр модулей, git-хостинг), режим «разрешено всё» — только в одноразовых контейнерах. Четвёртое: действия, которые выносят данные наружу или необратимы (push, публикация, комментарии в публичных репозиториях, миграции), — с подтверждением человека. Пятое: allowlist MCP-серверов с закреплёнными версиями и правило «по умолчанию только чтение». Шестое: общие настройки и AGENTS.md — в репозитории под ревью, CODEOWNERS на CI и эталон evals, логи действий агента для расследований. Правила работы команды (что можно отдавать агенту, как ревьюить) оформляю отдельным стандартом.

На что смотрит интервьюер: мышление моделью угроз, а не списком запретов в промпте: least privilege, разделение «режим подтверждений» и «изоляция», понимание непрямых инъекций через issues и зависимости, отсутствие прод-доступов как жёсткое правило. Хороший знак — кандидат помнит про облачных агентов и их токены и про то, что настройки должны быть общими и версионироваться, а не жить на ноутбуках.

Агент за вечер сгенерировал 200 eval-кейсов для вашего RAG. Как вы их используете?

Как черновик, а не как готовый набор. Первым делом разделяю входы и ожидаемые ответы: вопросы агент может придумывать, а ожидаемое (какие документы должны найтись, какие факты должны быть в ответе) задаю я или источник — сама документация. Иначе модель проверяет себя собственными ответами, и её систематические ошибки становятся эталоном. Дальше чистка: дедупликация и проверка разнообразия по темам, стилям и сложности против распределения реальных запросов из логов. Агенты склонны генерировать лёгкие и похожие кейсы, на которых всё зелёное. Смотрю, что текущая версия системы падает хотя бы на части набора, иначе набор ничего не различает. Проверяю утечки: нет ли в кейсах реальных персональных данных из логов и не совпадают ли они с примерами в промпте. Синтетические кейсы помечаю (source: synthetic), читаю случайную выборку глазами и держу отдельной частью набора, чтобы видеть метрики по реальным и синтетическим кейсам раздельно. Особенно ценна синтетика для того, чего мало в логах: вопросы без ответа в документации, атаки injection, граничные формулировки.

На что смотрит интервьюер: понимание круговой зависимости «оракул = испытуемый», внимание к качеству и разнообразию данных, проверку различающей способности набора, разделение синтетических и реальных кейсов в метриках. Красный флаг — «добавлю все 200 в golden set».