LLM 엔지니어링9장 · 도구를 쓰는 LLM 과 Agentic Workflow16 / 21← 이전목차다음 →MetaContents

9장. 도구를 쓰는 LLM 과 Agentic Workflow

실습 4 — 자율 루프 만들기

실습 3의 도구로 자율 루프를 만들어, "회식비 한도를 찾고 인원수를 곱하기"처럼 도구를 두 번 이상 써야 하는 질문을 풀게 합니다. 걸음마다 무엇을 했는지 출력해 봅니다.

1단계. 실습 3과 같은 준비

Python
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")
MODEL = "qwen2.5:3b"
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": lambda expression: safe_calc(expression), "search_rules": search_rules, "get_today": lambda: "2026-10-27"}
def tool(name, desc, props=None, req=None):
    return {"type": "function", "function": {"name": name, "description": desc,
            "parameters": {"type": "object", "properties": props or {}, "required": req or []}}}
TOOLS = [tool("calculate", "사칙연산 식을 계산한다. 숫자 계산이 필요하면 반드시 이 도구를 쓴다.",
              {"expression": {"type": "string", "description": "계산할 식. 예: 5*12"}}, ["expression"]),
         tool("search_rules", "솔내기술(가상 회사)의 사내 규정(근무, 휴가, 출장, 경비, 복지, 교육, 보안)을 찾는다.",
              {"query": {"type": "string", "description": "찾을 내용. 예: 회식비 한도"}}, ["query"]),
         tool("get_today", "오늘 날짜를 YYYY-MM-DD 로 돌려준다.")]
SYSTEM = ("당신은 솔내기술(가상 회사)의 사내 도우미입니다. 규정은 search_rules 로 찾아 확인하고, 계산은 calculate 로 하세요. "
          "도구로 확인한 내용만으로 짧게 답하세요.")
print("도구:", [t["function"]["name"] for t in TOOLS])
출력
도구: ['calculate', 'search_rules', 'get_today']

2단계. 루프 함수

Python
def run_agent(question, max_steps=6, show=True):
    """도구 호출이 없을 때까지 '묻기 → 실행 → 결과 덧붙이기'를 되풀이한다"""
    messages = [{"role": "system", "content": SYSTEM}, {"role": "user", "content": question}]
    for step in range(1, max_steps + 1):
        msg = client.chat.completions.create(model=MODEL, messages=messages, tools=TOOLS, temperature=0, seed=42).choices[0].message
        if not msg.tool_calls:                                   # 도구를 더 부르지 않으면 끝
            return msg.content, step
        messages.append({"role": "assistant", "content": msg.content or "", "tool_calls": [tc.model_dump() for tc in msg.tool_calls]})
        for tc in msg.tool_calls:
            try:
                args = json.loads(tc.function.arguments)
                result = IMPL[tc.function.name](**args)
            except Exception as e:                                # 이름이 없거나 인자가 틀리면 오류를 돌려준다
                result = {"오류": str(e)[:80]}
            if show:
                print(f"  걸음 {step}: {tc.function.name}({tc.function.arguments}) → {str(result)[:70]}")
            messages.append({"role": "tool", "tool_call_id": tc.id, "content": json.dumps(result, ensure_ascii=False)})
    return "[걸음 한도에 닿았습니다]", max_steps

answer, steps = run_agent("37 곱하기 48 은?")
print("답:", answer, "· 걸음:", steps)
출력
  걸음 1: calculate({"expression":"37*48"}) → 1776
답: 37 곱하기 48은 1776입니다. · 걸음: 2

3단계. 두 걸음 이상 필요한 질문

Python
for q in ["팀원 6명이 회식하면 회식비는 최대 얼마까지 쓸 수 있어요?", "해외 출장을 3일 가면 일비는 모두 몇 달러예요?"]:
    print("질문:", q)
    start = time.time()
    answer, steps = run_agent(q)
    print("답:", answer.replace("\n", " ")[:120], f"· 걸음 {steps} · {time.time() - start:.1f}초")
출력
질문: 팀원 6명이 회식하면 회식비는 최대 얼마까지 쓸 수 있어요?
  걸음 1: search_rules({"query":"회식비 한도"}) → ['제15조(회식비) 팀 회식비는 한 사람에 한 번 5만원까지, 한 달에 두 번까지 씁니다.', '제16조(야근 식대) 오후
답: 팀원 6명이 회식하면 회식비는 최대 30만원까지 쓸 수 있습니다. · 걸음 2 · 1.7초
질문: 해외 출장을 3일 가면 일비는 모두 몇 달러예요?
  걸음 1: search_rules({"query":"출장 일비 해외"}) → ['제11조(해외 출장비) 해외 출장의 일비는 하루 60달러입니다. 숙박비는 지역 등급에 따라 하룻밤 150달러에서 250달러
답: 해당 규정에 따르면 해외 출장의 일비는 하루 60달러입니다. 따라서 3일간의 해외 출장 비용은 180달러가 됩니다. · 걸음 2 · 2.5초

4단계. 걸음 한도에 닿으면

Python
# 걸음 한도를 1로 줄이면, 검색만 하고 계산까지 가지 못한다
answer, steps = run_agent("팀원 6명이 회식하면 회식비는 최대 얼마까지 쓸 수 있어요?", max_steps=1)
print("답:", answer, "· 걸음:", steps)
출력
  걸음 1: search_rules({"query":"회식비 한도"}) → ['제15조(회식비) 팀 회식비는 한 사람에 한 번 5만원까지, 한 달에 두 번까지 씁니다.', '제16조(야근 식대) 오후
답: [걸음 한도에 닿았습니다] · 걸음: 1

이 실습이 뜻하는 것

이 실습은 심부름꾼에게 "일이 끝날 때까지 알아서 오가되, 정한 횟수를 넘기지는 말라"고 맡기는 일과 같습니다. 실습 3의 세 도구를 그대로 준비합니다(1단계). "묻기, 도구 쓰기, 결과 보고 다시 묻기"를 도구를 더 부르지 않을 때까지 되풀이하는 함수를 만들고 곱셈으로 시험합니다(2단계). 규정을 찾고 곱해야 하는 질문 둘을 풉니다(3단계). 오가는 횟수를 한 번으로 줄여, 일을 끝내지 못하면 어떻게 되는지 봅니다(4단계). 여기서 심부름꾼이 run_agent, 한 번 오가는 것이 걸음, 정한 횟수가 max_steps 입니다.

단계출력뜻
1. 도구['calculate', 'search_rules', 'get_today']실습 3과 같은 도구 셋이 준비되었습니다
2. 37 곱하기 48걸음 1 계산기 → 1776, 걸음 2걸음 1에서 계산기를 쓰고 걸음 2에서 답을 썼습니다. 도구 한 번과 답 한 번입니다
3. 회식비제15조 "한 사람에 한 번 5만원까지" → "최대 30만원", 걸음 2, 1.7초규정을 찾아 6명 × 5만원 = 30만원으로 답했습니다. 다만 곱셈은 계산기 없이 모델이 했습니다
3. 해외 출장 일비제11조 "하루 60달러" → "180달러", 걸음 2, 2.5초이것도 맞혔고(60달러 × 3일), 역시 곱셈은 모델이 스스로 했습니다
4. 걸음 한도 1[걸음 한도에 닿았습니다]검색만 하고 답을 쓸 차례가 오지 않았습니다. 한도에 닿으면 답인 척하지 않고 실패라고 알립니다

한 줄로 줄이면, 같은 모델이라도 "결과를 보고 다시 묻는" 루프를 씌우면 여러 걸음이 필요한 질문을 풀 수 있다는 것을 보인 실습입니다. 지시문에 "계산은 calculate 로"라고 적었는데도 모델이 암산했으므로, 이번에 맞았다고 늘 맞는다는 뜻은 아닙니다. 실습 5에서는 같은 일을 글로 하는 ReAct 로 해 보고, 실습 6에서 이 루프에 안전장치를 붙입니다.

코드 설명

  • run_agent 는 본문 "자율 루프"의 뼈대에 실제 Ollama 호출을 넣은 것입니다. 도구 호출이 없는 응답이 오면 그 글을 답으로 돌려주고, 한도까지 돌면 "[걸음 한도에 닿았습니다]"를 돌려줍니다.
  • "37 곱하기 48"은 걸음 1에서 계산기를 부르고(1776), 걸음 2에서 도구 없이 답해 끝났습니다. 걸음 수 2 는 "도구 한 번 + 답 한 번"입니다.
  • 회식비 질문은 걸음 1에서 규정을 찾아 "한 사람에 한 번 5만원까지"를 보고, 걸음 2에서 "최대 30만원"이라고 답했습니다. 해외 출장도 "하루 60달러"를 찾아 "180달러"로 답했습니다. 둘 다 2초 안팎입니다. 다만 모델은 곱셈을 계산기에 맡기지 않고 스스로 했습니다. 이번에는 맞았지만, 지시문에 "계산은 calculate 로"라고 적어도 모델이 따르지 않을 수 있다는 것을 보여 줍니다.
  • 걸음 한도를 1로 줄이자 검색만 하고 "[걸음 한도에 닿았습니다]"로 끝났습니다. 한도에 닿으면 마지막 글을 답인 척 내보내지 않고 실패로 알립니다.
← 이전 절9장 · 실습 3 — 도구 여러 개에서 고르기다음 절 →9장 · 실습 5 — 글로 하는 ReAct