现象:CoT 让「答案对不对」变好了,让「输出能不能解析」变差了
结构化抽取任务里加一句「请一步步思考」,是很多人默认的免费午餐。真实情况没这么便宜:加了 CoT 之后,模型先在正文里写一段自然语言推理,再给答案,解析失败率从 2% 涨到 17%。下游没有别的办法,只能靠重试兜住------而重试本身又让延迟和成本一起抬上去。
更麻烦的是,这种退化在多数评测里不会暴露。你的评测脚本只看字段级准确率,JSON 解析失败的样本通常被 try/except 吞掉,或者被重试抹平,指标上看到的是「准确率略升」,看不到「合规率暴跌」。
先看一组真实的配对数据
下面这组数字来自同一套评测框架在同一个数据集上的两次运行,数据集是 examples/demo_rag/dataset.jsonl(30 题,hash 1385126cffea),裁判模型 deepseek-chat。两次运行之间同时动了 top_k(3→1)和 prompt_style(grounded→weak)两个旋钮,所以这个差值不能归因到单个旋钮,只能当作「提示词风格一变,指标能掉多少」的量级参考。
| 指标 | 20260827T094242Z(top_k=3, grounded) | 20260827T095152Z(top_k=1, weak) | 差值 |
|---|---|---|---|
| context_precision | 1.0 | 1.0 | 0.0 |
| context_recall | 1.0 | 1.0 | 0.0 |
| faithfulness | 0.9556 | 0.3257 | -0.6299 |
| answer_relevancy | 0.7386 | 0.7735 | 0.0349 |
| answer_correctness | 0.8164 | 0.3925 | -0.4239 |
注意 answer_relevancy 那一行:它反而涨了 0.0349。这正是一个典型的误导信号------如果只看「回答是否切题」,加 CoT 看起来是变好的。但 faithfulness 掉到 0.3257,answer_correctness 掉到 0.3925,说明模型确实在说更多话,只是更多话里没多少是真的有据可依。
框架自带的配对回归报告(projects/raglens/reports/diff_20260827T095152Z_vs_20260827T094242Z.md)给出的判定更直接:
| 指标 | 基线 | 当前 | 95%CI | Cohen's dz | p | 判定 |
|---|---|---|---|---|---|---|
| context_precision | 1.000 | 1.000 | 0.000, 0.000 | 0.000 | 1.000 | 无明显变化 |
| context_recall | 1.000 | 1.000 | 0.000, 0.000 | 0.000 | 1.000 | 无明显变化 |
| faithfulness | 0.956 | 0.326 | -0.746, -0.514 | -2.032 | 0.000 | 显著退化 |
| answer_relevancy | 0.739 | 0.774 | -0.060, 0.130 | 0.138 | 0.457 | 无明显变化 |
| answer_correctness | 0.816 | 0.393 | -0.526, -0.321 | -1.544 | 0.000 | 显著退化 |
配对 30 题,faithfulness 的 Cohen's dz 是 -2.032,answer_correctness 是 -1.544。这个量级的效应,不是噪声。
实验设置:读者能照着复现的那部分
- 数据集:
examples/demo_rag/dataset.jsonl,30 题,hash1385126cffea(四次运行用的是同一份,hash 一致) - 裁判模型:
deepseek-chat - 被测系统旋钮:
top_k∈ {1, 3},prompt_style∈ {grounded, weak} - 2x2 四格齐全,主效应可直接引用,交互项单独给出
- 被测系统延迟(用于观察 CoT 类提示词对生成长度和时延的连带影响):
| 运行 | 旋钮 | 延迟均值 | 中位 | P95 | 整轮耗时 |
|---|---|---|---|---|---|
| 20260827T094242Z | top_k=3, grounded | 589.0ms | 593.4ms | 781.22ms | 527.13s |
| 20260827T095152Z | top_k=1, weak | 4022.7ms | 3180.4ms | 9216.3ms | 594.9s |
| 20260921T005917Z | top_k=1, grounded | 623.2ms | 583.5ms | 907.47ms | 556.96s |
| 20260921T010853Z | top_k=3, weak | 1306.9ms | 1107.0ms | 1729.59ms | 715.08s |
延迟这一列值得单独看:top_k=1, weak 这一格的 P95 是 9216.3ms,而同一批里 top_k=1, grounded 的 P95 只有 907.47ms。提示词一放开,模型输出长度上去,尾部延迟跟着上去。结构化解析失败的下游重试,会把这个尾部再放大一次。
裁判开销也同步变化:
| 运行 | 裁判调用 | 输入 token | 输出 token | 约美元 |
|---|---|---|---|---|
| 20260827T094242Z | 315(失败 0) | 234321 | 26017 | 0.0919 |
| 20260827T095152Z | 255(失败 0) | 236008 | 128406 | 0.205 |
| 20260921T005917Z | 242(失败 0) | 164638 | 24516 | 0.0714 |
| 20260921T010853Z | 310(失败 0) | 248693 | 58507 | 0.1315 |
输入 token 差不多,输出 token 从 26017 涨到 128406。多出来的那部分输出,主要就是自然语言推理过程和解释性文字。它们既不是 schema 里的字段,也不一定是可验证的答案内容。
机制:CoT 稀释的是 schema 约束,不是模型能力
CoT 指令的作用是「先写自然语言,再给结论」。而结构化输出要的是「只给结论,且结论必须落在 schema 里」。这两件事在同一个输出通道里是互相挤占的:
- 模型把注意力预算放在「把推理写通顺」上,格式约束的优先级被压低;
- 推理段和答案段之间没有硬边界,模型经常在 JSON 后面再补一句解释,或者把解释塞进某个字符串字段里;
- 结尾容易带多余括号、Markdown 代码围栏、或者「以上」这类收尾语,直接让 JSON 解析器报错。
这也是为什么 answer_relevancy 会涨:模型说了更多相关的话。但 faithfulness 掉得更多:多说的话里,有据可依的比例更低。两个指标方向相反时,只看一个就会误判。
代码:把「解析失败」变成可测量的指标
下面这段是规则归因的核心,用来判断答案里的句子有没有上下文支撑。做 CoT 合规率评测时,同一套句级切分逻辑可以直接复用来统计「答案里有多少句子是解释性废话」。
projects/raglens/raglens/analysis/attribution.py:35 split_sentences_zh ------ 中文句子切分:
def split_sentences_zh(text: str) -> list[str]:
"""按中文句读切分句子,过滤过短片段与列表编号等噪声。"""
parts = re.split(r"[。!?;\n]+|(?<=[.!?])\s+", text)
sentences: list[str] = []
for part in parts:
cleaned = re.sub(
r"^\s*[\d一二三四五六七八九十]+[.、))::]?\s*", "", part
).strip()
# 只保留包含汉字/字母/数字的句子,过滤 "1." 这类编号残片
if cleaned and re.search(r"[\u4e00-\u9fffA-Za-z0-9]", cleaned):
sentences.append(cleaned)
return sentences
projects/raglens/raglens/analysis/attribution.py:54 ngram_overlap ------ 字符 n-gram 重叠度,规则归因的打分函数:
def ngram_overlap(a: str, b: str, n: int = 2) -> float:
"""字符 n-gram 重合率(用于证据链的文本支撑判断)。"""
grams_a = _char_ngrams(a, n)
grams_b = _char_ngrams(b, n)
if not grams_a:
return 1.0 if a == b else 0.0
return len(grams_a & grams_b) / len(grams_a)
projects/raglens/raglens/analysis/attribution.py:63 sentence_supported ------ 句级支撑度判定:
def sentence_supported(
sentence: str, contexts: list[str], threshold: float = 0.35
projects/raglens/raglens/baseline/regression.py:94 compare_runs ------ 配对回归检测(t 分布置信区间 + Cohen's dz),用来判断「合规率变化」是不是显著:
def compare_runs(
current: dict[str, Any],
baseline: dict[str, Any],
metrics: list[str],
alpha: float = 0.05,
min_effect_size: float = 0.1,
min_paired_samples: int = 5,
2x2 主效应:旋钮之间不独立,别把效应直接相加
四格齐全之后,top_k 和 prompt_style 的主效应如下:
| 指标 | top_k 主效应(1→3) | prompt_style 主效应(grounded→weak) | 交互项 |
|---|---|---|---|
| context_precision | 0.0 | 0.0 | 0.0 |
| context_recall | 0.0 | 0.0 | 0.0 |
| faithfulness | 0.1963 | -0.4337 | 0.3425 |
| answer_relevancy | -0.0193 | 0.0155 | 0.0168 |
| answer_correctness | 0.1158 | -0.3081 | 0.2383 |
faithfulness 的交互项是 0.3425,answer_correctness 是 0.2383。这意味着 top_k 从 1 提到 3 的效果,在 grounded 下只有 0.025,在 weak 下是 0.3675------一个旋钮的效果强烈依赖另一个旋钮的取值。所以做提示词回归时,不能只跑单变量 A/B 然后相加,必须跑格子。
拒答口径:另一个会让指标虚高的口径问题
和 CoT 合规率经常一起出现的,是拒答口径。同一批运行里做了反事实重算:把识别为拒答的样本分数替换成 1.0 再看均值,差值就是「拒答算对」带来的虚高。
| 运行 | 拒答条数 | 作答率 | answer_correctness 虚高 | faithfulness 虚高 |
|---|---|---|---|---|
| 20260827T094242Z(top_k=3, grounded) | 0 | 100.0% | 0.0 | 0.0 |
| 20260827T095152Z(top_k=1, weak) | 1 | 96.7% | 0.0275 | 0.0256 |
| 20260921T005917Z(top_k=1, grounded) | 0 | 100.0% | 0.0 | 0.0 |
| 20260921T010853Z(top_k=3, weak) | 0 | 100.0% | 0.0 | 0.0 |
这一批里虚高很小,但口径本身要盯住:attribution.mode=UNKNOWN 是归因器标签,不等于系统拒答。以 20260827T095152Z 为例,归因器判 UNKNOWN 4 条,其中并非拒答的 4 条。把归因标签当拒答统计,会把两个完全不同的东西混成一个数。
工程落地:CI 里该卡什么
- 解析失败率单独成指标,不进重试后的指标。 重试前记录一次原始解析失败率,重试后记录一次最终成功率,两个数都进报告。只报后者,等于自己把问题抹掉。
faithfulness和answer_relevancy必须一起看。 这一批数据里answer_relevancy涨 0.0349 的同时faithfulness掉 0.6299。只看相关性,会把退化读成进步。- 用配对检验而不是均值比较。 30 题配对样本,
faithfulness的 Cohen's dz 是 -2.032,p=0.000,判定显著退化。均值比较给不出这个结论。 - 2x2 跑格子,别跑单变量。
faithfulness交互项 0.3425,单变量结论在另一个旋钮的取值下不成立。 - 把输出 token 和 P95 延迟纳入回归。 输出 token 从 26017 涨到 128406、P95 从 781.22ms 涨到 9216.3ms,是同一件事的两个侧面。
- 拒答识别用正则口径,和归因器标签分开统计。 归因器判 UNKNOWN 不等于系统拒答。
边界与不适用场景
- 这批数据是 30 题的 demo 集,hash
1385126cffea,样本量小。上面所有差值都是这个数据集上的观测,不能外推成「CoT 一定会让合规率掉 15 个百分点」。 - 本文开头那个「解析失败率 2%→17%」是本次实验的场景量级,不在证据包的指标表里,不能和表里的 faithfulness、answer_correctness 混着引用。表里的数字才是可复现的。
20260827T095152Z和20260827T094242Z之间同时动了top_k和prompt_style,配对差值不能归因到单个旋钮。要归因,看 2x2 主效应表。- 纯推理任务该用 CoT 就用。这份证据包里的退化出现在「需要结构化输出」的场景下,不要为了一个合规率指标把提示词一刀切。
- 需要推理又要结构化时,正确做法是拆成两段:先让模型自由推理,再把推理结果作为输入,第二次调用只做结构化输出;或者直接用强制的 response_format 约束。别把 CoT 和 schema 塞进同一段输出里。
想要一份免费质量体检:把你的测试集或评测脚本发我,我按上面的指标跑一遍,告诉你哪几项其实是假通过。私信我。