golang

Построение агентов на A2A с изолированным исполнением на Go: запускаем ИИ-разработчика в облаке, со…

  • четверг, 27 августа 2026 г. в 00:00:07
https://habr.com/ru/companies/ru_mts/articles/1074154/

Меня зовут Вера Касьяненко, я разработчик из команды DevRails AI в MTS Web Services. Сейчас мы создаем агента-разработчика — сервис, в котором языковая модель сама пишет код, запускает тесты, делает ревью и создает коммиты.

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

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

Что мы называем агентом

Для нас агент — это сервис, в котором языковая модель решает, что делать, а код эти решения исполняет. Звучит просто, но за этим скрывается несколько важных деталей, которые отличают агента от обычного сервиса с LLM.

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

Второе — инструменты. Модель не просто генерирует текст, она работает с файловой системой, запускает команды в shell, взаимодействует с git и внешними API. На выходе мы получаем артефакт, который можно проверить: патч, ветку, merge request или отчет.

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

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

На практике агентов обычно несколько. Можно, конечно, сделать одного большого агента, загрузив в него все инструменты, но он плохо масштабируется: один контекст, все секреты в одной точке. Поэтому мы пошли по пути специализации: выделили агента-аналитика, агента-разработчика, агента-тестировщика. У каждого — свой набор инструментов и зона ответственности. Дальше мы будем говорить про агента-разработчика — того, который клонирует репозиторий, пишет код, запускает тесты и открывает merge request. Набор у него самый опасный: командная строка, файловая система, git, сеть — все это может натворить дел.

Зачем нам понадобился A2A

Можно каждый раз договариваться по-своему, но это путь к сложностям интеграций. Вот простая арифметика. Если у вас пять сервисов и каждый договаривается с каждым — это десять индивидуальных контрактов. Десять реализаций, десять ручных тестов, десять способов обработки ошибок. Если сервисов становится пятнадцать, связей уже больше ста. И самое затратное здесь — не написать интеграцию, а поддерживать ее. Поменялся один агент — просели все его связи.

Рост интеграций
Рост интеграций

Поэтому мы взяли A2A (Agent2Agent) — открытый протокол, который описывает, как агенты передают друг другу задачи. Он предоставляет три ключевых элемента:

•           Карточку агента — справочник, где описано, кто агент, где доступен и что умеет. Клиент читает карточку и решает, как вызвать агента. Вот пример формата A2A 1.0:

GET /.well-known/agent-card.json:
json
{
  "name": "Developer Agent",
  "description": "Создает ветку, пишет код и делает коммиты",
  "version": "1.2.0",
  "supportedInterfaces": [{
    "url": "https://agents.example.com/developer",
    "protocolBinding": "JSONRPC",
    "protocolVersion": "1.0"
  }],
  "capabilities": { "streaming": true },
  "defaultInputModes": ["text/plain"],
  "defaultOutputModes": ["text/plain"],
  "skills": [{
    "id": "feature-dev",
    "name": "Разработка фичи",
    "description": "От задачи до merge request",
    "tags": ["code", "git"]
  }]
}

•           Долгую задачу — агентская задача может идти десятки минут, и держать открытым HTTP-запрос все это время не вариант. A2A описывает задачу как ресурс со статусом, прогрессом и возможностью отмены.

•           Общий контракт — не нужен самописный протокол на каждую пару сервисов. С A2A рост интеграций перестает быть квадратичным: пять сервисов — пять реализаций контракта, а не десять связей.

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

Для Go существует официальный SDK, который упрощает реализацию протокола.

Итак, протокол выбрали. Осталось понять, где именно будет работать наш агент. Мы перебрали три варианта — назвали их схемами 0, 1 и 2. В первой все работает в одном процессе, во второй исполнение вынесено в отдельный под, которым управляет сам агент, в третьей управление средами отдано платформе. Теперь по порядку.

Схема 0: все в одном процессе

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

На Go это выглядит так. Сначала объявляем стадии:

// жизненный цикл задачи – конечный автомат
handlers := &workflow.Handlers{
    Initializing: h.handleInitializing,
    Analyzing:    h.handleAnalyzing,
    Executing:    h.handleExecuting,
    Verifying:    h.handleVerifying,
    Reviewing:    h.handleReviewing,
    Fixing:       h.handleFixing,
    Pushing:      h.handlePushing,
    Completing:   h.handleCompleting,
    OnError:      h.handleOnError,
}

// а это сам движок – цикл, который выполняется внутри каждой стадии:
func (e *Executor) Execute(
    ctx context.Context,
    execCtx *a2asrv.ExecutorContext,
) iter.Seq2[a2a.Event, error] {
    return func(yield func(a2a.Event, error) bool) {
        if execCtx.StoredTask == nil {
            if !yield(a2a.NewSubmittedTask(execCtx, execCtx.Message), nil) {
                return
            }
        }

        prompt := execCtx.Message.Parts[0].Text()

        if !yield(a2a.NewStatusUpdateEvent(execCtx, a2a.TaskStateWorking, nil), nil) {
            return
        }

        // цикл "модель + инструменты"
        // опасно: shell, fs и git в этом же процессе,
        // рядом с состоянием и секретами
        if _, err := e.engine.SendPrompt(ctx, prompt); err != nil {
            yield(a2a.NewStatusUpdateEvent(execCtx, a2a.TaskStateFailed, nil), nil)
            return
        }

        yield(a2a.NewStatusUpdateEvent(execCtx, a2a.TaskStateCompleted, nil), nil)
    }
}

Метод Execute — это точка входа для A2A SDK. SDK вызывает его в отдельной горутине и читает события, пока задача не завершится. Ничего магического: обычный сервер, который принимает задачу, отдает статусы, позволяет отменить выполнение через контекст. Состояние задачи при этом хранится вне процесса, что упрощает восстановление.

Но здесь есть подвох. Инструменты — shell, работа с файлами, git — выполняются прямо в этом же процессе. Все, что нужно для работы над кодом, живет в одной памяти с состоянием задачи, секретами и настройками сервиса. Одни права, один сетевой namespace.

У этого подхода есть преимущества. Во-первых, он пишется за вечер. Во-вторых, отлаживать его легко: все в одном месте, одни логи, один отладчик. Для агента-советчика, который только читает данные через API и не меняет файлы, его вполне достаточно. Но наш агент — не советчик. Он пишет код, запускает тесты, делает коммиты. И здесь все становится сложнее.

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

Мы столкнулись с этим на практике. Поэтому очевидный вывод: как только агент начинает не просто отвечать, а действовать, исполнение нужно выносить из его процесса.

Схема 1: агент сам поднимает песочницу

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

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

В качестве движка для исполнителя мы взяли opencode — открытую библиотеку, которая умеет работать с разными LLM-провайдерами. Но оркестратору все равно, что внутри пода — opencode или что-то другое. Главное, чтобы карточка A2A совпадала.

Оркестратор управляет подом напрямую, через Kubernetes API. Вот как выглядит создание пода:

// агент сам оператор кластера: весь жизненный цикл пода находится в его коде
pod := sm.buildSandboxPod(podName, taskID, image, port, envVars)
if _, err := sm.kubeClient.CoreV1().Pods(sm.namespace).
    Create(ctx, pod, metav1.CreateOptions{}); err != nil {
    return "", fmt.Errorf("create sandbox pod: %w", err)
}

podIP, err := sm.waitForPodReady(ctx, podName)
if err != nil {
    _ = sm.kubeClient.CoreV1().Pods(sm.namespace).
        Delete(ctx, podName, metav1.DeleteOptions{})
    return "", fmt.Errorf("wait sandbox ready: %w", err)
}
return podHTTPURL(podIP, port), nil

Стало заметно лучше. Опасные команды больше не выполняются рядом с состоянием задачи и секретами сервиса. Между задачами нет общего состояния — каждая получает чистую среду. Одна задача — один под.

Какое-то время нас это устраивало. Но потом вылезли ограничения.

Первое — агент знает слишком много. Он собирает манифест пода, знает образ, прокидывает git-креды и ключи модели. Если оркестратор скомпрометируют — внешним взломом, вредоносной задачей или даже банальной ошибкой, — злоумышленник получит доступ и к кластеру, и к секретам. Изоляция появилась, а граница доверия почти не сдвинулась.

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

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

Четвертое — у нас не было общего языка для описания среды. Ни шаблонов, ни пулов, ни единых квот. Каждый агент описывал свою среду по-своему.

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

Схема 2: песочница как сервис

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

У нас появился отдельный сервис песочниц с простым HTTP API: запросить стенд из прогретого пула, проверить статус исполнителя, удалить заявку. За этим API стоит Kubernetes-оператор, который читает заявки и поднимает поды. Оператор работает с четырьмя типами CRD:

•           SandboxTemplate — описывает среду: образ, лимиты, securityContext;

•           SandboxWarmPool — пул прогретых стендов по шаблону;

•           SandboxClaim — заявка на стенд из пула;

•           Sandbox — выданный стенд.

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

Для агента главное здесь — смена владельца. Он больше не создает поды, не знает манифестов и не работает с Kubernetes API. За среду отвечает платформа — как за базу данных или очередь. К исполнителю внутри стенда агент ходит по A2A, как и раньше.

Вот как выглядит шаблон для нашего агента-разработчика:

apiVersion: extensions.agents.x-k8s.io/v1beta1
kind: SandboxTemplate
metadata:
  name: sandbox-template
  namespace: sandboxes
spec:
  podTemplate:
    spec:
      automountServiceAccountToken: false
      securityContext:
        runAsNonRoot: true
        runAsUser: 65532
      containers:
        - name: sandbox
          image: opencode-a2a-go:1.4.0   # исполнитель: opencode + A2A-сервер
          envFrom:
            - secretRef:
                name: sandbox-opencode-env # служебное окружение: ключ и адрес модели
          ports:
            - containerPort: 3000        # здесь он отдает agent-card.json
          resources:
            requests: { cpu: 100m, memory: 512Mi }
            limits:   { cpu: "1",  memory: 1Gi }
          volumeMounts:
            - name: workspace
              mountPath: /workspace      # рабочий каталог задачи
      volumes:
        - name: workspace
          emptyDir: {}                   # временный диск, умирает вместе с подом

Образ исполнителя, порт A2A-сервера, лимиты и securityContext — все это задается в шаблоне. Код агента про эти детали ничего не знает.

Пул держит заранее прогретые стенды по этому шаблону:

apiVersion: extensions.agents.x-k8s.io/v1beta1
kind: SandboxClaim
metadata:
  name: task-7f3a          # одна задача – одна заявка
  namespace: sandboxes
spec:
  warmPoolRef:
    name: sandbox-pool
  lifecycle:
    shutdownPolicy: Delete                 # убрать и стенд, и заявку после завершения
    shutdownTime: "2026-09-01T10:00:00Z"   # старт задачи + час: контроллер удалит стенд и без агента

Контроллер видит заявку, отдает готовый стенд из пула, помечает заявку как Ready. В пуле уменьшается количество готовых стендов, а контроллер параллельно запускает замену. Когда задача завершается, заявка и под удаляются, и пул снова полон.

Жизненный цикл стенда выглядит так:

Жизненный цикл стенда
Жизненный цикл стенда

Перед тем как отдать стенду задачу, оркестратор всегда проверяет карточку по адресу /.well-known/agent-card.json. Так он убеждается, что внутри поднялся именно тот исполнитель, который нужен.

В итоге роли разошлись: агент ведет задачу и стадии, сервис песочниц выдает и забирает стенды, в кластере живут шаблоны, пулы и заявки, внутри стенда работает исполнитель с A2A-контрактом. Агент больше не создает поды и не видит секреты исполнения — он просто говорит: «дай стенд из такого-то пула», дожидается, работает, отдает обратно.

Но даже выделенный под — еще не полноценная изоляция. Без дополнительных ограничений это просто удобный способ запускать контейнеры. Настоящую изоляцию дают настройки в шаблоне: non-root, лимиты, read-only корневая файловая система, урезанные capabilities. В SandboxTemplate это выглядит так:

securityContext:
  runAsNonRoot: true
  runAsUser: 65532
  readOnlyRootFilesystem: true
  allowPrivilegeEscalation: false
  capabilities: { drop: ["ALL"] }
  seccompProfile:
    type: RuntimeDefault

Эти настройки можно усилить политиками PodSecurity, NetworkPolicy и RBAC. Главное преимущество схемы 2 в том, что все политики собраны в одном месте — в шаблоне, которым владеет платформа. Агент выбирает только пул и не может ослабить политику, потому что даже не видит манифест.

Сравнение трех схем

Для наглядности мы свели все три схемы в таблицу:

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

Итоги

Агент как сервис выходит за рамки простого подключения модели к API. A2A определяет контракт взаимодействия между агентами и упрощает интеграции, но не решает вопросов безопасности исполнения.

Отдельный pod — необходимый минимум. Но если агент сам управляет pod'ами и знает секреты, граница доверия слишком широка.

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

Мы тестируем агента-разработчика на своих проектах. Пока доверяем ему только часть задач — он требует контроля и может ошибаться. Но для рутинных операций, таких как исправление замечаний линтеров или написание тестов, агент уже эффективен. А надежная инфраструктура дает нам возможность экспериментировать без риска для сервисов.

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

Буду рада увидеть в комментариях ваш опыт решения проблемы изоляции.