9장. 도구를 쓰는 LLM 과 Agentic Workflow
ReAct
수학 문제를 풀 때 "먼저 넓이를 구하자(생각) → 공식을 쓴다(행동) → 24 가 나왔다(관찰) → 이제 둘레를 구하자(생각)"처럼 머릿속 생각을 적어 가며 풀면 실수가 줄고, 남이 봐도 어디서 틀렸는지 압니다. ReAct 는 LLM 이 이렇게 생각과 행동을 번갈아 적게 하는 방법입니다.
세 카드가 ReAct 의 한 바퀴입니다. 생각(Thought)에서 다음 할 일을 글로 적고, 행동(Action)에서 도구 이름과 입력을 정해진 꼴로 적고, 관찰(Observation)에는 우리 코드가 도구를 실행한 결과를 붙여 줍니다. 답을 알면 "Final Answer:"로 끝냅니다. 여기서 풀이 노트가 ReAct 의 글입니다.
도구 호출과 ReAct
| 견줄 것 | 도구 호출(function calling) | ReAct(글로 적기) |
|---|---|---|
| 도구를 부르는 법 | API 의 tools, tool_calls | 정해진 꼴의 글(Action: 도구) |
| 필요한 모델 | 도구를 지원하는 모델 | 아무 대화 모델 |
| 꼴이 깨질 위험 | 적다(API 가 맞춘다) | 크다(우리가 글을 해석한다) |
| 생각이 보이나 | 보통 안 보인다 | 생각이 글로 남는다 |
ReAct 의 글 해석하기
모델이 쓴 글에서 행동 부분을 정규식으로 뽑아냅니다.
import re
reply = """Thought: 회식비 한도를 먼저 찾아야 한다.
Action: search_rules
Action Input: 회식비"""
m = re.search(r"Action:\s*(\w+)\s*Action Input:\s*(.+)", reply)
if m:
print("도구:", m.group(1), "· 입력:", m.group(2).strip())
final = re.search(r"Final Answer:\s*(.+)", reply)
print("최종 답:", final.group(1) if final else "아직 없음")도구: search_rules · 입력: 회식비 와 최종 답: 아직 없음 이 나옵니다. 우리 코드가 search_rules 를 실행해 "Observation: …"을 붙여 다시 보내면 모델이 다음 생각을 이어 씁니다.
코드에 나오는 함수
| 코드 | 쉬운 뜻 |
|---|---|
import re | re 는 regular expression(정규식)의 줄임말이다. 글자의 무늬를 찾는 표준 라이브러리다 |
re.search(무늬, 글) | 글에서 무늬에 맞는 첫 곳을 찾는다. 없으면 None 을 돌려준다 |
r"..." | raw(날것) 글자다. \s 같은 역슬래시를 파이썬이 바꾸지 않고 그대로 정규식에 넘긴다 |
\s* | 빈칸이나 줄바꿈이 0 개 이상 있다는 뜻이다. s 는 space(빈칸)다 |
(\w+) | 글자·숫자·밑줄이 1 개 이상 이어진 것을 묶어 잡는다. w 는 word(낱말)다 |
(.+) | 줄 끝까지 아무 글자나 1 개 이상 묶어 잡는다 |
m.group(1), m.group(2) | 괄호로 묶어 잡은 첫째, 둘째 조각을 꺼낸다 |
.strip() | 앞뒤의 빈칸과 줄바꿈을 떼어 낸다 |
이 코드가 뜻하는 것
식당 주문서에서 "메뉴:" 칸과 "수량:" 칸만 오려 내 주방에 넘기는 일입니다. 손님이 여백에 무엇을 적었든, 정해진 칸 이름 뒤의 글자만 읽습니다. 여기서 주문서가 reply, "메뉴:" 칸이 Action:, "수량:" 칸이 Action Input: 입니다.
| 줄 | 하는 일 | 쉽게 말하면 |
|---|---|---|
reply = """Thought: ...""" | 모델이 썼다고 치는 ReAct 글 | 손님이 쓴 주문서 |
m = re.search(r"Action:\s*(\w+)\s*Action Input:\s*(.+)", reply) | 행동과 입력을 찾아 잡는다 | "메뉴:"와 "수량:" 칸을 찾아 오린다 |
m.group(1), m.group(2).strip() | 도구 이름과 입력을 꺼낸다 | 오린 칸의 글자를 읽는다 |
final = re.search(r"Final Answer:\s*(.+)", reply) | 최종 답이 있는지 찾는다 | "주문 끝" 표시가 있나 본다 |
final.group(1) if final else "아직 없음" | 있으면 답을, 없으면 "아직 없음"을 고른다 | 끝 표시가 없으면 주문을 더 받는다 |
출력은 "search_rules 도구를 회식비로 불러 달라"는 요청이 있고 아직 답은 없다는 뜻입니다. 비유와 다른 점은, 모델은 주문서 칸 이름을 틀리게 적을 때가 있어 무늬를 못 찾는 일이 생긴다는 것입니다.
최종 답이 왔을 때
관찰을 받은 뒤 모델이 "Final Answer:"로 끝냈다고 해 봅니다. 아래 글은 설명을 위해 지어낸 것이고, 답의 내용은 7장 company_rules.txt 의 제15조와 같게 적었습니다.
import re
# 관찰을 받은 뒤 모델이 썼다고 치는 글(설명용으로 지어낸 글)
reply = """Thought: 제15조에 한도가 있다. 이제 답할 수 있다.
Final Answer: 팀 회식비는 한 사람에 한 번 5만원까지입니다."""
m = re.search(r"Action:\s*(\w+)\s*Action Input:\s*(.+)", reply)
print("행동:", m)
final = re.search(r"Final Answer:\s*(.+)", reply)
print("최종 답:", final.group(1) if final else "아직 없음")행동: None
최종 답: 팀 회식비는 한 사람에 한 번 5만원까지입니다.| 결과 | 읽는 법 |
|---|---|
행동: None | Action: 무늬가 없다. 부를 도구가 없다 |
최종 답: 팀 회식비는 ... | "Final Answer:" 뒤의 글을 잡았다. 루프를 끝내고 이 글을 사용자에게 보여 준다 |
ReAct 루프는 행동이 있으면 도구를 실행해 "Observation:"을 붙이고, 최종 답이 있으면 멈춥니다. 둘 다 없으면 꼴이 깨진 것이므로 다시 묻거나 실패로 셉니다.
주의할 점
- 작은 모델은 꼴을 자주 어깁니다(Action 없이 끝내기, 관찰을 스스로 지어 쓰기). 모델이 "Observation:"까지 지어 쓰지 않도록 그 글자가 나오면 생성을 멈추게 합니다(
stop인자). - 생각 글은 모델의 설명일 뿐 실제 판단의 기록이 아닐 수 있습니다. 생각을 믿기보다 행동과 결과를 확인합니다.