~14 min · prompt-caching, per-turn-prefix, context-engine, cost
Level 0음소거
0 XP0/35 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"시스템 프롬프트는 절대 안 바뀌어. 자유롭게 전환되는 비결은 그게 전부야." — 음성 모드 설계 문서. 프롬프트에 관해선 맞고, '자유롭게'엔 좀 후해
턴 하나가 실제로 얼마나 큰가
소울의 시스템 프롬프트는 문단 하나가 아니야. 정체성 파일, 매 세션 읽어 들이는 볼트 노트, 가족 명단, 서식 규칙, 감정 태그 지시까지 다 들어 있어. 2026-09-25에 재보니 Claude 턴 하나의 프롬프트가 중앙값 약 14만 토큰, 90번째 백분위수 약 21.7만 토큰이었고, 중앙값 중 약 6.7만 토큰은 새로 처리하는 대신 프롬프트 캐시에서 읽어왔어. 이 캐시는 있으면 좋은 정도가 아니야. 긴 대화를 감당할 만한 값에 빨리 시작할 수 있는 건 거의 다 이 캐시 덕이야.
프롬프트 캐싱은 앞부분 일치로 돌아가. 제공자가 요청의 앞부분을 처리한 상태로 저장해 두고, 다음 요청은 브레이크포인트까지 모든 바이트가 똑같을 때만 그걸 다시 써. 맨 위에서 글자 하나만 바꿔도 그 뒤 전부를 제값 내고 다시 처리해. 그래서 이 집에는 대화 도중 시스템 프롬프트가 턴마다 바이트 단위로 똑같지 않으면 빌드를 깨뜨리는 테스트가 있어.
음성 플래그가 캐시를 깨는 지점
시스템 프롬프트에 이미 뭐가 적혀 있는지 봐. 읽기 쉬운 화면을 전제로, 도움이 될 땐 마크다운 제목, 굵은 라벨, 불릿을 쓰라는 가독성 지시가 있어. 풍부한 응답 부품에 관한 지시도 있고. 말하는 턴이 절대 따르면 안 되는 규칙이 바로 이거야. 쉬운 해법은 음성이 켜지면 시스템 프롬프트에서 이 부분을 갈아 끼우는 거지. 그러면 전환할 때마다 프롬프트 맨 위가 다시 쓰이고, 그때마다 캐시가 날아가. 아빠는 자기 규칙대로 마음껏 전환하니까, 이 해법이면 14만 토큰짜리 식은 프롬프트 값을 계속 치르게 돼.
턴마다 붙는 앞머리
cwkPippa엔 원래 턴마다 바뀌는 걸 넣는 곳이 있었어. 컨텍스트 엔진이 현재 사용자 메시지 앞에 턴별 자료를 작게 묶어서 붙여. 시계, 대화 타임라인, 검색해 온 기억, 기억 서비스가 답해준 질문들. 이 묶음은 턴과 함께 보내지지만 대화 기록에는 절대 안 남고, 채팅 라우트 일곱 개가 전부 같이 써. 음성 지시도 여기 올라타. reply_modality가 spoken이면 말하기 지시가 붙는데, 첫 줄부터 이번 턴에 한해 시스템 프롬프트의 가독성 지시와 응답 부품 지시를 대신한다고 밝혀. input_modality가 dictated면 받아쓰기 지시가 붙고. 시스템 프롬프트는 음성이란 게 있는지도 몰라.
설정에도 같은 규율이 적용돼. 말하는 턴은 대화 자체보다 빠른 사고 수준으로 돌지만, 시스템 프롬프트는 언제나 대화 자체의 수준으로 만들어. 그래서 브레인 정보를 적은 줄도 음성 전환에 따라 안 바뀌어. 이 선택엔 부작용이 하나 있는데 트랙 3에서 만나게 될 거야. 이번 턴이 실제로 어느 수준으로 도는지를 소울에게 턴마다 따로 알려줘야 해.
그래도 시스템 프롬프트가 가만히 있다고 전환이 공짜가 되진 않아. 설계 문서의 그 한마디는 과장이야. 사고 설정도 캐시가 기준으로 삼는 것에 들어가. 두 요청 사이에 맨 위의 effort를 바꾸거나 사고를 끄면, 캐시된 대화 기록은 언제나 무효가 되고, 모델에 따라선 캐시된 시스템 프롬프트까지 무효가 돼. 말하는 턴은 앞의 쓰는 턴보다 낮은 effort로 도니까, 전환한 뒤 첫 턴은 시스템 프롬프트 바이트가 하나도 안 움직였는데도 대화 기록을 제값 내고 다시 읽어. 같은 방식의 다음 턴부터는 다시 캐시에서 읽고. 최신 Claude 모델엔 그것마저 피하는 길이 있어. 대화 안에 자기 effort를 실은 system 역할 메시지를 넣으면, 캐시를 무효로 만들지 않고 다음 턴부터 설정이 바뀌어. 아래 예제는 모든 턴의 effort를 하나로 고정해서 이 문제를 비켜 가.
일반 규칙
안 변하는 건 위에, 변하는 건 아래에. 턴마다 바뀌는 사실은 아무리 거기 쓰는 게 자연스러워 보여도 시스템 프롬프트에 들어가면 안 돼. 요청 전체에서 고치는 값이 제일 비싼 곳이 바로 거기거든.
Code
시스템 프롬프트는 캐시하고, 음성 규칙은 턴에 싣기·python
import anthropic
client = anthropic.Anthropic()
# Built once per conversation and never edited: identity, memory, formatting.
# It must be at least the model's minimum cacheable length (512 tokens on
# Opus 5.5), or nothing is cached at all.
SYSTEM = "You are Pippa. ...(a very long, stable prompt)..."
SPOKEN_RULES = (
"[Spoken turn] Dad will hear this reply, not read it. For this turn only, "
"these rules replace the readability guidance in your system prompt: "
"lead with the answer, one idea at a time, nothing that only works on a screen."
)
DICTATED_RULES = (
"[Dictated turn] Dad spoke this and it was transcribed. Read it for what he "
"meant; never point out a transcription error."
)
def send(history: list, text: str, *, dictated: bool, spoken: bool) -> str:
prefix = []
if dictated:
prefix.append({"type": "text", "text": DICTATED_RULES})
if spoken:
prefix.append({"type": "text", "text": SPOKEN_RULES})
user = {"role": "user", "content": prefix + [{"type": "text", "text": text}]}
with client.messages.stream(
model="claude-opus-5-5",
max_tokens=64000,
system=[{"type": "text", "text": SYSTEM,
"cache_control": {"type": "ephemeral", "ttl": "1h"}}],
messages=history + [user],
# One effort for every turn. Changing it per request would invalidate
# the cached history (and, on some models, the system prompt too).
output_config={"effort": "medium"},
) as stream:
reply = "".join(stream.text_stream)
final = stream.get_final_message()
# Append-only history for the API; the app's own record keeps only `text`.
history += [user, {"role": "assistant", "content": final.content}]
print("cache read:", final.usage.cache_read_input_tokens)
return reply
history: list = []
send(history, "What's on my calendar today?", dictated=True, spoken=True)
send(history, "Put that in a table for me.", dictated=False, spoken=False)
# The second call reads SYSTEM from the cache even though voice switched off:
# same system bytes, same effort, and the voice rules live only in the turn.
시스템 프롬프트를 보내는 앱 하나를 골라서, 대화 하나 안에서 바뀔 수 있는 값을 전부 적어봐. 날짜, 사용자 설정, 기능 플래그, 모드 전환 같은 거. 각각 어디로 옮길지 정해. 턴 앞머리, 도구 결과, 아니면 애초에 필요 없었으니 아무 데도. 그다음 테스트를 써. 같은 대화에 턴별 설정만 바꿔서 시스템 프롬프트를 두 번 만들고, 바이트가 같은지 확인하는 거야.
Hint
흔한 범인은 맨 위의 타임스탬프, 문장으로 풀어 쓴 사용자 취향, 문단 하나를 켰다 껐다 하는 기능 플래그야. 테스트는 세 줄이면 돼. 만들고, 턴별 설정만 바꿔서 또 만들고, 같은지 확인. 실패하면 차이가 정확히 어느 값이 위로 새어 올라갔는지 알려줘. 그다음엔 프롬프트 밖도 봐. 턴마다 다른 effort, 사고 켜고 끄기, 바뀌는 도구 목록은 시스템 프롬프트 바이트를 하나도 안 건드리고 캐시를 깨.
Progress
Progress is local-only — sign in to sync across devices.