LLM API
Как устроен разговор с моделью по HTTP: messages и роли, стриминг SSE, структурированный вывод, tool use, prompt caching, batch, выбор модели и ошибки. Всё с кодом на GoJava, который не стыдно отнести в прод, и с цифрами, которые спросят на собесе.
Messages API и stateless
Почти все современные LLM API сводятся к одной операции: «вот история диалога, продолжи её». У Anthropic это POST https://api.anthropic.com/v1/messages. Запрос содержит:
model— идентификатор модели, напримерclaude-opus-5;max_tokens— жёсткий потолок на длину ответа (не «желаемая длина»: при упоре ответ обрезается и приходитstop_reason: "max_tokens");system— системный промпт: роль, правила, контекст. Отдельное поле, а не сообщение в списке;messages— массив сообщений с ролямиuserиassistant. Первое — всегдаuser, роли чередуются;- опционально
tools,tool_choice,thinking,output_config,metadata,stream.
Каждое сообщение — это не строка, а список content blocks: text, image, document, tool_use, tool_result, thinking. Ответ модели — тоже список блоков плюс stop_reason и usage (сколько токенов на входе, выходе, прочитано и записано в кэш). Эту структуру нужно держать в голове: почти все баги интеграции — это «я думал, что там строка, а там блоки».
API не помнит ничего
Messages API stateless: сервер не хранит диалог между вызовами. Хочешь третью реплику — отправляешь все предыдущие заново. Отсюда следствия, которые определяют архитектуру любого чата:
- История живёт у вас — в Postgres/Redis, с id сессии. Вы решаете, что в неё попадает, и отвечаете за её обрезку.
- Каждый ход дороже предыдущего: платите за всю историю на входе. Диалог из 30 ходов по 1 000 токенов — это не 30K, а ≈ 465K входных токенов суммарно (1+2+…+30 тыс.). Лечится prompt caching (ниже) и компакцией истории.
- Историю можно редактировать: выкинуть старые tool_result, заменить хвост суммаризацией. Но любая правка префикса ломает кэш.
- Горизонтальное масштабирование бесплатно: любой инстанс вашего сервиса может продолжить любой диалог, если достал историю из хранилища.
HttpSession: всё состояние приходит в запросе или лежит в вашей БД. Модель — чистая функция f(system, tools, messages) → message (с поправкой на недетерминизм). Никаких «соединений с памятью», как у JDBC-Connection с открытой транзакцией.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; показать пользователю корректное сообщение |
- Prefill (последнее сообщение
assistantс началом ответа, трюк «начни с{») на новых моделях Claude возвращает 400. Для формата — structured outputs. temperature/top_p/top_kна Opus 4.7+, Sonnet 5, Fable удалены (400). Глубину и стоимость рассуждения регулируютthinking: {type: "adaptive"}иoutput_config: {effort: "low|medium|high|xhigh|max"}. На других провайдерах и open-source (vLLM, Ollama) sampling-параметры по-прежнему работают.- Старый
thinking.budget_tokensна новых моделях отклоняется (400).
Стриминг 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"}
content_block_deltaбывает трёх видов:text_delta(текст),input_json_delta(кусок JSON аргументов tool_use — копить строку и парсить наcontent_block_stop),thinking_delta.message_deltaнесёт финальныеstop_reasonиusage.output_tokens— метрики стоимости берите отсюда.ping— keepalive, игнорировать.error— ошибка посреди стрима (напримерoverloaded_error) при уже отданном HTTP 200. Статус-код не спасёт, обрабатывайте событие.
Поток SSE-событий в реальном времени
Нажмите «Запустить»: события приходят по одному, справа собирается текст. Отметка TTFT — первый text_delta. Попробуйте оборвать соединение или отменить запрос на середине.
Отмена и обрыв соединения
- Отмена пользователем (закрыл вкладку, нажал «стоп») — закрываете HTTP-соединение с API: в Java это
close()уStreamResponse(try-with-resources вокруг стрима). Генерация на стороне API останавливается, но уже сгенерированные выходные токены закладывайте в стоимость. Свяжите жизненный цикл вашего ответа с вызовом LLM: в Spring MVC — колбэкиSseEmitter.onCompletion/onError/onTimeout, в WebFlux — отмена подписки наFlux. Иначе ушедший пользователь продолжает жечь деньги. - Отмена пользователем (закрыл вкладку, нажал «стоп») — закрываете HTTP-соединение, в Go это отмена
context. Генерация на стороне API останавливается, но уже сгенерированные выходные токены закладывайте в стоимость. Прокиньтеr.Context()из вашего хэндлера до клиента LLM — иначе ушедший пользователь продолжает жечь деньги. - Обрыв посреди ответа — поток закончился без
message_stop. Это неполный ответ, даже если текст «выглядит законченным». Докачать стрим с места нельзя, а prefill на новых моделях недоступен, так что стратегия одна — повторить запрос целиком (для UI — «перегенерировать»), а частичный текст пометить как оборванный. - Прокси и балансировщики: nginx по умолчанию буферизует ответ (
proxy_buffering offили заголовокX-Accel-Buffering: no), у ALB/Cloudflare есть idle timeout. Длинный thinking без токенов — ровно тот случай, когда соединение «молчит» и его убивают;pingпомогает, но проверьте таймауты по всей цепочке. - Большие
max_tokens: SDK отказываются делать нестриминговые запросы, которые могут длиться дольше ~10 минут; для топовых моделей с выводом до 128K токенов стриминг фактически обязателен.
Стриминг через 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
}
output_tokens / (total − ttft)), stop_reason, число оборванных стримов. TTFT растёт с длиной входа (prefill — обработка промпта) и падает при попадании в prompt cache: это одна из главных причин кэшировать.Structured output
LLM отдаёт текст, а вашему коду нужна структура. Эволюция подходов, от худшего к лучшему:
- «Ответь в JSON» в промпте + парсинг. Работает в 95–99% случаев, и эти 1–5% — markdown-обёртка
```json, пояснение перед JSON, лишняя запятая, обрезка по max_tokens. - Prefill (начать ответ за модель с
{). На новых моделях Claude запрещён — 400. - Tool use как экстрактор: описать инструмент
record_ticketсо схемой и попросить модель его вызвать. Аргументы — JSON по схеме. - Structured outputs — constrained decoding: сервер ограничивает генерацию так, что невалидный по схеме JSON просто не может быть сгенерирован. У Anthropic:
output_config: {format: {type: "json_schema", schema: {…}}}для ответа иstrict: trueу инструмента для аргументов tool_use.
Нюансы structured outputs, которые отличают сеньора:
- Схема ограничена: поддерживаются базовые типы,
enum,const,anyOf,$ref, форматы строк (date,email,uuid…), обязательныйadditionalProperties: false. Не поддерживаются рекурсивные схемы,minimum/maximum,minLength/maxLength. Python/TS SDK выкидывают неподдерживаемые ограничения из схемы и проверяют их на клиенте; в Go это ваша работа.в Java-примере ниже схема написана вручную, и это ваша работа. - Гарантия синтаксиса ≠ гарантия смысла. Схема обещает, что
priority— один изp1|p2|p3, но не то, что он выбран правильно. Бизнес-валидацию (сумма ≥ 0, дата не в будущем, id существует в БД) делаете сами. - Исключения: при
stop_reason: "max_tokens"JSON обрезан, при"refusal"может не соответствовать схеме. Проверяйте stop_reason до парсинга. - Первый запрос с новой схемой медленнее — схема компилируется в грамматику, дальше кэшируется (порядка суток). Не генерируйте схему динамически на каждый запрос.
- Несовместимо с prefill и citations; совместимо с batch, стримингом, thinking.
Паттерн «валидация + повтор с ошибкой»: если ответ не прошёл бизнес-валидацию, добавьте ответ модели в историю и следующим 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 # уже провалидированный объект
FAIL_ON_UNKNOWN_PROPERTIES плюс enum-типы на стороне генератора: синтаксис и форма гарантированы, а validate() (или Bean Validation с @Size/@Pattern) на доменном record всё равно нужен — ровно как после десериализации @RequestBody входящего HTTP-запроса.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_use. Выполняйте их конкурентно и возвращайте все результаты одним user-сообщением. Разнести их по нескольким сообщениям — частая ошибка, которая приучает модель вызывать по одному инструменту за ход (медленнее) или ломает валидацию. - Ошибки инструмента — это данные, а не исключения:
tool_resultсis_error: trueи понятным текстом («заказ не найден», «таймаут, попробуйте через минуту»). Модель адаптируется: переформулирует запрос, спросит пользователя. Падать всем циклом из-за одного инструмента — плохо. - Защита цикла: лимит итераций, общий дедлайн через
context(бюджет времени на весь цикл), таймаут на инструмент, лимит размера результата (100 КБ логов в tool_result — это деньги и деградация внимания). tool_choice:auto(по умолчанию),none,any/tool(принудительный вызов). На Fable 5.1 и Opus 5.5 принудительные режимы возвращают 400 — используйтеauto+ явную инструкцию в промпте +strict: true, либо structured outputs, если вызов был нужен только ради JSON.- Перед исполнением проверяйте
stop_reason: приmax_tokensаргументы могут быть обрезанным, но синтаксически валидным JSON; приrefusalне исполняйте инструменты этого хода.
Цикл tool calling по шагам
Клиент ↔ модель ↔ инструменты. Запрос «Погода в Берлине и Париже и есть ли билеты на завтра?» — три параллельных вызова, один из них падает по таймауту, модель переспрашивает инструмент с другими параметрами.
Проектирование описаний инструментов
Модель выбирает инструмент и заполняет аргументы только по описанию. Описание инструмента — это промпт, и к нему применимы все правила промпт-инжиниринга:
- Имя — глагол + объект:
search_orders, а неordersилиtool1. - Описание на 3–5 предложений: что делает, когда вызывать и когда не вызывать, что возвращает, ограничения («ищет только за последние 90 дней»).
- Описания полей с форматом и примером:
"date": "YYYY-MM-DD, по часовому поясу пользователя". Enum вместо свободной строки, где возможно. - Меньше, но крупнее: 5 хорошо различимых инструментов лучше 40 пересекающихся. Два инструмента с похожими описаниями — источник случайного выбора. При десятках инструментов — tool search или маршрутизация по подмножествам.
- Результат — для модели, а не для машины: верните компактный, читаемый текст/JSON с нужными полями, а не сырой ответ внутреннего API на 5 000 токенов. Ошибки — с подсказкой, что делать дальше.
- Идемпотентность и подтверждение: инструменты с побочными эффектами (оплата, отправка письма) — с ключом идемпотентности и, для рискованных, подтверждением человеком. Модель может повторить вызов.
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: превышен лимит итераций")
StructuredTaskScope.ShutdownOnFailure (preview в Java 21) или CompletableFuture.allOf с отменой при первой ошибке — правильно для «всё или ничего», но не для инструментов: таймаут погоды не должен убивать поиск билетов. Поэтому runOne ловит исключение и кладёт его в tool_result с is_error. От executor остаются виртуальный поток на вызов, Semaphore как лимит параллельности и close(), который ждёт все задачи. Порядок результатов задаёт список Future, поэтому общая коллекция с синхронизацией не нужна.errgroup.WithContext отменяет контекст при первой ошибке — это правильно для «всё или ничего», но не для инструментов: таймаут погоды не должен убивать поиск билетов. Поэтому горутины возвращают nil, а ошибку кладут в tool_result с is_error. От errgroup остаются SetLimit (семафор) и аккуратный Wait. Запись в results[i] по индексу без мьютекса безопасна: каждая горутина пишет в свою ячейку.Prompt caching
Большая часть входа повторяется между запросами: одни и те же tools, system prompt, документы, растущая история диалога. Prompt caching позволяет не обрабатывать этот префикс заново: сервер сохраняет вычисленное состояние (KV-cache из модуля 1) и при следующем запросе с тем же префиксом читает его.
- Это совпадение префикса байт в порядке
tools → system → messages. Любое изменение в позиции N инвалидирует всё после N. - Breakpoints:
cache_control: {type: "ephemeral"}на блоке помечает «закэшируй префикс до этого места». До 4 breakpoint'ов на запрос. Есть и автоматический вариант — top-levelcache_control, который ставит маркер на последний кэшируемый блок. - Экономика (на момент написания): чтение ≈ 0.1× цены input (у Opus 5.5 — 0.05×, у Fable 5.1 — 0.025×; по состоянию на октябрь 2026); запись 1.25× (TTL 5 минут) или 2× (TTL 1 час). Запись с 5-минутным TTL окупается уже на первом попадании: 1.25 + 0.1 < 2. TTL продлевается при каждом чтении.
- Латентность: попадание в кэш сокращает TTFT на длинных промптах в разы — prefill 100K токенов не бесплатен.
- Минимальный размер префикса зависит от модели (512–4096 токенов). Короче — кэш молча не создаётся, без ошибки:
cache_creation_input_tokens: 0. - Проверка:
usage.cache_read_input_tokensиusage.cache_creation_input_tokens. Если чтения нулевые при одинаковых запросах — где-то тихий инвалидатор.
Визуализатор 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)
Batch API
Message Batches (/v1/messages/batches) — асинхронная обработка пачки запросов со скидкой 50%. Вы отправляете до десятков тысяч запросов, каждый с вашим custom_id; обработка занимает до 24 часов (обычно заметно быстрее); результаты приходят в произвольном порядке — матчите по custom_id, не по индексу.
- Когда: всё, где пользователь не ждёт — ночная разметка, прогон evals, обогащение каталога, генерация эмбеддинг-описаний, переобработка архива после смены промпта.
- Когда нет: интерактив, цепочки, где следующий шаг зависит от предыдущего (каждый шаг — отдельный батч и сутки ожидания в худшем случае).
- Частичные сбои: у каждого результата свой статус (
succeeded,errored,canceled,expired). Повторная отправка — только упавших, поcustom_id. Делайтеcustom_idдетерминированным (id записи + версия промпта), тогда повтор идемпотентен. - Скидки складываются с prompt caching, но попадания в кэш внутри батча не гарантированы.
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 токенов:
| Модель | ID | Input | Output | Контекст | Когда |
|---|---|---|---|---|---|
| Claude Fable 5.1 | claude-fable-5-1 | 10 | 50 | 1M | Самые сложные задачи: длинные агентные сессии, тяжёлый код, где ошибка дороже токенов |
| Claude Opus 5.5 | claude-opus-5-5 | 4 | 20 | 1M | Флагманское качество дешевле Opus 5 |
| Claude Opus 5 | claude-opus-5 | 5 | 25 | 1M | Сильный дефолт для агентов и сложных рассуждений |
| Claude Sonnet 5 | claude-sonnet-5 | 2 | 10 | 1M | Рабочая лошадка: большинство продуктовых фич |
| Claude Haiku 4.5 | claude-haiku-4-5 | 1 | 5 | 200K | Классификация, роутинг, извлечение, высокие объёмы, низкая латентность |
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
- Routing: дешёвый классификатор (Haiku или даже не-LLM модель) определяет тип/сложность запроса и отправляет на подходящую модель. Ошибка роутера — главный риск: сложный запрос на слабой модели. Измеряйте качество по сегментам.
- Каскад: сначала дешёвая модель; если её ответ не прошёл проверку (валидация схемы, self-check, низкая уверенность, отказ) — эскалация на сильную. Хорошо работает, когда проверка дешевле генерации и есть объективный критерий.
- Fallback по доступности: при 529/5xx — другая модель или другой провайдер/регион (модуль 9). Промпты при этом могут вести себя по-разному — прогоняйте evals на fallback-модели тоже.
Калькулятор: модель × кэш × batch × routing
Месячная стоимость (30 дней) при ваших объёмах. Промахи кэша считаются как запись (1.25×). Роутинг отправляет долю трафика на Haiku 4.5, остальное — на выбранную модель.
Мультимодальность
Claude принимает изображения (image-блоки: base64, URL или file_id из Files API) и PDF (document-блоки). PDF обрабатывается и как текст, и как картинки страниц — поэтому модель видит таблицы, графики и сканы, но и платите вы за оба представления.
- Стоимость картинки зависит от площади в пикселях, а не от «информативности»: порядка одного токена на патч 28×28 px. Изображение 1280×720 — около 1 200 токенов; большие картинки сервер уменьшает сам, но вы платите за передачу и ждёте. Даунскейльте на клиенте до того, что нужно задаче: для классификации фото товара хватит 512 px, для чтения мелкого текста на скриншоте — нет.
- PDF: каждая страница стоит текст + изображение, то есть порядка тысяч токенов на страницу. Отчёт на 100 страниц — это сотни тысяч токенов. Для больших документов — извлечь текст сами, отправлять только нужные страницы, кэшировать документ (
cache_controlна document-блоке). - Порядок: изображения/документы ставьте до вопроса о них.
- Режимы отказа: модель уверенно «читает» размытый текст (галлюцинация OCR), путает оси графиков, плохо считает объекты. Для критичных цифр — сверка с текстовым источником или structured output с полем уверенности.
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 Messages | OpenAI Chat Completions | OpenAI Responses |
|---|---|---|---|
| System | Отдельное поле system | Сообщение с ролью system/developer в списке | Поле instructions или элемент input |
| Состояние | Stateless, история на клиенте | Stateless | Опционально на сервере: previous_response_id, store |
| Ответ | Список content blocks | choices[].message | Список output items |
| Tool call | Блок tool_use, input — объект | tool_calls[], arguments — JSON-строка | Item function_call |
| Результат инструмента | Блок tool_result в user-сообщении | Сообщение с ролью tool и tool_call_id | Item function_call_output |
| Structured output | output_config.format, strict у tools | response_format: json_schema + strict | text.format |
| Стриминг | Именованные события (event:) по блокам | Анонимные data:-чанки, конец — data: [DONE] | Типизированные семантические события |
| Prompt caching | Явные breakpoints, запись дороже, чтение ≈0.1× (Opus 5.5 — 0.05×, Fable 5.1 — 0.025×) | Автоматический по префиксу, скидка на кэшированный input, без платы за запись | |
| Batch | Асинхронно, до 24 ч, −50% у обоих провайдеров (на момент написания) | ||
Ошибки, ретраи, лимиты
| Код | Тип | Ретраить? | Что это |
|---|---|---|---|
| 400 | invalid_request_error | Нет | Невалидный запрос: нет чередования ролей, prefill/temperature на новой модели, схема вне поддерживаемого подмножества |
| 401 / 403 | authentication_error / permission_error | Нет | Ключ, права |
| 404 | not_found_error | Нет | Опечатка в ID модели — самая частая |
| 413 | request_too_large | Нет | Слишком большой запрос |
| 429 | rate_limit_error | Да, с учётом retry-after | Превышены RPM / input TPM / output TPM вашей организации |
| 500 | api_error | Да, backoff | Сбой на стороне API |
| 529 | overloaded_error | Да, backoff; думать о fallback | API перегружен целиком — это не ваш лимит, а общая нагрузка |
- 429 vs 529: 429 — ваша квота (смотрите заголовки
retry-afterиanthropic-ratelimit-*, сглаживайте трафик, client-side rate limiter). 529 — перегрузка провайдера: ретраи помогают, но при затяжной перегрузке нужен fallback на другую модель/регион. - SDK ретраит сам: по умолчанию 2 повтора с экспоненциальным backoff для 408/409/429/5xx. Если строите свой слой ретраев — отключите встроенный (
option.WithMaxRetries(0).maxRetries(0)у билдера клиента), иначе получите 3 × 3 = 9 попыток и retry storm. - Backoff с jitter: без случайного разброса все ваши воркеры ретраят синхронно и снова ловят 429. Full jitter:
sleep = rand(0, min(cap, base·2^n)). Если естьretry-after— он главнее формулы. - Бюджет ретраев: ограничивайте общее время через
contextдедлайном на всю операцию, а при массовых ошибках — circuit breaker, чтобы не добивать лежащий сервис. - Идемпотентность: сам вызов LLM «идемпотентен» (без побочных эффектов), но ретрай стоит денег, а ответ будет другим. Ретраить tool с побочным эффектом — только с ключом идемпотентности.
// Клиент без встроенных ретраев — политику задаём сами.
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.
- Отмена: закрытие соединения клиентом отменяет запрос к API (проверьте логом, что генерация прекращается).
- Обрыв без
message_stopпомечается в БД как неполный ответ. - Метрики: TTFT, total, output tokens/s, stop_reason, cache read/write.
- Prompt caching: на 3-м ходу диалога
cache_read_input_tokens> 0.
Задание 2: tool loop с параллельными инструментами
Агент с 3 инструментами (например, get_order, get_weather, search_docs), цикл на errgroupна virtual threads из модуля.
- Один инструмент искусственно падает в 30% случаев — модель получает
is_errorи справляется (перезапрос или честный ответ). - Лимит итераций, таймаут на инструмент, общий дедлайн.
- Тест: при двух параллельных tool_use оба результата уходят одним user-сообщением, порядок совпадает с порядком вызовов.
Задание 3: извлечение с валидацией и расчёт экономики
Извлечение сущностей из 200 текстов (отзывы, письма) в структуру через structured outputs + Validate()validate() + повтор с ошибкой. Прогоните один раз синхронно на Sonnet 5, один раз через Batch API на Haiku 4.5.
- Посчитайте фактическую стоимость по
usageобоих прогонов и долю повторов. - Сравните качество на 30 размеченных вручную примерах. Напишите вывод на абзац: что выбрали бы в прод и почему.
Самопроверка
Почему 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 делают это часовой задачей, а не проектом.
Источники
- Anthropic — Messages API reference
- Anthropic — Streaming messages
- Anthropic — Tool use overview
- Anthropic — Prompt caching
- Anthropic — Batch processing
- Anthropic — Vision и PDF support
- Anthropic — Errors и Rate limits
- anthropic-sdk-java на GitHub (примеры в
anthropic-java-example/) - anthropic-sdk-go на GitHub (примеры в
examples/) - OpenAI API docs (Chat Completions, Responses, Structured Outputs)
- HTML Standard — Server-sent events
- AWS Builders' Library — Timeouts, retries and backoff with jitter
- JEP 444 — Virtual Threads и JEP 453 — Structured Concurrency
- golang.org/x/sync/errgroup