# 第 26 课:Tool Use 与 Agentic RL 这一课讲 `trainer/train_agent.py`:MiniMind 如何把工具调用从“会输出一段 `` 文本”推进到“多轮 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) ## 0. 本节主线 MiniMind 的 Tool Use / Agentic RL 链路是: ```text 样本提供 messages + tools + gt -> chat_template 把 tools 渲染到 system prompt -> policy 生成 assistant response -> 从 response 中解析 {json} -> 执行 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 把模型文本中的 转成 OpenAI 风格 tool_calls ``` 一句话: ```text Tool calling 是模型生成结构化动作; Agentic RL 是把动作执行后的观察接回上下文,再用整条轨迹的结果训练模型。 ``` 本节最容易混的三个对象: ```text tool schema: 告诉模型有哪些工具、每个工具需要哪些参数。 tool_call: 模型生成的动作,格式是 {"name": "...", "arguments": {...}}。 tool_response: 环境执行动作后的观察,格式是 {...}。 ``` 第 25 课的 `reward / logprob / KL` 在这里没有换概念,只是 reward 从“单条回答分数”变成“多轮工具轨迹分数”。 ## 1. 本节要懂的 7 个原理 | 原理 | 要理解什么 | 源码位置 | |---|---|---| | 工具协议先由 tokenizer/template 固化 | ``、`` 是模型要学习和解析的文本协议 | `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 文本动作 | 训练端用正则找 `...`,再 `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]`。 ``` ## 2. 完整原理:从 tool call 到 trajectory reward ### 2.1 Tool calling 是结构化动作,不是普通文本答案 没有工具时,模型只要生成自然语言答案: ```text user -> assistant answer ``` 有工具时,模型要先生成一个动作: ```text assistant -> {"name":"calculate_math","arguments":{"expression":"256 * 37"}} ``` 这个动作不会直接结束对话。训练脚本会解析它,调用环境里的 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 {"name":"calculate_math","arguments":{"expression":"256 * 37"}} ``` 这个动作可能是合法的,但最终答案仍然可能错。 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。它只提供起始状态和校验目标。 ## 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 看服务端如何把文本中的 转成 OpenAI 风格 tool_calls 字段。 ``` ## 4. MiniMind 源码走读 ### 4.1 工具标签进入 tokenizer/template #### 源码证据 A:工具标签作为额外 token 文件:`trainer/train_tokenizer.py:35-38` 看它是为了理解:MiniMind 在训练 tokenizer 时显式加入工具调用和思考标签。 代码摘录: ```python additional_tokens_list = [ "", "", "", "", "", "" ] ``` 这段代码说明: - 工具协议不是临时字符串,它被纳入 tokenizer 训练配置。 - `` 和 `` 都是模型需要稳定生成/识别的结构标签。 - `` 与 tool calling 在同一套 chat template 里共存。 #### 源码证据 B:chat template 描述工具协议 文件:`model/tokenizer_config.json:333` 看它是为了理解:当 `tools` 存在时,template 会把工具 schema 放入 system prompt,并告诉模型按 `` 包裹 JSON。 代码摘录: ```jinja {%- if tools %} {{- '<|im_start|>system\n' }} {{- "# Tools\n\nYou may call one or more functions ..." }} {{- "function signatures within XML tags:\n" }} ... {{- "For each function call, return a json object ... within XML tags:" }} {%- endif %} ``` 这段代码说明: - 工具 schema 是 prompt 的一部分,不是模型内部结构。 - 模型输出 tool call 的约束来自 template 提示和 SFT/RL 训练。 - 后续解析器依赖 `...` 标签边界。 ### 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:解析 `` 文本 文件:`trainer/train_agent.py:76-95` 看它是为了理解:模型输出先是文本,训练脚本再用正则和 JSON 解析成动作。 代码摘录: ```python def parse_tool_calls(text): calls = [] for m in re.findall(r'(.*?)', 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('', 1)[-1].strip() if '' 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('') - turn.count('')) for turn in turn_answers) if not tool_calls: reward += 0.5 if 5 <= len(response.strip()) <= 800 else -0.5 if '' in response: think, answer = response.split('', 1) reward += 1.0 if 20 <= len(think.strip()) <= 300 else -0.5 reward += 0.25 if response.count('') == 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('')[-1] if '' 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'(.*?)', text, re.DOTALL) ... tool_calls = [] for i, m in enumerate(re.findall(r'(.*?)', 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'.*?', '', text, flags=re.DOTALL) return text.strip(), reasoning_content, tool_calls or None ``` 这段代码说明: - MiniMind 内部仍然生成 `` 文本。 - API 层把它转成 `message.tool_calls`。 - 如果有 tool call,正文里会移除 `...` 片段。 - 这是部署兼容层,不是训练 loss 的一部分。 ## 5. 本节必须会写 / 暂时不要求 必须会写: ```text 1. 从一段模型输出中解析 ...。 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 逻辑,不把完整训练作为本地必跑项。 ## 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 路径干扰。 ## 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: 没有闭合标签,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`。 ## 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 -> JSON -> mock tool execution -> 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。 下一课要解决: ```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 约定。 ```