为 Usage 结构体及其内嵌字段添加 `#[serde(default)]` 反序列化容错, 使 blocking 调用在火山平台返回不完整 usage 时不再崩溃。 - 新增 PDD:分析问题根因、设计变更方案、验证标准与回滚方案 - 新增 PRD:梳理需求范围、边界假设与验收标准
12 KiB
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_tokens、completion_tokens、total_tokens)定义为必填 u32,而火山平台的 Responses API 返回的 usage 对象中这些子字段偶发缺失。
影响面
4/5 的 LLM 命令被阻断:
| 功能 | 命令 | 状态 |
|---|---|---|
| AI 探索(产品采集) | llm_collect → workflow.rs .chat() |
❌ 不可用 |
| 普通聊天 | llm_chat → llm.rs .chat() |
❌ 不可用 |
| 要素值正则化 | llm_normalize_values → normalizer.rs .chat() |
❌ 不可用 |
| 数据验证 | validator.rs .chat() |
❌ 不可用 |
| 连接验证 | verify_llm_connection → chat_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-12:Usage结构体的prompt_tokens、completion_tokens、total_tokens定义为 必填u32,非Option<u32>src/llm/provider/openai_response.rs:207:OpenaiResponseBody内嵌usage: Usage,也是必填src/llm/provider/openai.rs:245:OpenaiChatResponse内嵌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 天然兜底 None;Some({不全}) 被 Usage struct-level #[serde(default)] 兜住。注意:此路径仅用于流式反序列化,API 流式 last chunk 的 usage 通常完整,受益场景概率极低 |
MessageResponse.usage |
Usage(IR 层) |
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 = 15。CostTracker::add 使用 saturating_add 不会崩溃,但聚合统计中 total_tokens 可能不等于 prompt_tokens + completion_tokens 之和。此行为与流式 PartialUsage::into_usage() 的 unwrap_or(0) 一致,属于已知的简化取舍。
Responses API 流式路径 usage 恒为零(pre-existing)
ResponseSseMeta(openai_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 缺失时不报错,默认值为0OpenaiResponseBody.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 验证字段值不变
CostTracker和session.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 JSON,usage 仅含 prompt_tokens |
缺省字段默认 0 |
serialize_deserialize_roundtrip |
序列化后反序列化,验证字段值不变 | 完整 Usage 结构体 |
roundtrip 后字段值一致 |
8. 回滚方案
逐个 revert 三个文件中的 #[serde(default)] 行,删除对应的测试用例。回滚后功能恢复原状(无数据迁移、无配置变更)。
9. 非目标
- 不修改
Usage字段类型(保持u32,不改为Option<u32>) - 不修改流式路径(
PartialUsage/ResponseSseMeta的 usage 捕获问题单独处理) - 不修改其他 provider(
Anthropic/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 推演 |