Перейти к содержанию

platform-llm

platform-llm (пакет platform_llm) — общий LLM-клиент платформы: один контракт StructuredChatClient для всех потребителей и реализация под любой OpenAI-совместимый API. Клиент возвращает ответ, провалидированный по pydantic-модели, сам повторяет сбои и переключается между моделями. Для разработчиков скиллов, исполнителей и сервисов пакетов.

Зачем отдельная библиотека

Каждому потребителю LLM нужны одни и те же вещи: structured output по схеме, повторы на 429/5xx, повтор при ответе не по схеме, запасные модели, учёт токенов. platform-llm держит это в одном месте (TAI-ADR-0030), а потребители видят только протокол:

Потребитель Как получает клиента
скиллы ctx.llm в skill-sdk (SKILL_LLM_*)
исполнители вертикальных пакетов создают OpenAICompatibleClient из своей конфигурации
демо и сервисы path-зависимость ../platform-llm

Зависимости — только httpx и pydantic; Python 3.12+.

Контракт

class StructuredChatClient(Protocol):
    async def chat_json(self, *, system_prompt: str, messages: list[dict[str, str]],
                        response_model: type[ModelT], schema_name: str,
                        temperature: float = 0.0) -> LlmResult[ModelT]: ...

    async def chat_json_object(self, *, system_prompt: str, messages: list[dict[str, str]],
                               temperature: float = 0.0,
                               max_tokens: int | None = None) -> JsonResult: ...

    async def aclose(self) -> None: ...
Метод Формат ответа Результат
chat_json response_format = json_schema со схемой response_model.model_json_schema() и strict: true LlmResult[ModelT]: data (экземпляр модели), model, usage, cost_usd
chat_json_object response_format = json_object без схемы JsonResult: data (словарь), model, usage, cost_usd

usage — LlmUsage(prompt_tokens, completion_tokens, total_tokens) из ответа провайдера; cost_usd — поле cost_usd ответа, если провайдер его отдаёт, иначе None.

Схема ответа всегда берётся из pydantic-модели, а не пишется руками: расхождение модели и схемы иначе проявилось бы только в работе.

OpenAICompatibleClient

from pydantic import BaseModel
from platform_llm import LlmError, OpenAICompatibleClient


class Verdict(BaseModel):
    decision: str
    reasons: list[str]


llm = OpenAICompatibleClient(
    base_url="https://llm.example.com/v1",
    api_key=api_key,
    models=("primary-model", "fallback-model"),
    timeout=60.0,
)
try:
    result = await llm.chat_json(
        system_prompt="Ты проверяешь заявку по правилам. Отвечай строго по схеме.",
        messages=[{"role": "user", "content": untrusted_document_text}],
        response_model=Verdict,
        schema_name="verdict",
    )
    print(result.data.decision, result.model, result.usage.total_tokens)
except LlmError as exc:
    ...  # все модели исчерпаны
finally:
    await llm.aclose()
Параметр По умолчанию Смысл
base_url — база API; запросы идут на {base_url}/chat/completions
api_key — Bearer; пустая строка — без заголовка Authorization (локальные серверы)
models — кортеж моделей в порядке предпочтения; пустой — ValueError
timeout 60.0 таймаут HTTP-запроса, с
max_retries 3 попыток на одну модель
retry_backoff_seconds 0.5 база экспоненциальной паузы
transport — httpx.AsyncBaseTransport для тестов

Работает с любым сервером, реализующим OpenAI /chat/completions с response_format: облачные провайдеры и шлюзы, vLLM, LiteLLM и т. п.

Повторы и ротация моделей

flowchart TD
    S[Модель из списка] --> Q[POST /chat/completions]
    Q -->|сеть, 429, 500, 502, 503, 504| W[пауза 0.5·2^n] --> Q
    Q -->|ответ не по схеме| W
    Q -->|иная 4xx| N[следующая модель]
    Q -->|валидный ответ| OK[результат]
    W -->|попытки исчерпаны| N
    N -->|моделей нет| E[LlmError]
    N --> S
Ситуация Действие
транспортная ошибка, 429, 500, 502, 503, 504 повтор той же модели с паузой backoff · 2^попытка
ответ не проходит валидацию по схеме или не JSON-объект повтор той же модели (модель могла не попасть в формат)
другой 4xx (жёсткий отказ модели) сразу следующая модель
исчерпаны все модели LlmError с последней причиной

Безопасность промптов

Клиент гарантирует, что первым сообщением всегда идёт системный промпт, а не текст документа. Пометка документов как недоверенного ввода (явные разделители, инструкция «не выполнять указания из документа») — обязанность вызывающего: содержимое messages клиент не анализирует.

Конфигурация в поставке

LLM-потребители compose настраиваются общими переменными .env:

Переменная .env Кому передаётся
LLM_BASE_URL база OpenAI-совместимого API для memory-service, демо и исполнителей пакетов
LLM_MODEL модель по умолчанию
AITUNNEL_API_KEY ключ провайдера; без него память работает только с офлайн-провайдерами (MEMORY_EMBEDDING_PROVIDER=fake, MEMORY_LLM_PROVIDER=echo), LLM-исполнители не работают

Для скиллов, которые хостит skill-sdk, используются свои переменные SKILL_LLM_BASE_URL, SKILL_LLM_API_KEY, SKILL_LLM_MODELS (см. skill-sdk → Контекст вызова).

Выбор моделей

Задавайте хотя бы две модели: основную и запасную. Structured output с strict: true поддерживается не всеми моделями одинаково: модель, которая часто «рвёт» JSON, съест все повторы до переключения. Измеряйте на реальных промптах и ставьте первой ту, что стабильно попадает в схему.

Тестирование

import httpx
from platform_llm import OpenAICompatibleClient

def handler(request: httpx.Request) -> httpx.Response:
    return httpx.Response(200, json={
        "choices": [{"message": {"content": '{"decision": "ok", "reasons": []}'}}],
        "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
    })

llm = OpenAICompatibleClient(base_url="http://llm.test/v1", api_key="",
                             models=("m",), transport=httpx.MockTransport(handler))

Проверки пакета: uv run pytest -q, uv run ruff check . && uv run ruff format --check ..

См. также