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

250 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`
```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<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`
```diff
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`
```diff
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 未覆盖的已知边界
```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<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 验证字段值不变
- [ ] `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 推演 |