b7d0a7335f
为 Usage 结构体及其内嵌字段添加 `#[serde(default)]` 反序列化容错, 使 blocking 调用在火山平台返回不完整 usage 时不再崩溃。 - 新增 PDD:分析问题根因、设计变更方案、验证标准与回滚方案 - 新增 PRD:梳理需求范围、边界假设与验收标准
8.7 KiB
8.7 KiB
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):- #[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 行
- 作用:JSON 中缺失
-
OpenaiResponseBody.usage加#[serde(default)](src/llm/provider/openai_response.rs:207):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 缺失时不报错,默认值为0OpenaiResponseBody.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 字段全为 0response_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为必填u32src/llm/provider/openai_response.rs的OpenaiResponseBody内嵌usage: Usage- 火山平台 Responses API 返回的 usage 子字段缺失 → serde 反序列化失败
修复方向:
Usage三字段加#[serde(default)](缺失时默认 0)OpenaiResponseBody.usage加#[serde(default)](整个缺失时默认 Usage::default())