minimind/course/26_tool_use_and_agentic_rl.md

1038 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 第 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. 本节主线](#l26-mainline)
- [1. 本节要懂的 7 个原理](#l26-principles)
- [2. 完整原理:从 tool call 到 trajectory reward](#l26-complete-principle)
- [3. 源码阅读顺序图](#l26-reading-order)
- [4. MiniMind 源码走读](#l26-source-walkthrough)
- [5. 本节必须会写 / 暂时不要求](#l26-must-write)
- [6. 手写与实验模块](#l26-handwrite)
- [7. 实验验证](#l26-experiment)
- [8. 阶段组装](#l26-stage-assembly)
- [9. 本节检查](#l26-check)
- [10. 下一课](#l26-next)
<a id="l26-mainline"></a>
## 0. 本节主线
MiniMind 的 Tool Use / Agentic RL 链路是:
```text
样本提供 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
```
一句话:
```text
Tool calling 是模型生成结构化动作;
Agentic RL 是把动作执行后的观察接回上下文,再用整条轨迹的结果训练模型。
```
本节最容易混的三个对象:
```text
tool schema:
告诉模型有哪些工具、每个工具需要哪些参数。
tool_call:
模型生成的动作,格式是 <tool_call>{"name": "...", "arguments": {...}}</tool_call>。
tool_response:
环境执行动作后的观察,格式是 <tool_response>{...}</tool_response>。
```
第 25 课的 `reward / logprob / KL` 在这里没有换概念,只是 reward 从“单条回答分数”变成“多轮工具轨迹分数”。
<a id="l26-principles"></a>
## 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` |
学完本节,你应该能解释这些变量:
```text
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]`。
```
<a id="l26-complete-principle"></a>
## 2. 完整原理:从 tool call 到 trajectory reward
### 2.1 Tool calling 是结构化动作,不是普通文本答案
没有工具时,模型只要生成自然语言答案:
```text
user -> assistant answer
```
有工具时,模型要先生成一个动作:
```text
assistant -> <tool_call>{"name":"calculate_math","arguments":{"expression":"256 * 37"}}</tool_call>
```
这个动作不会直接结束对话。训练脚本会解析它,调用环境里的 mock 工具,拿到观察:
```text
tool -> {"result": "9472"}
```
然后把观察重新放进上下文,让模型继续生成最终答案:
```text
assistant -> 256 乘以 37 等于 9472。
```
所以 Agentic RL 训练的对象不是单个 response,而是一条 trajectory:
```text
prompt
-> assistant action_1
-> tool observation_1
-> assistant action_2
-> tool observation_2
-> ...
-> assistant final_answer
```
### 2.2 为什么需要 response_mask
在一条 trajectory 里,不是所有 token 都是模型生成的。
```text
policy 生成的 assistant token:
应该参与 policy loss。
tool observation token:
是环境返回,不是 policy 动作,不应该参与 policy loss。
prompt / system / user token:
是条件上下文,也不应该参与 policy loss。
```
因此 `train_agent.py` 在 rollout 中维护:
```text
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 是延迟的
工具调用本身不一定立刻知道好坏。比如:
```text
<tool_call>{"name":"calculate_math","arguments":{"expression":"256 * 37"}}</tool_call>
```
这个动作可能是合法的,但最终答案仍然可能错。
MiniMind 的 Agent reward 因此按整条轨迹打分:
```text
R(trajectory)
= tool call 对齐分
+ final answer GT 命中分
+ thinking / 长度 / 格式分
+ reward model score
- 标签不闭合惩罚
- 未完成惩罚
- 重复惩罚
```
然后 GRPO/CISPO 把同一个 prompt 的多条 trajectory reward 做组内标准化:
```text
advantage = (reward - group_mean) / (group_std + 1e-4)
```
也就是说,模型不是学习“某个 token 本身是否好”,而是学习:
```text
在这个上下文中,生成这些动作 token 后,整条轨迹是否比同题其他轨迹更好。
```
### 2.4 Agentic RL 和 SFT Tool Calling 的区别
SFT Tool Calling 学的是数据里的参考轨迹:
```text
给定已有 messages/tools/tool_calls/tool_response/final answer
-> 让模型拟合 assistant 部分 token
```
Agentic RL 学的是在线采样轨迹:
```text
给定 messages/tools/gt
-> 让模型自己采样 tool_call / final answer
-> 环境执行工具
-> 根据最终轨迹打 reward
-> 用 RL 更新模型
```
所以 `AgentRLDataset` 不需要提前给出完整 assistant labels。它只提供起始状态和校验目标。
<a id="l26-reading-order"></a>
## 3. 源码阅读顺序图
建议按这个顺序读:
```text
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 字段。
```
<a id="l26-source-walkthrough"></a>
## 4. MiniMind 源码走读
### 4.1 工具标签进入 tokenizer/template
#### 源码证据 A:工具标签作为额外 token
文件:`trainer/train_tokenizer.py:35-38`
看它是为了理解:MiniMind 在训练 tokenizer 时显式加入工具调用和思考标签。
代码摘录:
```python
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。
代码摘录:
```jinja
{%- 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 的环境初始状态。
代码摘录:
```python
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。
代码摘录:
```json
{"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 列表。
代码摘录:
```python
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,不是外部真实服务。
代码摘录:
```python
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 解析成动作。
代码摘录:
```python
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` 一次,而是最多多轮“生成 -> 执行工具 -> 拼回上下文”。
代码摘录:
```python
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。
代码摘录:
```python
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 和多轮输出。
代码摘录:
```python
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 训练仍然允许不用工具的回答。
代码摘录:
```python
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”。
代码摘录:
```python
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 最后会被整理成训练张量。
代码摘录:
```python
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 张量逻辑。
代码摘录:
```python
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 来自多轮工具轨迹。
代码摘录:
```python
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”。
代码摘录:
```python
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 兼容响应字段。
代码摘录:
```python
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 的一部分。
<a id="l26-must-write"></a>
## 5. 本节必须会写 / 暂时不要求
必须会写:
```text
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。
```
暂时不要求:
```text
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 逻辑,不把完整训练作为本地必跑项。
<a id="l26-handwrite"></a>
## 6. 手写与实验模块
本节不新增 `course/impl/core/` 下的核心 RL loss。第 24-25 课已经把 `GRPO/CISPO`、`reward/logprob/KL` 的核心张量逻辑放进了手写复现路线。
本节新增一个源码观察实验:
```text
course/labs/trace_agent_tool_reward.py
```
它对齐的源码函数:
```text
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
```
实验检查的行为:
```text
1. 合法 tool_call 能被解析。
2. 合法工具和参数能被 mock executor 执行。
3. 错误参数、未知工具、标签不闭合会改变 reward。
4. 最终答案命中 gt 会增加 reward。
5. 同 prompt 多条 completion 的 reward 可以转成 GRPO-style advantage。
```
刻意简化:
```text
1. 不加载 tokenizer。
2. 不加载 MiniMind 模型。
3. 不采样真实 rollout。
4. 不初始化 reward model。
5. 不做反向传播。
```
这样做是为了单独验证 Agent reward 逻辑,避免被模型权重、数据下载、GPU、reward model 路径干扰。
<a id="l26-experiment"></a>
## 7. 实验验证
### 实验:解析 tool_call 并计算 trajectory reward
这个实验验证:
```text
模型输出文本
-> parse_tool_calls
-> execute_tool
-> validate_gt_in_text
-> calculate_rewards
-> group advantage
```
命令:
```bash
cd /home/sun/minimind
python course/labs/trace_agent_tool_reward.py
```
需要记录:
```text
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 =
```
输出怎么看:
```text
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 和模型权重,可以再跑真实训练入口。
命令:
```bash
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`。
<a id="l26-stage-assembly"></a>
## 8. 阶段组装
到第 26 课为止,RL/Agent 阶段可以这样组合:
```text
第 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。
```
教学版实现建议:
```text
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 训练脚本:
```text
Agentic RL 的难点不只在 loss,而在环境循环、工具执行、安全边界、数据准备、rollout engine 同步、reward 设计。
课程的核心目标是看懂 MiniMind 源码中这些边界如何连接,而不是复制所有工程外围。
```
阶段验收命令:
```bash
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 记录可以写:
```text
读懂并复现实验验证了 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。
```
<a id="l26-check"></a>
## 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 的多条轨迹连续排列?
<a id="l26-next"></a>
## 10. 下一课
第 27 课进入模型转换、OpenAI API 与 WebUI。
下一课要解决:
```text
convert_model.py 如何把原生 MiniMind 权重转换成 transformers 格式;
serve_openai_api.py 如何包装成 OpenAI 兼容接口;
web_demo.py 如何把 tokenizer/template/generate 接成可交互 UI;
tool_calls / reasoning_content / open_thinking 如何在部署侧流转;
部署脚本复用了哪些训练时已经学过的模型与 tokenizer 约定。
```