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

턴이 지금 어디 있는지

~15 min · master-chief, observability, ux, long-polling, compaction

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

모든 기다림에 단어 하나

앞 레슨들은 방에서 소음을 빼는 얘기였어. 이 레슨은 그 반대쪽 실패야. 뭔가 있는 것처럼 보이는 침묵. 2026-10-18에 해외 여행길에서 음성 모드를 쓰던 아빠가 알아챘어. 아빠가 마지막 말을 하고 피파 목소리가 나오기까지, 화면엔 "Thinking" 한 단어뿐이었어. 받아쓰기, 업로드, 화자 확인, 기억 검색, 모델이 아직 조용한 시간, 생각하는 시간이 전부 똑같아 보였고, 멈춘 단계도 바쁜 단계랑 똑같이 깜빡였어. 아빠를 불안하게 한 건 기다림이 아니었어. 안 움직이는 줄이었지.

"이걸 실시간으로 뭘 하고 있는지 계속 보여주면 응답이 늦어지더라도 불안하지는 않다고."
— 아빠

끝나지 않는 대화에선 이게 어디보다 중요해. Master Chief 턴은 다른 문의 턴 뒤에서, 음성 확인 뒤에서, 압축 뒤에서, 긴 도구 호출 뒤에서 기다릴 수 있어. 그런데 밖에서 보면 예전엔 그게 다 모델이 느린 걸로 보였어.

보드

이제 office가 턴마다 지금 어디 있는지 알려 (services/turn_stage.py, 투두 #531). 대화마다 항목 하나씩 두는 메모리 속 보드야. 그리고 일부러 시간을 따로 재지 않아. 턴을 로그용으로 이미 쪼개고 있던 시계 둘, [turn-prep]와 [turn-provider]가 표시를 찍을 때마다 여기에도 알려. 그래서 화면이 읽는 단계가 곧 로그가 이름 붙일 단계야. 순서는 이래. received, voice_check, preparing, memory, model(물어봤는데 아직 돌아온 게 없음), thinking과 writing(각각 첫 조각), 도구 이름이 붙는 tool, compacting, 그리고 done.

화면은 롱 폴링으로 읽어. 요청 id랑 마지막으로 본 순번을 들고 GET /api/chat/stage/{conversation_id}를 부르면, office는 단계가 그 순번을 넘어서는 순간, 아니면 길어야 25초 뒤에 답해. 다른 요청의 항목은 단계 없음으로 읽혀서, 화면이 남의 턴을 자기 턴처럼 보여 주는 일은 없어. 항목엔 단계마다 처음 들어간 시각도 남고, 끝난 턴은 최근 64개까지 요청 id로 찾을 수 있어 (끝난 항목 자체는 10분 뒤 보드에서 빠지고). 음성 턴 타임라인이 서버 쪽 절반을 이 보드에서 읽거든. 그래서 턴의 기록과 화면의 줄은 같은 한 군데서 나와. 비공개 대화의 항목은 로그에도 안 남고 저장도 안 돼.

압축도 단계야

아빠가 그날 저녁 요구 하나를 더 얹었어. 느린 턴을 압축으로 착각하면 안 되고, 거꾸로도 마찬가지라고. 그래서 압축은 전부 compacting과 그 끝을 알리고, 누구 압축인지 종류를 달아. before_reply(보통 대화에서 답하기 전에 도는 비상 압축), background(턴 사이에 도는 Master Chief 패스), manual(아빠가 채팅 메뉴에서 직접 한 것), 그리고 턴 안에서 벤더가 직접 하는 것. claude는 Claude CLI가 알려 주는 상태를 읽고, codex는 Codex 어댑터가 받는 Responses 스트림의 압축 항목을 읽어. 백그라운드 패스는 이미 돌고 있는 턴을 절대 가리지 않아. 대화 메타엔 compacting, last_compacted_at, 그리고 그 종류가 실려. 보드 자체는 메모리에만 있으니까, 재시작 뒤엔 메타가 제일 새 요약 행으로 대신해. 그러면 우리 쪽 마지막 압축이 언제였는지는 남지만, 원래 종류도, 턴 안에서 벤더가 한 압축도 안 남아. 폰 입력창 줄은 이제 늘 이걸 말해. 압축이 도는 중이거나, 아니면 "Last compacted"와 시각, 그리고 누가 했는지(Claude, Codex, 턴 사이, 아빠).

"멈춤"은 타이머가 아니라 진단이야

첫 버전의 줄도 한 군데선 여전히 거짓말을 했어. 휴대폰 데이터로 밖을 걷던 중에, 사진을 붙인 음성 턴 두 개에 "Uploading your recording · stalled, nothing for 70s"가 떴어. 실제로 넘어가던 건 사진이었는데. 그 빌드는 업로드를 전부 녹음이라고 불렀고, 다음 빌드는 사진 한 장이 다 올라가야 진행으로 쳤어. 그러니 40 KB/s로 1.5 MB짜리 사진을 올리면 바이트는 움직이는데도 화면엔 멈췄다고 떴을 거야. 그 길에서 아빠가 정한 규칙은 셋이었어. 어떤 단계도 얼어붙은 단어 하나로 머물지 않는다, "멈춤"은 진단이다, 워치는 폰이 보여 주는 걸 보여 준다.

  • 단어 말고 증거. 업로드는 바이트와 실제로 잰 속도를 보여 줘 ("Uploading photo 2 of 4 · 1.1 of 3.6 MB · 40 KB/s"). 남은 양이 더 많은 쪽 이름을 대고. 생각과 쓰기는 도착한 글자 수를 보여 줘. office에서만 일어나는 단계는 office가 보드의 롱 폴링에 마지막으로 언제 답했는지를 보여 줘. 새 증거는 진행이고, 같은 증거는 진행이 아냐.
  • 판정 전에 확인. 단계가 제한을 넘기면 폰이 먼저 자기 연결을, 그다음 office를 확인해 (보드 답이 15초 안이면 그걸로, 아니면 헬스 라우트로). 15초마다 다시 하고, 찾은 걸 줄에 적어. 47초째 바이트가 안 나가는데 office는 답한다면 휴대폰 데이터가 바이트를 못 나르고 있다고 적고, office는 답하는데 모델이 아직 시작을 안 했다거나, 폰에 아예 네트워크가 없다고 적기도 해. 주황색은 문제가 있을 때만 써.
  • 문구는 하나. 워치는 자기 턴에 대한 폰의 줄을 그대로 받아. 워치 앱이 앞에 있을 때만, 많아야 1초에 한 번. 그리고 그 줄이 얼마나 묵었는지 붙여 ("iPhone quiet 9s"). 손목에 문구를 따로 하나 더 두지 않아. 웹 음성 화면도 같은 진단에 같은 말로 닿아.
증거를 보여 주는 기다림은 안전하고, 얼어붙은 이름표는 고장이야. 이미 쓰고 있는 시계로 단계 출처 하나를 먹이고, 모든 화면이 그 하나를 읽게 해. 그리고 "멈춤"은 뭔가를 실제로 확인한 다음에만 말해.

Code

services/turn_stage.py 원문 발췌: 보드는 기존 시계가 이미 찍는 표시를 따라 움직인다·python
# After the route's mark named here, the next step is this stage. The marks
# are the ``[turn-prep]`` line's own (routes/chat.py and the Ollama route
# make them in this order); a mark not listed keeps the stage it is in.
STAGE_AFTER_MARK: dict[str, str] = {
    "resolve": "voice_check",
    "speaker": "preparing",
    "between": "memory",
    "history": "memory",
    "rag_and_prompt": "preparing",
}

# ``ProviderClock.first`` stages that move the board.
STAGE_AT_PROVIDER: dict[str, str] = {
    "sent": "model",
    "first_thinking": "thinking",
    "first_text": "writing",
}

# A finished entry is kept this long for a late reader, then dropped.
_KEEP_DONE_SECONDS = 600.0
# Finished turns findable by request id (``finished``), newest last.
_KEEP_FINISHED = 64
직접 돌려볼 수 있는 최소 스케치: 턴이 단계를 알리고, 화면이 롱 폴링으로 읽고, 조용한 단계엔 타이머 대신 확인이 붙는다 (표준 라이브러리, Python 3.9 이상)·python
"""A minimal sketch: one stage board a turn publishes to, a screen that long-polls
it, and "stalled" said only after something was checked. Not the engine."""
import asyncio
import time


class Board:
    def __init__(self):
        self.entry = {"stage": None, "detail": None, "seq": 0, "since": time.monotonic()}
        self.changed = asyncio.Event()

    def publish(self, stage, detail=None):
        self.entry = {"stage": stage, "detail": detail, "seq": self.entry["seq"] + 1,
                      "since": time.monotonic()}
        self.changed.set()                  # wake every waiter...
        self.changed = asyncio.Event()      # ...and arm the next move

    async def wait_past(self, seq, wait):
        """Answer as soon as the stage moves past seq, or after `wait` seconds."""
        if self.entry["seq"] > seq:
            return self.entry
        try:
            await asyncio.wait_for(self.changed.wait(), wait)
        except asyncio.TimeoutError:
            pass
        return self.entry


async def turn(board):
    steps = [("received", None, 0.05), ("voice_check", None, 0.2), ("memory", None, 0.3),
             ("model", None, 1.3), ("tool", "coop", 0.3), ("writing", None, 0.3), ("done", None, 0)]
    for stage, detail, seconds in steps:
        board.publish(stage, detail)
        await asyncio.sleep(seconds)


def check(link_ok, office_answered_ago):
    """A diagnosis, never a timer: look at the link, then at office."""
    if not link_ok:
        return "the phone has no network"
    if office_answered_ago is None or office_answered_ago > 15:
        return "office does not answer"
    return "office answers; the model has not started yet"


async def screen(board, limit=1.0):
    seq, start = 0, time.monotonic()
    while True:
        entry = await board.wait_past(seq, wait=0.5)
        quiet = time.monotonic() - entry["since"]
        moved = entry["seq"] != seq
        if moved or quiet > limit:
            line = entry["stage"] + (f" · {entry['detail']}" if entry["detail"] else "")
            if not moved:
                line += f" · quiet {quiet:.1f}s · {check(True, 2.0)}"
            print(f"{time.monotonic() - start:4.1f}s  {line}")
        seq = entry["seq"]
        if entry["stage"] == "done":
            return


async def main():
    board = Board()
    await asyncio.gather(turn(board), screen(board))


asyncio.run(main())

External links

Exercise

매일 쓰는 것 중에 제일 느린 기다림 하나를 골라 봐. 업로드든, 빌드든, 어시스턴트 답이든. 그동안 일어나는 단계를 전부 적고, 지금 화면이 그중 뭘 구별해 보여 주는지 표시해. 그다음 로그가 이미 남기는 사건으로 먹이는 단계 줄 하나를 붙여. 제일 자주 멈춘 것처럼 보이는 단계 하나엔, 시간 초과 문구 대신 뭘 찾았는지 말하는 확인을 넣어.
Hint
화면이 아니라 로그에서 시작해. 로그가 이름을 못 대는 단계라면 화면도 지어내면 안 돼. 확인은 질문 둘이면 대부분 덮여. 내 연결이 바이트를 나르고 있나, 서버가 대답은 하나. 그리고 둘 중 어느 쪽이 실패했는지 말해.

Progress

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

댓글 0

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

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