9장. 도구를 쓰는 LLM 과 Agentic Workflow
실습 5 — 글로 하는 ReAct
도구 호출을 지원하지 않는 gemma3:1b 로도 도구를 쓰게 하려면, 생각·행동·관찰을 글로 적게 하는 ReAct 를 씁니다. 같은 루프를 qwen2.5:3b 에도 돌려 견줍니다. ollama pull gemma3:1b(7장)로 받아 두어야 합니다.
1단계. 실습 4와 같은 도구와 ReAct 지시문
import ast, json, operator, re, time
import numpy as np
from sentence_transformers import SentenceTransformer
from openai import OpenAI
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv}
def safe_calc(expression):
def walk(node):
if isinstance(node, ast.Expression):
return walk(node.body)
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return node.value
if isinstance(node, ast.BinOp) and type(node.op) in OPS:
return OPS[type(node.op)](walk(node.left), walk(node.right))
if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub):
return -walk(node.operand)
raise ValueError("숫자와 + - * / 괄호만 쓸 수 있습니다")
return walk(ast.parse(expression, mode="eval"))
rules = [l for l in open("company_rules.txt", encoding="utf-8").read().split("\n") if re.match(r"^제\d+조\(", l)]
emb = SentenceTransformer("intfloat/multilingual-e5-small", device="cpu")
R = emb.encode(["passage: " + r for r in rules], normalize_embeddings=True)
def search_rules(query):
q = emb.encode("query: " + query, normalize_embeddings=True)
return [rules[i] for i in np.argsort(-(R @ q))[:2]]
IMPL = {"calculate": safe_calc, "search_rules": search_rules}
# 도구 설명과 글의 꼴을 지시문에 적는다(API 의 tools 대신)
REACT = """당신은 솔내기술(가상 회사)의 사내 도우미입니다. 아래 도구를 쓸 수 있습니다.
- search_rules: 사내 규정을 찾는다. 입력은 찾을 낱말(예: 회식비)
- calculate: 사칙연산 식을 계산한다. 입력은 식(예: 5*12)
반드시 아래 꼴로 한 번에 한 걸음씩 쓰세요.
Thought: 다음에 할 일
Action: 도구 이름
Action Input: 도구에 넣을 값
도구 결과는 Observation: 으로 주어집니다. 답을 알면 아래처럼 끝내세요.
Thought: 답을 안다
Final Answer: 최종 답"""
print(REACT.count("\n") + 1, "줄의 지시문")12 줄의 지시문2단계. ReAct 루프
def run_react(model, question, max_steps=5, show=True):
"""모델이 쓴 글에서 Action 을 뽑아 실행하고, Observation 을 붙여 다시 보낸다"""
transcript = f"Question: {question}\n"
for step in range(1, max_steps + 1):
resp = client.chat.completions.create(
model=model, temperature=0, seed=42, max_tokens=200,
messages=[{"role": "system", "content": REACT}, {"role": "user", "content": transcript}],
stop=["Observation:"]) # 모델이 관찰을 지어 쓰기 전에 멈춘다
text = resp.choices[0].message.content.strip()
transcript += text + "\n"
final = re.search(r"Final Answer:\s*(.+)", text, re.S)
if final:
return final.group(1).strip(), step
act = re.search(r"Action:\s*(\w+)\s*\n?\s*Action Input:\s*(.+)", text)
if not act: # 꼴을 어기면 알려 주고 다시 쓰게 한다
observation = "꼴이 틀렸습니다. Action 과 Action Input 을 쓰거나 Final Answer 로 끝내세요."
elif act.group(1) not in IMPL:
observation = f"{act.group(1)} 라는 도구는 없습니다."
else:
try:
observation = str(IMPL[act.group(1)](act.group(2).strip().strip('"')))
except Exception as e:
observation = f"오류: {e}"
if show:
print(f" 걸음 {step}: {text.replace(chr(10), ' | ')[:110]}")
print(f" Observation: {observation[:80]}")
transcript += f"Observation: {observation}\n"
return "[걸음 한도에 닿았습니다]", max_steps
answer, steps = run_react("qwen2.5:3b", "팀원 6명이 회식하면 회식비는 최대 얼마까지 쓸 수 있어요?")
print("qwen2.5:3b 답:", answer, "· 걸음:", steps) 걸음 1: Thought: 회식비를 계산하려면, 팀원 수와 회식 비용을 계산하는 식이 필요합니다. 일반적으로 회식 비용은 팀원 수를 기준으로 합니다. 예를 들어, 1명당 1000원을 지불한다고 가정하면, 6명
Observation: 6000
qwen2.5:3b 답: 팀원 6명이 회식하면 최대 6000원까지 회식비를 쓸 수 있습니다. · 걸음: 23단계. 도구를 지원하지 않는 모델로
answer, steps = run_react("gemma3:1b", "팀원 6명이 회식하면 회식비는 최대 얼마까지 쓸 수 있어요?")
print("gemma3:1b 답:", answer, "· 걸음:", steps) 걸음 1: Thought: 다음에 할 일은 사내 규정을 찾아 회식비에 대한 정보를 확인하는 것입니다. | Action: search_rules | Action Input: 회식비 규정
Observation: ['제15조(회식비) 팀 회식비는 한 사람에 한 번 5만원까지, 한 달에 두 번까지 씁니다.', '제16조(야근 식대) 오후 8시가 넘도록 일하
걸음 2: Thought: 다음에 할 일은 사내 규정을 찾아 회식비에 대한 정보를 확인하는 것입니다. | Action: search_fix | Action Input: 회식비 규정
Observation: search_fix 라는 도구는 없습니다.
걸음 3: Thought: 다음에 할 일은 사내 규정을 찾아 회식비에 대한 정보를 확인하는 것입니다. | Action: search_rules | Action Input: 회식비 규정
Observation: ['제15조(회식비) 팀 회식비는 한 사람에 한 번 5만원까지, 한 달에 두 번까지 씁니다.', '제16조(야근 식대) 오후 8시가 넘도록 일하
걸음 4: Thought: 다음에 할 일은 사내 규정을 찾아 회식비에 대한 정보를 확인하는 것입니다. | Action: search_rules | Action Input: 회식비 규정
Observation: ['제15조(회식비) 팀 회식비는 한 사람에 한 번 5만원까지, 한 달에 두 번까지 씁니다.', '제16조(야근 식대) 오후 8시가 넘도록 일하
걸음 5: Thought: 다음에 할 일은 사내 규정을 찾아 회식비에 대한 정보를 확인하는 것입니다. | Action: search_rules | Action Input: 회식비 규정
Observation: ['제15조(회식비) 팀 회식비는 한 사람에 한 번 5만원까지, 한 달에 두 번까지 씁니다.', '제16조(야근 식대) 오후 8시가 넘도록 일하
gemma3:1b 답: [걸음 한도에 닿았습니다] · 걸음: 54단계. 세 질문으로 견주기
tests = [("팀원 6명이 회식하면 회식비는 최대 얼마까지 쓸 수 있어요?", "30만"),
("해외 출장을 3일 가면 일비는 모두 몇 달러예요?", "180"),
("재택 근무는 일주일에 며칠까지 돼요?", "이틀|2일")]
for model in ["qwen2.5:3b", "gemma3:1b"]:
start, ok, total_steps = time.time(), 0, 0
for q, key in tests:
answer, steps = run_react(model, q, show=False)
ok += any(k in answer.replace(",", "").replace("300000", "30만") for k in key.split("|"))
total_steps += steps
print(f"{model:11s} 맞힘 {ok}/3 · 걸음 합 {total_steps} · {time.time() - start:.0f}초")qwen2.5:3b 맞힘 1/3 · 걸음 합 10 · 22초
gemma3:1b 맞힘 0/3 · 걸음 합 3 · 2초이 실습이 뜻하는 것
이 실습은 전화가 없는 사람과 정해진 꼴의 쪽지만 주고받으며 일하는 것과 같습니다. 쪽지에 "생각, 할 일, 넣을 값"을 정해진 꼴로 쓰라는 규칙을 적어 줍니다(1단계). 쪽지를 받으면 우리가 그 일을 대신 하고 "결과"를 적어 다시 넘기는 일을 되풀이합니다(2단계). 도구 호출 기능이 없는 작은 모델에게도 같은 쪽지 주고받기를 시키고(3단계), 세 질문으로 두 모델을 견줍니다(4단계). 여기서 이 방식이 ReAct(Reasoning and Acting, 생각하고 행동하기), 정해진 꼴의 쪽지가 Thought·Action·Action Input, 우리가 적어 주는 결과가 Observation, 일이 끝났다는 쪽지가 Final Answer 입니다. 비유와 다른 점은, 모델은 결과 칸까지 스스로 지어 쓰려 하므로 stop=["Observation:"] 으로 그 자리에서 끊어야 한다는 것입니다.
| 단계 | 출력 | 뜻 |
|---|---|---|
| 1. 지시문 | 12 줄의 지시문 | 도구 설명과 글의 꼴을 API(Application Programming Interface)의 tools 대신 지시문 12줄에 적었습니다 |
| 2. qwen2.5:3b 걸음 1 | "1명당 1000원을 지불한다고 가정하면" → 6000 | 규정을 찾지 않고 금액을 지어낸 뒤 계산기만 썼습니다 |
| 2. qwen2.5:3b 답 | "최대 6000원", 걸음 2 | 틀린 답입니다. 실습 4의 도구 호출로는 30만원을 맞혔던 질문입니다 |
| 3. gemma3:1b 걸음 1 | search_rules, 회식비 조항 | 도구 호출 기능이 없는 모델도 글의 꼴로 규정 검색을 불렀고, 5만원 조항까지 찾았습니다 |
| 3. gemma3:1b 걸음 2~5 | search_fix(없는 도구), 같은 검색 되풀이 | 결과를 보고도 다음으로 나아가지 못하고 같은 일을 되풀이했습니다 |
| 3. gemma3:1b 답 | [걸음 한도에 닿았습니다], 걸음 5 | 다섯 걸음 한도에 닿아 답하지 못했습니다. 한도가 없었다면 끝없이 돌았을 것입니다 |
| 4. qwen2.5:3b | 맞힘 1/3, 걸음 합 10, 22초 | 세 질문 가운데 하나만 맞혔습니다 |
| 4. gemma3:1b | 맞힘 0/3, 걸음 합 3, 2초 | 걸음 합이 3이니 세 질문 모두 한 걸음 만에 Final Answer 로 끝났지만, 맞힌 것은 없습니다. 3단계에서 같은 회식비 질문에 다섯 걸음을 돈 것과 달라, 실행마다 움직임이 달라질 수 있음을 보여 줍니다 |
한 줄로 줄이면, ReAct 는 도구 호출 기능이 없는 모델에게도 도구를 쥐여 주는 방법이지만, 작은 모델은 꼴을 지키고 결과를 이어 쓰는 힘이 모자라 잘 되지 않는다는 것을 보인 실습입니다. 세 질문으로 낸 결과라 맞힌 수 하나하나보다 "도구 호출을 지원하는 모델이 있으면 그쪽을 쓴다"는 흐름으로 읽습니다. 다음 실습 6에서는 실습 4의 도구 호출 루프로 돌아가 안전장치를 붙입니다.
코드 설명
stop=["Observation:"]은 모델이 "Observation:"을 쓰려는 순간 생성을 멈춥니다. 관찰은 우리 코드가 실제로 실행한 결과로 채워야 하는데, 막지 않으면 모델이 결과를 지어 씁니다.- 글로 하는 ReAct 에서
qwen2.5:3b는 규정을 찾지 않고 "1명당 1000원을 지불한다고 가정하면"이라고 생각을 지어낸 뒤 계산기로 6000 을 구해 "최대 6000원"이라고 틀리게 답했습니다. 같은 모델이 실습 4의 도구 호출로는 30만원을 맞혔으니, 이 모델에게는 API 의 도구 호출이 글의 꼴보다 훨씬 믿을 만합니다. - 도구를 지원하지 않는
gemma3:1b도 ReAct 의 꼴로search_rules를 불렀고, 회식비 조항(5만원)까지 찾았습니다. 그러나 결과를 보고도 같은 검색을 되풀이하고, 한 번은 없는 도구(search_fix)를 불러, 다섯 걸음 한도에 닿아 답하지 못했습니다. 걸음 한도가 없었다면 끝없이 돌았을 것입니다. - 세 질문에서
qwen2.5:3b는 1개,gemma3:1b는 0개를 맞혔습니다. ReAct 는 아무 모델에나 도구를 쥐여 주는 방법이지만, 꼴을 지키고 결과를 이어 쓰는 힘이 모자란 작은 모델에서는 잘 되지 않습니다. 도구 호출을 지원하는 모델이 있으면 그쪽을 씁니다.