LiteLLM: Traffic Mirroring та Batch Completions і трафік до двох провайдерів
0 (0)

Автор |  21/08/2026
Click to rate this post!
[Total: 0 Average: 0]

Готуємось до запуску self-hosted LLM, і на етапі тестування загальна ідея така, що будемо слати запити від клієнтів одночасно і до “дефолтної production моделі” типу GPT-5.6 – і до моделі на нашому власному сервері.

А після отримання відповідей – будемо виконувати порівняння з Phoenix чи Opik, і потроху тюнити наші “власні” модельки.

Всі запити у нас йдуть через LiteLLM, і перше, що прийшло в голову – це використати Traffic Mirroring з silent_model, але тут виявився один не дуже приємний нюанс, про який поговоримо далі.

Тому сьогодні розглянемо два варіанти: виконувати traffic mirroring на самій LiteLLM – або використати Batch Completions, і готувати запит до двох моделей на рівні клієнта.

Забігаючи наперед – жоден з цих варіантів не спрацював, і в результаті я зробив власний Custom Callback з “Traffic Mirroring” і трейсами – але про це вже в наступному пості.

Втім, загалом обидва варіанти працюють і можуть комусь підійти.

Версія LiteLLM у нас зараз і в прикладах в цьому пості v1.95 – бо в майбутніх, сподіваюсь, наведуть порядок.

A/B Testing – Traffic Mirroring

Перший і самий, здавалося б, варіант – це використати silent_model – див. A/B Testing – Traffic Mirroring.

Тут всі налаштування виконуються тільки на стороні LiteLLM – зручно, бо не треба робити ніяких змін на клієнтах.

Сама ідея виглядає прям топчик:

  • ми додаємо нову model з api_base на наш власний сервер
  • до існуючих моделей додається параметр silent_model – і LiteLLM під капотом з одного request від клієнта робить два requests до двох провайдерів, клієнту повертає response основної моделі, а response від silent_model записує в трейси
  • при цьому всі спани мають загальний trace_id – і ми можемо отримати і оригінальний промпт – і обидві відповіді, що потім порівняти результати двох моделей

Загалом схема робоча і класна – але є підводний камінь.

Поїхали тестити – а потім глянемо на проблему.

Налаштування silent_model

В model_list додаємо модель з нашого власного серверу:

...

# Silent/shadow model
- model_name: ab-matrix-gemma-4-26b
  litellm_params:
    model: openai/gemma-4-26b-a4b-it-q8
    api_base: http://matrix.neoc.vpn.ops.example.co:31000/v1
    api_key: unused

...

А потім для моделі, яка використовується клієнтами – додаємо silent_model:

...

      - model_name: ab-gpt-4.1
        litellm_params:
          model: openai/gpt-4.1
          api_key: os.environ/OPENAI_API_KEY
          silent_model: ab-matrix-gemma-4-26b

...

Можна тестити з curl, але я робив простенький скрипт – бо клієнти у нас все ж на Python:

#!/usr/bin/env python3

import os

from openai import OpenAI


client = OpenAI(
    api_key=os.environ["LITELLM_TESTING_KEY"],
    base_url="http://localhost:4000/v1",
)

response = client.chat.completions.create(
    model="ab-gpt-4.1",
    messages=[
        {
            "role": "user",
            "content": "Hello world",
        }
    ],
    temperature=0,
    max_tokens=256,
)

print("request_id:", response.id)
print("model:", response.model)
print("content:", response.choices[0].message.content)

Запускаємо його:

$ ./test_traffic_mirror.py 
request_id: chatcmpl-EFGbgUbcpWuZ9xaXMsc2OG6vw5FN4
model: ab-gpt-4.1
content: Hello! 🌍 How can I help you today?

Перша ознака, що все працює – це “2” в колонці Type замість простого “LLM” – бо LiteLLM об’єднала два запити в одну сесію:

LiteLLM: Traffic Mirroring та Batch Completions і трафік до двох провайдерів

Відкриваємо цю session, і бачимо два requests – до gpt-4.1 і до gemma-4:

LiteLLM: Traffic Mirroring та Batch Completions і трафік до двох провайдерів

Nice, правда?

Traces і отримання responses

І що саме корисне – це те, що обидва запити йдуть з одним trace_id:

"resource_attr:service.name":="litellm" "span_attr:litellm.metadata.user_api_key_alias":="ops-testing"
| trace_id:="5306641c704e688656ba3fac79cf09ef"
| name:~"chat"
| stats by ("span_attr:gen_ai.response.model") count()

LiteLLM: Traffic Mirroring та Batch Completions і трафік до двох провайдерів

А значить – ми можемо отримати і промпт, і обидві відповіді – і відправити їх на порівняння:

LiteLLM: Traffic Mirroring та Batch Completions і трафік до двох провайдерів

Виглядає просто як саме те, чого нам і треба!

Але.

LiteLLM silent_model та OpenAI Chat Completions vs Responses API

Протестувавши все вручну і радісно потираючи ручки я задеплоїв зміни в наш Production LiteLLM, і почав чекати трейсів, щоб вже зайнятись evaluation.

Але чомусь в Type постійно були тільки LLM:

LiteLLM: Traffic Mirroring та Batch Completions і трафік до двох провайдерів

WTF?

І тут звертаємо увагу на ім’я в Request ID – в production воно починається з resp_ – а на тесті був chatcmpl-.

Чому – бо в тестовому скрипті використовується client.chat.completions.create(), тобто старий Chat Completions API – тоді як клієнти всюди використовують responses.create() і Responses API (див. Migrate to the Responses API):

openai_client = get_openai_client()

response = openai_client.responses.create(
    model="gpt-5",
    instructions="You are an expert in creating detailed and insightful daily summaries...",
    input=dynamic_prompt,
    metadata={"user_id": str(self.participant_id)},
    reasoning={"effort": "medium"},
)

return response.output_text.strip()

І якщо ми в наш тестовий скрипт додамо саме responses.create():

import os

from openai import OpenAI


client = OpenAI(
    api_key=os.environ["LITELLM_TESTING_KEY"],
    base_url="http://localhost:4000/v1",
)

prompt = "Hello world"


chat_response = client.chat.completions.create(
    model="ab-gpt-4.1",
    messages=[
        {
            "role": "user",
            "content": prompt,
        }
    ],
    temperature=0,
    max_tokens=256,
)

print("=== Chat Completions API: POST /v1/chat/completions ===")
print("request_id:", chat_response.id)
print("model:", chat_response.model)
print("content:", chat_response.choices[0].message.content)
print()

responses_response = client.responses.create(
    model="ab-gpt-4.1",
    input=prompt,
    temperature=0,
    max_output_tokens=256,
)

print("=== Responses API: POST /v1/responses ===")
print("request_id:", responses_response.id)
print("model:", responses_response.model)
print("content:", responses_response.output_text)

То у випадку із запитом Responses API дійсно отримуємо тільки один запит з типом LLM – на скріні це 13:39:07, останній запис:

LiteLLM: Traffic Mirroring та Batch Completions і трафік до двох провайдерів

Чому – бо в LiteLLM v1.95.0 silent_model реалізований тільки для Chat Completions, і не запускається на generic execution path, який обслуговує Responses API.

Є дві відкриті GitHub Issues – Traffic Mirroring Not Working for Responses API і enable silent_model in generic execution, але вони поки не реалізовані.

Batch Completions – pass multiple models

Окей, подумав я – робити зміни в коді клієнтів не хочеться, але доведеться, і їх небагато – спробую з Batch Completions – pass multiple models.

Тут ідея полягає в тому, що ми на клієнті передаємо список моделей – model="gpt-4.1, gpt-5":

#! /usr/bin/env python3

import os
from openai import OpenAI

client = OpenAI(
    api_key=os.environ["LITELLM_TESTING_KEY"],
    base_url="http://localhost:4000/v1",
)


responses = client.chat.completions.create(
    model="gpt-4.1, gpt-5",
    messages=[
        {
            "role": "user", "content": "this is a test request, write a short poem"
        }
    ],
)

for response in responses:
    print("request_id:", response.id)
    print("model:", response.model)
    print()

Тоді LiteLLM (а це фішка саме LiteLLM – бо прямий виклик до OpenAI повернув би помилку типу “model not found”) розпарсить імена моделей, і зробить два запити.

Наче непогано? Да.

Запускаємо:

$ ./test_batch.py 
request_id: chatcmpl-EFH9PYiZJspAjG366obRjy2jeiEWR
model: gpt-4.1-2025-04-14

request_id: chatcmpl-EFH9Ps2PbJxJYVTuyY9WDb2G5G5uT
model: gpt-5-2025-08-07

Вау, супер!

Але…

В Logs ми бачимо тільки один запит:

LiteLLM: Traffic Mirroring та Batch Completions і трафік до двох провайдерів

І тут збережений тільки request_id chatcmpl-EFH9Ps2PbJxJYVTuyY9WDb2G5G5uT, тоді як запису chatcmpl-EFH9PYiZJspAjG366obRjy2jeiEWR нема взагалі.

Ба більше: chatcmpl-EFH9Ps2PbJxJYVTuyY9WDb2G5G5uT збережений як Model = openai/gpt-4.1, тоді як в output нашого скрипта request_id з EFH9Ps2PbJxJYVTuyY9WDb2G5G5uT був для gpt-5-2025-08-07!

Чому – бо LiteLLM паралельно виконує моделі з batch-запиту через abatch_completion(), при цьому logging_obj і litellm_call_id створюється ще до batch – і передаються в data до abatch_completion(), див. route_llm_request.py:

...
            models = [model.strip() for model in data.pop("model").split(",")]
            return llm_router.abatch_completion(models=models, **data)
...

В результаті маємо спільний logging context, значення в якому перезаписуються обома запитами.

^@%*&!!!!!

Замість висновків

Взагалі обидва варіанти працюють – запити дійсно йдуть.

Але, як бачимо – не все так гладко, як виглядало в документації.

Тому в результаті довелось робити власний custom callback, який сам виконує запити до нашої self-hosted моделі і створює OTel span з усіма необхідними атрибутами.

Чорнетка вже написана – скоро покажу, як це було реалізовано.

І, насправді – цей варіант навіть кращий, бо у нас набагато більше можливостей, ніж з використанням механізмів самої LiteLLM.

Loading