Без лишних предисловий приступаем.

Все артефакты: веса на Hugging Face и исходники на Codeberg, как обычно будет приложены в конце статьи.

О чем статья?

Расскажу, как я обучил языковую модель gemma-3 на 270 млн параметров (от Google) управлять гусеничным роботом с робо‑рукой в среде Mujoco, через команды на естественном языке от человека.

Он может свободно перемещаться по карте, идти вперед и назад, поворачиваться влево‑вправо, хватать и опускать предметы.

Ниже будет приложена видео‑демонстрация длиной 1 минуту.

Видео-демонстрация, в конце робот случайно уехал за кадр
Видео‑демонстрация, в конце робот случайно уехал за кадр

Как возникла идея?

У меня лежит дома DIY‑набор гусеничного робота с робо‑клешней, и когда я познакомился с моделью gemma-3-270m, которая удивительно хорошо соображает для своего размера — у меня возникла идея ее туда загрузить и заставить этим роботом управлять.

А вот и DIY-набор гусеничного робота с клешней
А вот и DIY‑набор гусеничного робота с клешней


Эта модель, по моим расчетам, без проблем влезла бы в Raspberry Pi Zero 2 W. Идея заключалась в том чтобы сделать интеллектуального робота, который может передвигаться по командам человека, описанными в свободной форме, навроде:

  • «поверни налево»

  • «пройди вперед на 10 метров»

  • «подними коробку»

Но перед тем как копаться в железе, стоило проверить возможности самой модели, благо все это можно проверить заранее в симуляции, об этом и будет статья.

Начинаем эксперименты

Для начала стоило проверить, как дефолтный Gemma-3-270m без файнтюнинга справляется с соблюдением инструкции.

User:
You are a robot controller. Convert user commands to JSON.
Available actions:
 - {"action": "move", "direction": "forward|backward", "distance_cm":
 - {"action": "rotate", "direction": "left|right", "angle_deg":
 - {"action": "pick_up", "target": "
 - {"action": "place", "target": "
 - {"action": "stop"}
Output only JSON, no explanations. Now just say Ready, and then the commands will follow.

Model: Ready
User:  Command: drive forward 30 centimeters
Model: Drive forward 30 centimeters.
User:  Are you idiot?
Model: No.

Как вы можете судить по этому тарантиновскому диалогу — модель с задачей справляется не очень. Нужен файн‑тюнинг.

Помимо самой модели, нужно было определиться со средой, где модель будет действовать.

Выбор пал на Mujoco — отличный симулятор для роботехники, физика там не такая реалистичная, как в Isaac Sim от NVIDIA, но при этом все летает на обычном ноутбуке, плюсом я уже был знаком с ним по моим прошлым пет‑проектам.

На сайте документации Mujoco можно иногда увидеть поистине странные вещи...
На сайте документации Mujoco можно иногда увидеть поистине странные вещи...

Насчет языка команд тоже думать долго не пришлось. Gemma-3-270m очень маленькая модель, которая лучше всего работает с английским текстом, нет смысла «напрягать» ее веса еще для того чтобы лучше понимать русский язык, может получиться так, что ей не хватит места для всего сразу. Так что язык для команд будет чисто английский.

Датасет будет генерироваться синтетический, через доступные на OpenRouter бесплатно мощные модели gpt‑oss-120b (от OpenAI) и nemotron‑super-120b (от NVIDIA).

По итогу нашу начальную цель можно сформулировать так

Зафайнтюнить Gemma-3 270M на трансляцию английских команд в валидный JSON, управляющий гусеничным роботом в MuJoCo, на основе синтетического датасета, сгенерированного моделями gpt‑oss-120b и nemotron‑super-120b.

Эксперимент будет разделен на 2 фазы.

  • 1 фаза: создание гусеничного робота в Mujoco, без руки и создание синтетического датасета для его управления, обучение gemma-3 270M на основе этого датасета, проверка обученной модели в симуляции.

  • 2 фаза: добавление к созданному в Mujoco гусеничному роботу конечности‑клешни, добавление двух новых действий — поднять и опустить, генерация дополненного синтетического датасета где будут описаны эти действия, повторный файн‑тюнинг модели, повторная проверка обученной модели в симуляции с новыми действиями.

1 фаза — Генерируем синтетический датасет

Цель шага: получить пары {"instruction": <англ. текст>, "output": <JSON команд>}, на которых дообучается Gemma-3-270M.

Источник — синтетика: ~70 ручных примеров → инфляция крупной моделью (120B) через OpenRouter → жёсткая валидация по JSON Schema.

1. Реальный промпт генератора (что уходит в модель‑генератор синтетики)

Воспроизводится командой python -m dataset_gen.generate --dry-run.

SYSTEM (схема + правила)

Начинается полной JSON Schema, для примера будет взят отрывок схемы движения (назад‑вперед):

{
  "type": "object", "required": ["commands"],
  "properties": {"commands": {"type": "array",
    "items": {"$ref": "#/definitions/command"}}},
  "definitions": {
    "command": {"oneOf": [
      {"$ref": "#/definitions/move"}, {"$ref": "#/definitions/turn"},
      {"$ref": "#/definitions/stop"}, {"$ref": "#/definitions/wait"},
      {"$ref": "#/definitions/grasp"}, {"$ref": "#/definitions/release"}
    ]},
    "move": {"required": ["action","direction","distance_m"],
      "properties": {"action": {"const": "move"},
        "direction": {"enum": ["forward","backward"]},
        "distance_m": {"type": "number", "exclusiveMinimum": 0,
                       "maximum": 100},
        "speed": {"enum": ["slow","normal","fast"]}}}
  }
}

Затем — текстовые правила:

ROLE: You translate ONE English natural-language instruction for a
tracked robot into a JSON object that strictly validates against the
schema above.

HARD RULES:
- Output ONLY the JSON object, no prose, no markdown fences.
- Always wrap commands in {"commands": [ ... ]}.
- distance_m and angle_deg are ALWAYS positive; sign via "direction".
- "speed" optional enum (slow|normal|fast) — only if pace implied.
- left = counter-clockwise (CCW). right = clockwise (CW).

INTERPRETATION CONVENTIONS:
- Distance: unspecified -> 1.0 m; "a bit"/"a little" -> 0.5;
  "a touch"/"slightly" -> 0.3; "a meter and a half" -> 1.5;
  word numbers -> the number ("two" -> 2.0).
- Angle: "quarter turn" -> 90; "half turn"/"turn around" -> 180;
  "full turn"/"360"/"spin around" -> 360. Unstated dir -> "right".
- Speed: slowly/creep/gently -> "slow"; quickly/fast/rush -> "fast".
- "stop"/"halt"/"freeze" -> [{"action":"stop"}].
- "wait/pause for N seconds" -> [{"action":"wait","duration_s":N}].
- Multi-step ("X then Y") -> ordered list.

USER (пример + задание)

EXAMPLES (format reference, do not copy):
{"instruction": "turn left then pick up the cube", "output":
 {"commands": [{"action":"turn","direction":"left","angle_deg":90},
               {"action":"grasp"}]}}
{"instruction": "put it down", "output":
 {"commands": [{"action":"release"}]}}
{"instruction": "do a half turn", "output":
 {"commands": [{"action":"turn","direction":"right","angle_deg":180}]}}

TASK: Produce 25 NEW and DIVERSE training pairs. Vary phrasing heavily:
imperative, polite, terse, conversational, robotic, with/without
units, word-numbers vs digits, multi-step, and include some
out-of-scope/nonsense mapped to {"commands": []}. Do NOT copy the
examples verbatim.

Return ONLY a JSON array; each element:
  {"instruction": "<english text>", "output": <command object>}

Пример берётся случайной выборкой (--fewshot N), поэтому каждый батч видит разные примеры → разнообразие.

2. Механика пайплайна (dataset_gen/generate.py)

  1. build_messages() собирает SYSTEM+USER (см. выше), few‑shot случайно из сидов.

  2. POST в OpenRouter (stdlib urllib, без зависимостей). Ротация двух 120B: openai/gpt-oss-120b:freenvidia/nemotron-3-super-120b-a12b:free.

  3. Ответ → вытащить JSON‑массив → для КАЖДОЙ пары jsonschema валидация по той же схеме → дедуп по нормализованной инструкции → дозапись в JSONL.

3. Итоговый датасет (data/dataset.jsonl)

Всего примеров (пар инструкция→JSON): 2505

Команды по типу (суммарно по всем командам во всех парах):

действие

кол‑во команд

move

1938

turn

1133

wait

456

stop

281

По итогу 4 действия. Разберем некоторые из них, и модификаторы к ним, более подробно.

wait — ожидание в секундах

Команда wait имеет параметр duration_s, где модель выставляет ожидание в секундах, ничего сложного. Для примера разберем такую последовательность команд из датасета.

"Move backward one meter, then pause for three seconds, then move forward one meter"
  -> [{"action":"move","direction":"backward","distance_m":1.0},
      {"action":"wait","duration_s":3},
      {"action":"move","direction":"forward","distance_m":1.0}]

turn — поворот корпуса на месте

Робот разворачивается вокруг себя (diff‑drive: борта крутятся встречно), меняя курс на заданный угол. В отличие от move (поступательное вперёд/назад), turn — только вращение.

Схема:

{"action":"turn",
 "direction":"left"|"right",   // left = CCW (ω>0), right = CW
 "angle_deg": >0 и ≤360,       // всегда положительный, знак — в direction
 "speed": "slow"|"normal"|"fast"   // опционально
}

Примеры из датасета: "turn left 90 degrees"{"action":"turn","direction":"left","angle_deg":90}; "Rotate 360 degrees to the left."angle_deg:360.

Регулировка скорости — опциональный enum speed

Скорость задаётся не числом, а enum'ом {slow, normal, fast} — на move и turnstop/wait его нет).

Это сознательное решение: модель не должна угадывать м/с.

«as fast as you can» → ???
“as fast as you can” →???

Модель ставит speed только если темп подразумевается в тексте («slowly»→slow, «quickly/rush/swiftly»→fast); иначе поле опускается и контроллер берёт normal.

speed

move (линейная)

turn (угловая)

slow

0.2 м/с

0.5 рад/с

normal (default)

0.5 м/с

1.0 рад/с

fast

1.0 м/с

2.0 рад/с

В датасете часть команд несёт явный speed, остальные — без поля, подразумевается скорость normal.

Пример из данных: "turn left ninety degrees then creep back 0.5 meters"[{turn left 90}, {move backward 0.5, "speed":"slow"}] («creep» → slow проставлен только там, где темп указан).

Как вы могли заметить, тут нет информации о карте в принципе, робот является «слепым», это сделано намерено для упрощения, хотя в следующих этапах эксперимента планируется это добавить. Например, для того чтобы робот при словесной команде «подними красный куб» сам мог этот куб найти и поднять.

1 фаза — Делаем танк! В Mujoco.

Только вместо пушки робо-клешня, и нет аниме-женщин
Только вместо пушки робо‑клешня, и нет аниме‑женщин

Робот рос из пустого MJCF‑файла постепенно: сначала появился мир, потом корпус, колёса, опоры — и только тогда всё это поехало без полета за орбиту, и другие астральные реальности.

Будем делать очевидное упрощение.. Настоящую гусеницу — замкнутую ленту с десятками сегментов нам для эксперимента не нужна, поэтому гусеницы не будут физически достоверными..

Вместо них — дифференциальный привод: два ведущих колеса, по одному на каждый борт, каждое со своим актуатором скорости. Поворот получается за счёт разницы скоростей бортов, ровно как у настоящего гусеничного шасси.

Мир и пол

Начинается всё с физических настроек сцены (в XML формате):

<option timestep="0.002" gravity="0 0 -9.81" integrator="implicitfast"/>

Помимо глобальных настроек есть настройки пола конкретно

<geom name="floor" type="plane" size="0 0 0.05" material="grid"
      friction="1.0 0.005 0.0001"/>

Разберем все 6 параметров по порядку

timestep=“0.002”

Шаг 0.002 с — это 500 Гц, привычная частота для симуляции с контактами: меньше — начинает «дёргаться» на касаниях колёс.

gravity=“0 0 -9.81”

Это вектор гравитации — три числа задают, в какую сторону и насколько сильно всё притягивается.

  • Три числа — это направление по осям X, Y, Z (вперёд, вбок, вверх).

  • 0 0 -9.81 значит: по X и Y притяжения нет, а вдоль Z — −9.81.

  • 9.81 — это ускорение свободного падения на Земле, 9.81 м/с² (то самое «g» из школьной физики).

  • Минус — потому что ось Z у нас смотрит вверх, а гравитация тянет вниз. Поэтому значение отрицательное: «вниз по Z с силой 9.81».

Проще говоря — это строчка «включить нормальную земную гравитацию, тянущую к полу». Если бы написали 0 0 0 — робот висел бы в невесомости; 0 0 -1.62 — была бы Луна; а 0 0 -9.81 — обычная Земля.

integrator=“implicitfast”

Симулятор не считает движение непрерывно — он идёт маленькими шажками по времени (у нас шаг 0.002 с). На каждом шаге надо ответить на вопрос: «робот сейчас вот тут и движется вот так — где он окажется через один шажок?». Способ, которым движок отвечает на этот вопрос, и называется интегратором.

Явный Эйлер — самый простой способ. Он смотрит только на то, что происходит прямо сейчас, и считает, что весь следующий шажок останется ровно таким же.

Проблема вылезает там, где силы «резкие» — например, жёсткий удар колеса о пол. За один шажок ситуация успевает сильно измениться, а явный Эйлер этого не замечает: он действует по устаревшей картинке. В итоге он не гасит толчок, а раздувает его — робот получает энергию из ниоткуда, начинает трястись, и в худшем случае улетает.

Implicit‑интеграторimplicitfast — его быстрая, облегчённая версия) работает хитрее: он учитывает не только «где робот сейчас», но и «куда он успеет прийти к концу шажка», и подбирает ответ так, чтобы он сам себе не противоречил.

Поэтому с implicitfast симуляция остаётся спокойной даже при жёстких контактах и относительно крупном шаге по времени.

«Fast» в названии — потому что полную implicit‑схему считать дорого, и MuJoCo применяет её только к той части сил, где это реально важно (вязкость, к примеру), не пересчитывая всё подряд.

geom name=“floor” type=“plane” size=“0 0 0.05” material=“grid”

Теперь разберем параметры пола (кроме friction, считай трения, он будет ниже)

  • name="floor" — имя геома, чтобы на него можно было ссылаться (в контактах, исключениях и тому подобное).

  • type="plane" — это бесконечная плоскость, а не плита конечного размера.

  • size="0 0 0.05" — у плоскости первые два числа игнорируются (она бесконечна), третье — шаг визуальной сетки для отрисовки.

  • material="grid" — просто внешний вид: клетчатая текстура, чтобы в вьювере было видно движение.

friction=“1.0 0.005 0.0001”

Сам пол — это один плоский геом. Трение у него задаётся не одним числом, а тремя: 1.0, 0.005, 0.0001.

Почему три? Потому что предмет может тереться о поверхность тремя разными способами, и физический движок их считает по отдельности:

  • Скольжение (1.0) — когда что‑то едет по полу, как коробка, которую толкаешь. Здесь трение большое: колёса должны цепляться, а не разъезжаться.

  • Верчение (0.005) — когда предмет крутится на месте вокруг точки касания. Тут трение почти нулевое — оно нам мешало бы при поворотах.

  • Качение (0.0001) — сопротивление именно качению, как у мячика, который катится и сам по себе замедляется. Делаем его совсем крошечным, чтобы колесо свободно катилось.

То есть одна цифра отвечает «насколько тяжело проскользнуть», вторая — «насколько тяжело провернуться на месте», третья — «насколько тяжело катиться». Для пола нам нужно: скользить трудно, а вертеться и катиться — легко.

Корпус

Корпус — это body chassis со свободным суставом (freejoint): шесть степеней свободы, робот ничем не прикреплён к миру и может ехать, поворачивать, в худшем случае — падать.
Ставим его на высоту 8.5 см, чтобы колёса именно касались пола, а не висели и не проваливались.

Геометрия — коробка с полуразмерами 0.15 0.08 0.035, то есть габариты 30×16×7 см. Массу и центр тяжести задаем: 2 кг, причём центр масс смещён назад на 2 см. Зачем такой сдвиг — станет понятно ниже, в истории про буксование.

Два ведущих колеса

Каждое колесо — дочернее тело корпуса:

параметр

значение

геометрия

цилиндр, радиус 6 см, полуширина 2 см

ориентация

повёрнут так, чтобы ось смотрела вбок (по Y)

положение

по бортам ±11 см, чуть ниже центра корпуса

сустав

шарнир с вращением вокруг боковой оси

масса

0.3 кг

трение

высокое (2.0) — чтобы не проскальзывало

Расстояние между бортами получается ровно 22 см, а эффективный радиус — 6 см.

Две опорные «лыжи»

Робот на двух колёсах сам по себе клевал бы носом или заваливался на корму. Чтобы этого не было, спереди и сзади добавлены две маленькие сферы‑опоры (радиус 1.5 см, по 50 г).

Они нарочно сделаны очень скользкими: их задача — не дать роботу опрокинуться, но при этом не тормозить и, главное, не нести на себе вес.

Поэтому они ещё и чуть приподняты над полом — таким образом они почти не касаются его, и всю нагрузку держат колёса.

Приводы

На каждый колёсный сустав — по актуатору скорости:

<velocity joint="wheel_left"  kv="20" ctrlrange="-30 30" forcerange="-6 6"/>
<velocity joint="wheel_right" kv="20" ctrlrange="-30 30" forcerange="-6 6"/>

kv — насколько жёстко привод догоняет заданную угловую скорость. ctrlrange ограничивает саму команду скорости. А вот forcerange здесь — спасение от очень эффектного бага, о котором ниже.

Жёсткие контакты

По умолчанию суставам задано небольшое демпфирование, а контактам — жёсткие, почти непроникающие параметры. Если контакты сделать мягкими, колёса буквально «тонут» в полу и буксуют. Плюс был явно отключен расчёт самопересечений корпуса с колёсами и колёс между собой: эти столкновения физически невозможны.

Битвы с физикой

Все «магические числа» выше появились не сразу — каждое получено своим багом.

Робот‑катапульта. Без ограничения по силе робот на первом же шаге улетал: высота 1.27 м, корпус задран почти вертикально. Лечится ограничением момента (forcerange) и более жёсткими контактами.

Все было именно так
Все было именно так

Буксование. Сначала опорные сферы стояли вровень с колёсами и забирали на себя часть веса — КПД проседал до 25–50%. Замер всё показал прямо: колёса крутятся на нужной скорости, а корпус почти стоит — чистое проскальзывание.

Лечилось тремя изменениями сразу:

  • приподнять опоры,

  • сместить центр тяжести корпуса назад,

  • поднять трение колесо‑пол.

В сумме на колёса легло около 88% веса.

Знак поворота. Банально, но проверять надо: по скоростям из симуляции можно было убедиться, что команда «влево» даёт вращение против часовой стрелки, если смотреть сверху. Совпало со стандартной конвенцией (X — вперёд, Y — влево, Z — вверх), ничего инвертировать не пришлось.

Итог: робот грузится, стоит устойчиво, едет и поворачивает в правильную сторону, а точность разомкнутого управления — приемлемая для дальнейшей работы.

Фаза 1 — Файн‑тюним модель

Обучающий датасет и физический модуль, которым модель будет управлять — готовы. Теперь необходимо было обучить и проверить модель.

Для файн‑тюнинга был выбран машина предоставляемая Kaggle, он представляет для пользователей 30 бесплатных часов в неделю для ML‑экспериментов, на выбор есть T4 x2 (каждая по 16 GB), и P100 (одна, 16 GB).

От P100 пришлось отказаться из‑за некоторых проблем с совместимостью, поэтому обучение велось на одной карте NVIDIA T4, чтобы не мучаться с параррельностью, тем более модель была маленькая и не требовала больших ресурсов.

А первый блин оказался комом. Тренировочный пример строился по такому принципу — в КАЖДЫЙ ПРИМЕР помещалась та же JSON‑схема, которая использовалась при генерации синтетического датасета.

Сокращённо выглядит так:

JSON SCHEMA (draft-07):
{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "Robot command list",
  "type": "object",
  "additionalProperties": false,
  "required": ["commands"],
  "properties": {
    "commands": { "type": "array", "items": { "$ref": "#/definitions/command" } }
  },
  "definitions": {
    "move": {
      "required": ["action","direction","distance_m"],
      "properties": {
        "action": { "const": "move" },
        "direction": { "enum": ["forward","backward"] },
        "distance_m": { "type": "number", "exclusiveMinimum": 0, "maximum": 100 },
        ...
      }
    },
    "turn": { ... "angle_deg": { "maximum": 360 } ... },
    "stop": { ... }, "wait": { ... }, "grasp": { ... }, "release": { ... }
  }
}

ROLE: You translate ONE English instruction ... into a JSON object that
strictly validates against the schema above.
HARD RULES:
- Output ONLY the JSON object, no prose, no markdown fences.
- distance_m and angle_deg are ALWAYS positive ...
- "speed" is an optional enum (slow|normal|fast) ...
INTERPRETATION CONVENTIONS:
- "quarter turn" -> 90; "turn around" -> 180; "full turn" -> 360 ...
- word numbers -> the number ("two" -> 2.0) ...
   ... (ещё десятки строк правил) ...

---
INSTRUCTION: turn left 90 degrees, then go forward 2 meters

Вся эта простыня — это ~1500 токенов, и она повторяется в каждом из тысяч обучающих примеров, хотя меняется в них только последняя строчка INSTRUCTION: ....

По итогу это привело к тому, что обучение длилось 3.5 часа, и программа упала на ошибке в ячейке с eval, так и не успев сохранить модель.

Снова тратить 3.5 часа не хотелось, поэтому был выбран другой подход — в каждом примере будет помещена только короткая инструкция.

You translate ONE English instruction for a tracked robot with a gripper
arm into a single JSON object {"commands":[...]} using actions: move, turn,
stop, wait, grasp, release. Output ONLY the JSON object, no prose, no
markdown. If the instruction is out of scope or nonsense, output
{"commands": []}.

---
INSTRUCTION: turn left 90 degrees, then go forward 2 meters

Это ~60 токенов вместо полутора тысяч. Никакой схемы, никаких таблиц правил — просто просьба:

«ты переводишь фразу в JSON такого вида, вот список действий, на бессмыслицу отвечай пустым списком».

С таким подходом обучение заняло 30–40 минут. Веса модели были скачаны, осталось протестировать в реальных условиях, точнее в симуляции Mujoco.

Фаза 1 — Тестируем модель

Тестировали как обычно тестируют результаты файнтюна‑ на отложенной выборке: 10% пар модель при обучении не видела вообще (фиксированный случайный отбор, чтобы результат можно было воспроизвести). Считали несколько метрик, каждая отвечает на свой вопрос:

  • schema_valid — какая доля ответов вообще является валидным JSON по нашей схеме. Получилось 1.000, то есть 100%.

  • exact_match — насколько часто ответ совпал с эталонным дословно. 0.920.

  • action_seq — правильная ли получилась последовательность действий по смыслу, даже если число чуть разошлось (важнее «повернул, потом поехал», чем «ровно 90.0 против 90»). 0.980.

  • ood_f1 — не выдумывает ли модель команды на бессмыслицу вроде «свари кофе» (правильный ответ — пустой список «ничего не делать»). 0.846.

Но самая важная проверка — последняя. Все предыдущие метрики сравнивают тексты. А нам важно, чтобы робот реально поехал куда надо.

Поэтому мы брали эталонную команду и предсказание модели, прогоняли обе в симуляции MuJoCo и сравнивали, где в итоге оказался робот (с разумным допуском — управление разомкнутое, физика немного шумит).

Эта метрика — task_success = 0.975: 39 запусков из 40 привели робота туда же, куда и эталон, ноль ошибок исполнения.

Мы опытным путем доказали, что схема «впечаталась» в веса модели во время обучения, и кидать ей полную схему было совершенно избыточно.

Теперь со спокойной совестью можно было приступать к Фазе 2.

Фаза 2 — Обновляем датасет для примеров с клешней

Мы добавляем два манипуляционных действия — release и grasp, каждая из них имеет необязательный параметр cell, который в свою очередь имеет enum в качестве параметров: front | front_left | front_right | left | right, со значением front по умолчанию, если параметр не указан.

Перед тем как формировать дополненный датасет нужно было исправить небольшой факап в его первой версий. Суть в том, что там встречались примеры где поднятие предметов характеризовались как заведомо невозможные, фразы вроде «подними коробку» там считались бессмыслицей и обучались на ответ «ничего не делать» ([]).

Прежде чем что‑то делать, мы это измерили: скрипт регулярками прошёл по всем 2505 старым парам и нашёл, сколько из них реально конфликтуют (манипуляционная фраза → пустой ответ).

Phase-1 pairs=2505 | manip-phrase=17 | CONFLICT=17
reuse unchanged=2488 (99.3%)

Конфликтных оказалось 17 из 2505 — 0.7%. Phase-1 почти не генерил таких фраз. Конфликтующие примеры из новой выборки были удалены.

Запустили тот же самый, уже обкатанный на Phase 1, конвейер генерации через OpenRouter — но с двумя точечными изменениями:

  • few‑shot‑примеры в промпте брались только из манипуляционных сидов (пары с grasp/release);

  • в промпт добавили явный акцент: «генерируй ТОЛЬКО задачи на руку, в каждой паре обязан быть grasp и/или release, чистую локомоцию НЕ выдавай».

Цель была 1000 пар. Итог прогона: все 1000 примеров приняты, 0 отбраковок схемой за весь прогон.

Что получилось

Финальный обьединенный датасет — 3518 пар:

действие

сколько

move

2239

turn

1322

grasp

814

release

584

wait

488

stop

285

Фаза 2 — Добавляем клешню к роботу

К счастью, нам не нужно столько клешней как у краба
К счастью, нам не нужно столько клешней как у краба

Чтобы кисть попала в нужную точку, нужно посчитать углы суставов руки — это обратная кинематика (IK).

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

Стоит подметить две детали:

  • IK двигает только суставы руки, база считается зафиксированной. Это упрощает математику и логически верно: при хватании робот стоит на месте.

  • IK не трогает живую симуляцию. Он сохраняет состояние мира, считает решение на копии и восстанавливает всё обратно, возвращая только целевые углы. Иначе каждый расчёт IK дёргал бы физику.

Модель шлёт {"action":"grasp"} (опц. с клеткой), а контроллер разворачивает это в фиксированную канонную рутину:

  • клетка → точка в мире (с учётом того, куда повёрнут робот) →

  • найти ближайший свободный объект рядом →

  • раскрыть клешню → подвести руку над объектом → опустить → сжать → поднять.

  • release — то же в обратную сторону: поднести к клетке → опустить → разжать

Никакого «творчества» в момент исполнения: модель решает что и примерно куда (клетка), а как именно двигать суставы — детерминированная процедура и IK.

Теперь разберем систему клеток. Они имеют такую структуру.

_CELLS = {
    "front":       (0.30, 0.00),
    "front_left":  (0.27, 0.13),
    "front_right": (0.27, -0.13),
    "left":        (0.23, 0.20),
    "right":       (0.23, -0.20),
}

Если посчитать расстояние и угол каждой клетки от центра робота (кадр робота: X вперёд, Y влево):

cell

радиус √(x²+y²)

угол от носа

front

0.300 м

front_left

0.300 м

+25.7°

front_right

0.300 м

−25.7°

left

0.305 м

+41.0°

right

0.305 м

−41.0°

Виден принцип: все пять клеток лежат на одной дуге радиусом ≈0.30 м перед роботом, просто разнесённые по разным направленим. То есть это не сетка координат, а один комфортный радиус досягаемости, нарезанный на 5 направлений в переднем секторе ~±41°.

Радиус ≈ 0.30 м — это «зона руки». Значение подобрано эмпирически так, чтобы IK туда дотягивался уверенно.

Фаза 2 — Файн‑тюним и тестим модель, а так же добавляем user‑api

Файн‑тюнинг модели на обновленном датасете прошел без сюрпризов, те же 30–40 минут. Момент, который стоило отметить — файн‑тюн происходил полностью нуля, без учета весов с первой фазы, потому что туда попали инструкции где поднятие предметов считалось бессмыслицей, в любом случае мы ничего не теряем, модель тренируется очень быстро.

Осталось протестировать модель. Для определения качества обучения мы вводим метрику, которая сравнивает финальное положение хватаемого объекта между эталоном и предсказанием (допуск 0.10 м).

Если модель сказала «возьми и положи слева», успех засчитывается, только когда куб реально оказался слева — и там же, куда его кладёт эталон.

Что показали цифры

Прогон на отложенной выборке (352 пары, та же, что у Phase 1):

метрика

Phase 2

Phase 1

schema_valid

0.991

1.000

exact_match

0.943

0.920

action_seq

0.980

0.980

ood_f1

0.857

0.846

task_success (MuJoCo, 40)

0.975

0.975

Как это читать:

  • schema_valid 0.991, а не 1.0 — небольшой регресс.

  • exact_match 0.943 — даже выше, чем у Phase 1 (0.920). Модель выучивает манипуляционные паттерны резче, чем разговорные формулировки расстояний.

  • task_success 0.975 при нуле ошибок исполнения — grasp/release/клетка отрабатывают в физике чисто, и куб оказывается там же, куда его кладёт эталон.

Добавляем интерактивный режим

Конвейер замкнут — но прогон фиксированного датасета это всё ещё «лабораторно». Хотелось добавить возможность командовать роботом свободным текстом в реальном времени.

Выбрали самое наглядное: REPL + окно MuJoCo. Печатаешь фразу — робот тут же её исполняет в живой симуляции, состояние копится между фразами (никакого сброса: сказал «проедь вперёд», потом «теперь налево» — он поедет от того места, где остановился).

Архитектурно тут решено было добавить два потока:

  • Поток ввода: блокирующий input() + инференс модели — складывает разобранные команды в очередь.

  • Главный поток владеет окном и физикой: есть команда в очереди — исполняет в реальном времени; нет — крутит холостой шаг (робот стоит, окно живёт). Только он зовёт шаг физики — поэтому нет гонок данных в MuJoCo.

REPL — это Read‑Eval‑Print Loop, «цикл: прочитал — выполнил — показал результат — снова жди ввод». Проще говоря, интерактивная консоль, которая в цикле:

  1. Read — ждёт, пока ты введёшь строку;

  2. Eval — выполняет её;

  3. Print — показывает результат;

  4. Loop — возвращается к шагу 1 и ждёт следующую.

Тут все вышло без проблем, результат вы могли увидеть в GIF в начале статьи.

Результаты

По итогу что было сделано

  • Была сделана с нуля физическая модель гусеничного робота с робо‑клешней в среде Mujoco

  • Была обучена Gemma-3 на 270 млн параметров принимать команды от человека на естественном языке и переводить это в JSON для управления гусеничным роботом

  • Обучение проводилось на бесплатной машине Kaggle, синтетический датасет собирался с помощью бесплатных моделей gpt‑oss-120b и nemotron‑super-120b на OpenRouter

Вот мы и подошли к концу этого путешествия. Но проект еще не закончен. Необходимо доработать два основных момента

  • Добавить роботу восприятие карты, чтобы он мог действовать более самостоятельно и анализировать обстановку, например, подбирать предметы по команде человека в свободной форме («подбери красный куб), преодолевать препятствия и так далее.»

  • Перенести модель в реального гусеничного робота, для этого необходимо собрать лежащий у меня DIY‑набор RaspTank, фото которого было показано в начале статьи.

Если вам эта статья покажется интересной, то я попробую этим заняться в следующей части.

А пока до новых встреч.

Источники

Исходный код и веса модели

Codeberg (исходный код) — https://codeberg.org/imperius/llm‑tank

Hugging‑Face (веса) — https://huggingface.co/Imperius/llm‑tank

Комментарии (7)


  1. Guron1989
    17.05.2026 22:27

    Думаю эта больше подходит для поста)


    1. Motvikios
      17.05.2026 22:27

      Или такая
      Или такая


  1. DamonV79
    17.05.2026 22:27

    Спасибо за статью, весьма познавательно!

    У Вас нет планирования маршрута перемещения (самого робота или исполнительного инструмента) моделью. По сути, Вы натренировали вызов тулов моделью. По этому интересен момент, а насколько ванильная FunctionGemma с переданным ей списком тулов справится с заданием?


    1. Imperius14 Автор
      17.05.2026 22:27

      ванильную гемму я проверял в начале статьи, она жестко галлюцинировала, поэтому и понадобился файн тюнинг.

      Собственно, гугл изначально и обозначил что это модель для файнтюна а не для использования ванили как есть.

      А зрением для модели, чтобы она могла сама находить нужные предметы я ща занимаюсь, должно получится интересно.


      1. DamonV79
        17.05.2026 22:27

        ванильную гемму я проверял в начале статьи

        Не ванильную Гемму, а FunctionGemma (https://ai.google.dev/gemma/docs/functiongemma). Google'ом зафайнтюненая версия Gemma3 для вызава тулов. Возможно, даже ее ванильная версия будет приемлема, если ей просто список тулов передать.


        1. Imperius14 Автор
          17.05.2026 22:27

          а, вы про это, извиняюсь. Спасибо, гляну


    1. Imperius14 Автор
      17.05.2026 22:27

      Но фактически да, это файн тюн на вызов тулзов, надо было обозначить это в статье, что вся эта эпопея показывает что модель потенциально можно использовать в агентных сценариях. Спасибо за комментарий