minimind/course/26_tool_use_and_agentic_rl.md

35 KiB
Raw Blame History

第 26 课:Tool Use 与 Agentic RL

这一课讲 trainer/train_agent.py:MiniMind 如何把工具调用从“会输出一段 <tool_call> 文本”推进到“多轮 rollout 中调用工具、观察结果、继续生成,并用延迟 reward 做 GRPO/CISPO 更新”。

本节不覆盖完整 Agent 系统设计。这里的 Agentic RL 是 MiniMind 项目里的狭义版本:固定工具集、mock 工具环境、有限多轮交互、规则/GT/reward model 组合打分。

目录

0. 本节主线

MiniMind 的 Tool Use / Agentic RL 链路是:

样本提供 messages + tools + gt
-> chat_template 把 tools 渲染到 system prompt
-> policy 生成 assistant response
-> 从 response 中解析 <tool_call>{json}</tool_call>
-> 执行 mock tool,得到 tool observation
-> 把 tool observation 作为 tool message 拼回 messages
-> 继续下一轮生成,直到没有 tool_call 或达到 max_turns
-> 对整条 trajectory 计算 reward
-> 同 prompt 多条 trajectory 做 group-relative advantage
-> 用 GRPO/CISPO + reference KL 更新 policy
-> eval/API 把模型文本中的 <tool_call> 转成 OpenAI 风格 tool_calls

一句话:

Tool calling 是模型生成结构化动作;
Agentic RL 是把动作执行后的观察接回上下文,再用整条轨迹的结果训练模型。

本节最容易混的三个对象:

tool schema:
  告诉模型有哪些工具、每个工具需要哪些参数。

tool_call:
  模型生成的动作,格式是 <tool_call>{"name": "...", "arguments": {...}}</tool_call>。

tool_response:
  环境执行动作后的观察,格式是 <tool_response>{...}</tool_response>。

第 25 课的 reward / logprob / KL 在这里没有换概念,只是 reward 从“单条回答分数”变成“多轮工具轨迹分数”。

1. 本节要懂的 7 个原理

原理 要理解什么 源码位置
工具协议先由 tokenizer/template 固化 <tool_call>、<tool_response> 是模型要学习和解析的文本协议 trainer/train_tokenizer.py:35-38, model/tokenizer_config.json:333
Agent RL 数据不是 labels 数据 AgentRLDataset 返回 messages/tools/gt,训练时在线 rollout,不直接返回 input_ids/labels dataset/lm_dataset.py:226-252
rollout 是环境循环 每轮生成 response,解析 tool_call,执行工具,把 tool observation 拼回上下文 trainer/train_agent.py:98-157
工具调用只是 JSON 文本动作 训练端用正则找 <tool_call>...</tool_call>,再 json.loads 成动作 trainer/train_agent.py:76-95, scripts/eval_toolcall.py:70-78
reward 是 trajectory 级信号 工具合法性、GT 命中、格式闭合、未完成、多余重复、RM 分数共同决定 reward trainer/train_agent.py:188-239
policy update 沿用 GRPO/CISPO Agent 训练仍然使用 group reward、old/current/ref logprob、reference KL 和 mask trainer/train_agent.py:242-333
eval/API 要把文本协议转成 tool_calls 字段 本地评测和 OpenAI API 服务端都要解析模型输出中的 tool_call scripts/eval_toolcall.py:177-199, scripts/serve_openai_api.py:83-102

学完本节,你应该能解释这些变量:

messages_batch:
  batch 内每条样本的多轮消息前缀,来自 AgentRLDataset。

tools_batch:
  每条样本可用的工具 schema。

gt_batch:
  最终答案校验目标,用来给工具轨迹打规则 reward。

turn_outputs_batch:
  一条 trajectory 中每轮 assistant 生成的文本。

response_ids:
  policy 生成的 response token,加上后续 tool observation delta。

response_mask:
  1 表示 policy 生成 token 参与 loss,0 表示 tool observation / prompt 部分不参与 loss。

response_old_logps:
  rollout 时生成 token 的 old logprob;tool observation token 对应 0.0。

rewards:
  每条 trajectory 的最终标量 reward,shape 是 `[B * num_generations]`。

2. 完整原理:从 tool call 到 trajectory reward

2.1 Tool calling 是结构化动作,不是普通文本答案

没有工具时,模型只要生成自然语言答案:

user -> assistant answer

有工具时,模型要先生成一个动作:

assistant -> <tool_call>{"name":"calculate_math","arguments":{"expression":"256 * 37"}}</tool_call>

这个动作不会直接结束对话。训练脚本会解析它,调用环境里的 mock 工具,拿到观察:

tool -> {"result": "9472"}

然后把观察重新放进上下文,让模型继续生成最终答案:

assistant -> 256 乘以 37 等于 9472。

所以 Agentic RL 训练的对象不是单个 response,而是一条 trajectory:

prompt
-> assistant action_1
-> tool observation_1
-> assistant action_2
-> tool observation_2
-> ...
-> assistant final_answer

2.2 为什么需要 response_mask

在一条 trajectory 里,不是所有 token 都是模型生成的。

policy 生成的 assistant token:
  应该参与 policy loss。

tool observation token:
  是环境返回,不是 policy 动作,不应该参与 policy loss。

prompt / system / user token:
  是条件上下文,也不应该参与 policy loss。

因此 train_agent.py 在 rollout 中维护:

response_ids:
  训练用的 token 序列后半部分。

response_mask:
  哪些 response_ids 是 policy 生成动作。

response_old_logps:
  policy 生成动作对应 rollout logprob;非 policy token 用 0.0 占位。

这就是本节比普通 GRPO 多出来的关键点:同一段 response_ids 中混有 policy 动作和环境观察,必须用 mask 区分。

2.3 Reward 是延迟的

工具调用本身不一定立刻知道好坏。比如:

<tool_call>{"name":"calculate_math","arguments":{"expression":"256 * 37"}}</tool_call>

这个动作可能是合法的,但最终答案仍然可能错。

MiniMind 的 Agent reward 因此按整条轨迹打分:

R(trajectory)
= tool call 对齐分
+ final answer GT 命中分
+ thinking / 长度 / 格式分
+ reward model score
- 标签不闭合惩罚
- 未完成惩罚
- 重复惩罚

然后 GRPO/CISPO 把同一个 prompt 的多条 trajectory reward 做组内标准化:

advantage = (reward - group_mean) / (group_std + 1e-4)

也就是说,模型不是学习“某个 token 本身是否好”,而是学习:

在这个上下文中,生成这些动作 token 后,整条轨迹是否比同题其他轨迹更好。

2.4 Agentic RL 和 SFT Tool Calling 的区别

SFT Tool Calling 学的是数据里的参考轨迹:

给定已有 messages/tools/tool_calls/tool_response/final answer
-> 让模型拟合 assistant 部分 token

Agentic RL 学的是在线采样轨迹:

给定 messages/tools/gt
-> 让模型自己采样 tool_call / final answer
-> 环境执行工具
-> 根据最终轨迹打 reward
-> 用 RL 更新模型

所以 AgentRLDataset 不需要提前给出完整 assistant labels。它只提供起始状态和校验目标。

3. 源码阅读顺序图

建议按这个顺序读:

1. trainer/train_tokenizer.py:35-38
   先看 tool/reasoning 标签如何进入 tokenizer 训练。

2. model/tokenizer_config.json:333
   看 chat_template 如何把 tools/tool_calls/tool role 渲染成文本协议。

3. dataset/lm_dataset.py:226-252
   看 AgentRLDataset 返回 messages/tools/gt,而不是 labels。

4. trainer/train_agent.py:40-95
   看工具定义、mock 执行、参数校验、tool_call 解析。

5. trainer/train_agent.py:98-180
   看多轮 rollout 如何执行工具并拼回 observation。

6. trainer/train_agent.py:188-239
   看 trajectory reward 的规则项。

7. trainer/train_agent.py:242-333
   看 reward 如何进入 GRPO/CISPO 更新。

8. scripts/eval_toolcall.py:177-199
   看推理侧如何反复 generate -> tool call -> tool result。

9. scripts/serve_openai_api.py:83-102
   看服务端如何把文本中的 <tool_call> 转成 OpenAI 风格 tool_calls 字段。

4. MiniMind 源码走读

4.1 工具标签进入 tokenizer/template

源码证据 A:工具标签作为额外 token

文件:trainer/train_tokenizer.py:35-38

看它是为了理解:MiniMind 在训练 tokenizer 时显式加入工具调用和思考标签。

代码摘录:

additional_tokens_list = [
    "<tool_call>", "</tool_call>",
    "<tool_response>", "</tool_response>",
    "<think>", "</think>"
]

这段代码说明:

  • 工具协议不是临时字符串,它被纳入 tokenizer 训练配置。
  • <tool_call> 和 <tool_response> 都是模型需要稳定生成/识别的结构标签。
  • <think> 与 tool calling 在同一套 chat template 里共存。

源码证据 B:chat template 描述工具协议

文件:model/tokenizer_config.json:333

看它是为了理解:当 tools 存在时,template 会把工具 schema 放入 system prompt,并告诉模型按 <tool_call> 包裹 JSON。

代码摘录:

{%- if tools %}
    {{- '<|im_start|>system\n' }}
    {{- "# Tools\n\nYou may call one or more functions ..." }}
    {{- "function signatures within <tools></tools> XML tags:\n<tools>" }}
    ...
    {{- "For each function call, return a json object ... within <tool_call></tool_call> XML tags:" }}
{%- endif %}

这段代码说明:

  • 工具 schema 是 prompt 的一部分,不是模型内部结构。
  • 模型输出 tool call 的约束来自 template 提示和 SFT/RL 训练。
  • 后续解析器依赖 <tool_call>...</tool_call> 标签边界。

4.2 数据集只提供起始状态和 GT

源码证据:AgentRLDataset

文件:dataset/lm_dataset.py:226-252

看它是为了理解:Agent RL 样本不是普通 SFT 的 input_ids/labels,而是用于在线 rollout 的环境初始状态。

代码摘录:

class AgentRLDataset(Dataset):
    def __init__(self, jsonl_path, tokenizer, max_length=1024):
        ...
        with open(jsonl_path, 'r', encoding='utf-8') as f:
            for line in f:
                self.samples.append(json.loads(line.strip()))

    def parse_conversations(self, conversations):
        messages = []
        tools = None
        for message in conversations:
            message = dict(message)
            if message.get("role") == "system" and message.get("tools"):
                tools = json.loads(message["tools"]) if isinstance(message["tools"], str) else message["tools"]
            messages.append(message)
        return messages[:-1], tools

    def __getitem__(self, index):
        sample = self.samples[index]
        messages, tools = self.parse_conversations(sample['conversations'])
        return {'messages': messages, 'tools': tools, 'gt': sample['gt']}

这段代码说明:

  • messages[:-1] 会去掉最后一轮,留下模型要开始 rollout 的上下文。
  • tools 从 system message 里解析出来。
  • gt 是最终校验目标,用于 reward,不是 labels。
  • __getitem__ 不返回 input_ids,因为 tokenization 会在 rollout 时根据多轮上下文动态发生。

README 里的数据格式也说明了这个约定。

文件:README.md:824-838

看它是为了理解:SFT/Agent 数据使用 OpenAI 风格消息,tools 挂在 system,tool_calls 挂在 assistant。

代码摘录:

{"role": "system", "content": "# Tools ...", "tools": "[...]"}
{"role": "user", "content": "帮我算一下 256 乘以 37 等于多少"}
{"role": "assistant", "content": "", "tool_calls": "[{\"name\":\"calculate_math\",\"arguments\":{\"expression\":\"256 * 37\"}}]"}
{"role": "tool", "content": "{\"result\":\"9472\"}"}
{"role": "assistant", "content": "256 乘以 37 等于 9472。"}

这段代码说明:

  • 监督数据里可以有完整 tool trajectory。
  • Agent RL 数据会额外使用 gt 作为最终校验目标。
  • 当前工作区没有实际 dataset/agent_rl.jsonl 文件;训练命令需要你另外准备数据。

4.3 工具定义、解析和执行

源码证据 A:固定工具 schema

文件:trainer/train_agent.py:40-47

看它是为了理解:训练脚本里的工具集是固定的 function schema 列表。

代码摘录:

TOOLS = [
    {"type": "function", "function": {"name": "calculate_math", ...}},
    {"type": "function", "function": {"name": "unit_converter", ...}},
    {"type": "function", "function": {"name": "get_current_weather", ...}},
    {"type": "function", "function": {"name": "get_current_time", ...}},
    {"type": "function", "function": {"name": "get_exchange_rate", ...}},
    {"type": "function", "function": {"name": "translate_text", ...}},
]

这段代码说明:

  • Agentic RL 训练不是开放世界工具系统。
  • 每个工具都有 name、description、parameters、required。
  • reward 校验会用这些 schema 判断 tool call 是否有效。

源码证据 B:mock 工具执行和参数校验

文件:trainer/train_agent.py:56-74

看它是为了理解:工具环境是训练脚本内置的 mock executor,不是外部真实服务。

代码摘录:

MOCK_RESULTS = {
    "calculate_math": lambda args: {"result": str(eval(...))},
    "unit_converter": lambda args: {"result": round(...)},
    "get_current_weather": lambda args: ...,
    "get_current_time": lambda args: ...,
    "get_exchange_rate": lambda args: ...,
    "translate_text": lambda args: ...,
}

CHECK_ARGS = {
    "calculate_math": lambda a: bool(a.get("expression")),
    "unit_converter": lambda a: a.get("value") is not None and a.get("from_unit") and a.get("to_unit"),
    ...
}

这段代码说明:

  • MOCK_RESULTS 决定动作执行后能拿到什么 observation。
  • CHECK_ARGS 决定 reward 里的“有效工具调用”。
  • 学这节时不要把 mock executor 当成生产级工具沙箱。

源码证据 C:解析 <tool_call> 文本

文件:trainer/train_agent.py:76-95

看它是为了理解:模型输出先是文本,训练脚本再用正则和 JSON 解析成动作。

代码摘录:

def parse_tool_calls(text):
    calls = []
    for m in re.findall(r'<tool_call>(.*?)</tool_call>', text, re.DOTALL):
        try: calls.append(json.loads(m.strip()))
        except: pass
    return calls

def execute_tool(name, args):
    fn = MOCK_RESULTS.get(name)
    if not fn: return None
    try:
        signal.signal(signal.SIGALRM, ...)
        signal.alarm(1)
        return fn(args)
    except:
        return None
    finally:
        try: signal.alarm(0)
        except: pass

这段代码说明:

  • 标签不闭合或 JSON 非法时,解析不到有效 tool call。
  • 未知工具返回 None。
  • 执行被 1 秒 alarm 限制,避免简单 mock 函数卡死。
  • 解析失败被吞掉,reward 侧再通过格式/GT/工具数量扣分。

4.4 多轮 rollout:动作、观察、再生成

源码证据:rollout_single

文件:trainer/train_agent.py:98-157

看它是为了理解:Agent rollout 不只是 model.generate 一次,而是最多多轮“生成 -> 执行工具 -> 拼回上下文”。

代码摘录:

for turn in range(max_turns):
    context = tokenizer.apply_chat_template(
        messages,
        tokenize=False,
        add_generation_prompt=True,
        tools=tools,
        open_thinking=open_thinking
    )
    inputs = tokenizer(context, return_tensors="pt", add_special_tokens=False).to(device)
    ...
    rollout_result = rollout_engine.rollout(...)
    new_ids = rollout_result.completion_ids[0].tolist()
    new_logps = rollout_result.per_token_logps[0].tolist()
    ...
    calls = parse_tool_calls(new_text)
    if not calls:
        break
    messages.append({"role": "assistant", "content": new_text})
    for call in calls:
        ...
        result = execute_tool(name, raw)
        result_str = (json.dumps(result, ensure_ascii=False) if result else '{"error": "tool not found"}')[:2048]
        messages.append({"role": "tool", "content": result_str})

这段代码说明:

  • 每一轮都会重新把 messages 渲染成 prompt。
  • 只有模型生成的 new_ids/new_logps 才是 policy action。
  • 如果这一轮没有 tool call,trajectory 结束。
  • 如果有 tool call,就把 assistant 文本和 tool result 追加到 messages,下一轮继续。
  • result_str[:2048] 防止工具结果太长撑爆后续 tokenizer。

源码证据:tool observation 不参与 policy loss

文件:trainer/train_agent.py:146-153

看它是为了理解:工具返回内容会进入训练序列,但 mask 为 0。

代码摘录:

observe_context = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=not unfinished, tools=tools, open_thinking=open_thinking)
observe_ids = tokenizer(observe_context, return_tensors="pt", add_special_tokens=False)["input_ids"][0].tolist()
current_len = len(prompt_ids) + len(response_ids)
obs_delta = observe_ids[current_len:]
response_ids.extend(obs_delta)
response_mask.extend([0] * len(obs_delta))
response_old_logps.extend([0.0] * len(obs_delta))

这段代码说明:

  • obs_delta 是本轮工具观察造成的新增上下文 token。
  • 它们会进入 response_ids,因为下一轮生成需要看到这些上下文。
  • 它们的 response_mask 是 0,因此不参与 policy loss。
  • 它们没有 rollout logprob,所以 response_old_logps 用 0.0 占位。

4.5 trajectory reward

源码证据 A:reward 输入

文件:trainer/train_agent.py:188-200

看它是为了理解:reward 函数拿到的是 prompt、completion、GT、tools 和多轮输出。

代码摘录:

def calculate_rewards(prompts, completions, gt_batch, tools_batch, num_gen, reward_model=None, device="cuda", turn_outputs_batch=None, unfinished_batch=None):
    rewards = torch.zeros(len(completions), device=device)
    for idx, response in enumerate(completions):
        reward, answer = 0.0, response
        sample_idx = idx // num_gen
        tools = tools_batch[sample_idx]
        turn_outputs = turn_outputs_batch[idx] if turn_outputs_batch is not None else [response]
        unfinished = unfinished_batch[idx] if unfinished_batch is not None else False
        turn_answers = [turn.split('</think>', 1)[-1].strip() if '</think>' in turn else turn.strip() for turn in turn_outputs]
        answer = turn_answers[-1] if turn_answers else response.strip()
        valid_names = {t['function']['name'] for t in tools} if tools else set()
        tool_calls = []
        for turn_answer in turn_answers: tool_calls.extend(parse_tool_calls(turn_answer))

这段代码说明:

  • idx // num_gen 把 flat trajectory 映射回原始样本。
  • reward 会看一条 trajectory 的所有 turn,不只看最后一段 completion。
  • valid_names 来自当前样本可用工具,不是全局工具全集。

源码证据 B:没有 tool call 时走普通回答 reward

文件:trainer/train_agent.py:201-218

看它是为了理解:Agent 训练仍然允许不用工具的回答。

代码摘录:

reward -= 0.5 * sum(abs(turn.count('<tool_call>') - turn.count('</tool_call>')) for turn in turn_answers)
if not tool_calls:
    reward += 0.5 if 5 <= len(response.strip()) <= 800 else -0.5
    if '</think>' in response:
        think, answer = response.split('</think>', 1)
        reward += 1.0 if 20 <= len(think.strip()) <= 300 else -0.5
        reward += 0.25 if response.count('</think>') == 1 else -0.25
        answer = answer.strip()
    if reward_model is not None:
        ...
        score = reward_model.get_score(messages, answer)
        reward += score
    reward -= rep_penalty(answer)
    rewards[idx] = max(min(reward, 3.0), -3.0)

这段代码说明:

  • 标签开闭数量不一致会扣分。
  • 没有工具调用时,reward 类似 SFT/RLAIF 的普通回答打分。
  • reward model 是可选叠加项。
  • 分数最终 clip 到 [-3.0, 3.0]。

源码证据 C:有 tool call 时看工具合法性和 GT 命中

文件:trainer/train_agent.py:219-239

看它是为了理解:工具轨迹的主要 reward 来自“调用是否对齐”和“最终答案是否命中 GT”。

代码摘录:

else:
    gt = gt_batch[sample_idx]
    valid_call_count = 0
    for tool_call in tool_calls:
        name, raw = tool_call.get("name", ""), tool_call.get("arguments", {})
        if isinstance(raw, str):
            try: raw = json.loads(raw)
            except: raw = {}
        check = CHECK_ARGS.get(name)
        valid_call_count += int(bool(name in valid_names and check and check(raw)))
    tool_gap = abs(valid_call_count - len(gt)) + max(0, len(tool_calls) - valid_call_count)
    reward += 0.5 if tool_gap == 0 else -0.5 * tool_gap

    final_text = "" if unfinished else (answer.split('</tool_call>')[-1] if '</tool_call>' in answer else answer)
    verified = validate_gt_in_text(final_text, gt) if gt else set()
    if gt: reward += 2.5 * len(verified) / len(gt)
    if unfinished: reward -= 0.5
    reward -= rep_penalty(final_text if final_text else answer)
    rewards[idx] = max(min(reward, 3.0), -3.0)

这段代码说明:

  • 工具名必须在当前样本可用工具里。
  • 参数必须通过 CHECK_ARGS。
  • tool_gap 同时惩罚工具数量不匹配和无效调用。
  • validate_gt_in_text 只检查最终文本,不检查 tool observation 本身。
  • 如果达到 max_turns 仍未完成,会扣 unfinished 分。

4.6 Agent RL 更新:GRPO/CISPO 没换

源码证据 A:rollout 后打包 token、mask、old logprob

文件:trainer/train_agent.py:242-273

看它是为了理解:多轮 trajectory 最后会被整理成训练张量。

代码摘录:

messages_batch = batch['messages']
tools_batch = batch['tools']
gt_batch = batch['gt']
...
completions, contexts, prompt_ids_batch, response_ids_batch, response_masks_batch, response_old_logps_batch, turn_outputs_batch, unfinished_batch = rollout_batch(...)
...
for p, r, m, old_lp in zip(prompt_ids_batch, response_ids_batch, response_masks_batch, response_old_logps_batch):
    ids = p + r
    mask = [0] * len(p) + m
    old_logps = [0.0] * max(len(p) - 1, 0) + old_lp
    ...
input_ids = torch.tensor(...)
full_response_masks = torch.tensor(...)
old_per_token_logps = torch.tensor(...)
...
rewards = calculate_rewards(...)

这段代码说明:

  • ids = prompt + response 是完整模型输入。
  • mask = prompt_mask_0 + response_mask 决定哪些 token 是 policy action。
  • old_logps 要和 input_ids[:, 1:] 对齐,所以 prompt 部分长度是 len(p)-1。
  • reward 在打包后才计算,仍然是每条 trajectory 一个标量。

源码证据 B:current/ref logprob、EOS mask、group advantage

文件:trainer/train_agent.py:275-318

看它是为了理解:Agent 训练复用第 24-25 课的 GRPO/CISPO 张量逻辑。

代码摘录:

res = model_unwrapped(input_ids, attention_mask=full_mask)
logits = res.logits[:, :-1, :]
per_token_logps = F.log_softmax(logits, dim=-1).gather(2, input_ids[:, 1:].unsqueeze(-1)).squeeze(-1)

with torch.no_grad():
    ref_per_token_logps = compute_per_token_logps(ref_model, input_ids, input_ids.size(1) - 1, attention_mask=full_mask)

completion_mask = full_response_masks[:, 1:]
...
completion_mask = completion_mask * (pos <= eos_idx.unsqueeze(1)).float()
token_counts = completion_mask.sum(dim=1)
...
grouped_rewards = rewards.view(-1, args.num_generations)
mean_r = grouped_rewards.mean(dim=1).repeat_interleave(args.num_generations)
std_r = grouped_rewards.std(dim=1, unbiased=False).repeat_interleave(args.num_generations)
advantages = (rewards - mean_r) / (std_r + 1e-4)

这段代码说明:

  • per_token_logps 是 current policy 对每个 next token 的 logprob。
  • ref_per_token_logps 来自冻结 reference model。
  • completion_mask 从 full_response_masks[:, 1:] 来,是为了和 next-token logprob 对齐。
  • group advantage 的 shape 仍然是 [B * num_generations]。

源码证据 C:GRPO/CISPO loss 和 rollout policy 同步

文件:trainer/train_agent.py:319-364

看它是为了理解:Agentic RL 的策略更新公式与第 24 课一致,只是 reward 和 mask 来自多轮工具轨迹。

代码摘录:

kl_div = ref_per_token_logps - per_token_logps
per_token_kl = torch.exp(kl_div) - kl_div - 1
ratio = torch.exp(per_token_logps - old_per_token_logps)
if args.loss_type == "cispo":
    clamped_ratio = torch.clamp(ratio, max=args.epsilon_high).detach()
    per_token_loss = -(clamped_ratio * advantages.unsqueeze(1) * per_token_logps - args.beta * per_token_kl)
else:
    clipped_ratio = torch.clamp(ratio, 1 - args.epsilon, 1 + args.epsilon)
    per_token_loss1 = ratio * advantages.unsqueeze(1)
    per_token_loss2 = clipped_ratio * advantages.unsqueeze(1)
    per_token_loss = -(torch.min(per_token_loss1, per_token_loss2) - args.beta * per_token_kl)
policy_loss = ...
loss.backward()
...
if step % args.save_interval == 0 or step == iters: rollout_engine.update_policy(model)

这段代码说明:

  • ratio 比较 current policy 和 rollout old policy。
  • per_token_kl 比较 current policy 和 frozen reference model。
  • completion_mask 保证只有 policy 生成 token 参与 loss。
  • 更新/保存后要同步 rollout engine,否则后续采样还会使用旧 policy。

4.7 推理侧:eval 和 API 都要解析 tool_call

源码证据 A:本地/API 评测循环

文件:scripts/eval_toolcall.py:177-199

看它是为了理解:推理时也要循环“模型输出 -> 执行工具 -> 添加 tool message”。

代码摘录:

def run_case(prompt, tools, args, model=None, tokenizer=None, client=None):
    messages = [{"role": "user", "content": prompt}]
    while True:
        if args.backend == 'local':
            content = generate(model, tokenizer, messages, tools, args)
            tool_calls = parse_tool_calls(content)
        else:
            content, tool_calls = chat_api(client, messages, tools, args, stream=bool(args.stream))
        if not tool_calls:
            break
        ...
        messages.append(...)
        for tc in tool_calls:
            result = execute_tool(...)
            messages.append({"role": "tool", "content": json.dumps(result, ensure_ascii=False)} ...)

这段代码说明:

  • eval 不是只看第一轮输出。
  • 只要模型继续生成 tool call,脚本就继续执行工具并扩展 messages。
  • 本地后端使用文本解析;API 后端使用 OpenAI 风格 tool_calls 字段。

源码证据 B:OpenAI API 服务端解析 tool_calls

文件:scripts/serve_openai_api.py:83-102

看它是为了理解:服务端把 MiniMind 原生文本协议转换成 OpenAI 兼容响应字段。

代码摘录:

def parse_response(text):
    reasoning_content = None
    think_match = re.search(r'<think>(.*?)</think>', text, re.DOTALL)
    ...
    tool_calls = []
    for i, m in enumerate(re.findall(r'<tool_call>(.*?)</tool_call>', text, re.DOTALL)):
        try:
            call = json.loads(m.strip())
            tool_calls.append({"id": f"call_{int(time.time())}_{i}", "type": "function", "function": {"name": call.get("name", ""), "arguments": json.dumps(call.get("arguments", {}), ensure_ascii=False)}})
        except Exception:
            pass
    if tool_calls:
        text = re.sub(r'<tool_call>.*?</tool_call>', '', text, flags=re.DOTALL)
    return text.strip(), reasoning_content, tool_calls or None

这段代码说明:

  • MiniMind 内部仍然生成 <tool_call> 文本。
  • API 层把它转成 message.tool_calls。
  • 如果有 tool call,正文里会移除 <tool_call>...</tool_call> 片段。
  • 这是部署兼容层,不是训练 loss 的一部分。

5. 本节必须会写 / 暂时不要求

必须会写:

1. 从一段模型输出中解析 <tool_call>...</tool_call>。
2. 判断 tool name 是否在当前样本可用工具列表里。
3. 判断 arguments 是否满足 CHECK_ARGS。
4. 执行一个 mock tool,得到 JSON observation。
5. 把 tool observation 作为 tool message 加回 messages。
6. 区分 policy token mask 和 tool observation mask。
7. 根据 tool_gap、GT 命中、unfinished、格式闭合计算 trajectory reward。

暂时不要求:

1. 实现生产级工具沙箱。
2. 接入真实天气、汇率、搜索 API。
3. 训练长期记忆、多任务规划、浏览器控制等完整 Agent 系统。
4. 手写 SGLang rollout server。
5. 重新实现 OpenAI API 服务端。
6. 在没有 agent_rl 数据和 reward model 的情况下跑完整 train_agent.py。

当前工作区缺少 dataset/agent_rl.jsonl 和默认 reward_model_path 指向的 reward model。第 26 课的验收实验因此选择验证解析和 reward 逻辑,不把完整训练作为本地必跑项。

6. 手写与实验模块

本节不新增 course/impl/core/ 下的核心 RL loss。第 24-25 课已经把 GRPO/CISPO、reward/logprob/KL 的核心张量逻辑放进了手写复现路线。

本节新增一个源码观察实验:

course/labs/trace_agent_tool_reward.py

它对齐的源码函数:

trainer/train_agent.py:76-82      parse_tool_calls
trainer/train_agent.py:84-95      execute_tool
trainer/train_agent.py:183-186    validate_gt_in_text
trainer/train_agent.py:188-239    calculate_rewards
trainer/train_agent.py:314-318    group reward normalization

实验检查的行为:

1. 合法 tool_call 能被解析。
2. 合法工具和参数能被 mock executor 执行。
3. 错误参数、未知工具、标签不闭合会改变 reward。
4. 最终答案命中 gt 会增加 reward。
5. 同 prompt 多条 completion 的 reward 可以转成 GRPO-style advantage。

刻意简化:

1. 不加载 tokenizer。
2. 不加载 MiniMind 模型。
3. 不采样真实 rollout。
4. 不初始化 reward model。
5. 不做反向传播。

这样做是为了单独验证 Agent reward 逻辑,避免被模型权重、数据下载、GPU、reward model 路径干扰。

7. 实验验证

实验:解析 tool_call 并计算 trajectory reward

这个实验验证:

模型输出文本
-> parse_tool_calls
-> execute_tool
-> validate_gt_in_text
-> calculate_rewards
-> group advantage

命令:

cd /home/sun/minimind
python course/labs/trace_agent_tool_reward.py

需要记录:

available_tools =
case=0 parsed_calls =
case=0 executed =
case=0 verified_gt =
case=0 reward =
case=1 reward =
case=2 reward =
case=3 reward =
advantages =
adv_mean =

输出怎么看:

case=0:
  合法 calculate_math 调用,最终文本命中 9472,reward 应该最高。

case=1:
  工具名合法,但 arguments 缺少 expression,CHECK_ARGS 不通过。

case=2:
  工具名不在当前可用工具里,即使最终文本包含 9472,也会有工具调用惩罚。

case=3:
  <tool_call> 没有闭合标签,parse_tool_calls 解析不到有效调用,格式惩罚会出现。

advantages:
  同一 prompt 的多条候选 completion 被放进一个 group。
  reward 高于组均值的 completion advantage > 0。
  reward 低于组均值的 completion advantage < 0。

可选观察:如果你已经准备好了 agent_rl.jsonl、reward model 和模型权重,可以再跑真实训练入口。

命令:

cd /home/sun/minimind/trainer
python train_agent.py --data_path ../dataset/agent_rl.jsonl --num_workers 0 --debug_mode --debug_interval 1

这个命令不是当前课程的默认验收命令,因为当前工作区没有 dataset/agent_rl.jsonl。

8. 阶段组装

到第 26 课为止,RL/Agent 阶段可以这样组合:

第 21-22 课:
  DPO 数据、chosen/rejected logprob、偏好 loss。

第 23 课:
  PPO rollout、old logprob、critic/value、GAE、policy update。

第 24 课:
  GRPO group advantage、CISPO loss 分支。

第 25 课:
  reward / old logprob / current logprob / ref logprob / KL penalty 的统一地图。

第 26 课:
  tool schema、tool_call、tool_response、多轮 rollout、trajectory reward。

教学版实现建议:

course/impl/core/grpo.py:
  继续作为 GRPO/CISPO loss 的核心实现。

course/labs/trace_agent_tool_reward.py:
  作为 Agent reward 和工具协议的观察实验。

原项目 trainer/train_agent.py:
  继续承担真实多轮工具环境、reward model、rollout engine、checkpoint、分布式训练。

为什么不急着手写完整 Agent 训练脚本:

Agentic RL 的难点不只在 loss,而在环境循环、工具执行、安全边界、数据准备、rollout engine 同步、reward 设计。
课程的核心目标是看懂 MiniMind 源码中这些边界如何连接,而不是复制所有工程外围。

阶段验收命令:

cd /home/sun/minimind
python course/labs/trace_grpo_cispo_loss.py
python course/labs/trace_reward_logprob_kl.py
python course/labs/trace_agent_tool_reward.py

Portfolio 记录可以写:

读懂并复现实验验证了 MiniMind Agentic RL 的工具调用链路:
tool schema -> <tool_call> JSON -> mock tool execution -> <tool_response> observation -> trajectory reward -> GRPO/CISPO update。
能够解释 policy token 与 tool observation token 的 mask 区别,以及为什么 Agent reward 是延迟的 trajectory-level signal。

9. 本节检查

  1. tool schema、tool_call、tool_response 分别是什么?
  2. 为什么 AgentRLDataset.__getitem__ 返回 messages/tools/gt,而不是 input_ids/labels?
  3. rollout_single 为什么在 tool observation 之后要重新 apply_chat_template?
  4. 为什么 obs_delta 要进入 response_ids,但 response_mask 是 0?
  5. tool_gap 同时惩罚了哪两类错误?
  6. validate_gt_in_text 检查的是 tool observation 还是最终文本?
  7. Agentic RL 里的 rewards.view(-1, num_generations) 为什么仍然要求同 prompt 的多条轨迹连续排列?

10. 下一课

第 27 课进入模型转换、OpenAI API 与 WebUI。

下一课要解决:

convert_model.py 如何把原生 MiniMind 权重转换成 transformers 格式;
serve_openai_api.py 如何包装成 OpenAI 兼容接口;
web_demo.py 如何把 tokenizer/template/generate 接成可交互 UI;
tool_calls / reasoning_content / open_thinking 如何在部署侧流转;
部署脚本复用了哪些训练时已经学过的模型与 tokenizer 约定。