이 글은 제 보안 프로젝트 AgentShield의 개인 트러블 슈팅 및 기록 블로그입니다.
배경
AgentShield는 LangGraph 기반 Red/Blue 에이전트 공방 파이프라인이다. Phase 1(공격 스캔) → Phase 2(변형 공격) → Phase 3(방어 코드) → Phase 4(방어 검증)로 이어지며, 각 단계에서 여러 LLM 에이전트가 Ollama를 호출한다.
로컬 GPU(4070Ti, 12GB VRAM)에서 이 파이프라인을 돌리자 다음 문제들이 동시에 터졌다:
Ollama returned empty response (attempt 1/3)
Ollama API Error 500: {"error":"EOF"}
[Target LLM] timeout (attempt 1/3)
에러 로그는 같은 초에 같은 요청이 여러 번 찍히고, 모델 로딩 충돌이 발생했다. 단일 GPU에서 여러 에이전트가 동시에 Ollama를 호출하면 어떤 일이 벌어지는가 — 이 글은 그 문제와 해결 과정을 다룬다.
문제 1: asyncio.gather가 만든 환상의 병렬성
증상
asyncio.gather로 12개 공격 패턴을 동시 실행하자 로그에 같은 타임스탬프에 2개씩 HTTP 요청이 찍혔다.
[TRACE] [18:00:13] http post -> http://localhost:8010/chat
[TRACE] [18:00:13] http post -> http://localhost:8010/chat ← 동시 요청
asyncio.Semaphore(1)을 걸었음에도 효과가 없었다.
원인 분석
첫 번째 문제는 세마포어 위치였다. 세마포어가 _execute_attack_pattern 내부의 타겟 호출 부분만 감쌌는데, asyncio.gather는 12개 코루틴을 동시에 이벤트 루프에 올린다.
# 잘못된 구조: gather가 모든 태스크를 동시 스케줄
tasks = [_execute_attack_pattern(...) for pattern in patterns]
results = await asyncio.gather(*tasks) # 12개 동시 실행
각 태스크 내부에서 세마포어를 획득하더라도, judge 호출(full_judge)은 세마포어 밖에 있었다. 결과적으로:
- 패턴 A: 타겟 호출 완료 → 세마포어 해제 → judge 시작
- 패턴 B: 세마포어 획득 → 타겟 호출 시작
- 동시에 타겟(gemma4) + judge(qwen3.5) = 2개 모델 활성화
두 번째 문제는 이중 로깅이었다. _patched_phase2_target_for_ollama가 httpx.AsyncClient를 전역 교체(phase2_red_agent.httpx.AsyncClient = OllamaCompatibleAsyncClient)했기 때문에, Phase 1의 httpx.AsyncClient도 패치된 클라이언트가 되어 Phase 1 전용 ollama_send_fn과 OllamaCompatibleAsyncClient 양쪽에서 중복 로깅이 발생했다. 실제 요청은 1개였지만 로그는 2개가 찍혔다.
해결: gather 제거, 순차 for 루프로 완전 직렬화
# 수정: 물리적으로 1개씩 순차 처리
results = []
for i, pattern in enumerate(attack_patterns):
try:
result = await _execute_attack_pattern(...)
results.append(result)
except Exception as e:
logger.error(f"[Phase1] Pattern {i + 1} unhandled exception: {e}")
세마포어 없이도 물리적으로 1개씩 처리된다. Semaphore를 통한 동시성 제어는 같은 이벤트 루프에서 코루틴이 경쟁하는 구조에서만 의미있다. 애초에 동시 실행하지 않으면 세마포어 경쟁 자체가 없다.
문제 2: 여러 모델 동시 로드 — VRAM 고갈
증상
ollama ps로 현재 로드된 모델을 확인했을 때:
qwen-coder:latest 21.9GB
qwen3.5:4b 5.8GB
gemma4:e2b 7.9GB
총 35.6GB — 12GB VRAM에서 불가능한 구성
원인: Docker 이미지에 구워진 구버전 .env
백엔드 Docker 컨테이너가 2일 전 빌드 시 .env가 이미지 내부에 복사되어 있었다. 이 구버전 .env에는:
OLLAMA_MODEL=gemma4:e2b
OLLAMA_RED_MODEL=gemma4-ara-abliterated
그리고 llm_client.py의 ollama_target_models에 하드코딩된 기본값:
self.ollama_target_models = {
"red": os.getenv("OLLAMA_RED_TARGET_MODEL", "agentshield-red"), # 존재하지 않음
"judge": os.getenv("OLLAMA_JUDGE_TARGET_MODEL", "agentshield-judge"), # 존재하지 않음
...
}
agentshield-red를 Ollama에 요청 → 404 응답 → fallback 로직:
if response.status_code == 404:
fallback_model = role_config["ollama_model"] # 구 .env의 값
self.active_ollama_model = fallback_model
구 .env → OLLAMA_RED_MODEL=gemma4-ara-abliterated → 이것도 없으면 연쇄 fallback → qwen-coder:latest 로드.
Python의 load_dotenv() 함정
load_dotenv()는 기본적으로 이미 환경변수에 있는 값을 덮어쓰지 않는다(override=False). Docker 컨테이너는 docker-compose.yml의 environment 섹션 값이 먼저 주입되고, 이후 load_dotenv()가 이미지 내부 .env를 읽어도 덮어쓰지 않는다.
따라서 docker compose restart(컨테이너 재시작)로는 새 환경변수가 반영되지 않는다. docker compose up -d --force-recreate로 컨테이너를 재생성해야 한다.
해결: docker-compose에 env 명시 + 모델 단일화
# docker-compose.yml
services:
backend:
environment:
OLLAMA_MODEL: qwen3.5:4b
OLLAMA_RED_MODEL: qwen3.5:4b
OLLAMA_JUDGE_MODEL: qwen3.5:4b
OLLAMA_GUARD_MODEL: qwen3.5:4b
OLLAMA_BLUE_MODEL: qwen3.5:4b
OLLAMA_BASE_TARGET_MODEL: qwen3.5:4b
OLLAMA_RED_TARGET_MODEL: qwen3.5:4b
OLLAMA_JUDGE_TARGET_MODEL: qwen3.5:4b
OLLAMA_BLUE_TARGET_MODEL: qwen3.5:4b
docker-compose.yml에 명시된 environment 값은 이미지 내부 .env보다 우선 적용된다. 재생성 후 확인:
docker exec agentshield-backend-1 python3 -c "
from backend.agents.llm_client import AgentShieldLLM
llm = AgentShieldLLM()
print(llm.active_ollama_model) # → qwen3.5:4b
print(llm.ollama_target_models) # → 전부 qwen3.5:4b
"
문제 3: LangGraph 내부 fan-out의 GPU 경쟁
구조
LangGraph judge graph:
scanner → strict_auditor ──┐
→ context_auditor ─┤→ consensus → [debate] → END
scanner에서 두 auditor로 fan-out이 발생한다. LangGraph의 ainvoke는 이를 asyncio로 동시 실행한다. 두 auditor 모두 call_ollama_judge()를 호출 — 같은 모델에 동시 요청 2개.
해결: 전역 Semaphore
_ollama_semaphore = asyncio.Semaphore(1)
async def call_ollama_judge(...):
async with _ollama_semaphore: # strict와 context가 여기서 직렬화
async with aiohttp.ClientSession(...) as session:
for attempt in range(max_retries):
...
핵심: 세마포어를 retry loop 전체에 씌워야 한다. retry 사이의 sleep도 세마포어를 잡고 있으면 다른 호출이 그 시간 동안 대기한다. 올바른 구조는 sleep을 세마포어 밖으로 빼는 것이다 — 단, 단일 GPU 단순화 후에는 Phase 1 자체가 순차 실행이므로 judge도 한 번에 1개만 실행된다.
최종 아키텍처: 단일 GPU 직렬화 원칙
[Phase 1 for loop]
패턴 1 → 타겟 호출 → judge(strict → context, _ollama_semaphore(1))
패턴 2 → 타겟 호출 → judge(strict → context, _ollama_semaphore(1))
...
동시 Ollama 호출 수: 최대 1개 (Phase 1 순차 실행 시)
모델 전환(gemma4 → qwen3.5) 없이 단일 모델(qwen3.5:4b)로 통일하면 모델 swap 오버헤드도 제거된다.
Docker + 로컬 GPU LLM 개발 체크리스트
# 1. 현재 로드된 Ollama 모델과 VRAM 사용량 확인
curl -s http://localhost:11434/api/ps | python3 -c "
import sys,json
for m in json.load(sys.stdin).get('models',[]):
print(f'{m[\"name\"]}: {m[\"size\"]/1e9:.1f}GB')
"
# 2. 불필요한 모델 언로드 (keep_alive=0)
curl http://localhost:11434/api/generate -d '{"model":"unused-model","keep_alive":0}'
# 3. Docker 컨테이너 환경변수 실제 적용 확인
docker exec <container> env | grep OLLAMA
# 4. 환경변수 변경 후 재생성 (restart가 아닌 up --force-recreate)
docker compose up -d --force-recreate <service>
핵심 요약
| 문제 | 잘못된 접근 | 올바른 접근 |
|---|---|---|
| 동시 요청 제어 | asyncio.Semaphore + gather |
gather 제거, 순차 for 루프 |
| 여러 모델 동시 로드 | 각 에이전트마다 다른 모델 | 전체 파이프라인 단일 모델 통일 |
| Docker env 반영 | docker restart |
docker compose up --force-recreate |
| LangGraph fan-out | 제어 없음 | 전역 Semaphore(1)로 직렬화 |
로컬 단일 GPU에서 멀티 에이전트를 돌릴 때의 원칙: 동시성은 버리고 직렬화를 선택하라. GPU는 병렬 호출을 받아도 결국 큐에서 순차 처리한다. 코드 레벨에서 미리 직렬화하면 OOM, EOF, 타임아웃 없이 안정적으로 실행된다. 하드웨어가 많이 바쳐진다면 동시성도 좋다!
'4. [팀] 프로젝트 및 공모전 > 4-5 AgentShield(보안 플랫폼)' 카테고리의 다른 글
| [AgentShield] 진행 상황 및 개인 공부 정리 (2): 파이프라인, DB 연결, 데이터 축적 (0) | 2026.04.27 |
|---|---|
| [AgentShield] 참고한 사이트 및 이유 총 정리 (1) (0) | 2026.04.26 |
| [트러블슈팅] Ollama EOF 에러의 원인: Thinking 모델과 KV Cache 오버플로 (0) | 2026.04.21 |
| [개인 공부] AgentShield: 자동화 모의해킹 파이프라인 및 DPO 데이터 수집 아키텍처 정리 (0) | 2026.04.15 |
| [개인 공부 메모] AgentShield LLM Judge 오탐 문제 분석 및 보안 판정 동향 (0) | 2026.04.14 |
