feat: watchdog на первый байт стрима (first_byte_timeout) — фейловер + backoff при молчании апстрима
This commit is contained in:
@@ -322,6 +322,69 @@ upstreams:
|
||||
При старте выводится, что настроено:
|
||||
`[llm-proxy] backoff: upstreams=routerai-gpt4o=PT15M providers=routerai=P1M`.
|
||||
|
||||
### Таймаут первого байта стрима (`first_byte_timeout`)
|
||||
|
||||
Для `stream=true` запросов прокси следит, чтобы апстрим начал отдавать
|
||||
**что-нибудь** (первый байт тела) в течение заданного окна. Если апстрим
|
||||
молчит дольше — это трактуется как его сбой: слот конкурентности
|
||||
освобождается, ошибка идёт в backoff-откат, и роутер **фейловерит** на
|
||||
следующего свободного апстрима модели (при его наличии). Это закрывает
|
||||
кейс, когда апстрим принял запрос, но «завис» и никогда не начинает
|
||||
стримить.
|
||||
|
||||
| Поле | Тип / дефолт | Значение |
|
||||
|---|---|---|
|
||||
| `providers[].first_byte_timeout` | ISO-8601-длительность / отсутствует | Окно ожидания первого байта для всех моделей провайдера |
|
||||
| `upstreams[].first_byte_timeout` | ISO-8601-длительность / отсутствует | Окно для конкретной модели (приоритет над провайдерским) |
|
||||
|
||||
**Дефолт** (если не задан нигде): `PT30S`.
|
||||
|
||||
**Приоритет — модели.** Если `upstreams[].first_byte_timeout` задан, он
|
||||
перекрывает провайдерский; если нет — берётся провайдерский; если нет и там —
|
||||
дефолт `PT30S`.
|
||||
|
||||
**Отключение:** `PT0S` на провайдере или апстриме отключает watchdog
|
||||
для этого апстрима — тогда первый байт ждём без лимита (в рамках общего
|
||||
`upstream: request_timeout`, см. ниже).
|
||||
|
||||
**Применяется только к `stream=true`.** Для `stream=false` (не-стриминговый
|
||||
ответ) действует только общий лимит `UPSTREAM_REQUEST_TIMEOUT_MS` (`5m`) —
|
||||
строки SSE там склеиваются в один ответ, и таймаут первого байта к нему
|
||||
неприменим.
|
||||
|
||||
Поведение:
|
||||
|
||||
- watchdog висит **на первом чтении** из тела стрим-ответа; как только
|
||||
пришёл хоть какой-то байт — лимит отключается, дальше стрим читается
|
||||
свободно (вплоть до общего request_timeout);
|
||||
- `data: [DONE]` приходит в теле — это уже «первый байт», таймаут не
|
||||
сработает;
|
||||
- при срабатывании — лог:
|
||||
`[llm-proxy] chat model=<m> upstream=<id> TIMEOUT: первый байт не пришёл за <N>ms → фейловер`
|
||||
(+ `(backoff: отдых …)` если задан backoff), метрика
|
||||
`llm_proxy_requests_total{result="timeout"}`;
|
||||
- закрытие апстримом пустого стрима (EOF без единого байта) — **не** таймаут:
|
||||
это валидный короткий ответ, он проксируется как обычно.
|
||||
|
||||
Значение — ISO-8601-длительность, формат как у `backoff` (`PT30S`,
|
||||
`PT1M30S`, `P1D`, …).
|
||||
|
||||
```yaml
|
||||
providers:
|
||||
- id: routerai
|
||||
url: "https://routerai.ru/api/v1"
|
||||
first_byte_timeout: PT30S # окно для всех моделей провайдера
|
||||
|
||||
upstreams:
|
||||
- id: routerai-gpt4o
|
||||
provider: routerai
|
||||
model: gpt-4o
|
||||
first_byte_timeout: PT0S # у модели watchdog отключён
|
||||
```
|
||||
|
||||
При старте выводится, что настроено:
|
||||
`[llm-proxy] first_byte_timeout: upstreams=routerai-gpt4o=PT0S providers=routerai=PT30S default=PT30S`.
|
||||
|
||||
### Prometheus-метрики (`/metrics`)
|
||||
|
||||
Прокси отдаёт pull-метрики в Prometheus text-формате по `GET /metrics`
|
||||
@@ -331,7 +394,7 @@ upstreams:
|
||||
|
||||
| Метрика | Тип | Смысл |
|
||||
|---|---|---|
|
||||
| `llm_proxy_requests_total{model, upstream, provider, result}` | counter | chat-запросы по исходу. `result`: `ok` — успех, `4xx` — ошибка запроса, `429`/`402`/`5xx` — исход с апстрима (каждая фейловер-попытка учитывается отдельно), `net_err` — сетевая ошибка/таймаут, `cancelled` — клиент отвалился посреди стрима |
|
||||
| `llm_proxy_requests_total{model, upstream, provider, result}` | counter | chat-запросы по исходу. `result`: `ok` — успех, `4xx` — ошибка запроса, `429`/`402`/`5xx` — исход с апстрима (каждая фейловер-попытка учитывается отдельно), `net_err` — сетевая ошибка/таймаут, `timeout` — апстрим не прислал первый байт стрима за `first_byte_timeout`, `cancelled` — клиент отвалился посреди стрима |
|
||||
| `llm_proxy_upstream_inflight{upstream, provider}` | gauge | занятые слоты апстрима прямо сейчас (конкурентность) |
|
||||
| `llm_proxy_upstream_fail_streak{upstream, provider}` | gauge | счётчик сбоев подряд (backoff): сколько раз подряд упал |
|
||||
| `llm_proxy_upstream_cooling_seconds{upstream, provider}` | gauge | сколько секунд апстрим ещё в backoff-откате (0 = жив) |
|
||||
@@ -343,7 +406,7 @@ upstreams:
|
||||
- оборот по виртуальным моделям: `sum(rate(llm_proxy_requests_total[5m])) by (model)`;
|
||||
- оборот по провайдерам: `sum(rate(llm_proxy_requests_total[5m])) by (provider)`;
|
||||
- «провайдер умер»: `llm_proxy_upstream_cooling_seconds > 0` дольше N минут — алерт;
|
||||
- доля ошибок провайдера: `sum(rate(llm_proxy_requests_total{result=~"4xx|429|402|5xx|net_err"}[10m])) by (provider) / sum(rate(llm_proxy_requests_total[10m])) by (provider)`.
|
||||
- доля ошибок провайдера: `sum(rate(llm_proxy_requests_total{result=~"4xx|429|402|5xx|net_err|timeout"}[10m])) by (provider) / sum(rate(llm_proxy_requests_total[10m])) by (provider)`.
|
||||
|
||||
### Пример сборки тела (многослойный `patch`)
|
||||
|
||||
@@ -429,10 +492,10 @@ data class ProviderConf(
|
||||
val think_tags: String? = null,
|
||||
val reasoning_field: String? = null,
|
||||
val reasoning_empty_ok: Boolean = false,
|
||||
val backoff: Duration? = null, // потолок backoff на провайдера (ISO-8601)
|
||||
val backoff: Duration? = null, // потолок backoff на провайдера (ISO-8601)
|
||||
val first_byte_timeout: Duration? = null, // окно первого байта стрима (ISO-8601)
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UpstreamConf(
|
||||
val id: String, // наш внутренний id апстрима
|
||||
val provider: String, // ссылка на ProviderConf.id
|
||||
@@ -441,6 +504,7 @@ data class UpstreamConf(
|
||||
val patch: JsonObject? = null, // к запросам этой апстрим-модели
|
||||
val think_tags: String? = null, // переопределение think-режима модели
|
||||
val backoff: Duration? = null, // потолок backoff на модель (приоритет над провайдерским)
|
||||
val first_byte_timeout: Duration? = null, // окно первого байта стрима (приоритет над провайдерским)
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -605,6 +669,10 @@ fun release(u: UpstreamConf) = active.getValue(u.id).decrementAndGet()
|
||||
(`(backoff: отдых PT…)`); при пропуске апстрима в откате —
|
||||
`upstream=<id> в backoff-откате (~Ns) — пропускаю`; при старте — список
|
||||
настроенных backoff (`backoff: upstreams=… providers=…`).
|
||||
- **Таймаут первого байта стрима**: при срабатывании watchdog'а —
|
||||
`upstream=<id> TIMEOUT: первый байт не пришёл за <N>ms → фейловер`;
|
||||
при старте — список настроенных `first_byte_timeout`
|
||||
(`first_byte_timeout: upstreams=… providers=… default=PT30S`).
|
||||
- **Все в откате** — `503 all upstreams cooling, retry in Ns` для
|
||||
`model=<витрина>` с заголовком `Retry-After: N`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user