← Start here · Module 5 · Project

Module 5 · Project — data-analyst agent

영어로 질문하면 read-only SQL 도구를 돌려 답을 찾아 설명하는 에이전트. Module 5의 ~20줄 agent loop를 실제로 굴려봅니다.

🤖 개념은 Module 5 · Agents에서 · 이 프로젝트는 5.7 Project(안전 패턴 2개 기구현)를 실행하는 곳이에요.
🚧 이 앱도 스타터에서 빌드합니다. 저장소에 완성본이 없어요 — agent-app.zip(SQLite + read-only SQL 도구 3개)에서 시작합니다. "Run it live"는 앱을 빌드한 뒤 켜집니다.

5D.1 · What it builds

"지난 분기 매출 상위 3개 제품은?" 같은 질문 → 에이전트가 스키마를 살펴보고 read-only SQL을 돌려 답을 말로 설명해요.

핵심은 화려한 UI가 아니라 루프예요. 모델에게 질문과 도구 3개를 주면, 모델은 매 턴 "다음에 어떤 도구를 부를지"만 정합니다. 우리가 그 도구를 실행해 결과를 돌려주고, 모델이 더 이상 도구를 안 부를 때까지 반복해요. 이게 5A.1의 agent loop 그대로고, 이 프로젝트는 그 ~20줄을 진짜로 굴리는 자리예요.

기구현된 안전 패턴 2개
  • 구조적 read-onlyrun_query(SELECT 전용)만 주고 쓰기 도구는 안 줌(안전 1)
  • max-steps 예산 — 루프가 N스텝 넘으면 강제 종료(안전 2)
🏢 비유하자면 신입 분석가에게 "읽기 전용 DB 계정"만 발급하는 회사예요. 아무리 실수해도 계정 권한 자체가 SELECT뿐이면 데이터를 지우거나 바꿀 방법이 존재하지 않아요. "쓰지 마세요"라고 부탁하는 게 아니라, 쓸 수단을 애초에 안 주는 거죠.
flowchart LR NEWA["new analyst = the model"] --> ROACC["read-only account
list_tables · describe_table · run_query"] ROACC --> STOREDB[("store database")] NEWA -.->|not on the menu| WRITES["INSERT · UPDATE · DROP"] class ROACC core;

점선으로 그린 쓰기 도구(INSERT/UPDATE/DROP)는 메뉴에 없어요 — 모델이 부르고 싶어도 부를 대상 자체가 없습니다. 이 "없음"이 프롬프트 경고보다 튼튼한 진짜 안전장치예요(자세히는 5D.5).

5D.2 · Scaffold — 파일 5개가 전부

agent-app.zip을 풀면 코드는 딱 세 파일(agent.py·tools.py·build_db.py) + 데이터(data.db) + README예요. 무거운 건 아무것도 없어요 — 에이전트의 본질은 짧습니다.

flowchart TB ZIP["agent-app.zip"] --> AG["agent.py
the ~20-line agent loop + CLI"] ZIP --> TL["tools.py
3 read-only tools + SELECT guard"] ZIP --> BD["build_db.py
deterministic seed -> data.db"] ZIP --> DB[("data.db
SQLite · ships prebuilt")] ZIP --> RM["README.md
concept + run + your-job"] class AG core;
파일역할
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.dbSQLite 파일. 이미 빌드돼 함께 옵니다. 에이전트는 이걸 읽기 전용으로만 엽니다.
README.md개념 · 실행법 · 스키마 · "네가 할 일"(도구 추가/한계 탐색 등).

5D.3 · 흐름 — the loop is the engine

answer_turn() 한 함수가 곧 에이전트예요. 아래 다이어그램은 agent.py실제 함수 호출 순서입니다. 초록 점이 가장 흔한 길(도구 호출 루프)을 계속 돌고, 도구를 그만 부르는 순간엔 노란 점이 되어 final answer로 빠져나가요 — 이 되돌이가 엔진입니다.

flowchart TD USERQ(["user question"]) --> MAXS["for _ in range(MAX_STEPS)
step budget"] MAXS --> CREATE["client.messages.create(tools=TOOLS)"] CREATE --> STOPQ{"any tool_use blocks?"} STOPQ -->|no| DONE(["final answer"]) STOPQ -->|yes| DISPATCH["dispatch(name, args)"] DISPATCH --> TOOLS3["list_tables / describe_table / run_query"] TOOLS3 --> APPEND["append tool_result to messages"] APPEND -.-> CREATE class CREATE core;

모델은 매 턴 "다음 도구 하나"만 정하고, 결과를 보고 또 정하길 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_tablesdescribe_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만 번의 작은 실패" 방지).

5D.4 · The database — 무엇을 물어볼 수 있나

"매출 top5" 같은 질문이 말이 되려면 데이터가 어떻게 생겼는지 알아야 해요. build_db.py가 만드는 스키마는 흔한 e-commerce 4테이블이에요.

flowchart LR CUST["customers
id · name · email · country · signup_date"] PROD["products
id · name · category · price · cost"] ORD["orders
id · customer_id · order_date · status"] ITEM["order_items
id · order_id · product_id · quantity · unit_price"] CUST -->|customer_id| ORD ORD -->|order_id| ITEM PROD -->|product_id| ITEM class ITEM core;

"매출"은 한 테이블에 없어요 — order_items.quantity × unit_priceorders에 조인해 status = 'completed'만 골라 합쳐야 나옵니다. 그래서 에이전트는 바로 쿼리하지 않고 먼저 list_tablesdescribe_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만"은 정답의 정의라 프롬프트가 제자리예요. 안전은 구조로, 규약은 프롬프트로 — 둘을 헷갈리지 않는 게 포인트예요.

5D.5 · 코드 — 세 조각으로 읽기

에이전트의 전부를 세 조각으로 봅니다: ① 루프(agent.py) ② 안전 게이트(tools.py) ③ 어떤 데이터가 있나(build_db.py). 각 코드 조각 위의 미니 다이어그램이 함수 흐름의 지도예요.

① The loop — answer_turn()

모델 호출 → tool_use 감지 → dispatch → 결과 append. 이 네 동작이 MAX_STEPS 예산 안에서 돕니다.

flowchart LR LMAX["for _ in range(MAX_STEPS)"] --> LCREATE["messages.create()"] LCREATE --> LCHK{"any tool_use?"} LCHK -->|no| LDONE["return text"] LCHK -->|yes| LRUN["dispatch(name, args)"] LRUN --> LFEED["append tool_result"] LFEED -.-> LCREATE class LCREATE core;
    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}"

② The safety gate — run_query()

안전은 두 겹이에요. 진짜 방벽은 mode=ro 커넥션(SQLite가 쓰기를 물리적으로 거부), 그 위의 SELECT 체크는 에러 대신 친절한 메시지를 주는 얇은 층이에요.

flowchart LR GSQL["sql string"] --> GGUARD{"SELECT / WITH?"} GGUARD -->|no| GREJECT["return 'Rejected…'"] GGUARD -->|yes| GCONN["_connect(mode=ro)"] GCONN --> GEXEC["conn.execute(sql)"] class GCONN core;
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)
🔒 왜 두 겹이냐면 — SELECT 체크만 있으면 언젠가 우회당할 수 있어요(교묘한 문자열). 하지만 mode=ro는 우회가 불가능해요: 커넥션 자체가 쓰기를 못 하니까요. 체크는 UX(친절한 메시지), 커넥션은 안전(진짜 방벽) — 안전은 프롬프트가 아니라 연결의 성질에서 나옵니다.

③ What data exists — build_db.py

"지난 분기 매출"이 말이 되려면 데이터의 모양·범위를 알아야 해요. 몇 줄만 보면 왜 시간 질문을 "오늘"이 아니라 "가장 최근 주문일" 기준으로 풀어야 하는지 보여요.

flowchart LR DSPAN["~18 months · ends 2026-05-31"] --> DVOL["500 customers · 3000 orders"] DVOL --> DSTAT["status mix:
completed / cancelled / refunded"]
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 주문만 매출로 쳐라." 데이터가 언제 실행하든 상하지 않도록 벽시계가 아니라 데이터 자체를 기준으로 삼는 설계예요.

5D.6 · Run it live

빌드해 두면, 버튼이 서버에서 잠깐 띄워 URL을 돌려줍니다.

⚙️ 런처가 agent 이미지를 띄울 수 있을 때 활성화됩니다. 그 전엔 버튼이 아래 빌드 & 로컬 실행으로 안내해요.

5D.7 · Build & run locally

# 스타터: 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.pydata.db를 다시 만들 수 있어요(씨드 고정 → 같은 결과). 위험 도구를 애초에 안 주는 게 가장 튼튼한 안전장치라는 걸 코드에서 확인해 보세요.

5D.8 · The key idea — the loop is the engine

"모델 호출 → 도구 실행 → 결과 append → 반복" 이 ~20줄이 모든 에이전트의 본질. 안전은 루프 밖 설계에서 나와요 — 준 도구(read-only)와 예산(max-steps).

이 프로젝트가 5A·5B·5C와 같은 리듬을 공유해요: 무거운 건 그대로 재사용하고, 목적에 맞게 얇은 층만 바꾼다. 여기선 모델을 손대지 않고 루프(제어 구조)도구 셋만 목적에 맞췄어요 — 그게 "에이전트"라는 것의 전부입니다.

더 해보기 (README의 "Your job")
  • 도구 추가top_n(metric, table, n)이나 monthly_trend(metric) 같은 함수를 만들고 TOOLS에 등록 + dispatch에 연결. 도구 하나 추가가 곧 능력 하나 추가예요.
  • 한계 탐색 — 에이전트가 틀리거나 비효율적인 질문을 찾아보세요(예: refunded를 매출로 세거나, 한 방이면 될 걸 다섯 쿼리로). 왜 그런지가 진짜 배움이에요.
  • 루프 조이기 — 동일 쿼리 반복 감지(loop detection)를 넣거나, 최종 SQL을 출력하게. MAX_STEPS만이 유일한 안전망이 아니게요.
  • 데이터 교체build_db.py를 다른 스키마(SaaS 구독 DB, 내 CSV)로 바꿔도 같은 에이전트가 그대로 동작하는지 확인. 루프는 스키마를 모르니까요.

이 에이전트에 run_query(SELECT 전용)만 주고 임의 SQL 실행 도구를 안 주는 이유는?

가장 튼튼한 안전장치는 위험한 도구를 애초에 안 주는 것이에요. 모델이 헷갈려도 줄 수 있는 게 SELECT뿐이면 DROP/UPDATE는 불가능합니다(프롬프트 경고보다 확실).