하네스 워크북 Lab 02 교재 0/11

Lab 02 · 약 25분

도구 = JSON 스키마 + 함수 디스패치

1 개념

원문: 03-tools.md

무엇이 깨지나

두뇌는 텍스트만 쓸 수 있다. 파일을 읽거나 명령을 실행할 손이 없다.

패치

모델에게 도구 스키마(이름·설명·입력 형식)를 보여주면, 모델은 tool_use 블록을 '쓴다'. 실행은 하네스가 하고 결과를 tool_result 블록(같은 id)으로 돌려준다. 에러도 문자열로 돌려줘야 모델이 읽고 대처한다.

비용

도구 스키마도 매 요청마다 토큰을 먹는다. 설명이 곧 프롬프트다.

할 일 (exercise.py)

python run_tests.py 2 -v

생각해볼 것

2 따라 해보기

비유 → 짧은 코드 → 실제 출력. 아래 출력은 미리 실행해 둔 결과이고, ▶ 실행을 누르면 브라우저 안 파이썬으로 다시 돌립니다(첫 실행 때 약 10MB 내려받음).

예제 1 날씨 도구 하나로 보는 도구 호출의 3단계

Hugging Face 에이전트 코스의 날씨 비서 예제를 하네스 쪽에서 본 모습입니다.

비유 — 모델은 전화로만 일하는 비서입니다. 직접 창밖을 볼 수 없으니 "부산 날씨 좀 확인해 주세요"라는 메모(tool_use) 를 건넵니다. 하네스가 실제로 확인하고, 같은 메모 번호(id)를 붙여 답장(tool_result) 을 돌려줍니다.

from common.brain import reply, text, tool_use
from common.show import show

WEATHER = {"서울": "맑음 18°C", "부산": "흐림 21°C"}       # 가짜 날씨 서버
weather_tool = {                                          # ① 모델에게 보여줄 스키마
    "name": "get_weather",
    "description": "도시의 현재 날씨를 알려준다.",
    "input_schema": {"type": "object",
                     "properties": {"city": {"type": "string"}},
                     "required": ["city"]},
}


def get_weather(city):                                    # ② 하네스가 실행할 함수
    return WEATHER.get(city, f"ERROR: '{city}' 날씨 정보가 없습니다")


# 모델이 '쓴' 응답 — 직접 실행하지 못하고 부탁만 한다
response = reply(text("날씨를 확인해 볼게요."),
                 tool_use("get_weather", {"city": "부산"}, id="toolu_01"))
call = response["content"][1]
print("1) 모델의 부탁  :", call["name"], call["input"])

result = get_weather(**call["input"])                     # 하네스가 실제로 실행
print("2) 하네스 실행  :", result)

messages = [                                              # 같은 id 로 결과를 돌려준다
    {"role": "user", "content": "부산 날씨 어때?"},
    {"role": "assistant", "content": response["content"]},
    {"role": "user", "content": [{"type": "tool_result",
                                  "tool_use_id": call["id"], "content": result}]},
]
print("3) 다음 요청에 실릴 배열:")
show(messages)
실행 결과
1) 모델의 부탁  : get_weather {'city': '부산'}
2) 하네스 실행  : 흐림 21°C
3) 다음 요청에 실릴 배열:
[0] user      "부산 날씨 어때?"
[1] assistant text "날씨를 확인해 볼게요."
              tool_use[toolu_01] get_weather({"city": "부산"})
[2] user      tool_result[toolu_01] "흐림 21°C"

💡 바꿔 보기: 도시를 "제주"로 바꾸면 어떤 결과가 돌아가나요? 에러도 문자열로 돌아가는 걸 확인하세요.

예제 2 에러도 '관찰'이다

도구가 실패해도 하네스는 죽으면 안 됩니다. 실패 내용을 문자열로 돌려주면 모델이 읽고 "경로를 고쳐서 다시 읽어볼게요" 하고 스스로 복구합니다.

import solution as lab

calls = [
    ("run_command", {"command": "echo 안녕 하네스"}),
    ("read_file", {"path": "없는파일.txt"}),
    ("read_file", {"wrong": "인자 이름이 틀림"}),
    ("fly_to_moon", {}),
]
for name, args in calls:
    print(f"{name:12} → {lab.execute_tool(name, args).strip()}")
실행 결과
run_command  → 안녕 하네스
read_file    → ERROR: FileNotFoundError: [Errno 2] No such file or directory: '없는파일.txt'
read_file    → ERROR: TypeError: _read_file() got an unexpected keyword argument 'wrong'
fly_to_moon  → ERROR: unknown tool fly_to_moon

💡 바꿔 보기: read_file 경로를 README.md 로 바꿔 성공 결과를 보세요.

예제 3 왜 도구 한 번으론 부족할까?

Lab 2 의 ask_with_tools 는 도구를 딱 한 번만 실행합니다. 그런데 모델은 "목록을 보고 → 파일을 읽고 → 답하기"처럼 여러 번 필요할 때가 많습니다. 두 번째 부탁은 실행되지 못하고 버려집니다. 이걸 고치는 게 Lab 3 의 에이전트 루프입니다.

import solution as lab
from common.brain import ScriptedBrain, reply, text, tool_use

brain = ScriptedBrain([
    reply(text("먼저 목록을 볼게요."),
          tool_use("run_command", {"command": "ls data/procurement/docs"}, id="toolu_a")),
    reply(text("이제 대금지급 규정을 읽어야겠어요."),
          tool_use("read_file", {"path": "data/procurement/docs/06_대금지급_규정.md"}, id="toolu_b")),
])
messages = []
answer = lab.ask_with_tools(brain, messages, "어음으로 지급해도 돼?")
print("사용자가 받은 답:", answer)
print("마지막 응답의 stop_reason:", "tool_use", "← 아직 도구를 원하는데 대화가 끝나버림")
print("⚠️ toolu_b (파일 읽기)는 실행되지 않았습니다.")
실행 결과
사용자가 받은 답: 이제 대금지급 규정을 읽어야겠어요.
마지막 응답의 stop_reason: tool_use ← 아직 도구를 원하는데 대화가 끝나버림
⚠️ toolu_b (파일 읽기)는 실행되지 않았습니다.

3 확인 퀴즈

Q1 tool_result 블록이 반드시 가져야 하는 것은?

Q2 도구 실행 중 예외가 났을 때 가장 좋은 처리는?

4 실습: exercise.py 채우기

TODO 표시 부분을 채우고 채점하세요. 입력 내용은 이 기기에 자동 저장됩니다.

5 정답과 테스트

정답 코드 보기 (solution.py)
#!/usr/bin/env python3
"""Lab 2 — 도구 = JSON 스키마 + 함수 디스패치.

두뇌는 세상을 만질 수 없습니다. 할 수 있는 건 "이 도구를 이 인자로 불러줘"라는
tool_use 블록을 '쓰는' 것뿐. 실제 실행은 하네스(몸)가 합니다.

실행:  python labs/02_tools/solution.py      (FakeBrain에서 'read README.md' 입력해보기)
"""
import os
import subprocess
import sys

sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
from common.brain import get_brain  # noqa: E402

SYSTEM = "너는 도구를 쓸 수 있는 조수다. 필요하면 도구를 호출하라."

TOOLS = [
    {"name": "read_file",
     "description": "텍스트 파일을 읽어 내용을 반환한다.",
     "input_schema": {"type": "object",
                      "properties": {"path": {"type": "string"}},
                      "required": ["path"]}},
    {"name": "run_command",
     "description": "셸 명령을 실행하고 stdout+stderr를 반환한다.",
     "input_schema": {"type": "object",
                      "properties": {"command": {"type": "string"}},
                      "required": ["command"]}},
]

# >>> TODO: write_file 도구 스키마를 TOOLS 에 추가하세요 (path, content 둘 다 필수 문자열)
TOOLS.append(
    {"name": "write_file",
     "description": "파일을 만들거나 덮어쓴다.",
     "input_schema": {"type": "object",
                      "properties": {"path": {"type": "string"},
                                     "content": {"type": "string"}},
                      "required": ["path", "content"]}})
# <<< TODO


def _read_file(path):
    with open(path, encoding="utf-8") as f:
        return f.read()


def _write_file(path, content):
    with open(path, "w", encoding="utf-8") as f:
        f.write(content)
    return f"wrote {len(content)} chars to {path}"


def _run_command(command):
    out = subprocess.run(command, shell=True, capture_output=True, text=True, timeout=120)
    return (out.stdout + out.stderr) or "(no output)"


HANDLERS = {"read_file": _read_file, "write_file": _write_file,
            "run_command": _run_command}


def execute_tool(name, args):
    """디스패치. 규칙:
    - 모르는 도구  → "ERROR: unknown tool <name>"
    - 실행 중 예외 → 예외를 던지지 말고 "ERROR: ..." 로 시작하는 문자열 반환
    """
    # >>> TODO: HANDLERS 로 디스패치하고, 위 규칙대로 에러를 문자열로 바꾸세요
    fn = HANDLERS.get(name)
    if fn is None:
        return f"ERROR: unknown tool {name}"
    try:
        return fn(**args)
    except Exception as e:
        return f"ERROR: {type(e).__name__}: {e}"
    # <<< TODO


def tool_results_for(response):
    """응답 속 '모든' tool_use 블록을 실행해서 tool_result 블록 리스트를 만든다.
    각 결과의 tool_use_id 는 해당 tool_use 블록의 id 와 같아야 한다."""
    # >>> TODO: {"type": "tool_result", "tool_use_id": ..., "content": ...} 리스트를 반환하세요
    return [{"type": "tool_result", "tool_use_id": b["id"],
             "content": execute_tool(b["name"], b["input"])}
            for b in response["content"] if b["type"] == "tool_use"]
    # <<< TODO


def ask_with_tools(brain, messages, user_text):
    """도구를 '딱 한 번만' 허용하는 버전. 첫 응답이 도구를 원하면 실행하고
    결과를 user 메시지로 붙여 한 번 더 호출한다. → 왜 이걸로 부족한지가 Lab 3의 출발점."""
    messages.append({"role": "user", "content": user_text})
    request = {"system": SYSTEM, "messages": messages, "tools": TOOLS}
    response = brain(request)
    messages.append({"role": "assistant", "content": response["content"]})
    if response["stop_reason"] == "tool_use":
        messages.append({"role": "user", "content": tool_results_for(response)})
        response = brain(request)
        messages.append({"role": "assistant", "content": response["content"]})
    return "".join(b["text"] for b in response["content"] if b["type"] == "text")


def main():
    brain, messages = get_brain(), []
    while True:
        try:
            user_text = input("\nyou> ").strip()
        except EOFError:
            break
        if user_text in ("/quit", ""):
            break
        print(f"\nassistant> {ask_with_tools(brain, messages, user_text)}")


if __name__ == "__main__":
    main()
채점 테스트 보기 (test_lab.py)
import os
import sys
import tempfile
import unittest

sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
from common.labkit import load_target  # noqa: E402
from common.brain import ScriptedBrain, reply, text, tool_use  # noqa: E402

lab = load_target(__file__)


class Lab2(unittest.TestCase):
    def test_write_file_schema(self):
        tools = {t["name"]: t for t in lab.TOOLS}
        self.assertIn("write_file", tools)
        s = tools["write_file"]["input_schema"]
        self.assertEqual(s["type"], "object")
        self.assertEqual(set(s["required"]), {"path", "content"})
        self.assertTrue(tools["write_file"]["description"])

    def test_dispatch_read_write(self):
        with tempfile.TemporaryDirectory() as d:
            p = os.path.join(d, "a.txt")
            out = lab.execute_tool("write_file", {"path": p, "content": "hi"})
            self.assertIsInstance(out, str)
            self.assertEqual(lab.execute_tool("read_file", {"path": p}), "hi")

    def test_unknown_tool_is_string(self):
        self.assertEqual(lab.execute_tool("fly", {}), "ERROR: unknown tool fly")

    def test_exceptions_become_strings(self):
        out = lab.execute_tool("read_file", {"path": "/no/such/file"})
        self.assertTrue(out.startswith("ERROR"), "예외를 던지지 말고 문자열로 돌려줘야 합니다")
        out = lab.execute_tool("read_file", {"wrong_arg": 1})
        self.assertTrue(out.startswith("ERROR"))

    def test_results_match_ids(self):
        r = reply(tool_use("run_command", {"command": "echo 1"}, id="A"),
                  tool_use("run_command", {"command": "echo 2"}, id="B"))
        res = lab.tool_results_for(r)
        self.assertEqual([x["tool_use_id"] for x in res], ["A", "B"])
        self.assertEqual([x["type"] for x in res], ["tool_result"] * 2)
        self.assertEqual(res[1]["content"].strip(), "2")

    def test_one_tool_round(self):
        brain = ScriptedBrain([reply(tool_use("run_command", {"command": "echo hi"}, id="X")),
                               reply(text("결과는 hi"))])
        msgs = []
        self.assertEqual(lab.ask_with_tools(brain, msgs, "hi 출력해"), "결과는 hi")
        self.assertEqual([m["role"] for m in msgs], ["user", "assistant", "user", "assistant"])
        self.assertEqual(msgs[2]["content"][0]["tool_use_id"], "X")


if __name__ == "__main__":
    unittest.main()

📚 더 읽을거리

교재 전체 목록 →