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

14 KiB
Raw Blame History

模板编译器 UTF-8 编码修复方案

  • 状态:Approved(第 1 轮审查通过,已按审查结论修正)
  • 作者:think
  • 日期:2026-08-03
  • 关联 PRDdesign/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-2parse_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 语法字符判断不变;字符累积分支统一改为:

} 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_fragmentsNone 分支绝不能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 签名变更

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,无 PartialEqassert_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: DebugOk 时 panic),但 PromptErrorPartialEq,拿 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 tests16 个用例) 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::fromsrc/ 无结果)
  • 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. 最小验证代码(可复制):
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(), "你是助手:你好");
  1. 行为变更提示:修复后 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 非目标补充裸闭合标签静默截断不在范围