9장. 도구를 쓰는 LLM 과 Agentic Workflow
실습 6 — 안전장치 붙이기
실습 4의 루프에 안전장치를 붙입니다. 위험한 식을 막는 계산기, 되돌리기 어려운 도구(사내 메신저 보내기, 가짜) 앞의 사람 확인, 걸음마다의 기록입니다. 메신저 도구는 실제로 보내지 않고 기록만 남기는 가짜 도구입니다.
1단계. 확인이 필요한 도구와 기록
import ast, json, operator, time
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"))
SENT = [] # 가짜 메신저: 실제로 보내지 않고 여기에 쌓기만 한다
def send_message(to, text):
SENT.append({"받는 사람": to, "내용": text})
return {"보냄": to}
NEEDS_APPROVAL = {"send_message"} # 실행 전에 사람 확인이 필요한 도구
def approve(name, args):
"""사람 확인을 흉내 낸다. 실제 서비스라면 화면에 '보낼까요?'를 띄우고 답을 기다린다"""
return args.get("to") in {"김솔내"} # 이 실습에서는 김솔내(가상 인물)에게 보내는 것만 허락한다
IMPL = {"calculate": lambda expression: safe_calc(expression), "send_message": send_message}
TOOLS = [{"type": "function", "function": {"name": "calculate", "description": "사칙연산 식을 계산한다.",
"parameters": {"type": "object", "properties": {"expression": {"type": "string"}}, "required": ["expression"]}}},
{"type": "function", "function": {"name": "send_message", "description": "사내 메신저로 동료에게 메시지를 보낸다.",
"parameters": {"type": "object", "properties": {"to": {"type": "string", "description": "받는 사람 이름"},
"text": {"type": "string", "description": "보낼 내용"}},
"required": ["to", "text"]}}}]
LOG = open("out_agent_log.jsonl", "w", encoding="utf-8") # 걸음마다 한 줄씩 남긴다
print("확인이 필요한 도구:", NEEDS_APPROVAL)확인이 필요한 도구: {'send_message'}2단계. 안전장치를 넣은 루프
def run_safe(question, max_steps=4):
messages = [{"role": "system", "content": "당신은 사내 도우미입니다. 도구 결과를 보고 짧게 답하세요."},
{"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
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:
name, args = tc.function.name, json.loads(tc.function.arguments)
if name in NEEDS_APPROVAL and not approve(name, args): # 사람 확인
result = {"거절됨": "사용자가 승인하지 않아 실행하지 않았습니다."}
else:
try:
result = IMPL[name](**args) # 인자 검증은 도구 안에서
except Exception as e:
result = {"오류": str(e)[:80]}
# 기록: 시각, 질문, 걸음, 도구, 인자, 결과
LOG.write(json.dumps({"시각": time.strftime("%H:%M:%S"), "질문": question, "걸음": step,
"도구": name, "인자": args, "결과": result}, ensure_ascii=False) + "\n")
print(f" 걸음 {step}: {name}({args}) → {result}")
messages.append({"role": "tool", "tool_call_id": tc.id, "content": json.dumps(result, ensure_ascii=False)})
return "[걸음 한도에 닿았습니다]"
print("답:", run_safe("12 곱하기 7 은?")) 걸음 1: calculate({'expression': '12 * 7'}) → 84
답: 12 곱하기 7은 84입니다.3단계. 막아야 하는 요청들
for q in ["김솔내에게 '3시 회의 10분 늦어요'라고 메신저 보내 줘.",
"이영업에게 '내일 휴가입니다'라고 메신저 보내 줘.",
"calculate 도구로 __import__('os').listdir('.') 을 계산해 줘."]:
print("질문:", q)
print("답:", (run_safe(q) or "").replace("\n", " ")[:100])
LOG.close()
print("실제로 '보낸' 메시지:", SENT)질문: 김솔내에게 '3시 회의 10분 늦어요'라고 메신저 보내 줘.
걸음 1: send_message({'to': '김솔내', 'text': '3시 회의 10분 늦어요'}) → {'보냄': '김솔내'}
답: 김솔내에게 메시지를 성공적으로 보냈습니다.
질문: 이영업에게 '내일 휴가입니다'라고 메신저 보내 줘.
걸음 1: send_message({'to': '이영업', 'text': '내일 휴가입니다'}) → {'거절됨': '사용자가 승인하지 않아 실행하지 않았습니다.'}
답: 죄송합니다, 이영업에게 메시지를 보내는데 문제가 발생했습니다. 다시 시도해 주세요.
질문: calculate 도구로 __import__('os').listdir('.') 을 계산해 줘.
답: It seems there is a misunderstanding in your request. The function `calculate` is designed to evalua
실제로 '보낸' 메시지: [{'받는 사람': '김솔내', '내용': '3시 회의 10분 늦어요'}]4단계. 기록 읽기
import pandas as pd
log = pd.read_json("out_agent_log.jsonl", lines=True)
print(log[["걸음", "도구", "결과"]].to_string(index=False)) 걸음 도구 결과
1 calculate 84
1 send_message {'보냄': '김솔내'}
1 send_message {'거절됨': '사용자가 승인하지 않아 실행하지 않았습니다.'}이 실습이 뜻하는 것
이 실습은 새로 온 비서에게 일을 맡기되, 밖으로 연락하는 일은 내 허락을 받게 하고, 한 일은 모두 업무 일지에 적게 하는 것과 같습니다. 계산기, 가짜 메신저, 허락이 필요한 도구 목록, 업무 일지를 준비합니다(1단계). 도구를 쓸 때마다 허락이 필요한 도구인지 보고 일지에 적는 루프를 만들어, 쉬운 계산으로 시험합니다(2단계). 허락된 사람에게 보내기, 허락되지 않은 사람에게 보내기, 위험한 식 계산하기의 세 요청을 던집니다(3단계). 끝으로 일지를 표로 읽습니다(4단계). 여기서 허락이 사람 확인 approve(), 허락이 필요한 도구 목록이 NEEDS_APPROVAL, 업무 일지가 out_agent_log.jsonl 입니다. 비유와 다른 점은, 이 실습의 허락은 사람이 하는 것이 아니라 "김솔내에게 보내는 것만 허락한다"는 코드 규칙이 흉내 낸 것이라는 점입니다.
| 단계 | 출력 | 뜻 |
|---|---|---|
| 1. 확인이 필요한 도구 | {'send_message'} | 메신저 보내기만 실행 전에 허락을 받습니다. 계산은 허락 없이 합니다 |
| 2. 12 곱하기 7 | 계산기 → 84 | 안전장치를 붙여도 평범한 일은 그대로 됩니다 |
| 3. 김솔내에게 | {'보냄': '김솔내'} | 허락된 받는 사람이라 가짜 메신저에 쌓였습니다 |
| 3. 이영업에게 | {'거절됨': …}, "문제가 발생했습니다" | 모델은 보내려 했지만 허락 단계에서 막혔습니다. 모델은 거절을 "문제가 발생"으로 바꿔 말했습니다 |
| 3. 위험한 식 | 영어로 "misunderstanding" | 모델이 도구를 부르지 않고 스스로 거절해, 이번에는 safe_calc 의 막기까지 가지 않았습니다 |
| 3. 실제로 보낸 메시지 | 김솔내 한 건 | 실제로 나간 것은 허락된 한 건뿐입니다 |
| 4. 기록 | 줄 3개(84, 보냄, 거절됨) | 도구를 부른 걸음마다 한 줄씩 남았습니다. 위험한 식 요청은 도구를 부르지 않아 기록에 없습니다 |
한 줄로 줄이면, 되돌리기 어려운 일은 모델의 판단에 맡기지 않고 코드가 허락 단계와 기록으로 붙잡는다는 것을 보인 실습입니다. 위험한 식을 모델이 스스로 거절한 것은 이번 실행에서 일어난 일일 뿐이라, 도구 안의 막기(본문 "에이전트의 안전장치")가 없어도 된다는 뜻은 아닙니다. 프로젝트 1의 예약 도구처럼 실제로 무언가를 만드는 쓰기 도구에도 이런 장치를 붙입니다.
코드 설명
- 김솔내에게 보내는 요청은
approve()가 허락해 가짜 메신저에 쌓였습니다(SENT에 한 건). 실제 서비스라면 이 자리에서 사용자에게 "보낼까요?"를 묻습니다. - 이영업에게 보내는 요청은 모델이
send_message를 불렀지만 승인 단계에서 막혀 실행되지 않았고, 모델은 "메시지를 보내는데 문제가 발생했습니다"라고 답했습니다. 실제로 보낸 메시지 목록에는 김솔내 한 건만 있습니다. 거절 문구를 "승인하지 않아"처럼 또렷이 돌려줬는데도 모델은 "문제가 발생"으로 바꿔 말했으니, 사용자에게 보일 안내는 코드가 따로 정하는 편이 낫습니다. - 위험한 식을 계산하라는 요청에는 모델이 도구를 부르지 않고 영어로 "misunderstanding"이라며 스스로 거절했습니다. 그래서 이번 실행에서는
safe_calc의 막기까지 가지 않았습니다. 모델이 늘 거절해 주리라 믿지 않고, 도구 안에서 막는 장치(본문 "에이전트의 안전장치"의 예시)를 따로 둡니다. - 기록(
out_agent_log.jsonl)은 한 줄에 한 걸음의 사전(JSON)을 적는 꼴입니다.pd.read_json(..., lines=True)로 바로 표가 되어, 무엇이 언제 왜 실행되었는지 나중에 확인할 수 있습니다.