Claude Haiku에게 수학을 맡기지 마세요 — 2단계 낭독 코칭 설계와 프롬프트 늪
요약
Claude Haiku를 활용한 낭독 코칭 앱 개발 과정에서 LLM의 역할을 '수치 계산'이 아닌 '문구 작성'으로 제한하는 2단계 설계 전략을 다룹니다. 모델의 한계를 극복하기 위해 계산 로직은 코드로 처리하고, LLM은 결과값을 자연스러운 언어로 변환하는 데 집중하는 역할 분담의 중요성을 설명합니다.
핵심 포인트
- LLM에게 복잡한 수학적 계산을 맡기지 말고 코드로 처리할 것
- 소형 모델(Haiku)은 수치 계산보다 문구 작성 및 톤 조절에 최적화됨
- 데이터를 확정된 사실(Settled Facts) 상태로 만들어 프롬프트에 전달
- 프롬프트에 작성된 내용이 반드시 준수되지 않을 수 있음을 인지
📝 원래 Zenn에 일본어로 게시되었습니다. 이것은 영어 버전입니다.
Canonical: https://zenn.dev/uya0526_design/articles/satellite2_haiku-coaching📚 이것은 저의 "낭독 속도 측정기 개발 로그" 시리즈의 **두 번째 위성 기사 (satellite article #2)**입니다. 전체적인 그림을 보려면 메인 기사를, AmiVoice 통합에 대해서는 위성 기사 #1을 참조하세요.
이 글의 위치
이 기사는 측정된 수치를 Claude Haiku에 전달하여 "한 가지 칭찬 + 한 가지 개선점" 형태의 코칭을 생성하는 낭독 속도 측정기의 부분을 다룹니다.
생성형 AI를 사용하는 많은 기사들은 단순히 모델에 모든 것을 던져버립니다: "여기 인식된 텍스트가 있으니, 좋게 평가해줘." 이 기사는 그 반대입니다. LLM(대규모 언어 모델)이 수학을 하게 두지 마세요. 모든 계산과 규칙 기반 결정은 코드에서 해결하며, Haiku는 오직 **문구 작성 (wording)**만 수행합니다. 저는 이 "2단계" 분할을 어떻게 설계했는지, 그리고 구현 과정에서 어떤 늪에 빠졌는지 설명하겠습니다.
네 가지 사항:
- "코드가 수학을 하고, Haiku는 문구만 작성한다"는 이유 (역할 분담 설계)
- 최종 확정된 프롬프트, 그리고
messages/system/cache_control구현 - 직접적인 발견: 작동하지 않았던 프롬프트 캐시 (prompt cache)
- 실제 데이터가 저에게 **"프롬프트에 작성됨 ≠ 준수됨"**이라는 사실을 깨닫게 해준 순간
💡 저는 공개적으로 TypeScript를 배우고 있는 전직 Java 엔지니어이므로, Java와의 비교를 덧붙일 수 있습니다.
왜 Haiku에게 "수학"을 맡기지 않는가?
먼저 말씀드리자면: Haiku는 피드백 생성에 충분하고도 남습니다 — 사실, 작업을 Haiku가 잘할 수 있는 형태로 구성할 수 있습니다. 핵심은 역할 분담입니다.
저는 모든 지표 계산(말하기 속도, 정체율, 임계값 결정)을 전적으로 코드 내에서 처리합니다. 따라서 Haiku에 도달할 때쯤이면 이미 다음과 같은 확정된 사실 (settled facts) 상태가 됩니다 (아래는 개발 과정 중 Heike 샘프를 읽으며 실제 측정값에 labelMetrics를 실행한 결과입니다. stagnationRate는 백분율 표시를 위한 숫자입니다):
{
"pureSpeakingSpeed": 322,
"pureSpeakingSpeedEvaluation": "slightly fast",
...
Haiku의 유일한 임무는 이 숫자와 레이블을 따뜻하고 구체적인 언어로 변환하는 것입니다. 이것이 바로 소형 모델(small models)이 가장 잘하는 일이며 — 표현(wording), 요약(summarizing), 톤(tone) 조절 — 수치적 정밀함을 요구하지 않습니다.
반대로, 저는 Haiku가 하지 말아야 할 일을 명확히 결정했습니다:
- 정밀한 수치 계산 (속도나 점수 도출) → 코드로 처리
- 다단계 임계값 분기 ( "분당 322자는 빠른가?" 를 판단하는 것) → 코드가 이를 "slightly fast"로 해석한 뒤 전달
☕ Java와의 비교: 이것은 서비스 계층 (Service layer, 계산 = 코드)과 프레젠테이션 계층 (presentation layer, 표현 = Haiku)의 분리와 정확히 일치합니다. 템플릿 엔진에 비즈니스 로직을 작성하지 않듯이, 저는 "LLM이 결정을 내리게 하지 마라"는 선을 그었습니다.
이러한 분리는 비용 이상의 실질적인 이점을 제공합니다. 임계값 결정을 코드로 밀어 넣으면 출력이 안정화됩니다. Haiku에게 매번 "이것이 빠른가?"를 판단하라고 요청하면 모델의 기분에 따라 결과가 흔들릴 수 있지만, "slightly fast"라는 레이블을 전달하면 표현 방식만 자유롭게 변할 뿐입니다.
완성된 프롬프트 (The Finalized Prompt)
다음은 시스템 프롬프트(정적인 코칭 페르소나)입니다. 실제 데이터를 대상으로 여러 번 실행해 본 결과, 이 형태로 정착되었습니다. (가독성을 위해 여기서는 번역되었습니다. 실제 운영 버전은 일본어로 되어 있습니다.)
당신은 일본어 낭독 연습을 검토하는 친절하고 구체적인 코치입니다.
# 전제 조건 (엄격함)
...
의도에 관한 세 가지 참고 사항:
저는 개선 사항을 우선시하고 첫 번째 일치 항목만 출력합니다. 또한 모델이 모든 것이 좋을 때 결함을 찾으려고 강요받지 않도록 네 번째 분기(폴백)를 추가했습니다.
- 명확하게 "정확히 두 문장"이라고 말하지 않아도, 서론이나 글머리 기호가 붙습니다. 저는 프롬프트를 통해 출력의 순수성을 고정합니다.
- "약 100자"는 최선의 노력 목표입니다. 아래에서 그 이유를 설명하지만, 이는 LLM이 일본어 문자를 정확하게 셀 수 없기 때문입니다.
API 구현: system / messages / cache_control
저는 Claude Messages API(@anthropic-ai/sdk)의 messages.create를 통해 이를 호출합니다.
const result = await client.messages.create({
model: process.env.ANTHROPIC_MODEL! // 예: claude-haiku-4-5
max_tokens: 256,
...
이 구조가 의미하는 바는 다음과 같습니다:
system= Claude의 페르소나 및 기본 규칙 (대화 외부). 정적인 코치 페르소나는 여기에 거주합니다.messagesrole: "user"= 실제 대화 입력(매번 변경되는 평가 JSON).- **
role**은 `
여기서부터 직접 검증하며 발견한 사실이 시작됩니다.
정적인 system 프롬프트는 매번 동일하기 때문에, **프롬프트 캐싱 (prompt caching)**을 통해 비용을 절감할 수 있겠다고 생각했습니다. 저는 cache_control: { type: "ephemeral" }를 추가하고 손익분기점 계산을 해보았습니다 (캐시 쓰기 비용은 첫 번째 호출 시 1.25배이며, 이후 읽기 비용은 약 90% 할인되므로 "두 번 사용하면 이득"이라는 결론이 나왔습니다). 그래서 "이것을 사용해야겠다"라고 결론지었습니다.
하지만 실제로 작동하지 않았습니다.
그 이유는 최소 토큰 장벽 (minimum-token wall) 때문이었습니다. Claude Haiku 4.5의 최소 캐싱 가능 크기는 4,096 토큰입니다. 제 system 프롬프트는 몇 백 토큰 수준이었고, 이는 임계값 미만이었습니다. cache_control을 작성했음에도 불구하고 아무것도 캐싱되지 않았습니다.
응답의 usage를 통해 확인할 수 있습니다:
console.log(result.usage);
// cache_creation_input_tokens: 0
// cache_read_input_tokens: 0 ← 둘 다 0이라면 아무것도 캐싱되지 않은 것입니다
둘 다 0이었습니다. 저는 손익분기점 계산에 너무 몰두한 나머지, 기저에 깔린 최소 토큰 조건이 저의 사각지대가 되었습니다.
cache_control을 작성하는 것 자체가 운영상 해를 끼치지는 않습니다 (단순히 무시될 뿐입니다). 하지만 이 앱에 "캐싱으로 최적화했다"라고 적는 것은 거짓이 될 것입니다. 저는 가장 정직하면서도 독자에게 가장 유용한 방식인 검증 과정으로서 남겨두기로 결정했습니다: 도움이 될 것이라 생각하여 확인해 보았으나, 조건을 충족하지 못해 도움이 되지 않았다는 사실을 발견했습니다.
💡 교훈: "공식 기능이 존재한다"는 것과 "내 사용 사례에 도움이 된다"는 것은 서로 다른 문제입니다. 최소 토큰 수와 같은 전제 조건을 확인하지 않으면, 아무것도 하지 않으면서 최적화를 했다고 착각할 수 있습니다.
늪 ② — "프롬프트에 적혀 있다고 해서 반드시 준수되는 것은 아니다"
또 다른 늪입니다. 실제 데이터를 사용하여 읽기를 수행했을 때 다음과 같은 일이 발생했습니다:
- 약 10초 정도의 짧은 샘플을 읽음 → 인식된 텍스트에 구두점(punctuation)이 전혀 없음
- 그럼에도 불구하고 Haiku는 입력값에 없던 구체적인 팁을 덧붙였습니다: "구두점에서 숨을 고르세요"
원인은 최종 확정된 프롬프트의 규칙에 "구두점에서 멈추세요"와 같이 구두점에 의존적인 문구가 포함되어 있었기 때문입니다. 이를 해결하기 위해 저는:
- 전제(premises)에 한 줄을 추가했습니다: "구두점은 언급하지 마세요(do not mention punctuation)".
- 규칙 3을 "구두점에서 멈추기(settle at the punctuation)"에서 "의식적으로 숨을 쉬고 더 느리게 읽기(consciously take a breath and read slower)"(구두점에 독립적인 문구)로 변경했습니다.
하지만 이것이 100% 지켜진다는 보장은 없습니다. LLM은 심지어 "~하지 마라(do not ~)"라는 금지 사항도 확률적으로 위반할 수 있습니다.
실제로 같은 실행에서 또 다른 불안정성이 나타났습니다. 입력이 '다소 많은(somewhat many)'이라는 정체 상태와 속도 '느림(slow)'이었기 때문에, 규칙에 따르면 **규칙 1 (멈춤 사용 방법)**을 우선시해야 합니다. 하지만 출력은 규칙 2 (전진하는 빠르기/템포) 쪽으로 기울어졌습니다. 이는 프롬프트에 우선순위 결정을 작성하고 Haiku가 선택하도록 맡기는 디자인의 한계를 보여주었습니다.
여기서 작동한 것은 과거 학습과 같은 패턴이었습니다: "통과 시험 ≠ 의도대로 행동하기." 중요한 것은 그것을 지시했는지 여부가 아니라, 실제로 그것이 지켜졌는지에 대해 실제 데이터로 검증하는 것입니다. "프롬프트에 작성된 것"은 곧 "지켜진 것"을 의미하지 않습니다.
☕ Java 비교: 이는 메서드의 Javadoc에 "100자 이내로 반환한다(returns within 100 characters)"라고 작성하는 것과 같지만, 여전히 호출자 측에서 검증해야 합니다. 사양을 작성하고 그 사양이 지켜지는 것은 별개의 문제입니다. 그것이 지켜졌는지는 호출자 측(코드)에서 확인합니다.
안정성이 필요하다면, "코드로 확정하여 전달하세요"
출력을 완전히 안정적으로 만들려면, 어떤 개선 사항을 코드로 내보낼지 결정하고 { "improvementFocus": "멈춤 사용 방법" }와 같은 단일 필드로 전달할 수 있습니다. 그러면 Haiku는 오직 문구에만 집중할 수 있고, 우선순위의 불안정성(wobble)은 사라집니다.
| 접근 방식 | 세부 사항 | 트레이드오프 |
|---|---|---|
| A (이번 사용) | 프롬프트에 우선순위를 작성하고 Haiku가 선택하게 함 | 똑똑해 보이지만, 불안정함(wobbles) |
| B | 개선 초점을 코드로 확정하여 단일 필드로 전달 | 안정적인 출력, 하지만 경직됨 |
마감 기한 때문에 이번에는 접근 방식 A로 남겨두었습니다 (프롬프트 수정 후 결과를 관찰하며). 하지만 만약 이 불안정성이 실제 운영 환경에서 두드러지게 나타난다면, B 쪽으로 기울이는 것이 확실한 방법이라는 것을 명확히 느꼈습니다. 이것 또한 디자인 결정의 순간이었습니다: "똑똑해 보이는 것 vs. 안정적인 것".
글자 수 제한을 처리하는 방법 (Phase 1)
Haiku 스스로에게 "100자 이내"로 세라고 하는 것은 신뢰할 수 없습니다. LLM은 일본어 글자 수를 정확하게 세지 못하며, 정기적으로 제한을 초과하여 반환합니다. Phase 1에서는 다음과 같이 진행합니다:
- 프롬프트의 "약 100자"는 **최선의 노력 목표 (best-effort goal)**로 유지합니다.
- 실제 상한선은
max_tokens: 256으로 제어합니다. - 코드 측 검증 (Code-side validation) (예: 초과 시 자르기)은 Phase 1에서 구현하지 않습니다 (향후 옵션).
늪 ②와 마찬가지로, 현재 상태는 "프롬프트가 명세(spec)이며, 엄격한 보장은 아직 코드에 있지 않다"는 것입니다.
내가 직접 구현한 것 / AI에게 요청한 것
| 영역 | 세부 사항 |
|---|---|
| 나의 결정 / 구현 | 역할 분담 (연산 = 코드 / 문구 = Haiku), 최종 확정된 프롬프트 텍스트, 개선 우선순위, max_tokens 값, 캐시 검증, 문장 부호 문제 격리 및 수정, A/B 트레이드오프(trade-off) 판단 |
| ... |
프롬프트 텍스트, 역할 분담, 그리고 검증 결정은 모두 저의 것입니다. 저는 실제 데이터로 이를 실행했고, 늪에 빠졌으며, 스스로 문제를 격리하고 수정했습니다.
마무리하며
이것은 Claude Haiku를 사용하여 낭독 코칭 피드백을 생성하기 위한 저의 설계였습니다. 세 가지 핵심 교훈은 다음과 같습니다:
- 2단계 설계 — 코드는 수학을 처리하고, Haiku는 문구만 처리합니다. LLM은 번역과 같은 최선의 기술에 집중하게 하고, 결정 사항은 코드에서 확정하십시오.
- 이 앱에서는 프롬프트 캐싱 (Prompt caching)이 작동하지 않았습니다 (Haiku의 최소 기준인 4,096토큰에 도달하지 못함). 전제 조건을 확인하지 않는다면, "최적화"는 "최적화하는 척하기"가 됩니다.
- 프롬프트에 작성되었다고 해서 반드시 준수되는 것은 아닙니다. 실제 데이터를 통해 문장 부호와 우선순위의 흔들림을 검증하십시오. 안정성이 필요하다면 결정 사항을 코드로 밀어 넣으십시오.
이 모든 과정을 관통하는 하나의 지점은 다음과 같습니다: 지시(instruction)와 검증(verification)은 분리되어야 합니다. 프롬프트는 명세(spec)이지 보증(guarantee)이 아닙니다. 그 전제하에, 검증을 코드와 실제 데이터에 두는 것이 생성형 AI를 실제 제품에서 사용할 때 취할 수 있는 정직한 태도라고 믿습니다.
상세한 개발 로그는 저장소의 LEARNING_LOG_Phase1_Step4.md에 있습니다.
다음번에는 지표(metrics) 뒤에 숨겨진 근거 — 즉, 제가 속도와 정체율(stagnation-rate) 임계값을 설정한 기준에 대해 깊이 파고들어 보겠습니다 → 위성 #3, "지표 뒤에 숨겨진 근거" (https://dev.to/uya0526design/i-went-looking-for-the-basis-of-n-characters-per-minute-is-fast-there-wasnt-one-setting-4967).
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기