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

도구 전에 한마디

~14 min · tool-use, streaming, filters, spoken-units

Level 0음소거
0 XP0/35 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"Before you call a tool, say one short line first ('hang on, let me look'), so the silence has a reason." — 말하는 턴 지시

천이백 번 중 네 번

도구가 있어야 소울이 쓸모 있어. 날씨, 달력, 검색, 파일. 그런데 도구엔 시간이 들어. 글 채팅에선 아무도 신경 안 써. 준비되면 답이 뜨니까. 2026-09-25의 설계 조사에서 소울이 도구를 부르기 전에 뭐라도 말한 횟수를 셌더니, 도구를 쓴 턴 약 1,265개 중 4번이었어. 글에선 그 침묵이 안 보여. 말에선 딱 끊긴 전화 소리야. 그래서 말하기 지시는 도구 전에 짧은 한 줄을 요구해. "잠깐, 일산 미세먼지 확인해볼게" 같은 거. 그리고 음성 파이프라인은 그 한 줄을 별도의 말 단위로 만들어서, 도구가 도는 동안 재생해.

한 줄은 쓰였는데, 아무도 못 들었어

지시를 넣자마자 효과가 있었어. 모델은 그 줄을 썼어. 그런데 그 줄이 서버에 머물러 있었어. Claude 라우트의 필터 두 개가 글을 붙잡고 있었거든. 둘 다 음성 모드보다 오래됐고, 둘 다 이유가 있어서 있는 거야.

  • 앞머리 비계 필터는 모든 답의 처음 600자까지를 붙잡고, 새어 나온 "Assistant" 역할 표시가 있는지 지켜봐. 드물게 생기는 모델 오류를 지우려고 있는 거야. 붙잡은 걸 놓는 건 답이 끝났을 때뿐이었어.
  • 부드러운 사고 필터는 모든 스트림의 마지막 아홉 글자를 붙잡아 둬. 그게 <thinking> 태그의 시작일 수도 있으니까.

둘이 합쳐지니까, 도구 전의 한 줄은 도구 뒤의 내용까지 포함한 답 전체가 끝나기 전엔 서버를 떠날 수가 없었어. 고친 방법은 필터를 없애는 게 아니었어. 도구 호출이 시작되는 순간, 답이 끝날 때 쓰던 순서 그대로 둘 다 비워내는 거였어. 도구 호출은 자연스러운 경계야. 그 앞의 글은 이미 확정이니까.

고친 뒤에 잰 것

고친 뒤의 실제 시험에서, "잠깐, 일산 미세먼지 확인해볼게."라는 줄은 턴 시작 3.35초에 도구 이벤트보다 먼저 서버를 떠났어. 셸 호출 두 개가 도는 동안 말로 나갔고, 답은 12.8초에 별도 단위로 따라왔어. "잠깐만" 하고 나서 답을 들고 돌아오는 사람의 모양 그대로야.

필터가 꾸며낸 측정값

여기엔 교훈이 하나 더 있어. 초기의 스트리밍 조사는 글 조각 하나의 중앙값이 약 300자라면서 모델이 큰 덩어리로 흘려보낸다고 결론 냈어. 아니었어. 그 숫자는 앞머리 비계 필터가 붙잡고 있던 걸 한꺼번에 놓은 거였어. 600자보다 짧은 글 답은 지금도 끝에 한 덩어리로 나타나는데, 이제 그건 음성이 아니라 모든 턴의 표시 문제야. 버퍼 뒤에서 잰 측정은 뭐든 버퍼부터 재는 거야.

Code

도구 경계에서 놓아주는 붙잡기 필터, 그리고 그 결과로 나오는 단위·python
from dataclasses import dataclass, field


@dataclass
class HoldingFilter:
    """Holds the start of a reply while it watches for a leaked role marker."""
    hold_chars: int = 600
    held: str = ""
    released: bool = False

    def feed(self, delta: str) -> str:
        if self.released:
            return delta
        self.held += delta
        if len(self.held) < self.hold_chars:
            return ""                       # still watching
        return self.flush()

    def flush(self) -> str:
        out, self.held, self.released = self.held.removeprefix("Assistant: "), "", True
        return out


@dataclass
class SpokenStream:
    flush_at_tools: bool
    filter: HoldingFilter = field(default_factory=HoldingFilter)
    sent: str = ""
    unit_ends: list[int] = field(default_factory=list)
    log: list[str] = field(default_factory=list)

    def on_event(self, t: float, kind: str, text: str = "") -> None:
        if kind == "text":
            out = self.filter.feed(text)
        elif kind == "tool_use":
            out = self.filter.flush() if self.flush_at_tools else ""
            if out or self.sent:
                self.unit_ends.append(len(self.sent) + len(out))   # a unit ends here
        else:  # done
            out = self.filter.flush()
        if out:
            self.sent += out
            self.log.append(f"{t:5.2f}s -> client: {out.strip()!r}")


events = [(2.9, "text", "잠깐, 일산 미세먼지 확인해볼게. "), (3.35, "tool_use"),
          (12.8, "text", "지금은 보통이야. 산책해도 괜찮아."), (13.1, "done")]
for flush in (False, True):
    stream = SpokenStream(flush_at_tools=flush)
    for event in events:
        stream.on_event(*event)
    print(f"flush at tools={flush}:", *stream.log, sep="\n  ")

External links

Exercise

코드를 돌려서 두 로그를 비교해. 그다음 두 번째 필터를 넣어. 사고 태그의 시작일지 몰라서 언제나 마지막 아홉 글자를 붙잡아 두는 필터야. cwkPippa가 하는 대로 첫 번째 필터 앞, 위쪽에 둬. 그래야 역할 표시 검사가 사고 블록이 이미 걷힌 글을 읽어. 도구 호출에서 둘 다 맞는 순서로 비우게 하고, 도구 전 한 줄이 여전히 3.35초에 마지막 아홉 글자까지 온전히 떠나는지 확인해.
Hint
순서가 중요한 이유는, 필터마다 다음 필터가 아직 못 본 글을 붙잡고 있을 수 있어서야. 위쪽의 사고 필터부터 비우고, 거기서 나온 걸 역할 표시 필터에 통과시킨 다음 그쪽도 비워. 답이 끝날 때 하던 그대로야. 자리를 거꾸로 두면 사고 블록 뒤에 오는 역할 표시가 빠져나가. 역할 표시 필터는 자기가 받은 글의 맨 앞만 보거든. 순서를 틀리면 그 줄의 마지막 몇 글자가 답보다 늦게 도착해.

Progress

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

댓글 0

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

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