Files
agcore/design/pdd/32-模板编译器UTF-8编码修复方案.md
T
徐涛 eb7d23de3d docs: 添加模板编译器 UTF-8 编码修复方案与需求文档
- 新增 PDD 方案(编号 32),覆盖 6 处逐字节强转修复、测试设计与版本升级计划
- 新增 PRD 需求(编号 2),定位模板编译阶段中文乱码根因并明确验收标准
- 添加 .codegraph 目录忽略规则,避免本地数据文件入库
2026-08-03 11:21:50 +08:00

223 lines
14 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.
# 模板编译器 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.7patch 发布
- 公开 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 恒在 0x800xBF,首字节 ≥ 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 个调用点:223compile_fragments/ 296parse_block/ 344parse_each_block/ 380parse_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) => {
// 推入对应 Stringliteral / 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 非目标补充裸闭合标签静默截断不在范围 |