Построение агентов на A2A с изолированным исполнением на Go: запускаем ИИ-разработчика в облаке, со…
- четверг, 27 августа 2026 г. в 00:00:07
Меня зовут Вера Касьяненко, я разработчик из команды DevRails AI в MTS Web Services. Сейчас мы создаем агента-разработчика — сервис, в котором языковая модель сама пишет код, запускает тесты, делает ревью и создает коммиты.
Когда прикручиваешь модель к сервису и выдаешь ей инструменты, довольно быстро обнаруживается неудобный факт: команды, которые она генерирует, где-то выполняются. И от ответа на вопрос, где именно — и кто этим местом управляет, — зависит вся остальная архитектура.
В статье рассмотрим три возможные схемы: от наименее изолированной к платформенной, и разберем, как устроена каждая, где она ломается и к какой пришли мы.

Для нас агент — это сервис, в котором языковая модель решает, что делать, а код эти решения исполняет. Звучит просто, но за этим скрывается несколько важных деталей, которые отличают агента от обычного сервиса с LLM.
Первое — входные данные. Мы работаем не с коротким запросом, а с долгой задачей. Модель не отвечает на вопрос и успокаивается — она решает проблему, которая может занимать минуты или даже часы. У такой задачи есть состояние: этапы выполнения, прогресс, промежуточные итоги.
Второе — инструменты. Модель не просто генерирует текст, она работает с файловой системой, запускает команды в shell, взаимодействует с git и внешними API. На выходе мы получаем артефакт, который можно проверить: патч, ветку, merge request или отчет.
Внутри все устроено циклом. Модель получает задачу и список доступных инструментов, но вместо текстового ответа она отдает вызов: «прочитай этот файл», «запусти тесты», «сделай коммит». Код выполняет этот вызов и возвращает результат обратно в модель. И так по кругу, пока задача не будет сделана.
Пока агент только отвечает текстом — это довольно безобидно. Но как только он получает право менять файлы и запускать команды, он становится процессом с реальными правами в вашей инфраструктуре. И здесь начинаются вопросы.
На практике агентов обычно несколько. Можно, конечно, сделать одного большого агента, загрузив в него все инструменты, но он плохо масштабируется: один контекст, все секреты в одной точке. Поэтому мы пошли по пути специализации: выделили агента-аналитика, агента-разработчика, агента-тестировщика. У каждого — свой набор инструментов и зона ответственности. Дальше мы будем говорить про агента-разработчика — того, который клонирует репозиторий, пишет код, запускает тесты и открывает merge request. Набор у него самый опасный: командная строка, файловая система, git, сеть — все это может натворить дел.
Можно каждый раз договариваться по-своему, но это путь к сложностям интеграций. Вот простая арифметика. Если у вас пять сервисов и каждый договаривается с каждым — это десять индивидуальных контрактов. Десять реализаций, десять ручных тестов, десять способов обработки ошибок. Если сервисов становится пятнадцать, связей уже больше ста. И самое затратное здесь — не написать интеграцию, а поддерживать ее. Поменялся один агент — просели все его связи.

Поэтому мы взяли 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. В первой все работает в одном процессе, во второй исполнение вынесено в отдельный под, которым управляет сам агент, в третьей управление средами отдано платформе. Теперь по порядку.
Самый простой способ — запустить агента как обычный сервис, где и оркестрация, и исполнение находятся в одном процессе. Внутри агента — две ключевые части. Первая — конечный автомат, который проводит задачу через стадии. Вторая — движок, который запускает цикл «модель + инструменты».
На 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 и память. И модель при этом будет искренне считать, что делает правильный шаг.
Мы столкнулись с этим на практике. Поэтому очевидный вывод: как только агент начинает не просто отвечать, а действовать, исполнение нужно выносить из его процесса.
Логика следующая: раз опасно держать исполнение рядом с оркестрацией, давайте разнесем их по разным процессам.
Снаружи остается оркестратор. Он принимает задачу, ведет ее состояние и решает, когда создать и удалить изолированную среду — под, внутри которого живет исполнитель. Исполнитель — это тот самый цикл «модель + инструменты» из схемы 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-креды и ключи модели. Если оркестратор скомпрометируют — внешним взломом, вредоносной задачей или даже банальной ошибкой, — злоумышленник получит доступ и к кластеру, и к секретам. Изоляция появилась, а граница доверия почти не сдвинулась.
Второе — холодный старт. Создание нового пода под каждую задачу занимает десятки секунд: скачать образ, инициализировать процесс, дождаться готовности. У нас набегало до минуты на каждую задачу.
Третье — когда агентов стало больше одного, мы заметили дублирование кода. Каждый агент тащит с собой логику создания и удаления пода. Две копии кода — две копии ошибок: один агент забыл почистить ресурсы, другой ослабил лимиты «на время», третий прокинул лишний секрет. Политика безопасности начинает зависеть от того, какой именно агент запустил задачу.
Четвертое — у нас не было общего языка для описания среды. Ни шаблонов, ни пулов, ни единых квот. Каждый агент описывал свою среду по-своему.
Вывод: под отделяет исполнение от процесса агента, но не отделяет управление инфраструктурой от логики задачи. К тому моменту мы уже понимали, чего хотим от следующей итерации: чтобы агент не знал секретов и не имел прав в кластере, чтобы среда описывалась шаблоном на стороне платформы и чтобы старт занимал секунды.
Следующий шаг — вынести управление песочницами из агента. По сути, мы сделали переход от подхода «каждый сервис сам создает себе виртуальную машину» к «есть платформа, которая выдает виртуальные машины по запросу».
У нас появился отдельный сервис песочниц с простым 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'ами и знает секреты, граница доверия слишком широка.
Управление средами стоит вынести в отдельный платформенный слой. Сервис песочниц выдает стенды по заявке, шаблоны и пулы задают политику безопасности. Компрометация агента в этой схеме означает только запрос дополнительного стенда, а не доступ к кластеру и секретам.
Мы тестируем агента-разработчика на своих проектах. Пока доверяем ему только часть задач — он требует контроля и может ошибаться. Но для рутинных операций, таких как исправление замечаний линтеров или написание тестов, агент уже эффективен. А надежная инфраструктура дает нам возможность экспериментировать без риска для сервисов.
И если соберетесь строить своих агентов, самый полезный вопрос на старте — тот, с которого началась эта статья: где именно выполняются команды, которые генерирует модель. Ответ на него и определит всю остальную архитектуру.
Буду рада увидеть в комментариях ваш опыт решения проблемы изоляции.