Продакшн-разработка ИИ-агентов · Модуль 6 · Урок 6.1

Глава 12. Стоимость и латентность

Цели главы

После этой главы вы сможете:

  • Разложить счёт за агента на составляющие: токены ввода (input tokens), токены вывода (output tokens) и число обращений к модели в цикле (round-trips).
  • Применять кеширование промпта (prompt caching) для повторно используемого стабильного префикса.
  • Строить каскад моделей (model cascade/routing): дешёвая модель сначала, эскалация на дорогую при сложности или низкой уверенности.
  • Выбирать между синхронным вызовом, батчингом (Message Batches API) и стримингом (streaming) под характер нагрузки.
  • Вводить бюджет токенов (token budget) на запрос и сессию с деградацией при превышении.
  • Понимать, что такое дистилляция (distillation), и когда она оправдана (обзорно).

Что нового (дельта к базовому курсу)

В базовом курсе агент рассматривался как «дай промпт — получи ответ»: стоимость и задержка были неявными. В продакшене они — первоклассные метрики наравне с качеством.

Главная дельта: цикл агента (agent loop) умножает затраты. Один пользовательский запрос превращается в N обращений к модели (по числу шагов с инструментами), и на каждом шаге весь предыдущий контекст пересылается заново. Поэтому стоимость растёт не линейно по числу шагов, а квадратично по объёму накопленного контекста. Базовый курс этого не затрагивал.

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

Куда уходят деньги и время

Счёт за вызов модели складывается из двух статей с разной ценой: input tokens (весь промпт — система, инструменты, история, текущий запрос) и output tokens (то, что сгенерировала модель). Вывод обычно в 4-5 раз дороже ввода за токен (сверьтесь с актуальным прайсом).

В одиночном вызове доминирует вывод. В агентном цикле — наоборот ввод: каждый шаг тащит за собой всю накопленную историю (предыдущие tool_use и tool_result), и эти токены оплачиваются как input снова и снова. Десять шагов по 8000 токенов контекста — это не 8000, а порядка 80000 оплаченных input-токенов.

Латентность (latency) складывается из похожих компонент: время до первого токена (TTFT, time to first token) растёт с размером промпта, а полное время ответа — ещё и с длиной вывода. В цикле задержки шагов суммируются последовательно. Поэтому два независимых рычага — «сколько токенов» и «сколько round-trip'ов» — нужно оптимизировать отдельно.

Prompt caching как рычаг стоимости

Кеширование промпта — это кеш по точному совпадению префикса (prefix match). Если стабильное начало промпта (системная инструкция, список инструментов, неизменные few-shot-примеры) байт-в-байт повторяется между запросами, повторные запросы читают его из кеша по цене около 0.1x от обычного ввода, а не оплачивают полностью.

Порядок рендеринга строгий: tools -> system -> messages. Любое изменение байта в префиксе аннулирует кеш для всего, что идёт после. Отсюда правило: стабильное — в начало, волатильное (текущий вопрос, метки времени, per-request ID) — после последней точки кеширования (cache breakpoint).

Запись в кеш дороже обычного ввода (около 1.25x для 5-минутного TTL), поэтому окупаемость наступает со второго-третьего запроса с тем же префиксом. Проверять попадание нужно по Usage.CacheReadInputTokens: если он стабильно ноль при одинаковом префиксе — где-то тихий инвалидатор (например, time.Now() в системном промпте или несортированный JSON).

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

Каскад моделей, батчинг и стриминг

Каскад моделей (cheap -> expensive). Не каждый запрос требует самой умной модели. Дешёвая и быстрая модель (например, класса Haiku) обрабатывает простое; при высокой сложности или низкой уверенности запрос эскалируется на более сильную (Sonnet, затем Opus). Решение об эскалации принимает либо отдельный классификатор сложности, либо сам ответ дешёвой модели (низкая уверенность -> повтор на дорогой). Экономия — на массе типовых запросов; риск — двойная оплата эскалированных, поэтому каскад выгоден, когда доля «простых» велика.

Батчинг (Message Batches API). Для офлайн-нагрузок, нечувствительных к задержке (массовая разметка, бэкофилл, ночные отчёты), есть асинхронный батч: до 10000 запросов в пачке, обработка обычно в пределах часа (гарантия — 24 часа), цена примерно вдвое ниже синхронной. Вы отправляете пачку, опрашиваете статус (polling), забираете результаты по готовности. Не путать с интерактивным путём: батч не для онлайн-чата.

Стриминг (streaming). Снижает не реальную, а воспринимаемую латентность: токены текут по мере генерации, пользователь видит первые слова почти сразу (низкий TTFT), хотя полный ответ готов в то же время. Кроме UX, стриминг обязателен для длинных ответов (большой MaxTokens): без него HTTP-запрос рискует упереться в таймаут SDK.

Бюджет токенов и дистилляция

Бюджет токенов (token budget). Это предохранитель против разорительного цикла. Вы задаёте лимит токенов на запрос или на сессию и считаете фактический расход по Usage после каждого вызова. При приближении к лимиту — деградация (degrade gracefully): сократить историю (компактация), снизить уровень усилий (effort), переключиться на дешёвую модель или вернуть пользователю честный отказ вместо неконтролируемого продолжения. Без бюджета один зациклившийся агент может сжечь дневной лимит.

Дистилляция (distillation), обзорно. Дорогая, сильная модель используется как учитель: на наборе типовых запросов она генерирует эталонные ответы, на которых дообучается дешёвая модель-ученик. Цель — приблизить качество дешёвой модели к дорогой на узком домене, сократив стоимость инференса. Это уже территория адаптации моделей (глава 14): прибегают к ней, только когда промптинг, кеш и каскад выжаты, а объём однотипной нагрузки оправдывает затраты на обучение и поддержку.

Семантический кеш ответов (semantic cache)

Не путайте два разных кеша. Prompt caching (разобран выше) — это серверный кеш по точному совпадению префикса байт-в-байт: повторно используется уже посчитанный контекст, но модель всё равно вызывается и генерирует ответ заново. Семантический кеш (semantic cache) живёт на стороне вашего приложения и матчит не «точные», а «похожие» запросы: если новый запрос близок по смыслу к ранее обработанному, вы возвращаете готовый ответ вообще без вызова модели.

Механика: запрос превращается в эмбеддинг (embedding) — числовой вектор смысла. Векторы прежних запросов и их ответы лежат в хранилище. Для нового запроса считаем эмбеддинг, ищем ближайший по косинусной близости (cosine similarity) и, если близость выше порога (например, > 0.95), отдаём кешированный ответ. Иначе — вызываем модель и кладём пару «эмбеддинг -> ответ» в кеш. Экономия здесь радикальнее, чем у prompt caching: на попадании вы не платите ни за input, ни за output, ни за латентность генерации.

Риски и оговорки:

  • Ложные попадания (false positives). Два запроса бывают близки по эмбеддингу, но требуют разных ответов («отмени заказ 123» vs «отмени заказ 124»). Слишком низкий порог -> выдача неверного ответа. Порог подбирайте консервативно и тестируйте на реальных парах.
  • Устаревание (staleness). Ответ на «какой у меня баланс?» нельзя кешировать надолго: мир изменился. Кешируйте только то, что стабильно во времени, и ставьте TTL/инвалидацию.
  • Скоуп по пользователю и правам. Это критично: нельзя отдать ответ одного пользователя другому. Кеш обязан быть разделён по пользователю/тенанту/правам доступа — иначе утечка чужих данных. Ключ кеша включает идентификатор скоупа, поиск ближайшего идёт только внутри скоупа.

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

Каскад моделей: классификатор сложности выбирает дешёвую или дорогую модель
package costlatency

import (
	"context"
	"strings"

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

// Complexity — оценка сложности запроса, по ней выбираем модель.
type Complexity int

const (
	Simple Complexity = iota // короткий вопрос, фактический ответ
	Hard                     // рассуждение, многошаговость, анализ
)

// classify — дешёвая эвристика «на входе». В проде роль классификатора
// может играть отдельный быстрый вызов модели класса Haiku, но эвристика
// бесплатна и часто достаточна как первый фильтр.
func classify(prompt string) Complexity {
	p := strings.ToLower(prompt)
	long := len(prompt) > 600
	reasoning := strings.Contains(p, "почему") ||
		strings.Contains(p, "сравни") ||
		strings.Contains(p, "проанализируй")
	if long || reasoning {
		return Hard
	}
	return Simple
}

// pickModel — каскад cheap -> expensive: простое отдаём быстрой дешёвой
// модели, сложное эскалируем на более сильную. Слаги сверяйте с доками.
func pickModel(c Complexity) anthropic.Model {
	if c == Hard {
		return anthropic.ModelClaudeSonnet4_6
	}
	return anthropic.ModelClaudeHaiku4_5_20251001
}

// Answer выполняет запрос на модели, выбранной каскадом.
func Answer(ctx context.Context, client anthropic.Client, prompt string) (string, error) {
	model := pickModel(classify(prompt))

	resp, err := client.Messages.New(ctx, anthropic.MessageNewParams{
		Model:     model,
		MaxTokens: 1024,
		Messages: []anthropic.MessageParam{
			anthropic.NewUserMessage(anthropic.NewTextBlock(prompt)),
		},
	})
	if err != nil {
		return "", err
	}

	var sb strings.Builder
	for _, block := range resp.Content {
		if t, ok := block.AsAny().(anthropic.TextBlock); ok {
			sb.WriteString(t.Text)
		}
	}
	return sb.String(), nil
}
Трекер бюджета токенов: учёт расхода и деградация при превышении
package costlatency

import (
	"context"
	"errors"
	"sync"

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

// ErrBudgetExceeded возвращается, когда сессия исчерпала бюджет токенов.
var ErrBudgetExceeded = errors.New("token budget exceeded")

// Budget — потокобезопасный счётчик токенов на сессию.
// Считаем суммарно input + cache + output как «использованный контекст».
type Budget struct {
	mu    sync.Mutex
	limit int64
	used  int64
}

func NewBudget(limit int64) *Budget {
	return &Budget{limit: limit}
}

// Remaining — сколько токенов ещё доступно.
func (b *Budget) Remaining() int64 {
	b.mu.Lock()
	defer b.mu.Unlock()
	return b.limit - b.used
}

// record прибавляет фактический расход из Usage ответа.
func (b *Budget) record(u anthropic.Usage) {
	b.mu.Lock()
	defer b.mu.Unlock()
	// Полный объём промпта = uncached input + запись в кеш + чтение из кеша.
	b.used += u.InputTokens +
		u.CacheCreationInputTokens +
		u.CacheReadInputTokens +
		u.OutputTokens
}

// Step делает один вызов модели под контролем бюджета.
// Если бюджет уже исчерпан — отказываем заранее (деградация), не тратя деньги.
func (b *Budget) Step(
	ctx context.Context,
	client anthropic.Client,
	params anthropic.MessageNewParams,
) (*anthropic.Message, error) {
	if b.Remaining() <= 0 {
		return nil, ErrBudgetExceeded
	}

	resp, err := client.Messages.New(ctx, params)
	if err != nil {
		return nil, err
	}

	// Учитываем расход постфактум: лимит — мягкий предохранитель, а не
	// жёсткий потолок на отдельный вызов (для этого есть MaxTokens).
	b.record(resp.Usage)
	return resp, nil
}
Стриминг ответа: низкий TTFT и сбор финального сообщения
package costlatency

import (
	"context"
	"fmt"
	"io"

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

// StreamAnswer стримит ответ в w по мере генерации (низкий TTFT для UX)
// и параллельно собирает полное сообщение через Accumulate.
func StreamAnswer(
	ctx context.Context,
	client anthropic.Client,
	w io.Writer,
	prompt string,
) (anthropic.Message, error) {
	stream := client.Messages.NewStreaming(ctx, anthropic.MessageNewParams{
		Model: anthropic.ModelClaudeOpus4_8,
		// Для длинных ответов даём простор и обязательно стримим,
		// иначе синхронный запрос рискует упереться в таймаут SDK.
		MaxTokens: 64000,
		Messages: []anthropic.MessageParam{
			anthropic.NewUserMessage(anthropic.NewTextBlock(prompt)),
		},
	})

	// В Go нет GetFinalMessage() на стриме — аккумулируем вручную.
	message := anthropic.Message{}
	for stream.Next() {
		event := stream.Current()
		message.Accumulate(event)

		// Печатаем дельты текста сразу — пользователь видит первые слова,
		// не дожидаясь конца генерации.
		if delta, ok := event.AsAny().(anthropic.ContentBlockDeltaEvent); ok {
			if td, ok := delta.Delta.AsAny().(anthropic.TextDelta); ok {
				fmt.Fprint(w, td.Text)
			}
		}
	}
	if err := stream.Err(); err != nil {
		return anthropic.Message{}, err
	}
	return message, nil
}
Семантический кеш: эмбеддинг запроса -> поиск ближайшего -> кеш или вызов модели
package costlatency

import (
	"context"
	"math"
	"sync"

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

// Embedder превращает текст в вектор смысла. Реализация — любой провайдер
// эмбеддингов; здесь нас интересует только контракт.
type Embedder interface {
	Embed(ctx context.Context, text string) ([]float32, error)
}

// cacheEntry — запомненная пара «вектор запроса -> ответ».
type cacheEntry struct {
	vec    []float32
	answer string
}

// SemanticCache — кеш ответов по эмбеддинг-похожести. Живёт на стороне
// приложения и матчит ПОХОЖИЕ запросы (в отличие от prompt caching,
// который матчит ТОЧНЫЙ префикс на сервере).
type SemanticCache struct {
	mu        sync.RWMutex
	embedder  Embedder
	client    anthropic.Client
	threshold float32 // порог косинусной близости, напр. 0.95

	// Скоуп по пользователю: ответы одного пользователя НЕ должны
	// возвращаться другому. Поиск ближайшего идёт только внутри скоупа.
	byUser map[string][]cacheEntry
}

func NewSemanticCache(e Embedder, c anthropic.Client, threshold float32) *SemanticCache {
	return &SemanticCache{
		embedder:  e,
		client:    c,
		threshold: threshold,
		byUser:    make(map[string][]cacheEntry),
	}
}

// cosine — косинусная близость двух векторов одинаковой длины.
func cosine(a, b []float32) float32 {
	var dot, na, nb float32
	for i := range a {
		dot += a[i] * b[i]
		na += a[i] * a[i]
		nb += b[i] * b[i]
	}
	if na == 0 || nb == 0 {
		return 0
	}
	// Векторы эмбеддингов обычно нормированы, но делим на нормы честно.
	return dot / float32(math.Sqrt(float64(na))*math.Sqrt(float64(nb)))
}

// lookup ищет ближайший кешированный ответ ВНУТРИ скоупа пользователя.
func (sc *SemanticCache) lookup(userID string, vec []float32) (string, bool) {
	sc.mu.RLock()
	defer sc.mu.RUnlock()
	var best float32
	var answer string
	for _, e := range sc.byUser[userID] {
		if s := cosine(vec, e.vec); s > best {
			best, answer = s, e.answer
		}
	}
	if best >= sc.threshold {
		return answer, true
	}
	return "", false
}

// Answer возвращает кешированный ответ при близком попадании,
// иначе вызывает модель и запоминает результат для будущих запросов.
func (sc *SemanticCache) Answer(
	ctx context.Context,
	userID, prompt string,
) (string, bool, error) {
	vec, err := sc.embedder.Embed(ctx, prompt)
	if err != nil {
		return "", false, err
	}

	if answer, ok := sc.lookup(userID, vec); ok {
		// Попадание: ни input, ни output, ни латентность генерации не платим.
		return answer, true, nil
	}

	// Промах: вызываем модель.
	resp, err := sc.client.Messages.New(ctx, anthropic.MessageNewParams{
		Model:     anthropic.ModelClaudeHaiku4_5_20251001,
		MaxTokens: 1024,
		Messages: []anthropic.MessageParam{
			anthropic.NewUserMessage(anthropic.NewTextBlock(prompt)),
		},
	})
	if err != nil {
		return "", false, err
	}

	var answer string
	for _, block := range resp.Content {
		if t, ok := block.AsAny().(anthropic.TextBlock); ok {
			answer += t.Text
		}
	}

	// Записываем в кеш под скоупом пользователя. В проде — с TTL/инвалидацией,
	// и только для запросов, чьи ответы устойчивы во времени.
	sc.mu.Lock()
	sc.byUser[userID] = append(sc.byUser[userID], cacheEntry{vec: vec, answer: answer})
	sc.mu.Unlock()

	return answer, false, nil
}

Anti-patterns

Грабли стоимости и латентности
ГрабляПочему плохоКак избегать
Метка времени или per-request ID в системном промптеПрефикс меняется каждый запрос, кеш аннулируется полностью, каждый вызов оплачивается по полнойДержать system/tools байт-стабильными; волатильное выносить в конец messages, после cache breakpoint
Несортированный JSON или итерация по map в стабильном префиксеНедетерминированный порядок ключей меняет байты префикса — тихий промах кеша без ошибкиСериализовать детерминированно (сортировать ключи), проверять CacheReadInputTokens
Все запросы идут на самую сильную модельПлатите Opus-цену за тривиальные запросы, которые решает Haiku в разы дешевле и быстрееКаскад cheap->expensive с классификатором сложности и эскалацией по уверенности
Синхронный вызов для массовой офлайн-разметкиПолная цена и удержание соединений там, где задержка не важнаMessage Batches API: асинхронно, примерно вдвое дешевле, до 10000 запросов в пачке
Цикл агента без бюджета токеновЗациклившийся или разросшийся контекст незаметно сжигает дневной лимитToken budget на сессию: учёт по Usage, деградация (компактация/дешёвая модель/отказ) при превышении
Большой MaxTokens без стримингаДлинная генерация упирается в HTTP-таймаут SDK, запрос падает и требует повтораДля длинных ответов всегда стримить и аккумулировать через Accumulate
Общий семантический кеш без скоупа по пользователюПохожий запрос другого пользователя получает чужой кешированный ответ — утечка данных и правКлючевать кеш по пользователю/тенанту/правам; искать ближайший только внутри скоупа; TTL и консервативный порог близости

Практическое задание (pro-m6-cost-latency)

  • Соберите шлюз агента, который перед вызовом классифицирует запрос (эвристика или быстрый вызов Haiku) и выбирает модель каскадом cheap->expensive.
  • Закешируйте стабильный префикс: системный промпт и набор инструментов с CacheControl на последнем system-блоке; убедитесь по CacheReadInputTokens, что кеш читается.
  • Оберните цикл в Budget: учитывайте input+cache+output из Usage, задайте лимит на сессию и реализуйте деградацию при ErrBudgetExceeded.
  • Для онлайн-ответов включите стриминг с Accumulate; измерьте TTFT до и после.
  • Вынесите офлайн-нагрузку (массовую разметку) в отдельный путь через Message Batches API и сравните стоимость с синхронным путём.
  • Залогируйте на каждый запрос: выбранную модель, разбивку Usage (input/cache_read/cache_creation/output) и оценку стоимости в долларах.
  • Добавьте семантический кеш для повторяющихся типовых запросов: эмбеддинг -> поиск ближайшего по cosine -> при превышении порога вернуть кеш; обязательно скоупьте кеш по пользователю и задайте TTL.

Проверка знаний

Агент делает 10 шагов с инструментами, на каждом шаге в контексте накапливается вся предыдущая история (tool_use и tool_result).

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

  • A Output tokens, потому что вывод дороже за токен
  • B Input tokens, потому что вся накопленная история пересылается заново на каждом шаге
  • C Стоимость стриминга, пропорциональная числу дельт
  • D Плата за хранение кеша между шагами

Команде нужно разметить 50000 документов к утру; задержка отдельного запроса не важна.

Какой путь обработки оптимален по стоимости?

  • A Синхронные вызовы в цикле с максимальной параллельностью
  • B Стриминг каждого документа для низкого TTFT
  • C Message Batches API: асинхронно, примерно вдвое дешевле
  • D Каскад моделей с эскалацией каждого документа на Opus

При повторных запросах с одинаковым системным промптом CacheReadInputTokens стабильно равен нулю.

Что наиболее вероятно происходит?

  • A Кеш работает, просто метрика не заполняется
  • B В стабильный префикс попал тихий инвалидатор — например, time.Now() или несортированный JSON
  • C Модель слишком сильная для кеширования
  • D Нужно увеличить MaxTokens, чтобы кеш активировался

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

В чём главная опасность этой реализации и чем семантический кеш отличается от prompt caching?

  • A Опасности нет: семантический кеш — это то же самое, что серверный prompt caching по префиксу
  • B Общий кеш без скоупа может вернуть похожему запросу чужой ответ; в отличие от prompt caching (точный префикс на сервере, модель всё равно вызывается), семантический кеш на стороне приложения матчит похожие запросы и отдаёт ответ без вызова модели
  • C Семантический кеш всегда дороже, потому что хранит эмбеддинги
  • D Prompt caching матчит похожие запросы, а семантический — точные