
구조화된 출력(Structured Output) 없이 모든 제공업체의 프롬프트 캐시(Prompt Cache)에 LLM 답변 제안 삽입하기
요약
프롬프트 캐싱과 스트리밍 환경에서 LLM 답변 제안 기능을 구현하기 위한 실전적인 접근법을 다룹니다. 구조화된 출력(Structured Output) 사용 시 발생하는 지연 시간과 캐시 무효화 문제를 피하기 위해 인라인 마커를 활용하는 방식을 제안합니다.
핵심 포인트
- 프롬프트 캐싱 유지를 위해 대화 접두사 재사용이 필수적임
- 구조화된 출력은 경량 모델에서 지연 시간을 증가시킴
- 스트리밍 TTS 환경에서는 구조화된 출력이 충돌을 일으킬 수 있음
- 인라인 마커 삽입 후 제거하는 방식이 비용과 성능 면에서 효율적임
저는 음성 역할극(voice roleplay) 채팅에 답변 제안(reply suggestions) 기능을 추가하고 싶었습니다. AI의 각 응답 아래에 "다음에 이렇게 말할 수 있습니다"라는 세 개의 칩(chips)이 나타나는 전형적인 UX입니다. 간단해 보입니다. 하지만 채팅이 스트리밍(streaming)과 프롬프트 캐싱(prompt caching)을 중심으로 구축되어 있다면, 모든 당연해 보이는 접근 방식이 부적합하다는 사실을 알게 됩니다.
결국 저는 응답에 인라인 마커(inline markers)를 삽입한 뒤 나중에 이를 제거하는 다소 투박한 방식을 선택했습니다. 그 결정에 이르게 된 과정은 글로 남길 만큼 흥미로웠습니다.

제가 만들고자 했던 것: AI 응답당 세 개의 "이렇게 말할 수 있습니다" 칩 — 구조화된 출력(structured output) 없음, 스트림 중단 없음, 캐시 무효화(cache invalidation) 없음.
두 가지 엄격한 제약 조건
1. 대화가 프롬프트 캐싱(prompt caching)을 중심으로 구축됨
LLM 채팅에서 토큰 비용을 낮게 유지하는 것은 캐싱(caching)에 달려 있으며, 모든 제공업체는 이를 다르게 처리합니다.
- Gemini: 명시적 캐시(explicit cache). 세션당 페르소나 프롬프트(persona prompt)와 대화 기록을 포함하는 캐시 객체가 생성됩니다. 각 턴(turn)에서는 차이점(diff)만 전송합니다. 기록이 너무 길어지면 캐시가 재구축됩니다.
- DeepSeek / Cerebras (OpenAI 호환): 매번
system + 전체 기록 + user를 전송하며 서버의 **암시적 접두사 캐시(implicit prefix cache)**를 활용합니다 (prompt_cache_hit_tokens등을 통해 측정 가능). - Grok (xAI):
x-grok-conv-id헤더가 요청을 동일한 대화에 연결하여 캐시에 고정(pinned)되도록 유지합니다.
공통점은 대화 접두사(conversation prefix, 즉 페르소나 + 기록)를 가능한 한 많이 재사용해야 한다는 것입니다. 이 접두사를 방해하는 모든 것은 비용과 지연 시간(latency) 모두에 악영향을 미칩니다.
2. 구조화된 출력(Structured output)은 제외됨
세 개의 제안을 가져오는 자연스러워 보이는 방식은 {"reply": "...", "suggestions": ["...", "...", "..."]}와 같은 형태일 것입니다. 저는 두 가지 이유로 이 방식을 배제했습니다.
- Gemini flash-lite 급 모델들은 구조화된 출력 (Structured Output) 사용 시 눈에 띄는 지연 시간 (Latency) 증가를 보입니다. 모델이 가벼울수록 작업 대비 스키마 준수 (Schema compliance) 비용이 더 무겁게 작용합니다.
- 이는 문장 단위의 TTS 스트리밍 (TTS streaming)과 직접적으로 충돌합니다. 이 채팅은 첫 번째 문장부터 바로 말을 시작하도록 설계되었습니다. 모델이 JSON을 출력하는 동안에는 그 첫 번째 문장을 추출할 방법이 없습니다. 구조화된 출력은 오디오가 재생되기 전에 전체 생성이 완료될 때까지 기다려야 함을 의미합니다.
고려했던 세 가지 접근 방식
A. 제안 생성을 위한 별도의 API 호출
메인 턴 (Main turn) 이후에 두 번째 요청을 보냅니다. 접두사 (Prefix)는 다시 캐시 (Cache)에 적중하겠지만, 추가적인 왕복 시간 (Round-trip)이 발생하며, Grok의 conv-id나 암시적 접두사 캐시 (Implicit prefix caches) 등에 걸쳐 캐시 일관성 (Cache consistency)을 유지하는 것이 사용자의 문제가 됩니다.
B. 메인 턴에 포함된 구조화된 출력
두 번째 요청이 없으므로 캐시 일관성 유지가 매우 쉽습니다. 하지만 앞서 언급한 이유(지연 시간 + 스트리밍 충돌)로 인해 제외되었습니다.
C. 메인 턴에 포함된 인라인 마커 (Inline markers) (선택됨)
모델에게 응답의 맨 마지막에 `{{SUGGEST: option1 | option2 | option3}}
중요한 부분은 다음과 같습니다: 일단 추출되면, 해당 마커는 TTS/표시 텍스트와 DB 히스토리 모두에서 제거되어야 합니다. 제안(Suggestions)은 일시적인 UI 스캐폴딩(scaffolding)일 뿐, 캐릭터의 실제 발화의 일부가 아닙니다. 이를 히스토리에 남겨두면 향후 턴의 컨텍스트(context)를 오염시키게 됩니다.
// {{SUGGEST: a | b | c}}를 추출하고 본문에서 완전히 제거합니다
static RE_SUGGEST: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?is)\{\{\s*SUGGEST\s*:\s*([\s\S]*?)\}\}").unwrap());
...
이 지점에서 기존의 "주석이 달린 상태로 저장 / 깨끗한 상태로 표시" 분리 방식이 빛을 발합니다. 이 채팅에서는 다음과 같이 작동합니다:
- 클라이언트에 반환되는
ai_text(표시 + TTS)는 모든 마커가 완전히 제거된 상태입니다. - DB에 저장되는 데이터에는
{{SHOW}}/{{POSE}}마커가 다시 부착됩니다 (따라서 모델은 히스토리에서 자신의 정형화된 포맷을 계속 확인하며 이를 올바르게 계속 사용할 수 있습니다).
{{SUGGEST}}는 {{SHOW}}/{{POSE}}와 다릅니다 — DB에 전혀 다시 들어가지 않습니다. 이는 일시적입니다. 마커별로 유지할지 버릴지를 선택하도록 설계되었기 때문에, 제안 기능은 다른 어떤 것에도 영향을 주지 않고 깔끔하게 끼워 넣을 수 있습니다.
프롬프트 측면에서는 페르소나 설정(persona config) 내의 기능 플래그(feature flag)로 제어되는 하나의 추가 블록일 뿐입니다:
응답의 맨 마지막에, 사용자가 다음에 할 법한 짧은 답변 세 개를 정확히 다음 형식으로 추가하세요:
{{SUGGEST: option1 | option2 | option3}}
...
암시적 접두사 캐시 정렬(Implicit Prefix Cache Alignment)에 관한 참고 사항
암시적 접두사 캐시(Implicit prefix caches)는 요청 시작 부분의 토큰 시퀀스가 이전에 본 접두사와 일치할 때 작동합니다. 마커 방식은 단순히 현재 턴의 응답의 일부로 제안을 생성할 뿐입니다. 즉, 다음 턴의 입력 접두사(시스템 + 히스토리)는 일반적인 대화에서의 접두사와 동일합니다. 따라서 접두사는 평소와 같이 정상적으로 캐시를 히트(hit)합니다. 제안은 접두사에 전혀 영향을 주지 않습니다. 이는 조용하지만 중요한 특성입니다.
요약
- 스트리밍(streaming) + 캐싱(caching) 채팅에 보조적인 구조화된 데이터(secondary structured data)를 추가할 때, 구조화된 출력(structured output)을 시도하기 전에 인라인 마커(inline markers) + 추출(extraction) 방식을 고려하십시오.
- 모든 것을 동일한 요청(request)에 묶으면, 설계 구조상 제공업체 간의 캐시 정렬(cache alignment) 문제는 발생하지 않습니다.
- 이미 마커 추출 파이프라인(marker extraction pipeline)을 갖추고 있다면, 추가되는 한계 비용(marginal cost)은 거의 제로에 가깝습니다. 마커별로 유지할지 또는 버릴지를 선택할 수 있도록 설계하십시오. 이러한 유연성 덕분에 나중에 일시적인 UI 추가를 고통 없이 수행할 수 있습니다.
비용 측면에서는: 출력 토큰(output tokens)이 수십 개 정도 증가하며, 가끔 모델이 마커 형식을 망가뜨릴 수 있습니다({{SHOW}}/{{POSE}}와 동일한 위험 수준). 두 가지 모두 허용 가능한 범위입니다.
이 채팅은 로컬 GPU에서 다국어 TTS × 립싱크 아바타(lip-sync avatars)를 실행하는 음성 역할극 제품인 kotonia의 일부입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기