项目地址:github.com/forever-fre…
Base model:Qwen/Qwen3-4B-Thinking-2507
0 前言
科学问答里,一个模型“看起来答对了”其实还不够。
真正麻烦的地方在于:它的答案有没有证据?证据能不能被另一个人检查?如果模型只给结论,不给出处和依据,那么这个回答在科研场景里很难被信任。
VeriSeek 做的是一个实用的后训练实验:从 Qwen/Qwen3-4B-Thinking-2507 出发,让模型在回答科学声明时同时给出判断和证据。这里不追求把系统做成一个完整的文献 Agent,而是先把问题收窄到一个可以分析的训练闭环里:
给定 scientific context,模型能不能稳定输出正确 answer,并且找出对应 evidence?
围绕这个问题,我比较了三条路线:
- 只做 SFT;
- 直接从 base model 做 evidence-aware RL;
- 先 SFT,再接 evidence-aware RL。
同时保留未训练的 base model 作为基线。最终想看的不是单个数字,而是这几条路线分别在学习什么,以及为什么 SFT+RL 会比 RL-only 稳定。
1 先把问题做小
我没有把 VeriSeek 做成完整 RAG 或多轮检索 Agent。
原因很简单:如果系统里同时有 retriever、PDF parser、表格解析、图像理解、LLM-as-a-judge、工具调用和复杂 rollout,最后模型指标变了,很难判断是哪一部分带来的。
所以这个项目里,训练栈尽量保持朴素。trainer 和 rollout 基本沿用现有框架,模型也不需要处理 PDF、figure、table 或 multimodal 信息。reward 也没有使用 embedding similarity 或 LLM judge,而是尽量用确定性规则来算。
这样做的好处是实验变量比较清楚:我们主要观察的是数据组织、输出协议、SFT 预热和 evidence-aware reward 对模型行为的影响。
模型最终只需要输出两个块:
<answer>
SUPPORTS / REFUTES / NOT_ENOUGH_INFO
</answer>
<evidence>
[1] evidence sentence
</evidence>
这个格式看起来很简单,但它决定了后面的很多事情:输出能不能解析,reward 能不能计算,评测能不能复现,都会依赖它。
2 数据管线:一条训练样本是怎么来的
训练之前,先要看清楚模型到底看到了什么。
以 SciFact 为例,原始数据主要由两部分组成:
claims:科学声明,以及对应的证据标注;corpus:论文标题、摘要和句子列表。
VeriSeek 的数据处理就是把这两部分 join 起来,整理成一条“prompt + ground truth”的训练样本。SFT 阶段和 RL 阶段会共用这套格式,只是消费方式不同。
2.1 提取证据标注
SciFact 每条 claim 的 evidence 字段是嵌套结构。实际处理时,需要先拿到 doc_id、label 和 evidence sentence indices。
项目里做了一个简化:每条 claim 只取第一篇论文的第一组 evidence set。SciFact 里有些样本会标注多篇论文或多组证据,但为了让这个 MVP 更容易训练和评测,VeriSeek 先把它收敛成单文档、单组 evidence。
# data/prepare_scifact.py
def _first_official_evidence(claim: dict) -> tuple[str, str, list[int]]:
evidence = claim.get("evidence") or {}
for doc_id, evidence_sets in evidence.items():
if evidence_sets:
first = evidence_sets[0]
return str(doc_id), first.get("label", "NOT_ENOUGH_INFO"), first.get("sentences", [])
cited = claim.get("cited_doc_ids") or []
doc_id = str(cited[0]) if cited else ""
return doc_id, "NOT_ENOUGH_INFO", []
如果没有 evidence 标注,就回退到 cited_doc_ids[0],同时把标签设成 NOT_ENOUGH_INFO。
2.2 拼出 prompt
拿到 doc_id 后,从 corpus 里找到对应论文的标题和摘要。摘要会按句子编号拼进 prompt,gold evidence 则通过 sentence indices 从摘要中切出来。
# data/prepare_scifact.py
def convert_claim(claim: dict, corpus: dict, split: str) -> dict:
doc_id, label, evidence_indices = _first_official_evidence(claim)
doc = corpus.get(doc_id, {})
abstract = _abstract_sentences(doc)
title = doc.get("title", "")
evidence = [
abstract[i]
for i in evidence_indices
if isinstance(i, int) and 0 <= i < len(abstract)
]
context = "\n".join(
f"[{i}] {sentence}" for i, sentence in enumerate(abstract)
)
prompt = f"""You are a scientific research agent.
Decide whether the claim is SUPPORTS, REFUTES, or NOT_ENOUGH_INFO.
Return your response using the exact format:
<answer>
SUPPORTS / REFUTES / NOT_ENOUGH_INFO
</answer>
<evidence>
[1] evidence sentence
</evidence>
Claim:
{claim.get("claim", "")}
Paper title:
{title}
Available abstract sentences:
{context}
"""
举一个例子。假设 claim 是:
Diffusion tensor MRI can assess microstructural development in cerebral white matter in living infants.
corpus 中对应论文的摘要会被编号后放进 prompt:
Available abstract sentences:
[0] Alterations of the architecture of cerebral white matter in the developing human brain can affect cortical development...
[1] A line scan diffusion-weighted magnetic resonance imaging sequence with diffusion tensor analysis was applied...
...
[10] The data indicate that quantitative assessment of water diffusion by diffusion tensor MRI provides insight into microstructural development in cerebral white matter in living infants.
这里标准证据是 [0] 和 [10] 两句,也就是数据集中人工标注、模型应该找出来的参考证据句。模型要做的不是凭空回答“支持”,而是从给定摘要里找出能支撑判断的句子。
2.3 打包成训练行
最后,每条样本被打包成统一的训练行:
# data/veriseek_common.py
def make_training_row(prompt, answer, evidence, data_source, index, split, **kwargs):
return {
"prompt": [{"role": "user", "content": prompt}],
"reward_model": {
"ground_truth": json.dumps({
"answer": str(answer),
"evidence": [str(item) for item in evidence if str(item).strip()]
}, ensure_ascii=False)
},
"data_source": data_source,
"ability": "scientific evidence QA",
"extra_info": {
"index": str(index),
"split": split,
**kwargs,
},
}
这里的 reward_model.ground_truth 很关键。它是一个 JSON 字符串,里面放了标准 answer 和标准 evidence。
它在两个阶段都会被用到:
- SFT 阶段:反序列化后拼成目标输出,做 next-token prediction;
- RL 阶段:reward 函数解析它,用来计算 answer、evidence、format 和 conciseness 分数。
Label 在写入前会统一归一化:
LABEL_MAP = {
"SUPPORT": "SUPPORTS", "SUPPORTS": "SUPPORTS",
"REFUTE": "REFUTES", "REFUTES": "REFUTES",
"CONTRADICT": "REFUTES", "CONTRADICTS": "REFUTES",
"NOT_ENOUGH_INFO": "NOT_ENOUGH_INFO", "NEI": "NOT_ENOUGH_INFO",
}
最终得到的 parquet 文件包含这些列:
prompt
reward_model
data_source
ability
extra_info
这一步看起来像普通数据预处理,但其实是整个项目的基础。如果数据没有被整理成“模型能学、reward 能算、评估能复现”的形式,后面的 SFT 和 RL 都会变得很脆弱。
3 三条训练路线分别在学什么
VeriSeek 比较了四个条件:
| Model | Training Path | 作用 |
|---|---|---|
| Qwen3-4B Base | none | 看原始模型的基础判断能力 |
| VeriSeek RL-only | RL-only | 测试 reward 能不能直接诱导证据寻址行为 |
| VeriSeek SFT | SFT | 让模型学会输出协议和基本任务行为 |
| VeriSeek SFT+RL | SFT+RL | 在 SFT 基础上继续优化证据 grounding |
3.1 SFT:先学会任务形状
SFT 在这里主要不是为了记住具体答案,而是让模型先学会这个任务的“形状”。
它需要学会:
读 claim 和 abstract
判断 SUPPORTS / REFUTES / NOT_ENOUGH_INFO
按 XML 协议输出 answer
从摘要里摘出 evidence
对于这种强格式任务,SFT 很重要。因为如果模型输出的格式不稳定,后面的 reward 函数就没法稳定解析,RL 也就拿不到有效信号。
3.2 RL-only:直接用奖励教模型
RL-only 是一个很自然但也很难的对照实验。
它直接从 base model 开始,用 evidence reward 去训练模型。理论上,如果 reward 足够清楚,模型应该可以慢慢学会什么样的输出更好。
但实际结果并不好。RL-only 最终只比 base model 高了一点点。这个现象后面会展开说,核心原因是冷启动阶段格式失败太多,reward 大面积为 0,GRPO 没有可用的组内差异。
3.3 SFT+RL:先模仿,再优化
SFT+RL 是最终效果最好的路线。
它的逻辑也最符合直觉:
SFT 先让模型会按协议回答
RL 再奖励更好的证据选择
也就是说,SFT 解决“能不能回答成这个样子”,RL 解决“在这个样子里,哪些回答更好”。
4 奖励函数:让模型不能只猜标签
VeriSeek 的 reward 代码在:
RL/verl/utils/reward_score/evidence_reward.py
训练阶段和 benchmark 阶段调用的是同一套函数,这样可以保证 train 和 eval 的口径一致。
整个 reward 是确定性的,不依赖 embedding 模型,也不依赖 LLM-as-a-judge。它主要看四个维度:
| 维度 | 含义 |
|---|---|
| answer | 标签是否正确 |
| evidence | 预测证据和标准证据之间的词级 F1,也就是两者在关键词覆盖上的接近程度。 |
| format | 是否完整包含 answer/evidence XML 块 |
| conciseness | 证据数量是否合理 |
4.1 先解析模型输出
模型输出应该包含 <answer> 和 <evidence> 两个块。解析逻辑比较直接:
def extract_answer(text: str) -> str:
match = re.search(
r"<answer>(.*?)</answer>",
text or "",
flags=re.IGNORECASE | re.DOTALL,
)
return match.group(1).strip() if match else ""
def extract_evidence(text: str) -> list[str]:
match = re.search(
r"<evidence>(.*?)</evidence>",
text or "",
flags=re.IGNORECASE | re.DOTALL,
)
if not match:
return []
body = match.group(1).strip()
if not body:
return []
items = []
for line in body.splitlines():
line = line.strip()
if not line:
continue
line = re.sub(r"^\[\d+\]\s*", "", line)
line = re.sub(r"^[-*]\s*", "", line)
if line:
items.append(line)
return items or [body]
extract_evidence 会去掉 [1]、[2] 这类编号,也会去掉 markdown 列表符。这样模型稍微换一种编号方式,仍然可以被解析。
4.2 用 token F1 算 evidence
证据不要求逐字完全一致,所以这里用 token-level F1。
def normalize_text(text) -> str:
text = "" if text is None else str(text).lower()
text = text.replace("_", " ")
text = text.translate(str.maketrans({c: " " for c in string.punctuation}))
return re.sub(r"\s+", " ", text).strip()
def token_f1(prediction, gold) -> float:
pred_tokens = normalize_text(prediction).split()
gold_tokens = normalize_text(gold).split()
if not pred_tokens or not gold_tokens:
return 0.0
common = Counter(pred_tokens) & Counter(gold_tokens)
num_same = sum(common.values())
if num_same == 0:
return 0.0
precision = num_same / len(pred_tokens)
recall = num_same / len(gold_tokens)
return 2 * precision * recall / (precision + recall)
归一化会把文本转小写、去掉标点、压缩空格。这样 "The data indicate that..." 和 "the data indicate that" 会被看成相同的 token 序列。
多句 evidence 时,对每条预测证据取与 gold evidence 的最大 F1,再求平均:
def _evidence_reward(pred_evidence: list[str], gold_evidence: list[str]) -> float:
if not pred_evidence or not gold_evidence:
return 0.0
scores = [
max(token_f1(pred, gold) for gold in gold_evidence)
for pred in pred_evidence
]
return sum(scores) / len(scores)
这个设计不完美,但它的优点就是便宜、可复现。
4.3 SciFact 的 gated reward
def _scifact_gated_score(solution_str, ground_truth) -> float:
parsed_gold = _parse_ground_truth(ground_truth)
pred_answer = extract_answer(solution_str)
pred_evidence = extract_evidence(solution_str)
components = compute_components(solution_str, ground_truth, "scifact_evidence")
if components["format"] < 1.0:
return 0.0
gold_label = _normalize_label(parsed_gold["answer"])
pred_label = _normalize_label(pred_answer)
if gold_label == "NOT_ENOUGH_INFO":
return 0.80 * components["answer"] + 0.20 * _empty_or_concise_nei_reward(pred_evidence)
if pred_label == "NOT_ENOUGH_INFO":
return 0.05
score = (
0.35 * components["answer"]
+ 0.55 * components["evidence"]
+ 0.10 * components["conciseness"]
)
if components["evidence"] < 0.20:
score = min(score, 0.25)
return score
这里有几个门控逻辑。
第一,格式不完整直接 0 分。
如果模型没有完整输出 <answer> 和 <evidence>,reward 直接为 0。这个设计很硬,但好处是训练目标很明确:不能生成一段“人能看懂但程序不可解析”的回答。
第二,NEI 样本奖励克制。
如果 gold label 是 NOT_ENOUGH_INFO,说明没有足够证据支持或反驳 claim。这时模型不应该强行编证据。最好的行为是给出 NEI,并让 evidence 保持为空或很简短。
def _empty_or_concise_nei_reward(pred_evidence: list[str]) -> float:
if not pred_evidence:
return 1.0
if len(pred_evidence) <= 2 and all(
len(normalize_text(item).split()) <= 20 for item in pred_evidence
):
return 0.5
return 0.0
第三,可回答样本不能轻易说 NEI。
如果 gold 是 SUPPORTS 或 REFUTES,而模型输出 NOT_ENOUGH_INFO,只给 0.05。这是为了防止模型学会“一律说证据不足”这种保守策略。
第四,证据差会封顶。
这是 reward 里我觉得最有用的一个设计。如果模型标签猜对了,但 evidence 很差,总分会被限制在 0.25 以内。
举个例子:
answer = 1.0
evidence = 0.08
conciseness = 1.0
基础分 = 0.35 * 1.0 + 0.55 * 0.08 + 0.10 * 1.0
= 0.494
因为 evidence < 0.20
最终分数 = min(0.494, 0.25) = 0.25
这会迫使模型不能只靠猜对 SUPPORTS / REFUTES 拿高分。它必须把证据也找得足够接近。
5 RL-only 为什么学不动
RL-only 路线最开始看起来很“干净”:不用 SFT,直接让模型通过 reward 学会任务。
但实验中它基本没有学起来。
后来回看 rollout 和 reward,问题很直接:base model 一开始不会稳定输出 XML 协议。它可能会写一段自然语言解释,或者输出合理的 reasoning,但只要没有完整的 <answer> 和 <evidence>,reward 就会被格式门拦住。
结果是:
format success rate ≈ 0
reward 大量为 0
对 GRPO 来说,这会变成一个很糟糕的情况。
GRPO 是在一组 generation 内做相对比较。假设一组里有 4 个回答,本来希望它们的 reward 有高有低:
R1 = 0.8
R2 = 0.6
R3 = 0.3
R4 = 0.1
这样模型就知道哪些方向更值得强化。
但 RL-only 初期更常见的是:
R1 = 0
R2 = 0
R3 = 0
R4 = 0
这时组内没有差异,advantage 也就没有有效信号。模型不是不想学,而是 reward 没有告诉它“哪个回答更好”。
这也是我对这个实验最深的一个感受:对于强格式任务,reward 设计得再清楚,如果模型一开始完全进不了协议,RL 也很难自己找到入口。
6 SFT 之后,RL 才真正开始工作
SFT 预热之后,情况明显不同。
模型先学会了 answer/evidence 输出协议,format success rate 从几乎不可用提升到接近稳定:
Format success rate: 0.0 → 0.993
一旦格式稳定,reward 才能正常执行。不同 rollout 之间也开始出现真实差异:
- 有些 answer 对,有些 answer 错;
- 有些证据更接近人工标注的标准证据;
- 有些 evidence 太长;
- 有些 answer 虽然对,但 evidence 很弱。
这些差异才是 GRPO 能利用的训练信号。
最终 RL 在 SFT 基础上带来了进一步改善:
Unsupported rate: 0.467 → 0.413
Evidence F1: 0.376 → 0.406
所以在这个项目里,SFT 和 RL 的分工很清楚:
SFT:让模型进入可训练的输出协议
RL:在协议内优化证据质量
7 最终结果
在 SciFact dev(n = 300)上,VeriSeek SFT+RL 是四条路径里表现最好的:
图 1 展示了四条训练路径在 SciFact dev 集上的对比结果。左图是答案三分类准确率,右图是 evidence grounding 的证据 F1。可以看到,Base 和 RL-only 只能作为 relaxed label diagnostic,无法稳定进入严格 XML evidence 评测;SFT 先把模型带到可解析的 answer/evidence 输出协议内,而 SFT+RL 在此基础上继续提升 answer accuracy 和 evidence F1。最终 VeriSeek SFT+RL 达到 0.793 的答案准确率和 0.406 的 evidence F1,说明强化学习阶段的收益主要体现在证据对齐的进一步细化,而不是一次性的能力跃迁。
| Model | Training Path | SciFact Answer Acc | SciFact Evidence F1 | Evaluation Note |
|---|---|---|---|---|
| Qwen3-4B Base | none | 0.553 | n/a | prefix-constrained label diagnostic |
| VeriSeek RL-only | RL-only | 0.563 | n/a | prefix-constrained label diagnostic |
| VeriSeek SFT | SFT | 0.780 | 0.376 | strict XML evidence evaluation |
| VeriSeek SFT+RL | SFT+RL | 0.793 | 0.406 | strict XML evidence evaluation |
相对 SFT baseline:
SciFact Answer Acc: +0.013
SciFact Evidence F1: +0.029
Unsupported Rate: -0.053
这个提升不大。它不是一个“模型能力飞跃”的故事。
但结果方向是干净的:answer accuracy 提升,evidence F1 提升,unsupported prediction 下降。模型不是简单地变得更保守,而是在更准确回答的同时,给出了更接近 gold evidence 的证据。
为什么 Base 和 RL-only 的 Evidence F1 是 n/a
Base 和 RL-only 模型不能稳定遵循 XML evidence 协议。它们的输出往往是自然语言 reasoning 段落,extract_evidence() 解析不出结构化证据。
如果硬算 evidence F1,结果基本没有信息量。因此这里只报告它们的 relaxed label accuracy,把它们作为标签空间诊断,而不是严格 grounding 评测。
8 如何复现
准备 SciFact 数据:
python data/prepare_scifact.py \
--output_dir data/processed/scifact
从 SFT checkpoint 跑 VeriSeek SFT+RL:
SFT_MODEL_PATH=$PWD/outputs/veriseek_sft_hf \
TRAIN_FILE=$PWD/data/processed/scifact/train.parquet \
VAL_FILE=$PWD/data/processed/scifact/dev.parquet \
OUTPUT=$PWD/outputs/veriseek_sft_rl \
EXPERIMENT_NAME=veriseek_sft_rl \
MAX_STEPS=200 \
SAVE_FREQ=50 \
TRAIN_BATCH_SIZE=2 \
PPO_MINI_BATCH_SIZE=2 \
GPU_NUM=2 \
TENSOR_MODEL_PARALLEL_SIZE=1 \
MAX_PROMPT_LEN=1664 \
MAX_RESPONSE_LEN=512 \
MAX_MODEL_LEN=2560 \
MAX_NUM_BATCHED_TOKENS=4096 \
ROLLOUT_GPU_MEMORY_UTILIZATION=0.55 \
ROLLOUT_MAX_NUM_SEQS=8 \
AGENT_GRPO_N=4 \
bash scripts/train_veriseek_sft_rl.sh
评测公开 checkpoint:
RUN_DIR=$PWD/outputs/veriseek_sft_rl \
BENCH_DIR=$PWD/outputs/benchmarks/veriseek_sft_rl \
TMP_PREFIX=$PWD/outputs/tmp_veriseek_sft_rl \
STEPS="200" \
bash scripts/eval_gated_checkpoints.sh
重画结果图:
python scripts/plot_veriseek_results.py \
--source assets/veriseek_scifact_benchmark_source.tsv \
--output_prefix assets/veriseek_scifact_benchmark
9 总结
VeriSeek 最终验证的是一个实用的经验:
对于科学问答这种强格式、强证据要求的任务,SFT 先把模型带到“能按协议回答”的状态,RL 才有机会通过 reward 继续优化证据质量。
这个项目是一个后训练闭环:
数据处理 → 输出协议 → SFT cold start → evidence-aware reward → GRPO → checkpoint evaluation
从结果上看,SFT+RL 相比 SFT 的提升不算大,但方向符合预期:模型的答案更准,给出的证据也更接近标准证据。