b7d0a7335f
为 Usage 结构体及其内嵌字段添加 `#[serde(default)]` 反序列化容错, 使 blocking 调用在火山平台返回不完整 usage 时不再崩溃。 - 新增 PDD:分析问题根因、设计变更方案、验证标准与回滚方案 - 新增 PRD:梳理需求范围、边界假设与验收标准
182 lines
8.7 KiB
Markdown
182 lines
8.7 KiB
Markdown
# PRD:Usage 字段反序列化容错
|
||
|
||
**状态**: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())
|