외부 API가 죽었을 때 서비스가 500을 내지 않고 백업 데이터로 응답하는지 보는 장애 대응 테스트입니다. 백엔드에서 늘 하던 retry, backoff, fallback 테스트와 같은데, 그 서비스가 LLM 에이전트라는 점만 다릅니다.
자산 상태를 조회하는 에이전트가 있습니다. 조회 API를 일부러 고장 내고(FAILURE_MODE), 에이전트가 ① 죽지 않고 ② 백업 도구로 갈아타서 ③ 답을 내는지,
그리고 ④ 그 와중에 로그에 API 키가 새지 않는지를 검사합니다. 4개 다 통과하면 "DoD 충족", 즉 운영에 내보내도 된다는 판정입니다.
def get_asset_status(asset_id): for attempt in range(3): # 재시도 try: data = backend_api(asset_id) validate(data) # 스키마 검증 return data except RateLimitError: sleep(2 ** attempt) # 백오프 except TimeoutError: continue return backup_cache(asset_id) # 폴백
def get_asset_status(asset_id): for attempt in range(3): # 재시도 (같음) try: ... # 검증 (같음) except RateLimitError: sleep(2 ** attempt) # 백오프 (같음) ... # 폴백을 직접 부르지 않고 "말로" 시킨다 return "에러: ... get_asset_status_backup 도구를 사용하세요."
차이는 마지막 줄 하나입니다. 도구는 백업을 직접 부르지 않고 "백업 도구를 쓰라"는 문장을 돌려줍니다. LLM이 그 문장을 도구 결과로 읽고, 다음 턴에 백업 도구를 스스로 호출합니다. 이 연결이 실제로 되는지 확인하는 것이 이 실습의 핵심입니다.
왜 raise가 아니라 return인가? 도구에서 예외가 터지면 에이전트 그래프가 그대로 죽습니다(처리 안 된 예외로 500이 나는 것과 같음).
문자열로 돌려주면 LLM에게는 정상적인 도구 결과라서 대화가 이어집니다.
python failure_agent.py를 치면 일어나는 일위에서 아래로 그대로 실행됩니다. 오른쪽 코드는 실제 파일의 해당 부분입니다.
import failure_injection 순간 agent.log 파일 로깅이 켜지고, 장애 스위치 FAILURE_MODE와 더미 API 키가 준비됩니다.
이어서 LLM에 도구 두 개(실시간, 백업)를 붙여 에이전트를 만듭니다.
agent = create_react_agent(llm,
[get_asset_status, get_asset_status_backup],
prompt="...get_asset_status를 먼저 사용하고 도구 에러 메시지에
안내된 지침을 그대로 따르세요.")
스위치를 "none"으로 두고 "A-1001 자산 상태 알려줘"를 물어봅니다. 실시간 도구가 바로 성공하는지 확인합니다. (대조군)
failure_injection.FAILURE_MODE = "none" normal = agent.invoke({"messages": [("user", question)]})
환경 변수 FAILURE_MODE를 읽어(없으면 timeout) 장애를 켜고 같은 질문을 다시 합니다. 이번에는 실시간 도구가 실패하므로 백업으로 갈아타야 합니다. (실험군)
mode = os.environ.get("FAILURE_MODE", "timeout") failure_injection.FAILURE_MODE = mode broken = agent.invoke(...)
장애 실행의 messages를 순서대로 찍어 "어떤 도구를 불렀고 무슨 결과가 왔는지"를 눈으로 봅니다. 실패 지점을 찾는 디버깅 용도입니다.
agent.log 파일을 열어 API 키 원문(sk-internal-dummy-1234, AKIA)이 있는지 문자열 검색합니다.
정상 호출 처리 # 2번에서 실시간 도구로 정상 답변했나 실패 호출 처리 # 3번에서 크래시 없이 답변이 나왔나 필수 장애 1개 복구 # 3번에서 백업 도구가 호출됐나 로그 Secret 점검 # 5번에서 키 원문이 없나 → 4개 다 통과하면 "DoD 충족"
agent.invoke() 한 번 안에서 누가 누구를 부르는지 시퀀스로 그렸습니다. timeout 모드 기준입니다.
노란 영역은 1단 방어(도구 코드의 재시도)이고, ②→③이 2단 방어(LLM의 경로 전환)입니다. ③은 코드의 if문이 아니라 LLM의 판단이라서, 프롬프트의 "에러 메시지 지침을 따르세요"가 이 연결을 걸어 줍니다.
logging.basicConfig(level=logging.INFO, ...,
handlers=[logging.FileHandler("agent.log"),
logging.StreamHandler()])
로그를 화면과 agent.log 파일에 동시에 남깁니다. 5번 보안 점검이 이 파일을 검사합니다.
FAILURE_MODE = os.environ.get("FAILURE_MODE", "none") API_KEY = os.environ.get("INTERNAL_API_KEY", "sk-internal-dummy-1234")
FAILURE_MODE는 테스트용 mock 스위치입니다. API_KEY는 "로그에 새면 안 되는 값"의 예시입니다.
def _call_backend(asset_id): logger.info("... api_key=%s***", API_KEY[:6]) if FAILURE_MODE == "timeout": time.sleep(5); raise TimeoutError(...) if FAILURE_MODE == "rate_limit": raise RateLimitError("429 ...") if FAILURE_MODE == "bad_schema": return {"assetStatus": "사용중인듯"} return {"asset_id": ..., "status": "사용중", "owner": "김하늘"}
진짜 자산 관리 서버 대신입니다. 스위치 값에 따라 일부러 느려지거나, 429를 내거나, 필드명이 다른 응답을 줍니다.
첫 줄 로그가 키를 앞 6자만 찍는 것이 마스킹입니다. 그래서 로그에는 sk-int***만 남습니다.
@tool def get_asset_status(asset_id): for attempt in range(1, 4): try: data = _call_backend(asset_id) if "status" not in data ...: return "에러: 형식 오류. backup 쓰세요" return f"{id}: {status} (사용자: {owner})" except RateLimitError: if 마지막: return "에러: 한도 초과. backup 쓰세요" time.sleep(2 ** attempt) except TimeoutError: if 마지막: return "에러: 지연. backup 쓰세요" except Exception: return "에러: 조회 실패. backup 쓰세요"
LLM이 부르는 도구입니다. 장애마다 대응이 다릅니다.
timeout: 그냥 다시 시도합니다.
429: 2초, 4초 기다렸다 시도합니다(지수 백오프).
형식 오류: 다시 불러도 같으니 재시도 없이 바로 포기합니다.
어떤 경우든 마지막에는 raise 대신 다음 행동을 적은 문자열을 return합니다. 이게 LLM에게 넘기는 바통입니다.
@tool def get_asset_status_backup(asset_id): """백업 자산 상태 조회. get_asset_status 실패 시 사용한다. 1시간 전 스냅샷...""" return f"{asset_id}: 사용중 (백업 스냅샷 기준...)"
캐시나 읽기 전용 복제본 역할입니다. 데이터는 1시간 늦지만 항상 성공합니다.
docstring의 "실패 시 사용한다"도 LLM이 읽는 설명이라, 언제 쓸지 판단하는 근거가 됩니다.
llm = ChatBedrockConverse(model="...sonnet-4-5...") agent = create_react_agent(llm, [get_asset_status, get_asset_status_backup], prompt="... 도구 에러 메시지에 안내된 지침을 그대로 따르세요.")
3일차에 직접 만든 agent ⇄ tools 그래프를 create_react_agent 한 줄로 만든 것입니다. 구조는 같습니다.
프롬프트의 "에러 지침을 따르라"가 2단 방어의 스위치입니다. 이 문장이 없으면 LLM이 에러를 보고 그냥 "조회 실패"라고 답할 수 있습니다.
def get_text(message): ... def print_trace(result): ...
get_text는 Bedrock 응답에서 텍스트만 뽑습니다. print_trace는 메시지 이력을 "사용자 / 도구요청 / 도구결과 / 최종답변" 한 줄씩으로 찍는 디버깅용 함수입니다(trace.py와 같음).
def tool_names(result): return [tc["name"] for m in result["messages"] if m.tool_calls for tc in m.tool_calls]
실행 이력에서 LLM이 요청한 도구 이름만 뽑습니다. 결과에 get_asset_status_backup이 있으면 "복구했다"로 판정합니다.
def check_secret_log(path="agent.log"): text = open(path).read() hits = [p for p in ("sk-internal-dummy-1234", "AKIA") if p in text] return (not hits), ...
로그 파일에서 키 원문을 문자열 검색합니다. AKIA는 AWS 액세스 키가 시작하는 접두어입니다.
if __name__ == "__main__": # 정상 실행 → 장애 실행 → trace # → 로그 점검 → DoD 4개 판정
2장의 1~6단계가 여기 그대로 들어 있습니다.
나누는 기준은 하나, "다시 부르면 나아질 장애인가?"입니다.
| FAILURE_MODE | HTTP로 치면 | 도구 안 대응 | 도구가 걸리는 시간 |
|---|---|---|---|
timeout | 504 Gateway Timeout | 일시적일 수 있으니 3번 재시도 | 약 15초 (5초 × 3) |
rate_limit | 429 Too Many Requests | 기다리면 풀리니 2초, 4초 쉬고 재시도 | 약 6초 |
bad_schema | 200인데 필드명이 바뀜 | 다시 불러도 같으니 재시도 없이 바로 포기 | 즉시 |
none | 200 정상 | - | 즉시 |
세 경우 모두 결국 "backup 도구를 쓰세요" 문자열로 끝나고, 그다음은 LLM 몫입니다.
# agent.log (Bedrock 호출 줄은 생략) 14:56:21 INFO backend 호출 asset_id=A-1001 api_key=sk-int*** ← 마스킹됨 14:56:26 WARNING timeout (시도 1/3) 14:56:31 WARNING timeout (시도 2/3) 14:56:36 WARNING timeout (시도 3/3) 14:56:39 INFO backup 경로 사용 asset_id=A-1001 ← LLM이 갈아탐 === DoD 판정 === 정상 호출 처리 : 통과 실패 호출 처리 : 통과 (그래프 중단 없이 답변 생성) 필수 장애 1개 복구 : 통과 (장애 timeout, 백업 도구 호출 있음) 로그 Secret 점검 : 통과 (발견 0건) 최종 : DoD 충족
다른 장애로 돌려 보려면 day4_practice에서 PowerShell로:
$env:FAILURE_MODE="rate_limit"; python failure_agent.py # 2초, 4초 백오프 로그 $env:FAILURE_MODE="bad_schema"; python failure_agent.py # 재시도 없이 바로 ERROR Remove-Item Env:FAILURE_MODE # 원래대로
agent.log는 실행할 때마다 계속 쌓입니다. 예전 실행에서 키가 한 번이라도 찍혔다면 이후 보안 점검이 계속 실패합니다. 파일은 실행한 폴더에 생깁니다.failure_injection.FAILURE_MODE = ...는 반영되지만, from failure_injection import FAILURE_MODE로 가져와 바꾸면 반영되지 않습니다.sleep 후 예외를 던집니다. 실제 코드라면 HTTP 클라이언트의 timeout= 옵션으로 기다림 자체를 끊어야 합니다.