Compare commits
4
Commits
c5afa4b31e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb7d23de3d | ||
|
|
7a215272a9 | ||
|
|
25238fc357 | ||
|
|
b7d0a7335f |
@@ -0,0 +1,5 @@
|
|||||||
|
# CodeGraph data files — local to each machine, not for committing.
|
||||||
|
# Ignore everything in .codegraph/ except this file itself, so transient
|
||||||
|
# files (the database, daemon.pid, sockets, logs) never show up in git.
|
||||||
|
*
|
||||||
|
!.gitignore
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "agcore"
|
name = "agcore"
|
||||||
version = "0.3.5"
|
version = "0.3.7"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
# 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 推演 |
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
# 模板编译器 UTF-8 编码修复方案
|
||||||
|
|
||||||
|
- 状态:Approved(第 1 轮审查通过,已按审查结论修正)
|
||||||
|
- 作者:think
|
||||||
|
- 日期:2026-08-03
|
||||||
|
- 关联 PRD:`design/prd/2-模板编译器UTF-8编码修复需求.md`
|
||||||
|
|
||||||
|
## 1. 背景与目标
|
||||||
|
|
||||||
|
agcore 的 `src/prompt/template.rs` 模板编译器有 6 处 `bytes[i] as char`(将 UTF-8 字节逐字节强转为 Unicode 码点,等价 Latin-1 解码),导致含中文等多字节字符的模板经 `compile → render` 后产生 mojibake 乱码,且乱码会原样发送给 LLM。下游 dc-management 的 system 提示词(采集策略 validator / collector)已全部乱码,属静默失败——模型在容忍乱码的情况下继续工作,但提示词中的规则约束已被破坏。
|
||||||
|
|
||||||
|
目标(对齐 PRD §1、§4 v1):
|
||||||
|
|
||||||
|
- 统一修复 6 处逐字节强转,非 ASCII 文本逐字符正确保留
|
||||||
|
- 补充回归测试(模板编译器首次补测试)
|
||||||
|
- 版本 0.3.6 → 0.3.7,patch 发布
|
||||||
|
- 公开 API 不变、ASCII 模板行为零变化
|
||||||
|
|
||||||
|
## 2. 需求推演概要
|
||||||
|
|
||||||
|
### 2.1 需求拆解
|
||||||
|
|
||||||
|
v1 必做三项:6 处修复(含 parse_tag 内标签内容)、回归测试、版本号更新。v2 无。
|
||||||
|
|
||||||
|
非目标:不重构语法、不加新功能、不改公开 API、不修改其他模块(全库已扫描确认无同类风险点)、不涉及下游回切决策。裸闭合标签(顶层出现 `{{/xxx}}`)的静默截断行为(template.rs L248-249 既有 `tag.starts_with("/") => break`)不在本次修复范围,后续单独评估。
|
||||||
|
|
||||||
|
### 2.2 边界识别
|
||||||
|
|
||||||
|
- 模板语法标签(`{{`、`}}`、`#if`、`#each`、`#raw`)均为 ASCII,字节比较判断语法安全,保持不变
|
||||||
|
- 非 ASCII 文本必须逐字符保留(修复目标是编译输出与模板原文一致)
|
||||||
|
- ASCII 模板行为零变化是硬约束(向后兼容,需回归测试保障)
|
||||||
|
|
||||||
|
### 2.3 关键假设
|
||||||
|
|
||||||
|
1. **UTF-8 结构性保证**:多字节字符的 continuation bytes 恒在 0x80–0xBF,首字节 ≥ 0xC0,而 `{`=0x7B、`}`=0x7D、`#`=0x23——任何多字节字符的任何字节都不可能等于语法字符,故 `bytes[i]` 字节比较永远不会在多字节字符内部误命中
|
||||||
|
2. **字符边界不变量**:循环中 i 起始为 0(边界);`{{`/`}}` 检测命中后 `end = i + 2`(`{`/`}` 各 1 字节,保持边界);字符推进按 `len_utf8()`(保持边界)。因此 `template[i..]` 切片不会 panic
|
||||||
|
3. 6 处 `as char` 是全库唯一编码风险点(grep `as char|char::from` 确认仅 template.rs 6 处)
|
||||||
|
4. 模板文件经 `include_str!` 加载有编译期 UTF-8 校验,编码问题不可能存在;日志/发送链路无转码——问题仅存在于模板编译器的内存字符串处理
|
||||||
|
|
||||||
|
## 3. 当前问题分析
|
||||||
|
|
||||||
|
### 3.1 根因
|
||||||
|
|
||||||
|
6 处 `bytes[i] as char` 明细:
|
||||||
|
|
||||||
|
| 行号 | 函数 | 破坏内容 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 255 | `compile_fragments` literal 分支 | 模板纯文本(主要破坏点) |
|
||||||
|
| 275 | `parse_tag` | `{{ 标签 }}` 内部内容(当前 ASCII 变量名未触发,中文变量名同样损坏) |
|
||||||
|
| 324 | `parse_block` else_body | `#if` else 分支块体 |
|
||||||
|
| 326 | `parse_block` body | `#if` 分支块体 |
|
||||||
|
| 364 | `parse_each_block` body | `#each` 块体 |
|
||||||
|
| 389 | `parse_raw_block` content | `#raw` 块内容 |
|
||||||
|
|
||||||
|
中文每字 3 字节(emoji 4 字节)被拆成多个 Latin-1 字符(0xE4→ä、0xBD→½),产生 mojibake。
|
||||||
|
|
||||||
|
### 3.2 关键代码结构观察
|
||||||
|
|
||||||
|
- `parse_block` / `parse_each_block` / `parse_raw_block` 遇到 `{{` 标签时用 `template[i..end]` 原样字符串切片推回(该路径天然保留 UTF-8),只有逐字节累积路径被破坏
|
||||||
|
- `parse_tag` 当前签名 `fn parse_tag(bytes: &[u8], start: usize)` 只收字节切片,无法按字符边界推进,需要改签名
|
||||||
|
- 4 个调用点:223(compile_fragments)/ 296(parse_block)/ 344(parse_each_block)/ 380(parse_raw_block),且宿主函数均已持有 `template: &str` 参数,调用点改换为纯机械替换
|
||||||
|
- `bytes` 局部变量在各函数中仍被 `{{` 判断使用,不可删除
|
||||||
|
|
||||||
|
## 4. 架构决策记录
|
||||||
|
|
||||||
|
### ADR-1:采用「字节索引 + 字符边界推进」(方案 A)
|
||||||
|
|
||||||
|
| 方案 | 描述 | 结论 |
|
||||||
|
|------|------|------|
|
||||||
|
| A. 字节索引 + 字符推进(采纳) | 保持 `bytes[i]` ASCII 判断,字符累积改 `template[i..].chars().next()` + `len_utf8()` 推进 | ✅ 改动最小,标签检测、`template[i..end]` 切片、递归编译逻辑零变动,正确性有结构性论证 |
|
||||||
|
| B. 整体重构 chars 迭代器(否决) | 编译器是「索引 + 原样子串回填」混合模型,迭代器消费性导致 4 个函数的位置换算全部重写、嵌套 depth 管理重做 | ❌ 回归风险远高于收益 |
|
||||||
|
| C. 手写 UTF-8 长度表(否决) | 避免 chars() 解码 | ❌ 引入手写 0xC0/0xE0/0xF0 分支,标准库更可靠,性能差异可忽略 |
|
||||||
|
|
||||||
|
### ADR-2:`parse_tag` 签名 `&[u8]` → `&str`
|
||||||
|
|
||||||
|
- 理由:类型系统强制 UTF-8 保证,未来维护者想再写 `bytes[i] as char` 必须显式 `as_bytes()`,从类型层面降低复发概率;改动成本几乎为零(1 处签名 + 4 处调用点机械替换)
|
||||||
|
- 否决替代:内部 `from_utf8(bytes)` 转换以保持 `&[u8]` 签名——引入不可能触发的错误分支和 O(n) 校验,语义绕
|
||||||
|
|
||||||
|
### ADR-3:加 `debug_assert!(template.is_char_boundary(i))`
|
||||||
|
|
||||||
|
- 零成本(仅 debug 构建生效)故障信号,防未来索引推进逻辑回归
|
||||||
|
|
||||||
|
## 5. 设计方案
|
||||||
|
|
||||||
|
### 5.1 修复模式(6 处统一)
|
||||||
|
|
||||||
|
保持 `bytes[i]` 对 ASCII 语法字符判断不变;字符累积分支统一改为:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
} else {
|
||||||
|
debug_assert!(template.is_char_boundary(i));
|
||||||
|
match template[i..].chars().next() {
|
||||||
|
Some(ch) => {
|
||||||
|
// 推入对应 String(literal / content / body / else_body)
|
||||||
|
i += ch.len_utf8();
|
||||||
|
}
|
||||||
|
None => return Err(PromptError::Parse("模板包含非法字符序列".to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
要点:
|
||||||
|
|
||||||
|
- **`None` 分支语义统一为显式失败**:6 处全部返回 `Err(PromptError::Parse("模板包含非法字符序列"))`。特别地,`compile_fragments` 的 `None` 分支**绝不能**写 `break`——顶层循环 break 后会以 `Ok(fragments)` 返回,静默丢弃模板剩余部分;其余 5 处 break 虽会落到函数末尾的 `Err`(非静默),但错误消息为「未闭合」不准确。统一显式 `Err` 使防御失效时可观测,且消息一致准确
|
||||||
|
- **None 分支为纯防御,结构性保证下不可达**:`while i < len` + 字符边界不变量保证 `template[i..]` 非空;且 `template[i..]` 在非字符边界处切片会先 panic(str 切片要求字符边界),`chars().next()` 返回 None 实际不会发生。该分支的价值在于:未来维护者若将索引逻辑改写为宽容 API(如 `get(i..)`),防御分支仍能保证显式失败而非静默错误
|
||||||
|
- 每字符常数开销约 2–3 倍于原逐字节路径,但模板编译是低频一次性操作(register 时编译 / register_lazy 首次 render),渲染热路径走 Fragment AST 与此无关,无需优化;纯 ASCII 快速路径属于过度设计,明确不做
|
||||||
|
|
||||||
|
### 5.2 `parse_tag` 签名变更
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn parse_tag(template: &str, start: usize) -> Result<(String, usize), PromptError> {
|
||||||
|
let bytes = template.as_bytes(); // {{ 判断仍用字节比较
|
||||||
|
// ...内容累积同样按 5.1 模式按字符边界推进
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 4 个调用点(223 / 296 / 344 / 380 行):`parse_tag(bytes, i)` → `parse_tag(template, i)`
|
||||||
|
- 改动后 grep `parse_tag(` 复核 4 处调用点无遗漏
|
||||||
|
|
||||||
|
### 5.3 测试设计(`#[cfg(test)] mod tests` 新建于 template.rs)
|
||||||
|
|
||||||
|
测试清单(对应 PRD §4 清单 + 评审增量,共 16 个用例):
|
||||||
|
|
||||||
|
| # | 测试 | 覆盖点 |
|
||||||
|
|---|------|--------|
|
||||||
|
| 1 | 纯中文模板 compile+render 与原文逐字符一致 | 主破坏路径 literal 恒等 |
|
||||||
|
| 2 | 真实故障素材:PRD §3.1 原文「你是采集策略专家,负责审查已采集的产品编码结果,决策下一轮搜索方向。」及含 ★、→、中文引号「」、全角标点的样本 | 故障现场固化回归(增量 B) |
|
||||||
|
| 3 | 中文 + `{{ var }}` 变量插值混合 | 混合渲染 |
|
||||||
|
| 4 | 中文位于 `#if` body | 块内中文 |
|
||||||
|
| 5 | 中文位于 `#if` else 分支 | 块内中文 |
|
||||||
|
| 6 | 中文位于 `#each` body | 块内中文(循环变量固定 `{{item}}`,Array 用 `TemplateContext::from_json(&json!(...))` 构造) |
|
||||||
|
| 7 | 中文位于 `#raw` 内容 | 块内中文 |
|
||||||
|
| 8 | emoji 等 4 字节字符 | 4 字节字符保留 |
|
||||||
|
| 9 | 多字节字符紧邻 `{{` / `}}` 边界 | 边界解析 |
|
||||||
|
| 10 | 模板以中文结尾(EOF 边界) | 尾部边界 |
|
||||||
|
| 11 | 空模板 | 边界 |
|
||||||
|
| 12 | 中文 + 未闭合 `{{` → 返回 Err 且不 panic | 错误路径(增量 A),断言 `matches!(err, PromptError::Parse(_))`;None 防御分支结构性不可达、不单独设用例,本用例确保索引推进边界改动不引入 panic |
|
||||||
|
| 13 | 纯 ASCII 模板渲染与修复前一致 | 向后兼容回归:渲染结果等于预定义期望输出(PRD §7 要求,勿做快照对比);以 composer.rs 既有 4 个 ASCII 测试为硬基线 |
|
||||||
|
| 14 | 中文变量名 `{{ 问候 }}` | 标签内非 ASCII 内容(增量,PRD 已有) |
|
||||||
|
| 15 | 中文 `#if` 条件 | 标签内非 ASCII 内容(增量,PRD 已有) |
|
||||||
|
| 16 | 嵌套块:外层 `#if` 分支内含 `#each` + 中文文本 | 嵌套块中多字节字符正确保留(审查观察补充) |
|
||||||
|
|
||||||
|
测试写法约定(增量 D):
|
||||||
|
|
||||||
|
- 错误断言必须用 `matches!`(`PromptError` 只 derive 了 `Error, Debug`,无 `PartialEq`,`assert_eq!` 不可用——本模块首个测试文件最易踩的坑)
|
||||||
|
- `#each` 循环变量硬编码为 `item`(渲染器 `child_ctx.vars.insert("item", ...)` 固定)
|
||||||
|
- Array 构造沿用 composer.rs 既有模式:`TemplateContext::from_json(&serde_json::json!({...}))`
|
||||||
|
- 避免对含 `TemplateValue::Object` 的渲染输出做全等断言(HashMap 无序迭代,Display 输出不稳定)
|
||||||
|
- 测试函数签名可用 `-> Result<(), PromptError>` + `?` 或与 composer.rs 一致的 unwrap 风格
|
||||||
|
- 测试 12 的未闭合标签错误断言:`tpl.unwrap_err()` 对 compile 返回值合法可调用(要求 `PromptTemplate: Debug`,Ok 时 panic),但 `PromptError` 无 `PartialEq`,拿 err 后无法 `assert_eq!` 比对变体;建议直接 `assert!(PromptTemplate::compile("中文{{未闭合").is_err())` 或 `matches!(tpl.unwrap_err(), PromptError::Parse(_))`
|
||||||
|
|
||||||
|
### 5.4 版本号
|
||||||
|
|
||||||
|
- `Cargo.toml` version 0.3.6 → 0.3.7
|
||||||
|
- 若仓库提交 Cargo.lock,确认 lock 中 agcore 条目随 `cargo build` 更新并一并提交
|
||||||
|
|
||||||
|
## 6. 实施步骤
|
||||||
|
|
||||||
|
| 步骤 | 操作 | 验证 |
|
||||||
|
|------|------|------|
|
||||||
|
| 1 | 跑基线:`cargo test` | composer.rs 4 个 ASCII 模板测试通过(硬基线) |
|
||||||
|
| 2 | 改 `parse_tag` 签名 + 4 调用点 | `cargo build` 通过 |
|
||||||
|
| 3 | 6 处字符推进修复 + debug_assert | `grep -n "as char\|char::from" src/` 无结果 |
|
||||||
|
| 4 | 新增 `#[cfg(test)] mod tests`(16 个用例) | `cargo test` 全绿 |
|
||||||
|
| 5 | Cargo.toml 0.3.6 → 0.3.7 + Cargo.lock 同步 | 版本号确认 |
|
||||||
|
| 6 | `cargo clippy` | 无新增警告 |
|
||||||
|
| 7 | 步骤 2–6 的改动合并为一次提交(避免「可编译但含缺陷」的中间态单独提交),打 tag v0.3.7(发布动作由维护者执行) | tag 描述含验证指引 |
|
||||||
|
|
||||||
|
## 7. 验证标准
|
||||||
|
|
||||||
|
对齐 PRD §7 验收标准,并补充增量 A:
|
||||||
|
|
||||||
|
- [ ] 中文模板经 compile + render 后与原文逐字符一致(含纯文本、变量插值混合、真实故障素材)
|
||||||
|
- [ ] 中文位于 #if / #each / #raw 块体内时正确保留
|
||||||
|
- [ ] emoji 等 4 字节字符正确保留
|
||||||
|
- [ ] 多字节字符紧邻 {{ / }} 边界时解析正确
|
||||||
|
- [ ] 模板以中文结尾、空模板等边界场景不 panic
|
||||||
|
- [ ] 中文 + 未闭合标签 → 返回 Err 且不 panic(增量 A)
|
||||||
|
- [ ] 纯 ASCII 模板渲染结果与修复前完全一致(composer.rs 4 个测试为基线全部通过,新测试断言等于预定义期望输出)
|
||||||
|
- [ ] 6 处逐字节强转全部消除(grep `as char|char::from` 于 `src/` 无结果)
|
||||||
|
- [ ] cargo test 全部通过(含新增 16 个测试)
|
||||||
|
- [ ] cargo clippy 无新增警告
|
||||||
|
- [ ] Cargo.toml 版本为 0.3.7
|
||||||
|
|
||||||
|
## 8. 发布计划
|
||||||
|
|
||||||
|
| 阶段 | 范围 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| v1 | 6 处修复 + 16 个回归测试 + 版本号 0.3.7 | 本方案范围 |
|
||||||
|
| 发布 | 打 tag v0.3.7,推送 origin | 实际发布动作由维护者执行 |
|
||||||
|
| 发布说明 | `git tag -a v0.3.7` tag 描述(增量 E): | 不更新 CHANGELOG |
|
||||||
|
| 下游升级 | dc-management 升级 agcore 引用至 v0.3.7 | 下游自行评估是否回切模板链路 |
|
||||||
|
|
||||||
|
tag 描述建议内容(增量 E,写入发布说明):
|
||||||
|
|
||||||
|
1. 修复说明:模板编译器 UTF-8 编码修复(6 处逐字节强转改为按字符边界推进),中文模板经 compile+render 后与原文一致
|
||||||
|
2. 最小验证代码(可复制):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use agcore::prompt::{PromptTemplate, TemplateContext};
|
||||||
|
let tpl = PromptTemplate::compile("你是助手:{{msg}}").unwrap();
|
||||||
|
let mut ctx = TemplateContext::new();
|
||||||
|
ctx.insert("msg", "你好");
|
||||||
|
assert_eq!(tpl.render(&ctx).unwrap(), "你是助手:你好");
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 行为变更提示:修复后 LLM 将从「读乱码提示词」变为「读正确提示词」,此前被破坏的规则约束(编码格式、品牌限定等)恢复生效,模型输出可能明显变化——升级后建议跑一轮真实采集对比验证后再正式切换
|
||||||
|
|
||||||
|
## 9. 回滚方案
|
||||||
|
|
||||||
|
- 代码回滚:`git revert` 该修复 commit,回到 0.3.6 行为
|
||||||
|
- 下游回退:dc-management 将 agcore 依赖回退至 0.3.6 即可恢复原行为(无 API 变更,回退无迁移成本)
|
||||||
|
- 注意:回滚即恢复乱码行为,仅作为应急手段;正确路径是验证后继续使用 0.3.7
|
||||||
|
- 版本号冲突:`git revert` 后 Cargo.toml 回到 0.3.6,若应急后需重新发布,将撞上已发布的 v0.3.6/v0.3.7 tag,应升级至 0.3.8
|
||||||
|
|
||||||
|
## 10. 历史版本
|
||||||
|
|
||||||
|
| 版本 | 日期 | 变更说明 |
|
||||||
|
|------|------|---------|
|
||||||
|
| v1 | 2026-08-03 | 首版方案(基于 PRD v1 + 双顾问评审增量) |
|
||||||
|
| v2 | 2026-08-03 | 第 1 轮审查结论修正:None 分支统一显式 Err(消除静默截断)、测试 13 断言措辞对齐 PRD、新增嵌套块测试 16、回滚补版本号冲突说明、grep 范围标注 src/ |
|
||||||
|
| v3 | 2026-08-03 | 第 2 轮复审修正:§6 步骤 4 用例数 15→16 全文统一、unwrap_err 断言表述订正、§7 第 7 条补「预定义期望输出」对齐 PRD、§2.1 非目标补充裸闭合标签静默截断不在范围 |
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
# 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())
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
# PRD:模板编译器 UTF-8 编码修复
|
||||||
|
|
||||||
|
**状态**:Draft
|
||||||
|
**作者**:proposal
|
||||||
|
**日期**:2026-08-03
|
||||||
|
|
||||||
|
## 1. 核心目标
|
||||||
|
|
||||||
|
修复 `agcore::prompt::PromptTemplate` 模板编译器(`src/prompt/template.rs`)对非 ASCII(UTF-8)文本的编码破坏问题:当前 6 处 `bytes[i] as char` 将 UTF-8 字节逐字节强转为 Unicode 码点(等价 Latin-1 解码),导致所有含中文等多字节字符的模板经 `compile → render` 后输出乱码(mojibake),且该乱码会原样发送给 LLM。本次修复使模板编译器对非 ASCII 文本逐字符正确保留,同时保持 ASCII 模板行为完全不变。
|
||||||
|
|
||||||
|
## 2. 目标用户与场景
|
||||||
|
|
||||||
|
| 用户角色 | 使用场景 | 核心诉求 |
|
||||||
|
|---------|---------|---------|
|
||||||
|
| agcore 下游 crate(如 dc-management) | 使用 `PromptTemplate` 编译中文 system prompt 模板(validator / collector 采集策略) | 模板文本经编译渲染后与原文一致,不产生乱码 |
|
||||||
|
| agcore 下游 crate | 使用 `PromptTemplate` 的 `#if` / `#each` / `#raw` 块语法,块体内含中文 | 块内容逐字符正确保留 |
|
||||||
|
| agcore 维护者 | 修复后发布 patch 版本,下游可升级 | 公开 API 不变、ASCII 行为不变、有回归测试保障 |
|
||||||
|
|
||||||
|
## 3. 问题描述
|
||||||
|
|
||||||
|
### 3.1 现象
|
||||||
|
|
||||||
|
dc-management 项目的探索调试日志(`log/explore-20260803-013158915066.txt`)显示:发送给 LLM 的 system 提示词全部乱码:
|
||||||
|
|
||||||
|
```
|
||||||
|
ä½ æ¯éä¾ç¥ç¥ä¸å®¶ï¼è´è´£å®¡æ¥å·²é产åç¼ç ç»æï¼å³çä¸ä¸è½®æç´¢æ¹åã
|
||||||
|
```
|
||||||
|
|
||||||
|
(原文为「你是采集策略专家,负责审查已采集的产品编码结果,决策下一轮搜索方向。」)
|
||||||
|
|
||||||
|
日志中的 user 消息与 LLM 响应均为正常中文,证明:乱码发生在**内存字符串**中(模板编译阶段),实际发送给 LLM 的请求体就是损坏文本;日志文件本身(纯 UTF-8 直写)只是如实记录了已损坏的内容。该缺陷为静默失败——模型在容忍乱码的情况下继续工作,但提示词中的规则约束(编码格式、品牌限定等)已被破坏,直接影响下游采集质量。
|
||||||
|
|
||||||
|
### 3.2 根因
|
||||||
|
|
||||||
|
`src/prompt/template.rs` 共 6 处 `bytes[i] as char`(将 UTF-8 字节逐字节强转为 Unicode 码点,等价 Latin-1 解码):
|
||||||
|
|
||||||
|
| 行号 | 位置 | 破坏内容 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 255 | `compile_fragments` literal 分支 | 模板纯文本(**主要破坏点**) |
|
||||||
|
| 275 | `parse_tag` | `{{ 标签 }}` 内部内容(当前为 ASCII 变量名,未触发;若使用中文变量名同样损坏) |
|
||||||
|
| 324 | `parse_block` else_body | `#if` 分支的 else 块体 |
|
||||||
|
| 326 | `parse_block` body | `#if` 分支块体 |
|
||||||
|
| 364 | `parse_each_block` body | `#each` 块体 |
|
||||||
|
| 389 | `parse_raw_block` content | `#raw` 块内容 |
|
||||||
|
|
||||||
|
中文 UTF-8 编码每字 3 字节(emoji 等 4 字节),被拆成多个 Latin-1 字符(如 `0xE4 → ä`、`0xBD → ½`),产生 mojibake。所有经 `PromptTemplate::compile → render` 链路的非 ASCII 模板文本均被破坏。
|
||||||
|
|
||||||
|
### 3.3 影响面
|
||||||
|
|
||||||
|
- 所有使用 `PromptTemplate` 且模板含非 ASCII 文本的 agcore 下游项目
|
||||||
|
- dc-management 的采集策略(validator)与产品信息采集(collector)两条 system prompt 链路全部受影响
|
||||||
|
- 已确认模板文件本身为合法 UTF-8、日志写出链路无转码、发送链路无转码——问题仅存在于模板编译器
|
||||||
|
|
||||||
|
### 3.4 已知线索
|
||||||
|
|
||||||
|
- 下游使用 `include_str!` 宏加载模板文件(如 dc-management 的 `src-tauri/src/llm/prompts.rs`),该宏具有编译期 UTF-8 校验:非法编码的模板文件在编译期即报错,故模板文件编码问题不可能存在
|
||||||
|
- agcore 仓库 `src/prompt/template.rs` 当前没有任何测试(无 `#[cfg(test)]` 模块),本次为模板编译器首次补测试
|
||||||
|
- 模板语法标签(`{{ }}`、`#if`、`#each`、`#raw`)均为 ASCII,字节比较判断语法是安全的,无需改动
|
||||||
|
|
||||||
|
## 4. 功能清单
|
||||||
|
|
||||||
|
### v1 必做
|
||||||
|
|
||||||
|
- **统一修复 6 处 `bytes[i] as char`**(`src/prompt/template.rs` 255 / 275 / 324 / 326 / 364 / 389 行):
|
||||||
|
- 保持现有 `bytes[i]` 对 ASCII 语法字符(`b'{'` / `b'}'` 等)的字节判断不变
|
||||||
|
- 字符累积改为按 UTF-8 字符边界推进:从当前位置取完整字符(如 `template[i..].chars().next()`),再按该字符的 UTF-8 长度推进索引(`i += ch.len_utf8()`)
|
||||||
|
- 效果:非 ASCII 文本(中文 / emoji / 任意多字节字符)逐字符正确保留;ASCII 文本行为与修复前完全一致
|
||||||
|
|
||||||
|
- **补充回归测试**(`src/prompt/template.rs` 新建 `#[cfg(test)] mod tests`):
|
||||||
|
- 纯中文模板 compile + render 后与原文逐字符一致(主破坏路径 literal)
|
||||||
|
- 中文文本 + `{{ var }}` 变量插值混合渲染正确
|
||||||
|
- 中文文本位于 `#if` / `#each` / `#raw` 块体内时正确保留
|
||||||
|
- emoji 等 4 字节字符正确保留
|
||||||
|
- 多字节字符紧邻 `{{` / `}}` 标签边界的解析正确性
|
||||||
|
- 纯 ASCII 模板渲染结果与修复前一致(向后兼容回归)
|
||||||
|
- 标签内部含非 ASCII 内容(中文变量名如 `{{ 问候 }}`、中文 `#if` 条件)的解析与渲染正确 ← PM Advisor
|
||||||
|
|
||||||
|
- **版本号更新**:`Cargo.toml` version `0.3.6` → `0.3.7`
|
||||||
|
|
||||||
|
### v1 可选
|
||||||
|
|
||||||
|
- 无(上述三项即可完整修复)
|
||||||
|
|
||||||
|
### v2 考虑
|
||||||
|
|
||||||
|
- 无。CHANGELOG 记录暂不做(本次需求明确暂不记录),后续版本若需要可单独补充
|
||||||
|
|
||||||
|
### 非目标
|
||||||
|
|
||||||
|
- 不重构模板语法(标签体系、块结构保持现状)
|
||||||
|
- 不新增模板功能(新标签、新渲染特性)
|
||||||
|
- 不改动公开 API(`PromptTemplate::compile` / `render` 签名与语义不变)
|
||||||
|
- 不修改其他模块(已扫描 `as char` / `from_utf8_lossy` 等模式,项目中无同类编码风险点)
|
||||||
|
- 不涉及下游 dc-management 是否回切模板链路的决策(由下游另行评估)
|
||||||
|
|
||||||
|
## 5. 边界与假设
|
||||||
|
|
||||||
|
| 边界 / 假设 | 来源 | 说明 |
|
||||||
|
|-------------|------|------|
|
||||||
|
| ASCII 语法判断可保留 | 语法分析 | 模板标签均为 ASCII(`{`、`}`、`#`、`/`、`>`),`bytes[i]` 字节比较对 ASCII 安全 |
|
||||||
|
| 非 ASCII 文本必须逐字符保留 | 需求确认 | 修复目标是编译输出与模板原文一致 |
|
||||||
|
| ASCII 模板行为零变化 | 需求确认 | 向后兼容是硬约束,需回归测试保障 |
|
||||||
|
| 公开 API 不变 | 需求确认 | `compile` / `render` 签名不变,下游无需改代码 |
|
||||||
|
| 修复范围 6 处统一 | 用户确认 | 「尽可能全面的修复」,包括 parse_tag 内的变量名内容 |
|
||||||
|
| 测试纳入本次范围 | 用户确认 | 用户明确要求补充测试 |
|
||||||
|
| 版本为 patch(0.3.7) | 用户确认 | bug 修复走 patch 版本,尽快让下游升级 |
|
||||||
|
| CHANGELOG 暂不记录 | 用户确认 | 本次发布不更新 CHANGELOG |
|
||||||
|
|
||||||
|
## 6. 术语表
|
||||||
|
|
||||||
|
| 术语 | 定义 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| mojibake | 乱码 | 文本因编码解码不匹配产生的字符错乱,此处为 UTF-8 字节被按 Latin-1 逐字节解码 |
|
||||||
|
| Latin-1 | ISO-8859-1 单字节编码 | 0x00–0xFF 每个字节对应一个 Unicode 码点;`u8 as char` 等价于该解码 |
|
||||||
|
| `len_utf8()` | Rust 标准库方法 | 返回 `char` 的 UTF-8 编码长度(1–4 字节),用于按字符边界推进索引 |
|
||||||
|
| `PromptTemplate` | agcore 提示词模板引擎 | 支持变量插值 `{{ var }}`、条件渲染 `#if`、循环 `#each`、原始块 `#raw` |
|
||||||
|
|
||||||
|
## 7. 验收标准
|
||||||
|
|
||||||
|
- [ ] 中文模板经 `compile` + `render` 后与原文逐字符一致(含纯文本、变量插值混合)
|
||||||
|
- [ ] 中文位于 `#if` / `#each` / `#raw` 块体内时正确保留
|
||||||
|
- [ ] emoji 等 4 字节字符正确保留
|
||||||
|
- [ ] 多字节字符紧邻 `{{` / `}}` 边界时解析正确
|
||||||
|
- [ ] 纯 ASCII 模板渲染结果与修复前完全一致(以 `src/prompt/composer.rs` 既有 4 个 ASCII 编译测试为基线全部通过,新测试断言等于预定义期望输出)← PM Advisor
|
||||||
|
- [ ] 6 处逐字节强转全部消除(grep 模式 `as char|char::from` 验证)← PM Advisor
|
||||||
|
- [ ] `cargo test` 全部通过(含新增测试)
|
||||||
|
- [ ] `cargo clippy` 无新增警告
|
||||||
|
- [ ] `Cargo.toml` 版本为 0.3.7
|
||||||
|
|
||||||
|
## 8. 风险评估
|
||||||
|
|
||||||
|
| 风险 | 影响 | 可能性 | 应对方向 |
|
||||||
|
|------|------|--------|---------|
|
||||||
|
| 索引推进改动引入边界回归(模板尾部、空模板、`{{` 未闭合) | 解析错误或 panic | 中 | 新增测试覆盖边界;保持 ASCII 判断逻辑不变 |
|
||||||
|
| parse_tag 内标签内容的解码方式与 literal 不一致 | 中文变量名场景损坏残留 | 低 | 6 处统一采用同一修复模式 |
|
||||||
|
| 下游对 0.3.7 的升级未验证中文模板 | 下游继续受影响 | 中 | tag 描述中明确修复内容与最小验证步骤(编译含中文的模板对比输出)← PM Advisor |
|
||||||
|
| 未来新增模板代码时再次引入逐字节强转 | 同类 bug 复发 | 低 | 回归测试(中文断言)可捕获此类问题 |
|
||||||
|
|
||||||
|
## 9. 发布计划
|
||||||
|
|
||||||
|
| 阶段 | 范围 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| v1 | 6 处修复 + 回归测试 + 版本号 0.3.7 | 本 PRD 范围 |
|
||||||
|
| 发布 | 打 tag `v0.3.7`,推送 origin | 实际发布动作由维护者执行 |
|
||||||
|
| 发布说明 | 以 `git tag -a v0.3.7` 的 tag 描述作为发布说明载体(附最小验证:编译含中文的模板并对比输出)← PM Advisor | 不更新 CHANGELOG,tag 描述随仓库分发 |
|
||||||
|
| 下游升级 | dc-management 升级 agcore 引用至 v0.3.7 | 下游自行评估是否回切模板链路 |
|
||||||
|
|
||||||
|
## 10. 历史版本
|
||||||
|
|
||||||
|
| 版本 | 日期 | 变更说明 |
|
||||||
|
|------|------|---------|
|
||||||
|
| v1 | 2026-08-03 | 人工种子(原始) |
|
||||||
|
|
||||||
|
### 种子内容
|
||||||
|
|
||||||
|
发起方:dc-management 项目排查探索调试日志 `log/explore-20260803-013158915066.txt`,发现发送给 LLM 的 system 提示词乱码。
|
||||||
|
|
||||||
|
原始需求描述(一字不改):
|
||||||
|
|
||||||
|
「检查一下当前日志 log 目录中保存的日志文件,似乎现在发送给LLM的提示词存在乱码?」
|
||||||
|
|
||||||
|
根因:
|
||||||
|
|
||||||
|
- `src/prompt/template.rs` 中 6 处 `bytes[i] as char`(255 / 275 / 324 / 326 / 364 / 389 行)将 UTF-8 字节逐字节强转为 Unicode 码点(等价 Latin-1 解码),非 ASCII 模板文本被破坏
|
||||||
|
- 日志文件、发送链路、模板文件编码均无问题,乱码发生在内存字符串(模板编译阶段)
|
||||||
|
|
||||||
|
修复方向(用户确认):
|
||||||
|
|
||||||
|
- 6 处统一修复:保持 ASCII 语法判断不变,字符累积按 UTF-8 字符边界推进
|
||||||
|
- 补充回归测试(中文模板恒等、混合插值、块内中文、emoji、边界、ASCII 回归)
|
||||||
|
- `Cargo.toml` 0.3.6 → 0.3.7,打 tag `v0.3.7`
|
||||||
|
- CHANGELOG 暂不记录
|
||||||
@@ -242,6 +242,7 @@ pub(crate) struct OpenaiChatResponse {
|
|||||||
pub created: u64,
|
pub created: u64,
|
||||||
pub model: String,
|
pub model: String,
|
||||||
pub choices: Vec<Choice>,
|
pub choices: Vec<Choice>,
|
||||||
|
#[serde(default)]
|
||||||
pub usage: crate::llm::types::usage::Usage,
|
pub usage: crate::llm::types::usage::Usage,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub system_fingerprint: Option<String>,
|
pub system_fingerprint: Option<String>,
|
||||||
@@ -1968,4 +1969,49 @@ data: [DONE]\n\n";
|
|||||||
"非法 header 名应被静默跳过"
|
"非法 header 名应被静默跳过"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== OpenaiChatResponse Usage 容错(v0.3.6 引入)=====
|
||||||
|
|
||||||
|
/// C1: Chat Completions 响应中 usage 键完全缺失 —— 默认 Usage::default()。
|
||||||
|
#[test]
|
||||||
|
fn deserialize_chat_response_missing_usage_key() {
|
||||||
|
let body: OpenaiChatResponse = serde_json::from_value(json!({
|
||||||
|
"id": "chatcmpl-test",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"created": 1718000000,
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"choices": [{
|
||||||
|
"index": 0,
|
||||||
|
"message": {"role": "assistant", "content": "hi"},
|
||||||
|
"finish_reason": "stop"
|
||||||
|
}]
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(body.usage.prompt_tokens, 0);
|
||||||
|
assert_eq!(body.usage.completion_tokens, 0);
|
||||||
|
assert_eq!(body.usage.total_tokens, 0);
|
||||||
|
assert!(body.usage.completion_tokens_details.is_none());
|
||||||
|
assert!(body.usage.prompt_tokens_details.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// C2: Chat Completions 响应中 usage 存在但缺子字段 —— 缺省字段默认 0。
|
||||||
|
#[test]
|
||||||
|
fn deserialize_chat_response_missing_usage_fields() {
|
||||||
|
let body: OpenaiChatResponse = serde_json::from_value(json!({
|
||||||
|
"id": "chatcmpl-test",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"created": 1718000000,
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"choices": [{
|
||||||
|
"index": 0,
|
||||||
|
"message": {"role": "assistant", "content": "hi"},
|
||||||
|
"finish_reason": "stop"
|
||||||
|
}],
|
||||||
|
"usage": {"prompt_tokens": 8}
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(body.usage.prompt_tokens, 8);
|
||||||
|
assert_eq!(body.usage.completion_tokens, 0);
|
||||||
|
assert_eq!(body.usage.total_tokens, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ pub(crate) struct OpenaiResponseBody {
|
|||||||
pub id: String,
|
pub id: String,
|
||||||
pub model: String,
|
pub model: String,
|
||||||
pub output: Vec<ResponseOutputItem>,
|
pub output: Vec<ResponseOutputItem>,
|
||||||
|
#[serde(default)]
|
||||||
pub usage: Usage,
|
pub usage: Usage,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
}
|
}
|
||||||
@@ -2509,4 +2510,92 @@ data: {\"type\":\"response.failed\",\"error\":{\"message\":\"server failed mid-s
|
|||||||
let wire = serde_json::to_value(&tool).unwrap();
|
let wire = serde_json::to_value(&tool).unwrap();
|
||||||
assert_eq!(wire, original);
|
assert_eq!(wire, original);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Usage 字段容错(v0.3.6 引入)=====
|
||||||
|
|
||||||
|
/// U1: usage 子字段缺失 —— 缺失字段默认 0/None,结构体反序列化不报错。
|
||||||
|
#[test]
|
||||||
|
fn deserialize_missing_usage_fields() {
|
||||||
|
let body: OpenaiResponseBody = serde_json::from_value(json!({
|
||||||
|
"id": "r_1",
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"output": [],
|
||||||
|
"usage": {"prompt_tokens": 5},
|
||||||
|
"status": "completed"
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(body.usage.prompt_tokens, 5);
|
||||||
|
assert_eq!(body.usage.completion_tokens, 0);
|
||||||
|
assert_eq!(body.usage.total_tokens, 0);
|
||||||
|
assert!(body.usage.completion_tokens_details.is_none());
|
||||||
|
assert!(body.usage.prompt_tokens_details.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// U2: usage 键完全缺失 —— OpenaiResponseBody.usage 默认 Usage::default()。
|
||||||
|
#[test]
|
||||||
|
fn deserialize_missing_usage_key() {
|
||||||
|
let body: OpenaiResponseBody = serde_json::from_value(json!({
|
||||||
|
"id": "r_1",
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"output": [],
|
||||||
|
"status": "completed"
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(body.usage.prompt_tokens, 0);
|
||||||
|
assert_eq!(body.usage.completion_tokens, 0);
|
||||||
|
assert_eq!(body.usage.total_tokens, 0);
|
||||||
|
assert!(body.usage.completion_tokens_details.is_none());
|
||||||
|
assert!(body.usage.prompt_tokens_details.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// U3: usage 为空对象 —— 全字段走 Default,无 None panic。
|
||||||
|
#[test]
|
||||||
|
fn deserialize_empty_usage_object() {
|
||||||
|
let body: OpenaiResponseBody = serde_json::from_value(json!({
|
||||||
|
"id": "r_1",
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"output": [],
|
||||||
|
"usage": {},
|
||||||
|
"status": "completed"
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(body.usage.prompt_tokens, 0);
|
||||||
|
assert_eq!(body.usage.completion_tokens, 0);
|
||||||
|
assert_eq!(body.usage.total_tokens, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// U4: 完整 usage(含 details)回归 —— 确保 #[serde(default)] 不破坏正常路径。
|
||||||
|
#[test]
|
||||||
|
fn deserialize_full_usage_with_details() {
|
||||||
|
let body: OpenaiResponseBody = serde_json::from_value(json!({
|
||||||
|
"id": "r_1",
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"output": [],
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": 10,
|
||||||
|
"completion_tokens": 20,
|
||||||
|
"total_tokens": 30,
|
||||||
|
"completion_tokens_details": {"reasoning_tokens": 5}
|
||||||
|
},
|
||||||
|
"status": "completed"
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(body.usage.prompt_tokens, 10);
|
||||||
|
assert_eq!(body.usage.completion_tokens, 20);
|
||||||
|
assert_eq!(body.usage.total_tokens, 30);
|
||||||
|
let details = body.usage.completion_tokens_details.unwrap();
|
||||||
|
assert_eq!(details.reasoning_tokens, Some(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// U5: OpenaiResponseBody 上下文中 usage key 完全缺失 —— 与 U2 同场景但明确命名。
|
||||||
|
#[test]
|
||||||
|
fn deserialize_response_body_missing_usage_key() {
|
||||||
|
let json_text = r#"{"id":"r_1","model":"gpt-4o","output":[],"status":"completed"}"#;
|
||||||
|
let body: OpenaiResponseBody = serde_json::from_str(json_text).unwrap();
|
||||||
|
assert_eq!(body.usage.prompt_tokens, 0);
|
||||||
|
assert_eq!(body.usage.completion_tokens, 0);
|
||||||
|
assert_eq!(body.usage.total_tokens, 0);
|
||||||
|
assert!(body.usage.completion_tokens_details.is_none());
|
||||||
|
assert!(body.usage.prompt_tokens_details.is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
pub struct Usage {
|
pub struct Usage {
|
||||||
pub prompt_tokens: u32,
|
pub prompt_tokens: u32,
|
||||||
pub completion_tokens: u32,
|
pub completion_tokens: u32,
|
||||||
@@ -79,3 +80,33 @@ impl Usage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deserialize_usage_missing_prompt_tokens() {
|
||||||
|
let value = json!({
|
||||||
|
"completion_tokens": 5,
|
||||||
|
"total_tokens": 5
|
||||||
|
});
|
||||||
|
let usage: Usage = serde_json::from_value(value).unwrap();
|
||||||
|
assert_eq!(usage.prompt_tokens, 0);
|
||||||
|
assert_eq!(usage.completion_tokens, 5);
|
||||||
|
assert_eq!(usage.total_tokens, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serialize_deserialize_roundtrip() {
|
||||||
|
let original = Usage::from_input_output(10, 20);
|
||||||
|
let serialized = serde_json::to_value(original).unwrap();
|
||||||
|
let deserialized: Usage = serde_json::from_value(serialized).unwrap();
|
||||||
|
assert_eq!(deserialized.prompt_tokens, original.prompt_tokens);
|
||||||
|
assert_eq!(deserialized.completion_tokens, original.completion_tokens);
|
||||||
|
assert_eq!(deserialized.total_tokens, original.total_tokens);
|
||||||
|
assert!(deserialized.completion_tokens_details.is_none());
|
||||||
|
assert!(deserialized.prompt_tokens_details.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+226
-18
@@ -220,7 +220,7 @@ fn compile_fragments(template: &str) -> Result<Vec<Fragment>, PromptError> {
|
|||||||
fragments.push(Fragment::Literal(literal.clone()));
|
fragments.push(Fragment::Literal(literal.clone()));
|
||||||
literal.clear();
|
literal.clear();
|
||||||
}
|
}
|
||||||
let (tag_content, end) = parse_tag(bytes, i)?;
|
let (tag_content, end) = parse_tag(template, i)?;
|
||||||
i = end;
|
i = end;
|
||||||
|
|
||||||
let tag = tag_content.trim();
|
let tag = tag_content.trim();
|
||||||
@@ -252,8 +252,16 @@ fn compile_fragments(template: &str) -> Result<Vec<Fragment>, PromptError> {
|
|||||||
fragments.push(Fragment::Variable { name });
|
fragments.push(Fragment::Variable { name });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
literal.push(bytes[i] as char);
|
debug_assert!(template.is_char_boundary(i));
|
||||||
i += 1;
|
match template[i..].chars().next() {
|
||||||
|
Some(ch) => {
|
||||||
|
literal.push(ch);
|
||||||
|
i += ch.len_utf8();
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
return Err(PromptError::Parse("模板包含非法字符序列".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,7 +272,8 @@ fn compile_fragments(template: &str) -> Result<Vec<Fragment>, PromptError> {
|
|||||||
Ok(fragments)
|
Ok(fragments)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_tag(bytes: &[u8], start: usize) -> Result<(String, usize), PromptError> {
|
fn parse_tag(template: &str, start: usize) -> Result<(String, usize), PromptError> {
|
||||||
|
let bytes = template.as_bytes();
|
||||||
let len = bytes.len();
|
let len = bytes.len();
|
||||||
let mut i = start + 2;
|
let mut i = start + 2;
|
||||||
let mut content = String::new();
|
let mut content = String::new();
|
||||||
@@ -272,8 +281,16 @@ fn parse_tag(bytes: &[u8], start: usize) -> Result<(String, usize), PromptError>
|
|||||||
if bytes[i] == b'}' && i + 1 < len && bytes[i + 1] == b'}' {
|
if bytes[i] == b'}' && i + 1 < len && bytes[i + 1] == b'}' {
|
||||||
return Ok((content, i + 2));
|
return Ok((content, i + 2));
|
||||||
}
|
}
|
||||||
content.push(bytes[i] as char);
|
debug_assert!(template.is_char_boundary(i));
|
||||||
i += 1;
|
match template[i..].chars().next() {
|
||||||
|
Some(ch) => {
|
||||||
|
content.push(ch);
|
||||||
|
i += ch.len_utf8();
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
return Err(PromptError::Parse("模板包含非法字符序列".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(PromptError::Parse("未闭合的 {{ 标签".to_string()))
|
Err(PromptError::Parse("未闭合的 {{ 标签".to_string()))
|
||||||
}
|
}
|
||||||
@@ -293,7 +310,7 @@ fn parse_block(
|
|||||||
|
|
||||||
while i < len && depth > 0 {
|
while i < len && depth > 0 {
|
||||||
if bytes[i] == b'{' && i + 1 < len && bytes[i + 1] == b'{' {
|
if bytes[i] == b'{' && i + 1 < len && bytes[i + 1] == b'{' {
|
||||||
let (tag, end) = parse_tag(bytes, i)?;
|
let (tag, end) = parse_tag(template, i)?;
|
||||||
let tag = tag.trim().to_string();
|
let tag = tag.trim().to_string();
|
||||||
if tag == format!("/{kind}") {
|
if tag == format!("/{kind}") {
|
||||||
depth -= 1;
|
depth -= 1;
|
||||||
@@ -320,12 +337,20 @@ fn parse_block(
|
|||||||
i = end;
|
i = end;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if is_else {
|
debug_assert!(template.is_char_boundary(i));
|
||||||
else_body.push(bytes[i] as char);
|
match template[i..].chars().next() {
|
||||||
} else {
|
Some(ch) => {
|
||||||
body.push(bytes[i] as char);
|
if is_else {
|
||||||
|
else_body.push(ch);
|
||||||
|
} else {
|
||||||
|
body.push(ch);
|
||||||
|
}
|
||||||
|
i += ch.len_utf8();
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
return Err(PromptError::Parse("模板包含非法字符序列".to_string()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
i += 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,7 +366,7 @@ fn parse_each_block(template: &str, start: usize) -> Result<(Vec<Fragment>, usiz
|
|||||||
|
|
||||||
while i < len && depth > 0 {
|
while i < len && depth > 0 {
|
||||||
if bytes[i] == b'{' && i + 1 < len && bytes[i + 1] == b'{' {
|
if bytes[i] == b'{' && i + 1 < len && bytes[i + 1] == b'{' {
|
||||||
let (tag, end) = parse_tag(bytes, i)?;
|
let (tag, end) = parse_tag(template, i)?;
|
||||||
let tag = tag.trim().to_string();
|
let tag = tag.trim().to_string();
|
||||||
if tag == "/each" {
|
if tag == "/each" {
|
||||||
depth -= 1;
|
depth -= 1;
|
||||||
@@ -361,8 +386,16 @@ fn parse_each_block(template: &str, start: usize) -> Result<(Vec<Fragment>, usiz
|
|||||||
i = end;
|
i = end;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
body.push(bytes[i] as char);
|
debug_assert!(template.is_char_boundary(i));
|
||||||
i += 1;
|
match template[i..].chars().next() {
|
||||||
|
Some(ch) => {
|
||||||
|
body.push(ch);
|
||||||
|
i += ch.len_utf8();
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
return Err(PromptError::Parse("模板包含非法字符序列".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,7 +410,7 @@ fn parse_raw_block(template: &str, start: usize) -> Result<(String, usize), Prom
|
|||||||
|
|
||||||
while i < len {
|
while i < len {
|
||||||
if bytes[i] == b'{' && i + 1 < len && bytes[i + 1] == b'{' {
|
if bytes[i] == b'{' && i + 1 < len && bytes[i + 1] == b'{' {
|
||||||
let (tag, end) = parse_tag(bytes, i)?;
|
let (tag, end) = parse_tag(template, i)?;
|
||||||
let tag = tag.trim().to_string();
|
let tag = tag.trim().to_string();
|
||||||
if tag == "/raw" {
|
if tag == "/raw" {
|
||||||
return Ok((content, end));
|
return Ok((content, end));
|
||||||
@@ -386,8 +419,16 @@ fn parse_raw_block(template: &str, start: usize) -> Result<(String, usize), Prom
|
|||||||
i = end;
|
i = end;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
content.push(bytes[i] as char);
|
debug_assert!(template.is_char_boundary(i));
|
||||||
i += 1;
|
match template[i..].chars().next() {
|
||||||
|
Some(ch) => {
|
||||||
|
content.push(ch);
|
||||||
|
i += ch.len_utf8();
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
return Err(PromptError::Parse("模板包含非法字符序列".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,3 +569,170 @@ impl PromptTemplateRegistry {
|
|||||||
tpl.render(ctx)
|
tpl.render(ctx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preserves_chinese_literal() -> Result<(), PromptError> {
|
||||||
|
let source = "这是一个纯中文模板。";
|
||||||
|
let template = PromptTemplate::compile(source)?;
|
||||||
|
|
||||||
|
assert_eq!(template.render(&TemplateContext::new())?, source);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preserves_real_world_failure_text() -> Result<(), PromptError> {
|
||||||
|
let source = "你是采集策略专家,负责审查已采集的产品编码结果,决策下一轮搜索方向。★ 下一步→「严格校验」,使用全角标点:,;!";
|
||||||
|
let template = PromptTemplate::compile(source)?;
|
||||||
|
|
||||||
|
assert_eq!(template.render(&TemplateContext::new())?, source);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_chinese_with_variable() -> Result<(), PromptError> {
|
||||||
|
let template = PromptTemplate::compile("你好,{{ name }}!")?;
|
||||||
|
let mut ctx = TemplateContext::new();
|
||||||
|
ctx.insert("name", "小明");
|
||||||
|
|
||||||
|
assert_eq!(template.render(&ctx)?, "你好,小明!");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preserves_chinese_in_if_body() -> Result<(), PromptError> {
|
||||||
|
let template = PromptTemplate::compile("{{#if enabled}}已启用{{/if}}")?;
|
||||||
|
let mut ctx = TemplateContext::new();
|
||||||
|
ctx.insert("enabled", true);
|
||||||
|
|
||||||
|
assert_eq!(template.render(&ctx)?, "已启用");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preserves_chinese_in_else_body() -> Result<(), PromptError> {
|
||||||
|
let template = PromptTemplate::compile("{{#if enabled}}已启用{{else}}未启用{{/if}}")?;
|
||||||
|
let mut ctx = TemplateContext::new();
|
||||||
|
ctx.insert("enabled", false);
|
||||||
|
|
||||||
|
assert_eq!(template.render(&ctx)?, "未启用");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preserves_chinese_in_each_body() -> Result<(), PromptError> {
|
||||||
|
let template = PromptTemplate::compile("{{#each items}}项目:{{item}};{{/each}}")?;
|
||||||
|
let ctx = TemplateContext::from_json(&json!({"items": ["甲", "乙"]}))?;
|
||||||
|
|
||||||
|
assert_eq!(template.render(&ctx)?, "项目:甲;项目:乙;");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preserves_chinese_in_raw_body() -> Result<(), PromptError> {
|
||||||
|
let template = PromptTemplate::compile("{{#raw}}原始中文:{{name}}{{/raw}}")?;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
template.render(&TemplateContext::new())?,
|
||||||
|
"原始中文:{{name}}"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preserves_four_byte_characters() -> Result<(), PromptError> {
|
||||||
|
let source = "你好👋🌍";
|
||||||
|
let template = PromptTemplate::compile(source)?;
|
||||||
|
|
||||||
|
assert_eq!(template.render(&TemplateContext::new())?, source);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_multibyte_characters_next_to_tag_boundaries() -> Result<(), PromptError> {
|
||||||
|
let template = PromptTemplate::compile("前{{name}}后")?;
|
||||||
|
let mut ctx = TemplateContext::new();
|
||||||
|
ctx.insert("name", "中");
|
||||||
|
|
||||||
|
assert_eq!(template.render(&ctx)?, "前中后");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preserves_chinese_at_end_of_template() -> Result<(), PromptError> {
|
||||||
|
let source = "template ends with 中文";
|
||||||
|
let template = PromptTemplate::compile(source)?;
|
||||||
|
|
||||||
|
assert_eq!(template.render(&TemplateContext::new())?, source);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_empty_template() -> Result<(), PromptError> {
|
||||||
|
let template = PromptTemplate::compile("")?;
|
||||||
|
|
||||||
|
assert_eq!(template.render(&TemplateContext::new())?, "");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_unclosed_tag_after_chinese_without_panicking() {
|
||||||
|
assert!(matches!(
|
||||||
|
PromptTemplate::compile("中文{{未闭合"),
|
||||||
|
Err(PromptError::Parse(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preserves_ascii_template_behavior() -> Result<(), PromptError> {
|
||||||
|
let template = PromptTemplate::compile(
|
||||||
|
"Hello {{name}}! {{#if active}}Active{{else}}Inactive{{/if}} {{#each items}}[{{item}}]{{/each}} {{#raw}}{{raw}}{{/raw}}",
|
||||||
|
)?;
|
||||||
|
let ctx = TemplateContext::from_json(&json!({
|
||||||
|
"name": "Alice",
|
||||||
|
"active": true,
|
||||||
|
"items": ["a", "b"]
|
||||||
|
}))?;
|
||||||
|
|
||||||
|
assert_eq!(template.render(&ctx)?, "Hello Alice! Active [a][b] {{raw}}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_chinese_variable_name() -> Result<(), PromptError> {
|
||||||
|
let template = PromptTemplate::compile("{{ 问候 }},世界!")?;
|
||||||
|
let mut ctx = TemplateContext::new();
|
||||||
|
ctx.insert("问候", "你好");
|
||||||
|
|
||||||
|
assert_eq!(template.render(&ctx)?, "你好,世界!");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_chinese_if_condition() -> Result<(), PromptError> {
|
||||||
|
let template = PromptTemplate::compile("{{#if 已启用}}条件成立{{/if}}")?;
|
||||||
|
let mut ctx = TemplateContext::new();
|
||||||
|
ctx.insert("已启用", true);
|
||||||
|
|
||||||
|
assert_eq!(template.render(&ctx)?, "条件成立");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preserves_chinese_in_nested_if_and_each_blocks() -> Result<(), PromptError> {
|
||||||
|
let template = PromptTemplate::compile(
|
||||||
|
"{{#if 已启用}}列表:{{#each 项目}}【{{item}}】{{/each}}{{/if}}",
|
||||||
|
)?;
|
||||||
|
let ctx = TemplateContext::from_json(&json!({
|
||||||
|
"已启用": true,
|
||||||
|
"项目": ["甲", "乙"]
|
||||||
|
}))?;
|
||||||
|
|
||||||
|
assert_eq!(template.render(&ctx)?, "列表:【甲】【乙】");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user