Перейти к основному содержимому

Слои

Слой оборачивает сам вызов, Request -> Result<EventStream, Error>, — для каждого запроса, который отправляет клиент. Слои добавляются на билдере, и первый добавленный слой — самый внешний: он первым видит запрос и последним — ответ.

use std::time::Duration;

use svir::layer::{Retry, Timeout, Trace};
use svir::prelude::*;

fn client() -> Result<Client, Error> {
Client::openai("https://models.example.com/v1")
.api_key_env("MODELS_API_KEY")
// Outermost: each retry gets the deadline and the trace below it.
.layer(Retry::transient(3))
.layer(Timeout::first_token(Duration::from_secs(60)))
.layer(Trace)
.build()
}

Клиент без слоёв ничего за них не платит. Со слоями клиент хранит их как trait-объекты, поэтому его тип остаётся Client, какими бы они ни были.

Встроенные​

СлойПоведение
Retry::connect(n)Повторяет вызов, чей запрос так и не дошёл до сервера: соединение отвергнуто или не установилось вовремя. Всегда безопасно
Retry::transient(n)Повторяет также таймауты, rate limit и ошибки сервера, случившиеся до начала ответа
.backoff(d) у любого из нихПауза перед первым повтором, 500 мс, если не задана. Удваивается каждый раз, до 30 с. Retry-After от сервера важнее
Timeout::first_token(d)Ни куска ответа (текста, рассуждений, части вызова инструмента) за d от вызова
Timeout::idle(d)Сервер молчит d
Timeout::total(d)Ответ не завершился за d от вызова
Trace (фича tracing)Сообщает о каждом вызове через tracing

Retry и Timeout лежат в svir::layer, а не в прелюдии.

Retry​

  • Повторяется только вызов, упавший до начала ответа. Как только ответ пошёл, ничего не повторяется: вызывающий код уже видел его часть.
  • Retry::transient может повторить запрос, который сервер получил. Для вызова модели это нормально — на сервере он ничего не меняет, — но стоит токенов.
  • Ставьте Retry снаружи Timeout (добавляйте первым), чтобы у каждой попытки был свой дедлайн.

Timeout​

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

Trace​

С фичей tracing слой Trace сообщает о каждом вызове через крейт tracing:

УровеньСобытиеПоля
debugrequestmodel, messages
debugresponse startedmodel, elapsed_ms
infoanswer completemodel, finish, input_tokens, output_tokens, elapsed_ms
warnrequest failed, answer failedmodel, kind, elapsed_ms

Он никогда не записывает текст, рассуждения, аргументы инструментов или сообщения сервера, поэтому его безопасно держать включённым в production.

Замыкание как слой​

wrap превращает замыкание в слой. Оно получает запрос и next — остаток стека.

use std::time::Instant;

use svir::prelude::*;

fn client() -> Result<Client, Error> {
Client::openai("http://127.0.0.1:1234")
.wrap(|request, next| async move {
let model = request.model.clone();
let started = Instant::now();
let answer = next.run(request).await;

match &answer {
Ok(_) => eprintln!("{model}: response after {:?}", started.elapsed()),
Err(error) => eprintln!("{model}: {error}"),
}
answer
})
.build()
}
next.run завершается, когда ответ начался

Он возвращает поток, как только пришли заголовки, а не когда ответ готов. Чтобы увидеть весь ответ, наблюдайте за возвращённым потоком — как ниже.

Собственный слой​

Реализуйте Layer для типа, который хранит состояние. Слой не может поменять тип потока, поэтому формирует поток через собственные методы EventStream.

use std::sync::{
Arc,
atomic::{AtomicU64, Ordering},
};

use svir::layer::{Layer, Next};
use svir::prelude::*;

/// Counts the tokens every answer of a client took to generate.
struct Meter {
generated: Arc<AtomicU64>,
}

impl Layer for Meter {
async fn call(&self, request: Request, next: Next) -> Result<EventStream, Error> {
let generated = self.generated.clone();
let stream = next.run(request).await?;

Ok(stream.inspect(move |item| {
if let Ok(Event::Completed(done)) = item {
let tokens = done.usage.map_or(0, |usage| usage.output);
generated.fetch_add(tokens, Ordering::Relaxed);
}
}))
}
}
Метод EventStreamДля слоя, который
inspect(closure)Наблюдает за каждым элементом, включая последний: метрики, логирование, учёт
first_token_by(instant)Проваливает поток, если к этому моменту не пришло ни куска ответа
complete_by(instant)Проваливает поток, если к этому моменту ответ не готов
idle_timeout(d)Проваливает поток, если сервер молчит d

Слой может изменить запрос перед передачей дальше (системный промпт по умолчанию, псевдоним модели), вызвать next.run больше одного раза, склонировав next (так делает Retry), или не вызывать его вовсе и вернуть ошибку. Переписывать события он не может.

Чего слои не видят​

Слои работают с событиями. client.send(), который возвращает сырые байты сервера для прокси, и client.list_models() через них не проходят.

Слой привязан к HTTP-бэкенду, для которого его добавили, поэтому .http(backend) должен стоять раньше любого .layer(..) или .wrap(..); см. Собственный HTTP-бэкенд.