← Start here · Module 5 · Project
영어로 질문하면 read-only SQL 도구를 돌려 답을 찾아 설명하는 에이전트. Module 5의 ~20줄 agent loop를 실제로 굴려봅니다.
agent-app.zip(SQLite + read-only SQL 도구 3개)에서 시작합니다. "Run it live"는
앱을 빌드한 뒤 켜집니다.
"지난 분기 매출 상위 3개 제품은?" 같은 질문 → 에이전트가 스키마를 살펴보고 read-only SQL을 돌려 답을 말로 설명해요.
핵심은 화려한 UI가 아니라 루프예요. 모델에게 질문과 도구 3개를 주면, 모델은 매 턴 "다음에 어떤 도구를 부를지"만 정합니다. 우리가 그 도구를 실행해 결과를 돌려주고, 모델이 더 이상 도구를 안 부를 때까지 반복해요. 이게 5A.1의 agent loop 그대로고, 이 프로젝트는 그 ~20줄을 진짜로 굴리는 자리예요.
run_query(SELECT 전용)만 주고 쓰기 도구는 안 줌(안전 1)
점선으로 그린 쓰기 도구(INSERT/UPDATE/DROP)는 메뉴에 없어요 — 모델이 부르고 싶어도
부를 대상 자체가 없습니다. 이 "없음"이 프롬프트 경고보다 튼튼한 진짜 안전장치예요(자세히는 5D.5).
agent-app.zip을 풀면 코드는 딱 세 파일(agent.py·tools.py·build_db.py) + 데이터(data.db) + README예요. 무거운 건 아무것도 없어요 — 에이전트의 본질은 짧습니다.
| 파일 | 역할 |
|---|---|
agent.py | 루프 본체. answer_turn()이 모델 호출 → tool_use 감지 → dispatch → 결과 append를 MAX_STEPS번까지 반복. CLI(대화형/one-shot)도 여기. |
tools.py | 도구 3개 + 안전 게이트. list_tables·describe_table·run_query, 그리고 run_query의 SELECT-only 체크와 mode=ro 커넥션. TOOLS(모델에 보낼 정의)와 dispatch(이름→함수)도 여기. |
build_db.py | 합성 데이터 생성기. 고정 씨드로 e-commerce 데이터(~60 제품 · 500 고객 · 3000 주문)를 data.db에 씀. 같은 씨드 → 누구나 같은 답. |
data.db | SQLite 파일. 이미 빌드돼 함께 옵니다. 에이전트는 이걸 읽기 전용으로만 엽니다. |
README.md | 개념 · 실행법 · 스키마 · "네가 할 일"(도구 추가/한계 탐색 등). |
answer_turn() 한 함수가 곧 에이전트예요. 아래 다이어그램은 agent.py의 실제 함수 호출 순서입니다. 초록 점이 가장 흔한 길(도구 호출 루프)을 계속 돌고, 도구를 그만 부르는 순간엔 노란 점이 되어 final answer로 빠져나가요 — 이 되돌이가 엔진입니다.
모델은 매 턴 "다음 도구 하나"만 정하고, 결과를 보고 또 정하길 tool_use가 사라질 때까지 반복해요.
멀티턴(대화 기억)은 거의 공짜예요 — 하나의 messages 리스트를 질문마다 이어 붙이기만 하면
"그럼 그 전 분기는?" 같은 후속 질문이 문맥을 그대로 가져갑니다.
실제로 돌리면 이 루프가 도구 호출을 한 줄씩 찍으며 진행돼요(agent.py가 각 tool_use를 출력):
you › Which single product had the highest revenue in the most recent quarter?
→ list_tables()
→ describe_table(table=order_items)
→ run_query(sql=SELECT p.name, SUM(oi.quantity*oi.unit_price) AS rev ...)
Robot Vacuum — $46,307 in revenue (Mar 3 – May 31, 2026, completed orders).
you › What about the quarter before that? ← follow-up: uses the history
→ run_query(sql=WITH product_revenue AS (...) ...)
Robot Vacuum again — $29,637 the prior quarter, so revenue jumped ~56%.
보이나요 — 첫 질문은 list_tables → describe_table로 먼저 살펴본 뒤 쿼리해요.
두 번째 질문("그 전 분기는?")은 테이블을 다시 안 봐요 — messages에 남은 문맥 덕분에 곧장 쿼리 하나로 답합니다.
이게 멀티턴이 "거의 공짜"라는 뜻이에요.
이 루프가 "이제 끝"이라고 판단하는 근거는 무엇일까요?
종료는 우리가 텍스트를 눈으로 읽어 판단하는 게 아니라 구조적 신호로 결정돼요. 코드는
tool_calls = [b … if b.type == "tool_use"]를 보고, 비어 있으면 곧장 text를 반환합니다.
즉 stop_reason/tool_use 유무가 루프를 운전해요 — 사람의 판단이 아니라.
MAX_STEPS = 12는 주로 무엇을 위한 장치일까요?
비용 절감은 부수효과예요. 본질은 이 루프에 종료 보장이 없다는 것 — 모델이 계속 도구를
부르면 원리상 무한히 돌 수 있어요. for _ in range(MAX_STEPS)가 그 위험한 무한을 잘라
"안 끝나도 12스텝이면 멈춘다"를 보장합니다(안전 슬라이드의 "1만 번의 작은 실패" 방지).
"매출 top5" 같은 질문이 말이 되려면 데이터가 어떻게 생겼는지 알아야 해요. build_db.py가 만드는 스키마는 흔한 e-commerce 4테이블이에요.
"매출"은 한 테이블에 없어요 — order_items.quantity × unit_price를 orders에 조인해
status = 'completed'만 골라 합쳐야 나옵니다. 그래서 에이전트는 바로 쿼리하지 않고 먼저
list_tables → describe_table로 스키마를 확인해요("look before it leaps").
cancelled/refunded 주문과 일부 빠진 country 값 같은 현실적 함정이 일부러 섞여 있어요.
이 규칙들을 에이전트에게 강제하는 건 agent.py의 시스템 프롬프트예요 — "먼저 살펴봐라 · 최신 order_date 기준으로 계산해라 · completed만 매출로 쳐라":
SYSTEM = """You are a data analyst. You answer questions about a SQLite database \
by inspecting its schema and running read-only SQL queries.
Always start by checking what's there: call list_tables, then describe_table on \
the tables you need, before writing any query. Use SQLite syntax. The data spans \
about 18 months; when a question is time-relative ("last quarter", "recent month"), \
work it out from the most recent order_date in the data, not today's date. Unless \
asked otherwise, treat only 'completed' orders as revenue.
..."""
왜 프롬프트로 이걸 정할까요? — 이건 안전이 아니라 분석 규약이라서예요. "쓰지 마"는 프롬프트로 부탁하면 안 되고(도구를 안 주는 게 정답), "revenue는 completed만"은 정답의 정의라 프롬프트가 제자리예요. 안전은 구조로, 규약은 프롬프트로 — 둘을 헷갈리지 않는 게 포인트예요.
에이전트의 전부를 세 조각으로 봅니다: ① 루프(agent.py) ② 안전 게이트(tools.py) ③ 어떤 데이터가 있나(build_db.py). 각 코드 조각 위의 미니 다이어그램이 함수 흐름의 지도예요.
answer_turn()모델 호출 → tool_use 감지 → dispatch → 결과 append. 이 네 동작이 MAX_STEPS 예산 안에서 돕니다.
for _ in range(MAX_STEPS):
resp = client.messages.create(
model=MODEL, max_tokens=2048, system=SYSTEM,
messages=messages, tools=TOOLS,
)
messages.append({"role": "assistant", "content": resp.content})
tool_calls = [b for b in resp.content if b.type == "tool_use"]
if not tool_calls: # no tools requested → done
return "".join(b.text for b in resp.content if b.type == "text").strip()
results = []
for call in tool_calls:
print(f" → {call.name}({_fmt(call.input)})")
output = dispatch(call.name, call.input)
results.append({
"type": "tool_result",
"tool_use_id": call.id,
"content": output,
})
messages.append({"role": "user", "content": results})
return "(stopped: hit the step budget without finishing)"
모델이 run_query를 "호출"할 때, 실제로 DB를 만지는 건 누구일까요?
모델의 tool_use는 "run_query를 이 SQL로 불러줘"라는 요청일 뿐, 아무것도 실행하지 않아요.
우리 코드의 dispatch(call.name, call.input)가 그 요청을 받아 우리가 정한 함수를 돌립니다.
바로 이 경계 덕분에 — 모델이 무슨 이름을 대든 — 우리가 준 세 함수 밖으로는 나갈 수 없어요.
그 경계가 바로 dispatch예요 — 모델이 준 이름을 우리 함수로 라우팅하는 짧은 스위치. 등록 안 된 이름은 그냥 문자열로 거절돼요:
def dispatch(name: str, tool_input: dict) -> str:
"""Route a tool_use block to the matching function."""
if name == "list_tables":
return list_tables()
if name == "describe_table":
return describe_table(tool_input["table"])
if name == "run_query":
return run_query(tool_input["sql"])
return f"Unknown tool: {name}"
run_query()안전은 두 겹이에요. 진짜 방벽은 mode=ro 커넥션(SQLite가 쓰기를 물리적으로 거부), 그 위의 SELECT 체크는 에러 대신 친절한 메시지를 주는 얇은 층이에요.
def _connect():
# Read-only connection — any attempted write raises sqlite3.OperationalError.
return sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True)
# … list_tables / describe_table omitted …
def run_query(sql: str) -> str:
"""Run a read-only SQL SELECT and return up to 100 rows."""
head = sql.strip().lstrip("(").lower()
if not (head.startswith("select") or head.startswith("with")):
return "Rejected: only read-only SELECT queries are allowed."
try:
with _connect() as conn:
cur = conn.execute(sql) # runs a single statement only
cols = [d[0] for d in cur.description] if cur.description else []
rows = cur.fetchmany(100)
except (sqlite3.Error, sqlite3.Warning) as e:
return f"SQL error: {e}"
# … then format the rows into text …
return "\n".join(out)
mode=ro는 우회가 불가능해요: 커넥션 자체가 쓰기를 못 하니까요. 체크는 UX(친절한 메시지),
커넥션은 안전(진짜 방벽) — 안전은 프롬프트가 아니라 연결의 성질에서 나옵니다.
build_db.py"지난 분기 매출"이 말이 되려면 데이터의 모양·범위를 알아야 해요. 몇 줄만 보면 왜 시간 질문을 "오늘"이 아니라 "가장 최근 주문일" 기준으로 풀어야 하는지 보여요.
END_DATE = date(2026, 5, 31)
START_DATE = END_DATE - timedelta(days=545) # ~18 months
N_CUSTOMERS = 500
N_ORDERS = 3000
# … product catalog + weighted country list omitted …
# Orders are weighted toward "completed"; the rest exercise status filtering.
STATUS = ["completed"] * 80 + ["cancelled"] * 12 + ["refunded"] * 8
그래서 시스템 프롬프트가 에이전트에게 못 박아요: "시간 상대 질문은 데이터의 가장 최근 order_date에서
계산하고, 별말 없으면 completed 주문만 매출로 쳐라." 데이터가 언제 실행하든 상하지 않도록
벽시계가 아니라 데이터 자체를 기준으로 삼는 설계예요.
빌드해 두면, 버튼이 서버에서 잠깐 띄워 URL을 돌려줍니다.
agent 이미지를 띄울 수 있을 때 활성화됩니다. 그 전엔 버튼이 아래
빌드 & 로컬 실행으로 안내해요.
# 스타터: agent-app.zip (SQLite + read-only SQL 도구 3개 + max-steps)
# https://ksetp.netlify.app/assets/agents/agent-app.zip
unzip agent-app.zip && cd agent-app
python3.11 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
python agent.py # 대화형 세션 (질문을 영어로 입력)
python agent.py "How many orders completed last month?" # one-shot
ANTHROPIC_API_KEY는 공유 .env를 그대로 씁니다. 데이터를 바꾸고 싶으면
python build_db.py로 data.db를 다시 만들 수 있어요(씨드 고정 → 같은 결과).
위험 도구를 애초에 안 주는 게 가장 튼튼한 안전장치라는 걸 코드에서 확인해 보세요.
"모델 호출 → 도구 실행 → 결과 append → 반복" 이 ~20줄이 모든 에이전트의 본질. 안전은 루프 밖 설계에서 나와요 — 준 도구(read-only)와 예산(max-steps).
이 프로젝트가 5A·5B·5C와 같은 리듬을 공유해요: 무거운 건 그대로 재사용하고, 목적에 맞게 얇은 층만 바꾼다. 여기선 모델을 손대지 않고 루프(제어 구조)와 도구 셋만 목적에 맞췄어요 — 그게 "에이전트"라는 것의 전부입니다.
top_n(metric, table, n)이나 monthly_trend(metric) 같은 함수를 만들고 TOOLS에 등록 + dispatch에 연결. 도구 하나 추가가 곧 능력 하나 추가예요.MAX_STEPS만이 유일한 안전망이 아니게요.build_db.py를 다른 스키마(SaaS 구독 DB, 내 CSV)로 바꿔도 같은 에이전트가 그대로 동작하는지 확인. 루프는 스키마를 모르니까요.이 에이전트에 run_query(SELECT 전용)만 주고 임의 SQL 실행 도구를 안 주는 이유는?
가장 튼튼한 안전장치는 위험한 도구를 애초에 안 주는 것이에요. 모델이 헷갈려도 줄 수 있는 게 SELECT뿐이면 DROP/UPDATE는 불가능합니다(프롬프트 경고보다 확실).