Что Go-разработчик уже умеет и как это переносится
Что Java-разработчик уже умеет и как это переносится
Процентов шестьдесят работы прикладного AI engineer — это обычный бэкенд: конкурентность, ретраи, лимиты, наблюдаемость, хранилища, тесты. Разберём, что из вашего GoJava-опыта переносится один в один, что меняет смысл (тесты превращаются в evals, логи — в учёт токенов) и что придётся учить с нуля.
Чем 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-система |
|---|---|---|
| Детерминизм | Один вход → один выход | Один вход → распределение выходов |
| Латентность p50 | 1–50 мс | 1–20 с (с reasoning — до минут) |
| Стоимость запроса | Доли цента на CPU | От 0.01 до 1 $ и выше, сильно зависит от длины контекста |
| Ошибки | Исключения, коды | Коды (429, 529, 5xx) плюс «тихо плохой ответ» с 200 OK |
| Тестирование | Unit/integration, assert | Evals: набор примеров, метрики, статистика |
| Изменение поведения | Деплой кода | Правка промпта, смена модели, обновление модели провайдером |
| Узкое место | CPU, БД, сеть | Rate limit провайдера (запросы и токены в минуту), бюджет |
Сервис делает 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/fan-in. Суммаризировать 500 документов, классифицировать 10 000 тикетов, прогнать eval-набор. Каждый элемент независим, результат собирается в срезмассив по индексу.
- Parallelization-паттерн из статьи Anthropic «Building effective agents». Sectioning: разные аспекты одной задачи (проверка на безопасность и основной ответ) параллельно. Voting: один и тот же вопрос N раз, берём большинство — дешёвый способ поднять надёжность классификации.
- Параллельные tool calls. Модель может вернуть несколько блоков
tool_useв одном ответе. Их выполняют конкурентно и возвращают всеtool_resultодним user-сообщением. - Hedging. Если запрос не ответил за p95, запустить второй и взять тот, что придёт первым. Работает против тяжёлых хвостов латентности, но удваивает стоимость хвоста.
Что меняется по сравнению с обычным fan-out на HTTP-сервисы:
- Лимит конкурентности задаёт не ваш CPU, а rate limit провайдера. У Anthropic три лимита на организацию и модель: RPM (запросы в минуту), ITPM (входные токены в минуту) и OTPM (выходные токены в минуту). Выбить ITPM проще, чем RPM: 50 параллельных запросов по 30K токенов контекста — это 1.5M входных токенов за несколько секунд.
- Длинные хвосты латентности. Время ответа пропорционально длине вывода, а длину вывода вы контролируете лишь частично. При fan-out из N вызовов общее время определяет самый медленный — классический «tail at scale».
- Частичный успех часто лучше полного провала. errgroup по умолчанию отменяет всё при первой ошибке.
StructuredTaskScope.ShutdownOnFailureи самописный fail-fast поверхCompletableFutureотменяют всё при первой ошибке. Для батча из 500 документов это неправильно: лучше собрать 497 результатов и 3 ошибки, чем потерять уже оплаченные 497 ответов.
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
- Если лимит только по числу вызовов, а результат нужен целиком, проще
pool.invokeAll(tasks)поверх того же семафора.Semaphoreумеет и веса:sem.acquire(n)резервирует «слоты» пропорционально ожидаемым токенам — отдельная библиотека не нужна. - Не ограничивайте конкурентность размером пула платформенных потоков (
newFixedThreadPool(8)): лимит — это бизнес-правило про rate limit провайдера, и он должен быть явным семафором, а не побочным эффектом настройки пула. - Fail-fast подходит для ответа пользователю (без одной части ответ бессмысленен). Для батчей и evals нужна семантика «собрать всё»: задача ловит исключение и кладёт его в
Result(например, sealed-иерархияOk | Failed), а не бросает наружу. StructuredTaskScopeвыражает то же короче, но в Java 21 это preview API (--enable-preview), и его интерфейс менялся между версиями. В проде на 21 LTS обычно хватает executor на virtual threads.- Внешний таймаут в 2 минуты при 500 документах и K=8 гарантированно сработает. Бюджет считают как ⌈N/K⌉ × p95 латентности или уходят в Batch API (−50% к цене, асинхронно, до 24 ч).
- SDK Anthropic по умолчанию сам ретраит 2 раза (408/409/429/5xx). Если сверху свой ретрай или Resilience4j
Retry— либо отключите встроенный (AnthropicOkHttpClient.builder().fromEnv().maxRetries(0)), либо помните, что попыток будет перемножено.
- Если лимит только по числу вызовов, проще
g.SetLimit(n)у errgroup:g.Goсам заблокируется. Weighted-семафор нужен, когда вес вызова разный — например, резервировать «слоты» пропорционально ожидаемым токенам. - Fail-fast подходит для ответа пользователю (без одной части ответ бессмысленен). Для батчей и evals нужна семантика «собрать всё»: горутина возвращает
nil, а ошибку кладёт вresults[i].Err. - Внешний таймаут в 2 минуты при 500 документах и K=8 гарантированно сработает. Бюджет считают как ⌈N/K⌉ × p95 латентности или уходят в Batch API (−50% к цене, асинхронно, до 24 ч).
- SDK Anthropic по умолчанию сам ретраит 2 раза (408/409/429/5xx). Если сверху свой ретрай — либо отключите встроенный (
option.WithMaxRetries(0)), либо помните, что попыток будет перемножено.
Стриминг и отмена через 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
}
- Ретрай после частичного ответа. Пользователь уже видел половину текста. Перезапуск даст другой текст. Варианты: показать ошибку и кнопку «повторить», либо продолжить генерацию, передав уже полученный текст в контекст (дороже, и шов бывает заметен).
- Буферизующие прокси. Nginx, некоторые ingress и CDN буферизуют ответ, и стрим приходит одним куском. Лечится
X-Accel-Buffering: noи настройкой прокси. - Idle timeout балансировщика. Длинный reasoning может молчать десятки секунд (провайдер шлёт
ping, но ваш прокси между сервисом и браузером — нет). Шлите свои keep-alive комментарии: ping\n\n. - stop_reason: max_tokens. Ответ обрезан. Для JSON это невалидный документ. Логируйте stop_reason всегда.
Ретраи и rate limit
Вызов LLM API падает заметно чаще, чем ваш внутренний сервис. Что ретраить, а что нет:
| Код | Что значит | Что делать |
|---|---|---|
| 400 | Невалидный запрос: лишний параметр, слишком длинный контекст, неподдерживаемое поле для модели | Не ретраить. Это баг или неучтённая длина входа |
| 401 / 403 | Ключ, права | Не ретраить, алертить |
| 408, 409 | Таймаут, конфликт | Ретрай с backoff |
| 429 | Rate limit (RPM, ITPM или OTPM) | Ретрай, уважая заголовок retry-after; снизить конкурентность |
| 500, 502, 503, 504 | Ошибка провайдера | Ретрай с backoff, при серии — circuit breaker и fallback |
| 529 | Anthropic: 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-контексте отличаются от привычных:
- Считать нужно правильные ошибки. 400 из-за вашего бага не должна открывать breaker. 429 — спорно: это не отказ провайдера, а ваш лимит; правильнее снизить конкурентность, а не отрубать всё. Открывать стоит на 5xx/529 и таймауты.
- Порог по доле, а не подряд. При 200 RPS «5 ошибок подряд» случается от шума. Используйте долю ошибок в скользящем окне с минимальным числом запросов.
- В Resilience4j это три настройки:
failureRateThreshold,slidingWindowSizeиminimumNumberOfCalls. Какие исключения считать отказом, задаётrecordException(e -> …): 5xx/529 и таймауты — да,BadRequestExceptionиRateLimitExceptionиз SDK — нет. Медленные вызовы ловитslowCallDurationThreshold. - Таймаут вызова — важный сигнал. Если p99 обычно 20 с, а сейчас запросы висят по 90 с, провайдер деградирует, хоть и отвечает 200.
- Отдельный breaker на модель и регион. Перегрузка одной модели не значит, что недоступна другая.
- Fallback меняет качество. Промпт, отлаженный под одну модель, на другой может дать другой формат. Fallback-модель должна проходить те же evals, иначе вы узнаете о проблеме в момент инцидента.
Observability → токены, стоимость, TTFT
Логи, метрики, трейсы остаются. Меняется набор сигналов. Классический RED (rate, errors, duration) нужен, но недостаточен: запрос может вернуть 200 за 3 секунды и быть бесполезным. Минимальный набор атрибутов на каждый LLM-вызов:
| Сигнал | Зачем | Откуда |
|---|---|---|
| model, версия промпта | Сравнение качества и стоимости до/после изменений | Ваш код; версию промпта храните как хеш или тег |
| input / output tokens | Стоимость, прогноз упора в ITPM/OTPM | usage в ответе |
| cache read / write tokens | Работает ли prompt caching; одна правка system prompt может обнулить hit rate | usage.cache_read_input_tokens, cache_creation_input_tokens |
| cost, $ | Бюджеты по фичам и клиентам, аномалии | Вычисляется из токенов и прайса |
| TTFT, total latency, output tokens/s | UX стриминга; TTFT растёт с длиной контекста | Замер в клиенте |
| stop_reason | max_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, сквозные проекты).
- Логирование промптов и ответов целиком. Это ПДн пользователей, секреты из документов, мегабайты на запрос. Нужны политики: семплирование, редактирование, отдельное хранилище с ограниченным доступом и TTL.
- Средняя стоимость врёт. Распределение с тяжёлым хвостом: 1% агентных сессий может давать 30% счёта. Смотрите p99 стоимости на сессию и алертите на аномалии.
- Высокая кардинальность. user_id и prompt_hash в лейблах Prometheus убьют его так же, как в любом другом сервисе. Детализация — в трейсы и логи, в метрики — агрегаты.
БД, очереди, кэши → векторные БД и семантический кэш
Ваш опыт с PostgreSQL, индексами и Redis напрямую нужен в RAG (retrieval-augmented generation — поиск релевантных документов и подстановка их в контекст модели). Новые сущности:
- Embedding — вектор из сотен–тысяч чисел, который модель-эмбеддер ставит в соответствие тексту так, что близкие по смыслу тексты близки по косинусному расстоянию. Это «ключ» для поиска по смыслу.
- Векторный индекс — приближённый поиск ближайших соседей (ANN), чаще всего HNSW. Для Java-разработчика самое естественное —
pgvectorв том же Postgres: транзакции, JOIN с метаданными, привычные бэкапы, Flyway-миграции. Из кода — обычный JDBC или jOOQ с типомPGvectorизpgvector-java, либо готовыйPgVectorStoreиз Spring AI (илиPgVectorEmbeddingStoreиз LangChain4j); JPA для векторного поиска неудобна — запросы с<=>всё равно пишутся нативным SQL. Интеграционные тесты — Testcontainers с образомpgvector/pgvector. Отдельные векторные БД (Qdrant, Weaviate, Milvus и др.) имеют смысл на десятках-сотнях миллионов векторов или при специфичных требованиях. Подробно — модуль 5. - Векторный индекс — приближённый поиск ближайших соседей (ANN), чаще всего HNSW. Для Go-разработчика самое естественное —
pgvectorв том же Postgres: транзакции, JOIN с метаданными, привычные бэкапы. Отдельные векторные БД (Qdrant, Weaviate, Milvus и др.) имеют смысл на десятках-сотнях миллионов векторов или при специфичных требованиях. Подробно — модуль 5. - Гибридный поиск. Векторный поиск плохо находит точные идентификаторы, коды ошибок, артикулы. Его комбинируют с полнотекстовым (BM25 /
tsvector) — тоже знакомая территория.
Кэширование раздваивается:
| Вид кэша | Ключ | Что экономит | Риск |
|---|---|---|---|
| Точный (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 |
| Особенности API | Content blocks, stop reasons, tool use, structured outputs, prompt caching, thinking с параметром effort. Знание конкретного API — то, что отличает «пробовал» от «делал» | 2 |
| Prompt engineering | Это не магия, а спецификация для недетерминированного исполнителя. Версионирование, регрессия, prompt injection | 3 |
| Python-экосистема | Evals, анализ данных, эксперименты живут там | 4 |
| Retrieval | Чанкинг, эмбеддинги, reranking, метрики поиска (recall@k, MRR) | 5 |
| Агенты | Цикл «модель → инструмент → модель», когда агент не нужен, MCP | 6 |
| Безопасность | Модель недетерминирована и читает недоверенный текст: инъекции, excessive agency, утечки через вывод. Классический AppSec нужен, но его недостаточно | 7 |
| Evals | Датасеты, LLM-as-a-judge и его калибровка, статистика | 8 |
| Основы ML | Precision/recall, переобучение, когда fine-tuning оправдан (редко) | 10 |
| Данные и governance | Парсинг документов, PII, версии датасетов, разметка, логи диалогов, EU AI Act и 152-ФЗ — то, на чём держатся качество и комплаенс | 11 |
| Senior-практики | ADR, design review, SLO для недетерминированной системы, постмортемы, разговор о метриках с бизнесом | 13 |
В 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-мире важнее: повтор стоит денег и даёт другой результат.
Источники
- Anthropic — Building effective agents (паттерны, включая parallelization)
- Anthropic API — Rate limits (RPM / ITPM / OTPM, retry-after)
- Anthropic API — Errors (коды, overloaded_error 529)
- Anthropic API — Streaming Messages (типы SSE-событий)
- anthropic-sdk-go — официальный Go SDK
- errgroup, semaphore, x/time/rate
- anthropic-sdk-java — официальный Java SDK (
com.anthropic:anthropic-java) - JEP 444 — Virtual Threads, JEP 453 — Structured Concurrency (Preview), Bucket4j
- Spring AI, LangChain4j, Testcontainers for Java
- AWS Architecture Blog — Exponential Backoff And Jitter
- Martin Fowler — CircuitBreaker; sony/gobreakerResilience4j CircuitBreaker
- Google SRE Book — Handling Overload (retry budgets)
- Dean, Barroso — The Tail at Scale (hedged requests)
- OpenTelemetry — Semantic conventions for GenAI
- Hamel Husain — Your AI Product Needs Evals
- pgvector и pgvector-java