20. Cost & Latency Engineering¶
Зачем это нужно?¶
Агент работает, но счёт за LLM API растёт неконтролируемо. Один запрос стоит $5, а другой — $0.10. Почему? Без контроля стоимости и оптимизации latency вы не можете:
- Предсказать бюджет на месяц
- Оптимизировать дорогие запросы
- Гарантировать время ответа пользователю
Cost & Latency Engineering — это контроль бюджета и производительности. Без него легко потратить тысячи долларов на простые запросы или получить медленного агента, которым никто не будет пользоваться.
Реальный кейс¶
Ситуация: Агент для DevOps работает в проде неделю. Счёт за LLM API — $5000 за месяц вместо ожидаемых $500.
Проблема: Агент использует самую дорогую модель для всех запросов, даже для простых проверок статуса. Нет лимитов на токены, system-префикс меняется между итерациями (кэш провайдера не работает), нет лимита итераций. Один запрос "проверь статус сервера" использует 50,000 токенов из-за большого контекста.
Решение: Бюджеты токенов, лимиты итераций, неизменяемый system-префикс под prompt cache провайдера, дешёвая модель на служебных узлах вне цикла. Теперь служебные запросы идут на лёгкую модель (GLM-5.3 Flash), основной цикл — на одну модель (GLM-5.3 или Fable 5.1). Счёт снизился до $600 в месяц.
Примечание: Цены на LLM API меняются часто. Актуальные тарифы проверяйте на сайтах провайдеров и держите в конфиге. В этой главе конкретных цен нет — только формулы, в которые подставляются ваши актуальные тарифы.
Теория простыми словами¶
Что такое Cost Engineering?¶
Cost Engineering — это контроль и оптимизация стоимости использования LLM API. Основные рычаги:
- Выбор модели — мощные модели (Fable 5.1, Opus 5.5) дороже лёгких (GLM-5.3 Flash) в разы; лёгкая — для служебных узлов вне цикла
- Количество токенов — чем больше контекст, тем дороже; блок
tools[]оплачивается в каждом запросе - Prompt cache провайдера — неизменяемый system-префикс (system + блок tools) кэшируется провайдером и тарифицируется в разы дешевле
- Количество запросов — каждый вызов LLM стоит денег
Что такое Latency Engineering?¶
Latency Engineering — это контроль времени ответа. Основные факторы:
- Модель — мощные модели медленнее лёгких
- Размер контекста — больше токенов = больше времени обработки
- Количество итераций — больше циклов ReAct = больше времени
- Таймауты — защита от зависаний
Как это работает (пошагово)¶
Шаг 1: Бюджеты токенов¶
Установите лимит платных токенов на ход (накопительно, с учётом кэш-скидки — см. ниже):
const (
MaxTokensPerTurn = 10000
MaxIterations = 10
)
type TokenBudget struct {
Used int
Limit int
}
func checkTokenBudget(budget TokenBudget) error {
if budget.Used > budget.Limit {
return fmt.Errorf("token budget exceeded: %d > %d", budget.Used, budget.Limit)
}
return nil
}
Шаг 2: Лимиты итераций¶
Ограничьте количество итераций ReAct loop — канон цикла и лимитов в главе 04; лаба lab04-autonomy переведена на тот же нейтральный клиент. Примеры используют нейтральный клиент из главы 04: client.Complete(ctx, Request{...}):
func runAgent(ctx context.Context, client Client, userInput string) (string, error) {
maxIterations := 10
tokenBudget := TokenBudget{Limit: MaxTokensPerTurn}
messages := []Message{
{Role: "system", Content: systemPrompt}, // неизменяемый префикс — под кэш
{Role: "user", Content: userInput},
}
toolDefs := []ToolDef{/* инструменты хода (гл. 03) */}
for i := 0; i < maxIterations; i++ {
resp, err := client.Complete(ctx, Request{Messages: messages, Tools: toolDefs})
if err != nil {
return "", err // транзиентные ошибки ретраит сам клиент (гл. 04)
}
// Бюджет — из Usage провайдера, по платным токенам, а не по TotalTokens:
// кэш-префикс дешевле генерации и вычитается из prompt (гл. 04, 13)
tokenBudget.Used += resp.Usage.PromptTokens - resp.Usage.CacheReadTokens + resp.Usage.CompletionTokens
// Проверяем бюджет
if err := checkTokenBudget(tokenBudget); err != nil {
return "", fmt.Errorf("stopping: %v", err)
}
// ... остальной код ...
}
return "", fmt.Errorf("max iterations (%d) exceeded", maxIterations)
}
Шаг 2.5: Артефакты для больших результатов инструментов¶
Один из самых быстрых способов уменьшить cost/latency — перестать складывать "толстые" результаты инструментов прямо в messages[].
Паттерн:
- сохраняем сырой результат инструмента во внешнем хранилище как артефакт,
- в диалог добавляем только короткий excerpt (например, top-20 строк) +
artifact_id, - если нужно — по запросу достаём артефакт кусками (
range/offset/limit).
Это часто дешевле и надёжнее, чем саммаризация: вы не теряете данные, просто переносите их из контекста в хранилище.
Шаг 3: Prompt cache — главный рычаг экономии¶
Классическая идея «закэшировать ответы LLM» — map[hash(messages)] → answer — для агентов не работает. Ключ почти всегда уникален: на каждой итерации история растёт (добавился ответ модели, добавились результаты инструментов), значит хэш меняется, значит попаданий в кэш нет. Ровно тот же вывод мы сделали для кэша контекста в главе 13.
Реальный рычаг — prompt cache провайдера. Провайдер кэширует неизменяемый префикс запроса: system-сообщение + блок tools[]. Правила:
- Префикс не меняется между итерациями и запросами: один и тот же system-промпт, один и тот же список tools. Всё динамическое — в конце истории (
user,tool-сообщения). - История только дополняется: запрос N+1 начинается с того же префикса + вся история запроса N. Провайдер не пересчитывает совпавший префикс.
- Попадание видно по метрикам:
cache-read > 0вUsageответа (названия полей у провайдеров отличаются —cache_read/cached_tokens/CacheRead, — суть одна). - Кэш привязан к модели: смена модели между запросами ломает кэш — у каждой модели он свой.
// Попадание в prompt cache видно прямо в Usage ответа:
resp, err := client.Complete(ctx, Request{Messages: messages, Tools: toolDefs})
if err != nil {
return "", err
}
// resp.Usage.PromptTokens — все prompt-токены (включая кэш)
// resp.Usage.CacheReadTokens — сколько из них пришло из кэша провайдера
hitRate := float64(resp.Usage.CacheReadTokens) / float64(resp.Usage.PromptTokens)
Экономия двойная:
- Стоимость: кэш-чтение тарифицируется в разы дешевле полных prompt-токенов (коэффициенты — в конфиге, см. главу 19: таблица цен per-модель).
- Латентность: кэшированный префикс не пересчитывается — время до первого токена заметно падает. Prompt cache — это одновременно и оптимизация latency, а не только денег.
Что убивает кэш на корню: «умный» system-промпт с датой/именем пользователя внутри, переупорядочивание tools между итерациями, ротация моделей внутри одного цикла.
Шаг 4: Выбор модели — один раз, на границе¶
Модель выбирается до старта цикла и не меняется внутри него. Критерии — класс задачи и требования к качеству, а решение принимает код на границе системы: явные сценарии в конфиге, отдельный классификатор (см. «Дешёвая модель на служебных узлах» ниже) или человек.
// selectModel вызывается один раз — до agent loop, не внутри него.
// Критерии приходят снаружи (конфиг сценария / классификатор), а не из
// эвристик по подстрокам пользовательского ввода: substring-магия хрупка
// и не переживает смену формулировок.
func selectModel(profile TaskProfile) string {
switch profile {
case ProfileAuxiliary: // служебные узлы вне цикла
return "glm-5.3-flash"
default: // основной цикл — одна модель на весь ход
return "glm-5.3"
}
}
Внутри цикла модель фиксирована: смена модели между итерациями ломает prompt cache (у каждой модели свой кэш) и обнуляет всю экономию из Шага 3.
Шаг 5: Fallback-модели¶
Fallback-цепочка нужна для отказоустойчивости, а не для экономии: «если основная модель недоступна — ход продолжается на резервной, а не падает». Переключение — только при transient-ошибках и не по соображениям цены. Механизм — гл. 03 → Fallbacks вместо маршрутизации и гл. 04:
// Fallback-и живут в конфигурации клиента (Resolve), цикл о них не знает.
cfg := ModelConfig{ // форма гл. 03: primary + явные fallback'и
Primary: "glm-5.3",
Fallbacks: []string{"fable-5.1"}, // только доступность, не «экономия»
}
// Клиент сам повторяет запрос к резервной модели при transient-ошибке;
// счётчик переключений попадает в RunResult (гл. 04) и в метрики failovers (гл. 19).
Шаг 6: Таймауты¶
Установите timeout для всего agent run и для каждого вызова инструмента:
import "context"
import "time"
func runAgentWithTimeout(ctx context.Context, client Client, userInput string) (string, error) {
// Timeout для всего agent run (5 минут)
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
maxIterations := 10 // терминальная граница хода (гл. 04)
messages := []Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userInput},
}
toolDefs := []ToolDef{/* инструменты хода (гл. 03) */}
// ... agent loop ...
for i := 0; i < maxIterations; i++ {
// Timeout для каждого вызова LLM (30 секунд)
callCtx, callCancel := context.WithTimeout(ctx, 30*time.Second)
resp, err := client.Complete(callCtx, Request{Messages: messages, Tools: toolDefs})
callCancel()
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return "", fmt.Errorf("LLM call timeout")
}
return "", err
}
// ... остальной код ...
}
// Исчерпание лимита итераций — терминальная граница (гл. 04)
return "", fmt.Errorf("agent exceeded max iterations (%d)", maxIterations)
}
Где это встраивать в нашем коде¶
Точка интеграции 1: Agent Loop¶
В своём рантайме из главы 04 добавьте проверку бюджета и лимит итераций (ревизованная lab04-autonomy использует ту же форму цикла):
const MaxIterations = 10
const MaxTokensPerTurn = 10000
// В цикле: бюджет — накопительный, на весь ход (гл. 04), не на один запрос
var usedPaidTokens int
for i := 0; i < MaxIterations; i++ {
resp, err := client.Complete(ctx, Request{Messages: messages, Tools: toolDefs})
if err != nil {
return "", err // до чтения Usage: resp == nil при ошибке
}
// Копим платные токены (кэш-префикс дешевле, гл. 04/13) и сверяем с лимитом хода
usedPaidTokens += resp.Usage.PromptTokens - resp.Usage.CacheReadTokens + resp.Usage.CompletionTokens
if usedPaidTokens > MaxTokensPerTurn {
return "", fmt.Errorf("token limit exceeded")
}
// ... остальной код ...
}
Точка интеграции 2: Context Optimization¶
В своём рантайме добавьте condense по порогу окна (гл. 12/13), не по бюджету хода (ревизованная lab09-context-optimization использует тот же клиент):
func adaptiveContextManagement(ctx context.Context, client Client, lastResp *Response, messages []Message, condenseAt float64) []Message {
// Триггер сжатия — порог контекстного окна (гл. 12/13), не бюджет хода:
// budget (MaxTokensPerTurn) — про деньги, condenseAt — про место в окне.
// Порог считается по usage.PromptTokens последнего ответа провайдера (гл. 12);
// пред-оценка «~3 символа на токен» — только fallback, пока ответа нет
var usedTokens int
if lastResp != nil {
usedTokens = lastResp.Usage.PromptTokens // первичный источник — провайдер
} else {
usedTokens = estimateTokens(messages) // fallback до первого ответа (гл. 12)
}
window := contextWindowTokens() // размер окна модели (гл. 01)
if float64(usedTokens)/float64(window) >= condenseAt { // типовое ~0.85
return compressOldMessages(ctx, client, messages)
}
// ... остальная логика ...
}
Мини-пример кода¶
Компактный пример с контролем стоимости на базе цикла из главы 04 — бюджеты, лимит итераций, таймауты, одна модель в цикле (клиент — нейтральный, из главы 04; шаги 1–2 и 6 выше, собранные в один листинг):
const (
MaxTokensPerTurn = 10000
MaxIterations = 10
LLMTimeout = 30 * time.Second
AgentTimeout = 5 * time.Minute
)
type TokenBudget struct {
Used int
Limit int
}
func checkTokenBudget(budget TokenBudget) error {
if budget.Used > budget.Limit {
return fmt.Errorf("token budget exceeded: %d > %d", budget.Used, budget.Limit)
}
return nil
}
// runAgentWithBudgets — цикл из главы 04 с бюджетом, лимитом итераций и таймаутами.
func runAgentWithBudgets(ctx context.Context, client Client, userInput string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, AgentTimeout) // дедлайн всего хода
defer cancel()
// Одна primary-модель на весь цикл (задана на клиенте, гл. 04) —
// prompt cache работает, переключение только через fallback-цепочку.
tokenBudget := TokenBudget{Limit: MaxTokensPerTurn}
messages := []Message{
{Role: "system", Content: systemPrompt}, // неизменяемый префикс — под кэш провайдера
{Role: "user", Content: userInput},
}
toolDefs := []ToolDef{/* инструменты хода (гл. 03) */}
for i := 0; i < MaxIterations; i++ {
// Проверяем бюджет перед запросом
if err := checkTokenBudget(tokenBudget); err != nil {
log.Printf("budget exceeded: %v", err)
return "", fmt.Errorf("token budget exceeded: %w", err) // терминально (гл. 04)
}
// Timeout для каждого вызова LLM
callCtx, callCancel := context.WithTimeout(ctx, LLMTimeout)
resp, err := client.Complete(callCtx, Request{Messages: messages, Tools: toolDefs})
callCancel()
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
log.Printf("LLM call timeout")
return "", err
}
log.Printf("API error: %v", err)
return "", err
}
msg := resp.Choices[0].Message
messages = append(messages, msg)
// Обновляем бюджет — по платным токенам, не по TotalTokens (гл. 04, 13).
// Упрощение-нижняя оценка: кэш-чтение не бесплатное, а тарифицируется
// со скидкой — точный учёт коэффициентом из конфига цен (гл. 19).
tokenBudget.Used += resp.Usage.PromptTokens - resp.Usage.CacheReadTokens + resp.Usage.CompletionTokens
log.Printf("tokens: %d/%d (cache_read=%d)",
tokenBudget.Used, tokenBudget.Limit, resp.Usage.CacheReadTokens)
if len(msg.ToolCalls) == 0 {
return msg.Content, nil
}
for _, toolCall := range msg.ToolCalls {
result, err := executeTool(ctx, toolCall)
if err != nil {
result = "error: " + err.Error()
}
messages = append(messages, Message{
Role: "tool", Content: result, ToolCallID: toolCall.ID,
})
}
}
// Исчерпание лимита итераций — терминальная граница (гл. 04), не тихий возврат
log.Printf("agent exceeded max iterations (%d), tokens: %d/%d", MaxIterations, tokenBudget.Used, tokenBudget.Limit)
return "", fmt.Errorf("agent exceeded max iterations (%d)", MaxIterations)
}
Дешёвая модель на служебных узлах¶
Идея «выбирать модель по сложности на каждой итерации» выглядит заманчиво, но внутри основного цикла она не работает: смена модели между запросами ломает prompt cache (у каждой модели свой кэш, Шаг 3), и «экономия 50%» из маркетинговых примеров не учитывает ни потерю кэша, ни рост latency на пересчёт префикса. Кроме того, дешёвая модель, выбирающая инструменты в середине хода, может увести рассуждение не туда — и тогда «сэкономленный» вызов оборачивается лишними итерациями.
Правильное место для дешёвой модели — служебные узлы вне основного цикла:
| Узел | Что делает | Модель |
|---|---|---|
| Классификация входа | простой/сложный запрос, роутинг сценария | GLM-5.3 Flash |
| Structured output | извлечение полей, парсинг ответа в структуру | GLM-5.3 Flash |
| Основной цикл | ReAct-ход: выбор инструментов, анализ, ответ | одна модель: GLM-5.3 или Fable 5.1 |
Condense — исключение, не служебный узел. Запрос суммаризации несёт тот же кэшируемый префикс (system + tools) и идёт на ту же модель, что и основной цикл: head читается из прогретого кэша, а не платится по полной (гл. 13). Дешёвая модель здесь — ложная экономия: свой промпт и «урезанная» история обнуляют cache-hit по всей старой истории, и condense становится дороже, чем вызов основной модели.
// condense — по канону гл. 12/13: та же модель, тот же кэшируемый префикс
// (system + tools), вход — вся старая история (head), максимум 1 раз за Run.
func condense(ctx context.Context, ep Client, msgs []Message) ([]Message, error) {
// 1. Разделить на head (старое) и tail (последние 2 user-шага)
// 2. Собрать summary head: запрос несёт тот же префикс system + tools
// — head читается из прогретого кэша (гл. 13)
// 3. Вернуть [summary as user message] + tail
}
Ключевые свойства служебных узлов: фиксированный формат выхода, короткий вход (нет растущей истории), ошибка обрабатывается fallback-ом на основную модель — качество деградирует мягко, а не падает.
Fallback-цепочки — отдельная история: они нужны для отказоустойчивости (основная модель недоступна — ход продолжается на запасной), а не для экономии. Механизм — гл. 03 и гл. 04.
Связь с другими главами: Function Calling и устройство tools — Глава 03: Инструменты и Function Calling. Сам цикл агента — Глава 04: Автономность и Циклы.
Бюджет контекста = деньги¶
Каждый запрос агента оплачивает не только историю, но и блок tools[] целиком — он уходит в каждом вызове. Четыре инструмента с развёрнутыми описаниями — это порядка ~700+ токенов в каждом запросе; на 10 итерациях и тысячах ходов в день счёт растёт быстрее, чем кажется.
Что с этим делать:
- Пишите короткие описания инструментов: имя, что делает, когда звать, ограничения. «Документация» на 500 токенов в description — это постоянный налог.
- Не таскайте весь каталог: давайте агенту список tools под задачу (узкий ToolRegistry), а не «всё, что есть». Связка — гл. 03: Инструменты и Function Calling.
- Скилы вместо каталогов: вместо 50 инструментов с длинными описаниями — несколько мета-инструментов («найди подходящий скил», «выполни скил»), которые подгружают инструкции по требованию. Тот же префикс, но оплата — по факту использования.
- Следите за метрикой: доля
cache_read / promptв Usage (гл. 19) показывает, что префикс действительно кэшируется, а не оплачивается заново.
Подробный разбор управления контекстом — Глава 13: Context Engineering.
Streaming для снижения Perceived Latency¶
Latency бывает двух видов:
- Фактическая — время от запроса до последнего токена ответа
- Воспринимаемая (perceived) — время, которое пользователь ощущает как ожидание
Streaming не ускоряет генерацию. Модель тратит столько же времени на весь ответ. Но пользователь видит первые слова через 200-500 мс вместо того, чтобы ждать 5-10 секунд до появления полного ответа. Это принципиально меняет UX.
Как работает streaming¶
Без streaming: запрос → ожидание 5 секунд → весь ответ целиком.
Со streaming: запрос → 200 мс → первый токен → токены появляются по одному → ответ готов.
Технически это Server-Sent Events (SSE) — HTTP-соединение остаётся открытым, сервер отправляет данные по мере готовности.
Streaming в agent loop¶
func streamAgentResponse(ctx context.Context, client Client, messages []Message) error {
stream, err := client.Stream(ctx, Request{Messages: messages}) // нейтральный стриминг-вызов
if err != nil {
return fmt.Errorf("stream error: %w", err)
}
defer stream.Close()
for {
delta, err := stream.Recv()
if errors.Is(err, io.EOF) {
fmt.Println()
return nil
}
if err != nil {
return fmt.Errorf("stream recv error: %w", err)
}
fmt.Print(delta.Text) // Выводим фрагмент сразу, без буферизации
}
}
Streaming через SSE для веб-клиентов¶
Если агент работает как HTTP-сервер, стриминг передаётся клиенту через Server-Sent Events: соединение не закрывается, сервер пишет строки data: ... и вызывает flusher.Flush() после каждого фрагмента. Полный разбор SSE-транспорта (заголовки, [DONE], реконнекты, WebSocket против SSE) — в Главе 21: Workflow и State Management.
Когда streaming не помогает¶
Streaming снижает perceived latency только для финального ответа пользователю. Промежуточные вызовы LLM (выбор инструмента, анализ результатов) не нужно стримить — пользователь их не видит. Для промежуточных вызовов важна фактическая latency. Её снижают выбором быстрой модели и оптимизацией контекста.
Параллельное выполнение Tool Calls¶
LLM может вернуть несколько tool calls за одну итерацию (см. Глава 03: Инструменты и Function Calling). Если инструменты независимы друг от друга, их можно выполнить параллельно.
Параллельное выполнение не экономит токены — LLM всё равно сгенерировал все tool calls за один запрос. Но оно сокращает wall-clock time — время от начала до конца итерации.
Когда безопасно параллелить¶
| Безопасно | Небезопасно |
|---|---|
check_disk + check_memory — читают разные ресурсы |
create_file → write_to_file — второй зависит от первого |
get_logs("app-1") + get_logs("app-2") — разные серверы |
stop_service → deploy → start_service — строгий порядок |
dns_lookup + ping — независимые проверки |
read_config → apply_config — сначала прочитать, потом применить |
Правило: если результат одного инструмента нужен как вход для другого — выполнять последовательно. Если нет — параллельно.
Реализация: executeToolsParallel¶
// ToolResult хранит результат выполнения одного инструмента
type ToolResult struct {
ToolCallID string
Content string
Err error
}
// executeToolsParallel запускает все tool calls одновременно.
// Каждый инструмент выполняется в отдельной горутине.
func executeToolsParallel(ctx context.Context, toolCalls []ToolCall) []Message {
results := make([]ToolResult, len(toolCalls))
var wg sync.WaitGroup
for i, tc := range toolCalls {
wg.Add(1)
go func(idx int, call ToolCall) {
defer wg.Done()
// Таймаут на каждый инструмент отдельно
toolCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
content, err := executeTool(toolCtx, call)
results[idx] = ToolResult{
ToolCallID: call.ID,
Content: content,
Err: err,
}
}(i, tc)
}
wg.Wait()
// Собираем результаты в формате messages
var messages []Message
for _, r := range results {
content := r.Content
if r.Err != nil {
content = fmt.Sprintf("error: %v", r.Err)
}
messages = append(messages, Message{
Role: "tool",
Content: content,
ToolCallID: r.ToolCallID,
})
}
return messages
}
Интеграция в agent loop — заменяем последовательное выполнение на параллельное:
for i := 0; i < MaxIterations; i++ {
resp, err := client.Complete(ctx, Request{Messages: messages, Tools: toolDefs})
if err != nil {
return "", err
}
msg := resp.Choices[0].Message
messages = append(messages, msg)
if len(msg.ToolCalls) == 0 {
return msg.Content, nil
}
// Было: последовательное выполнение
// for _, tc := range msg.ToolCalls { ... }
// Стало: параллельное выполнение
toolMessages := executeToolsParallel(ctx, msg.ToolCalls)
messages = append(messages, toolMessages...)
}
Сколько экономим по времени¶
Допустим, агент вызвал 3 инструмента за одну итерацию. Каждый инструмент выполняется ~2 секунды.
- Последовательно: 2 + 2 + 2 = 6 секунд
- Параллельно: max(2, 2, 2) = 2 секунды
На 8 итерациях с 2-3 tool calls каждая — это разница между 48 секундами и 16 секундами. Пользователь ждёт в 3 раза меньше.
Связь с Главой 04: В Главе 04: Автономность и Циклы tool calls выполняются последовательно. Параллельное выполнение — естественная оптимизация, когда агент стабильно работает и вы начинаете оптимизировать latency.
Типовые ошибки¶
Короткая сводка (подробные разборы — ниже):
| # | Ошибка | Симптом | Решение |
|---|---|---|---|
| 1 | Нет лимитов на токены | Счёт растёт неконтролируемо, запрос по 100k токенов | TokenBudget по Usage провайдера |
| 2 | Нет лимита итераций | Агент зацикливается, 50+ итераций | MaxIterations — терминальная граница (гл. 04) |
| 3 | Одна дорогая модель на всё | Служебные вызовы стоят дороже, чем нужно | GLM-5.3 Flash на служебных узлах вне цикла |
| 4 | Смена модели внутри цикла | Prompt cache ломается, префикс оплачивается заново | Одна модель в цикле (разбор ниже) |
| 5 | Свой result-cache вместо prompt cache | Попаданий нет, экономии нет | Неизменяемый system-префикс (Шаг 3, разбор ниже) |
| 6 | Нет таймаутов | Агент зависает на 10+ минут | Timeout на ход и на каждый вызов LLM |
Разбор 1: смена модели внутри цикла¶
Симптом: доля cache_read / prompt в Usage почти нулевая, хотя system-промпт не меняется; latency каждого вызова заметно выше первого.
Причина: модель чередуется между итерациями («дешёвая выбирает инструмент, дорогая отвечает»). У каждой модели свой prompt cache — при переключении префикс не переиспользуется и оплачивается по полной ставке. «Экономия 50%» из примеров про stage-роутинг не учитывает ни это, ни потерю качества рассуждения при смене модели посреди хода.
Решение: одна модель на весь цикл; лёгкая модель (GLM-5.3 Flash) — только на служебных узлах вне цикла, где истории почти нет и кэш основного хода не задействован.
Разбор 2: свой result-cache вместо prompt cache¶
Симптом: реализован map[hash(messages)] → answer, но hit rate около нуля, экономии нет.
Причина: ключ кэша уникален почти всегда — история диалога растёт на каждой итерации (ответ модели, результаты инструментов), хэш меняется, попаданий не бывает.
Решение: кэшируйте не ответы, а префикс — system-сообщение + блок tools[] держите неизменным, историю только дополняйте. Тогда работает кэш провайдера: попадание видно по CacheReadTokens > 0 в Usage (Шаг 3). Свой map для кэша ответов имеет смысл только для действительно идентичных запросов без истории (например, кэш эмбеддингов в RAG, гл. 06) — для агентского цикла он мёртв.
Мини-упражнения¶
Упражнение 1: Реализуйте проверку бюджета токенов¶
Добавьте проверку бюджета в свой рантайм из главы 04 (ревизованная lab04-autonomy использует тот же клиент):
func checkTokenBudget(used int, limit int) error {
// Ваш код здесь
// Верните ошибку, если превышен лимит
}
Ожидаемый результат:
- Функция возвращает ошибку, если использовано токенов больше лимита
- Функция возвращает nil, если лимит не превышен
Упражнение 2: Condense по канону гл. 12/13¶
Реализуйте condense-суммаризацию по канону: та же модель, тот же кэшируемый префикс, максимум 1 раз за Run:
func condense(ctx context.Context, ep Client, msgs []Message) ([]Message, error) {
// Ваш код здесь
// 1. Разделить на head/tail 2. Summary: запрос с тем же префиксом
// system + tools (гл. 13) 3. [summary as user message] + tail
}
Ожидаемый результат:
- Запрос суммаризации несёт тот же кэшируемый префикс — head читается из прогретого кэша
- Максимум 1 сжатие за Run; ошибка суммаризации — ход продолжается с несжатой историей (гл. 13)
Упражнение 3: Метрики prompt cache¶
Считайте hit rate кэша провайдера по Usage из ответов:
func cacheHitRate(usages []Usage) float64 {
// Ваш код здесь
// Сумма CacheReadTokens / сумма PromptTokens по всем вызовам
}
Ожидаемый результат:
- По неизменяемому system-префиксу hit rate заметно выше нуля
- При смене модели между запросами hit rate падает к нулю
Критерии сдачи / Чек-лист¶
Сдано (готовность к прод):
- Реализованы бюджеты токенов по Usage провайдера (prompt/completion) с проверкой перед каждым запросом
- Установлен лимит итераций ReAct loop
- Неизменяемый system-префикс (system + tools) — работает prompt cache провайдера, метрики cache_read отслеживаются
- Дешёвая модель (GLM-5.3 Flash) на служебных узлах вне цикла; в цикле — одна модель
- Fallback-цепочка настроена для отказоустойчивости, а не для экономии
- Установлены таймауты для вызовов LLM и agent run
Не сдано:
- Нет лимитов на токены
- Нет лимита итераций
- System-префикс меняется между итерациями / модель меняется внутри цикла
- Бюджет считается по TotalTokens или собственным оценкам вместо Usage провайдера
- Нет таймаутов
Связь с другими главами¶
- Observability: Логирование использования токенов — Глава 19: Observability и Tracing
- Context Optimization: Управление размером контекста — Глава 13: Context Engineering
- Agent Loop: Базовый цикл агента — Глава 04: Автономность и Циклы