Files
agcore/design/pdd/31-usage字段反序列化容错方案.md
T
徐涛 b7d0a7335f docs: 添加 Usage 字段反序列化容错方案文档
为 Usage 结构体及其内嵌字段添加 `#[serde(default)]` 反序列化容错,
使 blocking 调用在火山平台返回不完整 usage 时不再崩溃。

- 新增 PDD:分析问题根因、设计变更方案、验证标准与回滚方案
- 新增 PRD:梳理需求范围、边界假设与验收标准
2026-07-27 09:34:11 +08:00

12 KiB
Raw Blame History

PDD:Usage 字段反序列化容错方案

状态Draft 作者Think Agent 日期2026-07-27

1. 背景与目标

agcore::Usage 结构体的必填 token 字段添加 #[serde(default)] 反序列化容错,使 OpenAI Response API 和 Chat Completions API 的 blocking 调用在火山平台返回不完整的 usage 字段时不再崩溃。

触发场景

dc-management 项目调用火山引擎 Responses API 时,非流式路径报错:

OpenAI Response 响应解析失败 error=missing field 'prompt_tokens' at line 1 column 1581

根因是 Usage 的三个 token 字段(prompt_tokenscompletion_tokenstotal_tokens)定义为必填 u32,而火山平台的 Responses API 返回的 usage 对象中这些子字段偶发缺失。

影响面

4/5 的 LLM 命令被阻断:

功能 命令 状态
AI 探索(产品采集) llm_collectworkflow.rs .chat() 不可用
普通聊天 llm_chatllm.rs .chat() 不可用
要素值正则化 llm_normalize_valuesnormalizer.rs .chat() 不可用
数据验证 validator.rs .chat() 不可用
连接验证 verify_llm_connectionchat_stream() 幸免(流式)

2. 需求推演概要

需求拆解

  • 核心需求blocking 路径下 Usage 反序列化不因缺失子字段而崩溃
  • 范围边界:只改 serde 反序列化行为,不改字段类型,不改流式路径
  • 质量属性:最小变更(±3 行)、零副作用、测试可验证

关键假设

假设 依据 验证方式
Usage 已 derive Default 代码确认 编译通过
缺失字段默认 0 对所有消费者安全 CostTracker::add 使用 saturating_add;流式路径已有 unwrap_or(0) 行为 代码审查
#[serde(default)] 不影响序列化 serde 明确语义 代码审查(serde 明确语义)
usage: null 不会出现 当前未观察到,留 ponytail: 注释 生产观察
Chat Completions blocking 路径存在同源缺失风险 两路径共用火山平台底层 API 基础设施 在 §5.4 添加 #[serde(default)] 预防性加固(经 PRD 作者推演阶段确认)

3. 当前问题分析

根因

  • src/llm/types/usage.rs:4-12Usage 结构体的 prompt_tokenscompletion_tokenstotal_tokens 定义为 必填 u32,非 Option<u32>
  • src/llm/provider/openai_response.rs:207OpenaiResponseBody 内嵌 usage: Usage,也是必填
  • src/llm/provider/openai.rs:245OpenaiChatResponse 内嵌 usage: Usage,同样必填
  • 火山平台 Responses API 返回的 usage 对象中,子字段缺失 → serde 反序列化 missing field 错误
  • 流式路径不受影响:其 PartialUsage 所有字段均为 Option<u32>,缺失时通过 unwrap_or(0) 兜底

已知线索

  • dc-management 的 verify_llm_connection 已预见到此问题,注释特意说明使用流式规避
  • 这是已知的设计约束——流式已容错但 blocking 路径未同步加固

4. 架构决策记录

决策 选项 选择 理由
容错机制 #[serde(default)] vs 改为 Option<u32> vs 自定义 Deserialize #[serde(default)] 最小变更,不改类型语义
覆盖范围 仅 Responses API vs 同时覆盖 Chat Completions 同时覆盖 增量成本≈0,防患于未然(经 PRD 作者推演阶段确认)
测试范围 2 个场景 vs 4 个场景 4 个场景 必要的边界覆盖(缺失 key / 缺失字段 / 空对象 / 完整回归)

5. 设计方案

5.1 变更概览

3 行 serde attribute + 4 个测试用例,零逻辑变更。

5.2 变更一:Usage 结构体加 struct-level #[serde(default)]

文件src/llm/types/usage.rs:3

- #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
+ #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
+ #[serde(default)]
  pub struct Usage {
      pub prompt_tokens: u32,
      pub completion_tokens: u32,
      pub total_tokens: u32,
      #[serde(skip_serializing_if = "Option::is_none")]
      pub completion_tokens_details: Option<CompletionTokensDetails>,
      #[serde(skip_serializing_if = "Option::is_none")]
      pub prompt_tokens_details: Option<PromptTokensDetails>,
  }

作用JSON 中缺失 Usage任何 子字段时,自动使用 Default::default() 取值:

  • 三个 token 字段(u32):缺失时默认 0
  • 两个 Option 详情字段:缺失时默认 None

为什么 struct-level 够用struct-level 对子字段统一生效,不需要为每个字段单独标注。

5.3 变更二:OpenaiResponseBody.usage#[serde(default)]

文件src/llm/provider/openai_response.rs:207

  pub(crate) struct OpenaiResponseBody {
      pub id: String,
      pub model: String,
      pub output: Vec<ResponseOutputItem>,
+     #[serde(default)]
      pub usage: Usage,
      pub status: String,
  }

作用:整个 usage 键在 JSON 中完全缺失时,自动默认 Usage::default()

5.4 变更三:OpenaiChatResponse.usage#[serde(default)]

文件src/llm/provider/openai.rs:245

  pub(crate) struct OpenaiChatResponse {
      pub id: String,
      pub object: String,
      pub created: u64,
      pub model: String,
      pub choices: Vec<Choice>,
+     #[serde(default)]
      pub usage: crate::llm::types::usage::Usage,
      #[serde(skip_serializing_if = "Option::is_none")]
      pub system_fingerprint: Option<String>,
      #[serde(skip_serializing_if = "Option::is_none")]
      pub service_tier: Option<ServiceTier>,
  }

5.5 自动受益路径(无需变更)

路径 字段 为何已安全
OpenaiChatChunk.usage Option<Usage> Option 天然兜底 NoneSome({不全})Usage struct-level #[serde(default)] 兜住。注意:此路径仅用于流式反序列化,API 流式 last chunk 的 usage 通常完整,受益场景概率极低
MessageResponse.usage UsageIR 层) IR 层不直接反序列化 JSON,只从 provider 传递已解析的值

5.6 未覆盖的已知边界

// ponytail: #[serde(default)] handles missing key; null usage not observed from API,
// but if it appears, add deserialize_with to map null → Usage::default()

"usage": null 会导致 serde 尝试将 null 反序列化为 Usage 结构体,当前方案无法兜底。当前未观察到该行为,暂不处理。

total_tokens 缺失时的语义不一致

当 API 返回 {"prompt_tokens": 10, "completion_tokens": 5} 但缺失 total_tokens 时,struct-level #[serde(default)] 使 total_tokens = 0,而非 10 + 5 = 15CostTracker::add 使用 saturating_add 不会崩溃,但聚合统计中 total_tokens 可能不等于 prompt_tokens + completion_tokens 之和。此行为与流式 PartialUsage::into_usage()unwrap_or(0) 一致,属于已知的简化取舍。

Responses API 流式路径 usage 恒为零(pre-existing

ResponseSseMetaopenai_response.rs:335-340)不包含 usage 字段,导致流式 response.completed 事件的 token 用量信息未被捕获,MessageComplete.full_response.usage 恒为零。这是一个独立于本次变更的 pre-existing 缺陷。阻塞路径加 #[serde(default)] 后,两条路径行为一致(皆为零值),不引入新差异。此问题建议作为后续独立跟踪项处理。

5.7 效应链路

Usage struct-level #[serde(default)]
  ├─ OpenaiResponseBody.usage: Usage         ← 子字段缺失兜住
  │    └─ field-level #[serde(default)]       ← 整个 key 缺失兜住
  ├─ OpenaiChatResponse.usage: Usage          ← 子字段缺失兜住(自动受益)
  │    └─ field-level #[serde(default)]       ← 整个 key 缺失兜住(新增)
  └─ OpenaiChatChunk.usage: Option<Usage>     ← 子字段缺失兜住(自动受益,无需改动)
       └─ Option 已有 skip_serializing_if     ← None 时跳过

6. 实施步骤

步骤 文件 操作 验证
1 src/llm/types/usage.rs:3 #[serde(default)] cargo build
2 src/llm/provider/openai_response.rs:207 #[serde(default)] cargo build
3 src/llm/provider/openai.rs:245 #[serde(default)] cargo build
4 openai_response.rs tests 模块 加 4 个测试用例 cargo test
5 全量检查 cargo test && cargo clippy 无失败/新增警告

预计时长30 分钟(含测试编写与验证)。

7. 验证标准

  • Usage 结构体三个 token 字段在 JSON 缺失时不报错,默认值为 0
  • OpenaiResponseBody.usage 键在 JSON 中完全缺失时不报错,默认 Usage::default()
  • OpenaiChatResponse.usage 键在 JSON 中完全缺失时不报错,默认 Usage::default()
  • 序列化行为不受影响(输出 JSON 仍包含全部 token 字段)
  • cargo test 全部通过
  • cargo clippy 无新增警告
  • dc-management 项目 llm_collect(AI 探索)命令正常返回结果(agcore 发布后由 dc-management 侧执行)
  • dc-management 项目 llm_chat(普通聊天)命令正常响应(agcore 发布后由 dc-management 侧执行)
  • 序列化后反序列化 roundtrip 验证字段值不变
  • CostTrackersession.usage() 的聚合逻辑不依赖 total_tokens == prompt_tokens + completion_tokens 恒等式

测试清单

测试名 场景 输入 断言
deserialize_missing_usage_fields usage 存在但缺子字段 "usage": {"prompt_tokens": 5} 缺省字段默认 0
deserialize_missing_usage_key 整个 usage key 缺失 usage 字段 Usage::default()
deserialize_empty_usage_object usage: {} 空对象 全字段默认 0/None
deserialize_full_usage_with_details 完整 usage(含 details 含所有字段 正确解析,类型不变
deserialize_usage_missing_prompt_tokens 精确复现报错场景:usage 存在但缺 prompt_tokens "usage": {"completion_tokens": 5, "total_tokens": 5} prompt_tokens 默认 0
deserialize_response_body_missing_usage_key OpenaiResponseBody 上下文中 usage key 完全缺失 完整 OpenaiResponseBody JSON 无 usage usage == Usage::default()
deserialize_chat_response_missing_usage_key OpenaiChatResponse 上下文中 usage key 完全缺失 完整 OpenaiChatResponse JSON 无 usage usage == Usage::default()
deserialize_chat_response_missing_usage_fields OpenaiChatResponse 的 usage 存在但缺子字段 完整 OpenaiChatResponse JSONusage 仅含 prompt_tokens 缺省字段默认 0
serialize_deserialize_roundtrip 序列化后反序列化,验证字段值不变 完整 Usage 结构体 roundtrip 后字段值一致

8. 回滚方案

逐个 revert 三个文件中的 #[serde(default)] 行,删除对应的测试用例。回滚后功能恢复原状(无数据迁移、无配置变更)。

9. 非目标

  • 不修改 Usage 字段类型(保持 u32,不改为 Option<u32>
  • 不修改流式路径(PartialUsage / ResponseSseMeta 的 usage 捕获问题单独处理)
  • 不修改其他 providerAnthropic / Ollama,它们有自己的反序列化逻辑)
  • 不处理 "usage": null 边界(未观察到,留 ponytail: 注释)

10. 风险评估

风险 影响 可能性 应对方向
"usage": null 反序列化失败 崩溃 未观察到;留 ponytail 注释
零值 Usage 掩盖 API 异常 计费数据不全 CostTracker::add 可加 warn! 日志
prompt + completion ≠ total 聚合语义不一致 PartialUsage 已有同样行为,接受
下游依赖升级后反序列化行为变化 极低 类型不变、字段名不变

11. 术语表

术语 定义 说明
blocking 路径 LlmProvider::chat() 非流式调用 单次 HTTP 请求,完整 JSON 响应后一次性解析
streaming 路径 LlmProvider::chat_stream() 流式调用 通过 SSE 逐事件推送,usage 可选
#[serde(default)] serde 属性宏 反序列化时缺失字段使用类型的 Default 实现填充

12. 历史版本

版本 日期 变更说明
v1 2026-07-27 初始版本,基于 PRD 1 推演