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

Lab 01 · 약 20분

대화는 그냥 JSON 배열이다

1 개념

원문: 01-its-just-a-json-array.md

무엇이 깨지나

API는 지난 대화를 기억하지 않는다. 그럼 '대화'는 어디에 있나?

패치

대화 = 하네스가 들고 있는 messages 배열. 매 턴 배열 전체를 다시 보내고, 응답의 content 블록을 통째로 붙인다. 세션 = 이 배열을 저장한 JSON 파일.

비용

대화가 길어질수록 매 요청이 커진다 (→ Lab 4).

할 일 (exercise.py)

python run_tests.py 1 -v

생각해볼 것

2 따라 해보기

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

예제 1 금붕어 기억력: 모델은 지난 말을 모른다

비유 — 콜센터에 전화할 때마다 처음 보는 상담원이 받습니다. 지난 통화 내용을 알려주려면 통화 기록 전체를 매번 다시 읽어줘야 하죠. LLM API가 딱 그렇습니다.

아래 두뇌는 "배열 안에서만" 이름을 찾습니다. 마지막 말만 보내면 모르고, 배열 전체를 보내면 압니다.

from common.brain import reply, text


def brain(request):
    """배열에 '내 이름은 ~' 이 있을 때만 이름을 아는 아주 단순한 두뇌"""
    for m in request["messages"]:
        if isinstance(m["content"], str) and m["content"].startswith("내 이름은 "):
            return reply(text(f"당신은 {m['content'][6:].rstrip('.')}님이죠!"))
    return reply(text("죄송해요, 이름을 들은 적이 없어요."))


history = [
    {"role": "user", "content": "내 이름은 지수."},
    {"role": "assistant", "content": [{"type": "text", "text": "반가워요!"}]},
    {"role": "user", "content": "내 이름이 뭐게?"},
]
print("① 마지막 말만 보내면 →", brain({"messages": history[-1:]})["content"][0]["text"])
print("② 배열 전체를 보내면 →", brain({"messages": history})["content"][0]["text"])
실행 결과
① 마지막 말만 보내면 → 죄송해요, 이름을 들은 적이 없어요.
② 배열 전체를 보내면 → 당신은 지수님이죠!

💡 바꿔 보기: history 의 첫 메시지를 지우고 실행하면 ② 도 모른다고 답합니다.

예제 2 세 턴 동안 배열이 자라는 모습

chat_once 를 세 번 부르면 배열은 user/assistant 가 번갈아 6칸이 됩니다. 두뇌에게 보낸 메시지 수는 1 → 3 → 5 로 늘어납니다. 대화가 길어질수록 요청이 커지는 이유입니다.

import solution as lab
from common.brain import FakeBrain
from common.show import show

fake, sizes = FakeBrain(), []


def counting_brain(request):
    sizes.append(len(request["messages"]))     # 이번 요청에 실린 메시지 수 기록
    return fake(request)


messages = []
for said in ["안녕", "오늘 날씨 어때?", "고마워"]:
    lab.chat_once(counting_brain, messages, said)

print("매 호출에 보낸 메시지 수:", sizes)
show(messages, "3턴 뒤의 배열")
실행 결과
매 호출에 보낸 메시지 수: [1, 3, 5]
── 3턴 뒤의 배열 ──
[0] user      "안녕"
[1] assistant text "[fake] 메시지 1개를 받았어요. 마지막: '안녕'"
[2] user      "오늘 날씨 어때?"
[3] assistant text "[fake] 메시지 3개를 받았어요. 마지막: '오늘 날씨 어때?'"
[4] user      "고마워"
[5] assistant text "[fake] 메시지 5개를 받았어요. 마지막: '고마워'"

💡 바꿔 보기: 리스트에 말을 하나 더 넣어 보세요. 보낸 메시지 수가 몇이 될까요?

예제 3 역사 고쳐 쓰기: 배열이 곧 진실

세션 파일은 그냥 JSON 입니다. 파일 속 assistant 답을 몰래 바꾸면, 모델은 자기가 그렇게 말했다고 믿습니다. 모델이 아는 '과거'는 배열뿐이니까요.

(그래서 하네스는 배열을 함부로 고치면 안 되고, 디버깅할 땐 배열부터 봐야 합니다.)

import os
import tempfile
import solution as lab
from common.brain import reply, text


def parrot(request):
    """자기가 '마지막으로 한 말'을 배열에서 찾아 인용하는 두뇌"""
    said = [b["text"] for m in request["messages"] if m["role"] == "assistant"
            for b in m["content"]]
    return reply(text(f"제가 아까 '{said[-1]}' 라고 했잖아요." if said else "처음 뵙겠습니다."))


path = os.path.join(tempfile.mkdtemp(), "session.json")
messages = []
print("턴 1:", lab.chat_once(parrot, messages, "안녕"))
lab.save_session(path, messages)

data = lab.load_session(path)                     # 파일을 열어
data[1]["content"][0]["text"] = "저는 사실 고양이예요"  # assistant 답을 바꿔치기
lab.save_session(path, data)

messages = lab.load_session(path)                 # 다시 불러와 대화를 이어가면
print("턴 2:", lab.chat_once(parrot, messages, "아까 뭐라고 했죠?"))
실행 결과
턴 1: 처음 뵙겠습니다.
턴 2: 제가 아까 '저는 사실 고양이예요' 라고 했잖아요.

💡 바꿔 보기: 바꿔치기할 문장을 다른 말로 고쳐 실행해 보세요.

3 확인 퀴즈

Q1 두 번째 사용자 발화를 보낼 때 요청의 messages 에는 몇 개의 메시지가 들어가나요?

Q2 세션 파일의 assistant 답변을 손으로 고친 뒤 대화를 이어가면?

4 실습: exercise.py 채우기

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

5 정답과 테스트

정답 코드 보기 (solution.py)
#!/usr/bin/env python3
"""Lab 1 — 대화는 그냥 JSON 배열이다.

LLM API는 상태가 없습니다. 매 턴마다 '대화 전체'를 배열로 다시 보내고,
돌아온 응답을 배열 끝에 붙입니다. 세션 = 이 배열을 파일에 저장한 것.

실행:  python labs/01_array/solution.py [session.json]
"""
import json
import os
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 = "너는 간결하게 답하는 조수다."


def add_user(messages, text):
    """사용자 발화를 배열 끝에 붙이고 배열을 반환한다."""
    # >>> TODO: {"role": "user", "content": text} 를 messages 끝에 추가하세요
    messages.append({"role": "user", "content": text})
    # <<< TODO
    return messages


def add_assistant(messages, response):
    """API 응답의 content '블록 리스트 전체'를 assistant 메시지로 붙인다.
    (텍스트만 뽑아 넣으면 나중에 tool_use 블록이 사라져 대화가 깨집니다)"""
    # >>> TODO: response["content"] 를 그대로 담은 assistant 메시지를 추가하세요
    messages.append({"role": "assistant", "content": response["content"]})
    # <<< TODO
    return messages


def text_of(response):
    """응답에서 text 블록들의 글자만 이어 붙인다."""
    # >>> TODO: type == "text" 인 블록의 "text" 를 모두 이어 붙여 반환하세요
    return "".join(b["text"] for b in response["content"] if b["type"] == "text")
    # <<< TODO


def build_request(messages, system=SYSTEM):
    """두뇌에게 보낼 요청 dict. 모델은 이것 '만' 봅니다."""
    return {"system": system, "messages": messages}


def chat_once(brain, messages, user_text):
    """한 턴: 사용자 발화 추가 → 배열 전체 전송 → 응답 추가 → 텍스트 반환."""
    # >>> TODO: 위의 세 함수와 build_request 를 이용해 한 턴을 완성하세요
    add_user(messages, user_text)
    response = brain(build_request(messages))
    add_assistant(messages, response)
    return text_of(response)
    # <<< TODO


def save_session(path, messages):
    """세션 = 배열을 JSON 파일로 저장한 것."""
    # >>> TODO: messages 를 path 에 JSON으로 저장하세요 (ensure_ascii=False, indent=2 권장)
    with open(path, "w", encoding="utf-8") as f:
        json.dump(messages, f, ensure_ascii=False, indent=2)
    # <<< TODO


def load_session(path):
    """파일이 없으면 빈 배열(새 대화)."""
    # >>> TODO: 파일이 있으면 JSON을 읽어 반환, 없으면 [] 를 반환하세요
    if not os.path.exists(path):
        return []
    with open(path, encoding="utf-8") as f:
        return json.load(f)
    # <<< TODO


def main():
    path = sys.argv[1] if len(sys.argv) > 1 else None
    brain = get_brain()
    messages = load_session(path) if path else []
    print("명령: /quit 종료, /clear 새 대화, /dump 배열 보기")
    while True:
        try:
            user_text = input("\nyou> ").strip()
        except EOFError:
            break
        if user_text in ("/quit", ""):
            break
        if user_text == "/clear":
            messages = []
            continue
        if user_text == "/dump":
            print(json.dumps(messages, ensure_ascii=False, indent=2))
            continue
        print(f"\nassistant> {chat_once(brain, messages, user_text)}")
        if path:
            save_session(path, messages)


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  # noqa: E402

lab = load_target(__file__)


class Lab1(unittest.TestCase):
    def test_add_user_shape(self):
        m = lab.add_user([], "안녕")
        self.assertEqual(m, [{"role": "user", "content": "안녕"}])

    def test_add_assistant_keeps_all_blocks(self):
        r = reply(text("a"), text("b"))
        m = lab.add_assistant([], r)
        self.assertEqual(m[0]["role"], "assistant")
        self.assertEqual(m[0]["content"], r["content"], "content 블록 리스트를 통째로 넣어야 합니다")

    def test_text_of(self):
        self.assertEqual(lab.text_of(reply(text("가"), text("나"))), "가나")

    def test_every_call_resends_whole_history(self):
        brain = ScriptedBrain([reply(text("첫 답")), reply(text("둘째 답"))])
        messages = []
        self.assertEqual(lab.chat_once(brain, messages, "하나"), "첫 답")
        self.assertEqual(lab.chat_once(brain, messages, "둘"), "둘째 답")
        first, second = brain.requests
        self.assertEqual(len(first["messages"]), 1)
        self.assertEqual(len(second["messages"]), 3,
                         "두 번째 호출에는 [user, assistant, user] 전체가 가야 합니다")
        self.assertEqual(second["messages"][0]["content"], "하나")
        self.assertEqual(len(messages), 4)
        self.assertEqual([m["role"] for m in messages],
                         ["user", "assistant", "user", "assistant"])

    def test_session_roundtrip(self):
        with tempfile.TemporaryDirectory() as d:
            p = os.path.join(d, "s.json")
            self.assertEqual(lab.load_session(p), [], "없는 파일 = 새 대화 = []")
            msgs = [{"role": "user", "content": "한글 저장"}]
            lab.save_session(p, msgs)
            self.assertEqual(lab.load_session(p), msgs)


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

📚 더 읽을거리

교재 전체 목록 →