# 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` - **`llm/provider/openai_response.rs:207`**:`OpenaiResponseBody` 内嵌 `usage: Usage`,也是必填 - 火山平台的 Responses API 返回的 `usage` 对象中,三个必填 token 子字段**缺失**(`usage` 键存在但子字段不完整),触发了 serde 反序列化的 `missing field` 错误 - 流式路径不受影响:其 `PartialUsage` 所有字段均为 `Option`,缺失时通过 `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, #[serde(skip_serializing_if = "Option::is_none")] pub prompt_tokens_details: Option, } ``` - 作用: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, + #[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`) - 不修改流式路径(`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())