Microsoft의 정책 문서 자체가 score threshold를 0.2보다 높게 잡으면 cache mismatch가 발생할 수 있다고 적고 있는데, 이는 게이트웨이가 호출자가 묻지 않은 질문에 답하게 된다는 말의 정중한 표현이다. 이 시리즈의 다른 모든 메커니즘은 요란하게 실패했다. 429, 503, 만료된 batch job. semantic cache는 다른 누군가의 프롬프트에 속한, 유창하고 형식이 잘 갖춰진, 자신만만한 응답을 돌려주는 방식으로 실패한다. 5부는 지출을 귀속 가능하게 만들었다. 이번 편은 그 지출을 줄이되, 줄여 주는 메커니즘을 비용 레버가 아니라 정확성의 표면으로 다룬다.

cache가 동작하기 전에 실제로 필요한 것

API Management의 semantic caching에는 나중에 되돌릴 수 없는 인프라 선택을 결정짓는 전제 조건 목록이 있으므로, 정책 뒤가 아니라 앞에 놓을 가치가 있다.

  • embeddings용 두 번째 모델 배포. chat completion 배포는 소비자 호출을 처리하고, 별도의 embeddings 배포가 cache가 프롬프트를 벡터로 바꾸는 데 쓰는 것이다. 두 번째 용량 라인이고, 두 번째 quota이며, 모니터링할 두 번째 대상이다.
  • managed identity 인증이 API Management에서 모델 API로 이어져야 하고, 정책이 이를 요구한다. embeddings-backend-authsystem-assigned로 설정되어야 한다. key 기반 선택지는 없는데, 이는 좋은 제약이다.
  • RediSearch 모듈이 활성화된 Azure Managed Redis를 API Management 인스턴스의 external cache로 구성해야 한다.

마지막 항목이 함정을 품고 있다. RediSearch는 cache를 만드는 시점에만 활성화할 수 있고, 기존 cache에는 추가할 수 없다. 세션이나 rate-limit 상태용으로 Redis 인스턴스를 이미 운영하는 플랫폼이 semantic caching을 그냥 켤 수는 없다. 모듈과 함께 프로비저닝된 새 cache가 필요하고, 그 결정이 기록되는 곳이 2부의 Terraform이다. "caching 켜기"에 오후 한나절을 배정한 스프린트 도중에 이를 발견하는 것이 흔한 결말이다.

정책 한 쌍

lookup은 inbound에, store는 outbound에 들어가고, 이 쌍은 정책 섹션마다 각각 한 번씩만 나타날 수 있다. 둘 다 global, product, API, operation 범위에서 쓸 수 있고, classic, v2, consumption, self-hosted 게이트웨이 모두에서 사용할 수 있다.

<inbound>
  <base />
  <llm-semantic-cache-lookup
      score-threshold="0.05"
      embeddings-backend-id="embeddings-backend"
      embeddings-backend-auth="system-assigned"
      ignore-system-messages="true"
      max-message-count="6">
    <vary-by>@(context.Subscription.Id)</vary-by>
  </llm-semantic-cache-lookup>
  <rate-limit calls="10" renewal-period="60" />
</inbound>
<outbound>
  <llm-semantic-cache-store duration="60" />
  <base />
</outbound>

이 속성들 중 네 개는 boilerplate가 아니라 결정이다.

score-threshold는 들어오는 프롬프트가 cache된 프롬프트와 얼마나 가깝게 일치해야 하는지를 0.0에서 1.0 사이의 척도로 정의하는데, 값이 낮을수록 더 높은 semantic 유사도를 요구한다. 이는 처음 읽는 사람 대부분의 짐작을 뒤집고, 방향을 잘못 잡으면 보수적인 cache가 문란한 cache로 변한다. Microsoft의 지침은 0.05 근처의 낮은 값에서 시작해 원하는 hit 대 miss 비율을 향해 조정하라는 것이고, 0.2를 넘으면 mismatch가 발생하기 쉬워진다는 명시적 경고와 함께 민감한 사용 사례는 더 낮게 유지하라고 한다.

ignore-system-messages="true"는 유사도를 평가하기 전에 system message를 제거하며, 권장 설정이다. 들리는 것보다 중요하다. 사용자 질문은 같고 system prompt만 다른 두 애플리케이션이 아니면 cache에게 서로 다르게 보였을 것이고, system prompt가 수정된 단일 애플리케이션은 아무 semantic한 이유 없이 자기 cache 전체를 무효화했을 것이다.

max-message-count는 대화에 남은 message 수가 지정한 개수를 넘으면 caching을 건너뛴다. 긴 대화는 semantic 일치를 가장 믿을 수 없는 지점인데, 마지막 message의 의미가 embedding이 본 적 없는 턴들에 달려 있기 때문이다.

vary-by는 격리 경계이고, 다음 섹션은 그것이 왜 보안 통제인지에 관한 이야기다.

vary-by는 파티셔닝의 편의 장치가 아니다

vary-by가 없으면 하나의 cache가 그 API의 모든 호출자 사이에서 공유된다. 고객의 상세 정보를 담은 고객 서비스 어시스턴트의 프롬프트가 cache된 completion으로 답변될 수 있고, 다른 테넌트의 semantic하게 유사한 프롬프트가 바로 그 항목으로 답변될 수 있다. 문서는 이를 직설적으로 적는다. 구체적인 사용자 또는 사용자 그룹 식별자로 vary-by를 지정해 cache 항목에 대한 사용자 간 접근을 통제하라.

이 플랫폼에서 최소값은 subscription ID이고, 이는 cache 경계를 5부가 세운 테넌시 경계와 정확히 정렬한다. 데이터가 섞여서는 안 되는 최종 사용자를 서비스하는 애플리케이션이라면, 올바른 값은 호출자가 헤더에 넣을 수 있는 무언가가 아니라 검증된 token에서 나온 claim이고, 이것이 5부의 validate-jwt가 token을 output-token-variable-name에 담아 두는 이유다. 검증되지 않은 헤더를 읽는 vary-by는 root cause가 설정 파일인 테넌트 간 데이터 유출이다.

좁은 파티셔닝의 비용은 낮은 hit rate이고, 그 트레이드는 실재한다. 그리고 그것이 올바른 기본값이기도 하다. 절대 유출하지 않으면서 20퍼센트를 아끼는 cache가, 45퍼센트를 아끼다가 언젠가 엉뚱한 고객의 답을 돌려주는 cache를 이긴다.

방금 추가한 의존성

cache는 모든 호출의 요청 경로에 앉아 있고, 이는 Redis가 이제 게이트웨이의 critical path에 있다는 뜻이다. Microsoft의 권장은 구체적이고 그대로 따를 가치가 있다. rate-limit 또는 rate-limit-by-key 정책을 cache lookup 바로 뒤에 두어, cache를 쓸 수 없을 때 backend가 압도되지 않게 하라.

그 실패를 끝까지 생각해 보라. 평소에는 트래픽의 상당한 몫이 모델에 도달하지 않는다. Redis가 사용 불가가 되면, 그 요청 하나하나가 즉시 실제 completion 호출이 되어, cache된 정상 상태에 맞춰 산정된 quota를 때린다. cache 장애는 플랫폼을 저하시키는 것이 아니라 부하를 배가시키고, 그것을 장애가 아니라 스로틀링으로 바꿔 주는 것이 rate limit이다. 데이터베이스 앞의 어떤 cache에서든 얻는 것과 같은 교훈이지만, 여기서는 잊기 더 쉽다. cache가 용량 의존성이 아니라 비용 최적화로 도입되었기 때문이다.

embeddings 배포도 같은 수준의 고민을 받을 자격이 있다. 모든 lookup이 들어오는 프롬프트를 embed하므로, embeddings 모델에는 프로덕션의 프롬프트 볼륨과 프롬프트 길이에 맞는 충분한 용량과 충분한 컨텍스트 크기가 필요하다. proof of concept에 맞춰 산정된 embeddings 배포는 cache 여부와 무관하게 전체 트래픽의 병목이 된다. hit인지 miss인지 알기 전에 lookup이 먼저 일어나기 때문이다.

cache hit가 5부의 숫자에 하는 일

hit는 공짜가 아니고 completion도 아니다. embeddings 호출 한 번과 약간의 Redis 시간이 들고, completion token은 전혀 들지 않는다. 즉 5부의 chargeback 모델에는 다시 쓰기가 아니라 작은 변경 세 가지가 필요하다.

  • embeddings 지출은 플랫폼 비용 라인이 된다. miss를 포함한 모든 요청에서 발생하고, 어떤 completion에도 귀속되지 않는다. 요청 수에 비례해 테넌트에 청구하거나, 플랫폼이 흡수하거나다. 하나를 고르고 적어 두라. 대조에서 빼놓지는 마라.
  • hit rate는 정당한 metric dimension이다. cardinality가 낮고, 지출 옆 대시보드 자리에 속한다. 떨어지는 hit rate는 대개 아무도 공지하지 않은 프롬프트 템플릿 변경이기 때문이다.
  • 절감은 지출이 아니라 회피 비용으로 보고해야 한다. caching 덕에 청구서가 30퍼센트 줄어든 테넌트는 낮아진 숫자를 새 기준선으로 여길 것이다. 청구된 token 옆에 회피된 token을 나란히 보여 주는 것이 그 대화를 정직하게 유지하고, 플랫폼이 비용을 대는 Redis 인스턴스의 근거도 만들어 준다.

아예 cache하지 말아야 할 때

semantic caching은 볼륨이 크고 변형이 적고 개인화되지 않은 프롬프트에 맞는다. 이 회사의 제품 문서에 대한 리테일 지식 검색은 이상적인 경우에 가깝다. 나머지는 그렇지 않고, 정책은 전역으로 적용하는 대신 API별로 범위를 잡아야 하는데, 정책이 product와 API 범위를 지원하는 이유가 정확히 그것이다.

응답이 현재 시각이나 라이브 상태에 의존하는 곳에서는 건너뛰라. 60초의 duration은 신선도 취향이 아니라 정확성의 창이기 때문이다. tool-calling 흐름에서도 건너뛰라. 모델의 출력이 읽을 텍스트가 아니라 행동하라는 지시이고, 낡은 지시는 잘못된 행동이다. vary-by가 파티셔닝할 수 있는 수준을 넘어 개인화된 것도 전부 건너뛰라. 그리고 긴 multi-turn 대화는 유사도 점수가 알아차리기를 바라는 대신 max-message-count를 설정해 범위 밖으로 다루라.

지켜봐야 할 실패 모드

  • 거꾸로 설정된 threshold. 높을수록 느슨하다. hit rate를 최적화하던 팀이 0.4로 올리면 비율은 좋아지고, mismatch는 자기 질문과 아무 상관 없는 답을 받았다고 사용자가 신고할 때까지 보이지 않는다.
  • 빠진 vary-by. 오류도 경고도 없이, API의 모든 테넌트가 공유하는 cache가 된다.
  • 늦게 발견된 RediSearch. 모듈은 기존 cache에 추가할 수 없으므로, 답은 새 cache와 마이그레이션이고, 설정 변경이라 가정했던 작업 한가운데서다.
  • Redis가 죽고 backend가 범람한다. lookup 바로 뒤의 rate limit이 없으면 cache 장애는 트래픽 급증의 모습으로 모델에 도착한다.
  • 용량이 부족한 embeddings 배포. 모든 요청에서 호출되므로 hit와 miss를 가리지 않고 스로틀링하고, 증상은 cache가 오히려 느리게 만드는 것처럼 보인다.
  • cache를 조용히 비우는 프롬프트 템플릿 변경. 청구서에서 발견하는 대신 hit rate에 알림을 걸어 둘 가치가 있다.

7부가 물려받는 것

cache는 반복에 답한다. 훨씬 더 흔한 경우, 모델이 학습한 적 없는 정보를 필요로 하는 경우에는 아무것도 하지 못하는데, 그것이 retrieval 문제이고, 이 회사의 리테일 지식 검색이 이미 그 위에서 기다리고 있다. 다음은 Azure AI Search 대 전용 벡터 데이터베이스, 실제로 결정을 가르는 조건들 위에서의 비교다.

다음으로 읽을 글

이것을 대규모로 운영할 때의 인프라와 플랫폼 쪽 이야기는 ercan.cloud에, 허브는 ercanermis.com에 있다.

참고 자료