PAYATHON 2026

OpenAI Realtime C# 新会话首轮为何出现缓存 token 用量?

技术老齐

结论

新建 Realtime 会话的首轮回复可以出现缓存输入 token。即使会话没有历史消息,服务端也可能有可复用的 prompt 前缀。Azure 的 prompt caching 适用于 Realtime 操作,缓存也不以当前会话的生命周期为唯一边界。如果相同指令在此前测试或其他符合缓存匹配条件的请求中出现过,就可能解释新会话首轮的缓存命中。Azure prompt caching 文档

这份日志只能说明程序记录到了缓存用量,无法确定缓存来源,也不能仅凭数值断定 ReadUsage 没有问题。其中的 64 个缓存音频 token 尤其需要单独核对。

为什么首轮也有可缓存的输入

首轮请求中除了用户刚发送的音频,还有代码创建会话时设置的指令:

Instructions = firstLargeInstruction

这些指令属于模型生成回复时使用的上下文。Realtime 的 instructions 和 tool definitions 位于对话前部,内容保持稳定有利于缓存复用。Realtime 成本优化文档

对于这里的 gpt-realtime-mini,重点检查长指令和输入音频是否被重复使用。Azure 文档说明,prompt caching 需要至少 1,024 个 token,且起始的 1,024 个 token 必须完全一致。新建 session 不会自动清空已有的 prompt cache。Azure 缓存匹配条件

现有日志还不能确认具体的缓存来源:

  • firstLargeInstruction 是合理的候选来源,但日志无法证明 1,344 个缓存文本 token 全部来自它。
  • 64 个缓存音频 token 无法仅用文本指令缓存解释,需要检查音频复用、此前响应和字段映射。
  • 日志没有提供 Azure 内部 warm-start、隐藏提示预加载或 session.updated 触发预填充的证据,不能把这些机制当作已确认原因。

是否漏掉了会话初始化阶段的用量

统计模型回复用量时,应读取 response.done 中的 response.usage。session.created 和 session.updated 用于报告会话配置,不提供同样的回复用量对象。无需因为这次缓存现象,就假定初始化阶段还有一笔漏记的 response 用量。Realtime 服务端事件定义

目前监听 RealtimeServerUpdateResponseDone 的位置是合理的。需要确认是否捕获了全部响应,以及 SDK 对象到业务日志之间的映射是否正确。

先核对字段,再核对事件顺序

1. 对照原始 response.done

Realtime 协议中的字段路径是:

response.usage.input_tokens
response.usage.input_token_details.text_tokens
response.usage.input_token_details.audio_tokens
response.usage.input_token_details.cached_tokens
response.usage.input_token_details.cached_tokens_details.text_tokens
response.usage.input_token_details.cached_tokens_details.audio_tokens

CachedInputTokens 或 cached_input_tokens 可能是业务日志或 SDK 包装层使用的名称。应确认它最终读取的是 input_token_details.cached_tokens,并确认普通输入数量没有被误填进缓存字段。缓存 token 是输入 token 的子集。Realtime usage 字段定义

如果已经取得原始事件 JSON,可以用下面的 C# 代码独立核对核心字段。代码不依赖特定版本的 Realtime SDK 属性名称:

using System;
using System.Text.Json;

static void InspectResponseDone(string eventJson)
{
    using var doc = JsonDocument.Parse(eventJson);
    var root = doc.RootElement;

    if (root.GetProperty("type").GetString() != "response.done")
        return;

    var response = root.GetProperty("response");
    if (!response.TryGetProperty("usage", out var usage) ||
        usage.ValueKind != JsonValueKind.Object)
        return;

    var details = usage.GetProperty("input_token_details");
    long input = usage.GetProperty("input_tokens").GetInt64();
    long cached = details.GetProperty("cached_tokens").GetInt64();

    Console.WriteLine($"ResponseId: {response.GetProperty("id").GetString()}");
    Console.WriteLine($"InputTokens: {input}");
    Console.WriteLine($"CachedInputTokens: {cached}");
    Console.WriteLine($"UncachedInputTokens: {input - cached}");
}

这段代码用于检查已收到的原始事件。取得原始 JSON 需要按当前 SDK 的诊断或传输接口接入,不能假设 responseDone 一定提供某个序列化方法。

2. 检查首个业务步骤前是否已有响应

从连接建立开始记录 session.created、session.updated、response.created 和 response.done,同时保留 session ID、response ID、响应状态和时间顺序。不要等到进入 Voice1 步骤后才开始监听。

还要检查 BuildStandardAudioOptions() 的 VAD 设置。Realtime 可以在检测到语音结束后自动创建响应,而代码又显式调用了 StartResponseAsync()。如果开启了自动响应,需要核对是否已经有另一条响应被创建、完成或取消。当前片段不足以确认这种情况发生过。Realtime 自动响应与手动响应说明

统计时按 response ID 去重,也要检查已取消或未完成的响应返回的 usage,避免只记录播放成功的音频回复。

3. 做一次可解释的对照

保持部署和音频配置一致,分别测试重复使用原始长指令和修改指令开头两种情况,再比较原始事件中的缓存字段。如果只在末尾添加测试标识,前面的长前缀仍可能命中缓存。

同时确认 DefaultInputAudioPath 是否在多次测试中指向同一段音频。对照实验有助于判断缓存是否与重复输入有关,但缓存路由和保留状态会影响结果,单次未命中不能推翻缓存复用的解释。

这份日志能说明什么

按日志数值计算:

未缓存输入:1459 - 1408 = 51
未缓存文本输入:1349 - 1344 = 5
未缓存音频输入:110 - 64 = 46
总输入:1349 + 110 = 1459
总缓存输入:1344 + 64 = 1408
总用量:1459 + 396 = 1855

这些数值在算术上相互一致,但字段来源还没有得到核验。计费或汇总时,不要再把 CachedInputTokens 加到 InputTokens 上,否则会重复计算。

如果原始 response.done 与日志一致,且事件序列确认没有遗漏响应,就可以把首轮缓存视为服务端报告的正常缓存命中。具体哪些内容命中了缓存,单靠 usage 数量无法反推。只有原始字段与 ReadUsage 输出不一致时,才有直接依据定位到用量提取逻辑。

备注:内容仅供参考。