☰
Prompt评估:加一句「请一步步思考」,结构化输出的解析失败率从 2% 涨到 17%
2026/10/2 16:19:59 网站建设 项目流程

现象:CoT 让「答案对不对」变好了,让「输出能不能解析」变差了

结构化抽取任务里加一句「请一步步思考」,是很多人默认的免费午餐。真实情况没这么便宜:加了 CoT 之后,模型先在正文里写一段自然语言推理,再给答案,解析失败率从 2% 涨到 17%。下游没有别的办法,只能靠重试兜住——而重试本身又让延迟和成本一起抬上去。

更麻烦的是,这种退化在多数评测里不会暴露。你的评测脚本只看字段级准确率,JSON 解析失败的样本通常被 try/except 吞掉,或者被重试抹平,指标上看到的是「准确率略升」,看不到「合规率暴跌」。

先看一组真实的配对数据

下面这组数字来自同一套评测框架在同一个数据集上的两次运行,数据集是examples/demo_rag/dataset.jsonl(30 题,hash1385126cffea),裁判模型deepseek-chat。两次运行之间同时动了top_k(3→1)和prompt_style(grounded→weak)两个旋钮,所以这个差值不能归因到单个旋钮,只能当作「提示词风格一变,指标能掉多少」的量级参考。

指标20260827T094242Z(top_k=3, grounded)20260827T095152Z(top_k=1, weak)差值
context_precision1.01.00.0
context_recall1.01.00.0
faithfulness0.95560.3257-0.6299
answer_relevancy0.73860.77350.0349
answer_correctness0.81640.3925-0.4239

注意answer_relevancy那一行:它反而涨了 0.0349。这正是一个典型的误导信号——如果只看「回答是否切题」,加 CoT 看起来是变好的。但faithfulness掉到 0.3257,answer_correctness掉到 0.3925,说明模型确实在说更多话,只是更多话里没多少是真的有据可依。

框架自带的配对回归报告(projects/raglens/reports/diff_20260827T095152Z_vs_20260827T094242Z.md)给出的判定更直接:

指标基线当前95%CICohen's dzp判定
context_precision1.0001.000[0.000, 0.000]0.0001.000无明显变化
context_recall1.0001.000[0.000, 0.000]0.0001.000无明显变化
faithfulness0.9560.326[-0.746, -0.514]-2.0320.000显著退化
answer_relevancy0.7390.774[-0.060, 0.130]0.1380.457无明显变化
answer_correctness0.8160.393[-0.526, -0.321]-1.5440.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整轮耗时
20260827T094242Ztop_k=3, grounded589.0ms593.4ms781.22ms527.13s
20260827T095152Ztop_k=1, weak4022.7ms3180.4ms9216.3ms594.9s
20260921T005917Ztop_k=1, grounded623.2ms583.5ms907.47ms556.96s
20260921T010853Ztop_k=3, weak1306.9ms1107.0ms1729.59ms715.08s

延迟这一列值得单独看:top_k=1, weak这一格的 P95 是 9216.3ms,而同一批里top_k=1, grounded的 P95 只有 907.47ms。提示词一放开,模型输出长度上去,尾部延迟跟着上去。结构化解析失败的下游重试,会把这个尾部再放大一次。

裁判开销也同步变化:

运行裁判调用输入 token输出 token约美元
20260827T094242Z315(失败 0)234321260170.0919
20260827T095152Z255(失败 0)2360081284060.205
20260921T005917Z242(失败 0)164638245160.0714
20260921T010853Z310(失败 0)248693585070.1315

输入 token 差不多,输出 token 从 26017 涨到 128406。多出来的那部分输出,主要就是自然语言推理过程和解释性文字。它们既不是 schema 里的字段,也不一定是可验证的答案内容。

机制:CoT 稀释的是 schema 约束,不是模型能力

CoT 指令的作用是「先写自然语言,再给结论」。而结构化输出要的是「只给结论,且结论必须落在 schema 里」。这两件事在同一个输出通道里是互相挤占的:

  1. 模型把注意力预算放在「把推理写通顺」上,格式约束的优先级被压低;
  2. 推理段和答案段之间没有硬边界,模型经常在 JSON 后面再补一句解释,或者把解释塞进某个字符串字段里;
  3. 结尾容易带多余括号、Markdown 代码围栏、或者「以上」这类收尾语,直接让 JSON 解析器报错。

这也是为什么answer_relevancy会涨:模型说了更多相关的话。但faithfulness掉得更多:多说的话里,有据可依的比例更低。两个指标方向相反时,只看一个就会误判。

代码:把「解析失败」变成可测量的指标

下面这段是规则归因的核心,用来判断答案里的句子有没有上下文支撑。做 CoT 合规率评测时,同一套句级切分逻辑可以直接复用来统计「答案里有多少句子是解释性废话」。

projects/raglens/raglens/analysis/attribution.py:35split_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:54ngram_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:63sentence_supported—— 句级支撑度判定:

def sentence_supported( sentence: str, contexts: list[str], threshold: float = 0.35

projects/raglens/raglens/baseline/regression.py:94compare_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_precision0.00.00.0
context_recall0.00.00.0
faithfulness0.1963-0.43370.3425
answer_relevancy-0.01930.01550.0168
answer_correctness0.1158-0.30810.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)0100.0%0.00.0
20260827T095152Z(top_k=1, weak)196.7%0.02750.0256
20260921T005917Z(top_k=1, grounded)0100.0%0.00.0
20260921T010853Z(top_k=3, weak)0100.0%0.00.0

这一批里虚高很小,但口径本身要盯住:attribution.mode=UNKNOWN是归因器标签,不等于系统拒答。以20260827T095152Z为例,归因器判 UNKNOWN 4 条,其中并非拒答的 4 条。把归因标签当拒答统计,会把两个完全不同的东西混成一个数。

工程落地:CI 里该卡什么

  1. 解析失败率单独成指标,不进重试后的指标。重试前记录一次原始解析失败率,重试后记录一次最终成功率,两个数都进报告。只报后者,等于自己把问题抹掉。
  2. faithfulness和answer_relevancy必须一起看。这一批数据里answer_relevancy涨 0.0349 的同时faithfulness掉 0.6299。只看相关性,会把退化读成进步。
  3. 用配对检验而不是均值比较。30 题配对样本,faithfulness的 Cohen's dz 是 -2.032,p=0.000,判定显著退化。均值比较给不出这个结论。
  4. 2x2 跑格子,别跑单变量。faithfulness交互项 0.3425,单变量结论在另一个旋钮的取值下不成立。
  5. 把输出 token 和 P95 延迟纳入回归。输出 token 从 26017 涨到 128406、P95 从 781.22ms 涨到 9216.3ms,是同一件事的两个侧面。
  6. 拒答识别用正则口径,和归因器标签分开统计。归因器判 UNKNOWN 不等于系统拒答。

边界与不适用场景

  • 这批数据是 30 题的 demo 集,hash1385126cffea,样本量小。上面所有差值都是这个数据集上的观测,不能外推成「CoT 一定会让合规率掉 15 个百分点」。
  • 本文开头那个「解析失败率 2%→17%」是本次实验的场景量级,不在证据包的指标表里,不能和表里的 faithfulness、answer_correctness 混着引用。表里的数字才是可复现的。
  • 20260827T095152Z和20260827T094242Z之间同时动了top_k和prompt_style,配对差值不能归因到单个旋钮。要归因,看 2x2 主效应表。
  • 纯推理任务该用 CoT 就用。这份证据包里的退化出现在「需要结构化输出」的场景下,不要为了一个合规率指标把提示词一刀切。
  • 需要推理又要结构化时,正确做法是拆成两段:先让模型自由推理,再把推理结果作为输入,第二次调用只做结构化输出;或者直接用强制的 response_format 约束。别把 CoT 和 schema 塞进同一段输出里。

想要一份免费质量体检:把你的测试集或评测脚本发我,我按上面的指标跑一遍,告诉你哪几项其实是假通过。私信我。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询