OpenClaw 병렬 도구 호출에서 결과 순서를 지키고 부분 실패를 격리하는 법
멀티스텝 에이전트 파이프라인을 처음 구성할 때 가장 설레는 순간은 팬아웃이 처음 작동하는 걸 보는 때입니다. 오케스트레이터가 한 번에 네다섯 개 서브에이전트를 띄우고 레이턴시가 확 낮아지는 걸 보면 잠깐 뿌듯해지죠. 그런데 바로 그다음에 이런 상황이 옵니다 — 하나가 실패하면 전부 날아가거나, 결과가 뒤섞여 세션 상태가 조용히 오염되는 것.
저도 처음엔 "ID가 뭔 상관이야, 순서대로 오겠지"라고 대충 넘겼다가 꽤 고생했습니다. OpenClaw 런타임이 tool_use ID를 기준으로 tool_result를 매핑한다는 걸 제대로 이해하고 나서야 왜 세션이 조용히 부서지는지 납득이 됐습니다. 병렬 실행에서는 완료 순서와 모델이 기대하는 응답 순서가 다를 수 있고, 이 불일치가 파이프라인 전체를 망가뜨립니다.
이 글에서는 tool_use ID 기반 결과 정렬, 부분 실패 격리, executionMode 계약 선언, 그리고 반복 실패 루프 차단까지 순서대로 정리했습니다. 2026년 9월 시점의 PR #140767과 v2026.8.1 수정 내역까지 반영한 내용입니다.
병렬 실행이 세션을 오염시키는 구조적 원인
tool_use ID가 계약이다
모델이 단일 응답에서 여러 tool_use 블록을 내보낼 때, 런타임은 이를 병렬로 실행합니다. 문제는 실행이 끝난 순서대로 결과를 반환하면 안 된다는 점입니다. 모델은 자신이 발행한 tool_use ID와 tool_result ID가 1:1로 대응되어야 다음 추론을 올바르게 이어갈 수 있습니다.
ID 미스매치가 발생하면 모델은 엉뚱한 컨텍스트를 이어받아 이후 추론이 통째로 틀어집니다. 세션 로그를 보면 멀쩡해 보이지만 실제 행동은 오염된 상태가 되는, 디버깅하기 가장 까다로운 종류의 버그입니다.
Steering Queue가 결정하는 것
병렬 배치의 일부만 실행 체크포인트(launch checkpoint)를 넘긴 상황에서 에러가 발생하면, Steering Queue는 두 가지를 구분합니다.
- 이미 시작된 호출 → 계속 진행
- 아직 시작되지 않은 순차 호출 → 스킵 처리 후 합성(synthetic) 결과 페어 생성
합성 결과는 실제 실행 없이 생성된 빈 응답입니다. 런타임은 이를 자동으로 "실패"로 분류해주지 않기 때문에, 오케스트레이터에서 명시적으로 감지하지 않으면 성공한 응답과 구분되지 않고 그대로 다음 단계로 흘러갑니다. 아래 다이어그램은 런타임이 무엇까지 해주고, 어디서부터 오케스트레이터의 책임인지를 구분해 그린 것입니다.
이 검사 로직을 개발자가 명시적으로 넣지 않으면, 합성 결과는 성공으로 오인되어 파이프라인이 잘못된 상태로 흘러갑니다.
실전 패턴: 코드로 보는 격리 구현
아래 예시들은 모두 개념적 예시입니다. openclaw.runtime, openclaw.subagents, openclaw.watchers 같은 임포트 경로는 실제 SDK 심볼이 아니라 설명을 위해 이름을 붙인 것이니, 각자 사용 중인 언어·SDK에 맞게 매핑해서 읽어주세요.
1. 결과 ID 정렬 방어 로직
OpenClaw 런타임이 ID 매핑을 담당하지만, 커스텀 오케스트레이터 레이어에서 결과를 수집할 때 직접 방어 로직을 한 겹 더 두면 훨씬 안전합니다.
# 개념적 예시 - 실제 클래스명은 SDK에 따라 다름
import asyncio
async def collect_parallel_results(
tool_use_ids: list[str],
pending_futures: dict[str, asyncio.Future],
) -> list[dict]:
results_by_id: dict[str, dict] = {}
raw = await asyncio.gather(
*[pending_futures[tid] for tid in tool_use_ids],
return_exceptions=True,
)
for tid, outcome in zip(tool_use_ids, raw):
if isinstance(outcome, Exception):
results_by_id[tid] = {
"tool_use_id": tid,
"content": f"[격리된 실패] {type(outcome).__name__}: {outcome}",
"is_error": True,
}
else:
results_by_id[tid] = outcome
return [results_by_id[tid] for tid in tool_use_ids]return_exceptions=True 하나가 핵심입니다. 이 옵션이 없으면 첫 번째 예외에서 gather 전체가 터지면서 이미 성공한 결과까지 잃게 됩니다.
2. 팬아웃 분석 파이프라인의 부분 실패 격리
데이터 수집 에이전트가 원시 데이터를 가져온 후 전처리·감성 분석·토픽 추출·NER 서브에이전트를 동시에 팬아웃하는 구조를 가정해봅니다. 여기서 흔한 실수는 asyncio.create_task()로 태스크를 띄운 뒤 for 루프에서 하나씩 wait_for로 기다리는 패턴입니다. 태스크 실행 자체는 병렬이지만, 앞선 태스크가 타임아웃(60초)에 걸리면 이미 1초 만에 끝난 뒤 태스크의 결과도 그 60초 동안 방치됩니다. 위의 collect_parallel_results와 마찬가지로 gather + return_exceptions=True 조합으로 통일하는 게 낫습니다.
# 개념적 예시
import asyncio
ANALYSIS_AGENTS = {
"preprocessing": preprocess_agent_config,
"sentiment": sentiment_agent_config,
"topics": topic_agent_config,
"ner": ner_agent_config,
}
async def run_with_timeout(name, coro, timeout):
try:
return name, await asyncio.wait_for(coro, timeout=timeout)
except Exception as e:
return name, e
async def run_analysis_fanout(raw_data: str) -> dict:
coros = [
run_with_timeout(name, spawn_subagent(cfg, input=raw_data), timeout=60.0)
for name, cfg in ANALYSIS_AGENTS.items()
]
outcomes = await asyncio.gather(*coros) # 개별 실패는 각 코루틴에서 캡처
partial_results: dict = {}
failed_agents: list[str] = []
for name, outcome in outcomes:
if isinstance(outcome, Exception):
failed_agents.append(name)
partial_results[name] = None
else:
partial_results[name] = outcome
if failed_agents:
partial_results["_failed"] = failed_agents
return partial_results이렇게 하면 각 서브에이전트가 자기 몫의 타임아웃을 가지면서도, 하나의 지연이 다른 결과의 수집을 막지 않습니다.
주의: Issue #132765에서
agents_wait의timeoutSeconds가 무시되는 버그가 보고됐습니다. 각 코루틴 내부에서asyncio.wait_for로 타임아웃을 직접 제어하는 게 현재로선 더 안정적입니다.
3. executionMode: "sequential"로 레이스 컨디션 방지
상태를 공유하는 MCP 도구나 파일 시스템을 변경하는 도구는 병렬 실행되면 레이스 컨디션이 생깁니다. 도구 플러그인 계약에 executionMode를 선언하면 런타임이 이를 강제합니다.
{
"name": "file_writer",
"description": "로컬 파일시스템에 결과를 기록합니다.",
"executionMode": "sequential",
"inputSchema": {
"type": "object",
"properties": {
"path": { "type": "string" },
"content": { "type": "string" }
},
"required": ["path", "content"]
}
}PR #140767(2026년 9월 머지)이 Code Mode에서 이 계약이 무시되던 버그를 수정했습니다. 그 전까지는 executionMode: "sequential"을 선언해도 병렬 실행되는 케이스가 있었으니, 자신이 사용 중인 릴리스에 이 패치가 포함됐는지 체인지로그에서 반드시 교차 확인하는 편이 안전합니다. (필자가 PR 병합 시점에서 릴리스 번호를 특정할 수 있는 공식 근거를 찾지 못해, 여기서는 특정 버전을 단정하지 않았습니다.)
4. Watcher로 반복 실패 루프 차단
부분 실패가 오케스트레이터로 전달된 후 같은 오류를 반복 재시도하는 루프에 빠지는 경우가 있습니다. ClawKeeper의 Watcher 컴포넌트 방식을 참고하면 시스템 수준에서 이를 막을 수 있습니다. 아래는 인터페이스 이름과 등록 방식이 각자 SDK에 따라 달라질 수 있으니, "이런 훅 지점이 하나 있어야 한다"는 관점으로 봐주세요.
# 개념적 예시 - 실제 등록 방식은 SDK 문서 확인 필요
class RepeatFailureWatcher:
def __init__(self, max_consecutive_failures: int = 3):
self._failure_counts: dict[str, int] = {}
self._threshold = max_consecutive_failures
async def on_upstream_failure(self, event) -> bool:
agent_id = event.agent_id
self._failure_counts[agent_id] = (
self._failure_counts.get(agent_id, 0) + 1
)
# True 반환 시 이 Watcher를 등록한 오케스트레이터 훅이
# 해당 서브트리 재시도를 중단하도록 해석해야 합니다.
return self._failure_counts[agent_id] >= self._threshold이 Watcher가 실제 효과를 내려면 (a) 오케스트레이터의 재시도 루프에서 on_upstream_failure를 호출하고, (b) 그 반환값이 True일 때 재시도를 실제로 중단하도록 오케스트레이터 쪽 흐름을 연결해야 합니다. 런타임이 자동으로 등록·해석해주는 훅이 아니라는 점만 기억해두면 됩니다.
설정값과 한계: 출처를 함께 붙여 정리
아래 표에 나오는 수치는 모두 필자가 확인 가능한 출처로 연결했습니다. 확실한 근거를 찾지 못한 항목은 표에서 뺐거나 "확인 필요"로 표기했습니다.
| 항목 | 값 / 조건 | 출처·확인 방법 |
|---|---|---|
| 서브에이전트 동시성·복구 정책 | 설정 키·기본값은 릴리스마다 갱신됨 | Sub-agent concurrency, recovery, and stopping |
| 배달 백로그 경고·차단 임계값 | 프로젝트 설정에 따름 (기본값 확인 필요) | Steering queue |
| 서브에이전트 중첩 깊이 | 배포마다 상이할 수 있음 | Sub-agents에서 현재 사용 중인 버전의 문서로 확인 |
parallel_tool_calls 호환성 |
일부 OpenAI 호환 제공자에서 400 에러 유발 | Issue #37048 |
| Exec 도구 타임아웃 처리 | 런 전체가 중단되는 케이스 존재 | Issue #144514 |
초안에 있던 "maxChildrenPerAgent 기본값 5", "배달 백로그 경고 25 / 차단 50", "중첩 깊이 최대 2단계" 같은 구체 수치는 필자가 공식 문서에서 근거 링크를 특정하지 못해 이번 글에서는 빼두었습니다. 사용 중인 버전의 문서에서 각자 확인해보시길 권합니다.
parallel_tool_calls 호환성은 꽤 예상치 못한 함정입니다. v2026.3.2에서 OpenAI 호환 제공자에게도 parallel_tool_calls: true를 전송하여 400 에러 루프가 발생한 이슈가 커뮤니티에서 광범위하게 논의됐습니다. 제공자를 바꿀 계획이 있다면 반드시 해당 모델의 병렬 도구 호출 지원 여부를 사전에 확인하는 게 좋습니다.
트레이드오프 정리
버전 의존적 이슈는 각주로 빼서, 버그가 수정된 뒤에도 그 단점이 유효한 것처럼 보이지 않도록 정리했습니다.
| 접근 방식 | 장점 | 개념적 단점 |
|---|---|---|
| 완전 병렬 팬아웃 | 레이턴시 최소화 | ID 미스매치·결과 충돌 위험 |
| 부분 실패 격리 (개별 timeout) | 성공 결과 보존 | 오케스트레이터에 격리 규약 필요 1 |
executionMode: "sequential" |
레이스 컨디션 선언적 방지 | 도구 스루풋 감소 2 |
| Parallel Specialist Lanes | 공유 용량 경쟁 없음 | 아키텍처 설계 복잡도 증가 |
| Watcher 루프 차단 | 반복 실패 자동 탐지 | 임계값 튜닝 필요, 훅 배선 직접 해야 함 |
지금 챙겨야 할 실무 주의사항
Issue #108에서 팬아웃 완료 후 자식 결과가 충돌하여 드롭되던 문제가 보고됐고, v2026.8.1에서 수정됐습니다. 수정 이후에도 엣지 케이스는 남아 있을 수 있으니, _failed 필드처럼 명시적 실패 마커를 오케스트레이터 응답에 항상 포함시키는 방어 관행을 유지하는 게 안전합니다.
또 하나, 결정론적 팬인 배리어가 아직 런타임에 내장되어 있지 않습니다. Issue #38433이 2026년 주요 기능 요청으로 등록돼 있고, 그 전까지는 DEV Community 사례처럼 서드파티 라이브러리로 팬인 배리어를 직접 구현하거나, 위에서 보여드린 것처럼 asyncio.gather + 코루틴 내부 타임아웃 조합으로 같은 효과를 낼 수 있습니다.
정리하면 병렬 도구 호출 파이프라인의 안정성은 "런타임이 어디까지 해주고, 어디서부터 내가 방어해야 하는지"의 경계를 얼마나 명확히 이해하고 있느냐에 달려 있습니다. tool_use ID 매핑은 런타임 몫이지만, 합성 결과 감지와 실패 격리는 오케스트레이터 몫이고, executionMode 계약은 선언은 개발자가 하되 이행 여부는 릴리스에 따라 다시 검증해야 합니다. 이 경계를 명시적으로 코드에 새겨두면, 다음에 릴리스가 바뀌어도 세션이 조용히 부서지는 상황은 훨씬 줄어듭니다.
참고 자료
- Agent runtime · OpenClaw 공식 문서
- Sub-agents · OpenClaw 공식 문서
- Sub-agent concurrency, recovery, and stopping · OpenClaw
- Parallel specialist lanes · OpenClaw 공식 문서
- Steering queue · OpenClaw 공식 문서
- Tool plugins · OpenClaw 공식 문서
- PR #140767: fix(agents): honor sequential tools in Code Mode
- Issue #37048: v2026.3.2 sends parallel_tool_calls to unsupported models
- Issue #38433: Feature request — deterministic fan-out/fan-in barrier
- Issue #108: Subagent results lost after fan-out turn-claim collision
- Issue #132765: agents_wait ignores timeoutSeconds
- Issue #144514: Exec tool timeout aborts whole run instead of returning tool error
- v2026.8.1 Other Bug Fixes · OpenClaw
- How I Built a Deterministic Multi-Agent Dev Pipeline Inside OpenClaw — DEV Community
- ClawKeeper: Comprehensive Safety Protection for OpenClaw Agents (arXiv)
- OpenClaw Changelog (2026년 9월)
Footnotes
-
특정 버전에서는
agents_wait의timeoutSeconds가 무시되는 버그로 인해 코루틴 내부wait_for우회가 추가로 필요합니다(#132765). ↩ -
Code Mode에서 이 계약이 무시되던 버그는 PR #140767 이후 수정됐습니다. 사용 중인 릴리스에 해당 패치가 포함됐는지 별도 확인이 필요합니다. ↩