본문 바로가기
AI/Agent

7-3. [AI Agent 구현]: 자율성의 경계를 설계

by jys275 2026. 5. 29.

5주차는 메타데이터 필터링으로 2026년 문서를 정확히 찾아냈는데, 청크 상단에 박힌 "2025년 변경사항" 소제목이 LLM의 Faithfulness를 0.0으로 떨어뜨렸다. BM25 하이브리드 서치와 CohereRerank로 검색 품질을 극대화한 이후에도, 프롬프트 통제만으로는 데이터 전처리의 한계를 메울 수 없다는 것이 확인됐다.

 

그런데 그 결론에서 다른 질문이 나온다. 5주차까지 다룬 시스템은 "정해진 답이 있는 것"이었다. 2026년 의료급여 본인부담 상한액은 소득 분위별로 하나의 정답이 있고, 그 정답을 얼마나 정확하게 반환하는지를 YearAccuracy로 측정했다. 하지만 답이 정해져 있지 않은 과업. 예를 들어, 매 Observation 결과가 다음 Action을 결정해야 하는 과업이라면 어떤 구조가 맞을까?

 

6주차 설계서는 그 질문의 답으로 도메인을 바꿨다. 오늘의 Reddit·YouTube 밈과 GitHub·HackerNews IT 트렌드를 실시간으로 교차 분석하여 아이디어를 브리핑하는 에이전트다.

 

7주차는 이 설계를 LangGraph로 실제로 동작시키는 것이었다.

 

 


 

 

1. 왜 Agent였는가: 세 가지 부정의 논거

 

 

구현 단계로 넘어오면 "왜 에이전트인가"는 설계서에서 추상적으로 서술한 것과 달리 코드가 실제로 이 구조를 요구하는지의 문제가 된다. 아키텍처 선택의 근거를 다시 한번 명확히 정리한다.

 

단일 LLM 호출은 불가능하다

오늘 Reddit에서 무엇이 급상승했는지, GitHub에서 어떤 레포지토리가 트렌딩인지는 모델의 학습 데이터에 없다. 실시간 외부 Tool 없이는 답이 존재하지 않는다.

 

RAG도 불가능하다

RAG는 미리 수집해둔 문서 집합에서 검색한다. 밈과 IT 트렌드는 매일 바뀐다. 어제 인덱싱한 문서는 오늘 이미 낡았다. 인덱싱 시점과 응답 시점 사이의 간극이 RAG의 전제를 무너뜨린다.

 

고정 Workflow도 불가능하다

트렌드 수집 → 컨셉 생성 → 유사 앱 확인 → 난이도 판단이라는 기본 파이프라인은 고정된 순서로 처리할 수 있다. 그러나 유사 앱이 발견됐을 때 재생성할지, 트렌드 소스가 실패했을 때 나머지로 계속 진행할지, 난이도 제한을 초과했을 때 후보를 교체할지는 고정 경로로 표현할 수 없다. 이 예외 분기들이 Agent의 자율성을 정당화하는 근거다.

 

이 세 부정이 설계서의 출발점이었고, 구현 단계에서도 이 논거는 유효했다.

 

 


 

 

2. Workflow vs Agent 라벨링: 자율성을 의도적으로 좁힌 이유

 

 

부딪힌 한계

LangGraph의 create_react_agent는 LLM의 자율성이 넓다. 정상 흐름에서도 LLM이 매번 어떤 Tool을 호출할지 재계산한다. 첫 실행에서 문제가 드러났다. 출력이 마크다운 형태의 브리핑으로 나왔고, 이후 JSON 파싱이 깨졌다. 설계서에서 정의한 출력 스키마( today_brief, metadata, tool_trace 구조)가 실제 응답과 일치하지 않았다.

 

에이전트에게 자율성을 부여한다는 것이, 출력 형식의 자율성까지 허용한다는 의미가 아니었는데도 LLM은 그렇게 동작했다.

 

선택한 방향

system_prompt.py에 [WORKFLOW]·[AGENT] 구간을 명시적으로 라벨링했다. 설계서의 구분이 코드에 그대로 이식됐다.

[WORKFLOW — 고정 파이프라인 (정상 흐름)]
  Step 1. trend_scanner      → 트렌드 수집
  Step 2. concept_generator  → 컨셉 생성
  Step 3. app_existence_checker → 유사 앱 검색
  Step 4. feasibility_checker   → 난이도 판단

[AGENT — 동적 판단 (예외 발생 시에만 개입)]
  판단 1. similar_app_found=True → concept_generator 재호출 (루프백)
  판단 2. 트렌드 소스 partial_failure → 나머지 소스로 계속 진행 여부 결정
  판단 3. difficulty_limit_exceeded=True → 후보 컨셉 교체 여부 결정
  판단 4. loop_count >= 3 → 루프 탈출 및 fallback 처리

출력 형식 강제도 병행했다. "마크다운 금지, 순수 JSON만"을 프롬프트에 명시하고, 출력 스키마 예시를 그대로 넣어두었다.

 

트레이드오프

에이전트의 자율성을 의도적으로 좁혔다. 새로운 예외 분기 패턴이 등장하면 system prompt 수정이 필요하다. 유연성을 포기하는 대신 얻은 것은 두 가지다. 출력 일관성과 디버깅 가능성. 에이전트가 어디서 예상과 다르게 동작했는지를 라벨 기준으로 추적할 수 있게 됐다.

 

검증

4개의 케이스(IT 중심 요청, 유사 앱 발견 후 재생성, 트렌드 API 일부 실패, 유사 앱 3회 발견 후 fallback)에서 Tool 호출 순서가 [WORKFLOW] 라벨대로 동작했다. 출력 스키마 강제 이후 JSON 파싱 실패율은 없었다.

 

 


 

 

3. 출처 메타와 tool_trace: 멘토님의 피드백 4건이 짚은 공통점

 

 

부딪힌 한계

이번 주차 멘토님의 피드백 여러 건을 가로질러 반복된 메시지가 있었다.

 

 

"Tool 실행 결과도 실제 API와 mock fallback을 분리해서 보여주면 좋습니다. 각 구성이 실제 어떤 데이터로 수행됐는지 판단하기가 어렵습니다."

 

 

결과 JSON만 봐서는 어디까지가 실 API 응답이고 어디서부터 Mock인지 식별이 불가능했다. Reddit·YouTube가 Mock으로 처리되는 상황에서, 외부에서 보기에 실 API 응답과 구조가 동일한 Mock 데이터는 투명성 측면에서 결함이었다.

 

이것은 6주차 설계서에서 강조한 관측성(Observability)의 문제이기도 했다. 결과가 있다는 것과 결과가 어디서 왔는지를 알 수 있다는 것은 다른 차원의 신뢰성이다.

 

선택한 방향

각 Tool 출력에 표준 메타 5개를 부착했다.

data_source:      "real_api" | "mock" | "llm_inference" | "fallback"
endpoint:          호출된 API URL
fetched_at:        ISO 8601 타임스탬프
items_returned:    반환된 항목 수
fallback_reason:   실패 시 사유 (정상 시 null)

agent.py에 _extract_tool_trace() 함수를 추가했다. LangGraph의 messages에서 ToolMessage trace를 step별로 추출하여 metadata.tool_trace에 누적한다.

 

실제 case1_it_focus.json의 tool_trace 발췌

{
  "tool_trace": [
    {
      "step": 1,
      "tool": "trend_scanner",
      "ok": true,
      "source_provenance": {
        "hackernews": {
          "data_source": "real_api",
          "endpoint": "https://hacker-news.firebaseio.com/v0/topstories.json",
          "items_returned": 5,
          "fetched_at": "2026-05-29T04:33:35.652611+00:00"
        },
        "github": {
          "data_source": "real_api"
        }
      }
    },
    {
      "step": 2,
      "tool": "concept_generator",
      "ok": true,
      "source_provenance": {
        "data_source": "llm_inference",
        "model": "claude-sonnet-4-6",
        "temperature": 0.9
      }
    }
  ]
}

trend_scanner.py의 data_source 메타 부착 패턴은 다음과 같다.

def _fetch_hackernews(limit=5):
    try:
        # 실 API 호출
        return {
            "items": stories,
            "data_source": "real_api",
            "endpoint": HN_ENDPOINT
        }
    except Exception as e:
        return {
            "items": [],
            "data_source": "fallback",
            "endpoint": HN_ENDPOINT,
            "fallback_reason": f"hackernews_request_failed: {e}"
        }

 

트레이드오프

모든 Tool 시그니처에 메타를 추가하는 부담이 생겼고 결과 JSON의 부피가 증가했다. 대신 결과 파일 하나로 어떤 Tool이 어떤 데이터 소스에서 어떤 순서로 호출됐는지를 역추적할 수 있게 됐다. 8주차에 LangSmith를 붙이면 이 tool_trace 구조가 LangSmith의 trace 레이어에 자연스럽게 매핑된다. 이번 주에 미리 설계해둔 구조가 다음 주 Observability의 기반이 된다.

 

검증

결과 JSON에서 data_source 필드를 기준으로 실 API 호출 여부를 즉시 식별할 수 있다. HackerNews와 GitHub는 "real_api", Reddit과 YouTube는 "mock", concept_generator와 feasibility_checker는 "llm_inference"로 각각 명시된다. 어느 단계에서 무슨 데이터가 투입됐는지가 결과 파일 하나로 판독 가능해졌다.

 

 


 

 

4. invoke가 아닌 stream: 실패 케이스에서의 비대칭 이득

 

 

부딪힌 한계

--difficulty 1day --exclude-existing 조합으로 강제 제약을 주면 LangGraph의 기본 recursion_limit=15에 걸려 예외가 발생했다. 여기서 문제가 드러났다. agent.invoke()는 예외 발생 시 messages를 반환하지 않고 그냥 던져버린다.

 

결과 JSON의 실제 출력은 이랬다.

{
  "today_brief": null,
  "metadata": {
    "used_tools": [],
    "tool_trace": [],
    "failure_type": "agent_error",
    "error": "Recursion limit of 15 reached without hitting a stop condition."
  }
}

tool_trace가 비어 있다. 에이전트가 15단계 동안 어떤 Tool을 어떤 순서로 호출했는지 아무것도 남지 않았다. 설계서 검증 측면에서 이것은 데이터 없음과 같다. "에이전트가 의도한 경로로 동작했는가"를 판단할 근거가 없다.

 

선택한 방향

agent.stream(stream_mode="values")로 전환했다. 매 step의 messages를 accumulated_messages 변수에 누적한다. except 블록에서 _extract_tool_trace(accumulated_messages)를 호출하여 실패 직전까지의 부분 trace를 보존한다.

accumulated_messages = []
try:
    for chunk in agent.stream(
        {"messages": [HumanMessage(content=input_context)]},
        config={"recursion_limit": 15},
        stream_mode="values",
    ):
        accumulated_messages = chunk.get("messages", accumulated_messages)
    tool_trace = _extract_tool_trace(accumulated_messages)
except Exception as e:
    partial_trace = _extract_tool_trace(accumulated_messages)
    # 실패해도 부분 trace가 살아있다

 

트레이드오프

streaming 처리 코드가 invoke 대비 복잡해진다. 대신 얻은 것은 비대칭적으로 크다. 정상 종료 시에는 invoke와 동일한 결과를 얻고, 예외 발생 시에는 실패 직전까지의 전체 흐름이 보존된다. 실패 케이스에서만 발생하는 추가 이득이다.

 

8주차 LangSmith 트레이싱과의 매핑도 stream 구조가 더 자연스럽게 연결된다.

 

검증

같은 입력으로 재실행했을 때 tool_trace에 13단계가 보존됐다. concept_generator → app_existence_checker → feasibility_checker 순환이 두 사이클 반복된 것이 trace로 명확하게 기록됐다.

 

그리고 예상하지 못한 결론이 여기서 나왔다. 설계서에 명시한 "동일 조합 3회 재호출 시 루프 탈출"은 recursion_limit=15가 먼저 걸리기 때문에 실제로 발동되지 않는다. 설계서의 루프 탈출 조건이 코드에서는 의미 없는 죽은 규칙이었다는 것이 데이터로 확인됐다. 자체 루프 카운터 주입 또는 limit 상향이 8주차 보완 항목이 됐다.

 

 


 

 

5. 가드레일의 다층 방어: Agent 운영 경계의 첫 윤곽

 

 

부딪힌 한계

멘토님의 피드백 중 이 문장이 결정적이었다.

 

 

"'Agent가 돌지 않아야 하는 경우'를 추가해보는 것입니다. 이를 단순 실패 처리로만 보기보다는, 데이터와 프롬프트, Agent의 운영 경계를 확인하는 케이스로 보면 좋겠습니다. 이후 AI Observability를 붙일 때도 '이때는 동작하지 않는 것이 맞다'는 점을 증명할 수 있는 좋은 기준이 됩니다."

 

 

6주차 설계서의 "보안 확장 포인트"에 프롬프트 인젝션 방어를 적어두고 "추후 도입 후보"로 미뤄뒀었다. 그런데 8주차 Observability와의 연결을 생각하면, 가드레일은 기능이 아니라 시스템의 운영 경계 정의다. 지금 구현해두는 것이 맞다고 판단했다.

 

1차와 2차의 다층 방어로 구현했다.

 

1차: 정규식 기반 즉시 차단 (agent.py check_guardrail)

알려진 인젝션 패턴과 도메인 외 요청을 Tool 호출 없이 차단한다.

_INJECTION_PATTERNS = [
    r"ignore\s+previous\s+instructions",
    r"disregard\s+(the\s+)?(above|previous)",
    r"forget\s+(your|the)\s+(instructions|system\s+prompt)",
    r"reveal\s+(your|the)\s+system\s+prompt",
    r"<\s*system\s*>",
]

def check_guardrail(user_query):
    for pat in _INJECTION_PATTERNS:
        if re.search(pat, user_query, flags=re.IGNORECASE):
            return {
                "blocked": True,
                "reason": "prompt_injection_suspected",
                "matched_pattern": pat
            }
    return {"blocked": False, "reason": None, "matched_pattern": None}

1차에서 차단되면 Tool 호출이 0회이므로 API 비용이 발생하지 않는다.

 

2차: system prompt 행동 원칙

정규식이 잡지 못하는 우회 표현은 LLM이 today_brief=null로 거부하도록 프롬프트에 명시했다. LLM 비용이 발생하지만 1차를 통과한 우회 표현의 빈도는 낮다.

 

트레이드오프

정규식은 다국어 인젝션, base64 인코딩, 완곡어법은 잡지 못한다. 명백한 케이스를 비용 0으로 처리하는 것이 1차의 역할이고, 미묘한 우회는 2차 LLM 판단에 위임한다. 완벽한 방어가 아니라 방어의 비용과 범위를 의식적으로 설계한 구조다.

 

검증

case_guardrail_blocked.json의 전체 응답이다.

{
  "today_brief": null,
  "metadata": {
    "used_tools": [],
    "tool_trace": [],
    "failure_type": "guardrail_blocked",
    "fallback_action": "요청 거부 — 도메인 외 또는 인젝션 의심",
    "guardrail": {
      "blocked": true,
      "reason": "prompt_injection_suspected",
      "matched_pattern": "ignore\\s+previous\\s+instructions"
    }
  }
}

Tool 호출이 0회다. 에이전트 루프가 시작되기 전에 차단됐다. "이때는 동작하지 않는 것이 맞다"는 케이스가 결과 파일로 입증됐다. 8주차에 LangSmith를 붙이면 이 케이스가 trace에서 어떻게 보이는지를 정상 케이스와 비교할 수 있게 된다.

 

 

 


 

 

6. 7주차의 한계와 8주차로의 연결

 

 

구현이 완료됐다고 해서 설계서가 완전히 검증된 것은 아니다. 이번 주 구현에서 드러난 한계 네 가지를 정직하게 기록한다.

 

첫째, 루프 탈출 조건이 실제로는 작동하지 않는다

--difficulty 1day와 루프백이 겹치는 케이스에서, 설계서의 "동일 조합 3회 재호출 시 루프 탈출"은 LangGraph의 recursion_limit=15가 먼저 발동한다. 의도한 fallback이 아닌 agent_error로 종료된다. 자체 루프 카운터를 주입하거나 limit를 상향하는 것이 8주차 보완 항목이다.

 

둘째, Reddit·YouTube 실 API가 미연동 상태다

케이스 3 — 트렌드 API 일부 실패 시 fallback — 을 실 API로 재현하지 못했다. data_source: "mock"으로 trace에 명시되어 있다. 실 API를 연동하면 이 케이스의 검증이 완성된다.

 

셋째, 가드레일이 경량 방어선이다

정규식 + 프롬프트 기반의 1·2차 방어는 알려진 패턴과 명백한 도메인 외 요청을 처리한다. 다국어 인젝션이나 base64 우회는 잡지 못한다. 8주차에 LLM 의도 분류 단계를 추가하는 방향을 검토한다.

 

넷째, LangSmith 트레이싱이 미연동이다

현재 metadata.tool_trace 구조는 LangSmith의 trace 레이어와 자연스럽게 매핑되도록 설계됐다. 환경 변수 설정만으로 8주차에 즉시 활성화할 수 있다. 이번 주에 구조를 미리 잡아둔 이유다.

 

7주차 구현의 목적은 완성된 에이전트를 만드는 것이 아니었다. 6주차 설계서의 명제들이 코드에서 실제로 참인지 확인하고, 참이 아닌 지점을 데이터로 기록하는 것이었다. 그 작업은 완료됐다. 설계서에서 선언했던 것들 중 무엇이 검증됐고, 무엇이 수정이 필요한지가 이제 JSON 파일로 남아있다.

 

8주차에는 LangSmith를 붙이고, 루프 탈출 조건을 보완하며, 지금까지 구축한 시스템의 내부를 투명하게 들여다보는 작업이 시작된다.