Готуємось до запуску 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 об’єднала два запити в одну сесію:
Відкриваємо цю session, і бачимо два requests – до gpt-4.1 і до gemma-4:
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 silent_model та OpenAI Chat Completions vs Responses API
Протестувавши все вручну і радісно потираючи ручки я задеплоїв зміни в наш Production LiteLLM, і почав чекати трейсів, щоб вже зайнятись evaluation.
Але чомусь в Type постійно були тільки LLM:
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 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 ми бачимо тільки один запит:
І тут збережений тільки 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.
![]()






