Мультиагентный помощник оператора
Настоящая глава посвящена программе, установленной рядом с действующей машиной и взаимодействующей с оператором на естественном языке.
Задача
Оператор, заступивший на смену в пультовой, вынужден работать с тремя массивами сведений, никак между собой не связанными.
Первый массив составляют приборы. Сотни мониторов и тысячи 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.
- Глава про цифровой двойник — установка, на которой всё это отработано, а глава про автономного исследователя — соседний случай, в котором языковая модель работает без человека.