본문 바로가기
C.W.K.
Stream
Lesson 03 of 05 · published

두 번째 걸음: 문 규칙 하나

~15 min · first-steps, concurrency, idempotency, python

Level 0구경꾼
0 XP0/33 lessons0/11 achievements
0/100 XP to next level100 XP to go0% complete

문이 둘이 되면 다 달라져

첫걸음엔 터미널이 하나뿐이라 누가 먼저냐를 물을 일이 없었어. 두 번째 클라이언트가 같은 대화에 글을 보낼 수 있게 되는 순간, 그 질문이 생겨. 이번 걸음은 대화 하나에 문 두 개를 달아. 이미 있는 명령줄, 그리고 폰이나 브라우저 역할을 대신할 작은 HTTP 엔드포인트. 둘 다 같은 대화 ID를 대고, 둘 다 send라는 함수 하나를 지나가. 2트랙의 방 하나를 장난감으로 만든 거야. 어느 문이든 같은 대화를 찾아가고, 턴이 대화에 어떻게 붙을지는 문이 아니라 엔진이 정해.

규칙 셋

부모는 서버가 골라. 클라이언트는 다른 클라이언트가 방금 뭘 보냈는지 알 수가 없어. 그러니까 "내가 답하는 메시지"라는 클라이언트의 생각은 애초에 낡은 거야. 스케치는 클라이언트가 주는 부모를 절대 안 받아. 새 턴은 어느 문이 썼든 로그의 가장 새 행에 매달려. Master Chief도 새 턴마다 똑같이 하고, 클라이언트가 보낸 부모는 버려.

대화 하나엔 한 번에 한 턴. 잠금은 기록을 읽는 순간부터 답장을 다 쓸 때까지 쥐고 있어. 그래서 다음 턴은 직전 답이 이미 들어 있는 기록에서 출발해. 스케치는 로그 옆 잠금 파일에 fcntl.flock을 걸어. 스레드끼리도, Mac이나 리눅스 한 대 안의 프로세스끼리도 통해. 한 터미널의 서버와 다른 터미널의 CLI가 진짜로 서로를 기다려. Master Chief는 공정한 asyncio.Lock을 써서 도착한 순서대로 턴을 들여보내. flock은 순서를 약속 안 해. 이게 장난감인 이유 중 하나야.

요청 ID는 클라이언트가 만들어. 연결이 끊긴 폰은 메시지가 도착했는지 알 길이 없으니까 또 보내. 서버가 두 번째 사본을 못 알아보면 지시가 두 번 돌아. 그래서 클라이언트는 사용자가 보내기를 누를 때 ID를 딱 한 번 만들고, 재시도할 때마다 그 ID를 그대로 다시 써. 서버는 먼저 기록에서 그 ID를 찾고, 아는 ID면 모델을 또 부르는 대신 저장된 답을 already_accepted 상태로 돌려줘. Master Chief의 클라이언트들은 ID 앞에 어느 화면이 만들었는지 이름을 붙여. 텔레그램은 tg-<chat>-<message>, 브라우저는 webui-<uuid> 같은 모양이야.

경주를 돌려봐

python3 step2_doors.py race는 HTTP 문을 스레드로 띄워서 거기로 보내고, 50밀리초 뒤에 CLI 문을 쏴. 브라우저의 턴이 느린 가짜 모델을 아직 기다리는 동안이지. 잠금 없이 한 번, 잠금 걸고 한 번 돌리고, 그다음 브라우저의 요청을 같은 ID로 다시 보내.

잠금 없는 쪽을 꼼꼼히 읽어. 2트랙에서 말한 엉킴이 바로 이거야. 터미널의 메시지가 브라우저의 질문에 매달렸어. 사용자 행에 사용자 행이 이어진 거고, 브라우저의 질문엔 자식이 둘 생겼어. 갈라진 거지. 두 답장 모두 뭔가를 놓쳤어. 브라우저의 답은 터미널의 메시지를 못 봤고, 터미널의 답은 브라우저의 답을 못 봤어. 영원히 이어질 대화에서 이런 갈라짐은 영원히 남아. 잠금을 걸면 사슬이 깨끗해. 터미널의 턴이 기다렸고, 그 답은 자기 질문 앞에 놓인 브라우저의 질문과 답까지 봤어. 두 번 다 재전송은 already_accepted로 돌아왔고 행은 하나도 안 늘었어.

진짜 프로세스 두 개로 돌려볼 수도 있어. 한 터미널에서 python3 step2_doors.py serve, 다른 터미널에서 python3 step2_doors.py send hello나 serve 옆 주석에 있는 curl 줄.

이 스케치에 일부러 없는 것

진짜 시스템에 2트랙에서 본 장치가 전부 필요해지기 시작하는 게 딱 여기서부터야. 그리고 스케치엔 그중 하나도 없어.

  • 턴 상태가 없어. 질문을 쓰고 답을 쓰기 전에 서버가 죽으면, 재전송은 답 없는 already_accepted를 영원히 받아. 그다음 턴은 답 못 받은 그 질문에 매달려. 그래서 재전송 조회는 assistant 자식만 집어. 아무 자식이나 집으면 다음 턴의 질문을 답이라고 돌려주거든. Master Chief는 기다리는 턴을 pending이라고 말해줄 수 있고, Stop으로 철회된 턴과 종료 때문에 끊긴 턴을 구분해. 끊긴 턴은 클라이언트가 그냥 다시 보내면 돼.
  • Stop도, 종료 유예도 없어. 줄 선 턴을 철회할 방법이 없고, 서버를 죽이면 돌던 게 다 죽어. 재시작이 정확히 그렇게 한 게 콜드 리뷰의 2번 지적이었어.
  • 입장 심사가 없어. 포트에 닿는 누구든 대화에 글을 넣을 수 있어. 비공개 턴도, 자동 작업도, 다른 사람도 거절하지 않아. 스레드가 언제 문이 되는지 정하는 것도 없고.
  • 시간 제한이 없어. 모델 호출 하나가 멈추면 잠금을 쥔 채로 있고, 모든 문이 그 뒤에서 기다려.
  • 기계 한 대뿐이야. 잠금 파일은 디스크 하나에 있어. 서버 두 대가 기계 두 대에 있으면 완전히 다른 답이 필요해.
클라이언트는 대화 속 자기 자리를 고르지 않아. 자기 이름만 대. 부모는 서버가 고르고, 순서는 잠금이 고르고, 요청 ID 덕에 클라이언트는 두 번 시키지 않고도 "이거 받았어?"라고 물을 수 있어.

Code

돌려볼 수 있는 최소 스케치: step2_doors.py (이 레슨용으로 쓴 것, 표준 라이브러리만, macOS/리눅스, call_model이 네가 채울 스텁)·python
"""step2_doors.py: two doorways (a CLI and a tiny HTTP endpoint), one conversation.

New turns always hang off the newest row, one turn at a time per conversation,
and a client-minted request id makes a resend return the first answer.
Stdlib only, macOS/Linux (fcntl). Run:  python3 step2_doors.py race
Or two terminals:  python3 step2_doors.py serve   and   python3 step2_doors.py send hi
"""
import fcntl, json, sys, threading, time, urllib.request, uuid
from contextlib import contextmanager
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path

DATA = Path("doors-data")
LOCKED = True                       # the race below runs once without it


def call_model(system, messages):
    """STUB: your one model call goes here. The fake is slow on purpose,
    so two doorways arriving together really do overlap."""
    time.sleep(0.3)
    return f"(fake) I saw {len(messages)} message(s)"


def load(conv):
    p = DATA / f"{conv}.jsonl"
    return [json.loads(l) for l in p.read_text().splitlines() if l] if p.exists() else []


def append(conv, row):
    with (DATA / f"{conv}.jsonl").open("a") as f:
        f.write(json.dumps(row) + "\n")


@contextmanager
def conversation_lock(conv):
    """One turn at a time per conversation, across threads AND processes."""
    if not LOCKED:
        yield
        return
    with (DATA / f"{conv}.lock").open("w") as f:
        fcntl.flock(f, fcntl.LOCK_EX)           # blocks until the turn ahead is saved
        try:
            yield
        finally:
            fcntl.flock(f, fcntl.LOCK_UN)


def send(conv, text, request_id, door):
    """Every doorway calls this. The client never chooses the parent."""
    DATA.mkdir(exist_ok=True)
    with conversation_lock(conv):
        rows = load(conv)
        for r in rows:                  # a resend: answer from the record, run nothing
            if r.get("request_id") == request_id:
                reply = next((x for x in rows if x["parent"] == r["id"]   # its answer, not
                              and x["role"] == "assistant"), None)        # a later question
                return {"state": "already_accepted", "reply": reply and reply["content"]}
        parent = rows[-1]["id"] if rows else None          # the newest row, from any door
        user = {"id": uuid.uuid4().hex[:8], "parent": parent, "role": "user",
                "content": text, "door": door, "request_id": request_id}
        append(conv, user)
        replay = [{"role": r["role"], "content": r["content"]} for r in rows + [user]]
        reply = call_model("You are Pip.", replay)
        append(conv, {"id": uuid.uuid4().hex[:8], "parent": user["id"], "role": "assistant",
                      "content": reply, "replayed": len(replay)})
        return {"state": "done", "reply": reply}


class Door(BaseHTTPRequestHandler):
    def do_POST(self):
        body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
        data = json.dumps(send(body["conversation_id"], body["text"],
                               body["request_id"], "http")).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(data)

    def log_message(self, *args):
        pass


def post(port, conv, text, request_id):
    body = json.dumps({"conversation_id": conv, "text": text, "request_id": request_id})
    req = urllib.request.Request(f"http://127.0.0.1:{port}/chat", data=body.encode())
    return json.loads(urllib.request.urlopen(req).read())


def race(locked):
    global LOCKED
    LOCKED, conv = locked, f"race-{locked}"
    (DATA / f"{conv}.jsonl").unlink(missing_ok=True)
    server = ThreadingHTTPServer(("127.0.0.1", 0), Door)
    threading.Thread(target=server.serve_forever, daemon=True).start()
    port = server.server_address[1]
    web = threading.Thread(target=post, args=(port, conv, "sent from the browser", "web-1"))
    web.start()                                                 # door 1: HTTP
    time.sleep(0.05)
    send(conv, "sent from the terminal", "cli-1", "cli")        # door 2: CLI, a moment later
    web.join()
    rows = load(conv)
    text = {r["id"]: r["content"] for r in rows}
    print(f"LOCKED={locked}")
    for r in rows:
        print(f"  {r['role']:9} {r['content']!r:30} after: {text.get(r['parent'], '(start)')!r}")
    parents = [r["parent"] for r in rows]
    forks = len(parents) - len(set(parents))
    blind = sum(1 for i, r in enumerate(rows) if r["role"] == "assistant" and r["replayed"] < i)
    print(f"  forks: {forks}   replies that missed an earlier message: {blind}")
    again = post(port, conv, "sent from the browser", "web-1")  # the same request, resent
    print(f"  resend web-1 -> {again['state']}, rows still {len(load(conv))}")
    server.shutdown()


if __name__ == "__main__":
    DATA.mkdir(exist_ok=True)
    cmd = sys.argv[1] if len(sys.argv) > 1 else "race"
    if cmd == "race":
        race(False)
        race(True)
    elif cmd == "serve":   # curl -d '{"conversation_id":"default","text":"hi","request_id":"r1"}' 127.0.0.1:8765
        ThreadingHTTPServer(("127.0.0.1", 8765), Door).serve_forever()
    elif cmd == "send":
        print(send("default", " ".join(sys.argv[2:]), "cli-" + uuid.uuid4().hex[:8], "cli")["reply"])
실제로 돌렸을 때 경주가 찍은 것 (다섯 번 반복해도 똑같았어)·text
$ python3 step2_doors.py race
LOCKED=False
  user      'sent from the browser'        after: '(start)'
  user      'sent from the terminal'       after: 'sent from the browser'
  assistant '(fake) I saw 1 message(s)'    after: 'sent from the browser'
  assistant '(fake) I saw 2 message(s)'    after: 'sent from the terminal'
  forks: 1   replies that missed an earlier message: 2
  resend web-1 -> already_accepted, rows still 4
LOCKED=True
  user      'sent from the browser'        after: '(start)'
  assistant '(fake) I saw 1 message(s)'    after: 'sent from the browser'
  user      'sent from the terminal'       after: '(fake) I saw 1 message(s)'
  assistant '(fake) I saw 3 message(s)'    after: 'sent from the terminal'
  forks: 0   replies that missed an earlier message: 0
  resend web-1 -> already_accepted, rows still 4

External links

Exercise

경주를 돌린 다음, 규칙을 하나씩 일부러 깨봐. 첫째, 잠금은 둔 채로 클라이언트가 부모를 고르게 해봐(마지막으로 본 부모를 넘기게). 뭐가 틀어져? 둘째, post() 안에서 시도할 때마다 새 요청 ID를 만들고 두 번 불러봐. 행이 몇 개 생겨? 셋째, 턴이 call_model 안에서 자고 있을 때 Ctrl-C로 서버를 죽이고, 다시 띄운 다음 같은 요청 ID로 재전송해봐. 경우마다 클라이언트가 뭘 보는지 적어.
Hint
재밌는 건 세 번째야. 질문은 로그에 있고 답은 없어서, 재전송은 질문을 찾아내고 답 없는 already_accepted를 돌려줘. 클라이언트가 그다음에 뭘 해야 하는지, 그걸 알려주려면 서버가 뭘 기록해야 하는지 스스로 물어봐. 2트랙의 턴 상태들이 푸는 문제가 바로 그거야.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.