9장. 도구를 쓰는 LLM 과 Agentic Workflow
에이전트의 안전장치
자동차는 잘 달리는 것만큼 브레이크, 안전띠, 블랙박스가 중요합니다. 에이전트도 일을 잘하는 것만큼, 멈추고, 막고, 묻고, 기록하는 장치가 필요합니다.
네 카드가 이 장의 안전장치입니다. 걸음 한도는 끝없이 도는 것을 멈추는 브레이크, 인자 검증은 위험하거나 틀린 입력을 막는 안전띠, 사람 확인은 되돌리기 어려운 일 앞의 확인 단추, 기록은 무엇을 했는지 남기는 블랙박스입니다.
장치마다 막는 것
| 장치 | 막는 것 | 이 장에서 |
|---|---|---|
| 걸음 한도 | 끝없는 반복, 늘어나는 시간 | max_steps |
| 인자 검증 | 없는 방, 틀린 시각, 위험한 식 | 도구 안에서 확인하고 오류를 돌려준다 |
| 사람 확인 | 잘못된 예약, 보내기, 지우기 | 쓰는 도구 앞에서 승인을 받는다 |
| 기록 | "무엇을 했는지 모름" | 도구 호출과 결과를 out_ 파일에 남긴다 |
eval 을 쓰지 않는 계산기
계산기 도구를 eval(식) 으로 만들면, 모델(또는 모델을 속인 사용자)이 넘긴 식으로 아무 파이썬 코드나 실행됩니다. 숫자와 사칙연산만 허락하는 계산기를 만듭니다.
import ast, operator
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"))
print(safe_calc("(1234 + 66) * 3"))
try:
safe_calc("__import__('os').getcwd()")
except ValueError as e:
print("막음:", e)첫 줄은 3900, 둘째 줄은 "막음: 숫자와 + - * / 괄호만 쓸 수 있습니다"가 나옵니다. ast.parse 는 식을 실행하지 않고 나무 모양으로 읽기만 하므로, 허락한 마디만 계산할 수 있습니다.
코드에 나오는 함수
| 코드 | 쉬운 뜻 |
|---|---|
import ast, operator | ast 는 abstract syntax tree(추상 구문 나무)의 줄임말로, 코드를 나무 모양으로 읽는 표준 라이브러리다. operator(연산자)는 + 같은 기호를 함수로 쓰게 해 준다 |
OPS = {ast.Add: operator.add, ...} | 허락한 기호(더하기, 빼기, 곱하기, 나누기)와 실제 계산 함수를 짝지은 사전이다. 여기에 없는 기호는 계산하지 않는다 |
ast.parse(expression, mode="eval") | 식을 실행하지 않고 나무로 읽기만 한다. mode="eval" 은 식 하나만 받는다는 뜻이다 |
isinstance(node, ast.Constant) | is instance(~의 한 종류인가). 이 마디가 숫자 같은 상수(constant)인지 묻는다 |
ast.BinOp | binary operation(두 항 연산). 2 * 3 처럼 왼쪽, 기호, 오른쪽이 있는 마디다 |
ast.UnaryOp, ast.USub | unary operation(한 항 연산)과 unary subtract(앞에 붙는 빼기). -5 의 마디다 |
node.left, node.right, node.op | 두 항 연산 마디의 왼쪽, 오른쪽, 기호(operator)다 |
raise ValueError(...) | 허락하지 않은 마디를 만나면 오류를 일으켜 계산을 멈춘다 |
이 코드가 뜻하는 것
공항 보안 검색대를 떠올려 보세요. 가방을 열지 않고 엑스레이로 속을 비춰 보고, 허락 목록에 있는 물건만 통과시킵니다. 목록에 없는 물건이 하나라도 보이면 가방 전체를 막습니다. 여기서 엑스레이가 ast.parse, 가방 속 물건이 나무의 마디, 허락 목록이 숫자와 OPS 입니다.
| 줄 | 하는 일 | 쉽게 말하면 |
|---|---|---|
ast.parse(expression, mode="eval") | 식을 실행하지 않고 나무로 읽는다 | 가방을 엑스레이로 비춘다 |
walk(node) | 마디를 하나씩 따라 내려가며 본다 | 물건을 하나씩 살핀다 |
if isinstance(node, ast.Constant) ... | 숫자면 그 값을 돌려준다 | 허락된 물건이다 |
if isinstance(node, ast.BinOp) and type(node.op) in OPS | 허락한 기호면 양쪽을 계산해 잇는다 | 허락된 물건이다 |
raise ValueError(...) | 그 밖의 마디면 막는다 | 목록에 없는 물건, 통과 금지 |
safe_calc("__import__('os').getcwd()") | 함수 부르기가 든 식을 넣어 본다 | 금지 물품을 일부러 넣어 본다 |
3900 은 허락한 마디만 있어 계산됐다는 뜻이고, "막음:"은 함수 부르기 마디가 있어 계산 전에 멈췄다는 뜻입니다. 비유와 다른 점은, 검색대는 사람이 판단하지만 이 계산기는 목록에 없는 것을 예외 없이 모두 막는다는 것입니다.
나무 모양을 직접 보기
ast.parse 가 식을 어떤 나무로 읽는지 찍어 봅니다.
import ast
# 식을 실행하지 않고 나무 모양으로 읽기만 한다
tree = ast.parse("2 * 3 + 4", mode="eval")
print(ast.dump(tree.body))
print(type(tree.body).__name__, type(tree.body.op).__name__)BinOp(left=BinOp(left=Constant(value=2), op=Mult(), right=Constant(value=3)), op=Add(), right=Constant(value=4))
BinOp Add| 결과 | 읽는 법 |
|---|---|
바깥 BinOp(..., op=Add(), right=Constant(value=4)) | 맨 위 마디는 "무엇 + 4"다 |
안쪽 BinOp(left=Constant(value=2), op=Mult(), right=Constant(value=3)) | 그 "무엇"은 "2 × 3"이다. 곱하기를 먼저 하는 순서가 나무에 담겼다 |
BinOp Add | 맨 위 마디의 종류와 기호다 |
ast.dump 는 나무를 글로 쏟아 내(dump) 보여 줍니다. safe_calc 의 walk 는 이 나무를 위에서부터 따라 내려가며, 마디가 Constant 나 OPS 에 있는 BinOp 일 때만 계산합니다.
주의할 점
- 도구에 넘어오는 인자는 사용자의 입력과 같다고 보고 검증합니다. 모델이 문서나 웹에서 읽은 글에 "이 도구로 이렇게 하라"는 지시가 섞여 들어올 수 있습니다(프롬프트 주입, 20장).
- 쓰는 도구에는 되돌리는 도구(예약 취소)를 함께 두거나, 실제로 쓰기 전에 "이렇게 예약할까요?"를 묻는 단계를 둡니다.