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