Spring AI 내부에서 본 Anthropic 구조화 출력·프롬프트 캐싱, 그리고 비용을 거두는 Advisor
Temporal 워크플로에 Spring AI를 붙이면서 내부를 들여다봤습니다. useProviderStructuredOutput()이 Anthropic 네이티브 outputConfig로 나가는 경로, 프롬프트 캐싱을 켰지만 read가 0이라 손해였던 이유, 그리고 .entity()가 가린 토큰·비용을 CallAdvisor로 거둔 방법입니다. 2부작 중 2편입니다.
Sample code1편에서는 진단 리포트 생성을 SQS·Lambda에서 Temporal 워크플로로 옮긴 이야기를 했습니다. 그 과정에서 생성을 Spring AI(Anthropic)로 통합했는데, 막상 붙여 보니 “이게 안에서 정확히 어떻게 나가는 거지?”가 계속 걸렸습니다. 이번 글은 그 Spring AI를 내부까지 열어 본 기록입니다.
우리 호출은 이렇게 생겼습니다. 섹션마다 DTO 타입을 정해 두고 구조화 출력으로 받습니다.
chatClient
.prompt()
.system(system)
.user(user)
.call()
.entity(responseType) { spec ->
spec.useProviderStructuredOutput().validateSchema()
}
한 줄짜리 호출이지만 안에서 일어나는 일은 단순하지 않았습니다. 세 가지를 확인했습니다.
1. useProviderStructuredOutput()은 tool이 아니라 네이티브 outputConfig로 나간다
처음엔 Spring AI가 구조화 출력을 function/tool 정의로 우회한다고 생각했습니다. 실제 2.0.0 소스를 따라가 보니 아니었습니다. useProviderStructuredOutput()을 켜면 Anthropic의 네이티브 구조화 출력(output_config)으로 나갑니다.
graph TB
Entity["ChatClient.entity(type) { useProviderStructuredOutput() }"]
Native["context: STRUCTURED_OUTPUT_NATIVE = true"]
Schema["doSingleWithBeanOutputConverter()<br/>context: STRUCTURED_OUTPUT_SCHEMA = converter.jsonSchema"]
Model["AnthropicChatModel.call()"]
Params["MessageCreateParams.builder().outputConfig(...)"]
API["Anthropic /messages<br/>output_config.format"]
Entity --> Native --> Schema --> Model --> Params --> API
useProviderStructuredOutput()→ChatClientAttributes.STRUCTURED_OUTPUT_NATIVE = trueDefaultChatClient.doSingleWithBeanOutputConverter()가 네이티브 플래그를 보고 JSON 스키마를 context에 넣음AnthropicChatModel이 요청을 빌드할 때MessageCreateParams.builder().outputConfig(...)로 스키마를 직접 전달 (AnthropicChatModel.java:818)
차이는 명확합니다. useProviderStructuredOutput()을 안 쓰면 JSON 스키마가 프롬프트 본문에 텍스트 지시로 들어가고 쓰면 Anthropic API의 output_config.format 필드로 들어갑니다. 후자는 모델이 스키마를 “지시”가 아니라 “출력 형식”으로 받기 때문에 더 안정적입니다. (참고로 Spring AI 2.0.0-M3 이후 spring-ai-anthropic은 자체 RestClient 구현에서 공식 com.anthropic:anthropic-java SDK로 갈아탔습니다. 그래서 MessageCreateParams, OutputConfig 같은 게 전부 SDK 타입입니다.)
2. 프롬프트 캐싱을 켜봤다
생성 비용을 줄이려고 프롬프트 캐싱을 봤습니다. Spring AI는 전략으로 캐싱을 켭니다.
spring:
ai:
anthropic:
chat:
cache-options:
strategy: SYSTEM_ONLY # NONE / TOOLS_ONLY / SYSTEM_ONLY / SYSTEM_AND_TOOLS / CONVERSATION_HISTORY
내부적으로는 전략에 따라 요청의 특정 블록(system, 마지막 tool 등)에 cache_control: {type: ephemeral}을 붙입니다. Anthropic의 캐시 규칙은 이렇습니다.
- 캐시 계층 순서는 tools → system → messages
cache_control이 붙은 블록까지의 프리픽스가 바이트 단위로 정확히 같아야 cache hit (1바이트만 달라도 miss)- 기본 TTL 5분(beta 헤더로 1시간), 최소 캐시 길이는 대체로 1024 토큰
- breakpoint는 요청당 최대 4개
SYSTEM_ONLY로 system 블록(큰 학생부 컨텍스트)을 캐시 프리픽스로 잡으면, 같은 학생부를 여러 번 보낼 때 입력 비용이 내려갈 것 같았습니다.
3. 그런데 cache read가 계속 0이었다
켜고 한 리포트를 생성해 보니, 역량 4개 호출이 각각 약 24K 토큰을 cache write로 쓰고 cache read는 0이었습니다. write 토큰 수도 호출마다 미묘하게 달랐습니다(24065 / 24093 / 24105 / 24205).
이유는 워크로드에 있었습니다.
- 역량 섹션마다 프롬프트가 다릅니다(학업·탐구·진로·공동체). system/스키마가 섹션별로 달라서 캐시 프리픽스가 매 호출 달랐습니다. 프리픽스가 다르면 공유가 안 되니 매번 write, 재사용 0입니다.
- 리포트는 한 건당 한 번 생성합니다. 같은 프리픽스를 5분 TTL 안에 다시 보낼 일도 거의 없습니다.
여기서 단가 산술이 결정적입니다. Anthropic 캐시 단가는 write ×1.25, read ×0.1입니다.
fun estimateUsd(usage: TokenUsage): Double {
val inputRate = inputPerMillion / 1_000_000.0
val outputRate = outputPerMillion / 1_000_000.0
return usage.inputTokens * inputRate +
usage.outputTokens * outputRate +
usage.cacheWriteTokens * inputRate * cacheWriteMultiplier + // ×1.25
usage.cacheReadTokens * inputRate * cacheReadMultiplier // ×0.1
}
read가 0이면 cache write는 그냥 25% 비싼 입력입니다. 재사용이 없는 한 캐싱은 손해입니다. 샘플 테스트가 이걸 그대로 고정합니다.
@Test
fun `캐시 쓰기만 쌓이고 읽기가 0이면 캐시 없을 때보다 비싸다`() {
val noCache = pricing.estimateUsd(TokenUsage(inputTokens = 24_000, outputTokens = 1_000))
val cacheWriteOnly = pricing.estimateUsd(TokenUsage(cacheWriteTokens = 24_000, outputTokens = 1_000))
assertTrue(cacheWriteOnly > noCache)
}
그래서 캐싱은 NONE으로 껐습니다. 최적 케이스를 가정해도 이 워크로드에서 줄어드는 비용은 리포트당 $0.1 안팎이라, 복잡도를 더할 이유가 없었습니다.
캐싱이 나쁜 기능이라는 뜻은 아닙니다. 같은 큰 프리픽스를 짧은 시간에 여러 번 재사용하는 워크로드(긴 system을 공유하는 멀티턴 대화, 같은 문서를 반복 질의하는 RAG)에서는 강력합니다. 우리 워크로드가 거기서 벗어나 있었을 뿐입니다.
.entity()가 가린 것 — 토큰·비용은 Advisor로 거둔다
여기서 한 가지 문제가 더 있었습니다. 위 결정을 내리려면 호출마다 토큰을 봐야 하는데, .entity()로 구조화 출력을 받으면 파싱된 DTO만 돌아오고 ChatResponse(=usage)는 손에 안 들어옵니다. (Spring AI에도 관련 이슈가 있습니다 — ChatResponse.entity() 오버로드 요청.) usage 자체는 ChatResponse.metadata.usage에 있고 Anthropic 캐시 토큰은 거기서 cacheCreationInputTokens / cacheReadInputTokens로 노출됩니다. 호출부에서 못 읽을 뿐입니다.
해법은 호출부를 바꾸지 않는 쪽이었습니다. 모든 ChatClient 호출은 advisor 체인을 통과하므로, CallAdvisor를 하나 끼워 응답을 가로채 usage를 거둡니다.
@Component
class CostAdvisor(
private val meterRegistry: MeterRegistry,
private val costEstimator: CostEstimator,
) : CallAdvisor {
override fun getOrder() = Ordered.LOWEST_PRECEDENCE
override fun adviseCall(
request: ChatClientRequest,
chain: CallAdvisorChain,
): ChatClientResponse {
val response = chain.nextCall(request) // 모델 호출은 체인 끝에서
val usage = toTokenUsage(response.chatResponse())
CostAccumulator.add(currentRunKey(), usage, callCount = 1)
record(usage) // Micrometer 카운터 + 로그
return response
}
}
등록은 ChatClient.Builder에서 한 번만 합니다.
chatClientBuilder
.defaultAdvisors(SimpleLoggerAdvisor.builder()...build(), costAdvisor)
.build()
이렇게 하면 AiChatClient를 호출하는 코드는 한 줄도 바뀌지 않은 채, 모든 호출의 입력·출력·cache write·cache read 토큰이 집계됩니다. Temporal 실행(runKey = workflowId:runId)별로 누적해 리포트 한 건의 diagnostics에 추정 비용을 담고 동시에 Micrometer 메트릭으로도 내보냅니다(워커가 여러 개여도 전역 집계가 맞도록).
무엇을 남겼나
캐싱은 껐지만 이 작업에서 진짜로 남은 건 비용을 끄고 켜는 판단을 숫자로 할 수 있게 됐다는 점입니다. cache read가 0이라는 사실을 advisor가 집계한 메트릭에서 봤고 단가 산술로 손익을 확인했고 그래서 껐습니다. 추측이 아니라 측정으로 내린 결정이었습니다.
이번에 확인한 건 세 가지입니다.
useProviderStructuredOutput()은 Anthropic 네이티브output_config로 나간다(tool 우회 아님).- 프롬프트 캐싱은 같은 프리픽스를 짧은 시간에 재사용할 때만 이득이다. 섹션마다 프롬프트가 다르고 리포트당 한 번 생성하는 워크로드에서는 write만 쌓이고 read가 0이라 손해였다.
.entity()는ChatResponse를 돌려주지 않으므로, 토큰·비용은CallAdvisor로 호출부 변경 없이 거둔다.
샘플 코드는 seeun-blog-sample/spring-ai-anthropic-cost-advisor에 있습니다. ./gradlew :spring-ai-anthropic-cost-advisor:test로 캐시 단가 손익 산술을 확인할 수 있습니다.