캐시 진단
캐시 진단 (Cache diagnostics)
연속된 요청을 비교해서 프롬프트 캐시가 예상치 못하게 빗나간 이유를 정확히 진단할 수 있어요. 캐시 미스가 났을 때 캐시 읽기 토큰이 0으로 떨어지는 것만 보는 대신, 왜 어디서 프롬프트 접두사가 갈라졌는지 알려줘요. 프롬프트가 바이트 단위로 똑같아야만 캐시가 적용되기 때문이에요.
출처: 문서
본문
프롬프트 캐싱은 프롬프트 앞부분이 최근 요청과 바이트 단위로 똑같을 때만 레이턴시와 비용을 크게 줄여줘요. 도구 순서를 바꾸거나, 시스템 프롬프트에 타임스탬프를 끼워 넣거나, 이전 메시지를 편집하면 조용히 캐시가 무효화될 수 있어요. 캐시 진단이 없다면 보이는 신호는 usage.cache_read_input_tokens가 0으로 떨어지는 것뿐이고, 무엇이 바뀌었는지는 알 수 없어요.
캐시 진단은 그 빈틈을 메워줘요. 이전 응답의 id를 넘기면 API가 두 요청을 비교해서 어디서 갈라졌는지(모델, 시스템 프롬프트, 도구, 메시지 기록) 알려줘요. 그래서 추측 대신 근본 원인을 고칠 수 있어요.
캐시 진단이 동작하는 방식
베타 헤더가 있으면 API는 각 요청의 가벼운 지문을 응답 id를 키로 저장해요. 다음 요청에서 그 id를 diagnostics.previous_message_id로 넘기면, API는 새 요청의 지문을 다시 만들어 저장된 것과 비교하고, 첫 번째 분기 지점을 설명하는 diagnostics 객체를 응답에 붙여줘요.
비교는 캐시가 실제로 적중했는지와 무관하게 요청 구조에 관한 거예요. diagnostics 결과를 usage.cache_read_input_tokens와 어떻게 조합하는지는 reading diagnostics alongside usage를 보세요.
지문에는 해시와 토큰 수 추정치만 들어 있고(원시 프롬프트 내용은 절대 없어요), 제한된 시간 동안 보관되며 조직과 워크스페이스 범위로 한정돼요. 다른 용도로는 쓰이지 않아요.
기본 사용법
매 턴마다 베타 헤더를 보내요. 첫 턴에서는 "previous_message_id": null을 넘겨 비교할 이전 메시지 없이 옵트인해요. 이후 턴에서는 이전 응답의 id를 넘겨요.
Turn 2: reference the previous turn so the API can compare prefixes
curl -sS --fail-with-body https://api.anthropic.com/v1/messages
-H "x-api-key: $ANTHR...KEY"
-H "anthropic-version: 2023-06-01"
-H "anthropic-beta: cache-diagnosis-2026-04-07"
-H "content-type: application/json"
-d @- <<EOF | jq '{id, diagnostics}' # diagnostics: null means no divergence was found
{
"model": "claude-opus-5-5",
"max_tokens": 1024,
"cache_control": {"type": "ephemeral"},
"system": "You are an AI assistant analyzing a large document.
```bash CLI
# Turn 1
turn1=$(ant beta:messages create \
--beta cache-diagnosis-2026-04-07 \
--transform '{id,usage,diagnostics}' <<'YAML'
model: claude-opus-5-5
max_tokens: 1024
cache_control:
type: ephemeral
system: "You are an AI assistant analyzing a large document. <document>...</document>"
messages:
- role: user
content: Summarize section 1.
diagnostics:
previous_message_id: null
YAML
)
printf '%s\n' "$turn1"
# Turn 2: pass the id from turn 1 as previous_message_id
message_id=$(jq -r '.id' <<<"$turn1")
ant beta:messages create \
--beta cache-diagnosis-2026-04-07 \
--transform '{id,usage,diagnostics}' <<YAML
model: claude-opus-5-5
max_tokens: 1024
cache_control:
type: ephemeral
system: "You are an AI assistant analyzing a large document. <document>...</document>"
messages:
- role: user
content: Summarize section 1.
- role: assistant
content: Section 1 covers...
- role: user
content: Now summarize section 2.
diagnostics:
previous_message_id: $message_id
YAML
client = anthropic.Anthropic()
SYSTEM = "You are an AI assistant analyzing a large document. <document>...</document>"
# Turn 1: opt in with previous_message_id=None
r1 = client.beta.messages.create(
model="claude-opus-5-5",
max_tokens=1024,
cache_control={"type": "ephemeral"},
system=SYSTEM,
messages=[{"role": "user", "content": "Summarize section 1."}],
diagnostics={"previous_message_id": None},
betas=["cache-diagnosis-2026-04-07"],
)
# Turn 2: reference the previous response id
r2 = client.beta.messages.create(
model="claude-opus-5-5",
max_tokens=1024,
cache_control={"type": "ephemeral"},
system=SYSTEM,
messages=[
{"role": "user", "content": "Summarize section 1."},
{"role": "assistant", "content": r1.content},
{"role": "user", "content": "Now summarize section 2."},
],
diagnostics={"previous_message_id": r1.id},
betas=["cache-diagnosis-2026-04-07"],
)
diagnostics = r2.diagnostics
if diagnostics is None:
print("No divergence detected.")
elif diagnostics.cache_miss_reason is None:
print("Comparison still pending.")
else:
print(f"cache_miss_reason: {diagnostics.cache_miss_reason.type}")
const client = new Anthropic();
const SYSTEM = "You are an AI assistant analyzing a large document. <document>...</document>";
// Turn 1: opt in with previous_message_id: null
const r1 = await client.beta.messages.create({
model: "claude-opus-5-5",
max_tokens: 1024,
cache_control: { type: "ephemeral" },
system: SYSTEM,
messages: [{ role: "user", content: "Summarize section 1." }],
diagnostics: { previous_message_id: null },
betas: ["cache-diagnosis-2026-04-07"]
});
// Turn 2: reference the previous response id
const r2 = await client.beta.messages.create({
model: "claude-opus-5-5",
max_tokens: 1024,
cache_control: { type: "ephemeral" },
system: SYSTEM,
messages: [
{ role: "user", content: "Summarize section 1." },
{ role: "assistant", content: r1.content },
{ role: "user", content: "Now summarize section 2." }
],
diagnostics: { previous_message_id: r1.id },
betas: ["cache-diagnosis-2026-04-07"]
});
if (r2.diagnostics === null) {
console.log("No divergence detected.");
} else if (r2.diagnostics.cache_miss_reason === null) {
console.log("Comparison still pending.");
} else {
console.log(`cache_miss_reason: ${r2.diagnostics.cache_miss_reason.type}`);
}
AnthropicClient client = new();
var system = "You are an AI assistant analyzing a large document. <document>...</document>";
var r1 = await client.Beta.Messages.Create(
new()
{
Model = Messages::Model.ClaudeOpus5_5,
MaxTokens = 1024,
CacheControl = new(),
System = system,
Messages =
[
new() { Role = Role.User, Content = "Summarize section 1." },
],
Diagnostics = new() { PreviousMessageID = null },
Betas = [AnthropicBeta.CacheDiagnosis2026_04_07],
}
);
var r2 = await client.Beta.Messages.Create(
new()
{
Model = Messages::Model.ClaudeOpus5_5,
MaxTokens = 1024,
CacheControl = new(),
System = system,
Messages =
[
new() { Role = Role.User, Content = "Summarize section 1." },
new()
{
Role = Role.Assistant,
Content = r1.Content.Select(block => new BetaContentBlockParam(block.Json)).ToList(),
},
new() { Role = Role.User, Content = "Now summarize section 2." },
],
Diagnostics = new() { PreviousMessageID = r1.ID },
Betas = [AnthropicBeta.CacheDiagnosis2026_04_07],
}
);
Console.WriteLine(r2.Diagnostics switch
{
null => "No divergence detected.",
{ CacheMissReason: null } => "Comparison still pending.",
{ CacheMissReason.Type: var type } => $"cache_miss_reason: {type.GetString()}",
});
client := anthropic.NewClient()
ctx := context.Background()
system := []anthropic.BetaTextBlockParam{
{Text: "You are an AI assistant analyzing a large document. <document>...</document>"},
}
r1, err := client.Beta.Messages.New(ctx, anthropic.BetaMessageNewParams{
Model: anthropic.ModelClaudeOpus5_5,
MaxTokens: 1024,
CacheControl: anthropic.BetaCacheControlEphemeralParam{},
System: system,
Messages: []anthropic.BetaMessageParam{
anthropic.NewBetaUserMessage(anthropic.NewBetaTextBlock("Summarize section 1.")),
},
Diagnostics: anthropic.BetaDiagnosticsParam{
PreviousMessageID: param.Null[string](),
},
Betas: []anthropic.AnthropicBeta{anthropic.AnthropicBetaCacheDiagnosis2026_04_07},
})
if err != nil {
panic(err)
}
r2, err := client.Beta.Messages.New(ctx, anthropic.BetaMessageNewParams{
Model: anthropic.ModelClaudeOpus5_5,
MaxTokens: 1024,
CacheControl: anthropic.BetaCacheControlEphemeralParam{},
System: system,
Messages: []anthropic.BetaMessageParam{
anthropic.NewBetaUserMessage(anthropic.NewBetaTextBlock("Summarize section 1.")),
r1.ToParam(),
anthropic.NewBetaUserMessage(anthropic.NewBetaTextBlock("Now summarize section 2.")),
},
Diagnostics: anthropic.BetaDiagnosticsParam{
PreviousMessageID: anthropic.String(r1.ID),
},
Betas: []anthropic.AnthropicBeta{anthropic.AnthropicBetaCacheDiagnosis2026_04_07},
})
if err != nil {
panic(err)
}
switch {
case !r2.JSON.Diagnostics.Valid():
fmt.Println("No divergence detected.")
case !r2.Diagnostics.JSON.CacheMissReason.Valid():
fmt.Println("Comparison still pending.")
default:
fmt.Printf("cache_miss_reason: %s\n", r2.Diagnostics.CacheMissReason.Type)
}
var client = AnthropicOkHttpClient.fromEnv();
var system = "You are an AI assistant analyzing a large document. <document>...</document>";
var r1 = client.beta().messages().create(
MessageCreateParams.builder()
.model(Model.CLAUDE_OPUS_5_5)
.maxTokens(1024)
.cacheControl(BetaCacheControlEphemeral.builder().build())
.system(system)
.addUserMessage("Summarize section 1.")
// Pass null on the first turn to opt in without a prior message to compare.
.diagnostics(BetaDiagnosticsParam.builder().previousMessageId((String) null).build())
.addBeta(AnthropicBeta.CACHE_DIAGNOSIS_2026_04_07)
.build()
);
var r2 = client.beta().messages().create(
MessageCreateParams.builder()
.model(Model.CLAUDE_OPUS_5_5)
.maxTokens(1024)
.cacheControl(BetaCacheControlEphemeral.builder().build())
.system(system)
.addUserMessage("Summarize section 1.")
.addMessage(r1)
.addUserMessage("Now summarize section 2.")
.diagnostics(BetaDiagnosticsParam.builder().previousMessageId(r1.id()).build())
.addBeta(AnthropicBeta.CACHE_DIAGNOSIS_2026_04_07)
.build()
);
if (r2.diagnostics().isEmpty()) {
IO.println("No divergence detected.");
} else if (r2.diagnostics().get().cacheMissReason().isEmpty()) {
IO.println("Comparison still pending.");
} else {
var reason = r2.diagnostics().get().cacheMissReason().get();
// CacheMissReason doesn't expose a typed .type() accessor; read it from the raw JSON.
@SuppressWarnings("unchecked")
var json = (Map<String, JsonValue>) reason._json().orElseThrow().asObject().orElseThrow();
IO.println("cache_miss_reason: " + json.get("type").asStringOrThrow());
}
$client = new Client();
$system = 'You are an AI assistant analyzing a large document. <document>...</document>';
$r1 = $client->beta->messages->create(
model: Model::CLAUDE_OPUS_5_5,
maxTokens: 1024,
cacheControl: new BetaCacheControlEphemeral,
system: $system,
messages: [
['role' => 'user', 'content' => 'Summarize section 1.'],
],
diagnostics: (new BetaDiagnosticsParam)->withPreviousMessageID(null),
betas: [AnthropicBeta::CACHE_DIAGNOSIS_2026_04_07],
);
$r2 = $client->beta->messages->create(
model: Model::CLAUDE_OPUS_5_5,
maxTokens: 1024,
cacheControl: new BetaCacheControlEphemeral,
system: $system,
messages: [
['role' => 'user', 'content' => 'Summarize section 1.'],
['role' => 'assistant', 'content' => $r1->content],
['role' => 'user', 'content' => 'Now summarize section 2.'],
],
diagnostics: (new BetaDiagnosticsParam)->withPreviousMessageID($r1->id),
betas: [AnthropicBeta::CACHE_DIAGNOSIS_2026_04_07],
);
echo match (true) {
$r2->diagnostics === null => "No divergence detected.\n",
$r2->diagnostics->cacheMissReason === null => "Comparison still pending.\n",
default => "cache_miss_reason: {$r2->diagnostics->cacheMissReason->type}\n",
};
client = Anthropic::Client.new
SYSTEM = "You are an AI assistant analyzing a large document. <document>...</document>"
r1 = client.beta.messages.create(
model: :"claude-opus-5-5",
max_tokens: 1024,
cache_control: {type: "ephemeral"},
system_: SYSTEM,
messages: [
{role: "user", content: "Summarize section 1."}
],
diagnostics: {previous_message_id: nil},
betas: ["cache-diagnosis-2026-04-07"]
)
r2 = client.beta.messages.create(
model: :"claude-opus-5-5",
max_tokens: 1024,
cache_control: {type: "ephemeral"},
system_: SYSTEM,
messages: [
{role: "user", content: "Summarize section 1."},
{role: "assistant", content: r1.content},
{role: "user", content: "Now summarize section 2."}
],
diagnostics: {previous_message_id: r1.id},
betas: ["cache-diagnosis-2026-04-07"]
)
case r2.diagnostics
in nil
puts "No divergence detected."
in {cache_miss_reason: nil}
puts "Comparison still pending."
in {cache_miss_reason: {type:}}
puts "cache_miss_reason: #{type}"
end
스트리밍
스트리밍 응답에서는 diagnostics가 message_start 이벤트에 나타나요.
# Turn 2: stream. With --stream the CLI emits each SSE event as one JSON object.
# diagnostics arrives on the message_start event; pick it out with jq.
ant beta:messages create \
--beta cache-diagnosis-2026-04-07 \
--stream --format jsonl <<YAML |
model: claude-opus-5-5
max_tokens: 1024
cache_control:
type: ephemeral
system: "You are an AI assistant analyzing a large document. <document>...</document>"
messages:
- role: user
content: Summarize section 1.
- role: assistant
content: Section 1 covers...
- role: user
content: Now summarize section 2.
diagnostics:
previous_message_id: $message_id
YAML
jq -c 'select(.type == "message_start") | .message | {id,usage,diagnostics}'
# Turn 2: stream, referencing the previous response id
with client.beta.messages.stream(
model="claude-opus-5-5",
max_tokens=1024,
cache_control={"type": "ephemeral"},
system=SYSTEM,
messages=[
{"role": "user", "content": "Summarize section 1."},
{"role": "assistant", "content": r1.content},
{"role": "user", "content": "Now summarize section 2."},
],
diagnostics={"previous_message_id": r1.id},
betas=["cache-diagnosis-2026-04-07"],
) as stream:
for text in stream.text_stream:
print(text, end="", flush=True)
print()
r2 = stream.get_final_message()
diagnostics = r2.diagnostics
if diagnostics is None:
print("No divergence detected.")
elif diagnostics.cache_miss_reason is None:
print("Comparison still pending.")
else:
print(f"cache_miss_reason: {diagnostics.cache_miss_reason.type}")
const stream = client.beta.messages.stream({
model: "claude-opus-5-5",
max_tokens: 1024,
cache_control: { type: "ephemeral" },
system: SYSTEM,
messages: [
{ role: "user", content: "Summarize section 1." },
{ role: "assistant", content: r1.content },
{ role: "user", content: "Now summarize section 2." }
],
diagnostics: { previous_message_id: r1.id },
betas: ["cache-diagnosis-2026-04-07"]
});
for await (const event of stream) {
if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
process.stdout.write(event.delta.text);
}
}
process.stdout.write("\n");
// diagnostics arrives on message_start and is carried through to the final message
const r2 = await stream.finalMessage();
if (r2.diagnostics === null) {
console.log("No divergence detected.");
} else if (r2.diagnostics.cache_miss_reason === null) {
console.log("Comparison still pending.");
} else {
console.log(`cache_miss_reason: ${r2.diagnostics.cache_miss_reason.type}`);
}
// Turn 2: stream, referencing the previous response id
BetaDiagnostics? diagnostics = null;
var stream = client.Beta.Messages.CreateStreaming(
new()
{
Model = Messages::Model.ClaudeOpus5_5,
MaxTokens = 1024,
CacheControl = new(),
System = system,
Messages =
[
new() { Role = Role.User, Content = "Summarize section 1." },
new()
{
Role = Role.Assistant,
Content = r1.Content.Select(block => new BetaContentBlockParam(block.Json)).ToList(),
},
new() { Role = Role.User, Content = "Now summarize section 2." },
],
Diagnostics = new() { PreviousMessageID = r1.ID },
Betas = [AnthropicBeta.CacheDiagnosis2026_04_07],
}
);
await foreach (var streamEvent in stream)
{
if (streamEvent.TryPickStart(out var start))
{
// diagnostics arrives on the message_start event
diagnostics = start.Message.Diagnostics;
}
else if (streamEvent.TryPickContentBlockDelta(out var delta) && delta.Delta.TryPickText(out var textDelta))
{
Console.Write(textDelta.Text);
}
}
Console.WriteLine();
Console.WriteLine(diagnostics switch
{
null => "No divergence detected.",
{ CacheMissReason: null } => "Comparison still pending.",
{ CacheMissReason.Type: var type } => $"cache_miss_reason: {type.GetString()}",
});
// Turn 2: stream, referencing the previous response id
stream := client.Beta.Messages.NewStreaming(ctx, anthropic.BetaMessageNewParams{
Model: anthropic.ModelClaudeOpus5_5,
MaxTokens: 1024,
CacheControl: anthropic.BetaCacheControlEphemeralParam{},
System: system,
Messages: []anthropic.BetaMessageParam{
anthropic.NewBetaUserMessage(anthropic.NewBetaTextBlock("Summarize section 1.")),
r1.ToParam(),
anthropic.NewBetaUserMessage(anthropic.NewBetaTextBlock("Now summarize section 2.")),
},
Diagnostics: anthropic.BetaDiagnosticsParam{
PreviousMessageID: anthropic.String(r1.ID),
},
Betas: []anthropic.AnthropicBeta{anthropic.AnthropicBetaCacheDiagnosis2026_04_07},
})
defer stream.Close()
// diagnostics arrives on message_start; Accumulate carries it into r2
var r2 anthropic.BetaMessage
for stream.Next() {
if err := r2.Accumulate(stream.Current()); err != nil {
panic(err)
}
}
if err := stream.Err(); err != nil {
panic(err)
}
switch {
case !r2.JSON.Diagnostics.Valid():
fmt.Println("No divergence detected.")
case !r2.Diagnostics.JSON.CacheMissReason.Valid():
fmt.Println("Comparison still pending.")
default:
fmt.Printf("cache_miss_reason: %s\n", r2.Diagnostics.CacheMissReason.Type)
}
// Turn 2: stream, referencing the previous response id
var params = MessageCreateParams.builder()
.model(Model.CLAUDE_OPUS_5_5)
.maxTokens(1024)
.cacheControl(BetaCacheControlEphemeral.builder().build())
.system(system)
.addUserMessage("Summarize section 1.")
.addMessage(r1)
.addUserMessage("Now summarize section 2.")
.diagnostics(BetaDiagnosticsParam.builder().previousMessageId(r1.id()).build())
.addBeta(AnthropicBeta.CACHE_DIAGNOSIS_2026_04_07)
.build();
var accumulator = BetaMessageAccumulator.create();
try (var streamResponse = client.beta().messages().createStreaming(params)) {
streamResponse.stream()
.peek(accumulator::accumulate)
.flatMap(event -> event.contentBlockDelta().stream())
.flatMap(deltaEvent -> deltaEvent.delta().text().stream())
.forEach(textDelta -> IO.print(textDelta.text()));
IO.println("");
}
// diagnostics arrives on message_start and is carried through to the accumulated message
var diagnostics = accumulator.message().diagnostics();
if (diagnostics.isEmpty()) {
IO.println("No divergence detected.");
} else if (diagnostics.get().cacheMissReason().isEmpty()) {
IO.println("Comparison still pending.");
} else {
var reason = diagnostics.get().cacheMissReason().get();
// CacheMissReason doesn't expose a typed .type() accessor; read it from the raw JSON.
@SuppressWarnings("unchecked")
var json = (Map<String, JsonValue>) reason._json().orElseThrow().asObject().orElseThrow();
IO.println("cache_miss_reason: " + json.get("type").asStringOrThrow());
}
// Turn 2: stream, referencing the previous response id
$stream = $client->beta->messages->createStream(
model: Model::CLAUDE_OPUS_5_5,
maxTokens: 1024,
cacheControl: new BetaCacheControlEphemeral,
system: $system,
messages: [
['role' => 'user', 'content' => 'Summarize section 1.'],
['role' => 'assistant', 'content' => $r1->content],
['role' => 'user', 'content' => 'Now summarize section 2.'],
],
diagnostics: (new BetaDiagnosticsParam)->withPreviousMessageID($r1->id),
betas: [AnthropicBeta::CACHE_DIAGNOSIS_2026_04_07],
);
$diagnostics = null;
foreach ($stream as $event) {
switch (true) {
case $event instanceof \Anthropic\Beta\Messages\BetaRawMessageStartEvent:
// diagnostics arrives on the message_start event's embedded BetaMessage
$diagnostics = $event->message->diagnostics;
break;
case $event instanceof \Anthropic\Beta\Messages\BetaRawContentBlockDeltaEvent:
if ($event->delta instanceof \Anthropic\Beta\Messages\BetaTextDelta) {
echo $event->delta->text;
}
break;
}
}
echo PHP_EOL;
echo match (true) {
$diagnostics === null => "No divergence detected.\n",
$diagnostics->cacheMissReason === null => "Comparison still pending.\n",
default => "cache_miss_reason: {$diagnostics->cacheMissReason->type}\n",
};
# Turn 2: stream, referencing the previous response id
stream = client.beta.messages.stream(
model: :"claude-opus-5-5",
max_tokens: 1024,
cache_control: {type: "ephemeral"},
system_: SYSTEM,
messages: [
{role: "user", content: "Summarize section 1."},
{role: "assistant", content: r1.content},
{role: "user", content: "Now summarize section 2."}
],
diagnostics: {previous_message_id: r1.id},
betas: ["cache-diagnosis-2026-04-07"]
)
stream.each do |event|
print(event.text) if event.is_a?(Anthropic::Streaming::TextEvent)
end
puts
# diagnostics arrives on message_start and is retained on the accumulated message
r2 = stream.accumulated_message
case r2.diagnostics
in nil
puts "No divergence detected."
in {cache_miss_reason: nil}
puts "Comparison still pending."
in {cache_miss_reason: {type:}}
puts "cache_miss_reason: #{type}"
end
message_start 이벤트는 전체 diagnostics 필드를 담아요. 가능한 값은 response format을 보세요.
대화 루프에 진단을 관통시키기
다중 턴 대화에서는 최신 응답 id를 매 턴 previous_message_id로 이월해요. 첫 번째 반복은 null을 넘겨 옵트인하고, 이후 각 반복은 이전 응답의 id를 넘겨요.
SYSTEM = "You are an AI assistant analyzing a large document. <document>...</document>"
messages = []
prev_id = None
for i, user_message in enumerate(
["Summarize section 1.", "Now section 2.", "Now section 3."]
):
messages.append({"role": "user", "content": user_message})
r = client.beta.messages.create(
model="claude-opus-5-5",
max_tokens=1024,
cache_control={"type": "ephemeral"},
system=SYSTEM,
messages=messages,
diagnostics={"previous_message_id": prev_id},
betas=["cache-diagnosis-2026-04-07"],
)
if r.diagnostics is not None and r.diagnostics.cache_miss_reason is not None:
print(f"Turn {i + 1} cache_miss_reason: {r.diagnostics.cache_miss_reason.type}")
messages.append({"role": "assistant", "content": r.content})
prev_id = r.id
```
const SYSTEM = "You are an AI assistant analyzing a large document. <document>...</document>";
const prompts = ["Summarize section 1.", "Now section 2.", "Now section 3."];
const messages: BetaMessageParam[] = [];
let prevId: string | null = null;
for (const [i, prompt] of prompts.entries()) {
messages.push({ role: "user", content: prompt });
const r: BetaMessage = await client.beta.messages.create({
model: "claude-opus-5-5",
max_tokens: 1024,
cache_control: { type: "ephemeral" },
system: SYSTEM,
messages,
diagnostics: { previous_message_id: prevId },
betas: ["cache-diagnosis-2026-04-07"]
});
if (r.diagnostics?.cache_miss_reason) {
console.log(`Turn ${i + 1} cache_miss_reason: ${r.diagnostics.cache_miss_reason.type}`);
}
messages.push({ role: "assistant", content: r.content });
prevId = r.id;
}
```
var system = "You are an AI assistant analyzing a large document. <document>...</document>";
List<BetaMessageParam> messages = [];
string? prevId = null;
string[] prompts = ["Summarize section 1.", "Now section 2.", "Now section 3."];
for (int i = 0; i < prompts.Length; i++)
{
messages.Add(new() { Role = Role.User, Content = prompts[i] });
var r = await client.Beta.Messages.Create(
new()
{
Model = Messages::Model.ClaudeOpus5_5,
MaxTokens = 1024,
CacheControl = new(),
System = system,
Messages = messages,
Diagnostics = new() { PreviousMessageID = prevId },
Betas = [AnthropicBeta.CacheDiagnosis2026_04_07],
}
);
if (r.Diagnostics?.CacheMissReason is { Type: var type })
{
Console.WriteLine($"Turn {i + 1} cache_miss_reason: {type.GetString()}");
}
messages.Add(
new()
{
Role = Role.Assistant,
Content = r.Content.Select(block => new BetaContentBlockParam(block.Json)).ToList(),
}
);
prevId = r.ID;
}
```
system := []anthropic.BetaTextBlockParam{
{Text: "You are an AI assistant analyzing a large document. <document>...</document>"},
}
prompts := []string{"Summarize section 1.", "Now section 2.", "Now section 3."}
var messages []anthropic.BetaMessageParam
prevID := param.Null[string]()
for turn, prompt := range prompts {
messages = append(messages, anthropic.NewBetaUserMessage(anthropic.NewBetaTextBlock(prompt)))
r, err := client.Beta.Messages.New(ctx, anthropic.BetaMessageNewParams{
Model: anthropic.ModelClaudeOpus5_5,
MaxTokens: 1024,
CacheControl: anthropic.BetaCacheControlEphemeralParam{},
System: system,
Messages: messages,
Diagnostics: anthropic.BetaDiagnosticsParam{
PreviousMessageID: prevID,
},
Betas: []anthropic.AnthropicBeta{anthropic.AnthropicBetaCacheDiagnosis2026_04_07},
})
if err != nil {
panic(err)
}
if r.JSON.Diagnostics.Valid() && r.Diagnostics.JSON.CacheMissReason.Valid() {
fmt.Printf("Turn %d cache_miss_reason: %s\n", turn+1, r.Diagnostics.CacheMissReason.Type)
}
messages = append(messages, r.ToParam())
prevID = anthropic.String(r.ID)
}
```
var system = "You are an AI assistant analyzing a large document. <document>...</document>";
var prompts = List.of("Summarize section 1.", "Now section 2.", "Now section 3.");
var messages = new ArrayList<BetaMessageParam>();
String prevId = null;
for (var turn = 0; turn < prompts.size(); turn++) {
messages.add(
BetaMessageParam.builder()
.role(BetaMessageParam.Role.USER)
.content(prompts.get(turn))
.build()
);
var r = client.beta().messages().create(
MessageCreateParams.builder()
.model(Model.CLAUDE_OPUS_5_5)
.maxTokens(1024)
.cacheControl(BetaCacheControlEphemeral.builder().build())
.system(system)
.messages(messages)
.diagnostics(BetaDiagnosticsParam.builder().previousMessageId(prevId).build())
.addBeta(AnthropicBeta.CACHE_DIAGNOSIS_2026_04_07)
.build()
);
if (r.diagnostics().isPresent() && r.diagnostics().get().cacheMissReason().isPresent()) {
var reason = r.diagnostics().get().cacheMissReason().get();
// CacheMissReason doesn't expose a typed .type() accessor; read it from the raw JSON.
@SuppressWarnings("unchecked")
var json = (Map<String, JsonValue>) reason._json().orElseThrow().asObject().orElseThrow();
IO.println("Turn " + (turn + 1) + " cache_miss_reason: " + json.get("type").asStringOrThrow());
}
messages.add(r.toParam());
prevId = r.id();
}
```
$system = 'You are an AI assistant analyzing a large document. <document>...</document>';
$messages = [];
$prevId = null;
foreach (['Summarize section 1.', 'Now section 2.', 'Now section 3.'] as $i => $userMsg) {
$turn = $i + 1;
$messages[] = ['role' => 'user', 'content' => $userMsg];
$r = $client->beta->messages->create(
model: Model::CLAUDE_OPUS_5_5,
maxTokens: 1024,
cacheControl: new BetaCacheControlEphemeral,
system: $system,
messages: $messages,
diagnostics: (new BetaDiagnosticsParam)->withPreviousMessageID($prevId),
betas: [AnthropicBeta::CACHE_DIAGNOSIS_2026_04_07],
);
if ($r->diagnostics?->cacheMissReason !== null) {
echo "Turn {$turn} cache_miss_reason: {$r->diagnostics->cacheMissReason->type}\n";
}
$messages[] = ['role' => 'assistant', 'content' => $r->content];
$prevId = $r->id;
}
```
SYSTEM = "You are an AI assistant analyzing a large document. <document>...</document>"
messages = []
prev_id = nil
["Summarize section 1.", "Now section 2.", "Now section 3."].each_with_index do |user_msg, i|
messages << {role: "user", content: user_msg}
r = client.beta.messages.create(
model: :"claude-opus-5-5",
max_tokens: 1024,
cache_control: {type: "ephemeral"},
system_: SYSTEM,
messages: messages,
diagnostics: {previous_message_id: prev_id},
betas: ["cache-diagnosis-2026-04-07"]
)
if (reason = r.diagnostics&.cache_miss_reason)
puts "Turn #{i + 1} cache_miss_reason: #{reason.type}"
end
messages << {role: "assistant", content: r.content}
prev_id = r.id
end
```
응답 형식
응답 Message의 diagnostics 필드는 네 가지 상태가 있어요.
| 값 | 의미 |
|---|---|
| 필드 없음 | 요청에 diagnostics가 없었거나 베타 헤더가 빠졌어요. |
null |
previous_message_id가 null이었거나(첫 턴, 비교할 것이 없음), 비교가 실행됐는데 분기점이 없었어요. |
{"cache_miss_reason": null} |
응답이 직렬화될 때 비교가 아직 실행 중이었어요. 응답이 매우 빨리 시작될 때 발생할 수 있어요. 결론을 내리지 말고 다음 턴을 확인하세요. |
{"cache_miss_reason": {...}} |
cache_miss_reason이 붙었어요. *_changed 유형은 첫 번째 분기 지점을 알려주고, previous_message_not_found와 unavailable은 비교 결과가 없었던 경우예요. |
cache_miss_reason이 null이 아니면 이런 모양이에요.
{
"id": "msg_01Xyz...",
"type": "message",
"role": "assistant",
"content": [{ "type": "text", "text": "..." }],
"usage": {
"input_tokens": 42,
"cache_read_input_tokens": 0,
"cache_creation_input_tokens": 41850,
"output_tokens": 210
},
"diagnostics": {
"cache_miss_reason": {
"type": "system_changed",
"cache_missed_input_tokens": 41850
}
}
}
캐시 미스 원인 유형
cache_miss_reason은 type에 따라 갈라지는 판별 유니온이에요. 응답은 가장 이른 분기점만 보고하므로 그것부터 고쳐요. 그 뒤에 있는 것들은 가려질 수 있어요.
| 유형 | 의미 | 바꿀 것 |
|---|---|---|
model_changed |
model이 이전 요청과 달라요(예: 라우터, A/B 테스트, 폴백이 다른 모델을 선택). 캐시는 모델별이에요. |
캐시된 대화 안에서 모델을 일정하게 유지하세요. |
system_changed |
system 매개변수가 달라요. 보통 타임스탬프, 요청 ID, 기타 요청별 값이 시스템 프롬프트에 끼워 넣어졌기 때문이에요. |
시스템 프롬프트를 바이트 안정적인 상수로 만들고, 동적 데이터는 캐시 중단점 뒤의 첫 번째 user 메시지로 옮기세요. |
tools_changed |
tools 배열이 달라요. 턴 사이에 도구가 추가·제거·재정렬됐거나, 도구 input_schema JSON이 비결정적으로 직렬화됐어요. |
매 턴 같은 도구 목록을 고정 순서로, 결정적으로 직렬화된 스키마(예: 키 정렬)로 보내세요. |
messages_changed |
모델·시스템·도구가 모두 같은데 messages의 이전 항목이 추가 대신 변경·재정렬·제거됐어요. 보통 대화 기록이 잘렸거나 편집됐거나, 도우미 턴과 tool_result 블록이 재전송 시 다르게 재직렬화됐어요. |
기록을 append-only로 다루고, 도우미 content와 도구 결과를 그대로 에코하세요. |
previous_message_not_found |
제공한 previous_message_id에 대한 저장된 지문이 없어요. 요청이 바뀌었다는 증거는 아니에요. 보통 이전 요청이 베타 헤더를 안 썼거나, 다른 워크스페이스에서 왔거나, 보낸 지 너무 오래 지났어요. |
매 턴 베타 헤더를 보내고 연속 턴을 시간상 가깝게 유지하세요. |
unavailable |
이 요청에 대해 진단 정보를 쓸 수 없었어요. model, system, tools가 같은데 다른 프롬프트 영향 요청 매개변수(tool_choice, thinking, context_management, output_config, output_format, 또는 활성 anthropic-beta 헤더 집합)가 다르거나, 분기가 비교 지평선을 넘는 아주 긴 대화가 그 예시예요. 요청은 정상 처리됐어요. |
캐시된 대화 수명 동안 프롬프트 영향 요청 매개변수를 일정하게 유지하세요. 지속되면 프롬프트 캐싱 페이지의 troubleshooting common issues 아래 수동 점검을 적용하세요. |
usage와 함께 진단 읽기
diagnostics는 "요청이 바뀌었나?"에 답하고, usage.cache_read_input_tokens는 "캐시가 적중했나?"에 답해요. 둘을 합치면 어디를 봐야 할지 알 수 있어요.
이 표는 실제 previous_message_id를 넘긴 턴에 적용돼요. 첫 턴(previous_message_id: null)에서는 diagnostics가 항상 null이고, 캐시가 읽히는 게 아니라 쓰이는 중이라 cache_read_input_tokens는 보통 0이에요. 문제 해결이 필요 없어요. cache_miss_reason이 null(비교 아직 진행 중, 다음 턴 확인)이거나 type이 previous_message_not_found·unavailable(비교 결과 없음)일 때도 표가 적용되지 않아요.
| 진단 결과 | 캐시 읽기 토큰 | 해석 |
|---|---|---|
null |
높음 | 정상 동작이에요. 접두사가 안정적이고 캐시가 적중했어요. |
null |
낮음 또는 0 | 요청은 같지만 캐시 항목을 더는 쓸 수 없었어요. 턴 간 간격을 줄이거나 1시간 캐시 TTL을 사용하세요. |
cache_miss_reason이 *_changed 유형 |
낮음 또는 0 | 문제는 여러분 쪽이에요. 요청이 바뀌었으니 type이 가리키는 원인을 고치세요. |
cache_miss_reason이 *_changed 유형 |
높음 | 드물어요. 프롬프트 늦은 부분에서 변화가 일어났지만 이전 cache_control 중단점이 여전히 적중했어요. 고칠 가치가 있지만 영향은 작아요. |
제약 사항
- 베타: 이 기능이 베타인 동안 필드 이름과 의미는 바뀔 수 있어요.
- Claude API 전용: Amazon Bedrock이나 Google Cloud에는 없어요.
- 제한된 보관:
previous_message_id조회용 지문은 짧은 시간 후 만료돼요. 진단 비교는 시간이 가까운 요청 사이에서 실행하세요. - 같은 워크스페이스: 이전 요청은 같은 조직과 워크스페이스에서 실행됐어야 해요. 확인하려면 두 응답의
anthropic-workspace-id응답 헤더를 비교하세요. - 비교 지평선: 메시지 목록 깊은 곳에서만 바뀐 아주 긴 대화는 정확한 위치 대신
unavailable로 응답할 수 있어요. - 최선 노력: 진단은 요청을 막거나 실패시키지 않아요. 진단 정보를 쓸 수 없으면
unavailable을, 비교가 아직 진행 중이면cache_miss_reason: null을 반환해요.
데이터 보존
캐시 진단은 ZDR 자격이 있어요(적격). Anthropic은 이 기능을 위해 프롬프트 원문이나 Claude 출력을 저장하지 않아요.
각 요청에 저장되는 지문은 암호화 해시와 토큰 수 추정치뿐이며, 응답 id를 키로 하고 조직과 워크스페이스 범위로 한정돼요. 지문은 짧은 시간 후 만료되고 다른 용도로 쓰이지 않아요.
모든 기능의 ZDR 자격에 대해서는 API and data retention을 보세요.