Мультиагентный помощник оператора

Настоящая глава посвящена программе, установленной рядом с действующей машиной и взаимодействующей с оператором на естественном языке.

Задача

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

Первый массив составляют приборы. Сотни мониторов и тысячи PV-переменных названы по принятому в проекте регламенту наподобие ACC1:BI-LA1:PK3-fastAvX:Mea. Имя, собранное из участка, устройства, измеряемой величины и вида обработки, читается однозначно, однако удерживать в памяти тысячу таких имён невозможно. Для просмотра орбиты требуется знать и имена каналов, и панель, отведённую под них.

Второй массив представляет собой документацию, разбросанную по вики, чертежам, статьям и инструкциям, накопленным за годы проектирования. Ответ на вопрос «как измерить эмиттанс» где-то записан, но для его поиска необходимо вспомнить, в каком документе он находится, а документы, собранные разными коллективами за разные годы, единого оглавления не имеют.

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

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

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

Устройство системы

Оператор взаимодействует с системой из Telegram-бота, из веб-интерфейса или из терминала. Запрос поступает в роутер — сервис, запрашивающий у языковой модели, какой инструмент требуется в этом случае, и рассылающий команды агентам. Каждый агент выполняет одну задачу и владеет одним хранилищем.

                       Telegram / Web / CLI
                                │
                                │ запрос
                                ▼
                          ┌───────────┐
                    ┌────▶│   Роутер  │◀────┐
                    │     └─────┬─────┘     │
              ответ │           │           │ ответ
        опрос,      │  докумен- │           │  запись
     управление     │   тация   │           │
            ┌───────┴───┐  ┌────▼──────┐  ┌─┴─────────┐
            │Контроллер │  │Консультант│  │ Секретарь │
            │   EPICS   │  │    RAG    │  │  журнал   │
            └─────┬─────┘  └─────┬─────┘  └─────┬─────┘
                  │              │              │
            ┌─────▼─────┐  ┌─────▼─────┐  ┌─────▼─────┐
            │   EPICS   │  │  Vector   │  │ PostgreSQL│
            │GetPV/SetPV│  │   Store   │  │           │
            └─────┬─────┘  └───────────┘  └───────────┘
                  │
            ┌─────▼─────┐
            │ Ускоритель│
            └───────────┘

Ответы, возвращаемые агентами, роутер собирает и передаёт оператору одним сообщением. Агенты обмениваются сообщениями, передаваемыми через очереди Apache Kafka; сама механика очередей рассмотрена в главе про сети и веб-технологии. Журнал смены хранится в PostgreSQL, устройство которого описано в главе про базы данных.

Работа роутера состоит из двух шагов. Сначала модель, получившая историю разговора и список инструментов, называет подходящий; затем вызовы, названные ею, преобразуются в команды, отправляемые агентам. Инструментов, видимых модели, десять, и ещё пять остаются внутренними: график, голос и сводку смены заказывает сама система, не расходуя на них решение модели. Результаты, поступающие от агентов, роутер объединяет в один ответ и отправляет туда, откуда пришёл запрос.

У системы объявлено пять специализаций.

  • Консультант осуществляет поиск по документации через RAG и отвечает с указанием источника.
  • Секретарь ведёт журнал смены: записывает и то, что выполнила система, и то, что продиктовал оператор.
  • Контроллер опрашивает PV и выполняет базовые проверки через EPICS.
  • Тренер помогает обучать новых операторов, разбирая с ними учебные ситуации.
  • Исследователь помогает анализировать данные и готовить отчёты.

Состав системы объявлен один раз, в реестре. ToolSpec описывает инструмент, видимый языковой модели, а AgentSpec — сервис, исполняющий инструменты. Из реестра выводится всё остальное: схемы, передаваемые модели, маршрутизация «инструмент → агент», список очередей и цели, опрашиваемые мониторингом.

@dataclass(frozen=True)
class ToolSpec:
    """Инструмент: что видит модель и кто его исполняет."""

    name: str
    schema: dict | None = None      # None — внутренний, модели не показывается
    writes: bool = False            # трогает оборудование
    long_running: bool = False
    min_role: Role = Role.OPERATOR

Описание инструмента в реестре, sava_lib/registry.py

Инструмент, объявленный без схемы, модели не показывается: график строит и голосом отвечает сама система, а не модель, решившая, что это уместно. Поле min_role задаёт наименьший уровень доступа: knowledge открывает только документацию, observer разрешает наблюдать и задавать вопросы, operator — работать с машиной. Научному руководителю, посетившему смену, требуется чтение, а студенту достаточно документации.

Контроллер: опрос и управление

Агент представляет собой набор функций, помеченных декоратором. Цикл опроса очереди, метрики, повторы и разбор ошибок реализованы в общем AgentRunner, и в самом агенте остаётся только полезная работа.

@runner.tool("GetPV")
def get(ctx: Ctx) -> dict:
    """Читает один PV."""
    pv_name = ctx.payload.get("pv_name", "")
    ctx.policy.enforce_read(pv_name)
    res = get_pv(pv_name, use_monitor=bool(ctx.payload.get("use_monitor", False)))
    return {"result": json.dumps(res, ensure_ascii=False)}


@runner.tool("SetPV")
def put(ctx: Ctx) -> dict:
    """Запись одного PV — только по конверту, подтверждённому оператором."""
    pv_name = ctx.payload.get("pv_name", "")
    value = float(ctx.payload.get("value"))
    if not bool(ctx.payload.get("confirmed", False)):
        raise PolicyError(f"Запись {pv_name} без подтверждения оператора.")
    applied = ctx.writer.put(pv_name, value, confirmed=True)
    return {"result": json.dumps({"pv_name": pv_name, "value": applied})}

Чтение и запись переменной EPICS, agents/pv-agent/main.py

Значение, передаваемое оператору, снабжено признаком достоверности: канал, не ответивший вовремя, приводит к ошибке, а не к подставленному значению по умолчанию. Наиболее частый диалог в пультовой выглядит следующим образом. Оператор называет датчики словами, модель преобразует названное в вызовы GetPV, а показания, собранные с обоих каналов, пересказываются на естественном языке.

Оператор: Опроси датчики положения пучка
          BI-LA1:PK3-fastAvX:Mea, BI-LA2:PK4-fastAvX:Mea

Sava: -> GetPV("BI-LA1:PK3-fastAvX:Mea")  -> 0.15 мм
      -> GetPV("BI-LA2:PK4-fastAvX:Mea")  -> -0.08 мм

      Датчик PK3 показывает смещение 0.15 мм,
      PK4: -0.08 мм. Орбита близка к опорной.

Флаг writes, объявленный в реестре, делит инструменты на два класса. Читающему инструменту достаточно проверки политики, а пишущему выдаётся конверт — описание операции, составленное до разговора с оператором и подтверждаемое им целиком, одним «да». В конверте перечислены каналы, границы, назначенные каждому из них, предельное число записей и срок действия.

@dataclass(frozen=True)
class WriteEnvelope:
    """Что операции разрешено записать, в каких границах и сколько раз."""

    envelope_id: str
    command_id: str
    purpose: str                      # «матрица отклика орбиты»
    bounds: tuple[WriteBound, ...]
    max_writes: int
    ttl_s: float = 1800.0

    def check(self, pv: str, value: float) -> None:
        """Проверка одной записи."""
        b = self.bound_for(pv)
        if b is None:
            raise PolicyError(f"{pv} не входит в состав операции «{self.purpose}».")
        if value < b.lo or value > b.hi:
            raise PolicyError(f"{pv}={value:g} вне границ [{b.lo:g}, {b.hi:g}].")

Конверт записи, sava_lib/write_guard.py

Подтверждается операция целиком, а не каждая запись по отдельности. Одна коррекция орбиты складывается из десятков caput, выполняемых подряд, и оператор, перегруженный вопросами, перестанет их читать уже на пятом. Текст, показываемый оператору перед пуском, называет корректоры и пикапы поимённо, а не «сейчас поправлю орбиту», и рядом с каждым параметром отмечено, взят он из значений по умолчанию или из запроса. Значение, вышедшее за объявленную границу, приводит к отказу, а не к обрезке до допустимого.

Коррекция, состоящая из чтения, расчёта и двух записей, выполняется следующим образом.

Оператор: Орбита в линаке смещена, помоги скорректировать

Sava: -> GetPV("BI-LA1:PK3-fastAvX:Mea")  -> 0.15 мм
      -> GetPV("BI-LA2:PK4-fastAvX:Mea")  -> -0.08 мм
      -> CalculateCorrection(0.15, -0.08)
         -> {"MG-LA1:CL1:HKICK": 0.5, "MG-LA1:CL2:HKICK": -0.3}
      -> SetPV("MG-LA1:CL1:HKICK", 0.5)
      -> SetPV("MG-LA1:CL2:HKICK", -0.3)

      Коррекция выполнена: MG-LA1:CL1:HKICK=0.5, MG-LA1:CL2:HKICK=-0.3.
      Проверьте новые показания датчиков.

Математический аппарат, скрытый за строкой CalculateCorrection, — матрица отклика и разложение по сингулярным числам, рассмотренные в главе про коррекцию равновесной орбиты.

Консультант: поиск по документации

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

1. Оператор: «Как измерить эмиттанс?»
        │
        ▼
2. Векторизация запроса
        │
        ▼                                Chunk 1: Ma Xiaochao, стр. 45
3. Поиск в Vector Store  ─────────────▶  Chunk 2: EPICS Wiki
   топ-3 фрагмента                       Chunk 3: регламент СКИФ
        │
        ▼
4. Добавление контекста в промпт
        │
        ▼
5. Модель отвечает с указанием источника

Третья ступень в репозитории реализована двойной. Плотностной поиск по векторам объединён со списком, найденным классическим BM25, а два порядка слиты по правилу обратного ранга: таким образом, слово, редко встречающееся в корпусе, не теряется среди фрагментов, близких по смыслу. Инструмент, объявленный в реестре, называется FileSearch.

Оператор: Как измерить эмиттанс?

Sava: -> FileSearch("Как измерить эмиттанс?")
         Находит: Ma Xiaochao, стр. 45

      Ответ: Для измерения эмиттанса используйте метод...
      Источник: Ma Xiaochao, стр. 45

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

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

Секретарь: журнал смены

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

Оператор: Я сделал 2 раза коррекцию орбиты
          в линейном ускорителе, запиши!

Sava: -> ActionRecord(
          action="Коррекция орбиты",
          system="Линейный ускоритель",
          details="Выполнено 2 итерации",
          result="Успешно")

      Записал в журнал: коррекция орбиты линака.

Поле system приводится к закрытому словарю подсистем. Название, полученное от модели, само по себе непригодно: одна и та же подсистема называлась бы то «Орбита», то «Уставки машины», и группировка, собираемая по свободному тексту, нарушилась бы на первой же сотне записей. Поле с именем оператора модель не заполняет — его проставляет система поверх присланных аргументов.

Записи, накопленные в журнале, пронумерованы, и номер служит адресом. По нему разворачивается разбор одного действия: какие каналы затронуты, с каких значений на какие и сколько записей потребовалось. Отдельная сводка, собираемая при заступлении на смену, показывает действия за последние часы, отказы политики и прогоны процедур.

Настройка по шагам

Чтение, запись и проверка образуют цикл. Оператор ведёт настройку словами, а система отрабатывает шаг и показывает полученный результат.

Оператор: Проверь орбиту в линаке по x

Sava: -> GetPV("BI-LA1:PK3-fastAvX:Mea" ... "PK7-fastAvX:Mea")
      Макс. отклонение PK5: 207 мкм

Оператор: Сделай коррекцию — используй первые два корректора

Sava: -> GetPV("MG-LA1:CL1:HKICK") -> 0.05
      -> GetPV("MG-LA1:CL2:HKICK") -> 0.0
      -> SetPV("MG-LA1:CL1:HKICK",  0.03)   # было 0.05
      -> SetPV("MG-LA1:CL2:HKICK", -0.02)   # было 0.0
      Коррекция выполнена. Рекомендую проверить результат.

Оператор: Проверь орбиту

Sava: -> GetPV("BI-LA1:PK3-fastAvX:Mea" ... "PK7-fastAvX:Mea")
      Результат: макс. отклонение 77 мкм (↓ в 2.7 раза)

За три шага с обратной связью отклонение, измеренное на пикапе PK5, уменьшилось с 207 до 77 мкм — в 2.7 раза. Прежние уставки корректоров указаны в комментариях: оператор видит, откуда и куда произведён сдвиг, и может вернуть исходное значение. Такие прогоны выполняются на цифровом двойнике. Двойник работает по тому же протоколу Channel Access и с теми же именами каналов, поэтому помощник не отличает его от установки, а цена ошибки при обучении оказывается нулевой. На нём же отрабатывается специализация тренера.

Пока коррекция, запущенная оператором, изменяет уставки корректоров, машина занята. Блокировка, объявленная у агента в реестре, заставляет остальных отвечать «оборудование занято», а не передавать показания, снятые посреди чужой процедуры. Длительная операция отправляет прогресс, и шлюз редактирует одно уже отправленное сообщение, не заполняя чат лентой статусов. Прогон, прерванный на середине, остаётся в таблице открытым, и заступающая смена видит его в сводке отдельной строкой.

Инициатива со стороны системы

Разговор начинает не только человек. Сторож, приставленный к списку каналов, следит за порогами и отправляет оператору сообщение, минуя языковую модель: факт, доставляемый в обход неё, доходит и при недоступном провайдере. Объяснение, составленное моделью, приходит вторым сообщением, и его потеря не является критичной.

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

Доступ к системе

Интерфейсов три.

  • Telegram-бот — доступ с любого устройства и в любое время. Текст, фото и голос на входе, текст, графики и голос на выходе; кнопки подтверждения расположены здесь же.
  • Веб-интерфейс — расширенные возможности: длинные диалоги, графики, встроенные непосредственно в ленту, микрофон.
  • CLI — встраивание в скрипты и автоматизацию.

Транспортное ядро у всех трёх общее, поэтому разговор, начатый в боте, продолжается в вебе с того же места. Вход в веб и в терминал осуществляется по одноразовому коду, выданному ботом, и имя оператора в журнале берётся из этого кода, а не из заполненной формы.

Полезные ссылки

  • Apache Kafka, шина сообщений, связывающая агентов.
  • LangGraph, библиотека, строящая граф рассуждений модели.
  • EPICS, система управления, в которую в конечном счёте поступает запись.
  • faiss, поиск ближайших соседей, на котором основана векторная часть RAG.
  • Глава про цифровой двойник — установка, на которой всё это отработано, а глава про автономного исследователя — соседний случай, в котором языковая модель работает без человека.