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

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

182 lines
8.7 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.
# PRDUsage 字段反序列化容错
**状态**Draft
**作者**proposal
**日期**2026-07-27
## 1. 核心目标
`agcore::Usage` 结构体的必填 token 字段添加 `#[serde(default)]` 反序列化容错,使 OpenAI Response API 的 blocking(非流式)调用在火山平台返回不完整的 `usage` 字段时不再崩溃。
## 2. 目标用户与场景
| 用户角色 | 使用场景 | 核心诉求 |
|---------|---------|---------|
| dc-management 使用者 | AI 探索(产品信息采集) | 点击「AI 探索」后能正常返回结果,不因 usage 缺失而报错 |
| dc-management 使用者 | LLM 聊天 | 普通聊天功能正常响应 |
| dc-management 使用者 | 要素值正则化(Phase 2) | 数据归一化流程不因 usage 解析失败而中断 |
| agcore 下游 crate | 任何使用 `agcore::llm` 且后端可能不返回完整 usage 的项目 | 反序列化鲁棒性提升 |
## 3. 问题描述
### 3.1 报错信息
dc-management 项目调用火山引擎 Responses API 时,非流式路径报错:
```
OpenAI Response 响应解析失败 error=missing field 'prompt_tokens' at line 1 column 1581
```
### 3.2 影响面
经排查,以下功能被阻断(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()` | ✅ 幸免(流式) |
### 3.3 根因
- **`llm/types/usage.rs:4-12`**`Usage` 结构体的 `prompt_tokens``completion_tokens``total_tokens` 定义为 **必填 `u32`**,非 `Option<u32>`
- **`llm/provider/openai_response.rs:207`**`OpenaiResponseBody` 内嵌 `usage: Usage`,也是必填
- 火山平台的 Responses API 返回的 `usage` 对象中,三个必填 token 子字段**缺失**(`usage` 键存在但子字段不完整),触发了 serde 反序列化的 `missing field` 错误
- 流式路径不受影响:其 `PartialUsage` 所有字段均为 `Option<u32>`,缺失时通过 `unwrap_or(0)` 兜底
### 3.4 已知线索
- dc-management 的 `verify_llm_connection`(连接验证函数)**已预见到此问题**,注释明确说明特意使用流式来规避(`llm.rs:368-371`
- 这是已知的设计约束——流式已容错但 blocking 路径未同步加固
## 4. 功能清单
### v1 必做
- **`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 字段:缺失时默认 `0`
- 两个 `Option` 详情字段(`completion_tokens_details` / `prompt_tokens_details`):缺失时默认 `None`
- **类型全部保持不动**,不改为 `Option`,不丢失语义
- 等效代码行数:1 行
- **`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()`
- 等效代码行数:1 行
**变更合计:2 行 serde 属性宏,零逻辑变更,零类型变更。**
### v1 可选
- 无(上述两项即可完整修复)
### v2 考虑
- 无——struct-level `#[serde(default)]` 已覆盖所有子字段,`completion_tokens_details` / `prompt_tokens_details` 的 `#[serde(default)]` 需求已在 v1 中一并解决
- 流式路径的 `ResponseSseMeta` 不包含 `usage` 字段(`src/llm/provider/openai_response.rs:335-340`),`response.completed` 事件的 usage 信息未被捕获。这是一个独立的缺陷,与当前问题无关,建议单独处理
### 非目标
- 不修改 `Usage` 字段类型(保持 `u32`,不改为 `Option<u32>`
- 不修改流式路径(`PartialUsage` 的 usage 捕获问题单独处理)
- 不修改其他 provider`OpenaiChat` / `Anthropic`),它们有自己的反序列化逻辑
## 5. 边界与假设
| 边界 / 假设 | 来源 | 说明 |
|-------------|------|------|
| `Usage` 已 derive `Default` | 代码确认 | `#[serde(default)]` 直接使用 `Default` 实现(全字段 `0` |
| 缺失字段默认 0 对所有消费者安全 | 代码审查 | 所有消费方(`CostTracker::add`、`MessageResponse` 使用处)都在做 `saturating_add``0` 是安全值。流式路径的 `PartialUsage.into_usage()` 已经 `unwrap_or(0)`,行为一致 |
| 序列化行为不受影响 | serde 语义 | `#[serde(default)]` 仅在反序列化缺失字段时生效,不影响序列化输出 |
| 报错提示的 `missing field` 确认是子字段缺失而非整个 usage 缺失 | 错误消息分析 | 错误消息 `missing field 'prompt_tokens'` 说明 JSON 路径 `usage.prompt_tokens` 不存在即键 `usage` 存在但子字段缺失,因此 `#[serde(default)]` 加在子字段上是必要条件 |
## 6. 术语表
| 术语 | 定义 | 说明 |
|------|------|------|
| blocking 路径 | `LlmProvider::chat()` 非流式调用 | 发送单个 HTTP 请求,等待完整 JSON 响应后一次性解析 |
| streaming 路径 | `LlmProvider::chat_stream()` 流式调用 | 通过 SSE 逐事件推送,usage 可选 |
| `#[serde(default)]` | serde 属性宏 | 反序列化时如果字段缺失,使用类型的 `Default` 实现填充 |
| Responses API | OpenAI 标准 `POST /responses` 协议 | 区别于 Chat Completions`POST /chat/completions` |
## 7. 验收标准
- [ ] `Usage` 结构体三个 token 字段在 JSON 缺失时不报错,默认值为 `0`
- [ ] `OpenaiResponseBody.usage` 键在 JSON 中完全缺失时也不报错,默认值为 `Usage::default()`
- [ ] dc-management 的 `llm_collect`AI 探索)命令正常返回结果
- [ ] dc-management 的 `llm_chat`(普通聊天)命令正常响应
- [ ] 已有序列化行为不受影响(输出 JSON 仍包含全部 token 字段)
- [ ] `cargo test` 全部通过
- [ ] `cargo clippy` 无新增警告
- [ ] 补两条反序列化测试:
- `response_api_missing_usage_fields` — mock 响应中 `usage` 对象缺失 `prompt_tokens` / `completion_tokens` / `total_tokens`,验证不报错且 usage 字段全为 0
- `response_api_missing_usage_key` — mock 响应中完全没有 `usage` 键,验证不报错且默认 `Usage::default()`
## 8. 风险评估
| 风险 | 影响 | 可能性 | 应对方向 |
|------|------|--------|---------|
| 下游 crate 升级 agcore 后依赖 usage 字段不缺失 | 无——`#[serde(default)]` 不改变已有行为 | 极低 | 类型不变、字段名不变 |
| 序列化时 `#[serde(default)]` 影响输出 | 无——`#[serde(default)]` 只影响反序列化 | 极低 | serde 明确语义 |
| 测试覆盖不足 | 当前 mock 响应均携带完整 usage,不会触发新路径 | 中 | 建议加一条 usage 缺失的测试用例 |
## 9. 发布计划(可选)
| 阶段 | 范围 | 时间 |
|------|------|------|
| v1 | 两处变更(usage.rs 3 字段 + openai_response.rs 1 字段) | 即日 |
| 发布 | 打 tag(如 `v0.3.6` 或 `v0.3.5-usage-fix`),更新 dc-management 引用 | 即日 |
## 10. 历史版本
| 版本 | 日期 | 变更说明 |
|------|------|---------|
| v1 | 2026-07-27 | 人工种子(原始) |
### 种子内容
发起方:dc-management 项目 `src/routes/products/ai-explore/` 页面「AI 探索」功能报错
报错信息:
```
OpenAI Response 响应解析失败 error=missing field 'prompt_tokens' at line 1 column 1581
```
根因:
- `src/llm/types/usage.rs` 中 `Usage` 结构的 `prompt_tokens`, `completion_tokens`, `total_tokens` 为必填 `u32`
- `src/llm/provider/openai_response.rs` 的 `OpenaiResponseBody` 内嵌 `usage: Usage`
- 火山平台 Responses API 返回的 usage 子字段缺失 → serde 反序列化失败
修复方向:
- `Usage` 三字段加 `#[serde(default)]`(缺失时默认 0
- `OpenaiResponseBody.usage` 加 `#[serde(default)]`(整个缺失时默认 Usage::default()