ReAct 에이전트 구현
들어가며
RAG(검색 증강 생성) 파이프라인을 만들다 보면 가장 먼저 마주치는 벽이 "PDF를 어떻게 검색 가능한 형태로 바꿀 것인가"다. 그리고 그 다음 벽은 "여러 개의 검색기 중에서 에이전트가 스스로 적절한 것을 고르게 하려면 어떻게 해야 하는가"다.
이 글에서는 LangChain의 AgentExecutor나 create_react_agent 같은
고수준 API를 쓰지 않고, ReAct(Reasoning + Acting) 패턴을 프롬프트 설계부터
응답 파서, 실행 루프까지 처음부터 직접 구현한 과정을 정리한다.
목표는 "라이브러리를 가져다 쓰는" 게 아니라 에이전트가 내부적으로 어떻게 동작하는지 직접 손으로 짜보면서 이해하는 것이었다.
대상 도메인은 일본 ICT 정책, 미국 ICT 정책, 미국 블록체인 동향 3개였고, 최종적으로 Gradio 기반 웹 데모까지 만들었다.
전체 구조
에이전트는 이 사이클을 답이 나올 때까지 반복하고, 최대 반복 횟수를 넘기면 안전하게 종료한다.
1. PDF를 벡터DB로 바꾸기
가장 먼저 만든 건 PDF 경로만 넣으면 검색 가능한 retriever를 반환해주는 함수다.
# 임베딩 설정
embd = OpenAIEmbeddings()
def create_pdf_retriever(
pdf_path: str, # PDF 파일 경로
persist_directory: str, # 벡터 스토어 저장 경로
embedding_model: OpenAIEmbeddings,
chunk_size: int = 512,
chunk_overlap: int = 0
) -> Chroma.as_retriever:
# PDF 파일로드
loader = PyMuPDFLoader(pdf_path)
data = loader.load()
# 청킹
text_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(
chunk_size=chunk_size,
chunk_overlap=chunk_overlap
)
doc_splits = text_splitter.split_documents(data)
# 벡터 스토어로 적재
vectorstore = Chroma.from_documents(
persist_directory=persist_directory,
documents=doc_splits,
embedding=embedding_model,
)
return vectorstore.as_retriever()이 함수를 도메인별로 3번 호출해서 독립된 벡터DB 3개를 만들었다.
retriever_ict_japan = create_pdf_retriever(
pdf_path="ict_japan_2024.pdf",
persist_directory="db_ict_policy_japan_2024",
embedding_model=embd
)
retriever_ict_usa = create_pdf_retriever(
pdf_path="ict_usa_2024.pdf",
persist_directory="db_ict_policy_usa_2024",
embedding_model=embd
)
retriever_blockchain_usa = create_pdf_retriever(
pdf_path="blockchain_usa_2025.pdf",
persist_directory="db_blockchain_usa_2025",
embedding_model=embd
)배운 점: 벡터DB를 하나로 합치지 않고 도메인별로 분리한 이유는, 검색 범위를 좁혀야 관련 없는 결과가 섞여 들어오는 걸 막을 수 있기 때문이다. 분리된 저장소 + 명확한 description 조합이 결국 에이전트의 "도구 선택 정확도"를 결정한다.
2. 검색기를 에이전트가 쓸 수 있는 "도구"로 변환하기
retriever 객체 자체는 에이전트가 이해할 수 있는 형태가 아니다.
create_retriever_tool로 이름과 설명을 붙여줘야 한다.
ict_japan_engine = create_retriever_tool(
retriever=retriever_ict_japan,
name="japan_ict_trend_searcher",
description="일본의 ICT 산업의 시장동향 정보를 제공합니다."
)
ict_usa_engine = create_retriever_tool(
retriever=retriever_ict_usa,
name="usa_ict_trend_searcher",
description="미국의 ICT 산업의 시장동향 정보를 제공합니다."
)
blockchain_usa_engine = create_retriever_tool(
retriever=retriever_blockchain_usa,
name="usa_blockchain_trend_searcher",
description="미국의 블록체인 산업의 동향 정보를 제공합니다."
)
tools = [ict_japan_engine, ict_usa_engine, blockchain_usa_engine]
tool_map: Dict[str, object] = {t.name: t for t in tools}배운 점 — description이 전부다: 에이전트는 사용자 질문의 의미를 직접
이해해서 도구를 고르는 게 아니라, 오직 각 도구의 description 텍스트를
보고 판단한다. description이 애매하면 에이전트는 엉뚱한 도구를 고르거나
아예 도구를 쓰지 않는다. 그래서 "일본의 ICT 시장동향 정보를 제공합니다"처럼
용도를 구체적으로 적는 게 프롬프트 엔지니어링만큼 중요한 작업이라는 걸
알게 됐다.
tool_map은 도구 이름을 key로 갖는 딕셔너리로, 에이전트가 "이 도구를
쓰겠다"고 결정했을 때 tool_map["도구이름"]으로 즉시 찾아 실행하기 위해
만들었다.
3. ReAct 프롬프트를 직접 설계하기
여기서부터가 진짜 ReAct 패턴을 이해하는 구간이다. LangChain Hub에서 프롬프트를 가져다 쓰지 않고 직접 작성했다.
react_template = '''다음 질문에 최선을 다해 답변하세요. 당신은 다음 도구들에 접근할 수 있습니다:
{tools}
다음 형식을 사용하세요:
Question: 답변해야 하는 입력 질문
Thought: 무엇을 할지 항상 생각하세요
Action: 취해야 할 행동, [{tool_names}] 중 하나여야 합니다. 리스트에 있는 도구 중 1개를 택하십시오.
Action Input: 행동에 대한 입력값
Observation: 행동의 결과
... (이 Thought/Action/Action Input/Observation의 과정이 N번 반복될 수 있습니다)
Thought: 이제 최종 답변을 알겠습니다
Final Answer: 원래 입력된 질문에 대한 최종 답변
## 추가적인 주의사항
- 반드시 [Thought -> Action -> Action Input format] 이 사이클의 순서를 준수하십시오. 항상 Action 전에는 Thought가 먼저 나와야 합니다.
- 최종 답변에는 최대한 많은 내용을 포함하십시오.
- 한 번의 검색으로 해결되지 않을 것 같다면 문제를 분할하여 푸는 것은 중요합니다.
- 정보가 취합되었다면 불필요하게 사이클을 반복하지 마십시오.
- 묻지 않은 정보를 찾으려고 도구를 사용하지 마십시오.
시작하세요!
Question: {input}
{agent_scratchpad}'''
prompt = PromptTemplate.from_template(react_template){tools}와 {tool_names}는 고정된 텍스트가 아니라, 등록된 도구 목록에
따라 매번 동적으로 채워지는 값이다. 이걸 렌더링하는 함수도 직접 짰다.
def _format_tools_for_prompt(ts: List[object]) -> Tuple[str, str]:
lines, names = [], []
for t in ts:
names.append(t.name)
desc = getattr(t, "description", "")
lines.append(f"{t.name}: {desc}")
return "\n".join(lines), ", ".join(names)
def _render_prompt(user_input: str, scratchpad: str) -> str:
tools_str, tool_names = _format_tools_for_prompt(tools)
return prompt.format(
tools=tools_str,
tool_names=tool_names,
input=user_input,
agent_scratchpad=scratchpad,
)배운 점: ReAct는 결국 "LLM이 매 스텝마다 Thought/Action/Action
Input을 텍스트로 뱉게 만들고, 그걸 파싱해서 실제 함수 호출로 연결하는"
구조라는 걸 프롬프트를 직접 써보면서 체감했다. 프레임워크가 감춰주던
agent_scratchpad(지금까지의 사고 과정 누적 텍스트)도 결국 문자열을
계속 이어붙이는 것뿐이라는 걸 알게 됐다.
4. LLM 응답을 파싱하는 로직 직접 짜기
LLM은 자유 텍스트를 뱉는다. 이걸 구조화된 (도구명, 입력값) 또는 (최종답변)으로 바꾸는 파서가 필요하다.
llm = ChatOpenAI(model="gpt-4.1", temperature=0)
ACTION_RE = re.compile(r"^Action\s*:\s*(?P<tool>.+?)\s*$", re.MULTILINE)
ACTION_INPUT_RE = re.compile(r"^Action Input\s*:\s*(?P<input>.+?)\s*$", re.MULTILINE)
FINAL_ANSWER_RE = re.compile(r"Final Answer\s*:\s*(?P<final>[\s\S]+)$", re.IGNORECASE)
def _parse_action_and_input(text: str) -> Tuple[Optional[str], Optional[str]]:
m_final = FINAL_ANSWER_RE.search(text)
if m_final:
return "__FINAL__", m_final.group("final").strip()
m_act = ACTION_RE.search(text)
m_in = ACTION_INPUT_RE.search(text)
if m_act and m_in:
return m_act.group("tool").strip(), m_in.group("input").strip()
return None, Nonetemperature=0으로 설정한 이유는 명확하다. temperature는 LLM이 답변을
생성할 때 창의성/무작위성을 조절하는 매개변수로, 0에 가까울수록 같은
입력에 같은 출력을 낸다. 에이전트가 정해진 형식(Action:, Action Input:)을
일관되게 지켜야 정규식 파싱이 안정적으로 동작하기 때문에 0으로 고정했다.
배운 점: 정규식 파서를 직접 만들면서, LLM 출력 형식이 조금만 어긋나도(예: "Action :" 처럼 콜론 앞에 공백이 하나 더 들어가는 등) 파싱이 실패할 수 있다는 걸 체감했다. 그래서 파싱 실패 시 그냥 죽는 대신, 힌트를 주고 재시도하게 만드는 방어 로직이 꼭 필요했다.
5. 검색 결과를 에이전트가 읽을 수 있는 텍스트로 바꾸기
벡터 검색기를 실행하면 Document 객체 리스트가 반환된다. 이걸 그대로
프롬프트에 넣을 수 없으니 변환이 필요하다.
def _observation_to_text(observation_obj) -> str:
if isinstance(observation_obj, list):
# Document 리스트일 수 있음
def doc_to_str(d):
try:
meta = getattr(d, "metadata", {}) or {}
src = meta.get("source") or meta.get("file_path") or ""
txt = getattr(d, "page_content", "")
if len(txt) > 500:
txt = txt[:500] + "..."
return f"[source={src}] {txt}"
except Exception:
return str(d)
return "\n".join(doc_to_str(d) for d in observation_obj[:5])
return str(observation_obj)500자 초과 시 자르고, 상위 5개 문서만 처리하는 이유는 프롬프트가 지나치게 길어지는 걸 막기 위해서다.
배운 점: RAG에서 "검색이 잘 됐다"와 "검색 결과를 LLM이 잘 활용할 수 있는 형태로 넘겼다"는 별개의 문제다. 컨텍스트 길이 관리를 신경 쓰지 않으면 검색은 잘 했는데 프롬프트가 너무 길어져서 오히려 성능이 떨어지는 경우가 생길 수 있다.
6. 실행 루프: Thought-Action-Observation을 반복시키기
이제 위 조각들을 하나의 루프로 묶는다.
def run_react(user_input: str, max_iters: int = 8) -> Dict[str, str]:
scratchpad = ""
for _ in range(max_iters):
rendered = _render_prompt(user_input, scratchpad)
resp = llm.invoke(rendered)
text = resp.content if hasattr(resp, "content") else str(resp)
tool, action_input = _parse_action_and_input(text)
if tool is None:
hint = "\n[파싱안내] 형식을 엄격히 따르세요. 반드시 'Action:'와 'Action Input:'를 한 줄씩 제공하십시오.\n"
scratchpad += f"{text}\n{hint}"
continue
if tool == "__FINAL__":
final_answer = action_input
return {"output": final_answer, "log": scratchpad + "\n" + text}
if tool not in tool_map:
observation = f"[에러] 존재하지 않는 도구입니다: {tool}"
scratchpad += f"{text}\nObservation: {observation}\n"
continue
try:
observation_obj = tool_map[tool].invoke(action_input)
observation = _observation_to_text(observation_obj)
scratchpad += f"{text}\nObservation: {observation}\n"
except Exception as e:
scratchpad += f"{text}\nObservation: [도구실행오류] {e}\n"
return {
"output": "반복 한도를 초과했습니다. 질문을 더 구체화해 주세요.",
"log": scratchpad,
}이 함수 하나에 ReAct 사이클의 모든 분기 처리가 들어있다:
- 파싱 실패 → 힌트 재주입 후 재시도
- 최종 답변 감지 → 즉시 반환
- 존재하지 않는 도구 → 에러 메시지를 Observation으로 넣고 계속
- 도구 실행 오류 → 예외를 잡아서 Observation으로 변환
- 반복 한도 초과 → 안전하게 종료
배운 점: 실제로 동작하는 에이전트를 만들려면 "정상 케이스"보다 "비정상 케이스"를 처리하는 코드가 훨씬 많아진다는 걸 체감했다. LLM은 형식을 항상 완벽히 지키지 않고, 존재하지 않는 도구 이름을 지어내기도 한다. 이런 실패를 감안한 루프 설계가 프레임워크 안에서는 보이지 않던 부분이었다.
실행 결과 예시 (미국 블록체인 시장 규모 질문):
Thought: 미국의 블록체인 시장 규모에 대한 최신 동향과 수치를 확인하기 위해 관련 도구를 사용해야 합니다.
Action: usa_blockchain_trend_searcher
Action Input: 미국 블록체인 시장 규모 및 성장 동향
Observation: 2023년 기준 미국 블록체인 시장 규모는 약 170억 달러로 추정되며, 연평균 성장률(CAGR)은 약 35%로...
Thought: 이제 최종 답변을 알겠습니다.
Final Answer: 2023년 기준 미국의 블록체인 시장 규모는 약 170억 달러로 추정되며...7. Gradio로 웹 데모 만들기
마지막으로 대화형 UI를 붙였다. 채팅창과 별개로 ReAct 실행 로그를 보여주는 패널을 만들어서, 에이전트가 어떤 사고 과정을 거쳐 답을 냈는지 투명하게 확인할 수 있게 했다.
def chat_with_agent(message, history):
try:
response, execution_log = conversation_manager.process_message(message)
except Exception as e:
err_msg = f"[오류] {type(e).__name__}: {e}"
response = err_msg
execution_log = err_msg
history = (history or []) + [
{"role": "user", "content": message},
{"role": "assistant", "content": response},
]
return "", history, execution_log
def clear_chat_history():
conversation_manager.clear_history()
return [], ""
with gr.Blocks() as demo:
gr.Markdown("# ReACT 에이전트")
gr.Markdown("일본과 미국의 ICT 정책 또는 미국의 블록체인 동향에 관한 질문을 해보세요.")
with gr.Row():
with gr.Column(scale=2):
chatbot = gr.Chatbot(height=500)
msg = gr.Textbox(label="질문을 입력하세요", placeholder="예: 일본의 AI 정책에 대해 알려줘")
clear = gr.Button("대화 초기화")
with gr.Column(scale=1):
logs = gr.Textbox(label="ReACT 실행 로그", lines=25, max_lines=25)
msg.submit(chat_with_agent, inputs=[msg, chatbot], outputs=[msg, chatbot, logs])
clear.click(clear_chat_history, inputs=None, outputs=[chatbot, logs])
demo.launch(debug=True)배운 점: 실행 로그를 별도 패널로 노출한 게 의외로 중요했다. 에이전트가 왜 특정 도구를 선택했는지, 어떤 검색 결과를 근거로 답을 냈는지가 눈에 보이니까 디버깅과 신뢰성 검증이 훨씬 쉬워졌다. "설명 가능한 AI" 관점에서 실행 과정을 투명하게 보여주는 게 단순히 답만 주는 것보다 실무적으로 훨씬 유용하다는 걸 느꼈다. 우
웹 데모
전체적으로 배운 점
- ReAct는 프레임워크가 아니라 패턴이다. Thought-Action-Observation
사이클, 정규식 파서, 실행 루프까지 전부 일반적인 Python 코드로 구현
가능했다. LangChain의
AgentExecutor는 이 반복적인 작업을 대신 해주는 것뿐이라는 걸 직접 만들어보고 이해했다. - 도구 description이 곧 라우팅 로직이다. 에이전트는 코드가 아니라 텍스트 설명을 보고 판단한다. description을 정성껏 쓰는 게 프롬프트 엔지니어링만큼 중요하다.
- 실패 케이스 처리가 코드의 절반이다. 파싱 실패, 존재하지 않는 도구, 도구 실행 오류, 반복 초과 — 이런 것들을 처리하지 않으면 데모에서는 되는데 실서비스에서는 바로 터진다.
- 컨텍스트 길이 관리는 검색 성능만큼 중요하다. 검색 결과를 그대로 프롬프트에 넣으면 안 되고, truncation과 개수 제한 같은 장치가 필요하다.
다음에 보완할 것
- 멀티턴 대화 컨텍스트를 실제로 어떻게 프롬프트에 반영하는지
(
conversation_manager내부)를 더 명확히 정리해야 한다.