Ми зараз налаштовуємо власний hardware сервер, на якому хочемо покрутити self-hosted моделі. Але відразу переключати трафік клієнтів на них не можна – бо спочатку треба глянути, як ці self-hosted LLMs взагалі будуть працювати.
Тому загальна ідея зараз – продовжувати слати трафік до основного провайдеру, OpenAI та моделей 5.6, а паралельно налаштувати такий собі “hidden traffic mirroring” – і отримані від клієнтів запити відправляти до нашої Gemma-4 (поки тестуємо на ній).
А потім, маючи респонси від двох моделей – налаштувати порівняння і оцінку відповідей, але про це вже в наступній частині.
У LiteLLM є власні механізми для такої паралелізації запитів, але, як розбирав в попередньому пості LiteLLM: Traffic Mirroring та Batch Completions і трафік до двох провайдерів – жоден з них нам не підійшов.
Отже – будемо писати свій “dirty hack” з блекджеком і трейсами, а головна “фіча” цього рішення – налаштуємо створення власного OTel span зі своїми атрибутами.
Note: описане в цьому пості рішення цілком робоче, користувались ним, але пізніше трохи переробив його і додав Redis – Custom Callback пише задачу в Redis stream, звідти задачі читає окремий Mirror Worker, і вже він робить власне передачу запиту до нашого сервера. Про це детальніше буде в наступному пості по LLM Evaluations.
Сам LiteLLM у нас деплоїться з Helm, писав в LiteLLM: AI Gateway в Kubernetes та метрики до VictoriaMetrics, тому і Custom Callback будемо робити з ним.
Зміст
Загальна ідея
Суть задумки така:
- створюємо Python-скрипт, підключаємо його як Custom Callback до нашої LiteLLM
- після кожного успішного запиту до primary-моделі LiteLLM викликає callback і передає йому оригінальний промт, який відправляв клієнт, ім’я primary моделі, на яку запит відправлявся, та response, отриманий від цієї моделі
- наш callback відправляє той самий запит від клієнта до нашої self-hosted LLM, але відповідь клієнту не повертається – ми просто її зберігаємо, аби потім мати можливість порівняти відповідь основної моделі, і нашої
- LiteLLM викликає callback використовуючи загальний OpenTelemetry trace context, тому ми створюємо власний span з тим самим
trace_id, і в атрибутах нашого span зберігаємо оригінальний запит та відповіді основної моделі і нашої
В результаті в VictoriaTraces у нас буде trace приблизно такої структури:
POST /v1/chat/completions
├── chat primary-model
└── our-custom-span
├── original prompt
├── primary model response
└── our model response
Наш власний сервер має hostname “Matrix” – тому в тексті, коли буду писати про наш сервер для self-hosted буде зустрічатись таке ім’я.
Створення Callback
Документація LiteLLM – Custom Callbacks.
Власне, починаємо писати скрипт.
Для початку він буде тільки писати в лог, а підключати будемо через Helm та ConfigMap.
Додаємо файл helm/files/traffic_mirror_callback.py:
"""LiteLLM success callback used as the entry point for traffic mirroring.
The first PoC step only confirms that LiteLLM invokes the callback and exposes
the primary model and response ID. The Matrix request and OTEL span will be
added after the callback is wired into the Helm deployment.
"""
from datetime import datetime
from typing import Any
from litellm._logging import verbose_proxy_logger
from litellm.integrations.custom_logger import CustomLogger
class TrafficMirrorCallback(CustomLogger):
async def async_log_success_event(
self,
kwargs: dict[str, Any],
response_obj: Any,
start_time: datetime,
end_time: datetime,
) -> None:
verbose_proxy_logger.info(
"TRAFFIC_MIRROR_CALLBACK primary_model=%s response_id=%s",
kwargs.get("model"),
getattr(response_obj, "id", None),
)
traffic_mirror_callback = TrafficMirrorCallback()
Тут ми:
- описуємо власний клас
TrafficMirrorCallback, який наслідуєCustomLoggerсамої LiteLLM - з
async def async_log_success_event()описуємо власну реалізацію методу із класуCustomLogger - і всередині нашого
async_log_success_event()додаємо виклик методуverbose_proxy_logger.info()– стандартного логгеру LiteLLM
Тоді при обробці запиту від клієнта LiteLLM виконує цей запит до основного провайдера і моделі, після чого викликає по черзі callbacks, яким в kwargs передає всі параметри цього запиту – а ми їх поки використовуємо тільки для запису в лог імені моделі – kwargs.get("model").
При цьому наш callback спрацьовує тільки при успішних запитах до основної моделі – бо ми описали тільки метод async_log_success_event(). Якщо треба записувати failed requests – то в CustomLogger є метод async_log_failure_event() (див. Custom Callback Class [Async]).
Підключення файлу traffic_mirror_callback.py до LiteLLM Pods
Створюємо файл helm/templates/traffic-mirror-callback-configmap.yaml, де описуємо ConfigMap, який в data буде зберігати зміст файлу traffic_mirror_callback.py:
apiVersion: v1
kind: ConfigMap
metadata:
name: litellm-traffic-mirror-callback
data:
traffic_mirror_callback.py: |
{{ .Files.Get "files/traffic_mirror_callback.py" | indent 4 }}
У values-файлах для чарту додаємо новий volume з цього ConfigMap:
...
volumes:
- name: traffic-mirror-callback
configMap:
name: litellm-traffic-mirror-callback
...
І додаємо volumeMounts з цього volume як файл в LiteLLM Pods:
...
volumeMounts:
- name: traffic-mirror-callback
mountPath: /etc/litellm/traffic_mirror_callback.py
subPath: traffic_mirror_callback.py
...
Деплоїмо, перевіряємо, що файл створився:
$ kubectl -n test-litellm-ns exec deploy/litellm -- \ ls -l /etc/litellm/traffic_mirror_callback.py -rw-r--r-- 1 root root 938 Aug 21 12:15 /etc/litellm/traffic_mirror_callback.py
Але зараз він не викликається – тільки додається до контейнерів.
Додавання виклику Custom Callback до LiteLLM
В конфіг до litellm_settings.callbacks додаємо виклик нашого traffic_mirror_callback:
...
litellm_settings:
callbacks:
- prometheus
- arize_phoenix
- traffic_mirror_callback.traffic_mirror_callback
...
Тепер під час запуску LiteLLM:
- імпортує
/etc/litellm/traffic_mirror_callback.py - знайде
traffic_mirror_callback– instance нашого класуTrafficMirrorCallback() - додасть його до списку
callbacks - і буде викликати
async_log_success_event()після успішних запитів до основної моделі, яку передає клієнт
Запускаємо тестовий скрипт (він є в Налаштування silent_model попереднього посту):
$ ./test_single.py request_id: chatcmpl-EFIYxl58uvTqnygPeYFjjSRBuQ6yU model: gpt-4.1
Перевіряємо логи в VictoriaLogs – шукаємо по “TRAFFIC_MIRROR_CALLBACK“, який задавали в скрипті – verbose_proxy_logger.info( "TRAFFIC_MIRROR_CALLBACK primary_model=%s response_id=%s"):
Все працює.
Отримання Original Prompt та Primary Model Response
Зараз ми в лог пишемо тільки ім’я моделі та response_id – але для того, щоб передати запит до нашого Matrix нам потрібен, власне, текст самого запиту.
Для цього до verbose_proxy_logger.info() нам необхідно передати дані із kwargs з промптом, а потім розпарсити response_obj, де буде текст відповіді основної моделі.
Оновлюємо скрипт traffic_mirror_callback.py:
"""LiteLLM success callback used as the entry point for traffic mirroring.
The callback currently logs the primary request and response. The Matrix
request and OTEL span will be added in the next PoC steps.
"""
import json
from datetime import datetime
from typing import Any
from litellm._logging import verbose_proxy_logger
from litellm.integrations.custom_logger import CustomLogger
def _json(value: Any) -> str:
"""Serialize callback data into a single log line."""
if hasattr(value, "model_dump"):
value = value.model_dump()
return json.dumps(
value,
ensure_ascii=False,
default=str,
)
def _primary_input(kwargs: dict[str, Any]) -> Any:
"""Read input from either Chat Completions or Responses API kwargs."""
return kwargs.get("messages") or kwargs.get("input")
def _primary_output(response_obj: Any) -> Any:
"""Read assistant output from either Chat Completions or Responses API."""
choices = getattr(response_obj, "choices", None)
if choices:
message = getattr(choices[0], "message", None)
return getattr(message, "content", None)
output_text = getattr(response_obj, "output_text", None)
if output_text:
return output_text
return getattr(response_obj, "output", None)
class TrafficMirrorCallback(CustomLogger):
async def async_log_success_event(
self,
kwargs: dict[str, Any],
response_obj: Any,
start_time: datetime,
end_time: datetime,
) -> None:
verbose_proxy_logger.info(
"TRAFFIC_MIRROR_CALLBACK primary_model=%s response_id=%s "
"input=%s primary_response=%s",
kwargs.get("model"),
getattr(response_obj, "id", None),
_json(_primary_input(kwargs)),
_json(_primary_output(response_obj)),
)
traffic_mirror_callback = TrafficMirrorCallback()
Тут:
- додали власний “JSON serializer” – форматуємо request та response в JSON для запису в лог
- додали
_primary_input(), яка парсить текст request – і для Response API, і для Chat Completions, бо клієнти у нас користуються обома- тут є баг з форматами, див. нижче у “Bug з Responses API vs Chat Completions API messages format“
- і в
_primary_output()парсимо відповідь від primary model
Деплоїмо, робимо тестовий запит, і маємо в логах вже всі потрібні дані:
{
"_msg": "\u001b[92m13:00:46 - LiteLLM Proxy:INFO\u001b[0m: traffic_mirror_callback.py:49 - TRAFFIC_MIRROR_CALLBACK primary_model=gpt-4.1 response_id=chatcmpl-EFJ9Rio82fSnlf6PpkeUXYuxEWyGa input=[{\"role\": \"user\", \"content\": \"Test #1: this is a test request, write a short poem\"}] primary_response=\"A whisper drifts upon the air, \\nSoft as dawn’s first gentle light— \\nA silent hope, a quiet dare, \\nBorn within the heart of night. \\n\\nEven in this fleeting rhyme, \\nA test becomes a start— \\nWords that mark the stretch of time, \\nAnd poetry that stirs the heart.\"",
"_stream": "{namespace=\"test-litellm-ns\"}",
...
}
Реалізація “traffic mirroring”: відправка request до self-hosted LLM
Тепер, як маємо сам request message text – можемо додати передачу його до нашої власної моделі, тобто вже починати реалізовувати той самий “traffic mirroring”.
Змінні з параметрами для нашого серверу мають префікс MATRIX_ – аби явно відобразити, що це стосується нашого серверу з іменем Matrix, і їх тут три – URL серверу і порт llama.cpp на ньому, ім’я моделі, на яку передаємо запит, і таймаут очікування відповіді:
"""LiteLLM callback that mirrors successful requests to Matrix."""
import json
from datetime import datetime
from typing import Any
import httpx
from litellm._logging import verbose_proxy_logger
from litellm.integrations.custom_logger import CustomLogger
MATRIX_URL = "http://matrix.neoc.vpn.ops.example.co:31000/v1/chat/completions"
MATRIX_MODEL = "gemma-4-26b-a4b-it-q8"
MATRIX_TIMEOUT_SECONDS = 120.0
def _json(value: Any) -> str:
"""Serialize callback data into a single log line."""
if hasattr(value, "model_dump"):
value = value.model_dump()
return json.dumps(value, ensure_ascii=False, default=str)
def _primary_input(kwargs: dict[str, Any]) -> Any:
"""Read input from either Chat Completions or Responses API kwargs."""
return kwargs.get("messages") or kwargs.get("input")
def _primary_output(response_obj: Any) -> Any:
"""Read assistant output from either Chat Completions or Responses API."""
choices = getattr(response_obj, "choices", None)
if choices:
message = getattr(choices[0], "message", None)
return getattr(message, "content", None)
output_text = getattr(response_obj, "output_text", None)
if output_text:
return output_text
return getattr(response_obj, "output", None)
async def _request_matrix(messages: Any) -> dict[str, Any]:
"""Send a copy of the primary request directly to Matrix."""
async with httpx.AsyncClient(timeout=MATRIX_TIMEOUT_SECONDS) as client:
response = await client.post(
MATRIX_URL,
json={
"model": MATRIX_MODEL,
"messages": messages,
"temperature": 0,
"max_tokens": 1024,
},
)
response.raise_for_status()
return response.json()
class TrafficMirrorCallback(CustomLogger):
async def async_log_success_event(
self,
kwargs: dict[str, Any],
response_obj: Any,
start_time: datetime,
end_time: datetime,
) -> None:
primary_input = _primary_input(kwargs)
verbose_proxy_logger.info(
"TRAFFIC_MIRROR_CALLBACK primary_model=%s response_id=%s "
"input=%s primary_response=%s",
kwargs.get("model"),
getattr(response_obj, "id", None),
_json(primary_input),
_json(_primary_output(response_obj)),
)
try:
matrix_response = await _request_matrix(primary_input)
matrix_choice = matrix_response["choices"][0]
matrix_message = matrix_choice["message"]
verbose_proxy_logger.info(
"TRAFFIC_MIRROR_MATRIX primary_response_id=%s "
"matrix_model=%s matrix_response_id=%s finish_reason=%s "
"matrix_response=%s matrix_reasoning=%s",
getattr(response_obj, "id", None),
MATRIX_MODEL,
matrix_response.get("id"),
matrix_choice.get("finish_reason"),
_json(matrix_message.get("content")),
_json(matrix_message.get("reasoning_content")),
)
except Exception:
verbose_proxy_logger.exception(
"TRAFFIC_MIRROR_MATRIX_FAILED primary_response_id=%s",
getattr(response_obj, "id", None),
)
traffic_mirror_callback = TrafficMirrorCallback()
З основних змін тут – нова функція _request_matrix(), в якій з httpx.AsyncClient() робимо запит до llama.cpp API ендпоінту.
Можна було б зробити і з OpenAI клієнтом – але тут робив все максимально просто.
До async_log_success_event() додаємо спочатку виклик функції _request_matrix(), якій передаємо оригінальний request від клієнта, а потім записуємо response від Matrix.
Запускаємо, шукаємо в логах по “TRAFFIC_MIRROR_MATRIX“:
{
"_msg": "\u001b[92m13:11:39 - LiteLLM Proxy:INFO\u001b[0m: traffic_mirror_callback.py:82 - TRAFFIC_MIRROR_MATRIX primary_response_id=chatcmpl-EFJJuVK9gz6N2OAvV2ffBmyELU5Ku matrix_model=gemma-4-26b-a4b-it-q8 matrix_response_id=chatcmpl-96X4DynEjuuPcrx21elAZjhq8uirB8Vw finish_reason=stop matrix_response=\"A spark of thought, a line of code,\\nA journey on a digital road.\\nThe test is sent, the signal flies,\\nBeneath the glow of virtual skies.\" matrix_reasoning=\"* Input: \\\"Test #1: this is a test request, write a short poem\\\"\\n * Intent: The user is testing the system's ability to follow a simple instruction (writing a short poem).\\n * Constraint: \\\"short poem\\\".\\n\\n * Topic: Since it's a \\\"test,\\\" I could write about testing, or just a generic pleasant poem. A poem about the act of testing or a simple nature poem works well.\\n * Structure: A few lines, maybe a rhyme scheme (AABB or ABAB).\\n\\n * *Option 1 (Meta/Testing):*\\n A spark of thought, a line of code,\\n A journey on a digital road.\\n The test is sent, the signal flies,\\n Beneath the glow of virtual skies.\\n\\n * *Option 2 (Nature/Generic):*\\n The sun dips low behind the hill,\\n The evening air is calm and still.\\n A single star begins to peep,\\n As all the world prepares for sleep.\\n\\n Option 1 is more clever given the context of \\\"Test #1\\\".\\n\\n A spark of thought, a line of code,\\n A journey on a digital road.\\n The test is sent, the signal flies,\\n Beneath the glow of virtual skies.\\n\\n * Is it short? Yes.\\n * Is it a poem? Yes.\\n * Does it address the prompt? Yes.\"",
"_stream": "{namespace=\"test-litellm-ns\"}",
...
}
Тепер маємо:
primary_response_idmatrix_response_id- імена моделей
Але все це зараз тільки в логах, а хочеться мати красиво в трейсах разом з іншою телеметрією.
Bug з Responses API vs Chat Completions API messages format
В цій версії _primary_input() є баг – input з Responses API не обов’язково має той самий формат, що і messages з Chat Completions API.
- зараз до Matrix передається
primary_input, куди записується змістkwargs.get("messages")– для Chat Completions API, абоkwargs.get("input")– для Responses API- це в
_request_matrix(primary_input), аprimary_inputстворюється із_primary_input(), який виконуєreturn kwargs.get("messages") or kwargs.get("input")
- це в
- але до Matrix ми виконуємо запит на Chat Completions API –
MATRIX_URL = "[...]/v1/chat/completions" - при цьому в
_request_matrix()ми передаємо просто_request_matrix(primary_input), який формує поле"messages"
Помітив це вже пізніше і виправив у наступній версії callback – додав функцію, яка перевіряє формат і конвертує його в формат для Chat Completions API.
Тут вже міняти не буду, але як допишу наступний пост – додам сюди посилання на цей фікс.
OpenTelemetry traces та Custom Span до VictoriaTraces
Тут основна ідея, що ми створюємо власний span – але він додається до parent span, який LiteLLM створює при отриманні запиту від клієнта.
Тоді отримуємо всі дані в одному trace – і по самому запиту, і запиту до primary model, і по нашому запиту до Matrix.
Оновлюємо код:
"""LiteLLM callback that mirrors successful requests to Matrix."""
import json
from datetime import datetime
from typing import Any
import httpx
from litellm._logging import verbose_proxy_logger
from litellm.integrations.custom_logger import CustomLogger
from opentelemetry import trace
from opentelemetry.trace import SpanKind, Status, StatusCode
MATRIX_URL = "http://matrix.neoc.vpn.ops.example.co:31000/v1/chat/completions"
MATRIX_MODEL = "gemma-4-26b-a4b-it-q8"
MATRIX_TIMEOUT_SECONDS = 120.0
tracer = trace.get_tracer("litellm.traffic_mirror")
def _json(value: Any) -> str:
"""Serialize callback data into a single log line."""
if hasattr(value, "model_dump"):
value = value.model_dump()
return json.dumps(value, ensure_ascii=False, default=str)
def _primary_input(kwargs: dict[str, Any]) -> Any:
"""Read input from either Chat Completions or Responses API kwargs."""
return kwargs.get("messages") or kwargs.get("input")
def _primary_output(response_obj: Any) -> Any:
"""Read assistant output from either Chat Completions or Responses API."""
choices = getattr(response_obj, "choices", None)
if choices:
message = getattr(choices[0], "message", None)
return getattr(message, "content", None)
output_text = getattr(response_obj, "output_text", None)
if output_text:
return output_text
return getattr(response_obj, "output", None)
async def _request_matrix(messages: Any) -> dict[str, Any]:
"""Send a copy of the primary request directly to Matrix."""
async with httpx.AsyncClient(timeout=MATRIX_TIMEOUT_SECONDS) as client:
response = await client.post(
MATRIX_URL,
json={
"model": MATRIX_MODEL,
"messages": messages,
"temperature": 0,
"max_tokens": 1024,
},
)
response.raise_for_status()
return response.json()
class TrafficMirrorCallback(CustomLogger):
async def async_log_success_event(
self,
kwargs: dict[str, Any],
response_obj: Any,
start_time: datetime,
end_time: datetime,
) -> None:
primary_input = _primary_input(kwargs)
verbose_proxy_logger.info(
"TRAFFIC_MIRROR_CALLBACK primary_model=%s response_id=%s "
"input=%s primary_response=%s",
kwargs.get("model"),
getattr(response_obj, "id", None),
_json(primary_input),
_json(_primary_output(response_obj)),
)
try:
with tracer.start_as_current_span(
f"traffic_mirror {MATRIX_MODEL}",
kind=SpanKind.CLIENT,
) as span:
span.set_attribute("gen_ai.operation.name", "chat")
span.set_attribute("gen_ai.provider.name", "llama.cpp")
span.set_attribute("gen_ai.request.model", MATRIX_MODEL)
span.set_attribute("gen_ai.request.max_tokens", 1024)
span.set_attribute("gen_ai.request.temperature", 0.0)
span.set_attribute("gen_ai.input.messages", _json(primary_input))
span.set_attribute(
"traffic_mirror.primary.model",
str(kwargs.get("model")),
)
span.set_attribute(
"traffic_mirror.primary.response_id",
str(getattr(response_obj, "id", None)),
)
span.set_attribute(
"traffic_mirror.primary.response",
_json(_primary_output(response_obj)),
)
matrix_response = await _request_matrix(primary_input)
matrix_choice = matrix_response["choices"][0]
matrix_message = matrix_choice["message"]
span.set_attribute(
"gen_ai.response.id",
str(matrix_response.get("id")),
)
span.set_attribute(
"gen_ai.response.model",
str(matrix_response.get("model", MATRIX_MODEL)),
)
span.set_attribute(
"gen_ai.response.finish_reasons",
_json([matrix_choice.get("finish_reason")]),
)
span.set_attribute(
"gen_ai.output.messages",
_json([matrix_message]),
)
span.set_attribute(
"traffic_mirror.matrix.response",
_json(matrix_message.get("content")),
)
span.set_attribute(
"traffic_mirror.matrix.reasoning",
_json(matrix_message.get("reasoning_content")),
)
span.set_status(Status(StatusCode.OK))
verbose_proxy_logger.info(
"TRAFFIC_MIRROR_MATRIX primary_response_id=%s "
"matrix_model=%s matrix_response_id=%s finish_reason=%s "
"matrix_response=%s matrix_reasoning=%s",
getattr(response_obj, "id", None),
MATRIX_MODEL,
matrix_response.get("id"),
matrix_choice.get("finish_reason"),
_json(matrix_message.get("content")),
_json(matrix_message.get("reasoning_content")),
)
except Exception:
verbose_proxy_logger.exception(
"TRAFFIC_MIRROR_MATRIX_FAILED primary_response_id=%s",
getattr(response_obj, "id", None),
)
traffic_mirror_callback = TrafficMirrorCallback()
Тут:
- додаємо імпорти
opentelemetry - створюємо tracer –
trace.get_tracer– з ім’ям “litellm.traffic_mirror“ - створюємо сам custom span –
tracer.start_as_current_span() - зі
span.set_attribute()задаємо всі атрибути, які хочемо бачити, імена зgen_ai– це стандартний OTel semantic convention, див. GenAI Attributes
Деплоїмо, перевіряємо трейси по name:~"traffic_mirror":
{resource_attr:service.name="litellm"} "span_attr:litellm.metadata.user_api_key_alias":="ops-testing"
| name:~"traffic_mirror"
Знаходимо його trace_id і з фільтром (name:="chat gpt-4.1" OR name:="traffic_mirror gemma-4-26b-a4b-it-q8") дивимось спани і для primary model – і для нашої:
{resource_attr:service.name="litellm"}
trace_id:="bbd5c3e93a59186f88b03581dfd59877"
(name:="chat gpt-4.1" OR name:="traffic_mirror gemma-4-26b-a4b-it-q8")
| fields
_time,
trace_id,
span_id,
parent_span_id,
name,
duration,
status_code,
"span_attr:gen_ai.provider.name",
"span_attr:gen_ai.request.model",
"span_attr:gen_ai.input.messages",
"span_attr:gen_ai.response.id",
"span_attr:gen_ai.response.finish_reasons",
"span_attr:gen_ai.output.messages"
Наступна задача, використовуючи всі ці дані – відправляти текст запиту і обидва респонси до “судді” – третьої LLM. Вона буде оцінювати обидві відповіді, виставляти свою оцінку, а її будемо писати у вигляді метрик до VictoriaMetrics.
Це вже зроблено, працює, чорнетка посту є – сподіваюсь, допишу, бо там доволі багато тексту вийшло.
![]()


