Agentic Loop란?
이전 글에서는 3편의 멀티 에이전트 파이프라인을 LangGraph로 바꿔봤습니다.
구조는 아래처럼 일자형 그래프였습니다.
START
→ load_inputs
→ analyze_experience
→ match_job
→ write_resume
→ write_portfolio
→ review_outputs
→ render_final_markdown
→ save_result
→ END
이 구조는 실행 흐름이 명확해서 이해하기 좋습니다.
하지만 실제 에이전트 시스템은 한 번 작성하고 바로 끝나는 경우보다, 결과를 검토하고 다시 수정하는 흐름이 더 자연스럽습니다.
예를 들어 이력서 작성 시스템이라면 이런 흐름이 필요할 수 있습니다.
이력서 초안 작성
→ 리뷰
→ 부족한 점 발견
→ 다시 작성
→ 다시 리뷰
→ 통과하면 저장
이처럼 에이전트가 결과를 만들고, 스스로 검토하고, 필요하면 다시 작업하는 반복 구조를 Agentic Loop라고 볼 수 있습니다.
왜 Agentic Loop가 필요할까?
LLM은 한 번에 완벽한 결과를 만들 수도 있지만, 항상 그런 것은 아닙니다.
특히 이력서나 포트폴리오처럼 사실 기반과 표현 품질이 모두 중요한 작업에서는 한 번의 생성만으로 끝내기 어렵습니다.
예를 들어 첫 초안에서 이런 문제가 생길 수 있습니다.
- 성과가 너무 추상적으로 적혔다.
- 목표 직무와 연결되는 키워드가 부족하다.
- 실제 경험보다 표현이 과장됐다.
- 포트폴리오 설명에 문제 상황과 해결 방법이 빠졌다.
- 추가로 확인해야 할 질문이 정리되지 않았다.
이럴 때 사람이 직접 다시 요청할 수도 있습니다. 이건 단순 LLM 호출 방식입니다.
조금 더 구체적으로 다시 써줘.
과장된 표현은 줄여줘.
목표 직무와 더 연결되게 수정해줘.
Agentic Loop는 이 과정을 그래프 안에 넣는 방식입니다.
ReviewAgent가 수정 필요 여부를 판단
→ 수정 필요하면 RevisionAgent가 다시 작성
→ 다시 ReviewAgent가 검토
→ 통과하면 저장
LangGraph에서 Loop를 만드는 방법
LangGraph에서는 add_conditional_edges를 사용해서 조건에 따라 다음 노드를 선택할 수 있습니다.
공식 문서에 따르면 add_conditional_edges는 특정 노드가 끝난 뒤 routing function을 실행하고, 그 반환값에 따라 다음 노드를 결정합니다.
이번 글에서는 review_outputs 노드 뒤에 조건부 엣지를 붙입니다.
review_outputs
├─ REVISE → revise_outputs
└─ PASS → render_final_markdown
그리고 revise_outputs가 끝나면 다시 review_outputs로 돌아갑니다.
revise_outputs
→ review_outputs
전체 흐름은 아래와 같습니다.
START
→ load_inputs
→ analyze_experience
→ match_job
→ write_resume
→ write_portfolio
→ review_outputs
├─ 수정 필요 → revise_outputs → review_outputs
└─ 통과 → render_final_markdown → save_result → END
무한 루프를 막아야 한다
Loop를 만들 때 가장 중요한 것은 종료 조건입니다.
리뷰 에이전트가 계속 REVISE를 반환하면 그래프가 끝나지 않을 수 있습니다.
그래서 이번 예제에서는 최대 수정 횟수를 둡니다.
max_revisions = 2
전체 코드
from __future__ import annotations
import os
from pathlib import Path
from typing import Literal, TypedDict
from dotenv import load_dotenv
from langchain.agents import create_agent
from langchain_openai import ChatOpenAI
from langgraph.graph import END, START, StateGraph
ROOT_DIR = Path(__file__).parent
DATA_DIR = ROOT_DIR / "data"
OUTPUT_DIR = ROOT_DIR / "outputs"
class ResumeLoopState(TypedDict, total=False):
"""
Agentic Loop 전체에서 공유할 상태입니다.
"""
request: str
profile: str
work_history: str
target_role: str
experience_analysis: str
job_match_report: str
resume_draft: str
portfolio_draft: str
review_report: str
review_decision: str
revision_count: int
max_revisions: int
final_markdown: str
output_path: str
EXPERIENCE_ANALYZER_PROMPT = """
너는 ExperienceAnalyzerAgent다.
역할:
- 사용자의 업무 경험을 분석한다.
- 프로젝트별 역할, 사용 기술, 해결한 문제, 결과를 구조화한다.
- 이력서 문장으로 바로 바꾸기보다 먼저 재료를 정리한다.
주의:
- 주어진 정보에 없는 수치나 회사명은 만들지 않는다.
- 성과가 모호하면 '근거 보완 필요'라고 표시한다.
출력 형식:
# 업무 경험 분석
## 프로젝트별 요약
## 사용 기술
## 문제 해결 경험
## 성과 후보
## 근거 보완 필요 항목
"""
JOB_MATCHER_PROMPT = """
너는 JobMatcherAgent다.
역할:
- 목표 직무 요구사항과 업무 경험 분석 결과를 비교한다.
- 어떤 경험이 어떤 요구사항과 연결되는지 정리한다.
- 부족한 요구사항도 함께 표시한다.
출력 형식:
# 직무 매칭 분석
## 강하게 연결되는 경험
## 부분적으로 연결되는 경험
## 부족한 경험 또는 추가 확인 필요
## 이력서에서 강조할 키워드
"""
RESUME_WRITER_PROMPT = """
너는 ResumeWriterAgent다.
역할:
- 업무 경험 분석과 직무 매칭 결과를 바탕으로 이력서 초안을 작성한다.
- 문장은 간결하고 성과 중심으로 작성한다.
- 과장하지 않고 주어진 근거 안에서만 작성한다.
출력 형식:
# 이력서 초안
## 한 줄 소개
## 핵심 역량
## 경력 요약
## 프로젝트 경험
## 추가로 확인할 내용
"""
PORTFOLIO_WRITER_PROMPT = """
너는 PortfolioWriterAgent다.
역할:
- 업무 경험 분석과 직무 매칭 결과를 바탕으로 포트폴리오 초안을 작성한다.
- 프로젝트별로 문제, 역할, 해결 방법, 사용 기술, 결과를 정리한다.
- 실제 포트폴리오 페이지에 옮길 수 있는 구조로 작성한다.
출력 형식:
# 포트폴리오 초안
## 프로젝트 1
### 문제 상황
### 맡은 역할
### 해결 방법
### 사용 기술
### 결과
### 더 보완할 자료
"""
REVIEW_AGENT_PROMPT = """
너는 ReviewAgent다.
역할:
- 이력서 초안과 포트폴리오 초안을 검토한다.
- 과장된 표현, 근거 부족, 빠진 질문을 찾아낸다.
- 최종 제출 전 보완 방향을 정리한다.
판단 기준:
- 바로 사용해도 될 정도로 명확하면 PASS다.
- 근거 부족, 과장 가능성, 목표 직무와의 연결 부족이 크면 REVISE다.
출력 형식:
# 리뷰 결과
## 좋은 점
## 과장 가능성이 있는 표현
## 근거 보완이 필요한 부분
## 추가 질문
## 수정 제안
마지막 줄에는 반드시 아래 둘 중 하나만 작성한다.
REVIEW_DECISION: PASS
REVIEW_DECISION: REVISE
"""
REVISION_AGENT_PROMPT = """
너는 RevisionAgent다.
역할:
- ReviewAgent의 피드백을 반영해서 이력서 초안과 포트폴리오 초안을 다시 작성한다.
- 과장된 표현은 줄인다.
- 목표 직무와 연결되는 표현은 더 명확하게 만든다.
- 주어진 정보에 없는 사실은 추가하지 않는다.
출력 형식:
# 수정된 결과
## 수정된 이력서 초안
## 수정된 포트폴리오 초안
## 반영한 리뷰 포인트
"""
def read_markdown(filename: str) -> str:
"""
data 폴더 안의 markdown 파일을 읽습니다.
"""
path = DATA_DIR / filename
if not path.exists():
raise FileNotFoundError(f"파일을 찾을 수 없습니다: {path}")
return path.read_text(encoding="utf-8")
def save_markdown(filename: str, content: str) -> Path:
"""
outputs 폴더에 markdown 파일을 저장합니다.
"""
OUTPUT_DIR.mkdir(exist_ok=True)
safe_filename = filename.replace("/", "_").replace("\\", "_")
if not safe_filename.endswith(".md"):
safe_filename = f"{safe_filename}.md"
path = OUTPUT_DIR / safe_filename
path.write_text(content, encoding="utf-8")
return path
def last_message_text(result: dict) -> str:
"""
LangChain agent 실행 결과에서 마지막 메시지 내용을 문자열로 꺼냅니다.
"""
message = result["messages"][-1]
content = getattr(message, "content", "")
if isinstance(content, str):
return content
return str(content)
def parse_review_decision(review_report: str) -> str:
"""
ReviewAgent 응답의 마지막 판단값을 읽습니다.
"""
upper_report = review_report.upper()
if "REVIEW_DECISION: PASS" in upper_report:
return "PASS"
if "REVIEW_DECISION: REVISE" in upper_report:
return "REVISE"
return "REVISE"
def create_model() -> ChatOpenAI:
"""
모든 노드에서 사용할 언어 모델을 생성합니다.
"""
model_name = os.getenv("OPENAI_MODEL", "gpt-5.5")
return ChatOpenAI(
model=model_name,
temperature=0.2,
)
def create_agent_node(model: ChatOpenAI, name: str, system_prompt: str):
"""
LangGraph 노드 내부에서 호출할 LangChain agent를 생성합니다.
"""
return create_agent(
model=model,
tools=[],
system_prompt=system_prompt,
name=name,
)
def invoke_agent(agent, content: str) -> str:
"""
LangChain agent를 실행하고 마지막 응답 텍스트만 반환합니다.
"""
result = agent.invoke(
{
"messages": [
{
"role": "user",
"content": content,
}
]
}
)
return last_message_text(result)
def render_final_markdown(state: ResumeLoopState) -> str:
"""
Agentic Loop 최종 상태를 markdown 문서로 합칩니다.
"""
return f"""
# Agentic Loop 이력서 작성 결과
## 반복 정보
- 수정 횟수: {state.get("revision_count", 0)}
- 최대 수정 횟수: {state.get("max_revisions", 0)}
- 최종 리뷰 판단: {state.get("review_decision", "UNKNOWN")}
## 1. 업무 경험 분석
{state["experience_analysis"]}
## 2. 직무 매칭 분석
{state["job_match_report"]}
## 3. 최종 이력서 초안
{state["resume_draft"]}
## 4. 최종 포트폴리오 초안
{state["portfolio_draft"]}
## 5. 최종 리뷰 결과
{state["review_report"]}
""".strip()
def create_resume_loop_graph():
"""
Agentic Loop가 적용된 LangGraph workflow를 생성합니다.
"""
model = create_model()
experience_agent = create_agent_node(
model=model,
name="experience_analyzer_agent",
system_prompt=EXPERIENCE_ANALYZER_PROMPT,
)
job_matcher_agent = create_agent_node(
model=model,
name="job_matcher_agent",
system_prompt=JOB_MATCHER_PROMPT,
)
resume_writer_agent = create_agent_node(
model=model,
name="resume_writer_agent",
system_prompt=RESUME_WRITER_PROMPT,
)
portfolio_writer_agent = create_agent_node(
model=model,
name="portfolio_writer_agent",
system_prompt=PORTFOLIO_WRITER_PROMPT,
)
review_agent = create_agent_node(
model=model,
name="review_agent",
system_prompt=REVIEW_AGENT_PROMPT,
)
revision_agent = create_agent_node(
model=model,
name="revision_agent",
system_prompt=REVISION_AGENT_PROMPT,
)
def load_inputs_node(state: ResumeLoopState) -> ResumeLoopState:
"""
입력 markdown 파일을 읽어서 state에 추가합니다.
"""
return {
"profile": read_markdown("profile.md"),
"work_history": read_markdown("work_history.md"),
"target_role": read_markdown("target_role.md"),
"revision_count": state.get("revision_count", 0),
"max_revisions": state.get("max_revisions", 2),
}
def analyze_experience_node(state: ResumeLoopState) -> ResumeLoopState:
"""
업무 경험을 분석합니다.
"""
experience_analysis = invoke_agent(
experience_agent,
f"""
[업무 경험]
{state["work_history"]}
""".strip(),
)
return {"experience_analysis": experience_analysis}
def match_job_node(state: ResumeLoopState) -> ResumeLoopState:
"""
업무 경험 분석 결과와 목표 직무를 매칭합니다.
"""
job_match_report = invoke_agent(
job_matcher_agent,
f"""
[업무 경험 분석]
{state["experience_analysis"]}
[목표 직무]
{state["target_role"]}
""".strip(),
)
return {"job_match_report": job_match_report}
def write_resume_node(state: ResumeLoopState) -> ResumeLoopState:
"""
이력서 초안을 작성합니다.
"""
resume_draft = invoke_agent(
resume_writer_agent,
f"""
[기본 프로필]
{state["profile"]}
[업무 경험 분석]
{state["experience_analysis"]}
[직무 매칭 분석]
{state["job_match_report"]}
""".strip(),
)
return {"resume_draft": resume_draft}
def write_portfolio_node(state: ResumeLoopState) -> ResumeLoopState:
"""
포트폴리오 초안을 작성합니다.
"""
portfolio_draft = invoke_agent(
portfolio_writer_agent,
f"""
[업무 경험 분석]
{state["experience_analysis"]}
[직무 매칭 분석]
{state["job_match_report"]}
""".strip(),
)
return {"portfolio_draft": portfolio_draft}
def review_outputs_node(state: ResumeLoopState) -> ResumeLoopState:
"""
이력서와 포트폴리오 초안을 검토하고 PASS 또는 REVISE를 판단합니다.
"""
review_report = invoke_agent(
review_agent,
f"""
[이력서 초안]
{state["resume_draft"]}
[포트폴리오 초안]
{state["portfolio_draft"]}
[업무 경험 분석]
{state["experience_analysis"]}
[직무 매칭 분석]
{state["job_match_report"]}
[현재 수정 횟수]
{state.get("revision_count", 0)}
""".strip(),
)
review_decision = parse_review_decision(review_report)
return {
"review_report": review_report,
"review_decision": review_decision,
}
def revise_outputs_node(state: ResumeLoopState) -> ResumeLoopState:
"""
리뷰 결과를 반영해서 이력서와 포트폴리오 초안을 다시 작성합니다.
"""
revised_resume = invoke_agent(
revision_agent,
f"""
[기본 프로필]
{state["profile"]}
[업무 경험 분석]
{state["experience_analysis"]}
[직무 매칭 분석]
{state["job_match_report"]}
[기존 이력서 초안]
{state["resume_draft"]}
[리뷰 결과]
{state["review_report"]}
위 리뷰를 반영해서 이력서 초안만 다시 작성해줘.
포트폴리오 초안은 작성하지 말고, 이력서 초안만 출력해줘.
""".strip(),
)
revised_portfolio = invoke_agent(
revision_agent,
f"""
[기본 프로필]
{state["profile"]}
[업무 경험 분석]
{state["experience_analysis"]}
[직무 매칭 분석]
{state["job_match_report"]}
[기존 포트폴리오 초안]
{state["portfolio_draft"]}
[리뷰 결과]
{state["review_report"]}
위 리뷰를 반영해서 포트폴리오 초안만 다시 작성해줘.
이력서 초안은 작성하지 말고, 포트폴리오 초안만 출력해줘.
""".strip(),
)
return {
"resume_draft": revised_resume,
"portfolio_draft": revised_portfolio,
"revision_count": state.get("revision_count", 0) + 1,
}
def route_after_review(state: ResumeLoopState) -> Literal["revise", "finish"]:
"""
리뷰 결과에 따라 다음 노드를 결정합니다.
"""
should_revise = state.get("review_decision") == "REVISE"
revision_count = state.get("revision_count", 0)
max_revisions = state.get("max_revisions", 2)
if should_revise and revision_count < max_revisions:
return "revise"
return "finish"
def render_final_markdown_node(state: ResumeLoopState) -> ResumeLoopState:
"""
모든 결과를 하나의 markdown 문서로 합칩니다.
"""
return {"final_markdown": render_final_markdown(state)}
def save_result_node(state: ResumeLoopState) -> ResumeLoopState:
"""
최종 markdown 결과를 파일로 저장합니다.
"""
output_path = save_markdown(
filename="agentic_loop_resume_result.md",
content=state["final_markdown"],
)
return {"output_path": str(output_path)}
workflow = StateGraph(ResumeLoopState)
workflow.add_node("load_inputs", load_inputs_node)
workflow.add_node("analyze_experience", analyze_experience_node)
workflow.add_node("match_job", match_job_node)
workflow.add_node("write_resume", write_resume_node)
workflow.add_node("write_portfolio", write_portfolio_node)
workflow.add_node("review_outputs", review_outputs_node)
workflow.add_node("revise_outputs", revise_outputs_node)
workflow.add_node("render_final_markdown", render_final_markdown_node)
workflow.add_node("save_result", save_result_node)
workflow.add_edge(START, "load_inputs")
workflow.add_edge("load_inputs", "analyze_experience")
workflow.add_edge("analyze_experience", "match_job")
workflow.add_edge("match_job", "write_resume")
workflow.add_edge("write_resume", "write_portfolio")
workflow.add_edge("write_portfolio", "review_outputs")
workflow.add_conditional_edges(
"review_outputs",
route_after_review,
{
"revise": "revise_outputs",
"finish": "render_final_markdown",
},
)
workflow.add_edge("revise_outputs", "review_outputs")
workflow.add_edge("render_final_markdown", "save_result")
workflow.add_edge("save_result", END)
return workflow.compile()
def main() -> None:
load_dotenv()
app = create_resume_loop_graph()
result = app.invoke(
{
"request": "목표 직무에 맞는 이력서와 포트폴리오 초안을 작성하고 스스로 검토해줘.",
"revision_count": 0,
"max_revisions": 2,
}
)
print(f"Agentic Loop 결과 저장 완료: {result['output_path']}")
print(f"최종 리뷰 판단: {result.get('review_decision')}")
print(f"수정 횟수: {result.get('revision_count')}")
print("\n그래프 구조:")
print(app.get_graph().draw_mermaid())
if __name__ == "__main__":
main()
코드 설명
ResumeLoopState
class ResumeLoopState(TypedDict, total=False):
ResumeLoopState는 그래프 전체가 공유하는 상태입니다.
4편의 ResumeGraphState와 비슷하지만, 이번에는 loop를 위해 몇 가지 값이 추가되었습니다.
# PASS 또는 REVISE
review_decision: str
# 지금까지 수정한 횟수
revision_count: int
# 최대 수정 횟수
max_revisions: int
ReviewAgent의 판단값 만들기
이번 코드에서는 ReviewAgent가 마지막 줄에 판단값을 작성하도록 했습니다.
이 판단값을 읽기 위해 parse_review_decision 함수를 만들었습니다.
def parse_review_decision(review_report: str) -> str:
간단한 예제이기 때문에 문자열 검색으로 처리했습니다.
if "REVIEW_DECISION: PASS" in upper_report:
return "PASS"
실제 서비스에서는 더 안정적인 구조화 출력 방식을 사용하는 것이 좋습니다.
예를 들어 JSON 스키마를 사용해 아래처럼 받는 방식이 더 안전합니다.
{
"decision": "REVISE",
"reasons": ["성과 표현이 추상적임", "직무 키워드 연결 부족"]
}
route_after_review
def route_after_review(state: ResumeLoopState) -> Literal["revise", "finish"]:
이 함수가 Agentic Loop의 핵심입니다.
현재 리뷰 판단과 수정 횟수를 보고 다음 노드를 결정합니다.
should_revise = state.get("review_decision") == "REVISE"
revision_count = state.get("revision_count", 0)
max_revisions = state.get("max_revisions", 2)
수정이 필요하고, 아직 최대 수정 횟수에 도달하지 않았다면 revise를 반환합니다.
그 외에는 finish를 반환합니다.
...
if should_revise and revision_count < max_revisions:
return "revise"
return "finish"
add_conditional_edge
workflow.add_conditional_edges(
"review_outputs",
route_after_review,
{
"revise": "revise_outputs",
"finish": "render_final_markdown",
},
)
add_conditional_edges는 특정 노드가 끝난 뒤 다음 노드를 조건에 따라 결정합니다.
여기서는 review_outputs가 끝난 뒤 route_after_review를 실행합니다.
route_after_review가 revise를 반환하면 revise_outputs로 이동합니다.
finish를 반환하면 render_final_markdown으로 이동합니다.
review_outputs
├─ revise → revise_outputs
└─ finish → render_final_markdown
revise_outputs_node
def revise_outputs_node(state: ResumeLoopState) -> ResumeLoopState:
이 노드는 리뷰 결과를 반영해서 초안을 다시 작성합니다.
그리고 수정 횟수를 1 증가시킵니다.
return {
"resume_draft": revised_resume,
"portfolio_draft": revised_portfolio,
"revision_count": state.get("revision_count", 0) + 1,
}
이번 예제에서는 하나의 RevisionAgent를 두 번 호출했습니다.
첫 번째 호출에서는 이력서 초안만 다시 작성하고, 두 번째 호출에서는 포트폴리오 초안만 다시 작성합니다.
실제 프로젝트에서는 이력서 수정 에이전트와 포트폴리오 수정 에이전트를 따로 나누는 것이 더 좋습니다.
다시 review_outputs로 돌아가기
workflow.add_edge("revise_outputs", "review_outputs")
이 코드 때문에 loop가 만들어집니다.
수정이 끝나면 다시 리뷰 단계로 돌아갑니다.
revise_outputs
→ review_outputs
종료 조건
Agentic Loop에서 가장 중요한 것은 종료 조건입니다.
이번 예제의 종료 조건은 두 가지입니다.
1. ReviewAgent가 PASS를 반환한다.
2. revision_count가 max_revisions에 도달한다.
즉, 리뷰가 아직 REVISE라고 해도 최대 수정 횟수에 도달하면 종료합니다.
이렇게 해야 무한 루프를 막을 수 있습니다.
정리
실행 흐름
1. 입력 파일을 읽는다.
2. 업무 경험을 분석한다.
3. 목표 직무와 업무 경험을 매칭한다.
4. 이력서 초안을 작성한다.
5. 포트폴리오 초안을 작성한다.
6. 리뷰 에이전트가 PASS 또는 REVISE를 판단한다.
7. REVISE이고 수정 횟수가 남아 있으면 다시 작성한다.
8. 다시 리뷰한다.
9. PASS이거나 최대 수정 횟수에 도달하면 최종 결과를 저장한다.
핵심
- Agentic Loop는 생성, 검토, 판단, 수정, 재검토 흐름이다.
- LangGraph에서는 add_conditional_edges로 조건부 흐름을 만들 수 있다.
- 리뷰 결과가 REVISE이면 수정 노드로 이동한다.
- 수정 후 다시 리뷰 노드로 돌아가면 loop가 만들어진다.
- 무한 루프를 막기 위해 revision_count, max_revisions가 필요하다.
'AI' 카테고리의 다른 글
| [AI] DiffusionGemma 딥다이브 - Uniform State Diffusion과 추론 아키텍처 (0) | 2026.06.14 |
|---|---|
| [AI] LangGraph 써보기 (0) | 2026.05.27 |
| [AI] 멀티 에이전트 파이프라인 with LangChain (0) | 2026.05.26 |
| [AI] 멀티 에이전트를 만들어보자! (0) | 2026.05.20 |
| [AI] 에이전트를 만들어보자! (0) | 2026.05.17 |