defmodule CMDC.Plugin.Builtin.PromptCache do @moduledoc """ P1 Anthropic Prompt Caching 插件。 在 `before_request` 事件中检测是否为 Anthropic 模型,若是则选取最多 `max_cache_points`(默认 3)个消息位置打上 `cache_control` 标记, 通过 emit `{:update_message_metadata, list}` 通知 Agent 更新消息 metadata。 Provider 在构建 Anthropic 请求时读取 metadata 中的 `cache_control: true`, 将对应消息的 content block 末尾追加 `{"cache_control": {"type": "ephemeral"}}`。 非 Anthropic 模型(OpenAI、Gemini 等)自动跳过,不修改任何消息。 ## 缓存点选取策略 按以下优先级选取,最多 `max_cache_points` 个: 1. system 消息(通常是最大的固定内容) 2. 最后一条 user 消息(对话的最新上下文) 3. 最早的 assistant + tool_result 消息对(历史工具调用结果) ## 配置 {CMDC.Plugin.Builtin.PromptCache, max_cache_points: 3, # 最多打多少个缓存标记(Anthropic 限制最多 4 个) min_tokens: 100 # 跳过估计 token 数低于此值的消息(避免无效缓存) } """ @behaviour CMDC.Plugin require Logger @default_max_cache_points 3 @default_min_tokens 100 # ========================================================================== # Plugin Callbacks # ========================================================================== @impl true def init(opts) do state = %{ max_cache_points: Keyword.get(opts, :max_cache_points, @default_max_cache_points), min_tokens: Keyword.get(opts, :min_tokens, @default_min_tokens) } {:ok, state} end @impl true def priority, do: 130 @impl true def describe, do: "P1 Anthropic Prompt Caching:给关键消息追加 cache_control 标记" @impl true def handle_event({:before_request, messages}, state, ctx) do if anthropic_model?(ctx.model) do apply_cache_control(messages, state, ctx) else {:continue, state} end end def handle_event(_event, state, _ctx), do: {:continue, state} # ========================================================================== # 私有 — 缓存控制应用 # ========================================================================== defp apply_cache_control(messages, state, ctx) do candidates = select_cache_candidates(messages, state) if candidates == [] do {:continue, state} else Logger.debug( "[PromptCache] 为 #{length(candidates)} 条消息添加 cache_control session=#{ctx.session_id}" ) updates = Enum.map(candidates, fn msg -> {msg.id, %{cache_control: true}} end) {:emit, {:update_message_metadata, updates}, state} end end defp select_cache_candidates(messages, state) do system_msgs = Enum.filter(messages, &(&1.role == :system and long_enough?(&1, state.min_tokens))) user_msgs = Enum.filter(messages, &(&1.role == :user and long_enough?(&1, state.min_tokens))) last_user = List.last(user_msgs) tool_result_msgs = messages |> Enum.filter(&(&1.role == :tool_result and long_enough?(&1, state.min_tokens))) |> Enum.take(state.max_cache_points) candidates = [system_msgs, if(last_user, do: [last_user], else: []), tool_result_msgs] |> List.flatten() |> Enum.uniq_by(& &1.id) |> Enum.take(state.max_cache_points) candidates end defp long_enough?(%{content: content}, min_tokens) when is_binary(content) do div(byte_size(content), 4) >= min_tokens end defp long_enough?(_, _), do: false # ========================================================================== # 私有 — Anthropic 模型检测 # ========================================================================== defp anthropic_model?(model) when is_binary(model) do String.contains?(model, "anthropic:") or String.contains?(model, "claude") end defp anthropic_model?(%{provider: :anthropic}), do: true defp anthropic_model?(_), do: false end