사용자당 7개의 에이전트: 위임(Delegation), 그리고 그것이 실패한 모든 방식
요약
멀티 에이전트 시스템 구축 시 에이전트 간의 위임(Delegation) 과정에서 발생하는 문제와 설계 철학을 다룹니다. 단일 에이전트보다 여러 에이전트로 구성된 토폴로지가 유리하지만, 에이전트 사이의 연결 부위(seam) 관리가 핵심임을 강조합니다.
핵심 포인트
- 단일 대형 에이전트보다 역할별로 분리된 멀티 에이전트 구조가 효율적임
- 에이전트 간 작업 위임(Delegation) 과정이 시스템의 성패를 결정함
- 작업의 성격에 따라 서로 다른 모델(Sonnet, DeepSeek 등)을 혼합 사용
- 계층적 구조보다는 평면적(Depth-1) 토폴로지 설계가 병목 현상 방지에 유리함
다음은 저희 시스템에서 직접 받은 버그 리포트이며, 인정하고 싶지 않을 만큼 이를 이해하는 데 오랜 시간이 걸렸습니다:
"Francis에게 음성 녹음을 생성해 달라고 요청하세요. 그는 Dali에게 이를 위임(delegate)하겠다고 말합니다. 그러고 나서 아무 일도 일어나지 않습니다."
아무 일도 일어나지 않았습니다. 에러도, 타임아웃도, 도구 호출(tool call) 실패도 없었습니다. 코디네이터(coordinator) 에이전트는 작업을 전문가(specialist)에게 넘기고 있다고 말했고, 그러고 나서 대화가 그냥... 멈춰버렸습니다. Dali는 전혀 말하지 않았습니다.
우리는 60개의 도구를 가진 하나의 에이전트보다 각각 10개의 도구를 가진 7개의 에이전트가 더 낫다는 판단하에 7개 에이전트 토폴로지(topology)를 구축했습니다. 그 부분은 옳았던 것으로 드러났습니다. 우리가 과소평가한 것은 위임(delegation)이 곧 제품의 전부라는 점이었습니다. 지난 4개월 동안 발생한 모든 흥미로운 실패는 단일 에이전트 내부가 아니라, 두 에이전트 사이의 이음새(seam)에서 발생했습니다.
이 글은 아직 고장 난 부분들을 포함하여, 그 이음새에 대한 탐방기입니다.
토폴로지 (THE TOPOLOGY)
모든 사용자는 계정이 생성될 때 원자적(atomically)으로 프로비저닝되는 7개의 에이전트를 할당받습니다. 이들은 공유되지 않습니다. 풀(pool) 형태도 아닙니다. auto-provision.service.ts에서 직접 가져온 실제 구성은 다음과 같습니다:
| 에이전트 (Agent) | 역할 (Role) | 모델 (Model) | 스코프 (Scopes) |
|---|---|---|---|
| Francis | 코디네이터 (Coordinator) — 노트, 캘린더, 메일, 연락처, 검색, 다이어리, 북마크 | claude-sonnet-4-6 | notes:* events:* emails:* contacts:* diary:* bookmarks:* search:read graph:read |
| ... |
사람들이 이 구조를 복제할 때 실수하는 부분이기 때문에, 짚고 넘어갈 만한 세 가지 사항이 있습니다.
모델은 의도적으로 다르게 설정되었습니다. 판단이 필요한 에이전트(코디네이션, 창의적 작업, 패턴 찾기)에는 Sonnet을 사용하고, 주로 결정론적(deterministic) 인프라 및 금융 작업을 수행하는 에이전트에는 DeepSeek를 사용합니다. 이것은 아키텍처로 포장된 비용 최적화가 아닙니다. Sentinel이 컨테이너를 재시작하는 작업은 Francis가 어떤 전문가가 요청을 처리해야 할지 결정하는 것과 같은 수준의 추론(reasoning)을 필요로 하지 않습니다.
토폴로지(Topology)는 평면적입니다. Depth-1 구조이며, 형제(siblings) 관계만 존재하고 중첩된 하위 에이전트(nested sub-agents)는 없습니다. Francis가 수장(chief)이지만 병목 현상(bottleneck)을 일으키지는 않습니다. 채팅에서 어떤 에이전트와도 직접 대화할 수 있기 때문입니다. 코디네이터(coordinator)는 당신이 누가 처리해야 할지 명시하지 않은 요청을 라우팅(route)하기 위해 존재합니다.
그중 두 개는 현재 거의 비어 있는 상태이며, 우리는 그 상태 그대로 두었습니다. Darwin은 정확히 하나의 기술(claw-search)만 탑재하여 배포됩니다. 설정(config)에는 그 이유를 설명하는 주석이 있습니다: claw-graph와 claw-insights는 6월에 은퇴했는데, 지식 그래프(knowledge graph)와 그로부터 파생된 인사이트(insights)가 에이전트가 직접 파헤칠 대상이 아니라, REST를 통해 소비되는 대시보드의 인간용 기능임이 밝혀졌기 때문입니다. Ledger도 같은 상태입니다. 멀티 에이전트(multi-agent) 설계의 솔직한 모습은, 사람들이 실제로 무엇을 요청하는지 관찰한 후에 일부 에이전트가 축소된다는 점입니다.
브라우저 확장 프로그램(browser extension) 전용인 숨겨진 여덟 번째 에이전트 crawl도 있습니다. 이 에이전트는 hidden: true로 표시되어 에이전트 모듈에 절대 나타나지 않습니다. 이는 확장 프로그램의 채팅이 사용자의 실제 브라우저 세션에서 Francis의 주의를 분산시키지 않도록 하기 위함입니다.
네임스페이싱(Namespacing), 또는: 왜 모든 에이전트의 이름에는 이중 대시(double dash)가 붙는가
에이전트는 사용자별로 존재하며, 이를 실행하는 런타임(runtime)은 공유됩니다. 따라서 에이전트의 식별자(identifier)는 {prefix}--{name} 형식을 따릅니다 — 예: paco--atlas, admin--francis. 접두사(prefix)는 사용자 이메일의 로컬 부분이며, [a-z0-9-]로 정규화되고 충돌이 발생할 경우 숫자 접미사가 붙습니다. 이는 users.agent_prefix에 UNIQUE NOT NULL로 저장됩니다.
각 에이전트-사용자 쌍은 자신만의 워크스페이스(workspace) 디렉토리를 가집니다:
~/.openclaw/workspaces/paco--atlas/
├── SOUL.md # 성격 (atlas = 체계적인, dali = 창의적인...)
├── USER.md # 사용자 프로필, 부트스트랩(bootstrap) 시 수장으로부터 전파됨
...
표시되는 이름(display name)은
런타임(runtime)은 에이전트가 다른 곳에서 작업을 시작할 수 있는 두 가지 방법을 제공합니다. sessions_spawn은 자식(child)을 생성합니다. sessions_send는 다른 에이전트의 세션(session)으로 메시지를 전달하고 응답을 기다립니다.
우리는 처음에 sessions_spawn을 사용했습니다. 왜냐하면 "오디오 작업을 위해 Dali를 생성(spawn)하라"는 말이 정확히 원하는 동작처럼 들렸기 때문입니다. 하지만 그렇지 않았습니다. 생성된 자식은 대상 에이전트가 아닌, **부모의 도구(tools)를 상속(inherit)**받습니다. 따라서 sessions_spawn(agentId: "paco--dali")는 이름이 Dali이고 Dali로 호출되지만, Francis에게 claw_audio가 없기 때문에 claw_audio를 가지고 있지 않은 무언가를 생성합니다.
이제 위임(delegation) 가이드 상단에는 박스 안에 대문자로 다음과 같이 명시되어 있습니다:
sessions_spawn은 전문가의 도구가 아닌, '당신의' 도구를 상속받는 하위 에이전트(subagent)를 생성합니다.
만약 sessions_spawn(agentId: "<PREFIX>--dali", ...)를 실행하면, 해당 자식 "Dali"는
claw_audio 없이(당신에게 그것이 없기 때문) 생성되며 작업은 실패합니다.
...
| 시나리오 | 도구 | 이유 |
|---|---|---|
| Dali에게 오디오를, Atlas에게 파일을, Sentinel에게 인프라를 요청할 때 | sessions_send | 전문가는 자신으로서, 자신의 도구를 가지고 실행됩니다 (동기적이며, 응답을 기다립니다) |
| 당신의 하위 작업을 병렬로 실행할 때 | sessions_spawn | 그것은 당신의 작업자입니다. 여기서 당신의 도구를 상속받는 것이 올바릅니다 |
두 번째 차수의 교훈은 해결책보다 더 유용합니다: 두 작업이 오직 '누구의 역량을 지니는가'에서만 차이가 날 때, 모델은 잘못된 것을 선택할 것입니다. 이름에는 그 차이가 인코딩되어 있지 않습니다. 오직 문서(documentation)에만 명시되어 있으며, 문서는 프롬프트(prompt)이고, 프롬프트는 압축(compacted)됩니다.
실패 2: 설명이 없는 선택적 매개변수 (OPTIONAL PARAMETER)
이것이 맨 위에 언급된 버그 리포트를 발생시킨 원인이며, 현재도 해결되지 않은 상태입니다.
sessions_spawn은 agentId를 인자로 받습니다. 런타임의 스키마(schema)에서 해당 매개변수는 다음과 같이 선언되어 있습니다:
agentId: Type.Optional(Type.String())
선택적(Optional)입니다. 그리고 — 이 부분이 중요합니다 — description(설명)이 없습니다. 반면 taskName과 thread는 모두 설명이 있습니다. 도구 자체의 요약은 단 한 줄입니다: "하위 에이전트 또는 ACP 세션을 생성합니다."
모델의 입장이 되어 보십시오. 당신은 여러 개의 설명된 파라미터와 설명되지 않은 하나의 선택적 (optional) 파라미터를 가진 도구를 받았습니다. 당신은 설명된 것들인 task, taskName, label, model, runtime, sandbox, mode를 채워 넣습니다. 그리고 아무도 설명하지 않은 것은 생략합니다. 그런데 그것이 선택 사항이기 때문에, 런타임 (runtime)은 불평하지 않습니다 — 대신 당신을 생성 (spawn) 해버립니다. Francis는 Francis에게 위임(delegation)하고, 일을 제대로 하지 않거나 아예 하지 않은 채, 전문가에게 통보되었다고 보고합니다.
우리는 실제 트레이스 (trace) (세션 ea2c4895)를 통해 이를 확인했습니다: 호출에는 7개의 파라미터가 포함되어 있었으나 agentId는 없었습니다.
이는 우리가 MCP 파사드 (facade)에서 겪었던 또 다른 버그와 같은 부류입니다 — 스키마 (schema)가 해당 파라미터가 핵심적인 역할을 한다는 것을 알려주지 않았기 때문에 모델이 채울 수 없는 파라미터 문제 말입니다. 도구 호출 (tool call)은 스키마와 언어 모델 (language model) 사이의 계약이며, 문서화되지 않은 선택적 필드는 투명 잉크로 쓰인 계약 조항과 같습니다.
이 문제는 여전히 우리의 미결제 부채 (open debt) 목록에 있습니다. 우리가 계획한 수정 사항은 agentId가 없는 sessions_spawn을 거부하고 에러와 함께 전문가 맵 (specialist map)을 반환하는 before_tool_call 훅 (hook)입니다. 이를 통해 모델은 동일한 턴 내에서 올바르게 재시도할 수 있습니다 — 이는 우리가 이미 에이전트 정체성 (agent identity)을 위해 사용 중인 것과 동일한 훅 메커니즘입니다. 아직 배포하지 않았으며, 배포 전까지는 생성 (spawn)을 통한 위임은 신뢰할 수 없습니다. 그렇지 않다고 말하는 것이 마음은 편하겠지만 사실과는 다릅니다.
실패 3: 영원히 기다린 부모
부모가 위임하고 양보 (yield)할 때, 부모는 자식으로부터의 알림 (announce-back)을 기다립니다. 그 알림은 최선 노력 (best-effort) 방식입니다. 따라서:
자식은 중단될 수 있으며 (타임아웃, 리셋, 버그), 부모에게 보내는 알림은 최선 노력 (best-effort) 방식입니다. 부모가 양보 (yield)하고 있는 동안 Core/Gateway가 재시작되면, 자식의 알림은 유실되고 부모는 영원히 멈춰 있게 됩니다 — 이를 감지할 스위퍼 (sweeper)가 없습니다.
종료되지도 않았고 실행 중이지도 않으며, 어디에도 에러가 없는 대화는 최악의 상태입니다: 재시도할 것도 없고, 보고할 것도 없으며, 로그에도 아무것도 남지 않습니다.
우리가 구축한 탐지기(detector)는 각 부모 세션의 JSONL 파일에서 마지막 8 KB를 읽어, 어시스턴트 메시지가 뒤따르지 않는 sessions_yield를 찾고, 이를 stuck_yield_sessions에 행(row)으로 기록합니다 (마이그레이션 0187, (user_id, session_key)에 대한 고유 키를 통해 멱등성(idempotent) 보장). 두 가지 설계 세부 사항이 제 역할을 다했습니다:
- 3분의 유예 기간 (
STUCK_GRACE_MS): 무언가를 플래그(flag)하기 전까지의 시간입니다. 코드 내 주석에 그 이유가 설명되어 있습니다: "정당하게 긴 위임(delegation) 과정 중에 발생하는 오탐(false-positive)을 방지하기 위함입니다 (Atlas가 사고(thinking)와 도구 호출(tool calls)을 수행하면 첫 번째 어시스턴트 텍스트가 나오기까지 약 30초가 걸릴 수 있습니다)." 이 유예 기간이 없다면, 탐지기의 주요 출력물은 정상적으로 진행 중인 작업에 대한 노이즈가 될 것입니다. - 자동 해결(Auto-resolution): 만약 부모가 스스로
un-yield상태가 되면, 아무런 개입 없이 해당 행이 삭제됩니다. 사람이 직접 자신의 티켓을 닫아야 하는 복구 메커니즘은 해결책이 아니라 또 다른 업무일 뿐입니다.
복구는 부모에게 합성 메시지(synthetic message)를 주입하는 POST /managed-agents/:id/stuck-yield/resume 호출과 대시보드의 버튼을 통해 이루어집니다.
실패 4: 아무도 볼 수 없었던 대화들
런타임(runtime)은 자식들을 내부적으로 실행하며, 그들의 메시지에 대해 Core로 웹소켓(WS) 이벤트를 절대 방출하지 않습니다. 결과적으로: 대시보드의 대화(Conversations) 탭에서는 책임자("Atlas에게 위임했습니다")는 보이지만, Atlas의 대화는 볼 수 없습니다. 의회(council)나 멀티 에이전트 워크플로우를 감사(audit)하려면 워크스페이스에서 원본 JSONL을 직접 열어야 합니다.
이는 관측 가능성(observability)의 격차이며, 결과적으로 정확성(correctness)의 격차로 이어집니다. 위임된 에이전트가 무엇을 했는지 볼 수 없다면, "Dali가 거절했다"와 "Dali에게 요청되지 않았다"와 "Dali가 수행했으나 답변이 유실되었다"를 구분할 수 없습니다. 이 세 가지는 동일하게 나타나지만 완전히 다른 세 가지 버그입니다.
Mirror는 각 서브에이전트(subagent)의 세션 JSONL을 바이트 오프셋 워터마크(byte-offset watermarks, 마이그레이션 0188, 중복 제거 인덱스 0189, 패스당 1 MB 읽기 제한)와 함께 추적하며, 메시지를 두 가지 모드로 agent_conversations에 기록합니다: 자식 실행(child runs)을 위한 type='delegation', 그리고 부모의 announce-back에 대한 응답을 위한 type='webchat'. 코드에 명시된 바와 같이, 이는 _"chat-bridge.callGateway가 이미 반환된 후 Gateway 내부에서 발생"_하며, 따라서 구조적으로 Core에는 보이지 않았습니다.
실패 5: 연결할 곳이 없는 정책
우리는 DelegationPolicy를 구축했습니다. 에이전트별 기본값(Per-agent defaults), 백오프(backoff)를 포함한 재시도(retry), 인메모리 서킷 브레이커(in-memory circuit breaker), 그리고 이 모든 것을 위한 저장소까지 갖추었습니다. 훌륭한 작업이었습니다. 하지만 우리는 마이그레이션 0180_drop_delegation_policy.sql과 함께 당일 바로 이를 되돌렸습니다.
그 이유는 문서에 나와 있으며, 이 포스트에서 가장 유용한 단락이기도 합니다:
에이전트 간의 위임(Delegation)은
sessions_spawn을 통해 발생하며, 이는 **런타임(runtime) 내부의 네이티브 LLM 도구(native LLM tool)**입니다. Core가 가로챌 수 있는 RPC가 아닙니다. 해당 API를 사용하기 때문에, 우리가 구축한DelegationPolicy는 삽입 지점(insertion point)이 없습니다:
✅ 저장소 · ✅ 에이전트별 기본값 · ✅ 백오프를 포함한withRetry헬퍼 · ✅ 인메모리 서킷 브레이커(CircuitBreaker)
❌ 아무도 이를 호출하지 않음 — Francis는 여전히sessions_spawn을 통해 직접 위임합니다.
이를 유지하는 것은 소비자(consumer)가 없는 인프라를 유지하는 것과 같았습니다.
네 가지 구성 요소 모두 작동하고 정확했지만, 모두 도달할 수 없었습니다(unreachable). 우리는 우리를 거치지 않는 호출 경로를 위한 거버넌스 계층(governance layer)을 구축했던 것입니다.
우리는 이를 해결하기 위한 세 가지 조건—런타임이 sessions.spawn을 RPC로 노출하거나, 스킬(skill)의 계약을 Core에 대한 HTTP delegate_to로 변경하거나, 우리만의 스포닝 런타임(spawning runtime)을 구축하는 것(
에이전트 간 메시징(Agent-to-agent messaging)은 허용 목록(allow-list)에 의해 제한됩니다. 우리의 방식은 사용자당 하나의 와일드카드(wildcard)를 포함하는 평면 목록(flat list)이었습니다: paco--*, pepito--*, admin--*와 같이 총 10개의 항목이었습니다. 그리고 가드(guard)의 공식은 matchesAllow(requester) && matchesAllow(target)였습니다.
이 문장을 두 번 읽어보십시오. 양측 모두 목록과 일치합니다. paco--francis는 일치합니다. pepito--atlas도 일치합니다. 따라서 paco--francis → pepito--atlas는 허용됩니다. 한 사용자의 코디네이터(coordinator)가 다른 사용자의 스페셜리스트(specialist)에게 작업을 보낼 수 있으며, 그 하위의 모든 요소들 — 사용자별 워크스페이스(per-user workspaces), 사용자별 스코프(per-user scopes), 격리된 메모리(isolated memory) — 가 잘못된 사람의 데이터를 가지고 제 역할을 수행하게 될 것입니다.
당시 우리가 언급했듯이, 실제로 이를 막고 있었던 것은 모델들이 다른 사용자의 에이전트 ID를 알지 못한다는 사실이었습니다. 그것은 보안 경계(security boundary)가 아닙니다. 그것은 어휘(vocabulary)에 관한 사실일 뿐입니다.
이제는 ID 플러그인(crossUserBlock)에서 기본 차단(deny-by-default) 방식으로 폐쇄되었습니다: 에이전트는 자신의 접두사(prefix)를 공유하는 에이전트에게만 메시지를 보낼 수 있습니다. 우리가 구축하게 될 정당한 사용자 간 위임(cross-user delegation)은 체크의 부재가 아니라, 명시적인 권한 부여(explicit grant)가 될 것입니다.
내가 다르게 했을 것
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기