1. 7개 선택의 구조
아래 표가 이 글의 전체 구조다. 각 선택은 독립적이지 않다. 앞 선택이 뒤 선택의 조건을 만든다.
| 선택 | 결정 사항 | 이후 영향 |
| 1 | LangGraph create_react_agent | 8주차 LangSmith 연동 비용 0 |
| 2 | Tool 표준 반환 스키마 {ok, data, error} | _extract_tool_trace() 단일 로직으로 처리 |
| 3 | Tool 4개 분리 (단일 책임) | 실패 지점 Tool 단위로 추적 가능 |
| 4 | 모델 분리: Sonnet(창의) + Haiku(판단) | 비용·품질 균형 |
| 5 | 시스템 프롬프트 행동 원칙 5조 | 출력 일관성 확보 |
| 6 | 실 API + Mock 혼용 (동일 응답 구조) | data_source 메타 의미 부여 |
| 7 | CLI 진입점을 처음부터 | 케이스 재현성 확보 |
2. [선택 1] 프레임워크: LangGraph create_react_agent
네 가지 선택지가 있었다.
- 직접 ReAct 루프 구현 (while loop + tool 호출)
- LangChain AgentExecutor (deprecated 경로)
- LangGraph create_react_agent (그래프 기반 prebuilt)
- OpenAI Agents SDK
선택의 근거
결정적인 이유는 하나였다. 8주차 LangSmith 관측성 실습이 이미 예고된 상태였다.
LangGraph는 LangChain 생태계 안에 있다. create_react_agent가 내부적으로 OpenTelemetry 호환 계측을 포함하고 있어, 환경변수 두 개만 설정하면 agent.stream() 호출이 그대로 LangSmith Trace로 기록된다. 별도 SDK 주입이 필요 없다. 프레임워크 선택 하나가 8주차 연동 비용을 0으로 만드는 결정이었다.
ReAct 루프를 직접 구현하는 방식도 고려했다. 그러나 직접 구현하면 LangGraph가 제공하는 messages 프로토콜(HumanMessage, AIMessage, ToolMessage)을 잃게 된다. 이 프로토콜이 없으면 _extract_tool_trace()의 구현이 처음부터 달라진다. stream 모드에서 messages를 step별로 누적하는 구조 전체가 LangGraph의 messages 프로토콜 위에서 동작한다.
트레이드오프
create_react_agent는 prebuilt 그래프다. LLM의 자율성 통제권이 약하다. 첫 실행에서 LLM이 마크다운 형식 브리핑을 반환하여 JSON 파싱이 깨진 것이 그 결과였다.
이 약점은 본 글에서 system_prompt의 행동 원칙과 [WORKFLOW]·[AGENT] 라벨링으로 후속 통제했다. 구조를 먼저 세우고 통제 레이어를 얹는 순서가 여기서 정해졌다.
3. [선택 2] Tool 표준 반환 스키마: {ok, data, error}
Tool마다 자유로운 반환 형태를 허용할 것인가, 표준 스키마를 강제할 것인가.
선택의 근거
실제 동기는 5주차에서 온 경험이었다. RAG 파이프라인에서 청크 파서별로 분기 코드가 누적됐다. iTunes API 응답 형식과 GitHub API 응답 형식이 다르고, 각각을 처리하는 코드가 따로 쌓였다. Tool 4개가 각각 다른 반환 구조를 가지면 같은 패턴이 반복된다.
멘토님의 README 권장 형식이기도 했지만, 직접적인 이유는 디버깅 비용이었다. 표준 스키마가 없으면 _extract_tool_trace()가 Tool마다 다른 파싱 로직을 가져야 한다.
# 정상 반환
return {
"ok": True,
"data": {"meme_trends": [...], "it_trends": [...], ...},
"error": None
}
# 실패 반환
return {
"ok": False,
"data": None,
"error": {"code": "TOTAL_FAILURE", "message": "..."}
}
트레이드오프
Tool 작성 시 보일러플레이트가 증가한다. 모든 Tool이 ok, data, error 세 필드를 반드시 반환해야 한다. 그러나 이 구조 덕분에 agent.py의 _extract_tool_trace()는 Tool 종류에 관계없이 동일한 로직으로 처리할 수 있게 됐다.
ok = parsed.get("ok") if isinstance(parsed, dict) else None
err = parsed.get("error") if isinstance(parsed, dict) else None
본편에서 data_source 메타를 모든 Tool 출력에 표준적으로 부착할 수 있었던 것도 이 구조 위에서였다. 표준 스키마가 없었다면 메타 부착 위치가 Tool마다 달랐을 것이다.
4. [선택 3] Tool을 4개로 쪼갠 이유
"트렌드 분석해서 아이디어 줘"를 단일 거대 Tool로 만들 수도 있었다. 수집/생성/검증/판단을 하나의 함수 안에서 처리하는 방식이다.
선택의 근거
단일 책임 원칙이 출발점이었지만, 실질적인 이유는 디버깅 단위였다. "유사 앱 검색이 실패했나?"라는 질문이 생겼을 때 app_existence_checker 한 곳만 들여다보면 된다. 단일 거대 Tool이었다면 어느 단계에서 실패했는지를 함수 안에서 추적해야 한다.
설계서에서 각 Tool의 담당 구간을 명시한 이유도 여기 있다.
| Tool | WORKFLOW 구간 | AGENT 구간 |
| trend_scanner | 트렌드 수집 | 소스 일부 실패 시 진행 여부 |
| concept_generator | 컨셉 생성 | 유사 앱 발견 시 루프백 |
| app_existence_checker | 유사 앱 검색 | threshold 조정 후 재검색 |
| feasibility_checker | 난이도 판단 | 난이도 초과 시 후보 교체 |
같은 Tool이 정상 흐름에서는 Workflow에, 예외 상황에서는 Agent 판단에 의해 호출된다. 이 이중성을 Tool 수준에서 명시할 수 있었던 것은 Tool이 단일 책임을 가졌기 때문이다.
트레이드오프
Tool 호출이 4회로 늘어났다. LangGraph ReAct 루프는 매 step마다 누적 messages 전체를 context로 넣기 때문에 step 수가 늘어날수록 input token이 기하급수적으로 증가한다. 8주차 정상 케이스의 총 27,748 토큰, $0.117이라는 비용이 이 구조에서 나왔다.
대신 어느 단계에서 실패가 났는지가 결과 metadata의 tool_trace에 step 단위로 기록된다. 본편의 tool_trace가 의미를 가지는 전제 조건이 바로 Tool 4개 분리였다.
5. [선택 4] 모델 분리: Sonnet(창의) + Haiku(판단)
모든 LLM 호출에 동일 모델을 쓰는 것이 단순하다. 즉, task별 특성에 맞게 최적화할 이유가 존재한다.
선택의 근거
두 Tool의 요구 사항이 명확히 달랐다.
concept_generator는 일반 트렌드 IT 트렌드를 교차 조합하여 "실용적인" 컨셉을 만들어야 한다. 같은 트렌드 조합에서 매번 같은 컨셉이 나오면 안 된다. 발산적 사고가 필요하다. Sonnet + temperature=0.9.
feasibility_checker는 "이 컨셉이 1day인가 2~3days인가 1week인가"를 판단해야 한다. 같은 컨셉에 대해 매번 다른 판단이 나오면 신뢰할 수 없다. 일관성이 중요하다. Haiku + temperature=0.3.
# concept_generator.py
_llm = ChatAnthropic(
model="claude-sonnet-4-6",
temperature=0.9,
max_tokens=1024,
)
# feasibility_checker.py
_llm = ChatAnthropic(
model="claude-haiku-4-5-20251001",
temperature=0.3,
max_tokens=1024,
)
트레이드오프
두 모델을 관리해야 하는 운영 부담이 생겼다. 모델 버전 업그레이드가 있을 때 두 곳을 각각 검토해야 한다.
8주차 trace 기준으로 Sonnet 호출은 input 4,206 / output 350 토큰, Haiku 호출은 input 5,793 / output 298 토큰이다. Haiku의 input token이 더 많은 것은 누적 context 때문이다(step 6~7이 step 2~3보다 뒤에 실행되므로 더 많은 messages가 쌓인 상태다). 여기서 Sonnet 대신 Haiku를 쓴 비용 절감 효과가 나타난다.
6. [선택 5] 시스템 프롬프트 행동 원칙 5조
자연어로 목적과 방향을 부드럽게 안내하는 방식과, 명령형 원칙과 금지 사항을 명시하는 방식 사이의 선택이었다.
선택의 근거
첫 실행이 결정적인 교훈을 줬다. LLM이 마크다운 형식의 브리핑을 반환했고 JSON 파싱이 깨졌다. 자연어 안내로는 LLM의 출력 형식을 통제할 수 없었다.
5주차에서 "프롬프트 통제의 한계"를 체감했던 순간과 같은 패턴이었다. 그때는 데이터 전처리의 한계였고, 이번에는 LLM 출력 형식의 자율성 문제였다. 두 경우 모두 "말로 안 되면 구조로 통제한다"는 방향으로 수렴했다.
## 행동 원칙 (반드시 준수)
1. trend_scanner를 가장 먼저 호출한다. 실시간 데이터 없이 컨셉을 생성하지 않는다.
2. similar_app_found=True이면 즉시 concept_generator를 재호출한다.
3. 동일 조합으로 3회 이상 루프백 시 루프를 탈출한다.
4. 최종 응답에는 메타데이터(used_tools, loop_count, failure_type)를 포함한다.
5. 최대 15스텝을 초과하지 않는다.
## 출력 형식 (CRITICAL)
최종 응답은 반드시 순수 JSON만 반환한다.
마크다운, 설명 텍스트, 코드블록 없이.
금지: # 헤더, ** 볼드, 표, 설명 텍스트, 이모지 등 JSON 외 모든 형식.
트레이드오프
자연스러운 답변 톤을 포기했다. 에이전트가 사용자에게 직접 메시지를 건네는 방식이 아니라 순수 JSON만 반환하기 때문에, 출력을 렌더링하는 레이어가 별도로 필요하다.
대신 JSON 파싱 실패율이 0%가 됐다. 그리고 본편에서 [WORKFLOW]·[AGENT] 라벨을 추가할 때, 이미 명령형 prompt 구조였기 때문에 라벨이 기존 구조와 충돌 없이 얹혔다.
7. [선택 6]: 실 API + Mock 혼용 (동일 응답 구조로)
세 가지 방향이 있었다. 모든 소스를 Mock으로 시작하거나, Reddit OAuth·YouTube API Key 발급이 완료될 때까지 기다리거나, 실 API가 가능한 소스부터 연동하고 나머지를 Mock으로 처리하는 방식이다.
선택의 근거
"Mock이라면 실제 API 응답 구조와 동일한지 한 번 더 맞춰보는 것을 권장."
HackerNews·GitHub·iTunes는 인증 없이 또는 토큰 없이 실 API 호출이 가능하다. Reddit·YouTube는 OAuth/API Key 발급 절차가 필요하다. 발급 절차가 첫 실행을 막아서는 안 됐다. 단, Mock을 쓰더라도 응답 구조를 실 API와 100% 동일하게 유지하여 향후 전환 비용을 0으로 만드는 것이 조건이었다.
def _fetch_reddit_mock(limit: int = 3) -> dict:
items = [
{"keyword": "카피바라 밈", "source": "reddit_mock",
"url": "https://reddit.com/r/memes", "score": 94200},
...
][:limit]
return {
"items": items,
"data_source": "mock", # 실 API와 동일한 스키마
"endpoint": None,
"fallback_reason": "reddit_oauth_not_configured",
}
트레이드오프
Mock은 실패하지 않는다. 실 API 없이는 실패 분기를 시뮬레이션할 수 없다. 대신 두 가지를 얻었다. 첫째, 첫 실행이 Mock 데이터로도 즉시 가능했다. 둘째, data_source: "mock" 표기 덕분에 결과 파일에서 어디까지가 실 API이고 어디서부터 Mock인지를 trace 수준에서 판독할 수 있다. 본편의 "출처 메타" 섹션은 이 구조가 있었기에 성립했다.
8. 첫 실행이 의미한 것 (결론)
7개 선택으로 세운 골조 위에서 첫 ReAct 실행이 동작했다. 13 메시지, 7회 Tool 호출, JSON 출력 파싱 성공. examples/case1_it_focus.json에 결과가 남았다. 이 시점에서 두 가지가 명확해졌다.
- 첫째, 구조는 동작했지만 자율성이 너무 넓었다. 같은 입력에 다른 형식의 답이 나올 가능성, 출력 스키마가 흔들릴 가능성, recursion이 발산할 가능성이 모두 열려 있었다.
- 둘째, 실행이 어디로 갔는지 알 수 없었다. metadata.used_tools 배열만 봐서는 어느 step에서 무슨 일이 일어났는지 추적이 불가능했다.
이 두 인식이 첫 글의 출발점이다. 통제 레이어는 구조가 동작하고 나서야 무엇을 통제해야 하는지가 보인다. 설계서에서 미리 정의한 것들(Workflow vs Agent 구간 분리, 종료 조건, 루프 탈출)이 실제 코드에서 얼마나 다르게 드러나는지도, 첫 실행이 없었다면 알 수 없었을 것이다.
'AI > Agent' 카테고리의 다른 글
| 7-4. [AI Agent 구현]: 설계서를 코드로 (0) | 2026.05.29 |
|---|---|
| 7-3. [AI Agent 구현]: 자율성의 경계를 설계 (0) | 2026.05.29 |
| 7-1. [AI Agent 구현]: 설계가 코드를 만날 때 (0) | 2026.05.15 |
| 6-4. 설계의 중요성을 다시 한번 짚었다 (0) | 2026.05.15 |
| 6-3. [AI Agent 설계]: Agent는 예외 처리기다 (1) | 2026.05.15 |