# 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` - `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`,缺失时通过 `unwrap_or(0)` 兜底 ### 已知线索 - dc-management 的 `verify_llm_connection` 已预见到此问题,注释特意说明使用流式规避 - 这是已知的设计约束——流式已容错但 blocking 路径未同步加固 ## 4. 架构决策记录 | 决策 | 选项 | 选择 | 理由 | |------|------|------|------| | 容错机制 | `#[serde(default)]` vs 改为 `Option` 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` ```diff - #[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, #[serde(skip_serializing_if = "Option::is_none")] pub prompt_tokens_details: Option, } ``` **作用**: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` ```diff pub(crate) struct OpenaiResponseBody { pub id: String, pub model: String, pub output: Vec, + #[serde(default)] pub usage: Usage, pub status: String, } ``` **作用**:整个 `usage` 键在 JSON 中完全缺失时,自动默认 `Usage::default()`。 ### 5.4 变更三:`OpenaiChatResponse.usage` 加 `#[serde(default)]` **文件**:`src/llm/provider/openai.rs:245` ```diff pub(crate) struct OpenaiChatResponse { pub id: String, pub object: String, pub created: u64, pub model: String, pub choices: Vec, + #[serde(default)] pub usage: crate::llm::types::usage::Usage, #[serde(skip_serializing_if = "Option::is_none")] pub system_fingerprint: Option, #[serde(skip_serializing_if = "Option::is_none")] pub service_tier: Option, } ``` ### 5.5 自动受益路径(无需变更) | 路径 | 字段 | 为何已安全 | |------|------|-----------| | `OpenaiChatChunk.usage` | `Option` | `Option` 天然兜底 `None`;`Some({不全})` 被 `Usage` struct-level `#[serde(default)]` 兜住。注意:此路径仅用于流式反序列化,API 流式 last chunk 的 usage 通常完整,受益场景概率极低 | | `MessageResponse.usage` | `Usage`(IR 层) | IR 层不直接反序列化 JSON,只从 provider 传递已解析的值 | ### 5.6 未覆盖的已知边界 ```rust // 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 ← 子字段缺失兜住(自动受益,无需改动) └─ 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 验证字段值不变 - [ ] `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`) - 不修改流式路径(`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 推演 |