从 SFT 到 GRPO:把 Qwen3-4B 训练成会找证据的科学问答模型

4 阅读14分钟

项目地址: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_idlabel 和 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 比较了四个条件:

ModelTraining Path作用
Qwen3-4B Basenone看原始模型的基础判断能力
VeriSeek RL-onlyRL-only测试 reward 能不能直接诱导证据寻址行为
VeriSeek SFTSFT让模型学会输出协议和基本任务行为
VeriSeek SFT+RLSFT+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 是四条路径里表现最好的:

veriseek_scifact_benchmark.png

图 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,说明强化学习阶段的收益主要体现在证据对齐的进一步细化,而不是一次性的能力跃迁。

ModelTraining PathSciFact Answer AccSciFact Evidence F1Evaluation Note
Qwen3-4B Basenone0.553n/aprefix-constrained label diagnostic
VeriSeek RL-onlyRL-only0.563n/aprefix-constrained label diagnostic
VeriSeek SFTSFT0.7800.376strict XML evidence evaluation
VeriSeek SFT+RLSFT+RL0.7930.406strict 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 的提升不算大,但方向符合预期:模型的答案更准,给出的证据也更接近标准证据。