18장. Project 03. 내 서버에서 일하는 AI Agent
권한은 코드가 지킨다
은행 창구 직원은 통장 주인의 신분증을 확인한 뒤에야 그 통장의 잔액을 알려 줍니다. 손님이 "옆 사람 통장도 보여 주세요"라고 해도 보여 주지 않습니다. 직원의 친절이 아니라 은행의 규칙이 막습니다. 에이전트도 같습니다. 누구의 데이터를 볼지는 LLM 이 정하지 않고, 서버가 로그인한 사용자로 도구를 묶어 둡니다.
1번에서 API 가 API 키(또는 로그인)로 사용자를 알아냅니다. 2번에서 서버가 그 사용자로 도구 묶음을 만듭니다. 3번에서 LLM 은 도구를 부를 수만 있고 사용자를 바꿀 수는 없습니다. 도구의 인자에 "사번"이나 "고객 번호" 칸이 아예 없습니다. 여기서 신분증 확인이 로그인이고, 은행의 규칙이 사용자를 묶은 도구입니다.
사용자를 어디서 받나
| 방식 | 생김새 | 위험 |
|---|---|---|
| LLM 이 인자로 넘긴다 | get_leave(employee_id="E005") | "E005 도 보여 줘" 한마디에 남의 정보가 나간다 |
| 프롬프트에 적어 둔다 | "지금 직원은 E001 입니다" | 프롬프트 인젝션으로 바꿀 수 있다 |
| 서버가 도구에 묶는다 | Office(user="E001").get_my_leave() | LLM 이 바꿀 길이 없다 |
사용자를 묶은 도구
class Orders:
"""서버가 로그인한 고객으로 만든다. 도구의 인자에는 고객 번호가 없다"""
DATA = {"O-1": ("C01", "텀블러"), "O-2": ("C02", "도마")}
def __init__(self, customer):
self.customer = customer
def get_order(self, order_id):
owner, item = self.DATA.get(order_id, (None, None))
if owner != self.customer: # 없는 주문과 남의 주문을 똑같이 답한다
return {"오류": "고객님의 주문에서 찾을 수 없습니다."}
return {"주문번호": order_id, "상품": item}
tools = Orders(customer="C01") # API 키로 알아낸 고객
print(tools.get_order("O-1"))
print(tools.get_order("O-2"))
print(tools.get_order("O-9"))O-1 은 상품까지 나오고, 남의 주문 O-2 와 없는 주문 O-9 는 같은 오류가 나옵니다. 둘을 다르게 답하면 "그 번호의 주문이 있기는 하다"는 사실이 새어 나갑니다.
코드에 나오는 함수
| 코드 | 쉬운 뜻 |
|---|---|
DATA = {"O-1": ("C01", "텀블러"), …} | 주문번호마다 (주인 고객, 상품)을 적은 연습용 주문 장부다. 클래스에 붙어 있어 모든 객체가 함께 본다 |
Orders(customer="C01") | 서버가 로그인한 고객으로 도구 묶음을 만든다. 이 객체의 도구는 C01 의 주문만 다룬다 |
self.customer | 묶어 둔 고객 번호다. 도구의 인자가 아니라 객체 안에 있어 LLM 이 바꿀 길이 없다 |
self.DATA.get(order_id, (None, None)) | 주문을 찾고, 없으면 (None, None) 을 돌려준다. 없는 주문도 오류 없이 다음 줄로 간다 |
if owner != self.customer | 주인이 로그인한 고객이 아니면 막는다. 없는 주문은 주인이 None 이라 여기서 함께 막힌다 |
이 코드가 뜻하는 것
아파트의 무인 택배 보관함을 떠올려 봅니다. 입주민 카드를 대면 내 칸만 열립니다. "옆집 칸도 열어 주세요"라고 말해도 보관함은 카드가 정한 칸만 엽니다. 여기서 입주민 카드가 API 키, 카드로 정해진 칸이 Orders(customer="C01"), 보관함의 칸이 주문입니다.
| 줄 | 하는 일 | 쉽게 말하면 |
|---|---|---|
tools = Orders(customer="C01") | 로그인한 고객 C01 로 도구를 만든다 | 내 입주민 카드를 댄다 |
tools.get_order("O-1") | C01 의 주문이라 상품까지 돌려준다 | 내 칸이 열린다 |
tools.get_order("O-2") | C02 의 주문이라 오류를 돌려준다 | 옆집 칸은 열리지 않는다 |
tools.get_order("O-9") | 없는 주문이라 같은 오류를 돌려준다 | 없는 칸 번호를 눌러도 똑같이 "열 수 없음"이 뜬다 |
결과의 둘째 줄과 셋째 줄은 글자 하나 다르지 않습니다. 남의 주문인지 없는 주문인지 바깥에서는 가릴 수 없다는 뜻입니다. 비유와 다른 점은, 실제 보관함은 빈 칸이 겉으로 보이기도 하지만 이 코드는 일부러 둘을 구별할 수 없게 만든다는 것입니다.
작게 줄여서 눈으로 보기
앞 절의 주의할 점에서 본 getattr 의 위험을 눈으로 봅니다. 객체에는 도구가 아닌 이름도 들어 있습니다.
class Orders:
def __init__(self, customer):
self.customer = customer
def get_order(self, order_id):
return {"주문번호": order_id}
tools = Orders(customer="C01")
ALLOWED = {"get_order"} # 부를 수 있는 도구 이름
for name in ["get_order", "__init__", "customer"]:
print(name, "있는 이름:", hasattr(tools, name), "/ 허용:", name in ALLOWED)get_order 있는 이름: True / 허용: True
__init__ 있는 이름: True / 허용: False
customer 있는 이름: True / 허용: False| 결과 | 읽는 법 |
|---|---|
get_order … True / 허용: True | 진짜 도구다. 있고, 허용 목록에도 있다 |
__init__ … True / 허용: False | 객체를 만드는 메서드도 이름으로 찾을 수 있다. 허용 목록이 없으면 LLM 이 이 이름을 불러도 막지 못한다 |
customer … True / 허용: False | 묶어 둔 고객 번호도 이름으로 찾힌다. 도구가 아니므로 허용 목록에서 걸러야 한다 |
hasattr(객체, 이름) 은 has attribute(그 이름의 속성을 가졌나)의 줄임말로, 그 이름이 객체에 있으면 True 를 돌려줍니다. 있다는 것과 불러도 된다는 것은 다릅니다.
주의할 점
- 프롬프트에 "다른 사람의 정보는 알려 주지 마세요"라고 적는 것은 보조 수단입니다. 막는 것은 코드여야 합니다.
- 도구가 돌려준 결과는 LLM 이 다시 읽으므로, 사용자가 봐서는 안 되는 칸(다른 고객의 주소, 내부 메모)은 처음부터 결과에 넣지 않습니다.