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

한 번에 한 턴

~16 min · master-chief, concurrency, asyncio, conversation-tree

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

기기는 둘, 기록은 하나

방을 같이 쓰면 부딪혀. WebUI 답이 아직 흘러나오는데 아빠가 Telegram으로 보내. 피파 앱이 다시 연결되면서 신호 없는 데서 쳐둔 메시지를 쏟아내. 브라우저 탭이랑 폰이 둘 다 초안을 쥐고 있어. 이게 전부 따로따로 요청으로 와. 각 요청을 그냥 두면, 그 순간의 기록을 읽고, 제일 최신 행을 부모로 고르고, 모델을 불러. 두 요청이 동시에 그러면 엉켜. 사용자 행에 사용자 행이 매달리고, 답이 엉뚱한 질문에 붙고, 모델은 1초 전에 온 메시지를 못 본 채 답해. 영원히 이어질 대화에서 엉킴은 잠깐의 오류가 아니라 영구적인 갈림길이야.

공정한 락, 저장이 끝날 때까지

답은 master_chief.turn()이야. 받아들여진 Chief 턴은 전부, 브레인 핸들러가 돌기 전에 이 비동기 컨텍스트 매니저에 들어가. 대화마다 asyncio.Lock을 하나 둬 (키는 데이터베이스 실제 경로랑 대화 id). 파이썬 락은 공정해. 여럿이 기다리면 먼저 기다리기 시작한 쪽이 먼저 들어가. 그래서 모든 기기의 턴이 도착 순서대로 들어와. 락은 입장부터 답이 끝나고 저장될 때까지 쥐고 있어. 라우트의 프로듀서가 락에 들어가서, 원래 채팅 핸들러를 부르고, 흘러나온 답을 끝까지 다 받고, 그제야 놔. 그래서 다음 턴은 앞 턴의 답이 이미 들어 있는 기록에서 시작해.

락 안에서 문을 하나 더 지나. chief_turn인데, Master Chief보다 먼저 있던 거야. 대화에 흐르는 스트림이 있거나, 예약(heartbeat) 턴이 돌거나, 다른 chief 턴이 있거나, 입장과 스트림 사이에 걸린 채팅 파이프라인이 있으면 0.1초씩 기다려. 크론 턴이랑 Coop 자체의 chief 코디네이터가 이미 쓰던 배제야. Chief도 여기 합류하면서 Chief 턴, 예약 턴, Coop 깨우기가 한 대화에서 절대 겹치지 않게 됐어. 2026-10-08 콜드 리뷰가 입장 순서랑 부모 사슬을 실제 방에 대고 점검했고, 문제없다고 했어. 끊긴 부모 0개, 어시스턴트 자식이 둘 이상인 사용자 행 0개.

새 턴은 최신 답에 매달린다

클라이언트가 말하는 부모는 안 믿어. 폰은 1분 전에 Telegram이 뭘 보냈는지 알 수가 없거든. 그래서 새 턴마다 라우트는 클라이언트가 보낸 parent_id를 그냥 버려. 받아들여지고 나면 채팅 핸들러가 저장소한테 최신 살아 있는 행을 물어 (컴팩션이 리플레이에서 빼지 않은 가장 새 메시지). 그리고 턴을 거기 매달아. 엔진 테스트가 딱 이걸 해. 첫 메시지가 답하는 중에 부모를 "stale"로 단 두 번째 메시지를 보내고, 두 번째가 첫 답의 자식으로 붙는지 확인해.

고칠 수 있는 건 마지막 메시지뿐

피파 대화는 나무야. 메시지를 고치면 형제 가지가 생겨. 평범한 채팅에선 기능이지. Chief에선 콜드 리뷰 7번 지적이었어. 모든 문은 어느 가지든 가장 새 행에서 이어가. 그래서 WebUI에서 옛 메시지 하나를 고치면 그 가지가 가장 새 것이 됐어. 폰이랑 Telegram이 그쪽으로 넘어가고, 고친 지점 뒤의 턴이 며칠 치가 통째로 모델의 경로에서 빠졌어. 고친 커밋(7920a88f)이 넣은 게 edit_refusal이야. 고치는 메시지의 부모가 마지막 사용자 메시지의 부모와 같을 때만 돌고, 아니면 거부해. "In Master Chief only the latest message can be edited: every device continues from it, so an older one stays as it was." 확인은 두 번 해. 입장 전에 한 번 (409), 락을 잡은 뒤에 또 한 번 (edit_refused 에러 이벤트). 앞에 줄 선 턴이 "마지막 메시지"를 바꿔놓을 수 있으니까. WebUI는 그 메시지 하나에만 편집 버튼을 보여줘.

같은 지적엔 뒷부분이 있었어. 예전엔 편집이 고친 메시지 자신의 맥락을 달고 갔어. 그래서 Telegram 턴을 WebUI에서 고치면 답이 Telegram으로 다시 나갔고, 폰 턴을 고치면 브라우저 시계가 폰 밑으로 기록됐어. 이제 편집은 아빠가 고치는 그 자리에서 일어나. WebUI는 보내는 맥락에서 출처 필드를 지우고, 라우트는 편집할 수 없는 문(Telegram, 피파 앱, PippaGo, 예약 작업)을 가리키는 출처를 버려.

여러 작성자가 한 줄을 같이 쓰면, 자리는 서버가 정해. 클라이언트는 뭘 더하고 싶은지 말할 수 있어. 어디에 들어갈지는 모든 도착을 보는 쪽만 말할 수 있어.

돌고 있는 턴은 자기 도구를 지킨다

레슨 2에서 호스트 도구가 받아들여진 턴을 따라간다고 했지. 그 장치가 이거야. turn()이 락을 잡으면 ContextVar인 _origin에 이 턴의 대화와 출처를 넣고, 나갈 때 되돌려. asyncio 태스크는 저마다 컨텍스트 사본 안에서 돌아. 락을 기다리는 메시지는 다른 태스크라서, 그게 뭘 들고 있든 돌고 있는 턴의 값엔 손이 안 닿아. 따로 들어온 HTTP 요청으로 도착한 도구 콜백은 이 변수를 아예 못 물려받아. 그땐 받아들여진 사용자 메시지에 저장된 출처로 돌아가.

Code

엔진 발췌 (원문 그대로, services/master_chief.py): 저장까지 쥐고 있는 FIFO 입장·python
@asynccontextmanager
async def turn(conversation_id: str, *, store: ConversationStore | None = None,
               origin_conversation_id: str | None = None, cancellable: bool = False):
    """FIFO admission before history/parent selection, held through persistence.

    asyncio.Lock is fair. App bindings are task-local, so an arriving message
    cannot replace the active turn's tools while it waits for admission.
    """
    store = store or ConversationStore()
    key = (str(Path(store.db_path).resolve()), conversation_id)
    lock = _turn_locks.setdefault(key, asyncio.Lock())
    task = asyncio.current_task()
    waiting = _waiting.setdefault(conversation_id, set()) if cancellable else set()
    if cancellable:
        waiting.add(task)
    from app.streaming import chief_turn
    try:
        async with AsyncExitStack() as stack:
            await stack.enter_async_context(lock)
            await stack.enter_async_context(chief_turn(conversation_id))
            waiting.discard(task)
            token = _origin.set((conversation_id, origin_conversation_id))
            try:
                yield
            finally:
                _origin.reset(token)
    finally:
        waiting.discard(task)
        if cancellable and not waiting:
            _waiting.pop(conversation_id, None)
직접 돌려볼 수 있는 최소 스케치: 기기 셋, 공정한 락 하나, 부모는 서버가 고른다 (LOCKED = False로 바꾸면 엉킴이 보여)·python
from __future__ import annotations

import asyncio

LOCKED = True                  # set to False to watch the room tangle

rows: list[dict] = []          # the conversation: {"id", "role", "text", "parent"}
locks: dict[str, asyncio.Lock] = {}


class NoLock:
    async def __aenter__(self):
        return self

    async def __aexit__(self, *exc):
        return False


def newest_id() -> str | None:
    return rows[-1]["id"] if rows else None


async def model(text: str) -> str:
    await asyncio.sleep(0.2 if text == "from the phone" else 0.01)  # one slow reply
    return "reply to " + text


async def turn(conversation: str, text: str, client_parent: str | None) -> None:
    lock = locks.setdefault(conversation, asyncio.Lock()) if LOCKED else NoLock()
    async with lock:                          # fair: waiters enter in arrival order
        parent = newest_id()                  # the client's parent is ignored
        user = {"id": f"u{len(rows)}", "role": "user", "text": text, "parent": parent}
        rows.append(user)
        answer = await model(text)            # held through the reply...
        rows.append({"id": f"a{len(rows)}", "role": "assistant", "text": answer,
                     "parent": user["id"]})   # ...and through persistence


async def main() -> None:
    await asyncio.gather(
        turn("chief", "from the phone", client_parent=None),
        turn("chief", "from telegram", client_parent="stale"),
        turn("chief", "from the browser", client_parent="stale"),
    )
    for row in rows:
        print(f'{row["id"]:>3} <- {str(row["parent"]):>4}  {row["role"]:<9} {row["text"]}')


asyncio.run(main())

External links

Exercise

스케치를 두 번 돌려봐. LOCKED = True로 한 번, LOCKED = False로 한 번. 두 나무를 종이에 그려. 어느 사용자 행이 사용자 행을 가리키는지, 어느 답이 어느 질문에 붙었는지. 그다음 네 프로젝트에서 여러 클라이언트가 순서가 있는 목록 하나에 덧붙이는 곳을 찾아 (채팅, 활동 로그, 장부). 지금 새 항목의 자리를 누가 정하는지 적어. 클라이언트가 정한다면, 그 결정을 스레드별 락 뒤의 서버로 옮기고, 같은 낡은 부모를 단 항목 두 개를 보내는 테스트를 추가해.
Hint
스레드별 락은 프로세스 하나 안에서만 통해. 작성자가 여러 프로세스나 여러 머신에 있다면, 같은 규칙이라도 데이터베이스 수준에서 풀어야 해. 작성자를 하나로 몰거나, 부모가 더는 최신 행이 아니면 실패하는 조건부 insert를 쓰거나.

Progress

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

댓글 0

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

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