Модуль 0 · вводный

Что Go-разработчик уже умеет и как это переносится

Что Java-разработчик уже умеет и как это переносится

Процентов шестьдесят работы прикладного AI engineer — это обычный бэкенд: конкурентность, ретраи, лимиты, наблюдаемость, хранилища, тесты. Разберём, что из вашего GoJava-опыта переносится один в один, что меняет смысл (тесты превращаются в evals, логи — в учёт токенов) и что придётся учить с нуля.

≈ 4 часаGoJava + немного Python3 визуализацииактуально на сентябрь 2026

Чем AI engineering отличается от бэкенда

Прикладной AI engineer строит продукты поверх готовых LLM (large language model — большая языковая модель, которую вызывают по API). Модель вы не обучаете. Вы проектируете систему вокруг неё: как собрать контекст, как вызвать модель, как проверить ответ, как это масштабировать и во сколько это обойдётся. Инфраструктурная часть знакома. Новое — четыре свойства «зависимости», которой в обычном бэкенде не бывает.

1. Вероятностный вывод

Один и тот же запрос даёт разные ответы. «Правильно» бывает не бинарным: ответ может быть частично верным, верным по сути, но в неверном формате, или верным, но слишком длинным. Уверенный неправильный ответ (галлюцинация) выглядит так же, как правильный.

2. Стоимость за токен

Token — единица текста для модели, в среднем 3–4 символа английского текста; русский обычно дороже на символ. Вы платите за каждый входной и выходной токен, и выход в 5 раз дороже входа. Стоимость — функция промпта и поведения модели, а не только числа запросов.

3. Латентность в секундах

Не 20 мс, а 1–60 секунд. TTFT (time to first token, время до первого токена) обычно порядка сотен миллисекунд — пары секунд, дальше модель генерирует десятки–сотни токенов в секунду. Поэтому стриминг — не опция, а UX-требование.

4. Качество — метрика

Нельзя написать assert resp == expected. Качество измеряется на наборе примеров (eval set) как доля успешных ответов с доверительным интервалом и сравнивается между версиями промпта или модели.

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

ИзмерениеОбычный бэкендLLM-система
ДетерминизмОдин вход → один выходОдин вход → распределение выходов
Латентность p501–50 мс1–20 с (с reasoning — до минут)
Стоимость запросаДоли цента на CPUОт 0.01 до 1 $ и выше, сильно зависит от длины контекста
ОшибкиИсключения, кодыКоды (429, 529, 5xx) плюс «тихо плохой ответ» с 200 OK
ТестированиеUnit/integration, assertEvals: набор примеров, метрики, статистика
Изменение поведенияДеплой кодаПравка промпта, смена модели, обновление модели провайдером
Узкое местоCPU, БД, сетьRate limit провайдера (запросы и токены в минуту), бюджет
Цифры, чтобы почувствовать масштаб (на момент написания, сентябрь 2026)

Сервис делает 1 млн запросов в день, в каждом 2 000 входных и 500 выходных токенов. На Claude Opus 5 ($5 / $25 за 1M токенов): вход 2·10⁹ × 5 / 10⁶ = $10 000, выход 5·10⁸ × 25 / 10⁶ = $12 500, итого ≈ $22 500 в день. На Claude Haiku 4.5 ($1 / $5) — ≈ $4 500. Если 1 500 из 2 000 входных токенов — стабильный префикс и он попадает в prompt cache (чтение ≈ 0.1× цены входа), входная часть на Opus падает с $10 000 до ≈ $4 000. Выбор модели и кэширование — это решения на сотни тысяч долларов в год, и принимает их инженер, а не финансист.

Карта переноса навыков

Ниже — сопоставление того, что вы уже делаете в Go-сервисах, с практиками AI engineering. Кликните на навык слева, чтобы увидеть, во что он превращается, или на практику справа, чтобы увидеть, из чего она растёт. Связи «многие ко многим»: например, context участвует и в параллельных вызовах, и в стриминге.

Ниже — сопоставление того, что вы уже делаете в Java-сервисах, с практиками AI engineering. Кликните на навык слева, чтобы увидеть, во что он превращается, или на практику справа, чтобы увидеть, из чего она растёт. Связи «многие ко многим»: например, отмена и таймауты участвуют и в параллельных вызовах, и в стриминге.

Навык GoJava → практика AI engineering

Клик или Enter по узлу. Цвет связи: зелёный — переносится почти без изменений, жёлтый — идея та же, смысл меняется, красный — только каркас, содержание новое.

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

Конкурентность → параллельные вызовы

Самый прямой перенос. LLM-вызов — это I/O на секунды, CPU почти не занят. Горутины идеальны: 200 одновременных вызовов стоят несколько мегабайт памяти. Типичные задачи:

Самый прямой перенос. LLM-вызов — это I/O на секунды, CPU почти не занят. Virtual threads из Java 21 идеальны: 200 одновременных блокирующих вызовов стоят несколько мегабайт памяти, без реактивщины и без пула на 200 платформенных потоков. Типичные задачи:

Что меняется по сравнению с обычным fan-out на HTTP-сервисы:

Fan-out: последовательно vs параллельно с лимитом

Длительности вызовов случайны (логнормальное распределение вокруг средней латентности, фиксированный seed). Двигайте N, латентность, разброс и лимит конкурентности K, затем нажмите «Запустить».

Что видно на модели. При K ≥ N общее время равно максимальной длительности, а не сумме: 12 вызовов по 4 с укладываются примерно в 6–8 с вместо ~48. Увеличение разброса бьёт именно по параллельному варианту: он ждёт самый медленный вызов. При K < N время примерно ⌈N/K⌉ × средняя латентность, и выгода от K растёт почти линейно до упора в rate limit провайдера. В проде K подбирают так, чтобы K × (токены на запрос) × (запросов в минуту на один слот) укладывалось в ITPM с запасом.

Код: errgroup + semaphore + context

Код: virtual threads + Semaphore + дедлайн

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

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

package fanout;

import com.anthropic.client.AnthropicClient;
import com.anthropic.core.RequestOptions;
import com.anthropic.models.messages.Message;
import com.anthropic.models.messages.MessageCreateParams;
import com.anthropic.models.messages.TextBlock;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
import java.util.stream.Collectors;

public final class FanOut {
    public record Result(String text, long inputTokens, long outputTokens) {}

    private static final RequestOptions PER_CALL = RequestOptions.builder()
            .timeout(Duration.ofSeconds(45)) // таймаут одного вызова
            .build();

    /**
     * Делает docs.size() вызовов, не более maxInFlight одновременно.
     * Fail-fast: первая ошибка прерывает остальные вызовы.
     */
    public static List<Result> summarize(AnthropicClient client, List<String> docs, int maxInFlight)
            throws InterruptedException, ExecutionException, TimeoutException {
        long deadline = System.nanoTime() + Duration.ofMinutes(2).toNanos(); // бюджет на всю операцию
        Semaphore sem = new Semaphore(maxInFlight);
        Result[] results = new Result[docs.size()]; // каждый поток пишет только в свой индекс

        try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
            var done = new ExecutorCompletionService<Void>(pool);
            List<Future<Void>> tasks = new ArrayList<>();
            for (int i = 0; i < docs.size(); i++) {
                int idx = i;
                tasks.add(done.submit(() -> {
                    sem.acquire(); // ждущий виртуальный поток почти ничего не стоит
                    try {
                        results[idx] = summarizeOne(client, docs.get(idx));
                    } catch (RuntimeException e) {
                        throw new RuntimeException("doc " + idx, e);
                    } finally {
                        sem.release();
                    }
                    return null;
                }));
            }
            try {
                for (int n = 0; n < tasks.size(); n++) {
                    Future<Void> f = done.poll(deadline - System.nanoTime(), TimeUnit.NANOSECONDS);
                    if (f == null) {
                        throw new TimeoutException("fan-out aborted: deadline exceeded");
                    }
                    f.get(); // ExecutionException первой упавшей задачи
                }
            } catch (ExecutionException | TimeoutException | InterruptedException e) {
                tasks.forEach(t -> t.cancel(true)); // прерываем ждущие и летящие вызовы
                throw e;
            }
        }
        return List.of(results);
    }

    static Result summarizeOne(AnthropicClient client, String doc) {
        Message resp = client.messages().create(MessageCreateParams.builder()
                .model("claude-opus-5")
                .maxTokens(1024)
                .addUserMessage("Суммаризируй в 3 пунктах:\n\n" + doc)
                .build(), PER_CALL);
        String text = resp.content().stream()
                .flatMap(b -> b.text().stream())
                .map(TextBlock::text)
                .collect(Collectors.joining());
        return new Result(text, resp.usage().inputTokens(), resp.usage().outputTokens());
    }
}
package fanout

import (
	"context"
	"fmt"
	"time"

	"github.com/anthropics/anthropic-sdk-go"
	"golang.org/x/sync/errgroup"
	"golang.org/x/sync/semaphore"
)

type Result struct {
	Text          string
	InputTokens   int64
	OutputTokens  int64
}

// Summarize делает len(docs) вызовов, не более maxInFlight одновременно.
// Fail-fast: первая ошибка отменяет остальные вызовы через ctx.
func Summarize(ctx context.Context, client *anthropic.Client, docs []string, maxInFlight int64) ([]Result, error) {
	ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) // бюджет на всю операцию
	defer cancel()

	sem := semaphore.NewWeighted(maxInFlight)
	g, ctx := errgroup.WithContext(ctx)
	results := make([]Result, len(docs)) // каждая горутина пишет только в свой индекс
	var acquireErr error

	for i, doc := range docs {
		// Acquire блокирует цикл, а не плодит тысячи ждущих горутин.
		if err := sem.Acquire(ctx, 1); err != nil {
			acquireErr = err // ctx отменён: таймаут или чья-то ошибка
			break
		}
		g.Go(func() error {
			defer sem.Release(1)
			callCtx, cancel := context.WithTimeout(ctx, 45*time.Second) // таймаут одного вызова
			defer cancel()

			resp, err := client.Messages.New(callCtx, anthropic.MessageNewParams{
				Model:     "claude-opus-5",
				MaxTokens: 1024,
				Messages: []anthropic.MessageParam{
					anthropic.NewUserMessage(anthropic.NewTextBlock("Суммаризируй в 3 пунктах:\n\n" + doc)),
				},
			})
			if err != nil {
				return fmt.Errorf("doc %d: %w", i, err)
			}
			var text string
			for _, b := range resp.Content {
				if t, ok := b.AsAny().(anthropic.TextBlock); ok {
					text += t.Text
				}
			}
			results[i] = Result{Text: text, InputTokens: resp.Usage.InputTokens, OutputTokens: resp.Usage.OutputTokens}
			return nil
		})
	}
	if err := g.Wait(); err != nil {
		return nil, err
	}
	if acquireErr != nil {
		return nil, fmt.Errorf("fan-out aborted: %w", acquireErr)
	}
	return results, nil
}
import asyncio
import anthropic

client = anthropic.AsyncAnthropic()  # ключ из ANTHROPIC_API_KEY

async def summarize(docs: list[str], max_in_flight: int = 8) -> list[str]:
    sem = asyncio.Semaphore(max_in_flight)
    results: list[str] = [""] * len(docs)

    async def one(i: int, doc: str) -> None:
        async with sem:  # ограничение параллелизма, как семафор
            async with asyncio.timeout(45):  # таймаут одного вызова
                resp = await client.messages.create(
                    model="claude-opus-5",
                    max_tokens=1024,
                    messages=[{"role": "user", "content": f"Суммаризируй в 3 пунктах:\n\n{doc}"}],
                )
        results[i] = "".join(b.text for b in resp.content if b.type == "text")

    # TaskGroup: первая ошибка отменяет остальные задачи
    async with asyncio.timeout(120):
        async with asyncio.TaskGroup() as tg:
            for i, d in enumerate(docs):
                tg.create_task(one(i, d))
    return results
Нюансы, которые спросят на ревью
Нюансы, которые спросят на ревью

Стриминг и отмена через context

Стриминг и отмена запроса

Ответ в 800 токенов генерируется 10–20 секунд. Без стриминга пользователь смотрит на спиннер всё это время, со стримингом видит первые слова через TTFT. Провайдеры отдают поток через SSE (Server-Sent Events: HTTP-ответ text/event-stream, события вида event: …\ndata: {json}\n\n). У Anthropic последовательность такая: message_start → для каждого блока content_block_start, серия content_block_delta (text_delta, input_json_delta для аргументов инструментов, thinking_delta), content_block_stop → message_delta (stop_reason и итоговый usage) → message_stop. Между ними бывают ping, а посреди стрима может прийти error (например, overloaded_error) — уже после 200 OK.

Для Go-разработчика тут всё знакомо: bufio.Scanner по телу ответа, канал событий наружу, http.Flusher на своей стороне, чтобы проксировать поток в браузер. Ключевая вещь — отмена. Пользователь закрыл вкладку → r.Context() вашего хендлера отменён → вы должны закрыть исходящий запрос к провайдеру, иначе модель продолжит генерировать токены, за которые вы заплатите. Если передавать r.Context() в SDK, это происходит само.

Для Java-разработчика тут всё знакомо: SDK отдаёт поток событий как StreamResponse (это AutoCloseable с java.util.stream.Stream внутри), а на своей стороне вы проксируете его в браузер через SseEmitter в Spring MVC или Flux<ServerSentEvent> в WebFlux. Ключевая вещь — отмена. Пользователь закрыл вкладку → следующий emitter.send() бросает IOException → вы должны закрыть исходящий запрос к провайдеру, иначе модель продолжит генерировать токены, за которые вы заплатите. Если держать StreamResponse в try-with-resources, закрытие происходит само: исключение выходит из блока, и соединение с провайдером рвётся. В WebFlux то же делает отмена подписки.

// Проксирование стрима в браузер и замер TTFT. Клиент ушёл → send() бросает IOException →
// try-with-resources закрывает StreamResponse → SDK рвёт соединение с провайдером.
@GetMapping(path = "/answer", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter answer(@RequestParam String q) {
    SseEmitter emitter = new SseEmitter(0L); // без таймаута Spring: ответ может идти минуты
    Thread.startVirtualThread(() -> streamAnswer(emitter, q));
    return emitter;
}

void streamAnswer(SseEmitter emitter, String q) {
    long start = System.nanoTime();
    long ttftNanos = -1;
    MessageAccumulator acc = MessageAccumulator.create();
    MessageCreateParams params = MessageCreateParams.builder()
            .model("claude-opus-5")
            .maxTokens(2048)
            .addUserMessage(q)
            .build();
    try (StreamResponse<RawMessageStreamEvent> stream = client.messages().createStreaming(params)) {
        for (Iterator<RawMessageStreamEvent> it = stream.stream().iterator(); it.hasNext(); ) {
            RawMessageStreamEvent ev = it.next();
            acc.accumulate(ev);
            Optional<String> text = ev.contentBlockDelta()
                    .flatMap(d -> d.delta().text())
                    .map(TextDelta::text);
            if (text.isPresent()) {
                if (ttftNanos < 0) {
                    ttftNanos = System.nanoTime() - start;
                }
                emitter.send(SseEmitter.event().data(text.get(), MediaType.APPLICATION_JSON));
            }
        }
        Message msg = acc.message();
        log.atInfo().setMessage("llm_call")
                .addKeyValue("ttft_ms", ttftNanos / 1_000_000)
                .addKeyValue("total_ms", (System.nanoTime() - start) / 1_000_000)
                .addKeyValue("in", msg.usage().inputTokens())
                .addKeyValue("out", msg.usage().outputTokens())
                .addKeyValue("stop", msg.stopReason().orElse(null))
                .log();
        emitter.complete();
    } catch (IOException | RuntimeException e) { // включая ошибку, пришедшую посреди стрима
        emitter.completeWithError(e);
    }
}
// Проксирование стрима в браузер и замер TTFT. ctx = r.Context() хендлера:
// клиент ушёл → ctx отменён → SDK закрывает соединение с провайдером.
func streamAnswer(ctx context.Context, w http.ResponseWriter, client *anthropic.Client, q string) error {
	flusher, ok := w.(http.Flusher)
	if !ok {
		return errors.New("streaming unsupported")
	}
	w.Header().Set("Content-Type", "text/event-stream")
	w.Header().Set("Cache-Control", "no-cache")

	start := time.Now()
	var ttft time.Duration
	stream := client.Messages.NewStreaming(ctx, anthropic.MessageNewParams{
		Model:     "claude-opus-5",
		MaxTokens: 2048,
		Messages:  []anthropic.MessageParam{anthropic.NewUserMessage(anthropic.NewTextBlock(q))},
	})
	msg := anthropic.Message{}
	for stream.Next() {
		ev := stream.Current()
		if err := msg.Accumulate(ev); err != nil {
			return err
		}
		if d, ok := ev.AsAny().(anthropic.ContentBlockDeltaEvent); ok {
			if t, ok := d.Delta.AsAny().(anthropic.TextDelta); ok {
				if ttft == 0 {
					ttft = time.Since(start)
				}
				b, _ := json.Marshal(t.Text)
				fmt.Fprintf(w, "data: %s\n\n", b)
				flusher.Flush()
			}
		}
	}
	if err := stream.Err(); err != nil {
		return err // включая ошибку, пришедшую посреди стрима
	}
	slog.InfoContext(ctx, "llm_call", "ttft_ms", ttft.Milliseconds(),
		"total_ms", time.Since(start).Milliseconds(),
		"in", msg.Usage.InputTokens, "out", msg.Usage.OutputTokens, "stop", msg.StopReason)
	return nil
}
Режимы отказа стриминга

Ретраи и rate limit

Вызов LLM API падает заметно чаще, чем ваш внутренний сервис. Что ретраить, а что нет:

КодЧто значитЧто делать
400Невалидный запрос: лишний параметр, слишком длинный контекст, неподдерживаемое поле для моделиНе ретраить. Это баг или неучтённая длина входа
401 / 403Ключ, праваНе ретраить, алертить
408, 409Таймаут, конфликтРетрай с backoff
429Rate limit (RPM, ITPM или OTPM)Ретрай, уважая заголовок retry-after; снизить конкурентность
500, 502, 503, 504Ошибка провайдераРетрай с backoff, при серии — circuit breaker и fallback
529Anthropic: overloaded_error, перегрузка на стороне APIРетрай с backoff; при длительной — fallback на другую модель или регион

Ретрай в LLM-мире дороже, чем кажется. Если запрос упал на середине стрима, вы уже заплатили за выданные токены. Если ретраите запрос со 100K токенов контекста, каждая попытка — это 100K входных токенов в ITPM. Агрессивные ретраи при 429 сами поддерживают перегрузку: это retry storm. Поэтому backoff экспоненциальный и обязательно с jitter — случайной добавкой, которая «размазывает» повторы клиентов во времени.

Код: ретрай с экспоненциальным backoff + jitter

Вариант «full jitter» из классической статьи AWS: пауза равномерно случайна в интервале (0, min(cap, base·2attempt)]. Если провайдер прислал retry-after, ждём не меньше него.

package retry;

import com.anthropic.errors.AnthropicIoException;
import com.anthropic.errors.AnthropicServiceException;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.ThreadLocalRandom;

public final class Retry {
    /**
     * Вызывает fn до attempts раз. Встроенные ретраи SDK стоит отключить:
     * AnthropicOkHttpClient.builder().fromEnv().maxRetries(0).build(), иначе попытки перемножатся.
     * Отмена — прерывание потока: Thread.sleep бросит InterruptedException.
     */
    public static <T> T call(int attempts, Duration base, Duration maxDelay, Callable<T> fn) throws Exception {
        for (int a = 0; ; a++) {
            try {
                return fn.call();
            } catch (Exception e) {
                Duration retryAfter = retryAfter(e); // null — не ретраить
                if (retryAfter == null || a == attempts - 1) {
                    throw e;
                }
                Duration delay = fullJitter(a, base, maxDelay);
                if (retryAfter.compareTo(delay) > 0) {
                    delay = retryAfter; // сервер знает лучше, когда освободится лимит
                }
                Thread.sleep(delay);
            }
        }
    }

    static Duration fullJitter(int attempt, Duration base, Duration maxDelay) {
        long cap = maxDelay.toMillis();
        long d = base.toMillis() << Math.min(attempt, 30);
        if (d <= 0 || d > cap) { // <= 0 — защита от переполнения сдвига
            d = cap;
        }
        return Duration.ofMillis(ThreadLocalRandom.current().nextLong(d) + 1);
    }

    /** null — ретраить бессмысленно; иначе минимальная пауза из retry-after (может быть ZERO). */
    static Duration retryAfter(Exception e) {
        if (e instanceof AnthropicServiceException se) {
            return switch (se.statusCode()) {
                case 408, 409, 429, 500, 502, 503, 504, 529 -> parseRetryAfter(se);
                default -> null; // 400/401/403/404/413 — повтор не поможет
            };
        }
        if (e instanceof AnthropicIoException) {
            return Duration.ZERO; // сетевая ошибка: соединение сброшено, DNS и т.п.
        }
        return null; // InterruptedException — вызывающий сдался, не спорим с ним
    }

    static Duration parseRetryAfter(AnthropicServiceException e) {
        List<String> v = e.headers().values("retry-after");
        try {
            return v.isEmpty() ? Duration.ZERO : Duration.ofSeconds(Math.max(0, Long.parseLong(v.get(0))));
        } catch (NumberFormatException ex) {
            return Duration.ZERO;
        }
    }
}
package retry

import (
	"context"
	"errors"
	"math/rand/v2"
	"net/http"
	"strconv"
	"time"

	"github.com/anthropics/anthropic-sdk-go"
)

// Do вызывает fn до attempts раз. Встроенные ретраи SDK стоит отключить:
// anthropic.NewClient(option.WithMaxRetries(0)), иначе попытки перемножатся.
func Do[T any](ctx context.Context, attempts int, base, maxDelay time.Duration, fn func(context.Context) (T, error)) (T, error) {
	var zero T
	var err error
	for a := 0; a < attempts; a++ {
		var v T
		if v, err = fn(ctx); err == nil {
			return v, nil
		}
		retryable, retryAfter := classify(err)
		if !retryable || a == attempts-1 {
			break
		}
		delay := fullJitter(a, base, maxDelay)
		if retryAfter > delay {
			delay = retryAfter // сервер знает лучше, когда освободится лимит
		}
		t := time.NewTimer(delay)
		select {
		case <-ctx.Done():
			t.Stop()
			return zero, errors.Join(err, ctx.Err())
		case <-t.C:
		}
	}
	return zero, err
}

func fullJitter(attempt int, base, maxDelay time.Duration) time.Duration {
	d := base << attempt
	if d <= 0 || d > maxDelay { // <= 0 — защита от переполнения сдвига
		d = maxDelay
	}
	return time.Duration(rand.Int64N(int64(d))) + time.Millisecond
}

func classify(err error) (retryable bool, retryAfter time.Duration) {
	if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
		return false, 0 // вызывающий сдался — не спорим с ним
	}
	var apierr *anthropic.Error
	if !errors.As(err, &apierr) {
		return true, 0 // сетевая ошибка: соединение сброшено, DNS и т.п.
	}
	switch apierr.StatusCode {
	case http.StatusRequestTimeout, http.StatusConflict, http.StatusTooManyRequests,
		500, 502, 503, 504, 529:
		return true, parseRetryAfter(apierr.Response)
	}
	return false, 0 // 400/401/403/404/413 — повтор не поможет
}

func parseRetryAfter(resp *http.Response) time.Duration {
	if resp == nil {
		return 0
	}
	if s, err := strconv.Atoi(resp.Header.Get("retry-after")); err == nil && s > 0 {
		return time.Duration(s) * time.Second
	}
	return 0
}

Rate limit на своей стороне: token bucket

Ждать 429 от провайдера — реактивная стратегия. Проактивная — не превышать лимит самому. Token bucket (ведро токенов) — то, что вы знаете по golang.org/x/time/rateBucket4j, Guava RateLimiter или Resilience4j RateLimiter: ведро ёмкостью burst пополняется со скоростью r в секунду, каждый запрос забирает из него «жетоны». Новое здесь одно: жетоны — это LLM-токены, а не запросы. Лимитер на запросы защищает RPM, но не ITPM; один запрос с 150K контекста съест лимит, как сотня маленьких.

// Bucket4j. Два ведра: на запросы и на входные токены. Значения — из вашего tier'а в консоли провайдера.
static final long TOKEN_BURST = 200_000;
static final Bucket REQ_LIM = Bucket.builder()
        .addLimit(l -> l.capacity(50).refillGreedy(4_000, Duration.ofMinutes(1)))              // 4000 RPM
        .build();
static final Bucket TOK_LIM = Bucket.builder()
        .addLimit(l -> l.capacity(TOKEN_BURST).refillGreedy(2_000_000, Duration.ofMinutes(1))) // 2M ITPM, burst 200K
        .build();

static void acquire(long estInputTokens) throws InterruptedException {
    REQ_LIM.asBlocking().consume(1);
    // Больше ёмкости ведро не накопит никогда: огромный запрос надо резать или слать в Batch API.
    if (estInputTokens > TOKEN_BURST) {
        throw new IllegalArgumentException("request exceeds token burst: " + estInputTokens);
    }
    TOK_LIM.asBlocking().consume(estInputTokens);
}
// Два ведра: на запросы и на входные токены. Значения — из вашего tier'а в консоли провайдера.
reqLim := rate.NewLimiter(rate.Limit(4000.0/60), 50)           // 4000 RPM
tokLim := rate.NewLimiter(rate.Limit(2_000_000.0/60), 200_000) // 2M ITPM, burst 200K

func acquire(ctx context.Context, estInputTokens int) error {
	if err := reqLim.Wait(ctx); err != nil {
		return err
	}
	// WaitN вернёт ошибку, если n > burst: огромный запрос надо резать или слать в Batch API.
	return tokLim.WaitN(ctx, estInputTokens)
}
Где это живёт в архитектуре

В одном процессе Bucket4j в памяти достаточно. Когда реплик 20, лимит провайдера общий, а лимитеры локальные — вы превысите его в 20 раз. Решения: распределённый лимитер в Redis (у Bucket4j есть бэкенды для Redis через Lettuce/Redisson и для JCache с тем же API; либо свой скрипт на Lua, sliding window или GCRA), либо отдельный LLM-gateway — единая точка выхода к провайдерам с лимитами, ретраями, кэшем, учётом стоимости по командам и fallback. Это один из стандартных вопросов на AI system design (см. модуль 12). Оценку входных токенов до вызова берут из эндпоинта подсчёта токенов провайдера или грубо: символы / 3.5 с запасом.

Где это живёт в архитектуре

В одном процессе x/time/rate достаточно. Когда реплик 20, лимит провайдера общий, а лимитеры локальные — вы превысите его в 20 раз. Решения: распределённый лимитер в Redis (скрипт на Lua, sliding window или GCRA), либо отдельный LLM-gateway — единая точка выхода к провайдерам с лимитами, ретраями, кэшем, учётом стоимости по командам и fallback. Это один из стандартных вопросов на AI system design (см. модуль 12). Оценку входных токенов до вызова берут из эндпоинта подсчёта токенов провайдера или грубо: символы / 3.5 с запасом.

Circuit breaker и fallback

Circuit breaker — тот же паттерн, что вы ставите перед нестабильным сервисом (sony/gobreaker и аналогиResilience4j CircuitBreaker, в Spring Boot — через @CircuitBreaker(fallbackMethod = …) или Spring Cloud CircuitBreaker). В LLM-мире у него две задачи: не долбить перегруженного провайдера (529 сериями по несколько минут — реальность) и быстро переключиться на fallback. Fallback-цепочка типична: основная модель → более дешёвая модель того же провайдера → другой провайдер → деградированный ответ («сервис перегружен, попробуйте позже» или ответ без LLM).

Состояния circuit breaker

Отправляйте «запросы» кнопками. В состоянии open запросы отклоняются сразу, не доходя до провайдера. Через cooldown (здесь 5 с) breaker переходит в half-open и пропускает один пробный запрос.

Настройки, которые в LLM-контексте отличаются от привычных:

Observability → токены, стоимость, TTFT

Логи, метрики, трейсы остаются. Меняется набор сигналов. Классический RED (rate, errors, duration) нужен, но недостаточен: запрос может вернуть 200 за 3 секунды и быть бесполезным. Минимальный набор атрибутов на каждый LLM-вызов:

СигналЗачемОткуда
model, версия промптаСравнение качества и стоимости до/после измененийВаш код; версию промпта храните как хеш или тег
input / output tokensСтоимость, прогноз упора в ITPM/OTPMusage в ответе
cache read / write tokensРаботает ли prompt caching; одна правка system prompt может обнулить hit rateusage.cache_read_input_tokens, cache_creation_input_tokens
cost, $Бюджеты по фичам и клиентам, аномалииВычисляется из токенов и прайса
TTFT, total latency, output tokens/sUX стриминга; TTFT растёт с длиной контекстаЗамер в клиенте
stop_reasonmax_tokens = обрезанный ответ, refusal = отказ моделиОтвет / message_delta
число итераций, tool callsАгенты: зацикливание, взрыв стоимостиВаш агентный цикл
оценка качестваОнлайн-evals, фидбек пользователя (лайк/дизлайк), доля ответов, прошедших валидациюОтдельный пайплайн

Для трейсов есть стандарт: OpenTelemetry GenAI semantic conventions — атрибуты gen_ai.request.model, gen_ai.usage.input_tokens, gen_ai.usage.output_tokens, gen_ai.response.finish_reasons и т.д. В агентах трейс особенно ценен: span на каждый шаг модели и каждый tool call показывает, где ушли 40 секунд и 200K токенов.

В Spring Boot это ложится на привычный стек: Micrometer Observation API даёт и метрики, и спаны одним вызовом, Micrometer Tracing с мостом в OpenTelemetry экспортирует их по OTLP, а /actuator/prometheus отдаёт счётчики токенов и стоимости. Spring AI и LangChain4j сами создают observation на каждый вызов модели с атрибутами gen_ai.*; если вы зовёте официальный anthropic-java напрямую, оберните вызов в свой Observation или спан OpenTelemetry Java API (пример — в проекте D, сквозные проекты).

Что ломается

БД, очереди, кэши → векторные БД и семантический кэш

Ваш опыт с PostgreSQL, индексами и Redis напрямую нужен в RAG (retrieval-augmented generation — поиск релевантных документов и подстановка их в контекст модели). Новые сущности:

Кэширование раздваивается:

Вид кэшаКлючЧто экономитРиск
Точный (exact) кэш ответаХеш модели + промпта + параметров100% вызоваНизкий hit rate: пользователи формулируют по-разному
Семантический кэшEmbedding запроса, порог близости100% вызова при похожем вопросе«Похожий» вопрос с другим смыслом («как отменить заказ» vs «как не отменять заказ») получает чужой ответ. Порог подбирается на evals
Prompt caching провайдераСовпадение префикса байт: tools → system → messages≈90% стоимости закэшированной части входа и часть TTFTЛюбое изменение префикса (timestamp в system prompt, порядок tools) инвалидирует всё после него

Очереди тоже переносятся: длинные задачи (суммаризация документа на 300 страниц, агент, который работает 10 минут) не должны жить в HTTP-запросе. Паттерн «задача в очередь → воркер → статус и результат в БД → уведомление» ровно тот же. Отличие — идемпотентность дороже: повторная обработка сообщения не просто нагружает базу, а стоит денег и может дать другой результат. Храните результат по ключу задачи и проверяйте его перед вызовом модели. Для офлайн-нагрузок у провайдеров есть Batch API: у Anthropic −50% к цене, результаты до 24 часов, порядок произвольный, сопоставление по custom_id.

Тесты → evals

Самое большое смысловое изменение. Table-driven тестыПараметризованные тесты JUnit 5 (@ParameterizedTest с @CsvFileSource или @MethodSource) — отличная интуиция для evals: таблица входов и ожиданий. Но проверка меняется с «равно» на «насколько хорошо», а результат — с «зелёный/красный» на число с погрешностью.

Unit-тестEval
ВходФиксированныйНабор реалистичных примеров (golden dataset), часто из прод-логов
Проверкаassert got == wantКод (JSON валиден, есть поле, число в диапазоне), LLM-as-a-judge по рубрике, человек
Результатpass/failДоля успешных, средний балл, с доверительным интервалом
ФлейкиБаг, чинитьНорма. Прогоняют несколько раз или на большом наборе
Когда падаетПосле изменения кодаПосле изменения промпта, модели, retrieval, или провайдер обновил модель
Стоимость прогонаБесплатноДеньги и минуты; 500 примеров × judge = реальный счёт

Статистика здесь не для красоты. 50 примеров, 45 прошли — 90%, но 95% доверительный интервал (Уилсона) примерно 79–96%. Если новая версия промпта дала 47 из 50, это не доказательство улучшения, это шум. Для различения 90% и 93% нужны сотни примеров. Для сравнения двух версий на одном наборе используют парные тесты (McNemar) — смотрят только примеры, где версии разошлись.

// Запуск: mvn verify -Dgroups=evals (по умолчанию тег исключён в surefire — это стоит денег)
@Tag("evals")
class ClassifierEvalTest {
    @Test
    void classifierAccuracy() throws Exception {
        List<GoldenCase> cases = Golden.load("tickets_golden.jsonl"); // 300 размеченных тикетов
        var pass = new AtomicInteger();
        var sem = new Semaphore(8);
        try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
            var futures = cases.stream().map(c -> pool.submit(() -> {
                sem.acquire();
                try {
                    if (classifier.classify(c.text()).category().equals(c.want())) {
                        pass.incrementAndGet();
                    }
                } finally {
                    sem.release();
                }
                return null;
            })).toList();
            for (var f : futures) {
                f.get(); // инфраструктурная ошибка — это не «модель ошиблась»: тест падает
            }
        }
        double n = cases.size();
        double rate = pass.get() / n;
        double lo = wilsonLower(rate, n, 1.96);
        System.out.printf("accuracy=%.3f (95%% CI lower=%.3f, n=%.0f)%n", rate, lo, n);
        assertTrue(lo >= 0.85, // порог по нижней границе, а не по точечной оценке
                () -> "accuracy lower bound %.3f < 0.85".formatted(lo));
    }
}
//go:build evals

// Запуск: go test -tags evals -run TestClassifierEval ./... (не в каждом CI, это стоит денег)
func TestClassifierEval(t *testing.T) {
	cases := loadGolden(t, "testdata/tickets_golden.jsonl") // 300 размеченных тикетов
	var pass atomic.Int64
	g, ctx := errgroup.WithContext(context.Background())
	g.SetLimit(8)
	for _, c := range cases {
		g.Go(func() error {
			got, err := Classify(ctx, client, c.Text)
			if err != nil {
				return err // инфраструктурная ошибка — это не «модель ошиблась»
			}
			if got.Category == c.Want {
				pass.Add(1)
			}
			return nil
		})
	}
	if err := g.Wait(); err != nil {
		t.Fatal(err)
	}
	n := float64(len(cases))
	rate := float64(pass.Load()) / n
	lo := wilsonLower(rate, n, 1.96)
	t.Logf("accuracy=%.3f (95%% CI lower=%.3f, n=%.0f)", rate, lo, n)
	if lo < 0.85 { // порог по нижней границе, а не по точечной оценке
		t.Errorf("accuracy lower bound %.3f < 0.85", lo)
	}
}

В проде так делают редко на GoJava целиком: разметка, анализ и графики удобнее в Python (pandas, Jupyter, модуль 4), а evals — отдельная дисциплина (модуль 8). Но прогонщик в CI, который шлёт сотни запросов с лимитами и ретраями, — это как раз ваша сильная сторона.

Что учить с нуля

Переносится инфраструктура. Новое — всё, что про поведение модели и оценку качества:

НавыкПочему новоеМодуль
Как работает LLMТокенизация, контекстное окно, сэмплинг, почему модель галлюцинирует и почему длинный контекст не бесплатен. Без этого не объяснить ни стоимость, ни отказы1
Особенности APIContent blocks, stop reasons, tool use, structured outputs, prompt caching, thinking с параметром effort. Знание конкретного API — то, что отличает «пробовал» от «делал»2
Prompt engineeringЭто не магия, а спецификация для недетерминированного исполнителя. Версионирование, регрессия, prompt injection3
Python-экосистемаEvals, анализ данных, эксперименты живут там4
RetrievalЧанкинг, эмбеддинги, reranking, метрики поиска (recall@k, MRR)5
АгентыЦикл «модель → инструмент → модель», когда агент не нужен, MCP6
БезопасностьМодель недетерминирована и читает недоверенный текст: инъекции, excessive agency, утечки через вывод. Классический AppSec нужен, но его недостаточно7
EvalsДатасеты, LLM-as-a-judge и его калибровка, статистика8
Основы MLPrecision/recall, переобучение, когда fine-tuning оправдан (редко)10
Данные и governanceПарсинг документов, PII, версии датасетов, разметка, логи диалогов, EU AI Act и 152-ФЗ — то, на чём держатся качество и комплаенс11
Senior-практикиADR, design review, SLO для недетерминированной системы, постмортемы, разговор о метриках с бизнесом13
Экосистема Java: Spring AI и LangChain4j

В Java есть два зрелых фреймворка поверх LLM API. Spring AI — «Spring Data для моделей»: ChatClient, advisors для RAG и памяти, VectorStore (включая pgvector), structured output в records, tool calling через @Tool, MCP-клиент и MCP-сервер как Spring Boot-стартеры, observability через Micrometer из коробки. LangChain4j — порт идей LangChain: декларативные AI Services (интерфейс + аннотации), RAG-пайплайны, агенты, интеграции со Spring Boot и Quarkus. Оба удобны для типовых задач, но прячут детали API: в курсе основной код написан на официальном SDK com.anthropic:anthropic-java, чтобы было видно, что уходит в модель. Фреймворк берите, когда понимаете, что он делает за вас, и проверяйте это трейсами.

Позиционирование на рынке

Сильная сторона GoJava-бэкендера на собеседовании на AI engineer — «я знаю, как это выдержит прод»: лимиты, ретраи, стоимость, наблюдаемость, безопасность. Слабое место, которое проверят, — понимание модели и evals. Типичная ошибка кандидатов с бэкенд-опытом: говорить о LLM как о детерминированном сервисе и не иметь ответа на «как вы узнаете, что стало лучше?». Курс построен так, чтобы закрыть именно это.

Практика

Задание 1: надёжный fan-out-клиент

Напишите на Go пакет llmbatch: функция принимает срез промптов и возвращает срез результатов в том же порядке, где у каждого элемента есть текст, usage и ошибка. Используйте errgroup с лимитом, общий дедлайн, таймаут на вызов, свой ретрай с full jitter и retry-after (встроенный ретрай SDK отключите), token bucket по входным токенам.

Напишите на Java пакет llmbatch: метод принимает список промптов и возвращает список результатов в том же порядке, где каждый элемент — sealed-тип Ok(text, usage) | Failed(exception). Используйте executor на virtual threads с Semaphore, общий дедлайн, таймаут на вызов через RequestOptions, свой ретрай с full jitter и retry-after (встроенный ретрай SDK отключите через maxRetries(0)), token bucket по входным токенам (Bucket4j).

Критерии готовности:

Задание 2: breaker и fallback

Оберните клиент в circuit breaker (свой или sony/gobreakerсвой или Resilience4j CircuitBreaker) с fallback с основной модели на более дешёвую. Считайте ошибками только 5xx/529/таймауты.

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

Почему при fan-out из N параллельных LLM-вызовов общее время не равно средней латентности?

Общее время равно максимуму из N длительностей (при K ≥ N). Латентность LLM сильно зависит от длины вывода и имеет тяжёлый хвост, поэтому максимум из N растёт с N: чем больше вызовов, тем вероятнее, что один из них попадёт в p99. Лечится ограничением max_tokens, hedging-запросами, или UX, который показывает частичные результаты по мере готовности.

Какие лимиты есть у Anthropic API и почему лимитер на запросы в секунду недостаточен?

RPM, ITPM и OTPM на модель. Лимитер на запросы защищает только RPM; запросы с длинным контекстом исчерпывают ITPM при небольшом RPS. Нужен token bucket, где «жетоны» — оценка входных токенов, и учёт выходных токенов через max_tokens.

Зачем jitter в backoff, если экспонента уже разносит попытки?

Без jitter все клиенты, получившие 429 одновременно, повторят попытку тоже одновременно: через 1, 2, 4 секунды. Волны повторов синхронизированы и снова упираются в лимит. Jitter размазывает их по интервалу. Full jitter даёт наименьшую суммарную нагрузку при конкуренции.

Какие коды ошибок не надо ретраить и почему 429 не стоит считать отказом для circuit breaker?

Не ретраить 400, 401, 403, 404, 413: повтор даст то же самое. 429 означает, что вы превысили свой лимит, провайдер при этом здоров; открывать breaker — значит отрезать трафик, который прошёл бы через секунду. Правильная реакция — снизить конкурентность и уважать retry-after.

Что произойдёт, если пользователь закрыл вкладку во время стриминга, а вы не передали r.Context() в вызов SDK?

Запрос к провайдеру продолжит выполняться до конца генерации: вы заплатите за все выходные токены, которые никто не увидит, и займёте слот конкурентности и лимит OTPM. Передача контекста хендлера закрывает соединение, и генерация на стороне провайдера прекращается.

Что произойдёт, если пользователь закрыл вкладку во время стриминга, а вы проглотили IOException от SseEmitter и не закрыли StreamResponse?

Запрос к провайдеру продолжит выполняться до конца генерации: вы заплатите за все выходные токены, которые никто не увидит, и займёте слот конкурентности и лимит OTPM. StreamResponse в try-with-resources (или отмена подписки во WebFlux) закрывает соединение, и генерация на стороне провайдера прекращается.

Почему семантический кэш опаснее обычного?

Он возвращает ответ на «похожий» вопрос. Эмбеддинги плохо различают отрицания, числа, имена сущностей: «как отменить подписку» и «как не отменять подписку автоматически» близки по вектору. Пользователь получит чужой ответ. Порог близости подбирают по evals, кэш ограничивают по тенанту и типу запросов, часто кэшируют только FAQ-подобные вопросы.

Eval показал 88% на старом промпте и 92% на новом, по 50 примеров. Катить?

Этого недостаточно для вывода. Доверительные интервалы при n=50 шириной около ±8–9 п.п. и пересекаются. Нужно больше примеров, парное сравнение на одних и тех же примерах (смотреть, где версии разошлись), несколько прогонов из-за недетерминизма, и проверка по срезам: общий рост может скрывать падение в важной категории.

Что обязательно логировать на каждый LLM-вызов?

Модель, версию промпта, input/output/cache токены, стоимость, TTFT и общую латентность, stop_reason, ошибку и число попыток, для агентов — число итераций и tool calls, trace id. Промпты и ответы — только с учётом политики ПДн: семплирование, редактирование, TTL.

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

Вам нужно обработать 2 млн документов LLM за ночь. Как спроектируете?

Сначала считаю: средняя длина документа в токенах × 2 млн = входные токены, плюс ожидаемый выход. Это даёт стоимость и сравнение с лимитами ITPM/OTPM. Если задача офлайн и срок — «к утру», первый кандидат — Batch API: −50% к цене, лимиты отдельные, результаты до 24 ч, сопоставление по custom_id. Если нужно быстрее или batch не подходит, делаю пайплайн: очередь задач, воркеры с ограничением конкурентности, распределённый token bucket по ITPM, ретраи с jitter, идемпотентность по ключу документа (результат в БД, повторная обработка пропускается), чекпоинты для рестарта.

Отдельно: выбор модели по evals на выборке из 200–500 документов (часто Haiku-класс хватает, и это ×5 дешевле), prompt caching для общей инструкции, max_tokens с запасом и мониторинг stop_reason=max_tokens. Метрики: throughput, доля ошибок, стоимость на документ, ETA. Первая итерация — на 1% данных, чтобы найти сюрпризы до того, как потрачен весь бюджет.

Чем ретраи LLM-вызовов отличаются от ретраев обычного HTTP?

Три отличия. Стоимость: каждая попытка оплачивается по токенам, а упавший на середине стрим уже стоил денег. Недетерминизм: повтор даст другой ответ, поэтому ретрай после частично показанного пользователю ответа — UX-проблема. Лимиты: ретраи с длинным контекстом съедают ITPM и усугубляют 429, поэтому нужен экспоненциальный backoff с full jitter, уважение retry-after, общий бюджет ретраев (например, не больше 10% от трафика) и отключение встроенных ретраев SDK, если сверху свой слой. Плюс «тихие» ошибки — 200 OK с невалидным JSON или обрезанный по max_tokens ответ; их иногда тоже ретраят, но это уже логика валидации, не транспорта.

Как вы измеряете и оптимизируете латентность LLM-фичи?

Разделяю на TTFT и скорость генерации. TTFT зависит от длины входа (prefill), загрузки провайдера, reasoning перед ответом; помогают prompt caching, сокращение контекста, более быстрая модель, меньший effort. Время генерации ≈ выходные токены / скорость; помогают более короткие ответы (инструкция, max_tokens, структурированный формат), более быстрая модель. Архитектурно — стриминг, параллелизация независимых шагов, спекулятивные запросы, кэш. Замеряю p50/p95/p99 отдельно по TTFT и total, в разрезе модели и длины входа. На собесе важно сказать, что снижение латентности почти всегда покупается качеством или деньгами, и это решение проверяется evals.

Как бы вы тестировали фичу на LLM, если ответы недетерминированы?

Слоями. Детерминированный код вокруг модели (парсинг, валидация, tool-хендлеры, ретраи) — обычные unit-тесты с фейковым клиентом. Контракт формата — structured outputs и проверка схемы, это можно сделать строгим. Качество — eval-набор из реальных примеров с метриками по срезам: code-based проверки где возможно, LLM-as-a-judge с рубрикой и калибровкой на ручной разметке где нет. Сравнение версий — на одном наборе, парно, с доверительными интервалами, порог по нижней границе. В CI — быстрый smoke-набор на каждый PR, полный — на изменения промптов и моделей и по расписанию (провайдер может обновить модель). В проде — онлайн-сигналы: фидбек, доля провалов валидации, выборочная ручная проверка.

Провайдер лёг на 20 минут. Что видит пользователь вашего продукта?

Хороший ответ описывает деградацию, а не «всё упадёт». Circuit breaker по доле 5xx/529/таймаутов на модель открывается за секунды, дальше fallback-цепочка: другая модель того же провайдера (часто перегрузка затрагивает одну модель), другой провайдер или регион через gateway, затем деградированный режим: кэшированные ответы, не-LLM путь (поиск без генерации), честное сообщение и очередь на асинхронную обработку. Обязательно: fallback-модели прогнаны через те же evals, промпты адаптированы, есть алерт и дашборд. Стоит упомянуть, что мультипровайдерность стоит дорого в поддержке: разные форматы tool use, разное поведение, двойной набор evals. Для многих продуктов достаточно двух моделей одного провайдера плюс деградированного режима.

Чем AI engineer отличается от ML engineer и от бэкендера, который вызывает API?

ML engineer обучает и обслуживает модели: данные, обучение, инференс-инфраструктура. AI engineer строит продукт поверх готовых моделей: контекст, промпты, retrieval, агенты, evals, стоимость, надёжность. От бэкендера, который «дёргает API», отличается ответственностью за качество: умеет измерить, что система работает, найти, почему плохо, и улучшить итеративно, с цифрами. Правильный акцент на собесе: «моя работа — качество × стоимость × латентность под ограничения продукта».

Нужен ли очередь/воркер для LLM-фичи или хватит синхронного HTTP?

Зависит от длительности и интерактивности. Чат с ответом за 5–20 с — синхронно со стримингом. Задачи дольше минуты (агент, большой документ, генерация отчёта) — асинхронно: очередь, воркеры, статус в БД, уведомление или polling, потому что HTTP-таймауты балансировщиков, деплои и разрывы сети убьют длинный запрос. Бонус асинхронности — сглаживание пиков под rate limit и возможность Batch API. Цена — сложность UX и идемпотентность, которая в LLM-мире важнее: повтор стоит денег и даёт другой результат.