Архитектура¶
Как устроены части, что что имеет право импортировать и что на самом деле происходит при запуске команды. Каждая диаграмма здесь — текст в репозитории, поэтому её видно в диффе.
Слои¶
graph TD
subgraph foundation[" "]
CORE["<b>core</b><br/><small>поля · метакласс · Options · реестр<br/>настройки · сигналы · исключения</small>"]
end
subgraph persistence[" "]
META["<b>metastore</b><br/><small>10 таблиц · сессии</small>"]
MIG["<b>migrations</b><br/><small>автодетектор · writer · executor</small>"]
STORE["<b>storage</b><br/><small>артефакты</small>"]
end
subgraph families[" "]
DATA["<b>data</b><br/><small>Dataset · QuerySet · Sources</small>"]
TRAIN["<b>training</b><br/><small>Model · тренеры · раны · свипы</small>"]
AGENTS["<b>agents</b><br/><small>Agent · инструменты · память · провайдеры</small>"]
EVALS["<b>evals</b><br/><small>Eval · скореры</small>"]
end
subgraph surfaces[" "]
ADMIN["<b>admin</b>"]
SERVE["<b>serve</b>"]
CLI["<b>management</b><br/><small>manage.py</small>"]
end
CORE --> META
CORE --> STORE
META --> MIG
CORE --> DATA
CORE --> TRAIN
CORE --> AGENTS
CORE --> EVALS
META -.-> DATA
META -.-> TRAIN
META -.-> AGENTS
META -.-> EVALS
DATA --> ADMIN
TRAIN --> ADMIN
AGENTS --> ADMIN
EVALS --> ADMIN
DATA --> SERVE
TRAIN --> SERVE
AGENTS --> SERVE
ADMIN --> CLI
SERVE --> CLI
Правила, которые проверяют и tests/, и ревью:
| Слой | Может импортировать | Не должен импортировать |
|---|---|---|
core |
стандартную библиотеку | что-либо ещё из mlango |
metastore, storage |
core |
четыре семейства |
data, training, agents, evals |
core, metastore, storage |
друг друга |
admin, serve, management |
всё, но через _meta |
— |
Важнее всего третье. Именно из-за того, что семейства не импортируют друг друга, можно пользоваться агентской половиной без ML-половины, и поэтому проект, объявляющий только датасеты, не загружает ни строчки агентского кода.
Помощник, который нужен двум семействам, живёт в core. core/serialization.py
существует ровно поэтому: agents и evals оба лезли в приватную функцию
внутри training.
Как декларация становится метаданными¶
sequenceDiagram
participant P as Тело вашего класса
participant M as DeclarativeMeta
participant O as Options (_meta)
participant R as Реестр
P->>M: class Urgency(Model)
M->>M: собрать Field'ы в порядке объявления
M->>M: пройти MRO за унаследованными полями и опциями Meta
M->>O: собрать Options(kind, label, app_label, fields, extras)
O->>O: сверить ключи Meta с _meta_options
M->>P: заменить каждое Field на FieldDescriptor
M->>P: вызвать _prepare() (Dataset получает .objects)
M->>R: зарегистрировать, если не Meta.abstract
Note over R: apps.get_model("tickets.Urgency") теперь разрешается
Два следствия, о которых стоит знать.
Опции Meta наследуются. Тела классов в Python сами по себе не наследуются,
поэтому подкласс, написавший свой class Meta, молча терял бы всё объявленное
родителем. Options.inherit_extras() дозаполняет то, чего подкласс не назвал, —
именно это делает возможным переиспользуемый базовый класс вроде
TextClassifier. abstract исключён, иначе каждый подкласс абстрактной базы был
бы абстрактным.
Неизвестный ключ в Meta — ошибка. С перечислением допустимых: молча
проигнорированная опция — это баг, который находят неделями позже.
Что на самом деле делает manage.py train¶
sequenceDiagram
autonumber
participant U as Вы
participant C as manage.py
participant R as Реестр
participant M as Model
participant Q as QuerySet
participant T as Тренер
participant DB as Метастор
participant S as Хранилище
U->>C: train tickets.Urgency -p C=2.0
C->>C: читает настройки, mlango.setup()
C->>R: находит datasets/models/agents/evals по приложениям
C->>R: get_model("tickets.Urgency")
C->>M: Urgency(C=2.0) — поля валидируют значение
M->>DB: RunContext.start() — сид, устройство, git-коммит, хост
M->>Q: dataset.objects → сплит по хешу ключа
M->>DB: пишет _data_fingerprint
M->>T: fit(train, validation, run, callbacks)
loop каждая эпоха
T->>DB: log_metrics()
T->>C: callbacks.emit(on_epoch_end)
end
T->>S: сохраняет обученный артефакт
M->>DB: регистрирует ModelVersion v1
M->>DB: ран завершён
Всё после пятого шага происходит независимо от того, просили вы об этом или нет. В этом и состоит инверсия: то, что легко забыть, — это то, чего вы не пишете.
Метастор¶
Десять таблиц, по умолчанию SQLite, та же схема на Postgres.
erDiagram
RUN ||--o{ METRIC : "записывает"
RUN ||--o{ ARTIFACT : "пишет"
RUN ||--o{ MODEL_VERSION : "регистрирует"
RUN ||--o{ TRACE : "порождает"
RUN ||--o{ EVAL_RESULT : "оценивает"
DATASET_VERSION ||--o{ RUN : "был прочитан"
TRACE ||--o{ SPAN : "по шагам"
MIGRATION {
string app
string name
datetime applied
}
RUN {
string uuid
string kind
string target
string status
json params
json summary
string git_commit
}
MODEL_VERSION {
int version
string stage
string path
}
MODEL_VERSION ||--o{ PREDICTION : "ответила на"
PREDICTION {
int version
json inputs
json output
datetime created_at
}
DATASET_VERSION {
int version
string content_hash
string fingerprint
}
RUN.kind — это train, sweep, eval или agent. Поэтому свип и его
попытки, или вызов агента и обучение модели, живут в одной истории и одной
админке.
У версии датасета намеренно разделены две идентичности: fingerprint — хеш
декларации, content_hash — хеш строк. Изменение схемы и изменение данных —
разные события, и различить их через полгода и есть смысл хранить оба.
Точки расширения¶
Каждая — это запись в настройках с путём к классу, поэтому замена любой из них никогда не означает форк фреймворка.
| Точка | Настройка | Контракт |
|---|---|---|
| Тренер | TRAINERS |
fit, predict, save, load |
| LLM-провайдер | PROVIDERS |
один метод: complete() |
| Хранилище артефактов | STORAGE["BACKEND"] |
path, open, save_bytes, read_bytes, exists, delete, size, listdir |
| Middleware сервинга | SERVE_MIDDLEWARE |
ASGI middleware, снаружи внутрь |
| Колбэки обучения | DEFAULT_CALLBACKS |
любое подмножество хуков Callback |
| Источник данных | Meta.source |
итерируемое из словарей, по желанию count() |
| Команды | <app>/management/commands/ |
Command(BaseCommand), можно переопределить встроенную |
Узкие контракты узки намеренно. У провайдера один метод, потому что цикл агента, диспетчеризация инструментов, память и трейсинг принадлежат фреймворку: смена провайдера не должна уметь менять поведение агента.
Пути запроса¶
graph LR
REQ([HTTP-запрос]) --> MW["SERVE_MIDDLEWARE<br/><small>логи · api-ключ · rate limit · guardrails</small>"]
MW --> R{путь}
R -->|/admin| ADM["Админка<br/><small>Jinja2, встроенный SVG</small>"]
R -->|/api/...| EP["Эндпоинт из декларации"]
R -->|/api/docs| DOC["OpenAPI из _meta"]
EP --> LOAD["Зарегистрированная версия<br/><small>грузится один раз, кэшируется</small>"]
ADM --> DB[(Метастор)]
LOAD --> ST[(Хранилище)]
Админка и API — одно ASGI-приложение, поэтому manage.py runserver в разработке
это один процесс, а в продакшене тот же объект за gunicorn.