도대체 뭐하는 실습이냐면
외부 API가 죽었을 때 서비스가 500을 내지 않고 백업 데이터로 응답하는지 보는 장애 대응 테스트입니다. 백엔드에서 늘 하던 retry, backoff, fallback 테스트와 같은데, 그 서비스가 LLM 에이전트라는 점만 다릅니다.
한 문단 요약
자산 상태를 조회하는 에이전트가 있습니다. 조회 API를 일부러 고장 내고(FAILURE_MODE), 에이전트가 ① 죽지 않고 ② 백업 도구로 갈아타서 ③ 답을 내는지,
그리고 ④ 그 와중에 로그에 API 키가 새지 않는지를 검사합니다. 4개 다 통과하면 "DoD 충족", 즉 운영에 내보내도 된다는 판정입니다.
1. 평소 백엔드 코드라면 이렇게 짰을 겁니다
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에게는 정상적인 도구 결과라서 대화가 이어집니다.
2. python failure_agent.py를 치면 일어나는 일
위에서 아래로 그대로 실행됩니다. 오른쪽 코드는 실제 파일의 해당 부분입니다.
import 시점: 로그 설정과 에이전트 조립
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(...)
trace 출력: 메시지 이력 찍기
장애 실행의 messages를 순서대로 찍어 "어떤 도구를 불렀고 무슨 결과가 왔는지"를 눈으로 봅니다. 실패 지점을 찾는 디버깅 용도입니다.
로그 보안 점검
agent.log 파일을 열어 API 키 원문(sk-internal-dummy-1234, AKIA)이 있는지 문자열 검색합니다.
DoD 판정: assert 4개
정상 호출 처리 # 2번에서 실시간 도구로 정상 답변했나 실패 호출 처리 # 3번에서 크래시 없이 답변이 나왔나 필수 장애 1개 복구 # 3번에서 백업 도구가 호출됐나 로그 Secret 점검 # 5번에서 키 원문이 없나 → 4개 다 통과하면 "DoD 충족"
3. 3번(장애 실행) 안에서 벌어지는 일
agent.invoke() 한 번 안에서 누가 누구를 부르는지 시퀀스로 그렸습니다. timeout 모드 기준입니다.
4. failure_injection.py: 고장 나는 도구
강의 제공 코드. "테스트 대상 + mock"을 한 파일에 담았습니다.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": "김하늘"}
가짜 백엔드 API (mock)
진짜 자산 관리 서버 대신입니다. 스위치 값에 따라 일부러 느려지거나, 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 쓰세요"
실시간 조회 도구 (1단 방어)
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이 읽는 설명이라, 언제 쓸지 판단하는 근거가 됩니다.
5. failure_agent.py: 시험 감독관
실습 코드. 에이전트를 만들고, 돌리고, 채점합니다.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단계가 여기 그대로 들어 있습니다.
6. 주입할 수 있는 장애 3종
나누는 기준은 하나, "다시 부르면 나아질 장애인가?"입니다.
| 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 몫입니다.
7. 실제로 돌린 결과 (timeout)
# 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 # 원래대로
8. 이 설계의 약점 (알고 쓰기)
- 폴백이 확률적입니다. 일반 코드의 폴백은 if문이라 항상 실행되지만, 여기서는 LLM이 문구를 따라 줘야 합니다. 그래서 "복구"를 실제 호출 이력으로 채점하고, 매번 통과한다는 보장은 없습니다. 반드시 폴백해야 하는 요구사항이면 LangGraph 조건부 엣지로 경로를 코드에 박는 게 맞습니다.
- "정상 호출" 채점이 느슨합니다. 최종 답변에 "에러"라는 단어가 없는지만 봅니다.
agent.log는 실행할 때마다 계속 쌓입니다. 예전 실행에서 키가 한 번이라도 찍혔다면 이후 보안 점검이 계속 실패합니다. 파일은 실행한 폴더에 생깁니다.- 장애 스위치는 모듈 변수를 직접 바꿔야 먹힙니다.
failure_injection.FAILURE_MODE = ...는 반영되지만,from failure_injection import FAILURE_MODE로 가져와 바꾸면 반영되지 않습니다. - timeout은 흉내일 뿐입니다. 5초
sleep후 예외를 던집니다. 실제 코드라면 HTTP 클라이언트의timeout=옵션으로 기다림 자체를 끊어야 합니다.