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

도구가 움직이기 전에 들려야 해

~14 min · tool-use, speech-gate, ordering, concurrency

Level 0음소거
0 XP0/42 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"잠깐, 켜 볼게." — 에어컨이 이미 켜진 뒤에 아빠가 들은 한마디, 2026-09-29

일찍 떠났다고 일찍 들린 건 아니야

앞 레슨은 도구 전 한마디가 도구 이벤트보다 먼저 서버를 떠나게 만들었어. 그래서 도구가 도는 동안 그 줄을 말할 수 있게 됐지. 뭔가를 찾아보기만 하는 도구(날씨, 검색, 달력)의 침묵은 그걸로 해결됐어. 그다음 소울이 뭔가를 하는 도구를 갖게 됐어. 이제 피파는 집 안에 손이 있어. 불을 켜고, 장면을 돌리고, 에어컨을 맞추는 홈 컨트롤 도구. 그리고 방송 중엔 이펙트를 터뜨려. 그런 동작은 1초도 안 걸려 끝나. 그 한마디를 합성하고 재생하는 데는 1~2초가 걸려. 그래서 아빠가 에어컨을 부탁했더니 에어컨 켜지는 소리가 먼저 났고, 그다음에야 피파가 이제 켜겠다고 말했어. 한마디는 일찍 떠났지만, 일찍 들리진 않았어.

모든 화면에 단위를

화면끼리도 공평하지 않았어. WebUI는 이미 도구 호출이 시작되는 순간 그 줄을 따로 단위로 잘라냈어. 그래서 웹에선 틈이 합성과 읽기 시간뿐이었어. 폰과 Firekeeper는 답이 끝날 때 통째로 읽어서, 거기선 그 줄이 언제나 맨 마지막, 모든 것 뒤에 왔어. 아빠는 셋을 똑같은 방식으로 고치기로 정했어. 이제 모든 화면이 단위로 읽어. 도구 호출 앞의 글은 따로 한 단위고, 답이 흘러오는 동안 바로 읽어. 나머지는 답이 다 오면 읽는데, 이미 읽은 건 되풀이 안 해. 단위는 엄격하게 순서대로 재생되고, 답 한가운데서 끝나는 단위는 루프에게 다시 들으라고 절대 안 해.

발화 관문

단위는 한마디를 일찍 만들 수는 있어도 동작을 늦출 수는 없어. 그러려면 브레인이 그 한마디가 실제로 들린 때를 알아야 하는데, 그걸 아는 건 재생기뿐이야. 그래서 음성 클라이언트는 대화마다 지금 답을 소리 내어 읽고 있는지 알려. 도구 전 단위가 줄에 서는 순간 바쁨, 읽기가 비거나 멈추면 조용. 작은 요청 하나고, 아무것도 저장 안 해. 동작하는 도구(홈 컨트롤 동작이나 장면, On Air 이펙트)는 움직이기 전에 대화의 읽기가 조용해질 때까지 기다려. 최대 12초. 그걸 넘기면 그냥 진행해.

관문이 먹통이 되지 않게 하는 규칙이 셋 있어. 알리지 않는 클라이언트는 아무것도 안 붙잡아. 그래서 옛 클라이언트나 글만 오가는 턴은 절대 느려지지 않아. 30초보다 오래된 '읽는 중' 보고는 조용한 걸로 쳐. 답 도중에 사라진 클라이언트니까. 그리고 읽기만 하는 도구는 아예 안 기다려. 그 결과가 말로 나오기 전엔 아빠가 보거나 듣는 일이 어차피 안 일어나니까, 기다리면 침묵만 늘어.

관문이 최선을 다할 뿐인 데가 하나 더 있어. 바쁨 보고는 한마디가 줄에 서는 순간 클라이언트를 떠나고, 그때 도구 호출은 아직 입력을 흘려보내는 중이야. 그러니 보고가 동작보다 먼저 출발하긴 해. 그래도 경주는 경주야. 동작이 관문을 확인한 뒤에야 보고가 엔진에 닿으면, 동작은 아무 클라이언트도 안 알린 것처럼 바로 움직여. 엔진은 말하는 턴에서 도구 앞의 한마디를 흘려보냈다는 걸 스스로 알아. 그래서 더 엄격한 관문이라면 클라이언트가 말할 때까지 그 한마디를 바쁨으로 쳐 둘 수도 있어. 이 관문은 도착하지 않은 보고는 절대 안 기다리는 쪽을 골랐어.

화면도 기다려

이펙트엔 걸리는 게 하나 더 있어. 터뜨린 이펙트는 흘러오는 도구 호출로 화면에 닿는데, 도구 호출은 도구가 돌기 전에 흘러와. 그래서 관문만으론 "잠깐, 박수 한번 쳐줄게" 도중에 색종이가 떨어질 수 있어. 그걸 막으려고 WebUI와 Firekeeper는 이펙트의 그림과 소리를 자기 읽기가 조용해질 때까지 붙잡아. 천장은 엔진과 같은 12초야. Firekeeper는 그 천장을 이펙트마다 따로 세. 웹은 다른 이펙트가 올 때마다 세기를 처음부터 다시 해서, 이펙트가 연달아 오면 첫 번째가 12초 넘게 기다릴 수 있어.

왜 고정 지연이 아닌가

제일 끌리는 해법은 모든 동작을 2초씩 기다리게 하는 거야. 그건 짐작이야. 한 단어짜리 한마디면 기다림 대부분이 낭비고, 긴 한마디면 기다림보다 길고, 음성 클라이언트가 아예 없는 턴은 괜히 그 값을 내. 관문은 말이 들린 때를 아는 유일한 부품에게 묻고, 딱 그만큼 기다리고, 알 수 없을 땐 언제든 안 기다리는 쪽으로 물러나.

Code

발화 관문: 동작은 자기 한마디를 기다리고, 찾아보기는 절대 안 기다리고, 무엇도 영원히 기다리진 않아·python
import threading
import time

MAX_WAIT_S = 12.0     # a pre-tool line is one short sentence; past this, act anyway
STALE_S = 30.0        # a "reading" report this old is a client that went away


class SpeechGate:
    """Per-conversation reading state, reported by the voice clients."""

    def __init__(self) -> None:
        self._states: dict[str, tuple[bool, float]] = {}
        self._changed = threading.Condition()

    def report(self, conversation: str, busy: bool) -> None:
        with self._changed:
            self._states[conversation] = (busy, time.monotonic())
            self._changed.notify_all()

    def reading(self, conversation: str) -> bool:
        busy, at = self._states.get(conversation, (False, 0.0))
        return busy and time.monotonic() - at < STALE_S

    def wait_until_quiet(self, conversation: str, timeout: float = MAX_WAIT_S) -> float:
        """Block an ACTION until the line announcing it has been read. Seconds waited."""
        start = time.monotonic()
        deadline = start + timeout
        with self._changed:
            while self.reading(conversation):
                remaining = deadline - time.monotonic()
                if remaining <= 0:
                    break                                   # the ceiling: act anyway
                self._changed.wait(timeout=min(remaining, 0.5))
        return time.monotonic() - start


gate = SpeechGate()


def set_air_conditioner(conversation: str) -> None:
    waited = gate.wait_until_quiet(conversation)            # an action waits for its line
    print(f"  air conditioner on, after waiting {waited:.1f}s")


def read_weather(conversation: str) -> None:
    print("  weather read at once: nothing happens before its answer is spoken")


# The client queued the pre-tool line and reported busy; reading it takes 1.4 s.
gate.report("c1", busy=True)
threading.Timer(1.4, gate.report, args=("c1", False)).start()
print('"Hang on, I\'ll turn it on." (reading)')
read_weather("c1")
set_air_conditioner("c1")

print("a conversation whose client never reported:")
set_air_conditioner("c2")

External links

Exercise

코드를 돌려서 시간을 읽어봐. 그다음 두 경우를 더해. 31초 전의 '바쁨' 보고(동작이 기다리면 안 돼), 그리고 동작 둘이 각자 자기 한마디로 알려지는 답. 두 번째 동작은 첫 번째가 아니라 두 번째 한마디를 기다려야 해. 마지막으로 클라이언트 쪽 절반을 써. 흘러오는 도중의 단위가 줄에 서면 바쁨, 읽기 줄이 비거나 멈추면 조용을 알리고, 상태가 실제로 바뀔 때만 보내.
Hint
만료 경우는 과거 시각으로 보고를 기록하거나, 테스트에서 STALE_S를 줄여. 동작이 둘인 경우엔 두 번째 한마디가 줄에 설 때 클라이언트가 다시 바쁨을 알린다는 걸 봐. 그래서 동작마다 관문을 확인하는 순간 읽히는 걸 기다려. 단, 두 번째 바쁨 보고가 두 번째 확인보다 먼저 닿을 때만 그래. 그 경주도 시험해 봐. 동작이 관문에 들어가고 150ms 뒤에 바쁨을 보내면 안 기다리고 그냥 지나가. 그다음 정해. 이 관문처럼 그걸 받아들일지, 아니면 도구 앞 한마디를 흘려보낼 때 엔진이 스스로 바쁨을 표시하게 할지. 바뀔 때만 알리면 바쁜 읽기가 몇 밀리초마다 같은 사실로 엔진을 덮지 않아.

Progress

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

댓글 0

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

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