Модуль 2

LLM API

Как устроен разговор с моделью по HTTP: messages и роли, стриминг SSE, структурированный вывод, tool use, prompt caching, batch, выбор модели и ошибки. Всё с кодом на GoJava, который не стыдно отнести в прод, и с цифрами, которые спросят на собесе.

≈ 10 часовGoJava + Python4 визуализациицены на сентябрь 2026

Messages API и stateless

Почти все современные LLM API сводятся к одной операции: «вот история диалога, продолжи её». У Anthropic это POST https://api.anthropic.com/v1/messages. Запрос содержит:

Каждое сообщение — это не строка, а список content blocks: text, image, document, tool_use, tool_result, thinking. Ответ модели — тоже список блоков плюс stop_reason и usage (сколько токенов на входе, выходе, прочитано и записано в кэш). Эту структуру нужно держать в голове: почти все баги интеграции — это «я думал, что там строка, а там блоки».

API не помнит ничего

Messages API stateless: сервер не хранит диалог между вызовами. Хочешь третью реплику — отправляешь все предыдущие заново. Отсюда следствия, которые определяют архитектуру любого чата:

Аналогия из JavaЭто как stateless REST-контроллер без HttpSession: всё состояние приходит в запросе или лежит в вашей БД. Модель — чистая функция f(system, tools, messages) → message (с поправкой на недетерминизм). Никаких «соединений с памятью», как у JDBC-Connection с открытой транзакцией.
Аналогия из GoЭто как HTTP-хэндлер без сессий: всё состояние приходит в запросе или лежит в вашей БД. Модель — чистая функция f(system, tools, messages) → message (с поправкой на недетерминизм). Никаких «соединений с памятью», как у database/sql транзакции.
// Минимальный чат: история на клиенте, каждый ход — полный список сообщений.
package chat;

import com.anthropic.client.AnthropicClient;
import com.anthropic.client.okhttp.AnthropicOkHttpClient;
import com.anthropic.models.messages.Message;
import com.anthropic.models.messages.MessageCreateParams;
import com.anthropic.models.messages.MessageParam;
import com.anthropic.models.messages.StopReason;
import com.anthropic.models.messages.TextBlock;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

public final class Session {
    private final AnthropicClient client = AnthropicOkHttpClient.fromEnv(); // ключ из ANTHROPIC_API_KEY
    private final String system;
    private final List<MessageParam> history = new ArrayList<>(); // в проде — в БД, по session_id

    public Session(String system) {
        this.system = system;
    }

    public String ask(String userText) {
        history.add(MessageParam.builder().role(MessageParam.Role.USER).content(userText).build());
        Message resp;
        try {
            resp = client.messages().create(MessageCreateParams.builder()
                    .model("claude-opus-5")
                    .maxTokens(16000)
                    .system(system)
                    .messages(history)
                    .build());
        } catch (RuntimeException e) {
            history.removeLast(); // не оставляем «висящий» user-ход
            throw e;
        }
        history.add(resp.toParam()); // ответ целиком, со всеми блоками

        String text = resp.content().stream()
                .flatMap(b -> b.text().stream())
                .map(TextBlock::text)
                .collect(Collectors.joining());
        if (resp.stopReason().map(StopReason.MAX_TOKENS::equals).orElse(false)) {
            throw new IllegalStateException("ответ обрезан по max_tokens: " + text.length() + " символов");
        }
        return text;
    }
}
// Минимальный чат: история на клиенте, каждый ход — полный список сообщений.
package chat

import (
	"context"
	"fmt"
	"strings"

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

type Session struct {
	client  anthropic.Client
	system  string
	history []anthropic.MessageParam // в проде — в БД, по session_id
}

func NewSession(system string) *Session {
	return &Session{client: anthropic.NewClient(), system: system} // ключ из ANTHROPIC_API_KEY
}

func (s *Session) Ask(ctx context.Context, userText string) (string, error) {
	s.history = append(s.history, anthropic.NewUserMessage(anthropic.NewTextBlock(userText)))
	resp, err := s.client.Messages.New(ctx, anthropic.MessageNewParams{
		Model:     "claude-opus-5",
		MaxTokens: 16000,
		System:    []anthropic.TextBlockParam{{Text: s.system}},
		Messages:  s.history,
	})
	if err != nil {
		s.history = s.history[:len(s.history)-1] // не оставляем «висящий» user-ход
		return "", fmt.Errorf("messages.new: %w", err)
	}
	s.history = append(s.history, resp.ToParam()) // ответ целиком, со всеми блоками

	var b strings.Builder
	for _, block := range resp.Content {
		if t, ok := block.AsAny().(anthropic.TextBlock); ok {
			b.WriteString(t.Text)
		}
	}
	if resp.StopReason == anthropic.StopReasonMaxTokens {
		return b.String(), fmt.Errorf("ответ обрезан по max_tokens")
	}
	return b.String(), nil
}
import anthropic

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

def ask(text: str) -> str:
    history.append({"role": "user", "content": text})
    resp = client.messages.create(
        model="claude-opus-5",
        max_tokens=16000,
        system="Ты — ассистент поддержки. Отвечай кратко.",
        messages=history,
    )
    history.append({"role": "assistant", "content": resp.content})
    return "".join(b.text for b in resp.content if b.type == "text")

Роли, system prompt и stop_reason

System prompt задаёт «кто ты и по каким правилам работаешь»; модели обучены придавать ему больший вес, чем user-сообщениям, но это приоритет, а не граница безопасности — об этом в модуле 3. В system кладут стабильное (роль, политика, формат), в messages — изменчивое (вопрос, документы конкретного запроса, текущая дата). Это важно и для кэша: всё динамическое в system убивает prompt caching.

stop_reasonЧто значитЧто делать клиенту
end_turnМодель закончилаОтдать ответ
max_tokensУпёрлись в потолокНе парсить как готовый JSON; поднять лимит или попросить продолжить
tool_useМодель просит вызвать инструментыВыполнить, вернуть tool_result, продолжить цикл
stop_sequenceВстретилась ваша стоп-последовательностьСмотреть stop_sequence
pause_turnСерверный инструмент (web search и т.п.) поставил ход на паузуОтправить ответ обратно как есть, чтобы модель продолжила
refusalМодель отказалась по политике безопасностиНе исполнять недописанные tool_use; показать пользователю корректное сообщение
Изменения в новых моделях (на момент написания, сентябрь 2026)

Стриминг SSE

Без стриминга пользователь смотрит на спиннер, пока модель не допишет весь ответ: при 80 токенах/с ответ на 800 токенов — это 10 секунд тишины. Со стримингом первые слова появляются через TTFT (time to first token — время до первого токена, обычно сотни миллисекунд – пара секунд; с thinking — дольше, потому что сначала идёт рассуждение). Воспринимаемая скорость определяется TTFT, а не полным временем.

При "stream": true ответ приходит как Server-Sent Events: текстовый поток text/event-stream, где каждое событие — строки event: … и data: {json}, разделённые пустой строкой. Последовательность событий у Anthropic:

event: message_start
data: {"type":"message_start","message":{"id":"msg_01…","role":"assistant","content":[],"usage":{"input_tokens":412,"output_tokens":1}}}

event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}

event: ping
data: {"type":"ping"}

event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Привет"}}

event: content_block_stop
data: {"type":"content_block_stop","index":0}

event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":57}}

event: message_stop
data: {"type":"message_stop"}

Поток SSE-событий в реальном времени

Нажмите «Запустить»: события приходят по одному, справа собирается текст. Отметка TTFT — первый text_delta. Попробуйте оборвать соединение или отменить запрос на середине.

сырые события
что видит пользователь
Ожидание запуска.

Отмена и обрыв соединения

Стриминг через Go SDK

Стриминг через Java SDK

// streamAnswer пишет текст в out по мере генерации, возвращает собранное сообщение.
public static Message streamAnswer(AnthropicClient client, String prompt, Writer out) throws IOException {
    long start = System.nanoTime();
    long ttft = 0;

    var params = MessageCreateParams.builder()
            .model("claude-opus-5")
            .maxTokens(64000)
            .addUserMessage(prompt)
            .build();
    var acc = MessageAccumulator.create(); // собирает итоговое Message из событий стрима

    try (StreamResponse<RawMessageStreamEvent> stream = client.messages().createStreaming(params)) {
        Iterator<RawMessageStreamEvent> it = stream.stream().iterator();
        while (it.hasNext()) { // сюда же прилетают event: error и обрыв — исключением из it.next()
            RawMessageStreamEvent ev = it.next();
            acc.accumulate(ev);
            Optional<String> text = ev.contentBlockDelta()
                    .flatMap(d -> d.delta().text())
                    .map(TextDelta::text);
            if (text.isPresent()) {
                if (ttft == 0) {
                    ttft = System.nanoTime() - start;
                }
                out.write(text.get()); // IOException: клиент ушёл — выходим, try закроет стрим
                out.flush();
            }
        }
    }
    Message msg = acc.message();
    log.atInfo().setMessage("llm stream done")
            .addKeyValue("ttft_ms", ttft / 1_000_000)
            .addKeyValue("total_ms", (System.nanoTime() - start) / 1_000_000)
            .addKeyValue("stop_reason", msg.stopReason().orElse(null))
            .addKeyValue("out_tokens", msg.usage().outputTokens())
            .log();
    return msg;
}
// StreamAnswer пишет текст в w по мере генерации, возвращает собранное сообщение.
func StreamAnswer(ctx context.Context, client anthropic.Client, prompt string, w io.Writer) (anthropic.Message, error) {
	start := time.Now()
	var ttft time.Duration

	stream := client.Messages.NewStreaming(ctx, anthropic.MessageNewParams{
		Model:     "claude-opus-5",
		MaxTokens: 64000,
		Messages:  []anthropic.MessageParam{anthropic.NewUserMessage(anthropic.NewTextBlock(prompt))},
	})
	defer stream.Close()

	msg := anthropic.Message{} // у Go-стрима нет GetFinalMessage — аккумулируем сами
	for stream.Next() {
		ev := stream.Current()
		if err := msg.Accumulate(ev); err != nil {
			return msg, fmt.Errorf("accumulate: %w", 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)
				}
				if _, err := io.WriteString(w, t.Text); err != nil {
					return msg, err // клиент ушёл — выходим, defer закроет стрим
				}
				if f, ok := w.(http.Flusher); ok {
					f.Flush()
				}
			}
		}
	}
	if err := stream.Err(); err != nil { // сюда же попадает event: error и обрыв
		return msg, fmt.Errorf("stream: %w", err)
	}
	slog.InfoContext(ctx, "llm stream done",
		"ttft_ms", ttft.Milliseconds(), "total_ms", time.Since(start).Milliseconds(),
		"stop_reason", msg.StopReason, "out_tokens", msg.Usage.OutputTokens)
	return msg, nil
}
import time, anthropic

client = anthropic.Anthropic()

def stream_answer(prompt: str) -> anthropic.types.Message:
    start, ttft = time.monotonic(), None
    with client.messages.stream(
        model="claude-opus-5",
        max_tokens=64000,
        messages=[{"role": "user", "content": prompt}],
    ) as stream:
        for text in stream.text_stream:
            if ttft is None:
                ttft = time.monotonic() - start
            print(text, end="", flush=True)
        msg = stream.get_final_message()
    print(f"\nTTFT={ttft:.2f}s stop={msg.stop_reason} out={msg.usage.output_tokens}")
    return msg

Учебный разбор SSE руками: net/http + bufio

Учебный разбор SSE руками: java.net.http + Jackson

В проде берите SDK. Но один раз написать парсер стоит: станет понятно, что SSE — это просто строки, почему статус 200 ещё ничего не гарантирует и как отличить нормальное завершение от обрыва.

В проде берите SDK. Но один раз написать парсер стоит: станет понятно, что SSE — это просто строки, почему нужен большой буфер у bufio.Scanner и как отличить нормальное завершение от обрыва.

// Учебный SSE-клиент для /v1/messages. В проде используйте SDK.
private static final HttpClient HTTP = HttpClient.newHttpClient();
private static final ObjectMapper JSON = new ObjectMapper();

public static String rawStream(String apiKey, String prompt, Consumer<String> onText)
        throws IOException, InterruptedException {
    byte[] body = JSON.writeValueAsBytes(Map.of(
            "model", "claude-opus-5", "max_tokens", 4096, "stream", true,
            "messages", List.of(Map.of("role", "user", "content", prompt))));
    HttpRequest req = HttpRequest.newBuilder(URI.create("https://api.anthropic.com/v1/messages"))
            .header("x-api-key", apiKey)
            .header("anthropic-version", "2023-06-01")
            .header("content-type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofByteArray(body))
            .build(); // без timeout(): длину стрима ограничивает вызывающий (interrupt / cancel)

    HttpResponse<Stream<String>> resp = HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
    try (Stream<String> lines = resp.body()) { // close() рвёт соединение — так и отменяем стрим
        if (resp.statusCode() != 200) {
            String b = lines.limit(100).collect(Collectors.joining("\n"));
            throw new IOException("status %d (retry-after=%s): %s".formatted(resp.statusCode(),
                    resp.headers().firstValue("retry-after").orElse(""), b));
        }
        String event = "";
        String stopReason = null;
        boolean gotStop = false;
        for (Iterator<String> it = lines.iterator(); it.hasNext(); ) {
            String line = it.next(); // строка data: может быть длинной — ofLines её не режет
            if (line.startsWith("event: ")) {
                event = line.substring(7);
            } else if (line.startsWith("data: ")) {
                JsonNode d = JSON.readTree(line.substring(6)); // битый JSON → JsonProcessingException
                switch (event) {
                    case "content_block_delta" -> {
                        if ("text_delta".equals(d.path("delta").path("type").asText())) {
                            onText.accept(d.path("delta").path("text").asText());
                        }
                    }
                    case "message_delta" -> stopReason = d.path("delta").path("stop_reason").asText(null);
                    case "message_stop" -> gotStop = true;
                    case "error" -> throw new IOException("stream error %s: %s".formatted(
                            d.path("error").path("type").asText(), d.path("error").path("message").asText()));
                    default -> { } // ping, content_block_start/stop и т.п.
                }
            }
        }
        if (!gotStop) { // поток кончился без message_stop — ответ неполный
            throw new EOFException("stream ended without message_stop, stop_reason=" + stopReason);
        }
        return stopReason;
    }
}
// Учебный SSE-клиент для /v1/messages. В проде используйте SDK.
func RawStream(ctx context.Context, apiKey, prompt string, onText func(string)) (stopReason string, err error) {
	body, _ := json.Marshal(map[string]any{
		"model": "claude-opus-5", "max_tokens": 4096, "stream": true,
		"messages": []map[string]any{{"role": "user", "content": prompt}},
	})
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.anthropic.com/v1/messages", bytes.NewReader(body))
	if err != nil {
		return "", err
	}
	req.Header.Set("x-api-key", apiKey)
	req.Header.Set("anthropic-version", "2023-06-01")
	req.Header.Set("content-type", "application/json")

	resp, err := http.DefaultClient.Do(req) // без Timeout: время ограничивает ctx
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
		return "", fmt.Errorf("status %d (retry-after=%q): %s", resp.StatusCode, resp.Header.Get("retry-after"), b)
	}

	sc := bufio.NewScanner(resp.Body)
	sc.Buffer(make([]byte, 64*1024), 4*1024*1024) // строка data: может быть длинной
	var event string
	gotStop := false
	for sc.Scan() {
		line := sc.Text()
		switch {
		case strings.HasPrefix(line, "event: "):
			event = strings.TrimPrefix(line, "event: ")
		case strings.HasPrefix(line, "data: "):
			var d struct {
				Delta struct {
					Type       string `json:"type"`
					Text       string `json:"text"`
					StopReason string `json:"stop_reason"`
				} `json:"delta"`
				Error struct {
					Type    string `json:"type"`
					Message string `json:"message"`
				} `json:"error"`
			}
			if err := json.Unmarshal([]byte(line[6:]), &d); err != nil {
				return "", fmt.Errorf("bad data for %s: %w", event, err)
			}
			switch event {
			case "content_block_delta":
				if d.Delta.Type == "text_delta" {
					onText(d.Delta.Text)
				}
			case "message_delta":
				stopReason = d.Delta.StopReason
			case "message_stop":
				gotStop = true
			case "error":
				return stopReason, fmt.Errorf("stream error %s: %s", d.Error.Type, d.Error.Message)
			}
		}
	}
	if err := sc.Err(); err != nil {
		return stopReason, err // включая context.Canceled
	}
	if !gotStop {
		return stopReason, io.ErrUnexpectedEOF // поток кончился без message_stop — ответ неполный
	}
	return stopReason, nil
}
Метрики стриминга, которые стоит писатьTTFT, общее время, выходные токены/сек (output_tokens / (total − ttft)), stop_reason, число оборванных стримов. TTFT растёт с длиной входа (prefill — обработка промпта) и падает при попадании в prompt cache: это одна из главных причин кэшировать.

Structured output

LLM отдаёт текст, а вашему коду нужна структура. Эволюция подходов, от худшего к лучшему:

  1. «Ответь в JSON» в промпте + парсинг. Работает в 95–99% случаев, и эти 1–5% — markdown-обёртка ```json, пояснение перед JSON, лишняя запятая, обрезка по max_tokens.
  2. Prefill (начать ответ за модель с {). На новых моделях Claude запрещён — 400.
  3. Tool use как экстрактор: описать инструмент record_ticket со схемой и попросить модель его вызвать. Аргументы — JSON по схеме.
  4. Structured outputs — constrained decoding: сервер ограничивает генерацию так, что невалидный по схеме JSON просто не может быть сгенерирован. У Anthropic: output_config: {format: {type: "json_schema", schema: {…}}} для ответа и strict: true у инструмента для аргументов tool_use.

Нюансы structured outputs, которые отличают сеньора:

Паттерн «валидация + повтор с ошибкой»: если ответ не прошёл бизнес-валидацию, добавьте ответ модели в историю и следующим user-сообщением отдайте текст ошибки. Модель чинит конкретную проблему лучше, чем при слепом повторе. Лимит — 2–3 попытки, дальше ошибка наверх и метрика.

// Ticket.java. Jackson по умолчанию падает на неизвестных полях (FAIL_ON_UNKNOWN_PROPERTIES).
public record Ticket(String category, String priority, String summary,
                     @JsonProperty("order_id") String orderId) {

    static final Map<String, Object> SCHEMA = Map.of(
            "type", "object",
            "properties", Map.of(
                    "category", Map.of("type", "string", "enum", List.of("billing", "bug", "delivery", "other")),
                    "priority", Map.of("type", "string", "enum", List.of("p1", "p2", "p3")),
                    "summary", Map.of("type", "string"),
                    "order_id", Map.of("type", "string")),
            "required", List.of("category", "priority", "summary", "order_id"),
            "additionalProperties", false);

    private static final Pattern ORDER = Pattern.compile("^(ORD-\\d{6})?$");

    /** То, что схема выразить не может (maxLength, формат id, бизнес-правила). */
    void validate() {
        int n = summary.codePointCount(0, summary.length());
        if (n == 0 || n > 200) {
            throw new IllegalArgumentException("summary: длина %d, нужно 1..200 символов".formatted(n));
        }
        if (!ORDER.matcher(orderId).matches()) {
            throw new IllegalArgumentException("order_id \"%s\": ожидается ORD-NNNNNN или пустая строка".formatted(orderId));
        }
    }
}

// TicketExtractor.java
static Ticket extractTicket(AnthropicClient c, String text) {
    var params = MessageCreateParams.builder()
            .model("claude-sonnet-5")
            .maxTokens(1024)
            .addUserMessage("Разбери обращение клиента в тикет.\n<message>\n" + text + "\n</message>")
            // output_config через escape hatch тела запроса: работает независимо от версии SDK
            .putAdditionalBodyProperty("output_config", JsonValue.from(Map.of(
                    "format", Map.of("type", "json_schema", "schema", Ticket.SCHEMA))));
    Exception lastErr = null;
    for (int attempt = 0; attempt < 3; attempt++) {
        // сетевые ошибки и 429 ретраит слой ниже, здесь — только логика
        Message resp = c.messages().create(params.build());
        StopReason sr = resp.stopReason().orElseThrow();
        if (sr.equals(StopReason.MAX_TOKENS) || sr.equals(StopReason.REFUSAL)) {
            throw new IllegalStateException("no valid output: stop_reason=" + sr);
        }
        String raw = resp.content().stream()
                .flatMap(b -> b.text().stream())
                .map(TextBlock::text)
                .collect(Collectors.joining());
        try {
            Ticket t = JSON.readValue(raw, Ticket.class);
            t.validate();
            return t;
        } catch (JsonProcessingException | IllegalArgumentException e) {
            lastErr = e;
        }
        // Повтор с конкретной ошибкой: модель видит свой ответ и что в нём не так.
        params.addMessage(resp.toParam())
              .addUserMessage("Ответ не прошёл валидацию: " + lastErr.getMessage()
                      + ". Верни исправленный JSON целиком.");
    }
    throw new IllegalStateException("ticket invalid after retries", lastErr);
}
type Ticket struct {
	Category string `json:"category"`
	Priority string `json:"priority"`
	Summary  string `json:"summary"`
	OrderID  string `json:"order_id"`
}

var ticketSchema = map[string]any{
	"type": "object",
	"properties": map[string]any{
		"category": map[string]any{"type": "string", "enum": []string{"billing", "bug", "delivery", "other"}},
		"priority": map[string]any{"type": "string", "enum": []string{"p1", "p2", "p3"}},
		"summary":  map[string]any{"type": "string"},
		"order_id": map[string]any{"type": "string"},
	},
	"required":             []string{"category", "priority", "summary", "order_id"},
	"additionalProperties": false,
}

var orderRe = regexp.MustCompile(`^(ORD-\d{6})?$`)

// Validate — то, что схема выразить не может (maxLength, формат id, бизнес-правила).
func (t Ticket) Validate() error {
	if n := utf8.RuneCountInString(t.Summary); n == 0 || n > 200 {
		return fmt.Errorf("summary: длина %d, нужно 1..200 символов", n)
	}
	if !orderRe.MatchString(t.OrderID) {
		return fmt.Errorf("order_id %q: ожидается ORD-NNNNNN или пустая строка", t.OrderID)
	}
	return nil
}

func ExtractTicket(ctx context.Context, c anthropic.Client, text string) (Ticket, error) {
	msgs := []anthropic.MessageParam{anthropic.NewUserMessage(anthropic.NewTextBlock(
		"Разбери обращение клиента в тикет.\n<message>\n" + text + "\n</message>"))}
	var lastErr error
	for attempt := 0; attempt < 3; attempt++ {
		resp, err := c.Messages.New(ctx, anthropic.MessageNewParams{
			Model: "claude-sonnet-5", MaxTokens: 1024, Messages: msgs,
			OutputConfig: anthropic.OutputConfigParam{
				Format: anthropic.JSONOutputFormatParam{Schema: ticketSchema},
			},
		})
		if err != nil {
			return Ticket{}, err // сетевые/429 ретраит слой ниже, здесь — только логика
		}
		switch resp.StopReason {
		case anthropic.StopReasonMaxTokens, anthropic.StopReasonRefusal:
			return Ticket{}, fmt.Errorf("no valid output: stop_reason=%s", resp.StopReason)
		}
		var raw strings.Builder
		for _, b := range resp.Content {
			if t, ok := b.AsAny().(anthropic.TextBlock); ok {
				raw.WriteString(t.Text)
			}
		}
		var t Ticket
		if lastErr = json.Unmarshal([]byte(raw.String()), &t); lastErr == nil {
			if lastErr = t.Validate(); lastErr == nil {
				return t, nil
			}
		}
		// Повтор с конкретной ошибкой: модель видит свой ответ и что в нём не так.
		msgs = append(msgs, resp.ToParam(), anthropic.NewUserMessage(anthropic.NewTextBlock(
			"Ответ не прошёл валидацию: "+lastErr.Error()+". Верни исправленный JSON целиком.")))
	}
	return Ticket{}, fmt.Errorf("ticket invalid after retries: %w", lastErr)
}
from typing import Literal
from pydantic import BaseModel, Field
import anthropic

class Ticket(BaseModel):
    category: Literal["billing", "bug", "delivery", "other"]
    priority: Literal["p1", "p2", "p3"]
    summary: str = Field(max_length=200)        # SDK проверит на клиенте
    order_id: str = Field(pattern=r"^(ORD-\d{6})?$")

client = anthropic.Anthropic()

resp = client.messages.parse(
    model="claude-sonnet-5",
    max_tokens=1024,
    messages=[{"role": "user", "content": f"Разбери обращение в тикет.\n<message>\n{text}\n</message>"}],
    output_format=Ticket,
)
ticket: Ticket = resp.parsed_output  # уже провалидированный объект
Аналогия из JavaStructured outputs — это как Jackson с включённым FAIL_ON_UNKNOWN_PROPERTIES плюс enum-типы на стороне генератора: синтаксис и форма гарантированы, а validate() (или Bean Validation с @Size/@Pattern) на доменном record всё равно нужен — ровно как после десериализации @RequestBody входящего HTTP-запроса.
Аналогия из GoStructured outputs — это как json.Decoder.DisallowUnknownFields() плюс enum-типы на стороне генератора: синтаксис и форма гарантированы, а Validate() на доменной структуре всё равно нужен — ровно как после json.Unmarshal входящего HTTP-запроса.

Tool use

Tool use (он же function calling) — модель не выполняет код, она просит вас выполнить функцию. Вы передаёте в tools описания: имя, текстовое описание, JSON Schema входа. Модель отвечает блоками tool_use (id, name, input) и stop_reason: "tool_use". Вы исполняете, кладёте результаты в user-сообщение блоками tool_result с тем же tool_use_id и снова зовёте API. Цикл продолжается до end_turn.

Цикл tool calling по шагам

Клиент ↔ модель ↔ инструменты. Запрос «Погода в Берлине и Париже и есть ли билеты на завтра?» — три параллельных вызова, один из них падает по таймауту, модель переспрашивает инструмент с другими параметрами.

Проектирование описаний инструментов

Модель выбирает инструмент и заполняет аргументы только по описанию. Описание инструмента — это промпт, и к нему применимы все правила промпт-инжиниринга:

Tool loop с параллельным выполнением на errgroup

Tool loop с параллельным выполнением на virtual threads

Готовые циклы есть в tool runner Python/TS SDK и во фреймворках (LangChain4j AiServices, Spring AI ChatClient с tools): они крутят цикл сами. Ручной цикл нужен, когда хотите контроль: параллельность, таймауты по инструменту, аудит, human-in-the-loop. Вот он:

В Go SDK есть бета toolrunner (client.Beta.Messages.NewToolRunner), который крутит цикл сам. Ручной цикл нужен, когда хотите контроль: параллельность, таймауты по инструменту, аудит, human-in-the-loop. Вот он:

@FunctionalInterface
public interface ToolFunc {
    // Реализация обязана уважать timeout: HttpRequest.timeout, JDBC queryTimeout и т.п.
    String call(JsonValue input, Duration timeout) throws Exception;
}

public static String runTools(AnthropicClient c, List<ToolUnion> tools, Map<String, ToolFunc> impl,
                              List<MessageParam> msgs) {
    var params = MessageCreateParams.builder()
            .model("claude-opus-5").maxTokens(16000).tools(tools).messages(msgs);

    for (int turn = 0; turn < 10; turn++) { // лимит итераций — защита от зацикливания
        Message resp = c.messages().create(params.build());
        params.addMessage(resp.toParam()); // ответ модели — в историю ДО tool_result

        if (!resp.stopReason().map(StopReason.TOOL_USE::equals).orElse(false)) {
            return resp.content().stream()
                    .flatMap(b -> b.text().stream())
                    .map(TextBlock::text)
                    .collect(Collectors.joining());
        }

        List<ToolUseBlock> calls = resp.content().stream().flatMap(b -> b.toolUse().stream()).toList();
        var limit = new Semaphore(4); // не больше 4 инструментов одновременно
        List<Future<ContentBlockParam>> futures;
        try (var exec = Executors.newVirtualThreadPerTaskExecutor()) { // close() ждёт все задачи
            futures = calls.stream().map(call -> exec.submit(() -> {
                limit.acquire();
                try {
                    return runOne(impl, call); // никогда не бросает: ошибка — в tool_result
                } finally {
                    limit.release();
                }
            })).toList();
        }
        // Порядок = порядок вызовов, а не порядок завершения.
        List<ContentBlockParam> results = futures.stream().map(Future::resultNow).toList();
        params.addUserMessageOfBlockParams(results); // ВСЕ результаты одним сообщением
    }
    throw new IllegalStateException("tool loop: превышен лимит итераций");
}

static ContentBlockParam runOne(Map<String, ToolFunc> impl, ToolUseBlock call) {
    ToolFunc fn = impl.get(call.name());
    if (fn == null) {
        return toolResult(call.id(), "error: неизвестный инструмент " + call.name(), true);
    }
    try {
        String out = fn.call(call._input(), Duration.ofSeconds(15));
        if (out.length() > 20_000) { // ponytail: обрезка по символам; лучше — суммаризация/пагинация в самом инструменте
            out = out.substring(0, 20_000) + "\n…[обрезано]";
        }
        return toolResult(call.id(), out, false);
    } catch (Exception e) { // ошибка инструмента — данные для модели, а не падение цикла
        return toolResult(call.id(), "error: " + e.getMessage(), true);
    }
}

static ContentBlockParam toolResult(String toolUseId, String content, boolean isError) {
    return ContentBlockParam.ofToolResult(ToolResultBlockParam.builder()
            .toolUseId(toolUseId).content(content).isError(isError).build());
}
type ToolFunc func(ctx context.Context, input json.RawMessage) (string, error)

func RunTools(ctx context.Context, c anthropic.Client, tools []anthropic.ToolUnionParam,
	impl map[string]ToolFunc, msgs []anthropic.MessageParam) (string, error) {

	for turn := 0; turn < 10; turn++ { // лимит итераций — защита от зацикливания
		resp, err := c.Messages.New(ctx, anthropic.MessageNewParams{
			Model: "claude-opus-5", MaxTokens: 16000, Tools: tools, Messages: msgs,
		})
		if err != nil {
			return "", err
		}
		msgs = append(msgs, resp.ToParam()) // ответ модели — в историю ДО tool_result

		if resp.StopReason != anthropic.StopReasonToolUse {
			var sb strings.Builder
			for _, b := range resp.Content {
				if t, ok := b.AsAny().(anthropic.TextBlock); ok {
					sb.WriteString(t.Text)
				}
			}
			return sb.String(), nil
		}

		var calls []anthropic.ToolUseBlock
		for _, b := range resp.Content {
			if tu, ok := b.AsAny().(anthropic.ToolUseBlock); ok {
				calls = append(calls, tu)
			}
		}
		results := make([]anthropic.ContentBlockParamUnion, len(calls)) // порядок = порядок вызовов
		g, gctx := errgroup.WithContext(ctx)
		g.SetLimit(4) // не больше 4 инструментов одновременно
		for i, call := range calls {
			g.Go(func() error {
				out, err := runOne(gctx, impl, call)
				if err != nil { // ошибка инструмента — данные для модели, а не падение цикла
					results[i] = anthropic.NewToolResultBlock(call.ID, "error: "+err.Error(), true)
					return nil
				}
				results[i] = anthropic.NewToolResultBlock(call.ID, out, false)
				return nil
			})
		}
		_ = g.Wait() // горутины всегда возвращают nil; отмену ctx поймает следующий Messages.New
		msgs = append(msgs, anthropic.NewUserMessage(results...)) // ВСЕ результаты одним сообщением
	}
	return "", errors.New("tool loop: превышен лимит итераций")
}

func runOne(ctx context.Context, impl map[string]ToolFunc, call anthropic.ToolUseBlock) (string, error) {
	fn, ok := impl[call.Name]
	if !ok {
		return "", fmt.Errorf("неизвестный инструмент %q", call.Name)
	}
	ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
	defer cancel()
	out, err := fn(ctx, json.RawMessage(call.JSON.Input.Raw()))
	if len(out) > 20_000 { // ponytail: обрезка по байтам; лучше — суммаризация/пагинация в самом инструменте
		out = out[:20_000] + "\n…[обрезано]"
	}
	return out, err
}
import asyncio, json, anthropic

client = anthropic.AsyncAnthropic()

async def run_tools(tools, impl, messages):
    for _ in range(10):
        resp = await client.messages.create(
            model="claude-opus-5", max_tokens=16000, tools=tools, messages=messages)
        messages.append({"role": "assistant", "content": resp.content})
        if resp.stop_reason != "tool_use":
            return "".join(b.text for b in resp.content if b.type == "text")

        calls = [b for b in resp.content if b.type == "tool_use"]

        async def one(call):
            try:
                out = await asyncio.wait_for(impl[call.name](**call.input), timeout=15)
                return {"type": "tool_result", "tool_use_id": call.id, "content": str(out)}
            except Exception as e:  # ошибка — модели, а не наверх
                return {"type": "tool_result", "tool_use_id": call.id,
                        "content": f"error: {e}", "is_error": True}

        results = await asyncio.gather(*(one(c) for c in calls))
        messages.append({"role": "user", "content": list(results)})
    raise RuntimeError("tool loop: превышен лимит итераций")
Почему не fail-fast и ошибки не бросаемStructuredTaskScope.ShutdownOnFailure (preview в Java 21) или CompletableFuture.allOf с отменой при первой ошибке — правильно для «всё или ничего», но не для инструментов: таймаут погоды не должен убивать поиск билетов. Поэтому runOne ловит исключение и кладёт его в tool_result с is_error. От executor остаются виртуальный поток на вызов, Semaphore как лимит параллельности и close(), который ждёт все задачи. Порядок результатов задаёт список Future, поэтому общая коллекция с синхронизацией не нужна.
Почему errgroup, а ошибки не возвращаемerrgroup.WithContext отменяет контекст при первой ошибке — это правильно для «всё или ничего», но не для инструментов: таймаут погоды не должен убивать поиск билетов. Поэтому горутины возвращают nil, а ошибку кладут в tool_result с is_error. От errgroup остаются SetLimit (семафор) и аккуратный Wait. Запись в results[i] по индексу без мьютекса безопасна: каждая горутина пишет в свою ячейку.

Prompt caching

Большая часть входа повторяется между запросами: одни и те же tools, system prompt, документы, растущая история диалога. Prompt caching позволяет не обрабатывать этот префикс заново: сервер сохраняет вычисленное состояние (KV-cache из модуля 1) и при следующем запросе с тем же префиксом читает его.

Визуализатор prompt caching

Запрос — это последовательность блоков. Флажки между блоками — breakpoints (клик по флажку включает/выключает). «Изменить» имитирует правку блока относительно прошлого запроса: всё после неё перезаписывается. Зелёное — чтение кэша, оранжевое — запись, серое — обычный input.

Типичные инвалидаторы

Что в кодеПочему ломает кэшКак чинить
time.Now()Instant.now() / дата в system promptПрефикс меняется каждый запросДату — в конец, в user-сообщение
Request ID, UUID в начале контентаКаждый запрос уникаленУбрать или перенести после последнего breakpoint
Сериализация map в JSON с недетерминированным порядкомБайты префикса различаютсяВ Go encoding/json сортирует ключи map — но ваш собственный рендер через range по map нетJackson пишет HashMap в порядке итерации: включите ORDER_MAP_ENTRIES_BY_KEYS или берите TreeMap/record; свой рендер через forEach по HashMap тоже недетерминирован
Набор tools зависит от пользователя/режимаTools в позиции 0 — не кэшируется ничегоСтабильный набор, отсортированный по имени; режим передавать в messages
Условные секции в system (if flag { system += … })Каждая комбинация флагов — отдельный префиксМеньше вариантов или флаги в messages
Смена модели, thinking/effort между запросамиКэш привязан к модели; thinking/effort инвалидируют кэш сообщенийФиксировать настройки на маршрут
Редактирование старой истории (удалили tool_result на 5-м ходу)Всё после правки — промахAppend-only история, компакция реже и крупными шагами
Message resp = client.messages().create(MessageCreateParams.builder()
        .model("claude-opus-5")
        .maxTokens(4096)
        .tools(tools) // стабильный набор, отсортирован по имени
        .systemOfTextBlockParams(List.of(TextBlockParam.builder()
                .text(policyAndCatalog) // 20K токенов, не меняется
                .cacheControl(CacheControlEphemeral.builder().build()) // breakpoint #1: tools + system
                .build()))
        .messages(history) // на последнем блоке истории — breakpoint #2
        .build());
Usage u = resp.usage();
log.info("cache read={} write={} uncached={}",
        u.cacheReadInputTokens().orElse(0L), u.cacheCreationInputTokens().orElse(0L), u.inputTokens());
resp, err := client.Messages.New(ctx, anthropic.MessageNewParams{
	Model:     "claude-opus-5",
	MaxTokens: 4096,
	Tools:     tools, // стабильный набор, отсортирован по имени
	System: []anthropic.TextBlockParam{{
		Text:         policyAndCatalog, // 20K токенов, не меняется
		CacheControl: anthropic.NewCacheControlEphemeralParam(), // breakpoint #1: tools + system
	}},
	Messages: history, // на последнем блоке истории — breakpoint #2 (или top-level CacheControl)
})
if err != nil {
	return err
}
u := resp.Usage
slog.Info("cache", "read", u.CacheReadInputTokens, "write", u.CacheCreationInputTokens, "uncached", u.InputTokens)
resp = client.messages.create(
    model="claude-opus-5",
    max_tokens=4096,
    tools=tools,
    system=[{"type": "text", "text": policy_and_catalog,
             "cache_control": {"type": "ephemeral"}}],   # или {"type": "ephemeral", "ttl": "1h"}
    messages=history,
)
u = resp.usage
print(u.cache_read_input_tokens, u.cache_creation_input_tokens, u.input_tokens)
Режим отказаКэш изолирован по workspace (на Claude API) и никогда не шарится между организациями. Трафик одного промпта, размазанный по двум workspace, пишет два разных кэша. А на запрос из батча попадание в кэш — best effort: запросы батча обрабатываются в произвольном порядке и времени.

Batch API

Message Batches (/v1/messages/batches) — асинхронная обработка пачки запросов со скидкой 50%. Вы отправляете до десятков тысяч запросов, каждый с вашим custom_id; обработка занимает до 24 часов (обычно заметно быстрее); результаты приходят в произвольном порядке — матчите по custom_id, не по индексу.

import anthropic
from anthropic.types.message_create_params import MessageCreateParamsNonStreaming
from anthropic.types.messages.batch_create_params import Request

client = anthropic.Anthropic()
batch = client.messages.batches.create(requests=[
    Request(custom_id=f"review-{r.id}-p7",   # id записи + версия промпта
            params=MessageCreateParamsNonStreaming(
                model="claude-haiku-4-5", max_tokens=512,
                system=CLASSIFY_PROMPT,
                messages=[{"role": "user", "content": r.text}]))
    for r in reviews
])
# ... позже, когда batch.processing_status == "ended":
for res in client.messages.batches.results(batch.id):
    if res.result.type == "succeeded":
        save(res.custom_id, res.result.message)
    else:
        retry_queue.append(res.custom_id)       # errored / expired — отправить повторно

Выбор модели и routing

Выбор модели — это компромисс трёх осей: качество на вашей задаче (измеряется evals, модуль 8), цена и латентность (TTFT и токены/с). Цены Anthropic на момент написания (сентябрь 2026), $ за 1M токенов:

МодельIDInputOutputКонтекстКогда
Claude Fable 5.1claude-fable-5-110501MСамые сложные задачи: длинные агентные сессии, тяжёлый код, где ошибка дороже токенов
Claude Opus 5.5claude-opus-5-54201MФлагманское качество дешевле Opus 5
Claude Opus 5claude-opus-55251MСильный дефолт для агентов и сложных рассуждений
Claude Sonnet 5claude-sonnet-52101MРабочая лошадка: большинство продуктовых фич
Claude Haiku 4.5claude-haiku-4-515200KКлассификация, роутинг, извлечение, высокие объёмы, низкая латентность
Обновление на октябрь 2026После написания курса в линейке появились Claude Sonnet 5.5 (claude-sonnet-5-5, $2 / $10) и Claude Haiku 5.5 (claude-haiku-5-5, $0.10 / $0.50 для промптов до 100K токенов, дороже — для более длинных). Чтение кэша у Opus 5.5 и Sonnet 5.5 стоит 0.05× цены input, у Fable 5.1 — 0.025×. Для Haiku 4.5 объявлена ориентировочная дата вывода из эксплуатации — не раньше 15 октября 2026. Примеры и калькуляторы курса считают по таблице выше. Перед реальными решениями сверяйтесь с прайсом и графиком вывода моделей. Смена модели — это релиз с прогоном evals (модуль 8), а не правка строки в конфиге.

Output в 5 раз дороже input — поэтому экономия чаще всего в длине ответа (формат, лимиты, structured output без «воды»), а не в укорачивании промпта. Ещё один рычаг — effort: на тяжёлой модели low может быть дешевле и быстрее, чем средняя модель на high.

Каскады и routing

Калькулятор: модель × кэш × batch × routing

Месячная стоимость (30 дней) при ваших объёмах. Промахи кэша считаются как запись (1.25×). Роутинг отправляет долю трафика на Haiku 4.5, остальное — на выбранную модель.

Мультимодальность

Claude принимает изображения (image-блоки: base64, URL или file_id из Files API) и PDF (document-блоки). PDF обрабатывается и как текст, и как картинки страниц — поэтому модель видит таблицы, графики и сканы, но и платите вы за оба представления.

byte[] pdf = Files.readAllBytes(Path.of("invoice.pdf"));
MessageParam msg = MessageParam.builder()
        .role(MessageParam.Role.USER)
        .contentOfBlockParams(List.of(
                ContentBlockParam.ofDocument(DocumentBlockParam.builder()
                        .source(Base64PdfSource.builder()
                                .data(Base64.getEncoder().encodeToString(pdf))
                                .build())
                        .build()),
                ContentBlockParam.ofText(TextBlockParam.builder()
                        .text("Извлеки номер счёта, дату и итоговую сумму.")
                        .build())))
        .build();
// Картинки — аналогично ImageBlockParam (base64/URL); для повторного использования — Files API.
pdf, err := os.ReadFile("invoice.pdf")
if err != nil {
	return err
}
msg := anthropic.NewUserMessage(
	anthropic.NewDocumentBlock(anthropic.Base64PDFSourceParam{Data: base64.StdEncoding.EncodeToString(pdf)}),
	anthropic.NewTextBlock("Извлеки номер счёта, дату и итоговую сумму."),
)
// Картинки — аналогично image-блоком (base64/URL); для повторного использования — Files API.

Vision как задача (режимы отказа, VLM против OCR, evals) и голосовые агенты (STT → LLM → TTS против speech-to-speech, бюджет латентности, перебивание) — в факультативном модуле 16; парсинг PDF и таблиц для индексации — в модуле 11.

Сравнение с OpenAI API

Концепции одни и те же, отличаются формы. На собесе часто спрашивают «чем отличается», и хороший ответ — про модели данных и управление состоянием, а не про синтаксис. Детали API меняются — сверяйтесь с актуальной документацией.

АспектAnthropic MessagesOpenAI Chat CompletionsOpenAI Responses
SystemОтдельное поле systemСообщение с ролью system/developer в спискеПоле instructions или элемент input
СостояниеStateless, история на клиентеStatelessОпционально на сервере: previous_response_id, store
ОтветСписок content blockschoices[].messageСписок output items
Tool callБлок tool_use, input — объектtool_calls[], arguments — JSON-строкаItem function_call
Результат инструментаБлок tool_result в user-сообщенииСообщение с ролью tool и tool_call_idItem function_call_output
Structured outputoutput_config.format, strict у toolsresponse_format: json_schema + stricttext.format
СтримингИменованные события (event:) по блокамАнонимные data:-чанки, конец — data: [DONE]Типизированные семантические события
Prompt cachingЯвные breakpoints, запись дороже, чтение ≈0.1× (Opus 5.5 — 0.05×, Fable 5.1 — 0.025×)Автоматический по префиксу, скидка на кэшированный input, без платы за запись
BatchАсинхронно, до 24 ч, −50% у обоих провайдеров (на момент написания)
Что сказать на собесеГлавная концептуальная разница — где живёт состояние. Stateless API проще масштабировать и аудировать, всё под вашим контролем, но вы платите за историю и сами её обрезаете. Серверное состояние (Responses) экономит трафик и код, но это vendor lock-in и вопросы к хранению данных. Для абстракции над провайдерами (LLM-gateway) внутренний формат обычно ближе к блокам Anthropic/items Responses, потому что плоская строка не выражает tool calls и мультимодальность.

Ошибки, ретраи, лимиты

КодТипРетраить?Что это
400invalid_request_errorНетНевалидный запрос: нет чередования ролей, prefill/temperature на новой модели, схема вне поддерживаемого подмножества
401 / 403authentication_error / permission_errorНетКлюч, права
404not_found_errorНетОпечатка в ID модели — самая частая
413request_too_largeНетСлишком большой запрос
429rate_limit_errorДа, с учётом retry-afterПревышены RPM / input TPM / output TPM вашей организации
500api_errorДа, backoffСбой на стороне API
529overloaded_errorДа, backoff; думать о fallbackAPI перегружен целиком — это не ваш лимит, а общая нагрузка
// Клиент без встроенных ретраев — политику задаём сами.
static final AnthropicClient CLIENT = AnthropicOkHttpClient.builder().fromEnv().maxRetries(0).build();

public static <T> T withRetry(int attempts, Duration budget, Callable<T> fn) throws Exception {
    Instant deadline = Instant.now().plus(budget); // общий бюджет времени на все попытки
    for (int n = 0; ; n++) {
        try {
            return fn.call();
        } catch (Exception e) {
            Optional<Duration> delay = classify(e, n);
            if (delay.isEmpty() || n + 1 >= attempts || Instant.now().plus(delay.get()).isAfter(deadline)) {
                throw e;
            }
            log.warn("llm retry attempt={} delay={}", n + 1, delay.get(), e);
            Thread.sleep(delay.get()); // в виртуальном потоке дёшево; interrupt = отмена
        }
    }
}

/** Пустой Optional — не ретраить; иначе — сколько ждать. */
static Optional<Duration> classify(Exception e, int n) {
    long backoff = Math.min(30_000, 500L << n);
    Duration delay = Duration.ofMillis(ThreadLocalRandom.current().nextLong(backoff) + 100); // full jitter

    if (e instanceof AnthropicServiceException api) {
        int s = api.statusCode();
        if (s == 408 || s == 409 || s == 429 || s >= 500) { // 529 тоже здесь
            return Optional.of(retryAfter(api).orElse(delay)); // сервер знает лучше
        }
        return Optional.empty(); // 400/401/403/404/413 — повтор не поможет
    }
    if (e instanceof AnthropicIoException || e instanceof IOException) {
        return Optional.of(delay); // сеть, обрыв стрима
    }
    return Optional.empty(); // InterruptedException и прочее — не ретраим
}

static Optional<Duration> retryAfter(AnthropicServiceException e) {
    try {
        return e.headers().values("retry-after").stream().findFirst()
                .map(v -> Duration.ofSeconds(Long.parseLong(v.trim())));
    } catch (NumberFormatException x) {
        return Optional.empty();
    }
}

// Использование:
// Message resp = withRetry(4, Duration.ofSeconds(60), () -> CLIENT.messages().create(params));
// Клиент без встроенных ретраев — политику задаём сами.
var client = anthropic.NewClient(option.WithMaxRetries(0))

func WithRetry[T any](ctx context.Context, attempts int, fn func(context.Context) (T, error)) (T, error) {
	var zero T
	for n := 0; ; n++ {
		v, err := fn(ctx)
		if err == nil {
			return v, nil
		}
		delay, retryable := classify(err, n)
		if !retryable || n+1 >= attempts {
			return zero, err
		}
		slog.WarnContext(ctx, "llm retry", "attempt", n+1, "delay", delay, "err", err)
		t := time.NewTimer(delay)
		select {
		case <-t.C:
		case <-ctx.Done():
			t.Stop()
			return zero, errors.Join(err, ctx.Err())
		}
	}
}

func classify(err error, n int) (time.Duration, bool) {
	backoff := min(30*time.Second, 500*time.Millisecond<<n)
	delay := rand.N(backoff) + 100*time.Millisecond // full jitter

	var apierr *anthropic.Error
	if errors.As(err, &apierr) {
		switch s := apierr.StatusCode; {
		case s == 408, s == 409, s == 429, s == 529, s >= 500:
			if apierr.Response != nil {
				if ra, err := strconv.Atoi(apierr.Response.Header.Get("retry-after")); err == nil {
					delay = time.Duration(ra) * time.Second // сервер знает лучше
				}
			}
			return delay, true
		default:
			return 0, false // 400/401/403/404/413 — повтор не поможет
		}
	}
	if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
		return 0, false
	}
	var ne net.Error
	if errors.As(err, &ne) || errors.Is(err, io.ErrUnexpectedEOF) {
		return delay, true // сеть, обрыв стрима
	}
	return 0, false
}

// Использование:
// resp, err := WithRetry(ctx, 4, func(ctx context.Context) (*anthropic.Message, error) {
//     return client.Messages.New(ctx, params)
// })
import anthropic

# Обычно достаточно встроенных ретраев: backoff и retry-after SDK учитывает сам.
client = anthropic.Anthropic(max_retries=4, timeout=120.0)

try:
    resp = client.messages.create(model="claude-opus-5", max_tokens=1024,
                                  messages=[{"role": "user", "content": "…"}])
except anthropic.RateLimitError as e:        # 429 после всех повторов
    log.warning("rate limited, retry-after=%s", e.response.headers.get("retry-after"))
    raise
except anthropic.APIStatusError as e:
    if e.status_code == 529 or e.type == "overloaded_error":
        resp = call_fallback_model()           # затяжная перегрузка — другая модель
    else:
        raise

Практика

Задание 1: стриминговый чат-сервис на GoJava

HTTP-сервис POST /chat/{session} (Spring Boot, SseEmitter или WebFlux), который отдаёт ответ Claude через SSE вашему фронту. История — в Postgres или SQLite.

Задание 2: tool loop с параллельными инструментами

Агент с 3 инструментами (например, get_order, get_weather, search_docs), цикл на errgroupна virtual threads из модуля.

Задание 3: извлечение с валидацией и расчёт экономики

Извлечение сущностей из 200 текстов (отзывы, письма) в структуру через structured outputs + Validate()validate() + повтор с ошибкой. Прогоните один раз синхронно на Sonnet 5, один раз через Batch API на Haiku 4.5.

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

Почему 30-й ход диалога стоит дороже первого, и что с этим делать?

API stateless: на каждом ходу отправляется вся история, и вы платите за неё как за input. Суммарные входные токены растут квадратично по числу ходов. Лечится prompt caching (история читается за 0.1×), компакцией/суммаризацией старой части, обрезкой больших tool_result и выносом больших документов в кэшируемый префикс.

Поток закончился, текст выглядит законченным, но message_stop не пришёл. Что делать?

Считать ответ неполным: нет гарантии, что это конец, и нет stop_reason/финального usage. Возобновить стрим нельзя, prefill на новых моделях запрещён — повторить запрос целиком (с ретрай-политикой), частичный ответ пометить как оборванный.

Модель вернула три tool_use. Как правильно вернуть результаты?

Выполнить конкурентно, собрать три tool_result с соответствующими tool_use_id и отправить их одним user-сообщением сразу после assistant-сообщения с этими tool_use. Упавший инструмент — is_error: true с понятным текстом, а не исключение на весь цикл.

Structured outputs гарантируют валидный JSON. Зачем тогда валидация на клиенте?

Гарантирована форма, а не смысл: значения могут быть неправильными, бизнес-правила (формат id, диапазоны, существование в БД) схема не выражает — minLength, minimum и т.п. не поддерживаются. Плюс при max_tokens и refusal гарантия не действует.

В system prompt добавили строку «Текущее время: …». Что сломалось?

Prompt caching: system стоит в начале префикса, а время меняется каждый запрос — байты префикса не совпадают, и всё после tools перестаёт кэшироваться. Время надо перенести в user-сообщение (после последнего breakpoint) или округлить до дня, если это допустимо.

Когда 1-часовой TTL кэша выгоднее 5-минутного?

Запись 2× против 1.25×. 1h выгоден, когда запросы с тем же префиксом приходят реже раза в 5 минут, но чаще раза в час (иначе 5-минутный кэш каждый раз протухает и вы платите запись снова), например редкий трафик внутреннего инструмента или агент, который думает дольше 5 минут между ходами.

Чем 429 отличается от 529 с точки зрения реакции системы?

429 — вы превысили свои лимиты: сглаживать трафик, соблюдать retry-after, клиентский rate limiter, приоритезация. 529 — провайдер перегружен целиком: backoff помогает на коротких всплесках, при затяжных — fallback на другую модель или провайдера, деградация функциональности.

Результаты batch пришли в другом порядке. Как их сопоставить и как повторить упавшие?

По custom_id, который вы задали для каждого запроса. Делайте его детерминированным (id записи + версия промпта): тогда повторная отправка только упавших (errored/expired) идемпотентна и легко дедуплицируется.

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

Спроектируйте слой интеграции с LLM API для продукта с 50 RPS. Что в нём будет?

Тонкая обёртка над SDK со следующими ответственностями: (1) единая модель запроса/ответа с блоками, версией промпта и маршрутом; (2) ретраи с jitter и учётом retry-after, встроенные ретраи SDK отключены; бюджет по времени через contextна всю операцию; circuit breaker на провайдера; (3) клиентский rate limiter по RPM и TPM (токены оценивать заранее), очередь с приоритетами для фоновых задач; (4) fallback-цепочка моделей/провайдеров с отдельными evals; (5) prompt caching: стабильный префикс, мониторинг hit rate; (6) observability: трейс на вызов с моделью, версией промпта, токенами (input/output/cache), стоимостью, TTFT, stop_reason; (7) стриминг до клиента с отменой по contextпри разрыве соединения; (8) лимиты стоимости на пользователя/тенанта. Оценка: 50 RPS × 3K input токенов — это 9M input токенов в минуту, надо сразу смотреть на TPM-лимиты и кэш.

Как бы вы реализовали tool use с 3 параллельными вызовами, где один — медленный внешний API?

Конкурентное выполнение (errgroup с SetLimitвиртуальный поток на вызов + Semaphore), таймаут на каждый инструмент, общий дедлайн цикла. Упавший или просроченный инструмент возвращает tool_result с is_error: true и подсказкой — модель решит, что делать; остальные результаты не теряются. Все результаты — одним user-сообщением. Если медленный API частый — кэш результатов, асинхронный инструмент (запустил задачу, вернул id, отдельный инструмент для статуса) или предзагрузка. Побочные эффекты — с ключом идемпотентности, потому что модель может повторить вызов.

У вас стоимость LLM выросла втрое за месяц без роста трафика. Как будете искать причину?

Разложить стоимость по измерениям из телеметрии: маршрут/фича, модель, версия промпта, input vs output vs cache write vs cache read. Типичные причины: (1) упал cache hit rate — кто-то добавил динамику в system или поменял набор tools (сравнить payload двух соседних запросов); (2) выросла длина ответа — новая модель/промпт стали многословнее, или thinking/effort подняли; (3) растущие истории без компакции; (4) ретраи/зацикленные агенты — число вызовов на пользовательский запрос; (5) переключение fallback на дорогую модель. Лечить с метриками «стоимость на успешную задачу», а не на запрос.

JSON mode, tool use как экстрактор, structured outputs — что и когда выбрать?

Structured outputs (constrained decoding по схеме) — дефолт для извлечения данных: гарантирует синтаксис и соответствие схеме, не нужен парсер-чинильщик. Strict tools — когда модель должна выбирать действия и аргументы должны быть валидны. «Просто попросить JSON» — только для прототипа или провайдеров без поддержки, с устойчивым парсером и повтором. В любом варианте — бизнес-валидация, обработка max_tokens/refusal и повтор с текстом ошибки. Минусы constrained decoding: ограниченное подмножество JSON Schema, компиляция схемы на первом запросе.

Объясните prompt caching так, чтобы стало понятно, почему порядок блоков важен.

Сервер кэширует вычисленное внутреннее состояние модели (KV-cache) для префикса запроса. Префикс определён порядком tools → system → messages, ключ — точные байты до breakpoint'а. Трансформер обрабатывает токены слева направо, и состояние каждого зависит от всех предыдущих — поэтому изменение в середине делает невалидным всё справа, а изменение в конце — ничего. Значит: стабильное в начало (tools, политика, большие документы), изменчивое в конец. Экономика: чтение 0.1×, запись 1.25× (5 мин) или 2× (1 ч); плюс сильное снижение TTFT на длинных промптах. Мониторить cache_read_input_tokens.

Когда Batch API — плохая идея, даже если скидка 50%?

Когда нужен ответ пользователю в интерактиве; когда шаги зависят друг от друга (цепочка из 5 батчей = до 5 суток в худшем случае); когда SLA на обработку короче 24 часов и нет запасного синхронного пути; когда объём маленький и сложность оркестрации (опрос статуса, частичные сбои, повторы) дороже экономии. Хорошее применение — ночные переобработки, evals, разметка, обогащение каталога.

Стримим ответ пользователю, а посреди стрима пришёл overloaded_error. Как это обработать в продукте?

HTTP-статус уже 200, поэтому ошибка приходит событием error — его надо обрабатывать явно. Частичный ответ нельзя «докачать»: повторить запрос целиком с backoff, возможно на fallback-модели; в UI либо заменить частичный текст новым (с индикацией «перегенерируем»), либо показать ошибку с кнопкой повтора. Логировать как оборванный стрим, учитывать сгенерированные токены в стоимости. Если частые обрывы — смотреть на прокси/таймауты и на долю 529 по модели.

Как выбрать модель для новой фичи, не угадывая?

Собрать eval-набор 50–200 примеров с критериями, прогнать 2–3 модели (и 2–3 уровня effort) и построить фронт Парето: качество vs стоимость на задачу vs p95 латентности. Выбрать самую дешёвую, проходящую порог качества с запасом. Рассмотреть каскад (дешёвая модель + проверка + эскалация) и роутинг по сегментам. Пересматривать при выходе новых моделей — evals делают это часовой задачей, а не проектом.