# 数学任务（LLM Judge） — Agentic RL 最佳实践

> **任务类型**: 大学水平数学推理（自由形式答案）  
> **基座模型**: Qwen3.5-9B  
> **训练任务 ID**: `ft-202606081254-07ae`  
> **训练任务名**: `cangyu_qwen3.5-9b_umath_full_python_interpreter_16k_llm_judge_test4_reward_question_provided_test2-7a5af1f7`  
> **Rollout 处理器**: `CalcXRolloutProcessor`（MCP + LangGraph，与 MATH 共用）  
> **Reward 处理器**: `UMathRewardProcessor`（format 0.2 + LLM judge accuracy 0.8）

---

## 1. 数据来源与准备

### 1.1 数据集简介

[U-MATH](https://huggingface.co/datasets/toloka/u-math) 由 Toloka 发布，包含 1100 道大学水平数学题。与 Hendrycks MATH（高中竞赛数学）的关键区别：

| 对比维度 | Hendrycks MATH | U-MATH |
|---------|----------------|--------|
| 难度层次 | 高中竞赛（Level 1-5） | 大学水平（微积分、线性代数、概率论等） |
| 答案格式 | 标准 `\boxed{}` 格式 | **自由形式文本**（多部分表格、`\lor` 连接的解集、积分常数 C、文字描述等） |
| 答案验证 | SymPy 精确匹配足够 | 需要 **LLM-as-judge** 支持 partial credit |
| 是否含图片 | 否 | 部分含图片（需过滤） |
| 数据规模 | ~7500 train / ~5000 test | 900 text-only（700 train / 200 test） |

### 1.2 数据文件

本 demo 已将训练和验证数据放入 `data/` 目录，可直接使用：

- `data/umath_train.jsonl` — 训练集（700 样本，~315 KB）
- `data/umath_test.jsonl` — 测试集（200 样本，~85 KB）

运行 `submit_job.py` 时，提交脚本会先检查脚本同级 `data/` 下是否存在上述两个目标文件；如果都存在则跳过下载，如果缺失则自动运行 `scripts/download_umath.py` 生成后再继续提交。

### 1.3 数据生成方式（可选）

如需从头生成数据，可手动运行 [`scripts/download_umath.py`](scripts/download_umath.py)（`main()` [L42](scripts/download_umath.py#L42)）：

```bash
python scripts/download_umath.py
```

脚本会从 HuggingFace 下载 `toloka/u-math`（1100 题），过滤掉含图片的题目，保留 900 道 text-only，按 700/200 拆分训练/测试集，并输出：

- `data/umath_train.jsonl`
- `data/umath_test.jsonl`

### 1.4 JSONL 数据 Schema

```json
{
  "messages": [
    {"content": "Find the limit of the sequence a_n = (n^2 + 3n) / (2n^2 - 1) as n -> infinity.", "role": "user"}
  ],
  "rollout_extra": {
    "solution": "1/2",
    "dataset": "umath",
    "subject": "Calculus"
  }
}
```

| 字段 | 说明 |
|------|------|
| `messages` | 对话格式，仅包含一条 user 消息（题目） |
| `rollout_extra.solution` | 标准答案（自由形式文本，可能是多部分的复杂表达式） |
| `rollout_extra.dataset` | 数据集标识 `"umath"` |
| `rollout_extra.subject` | 学科分类（Calculus, Linear Algebra, Probability 等） |

### 1.5 与 MATH 的数据差异

U-MATH 数据量较小（700 vs ~3500），因此训练 epoch 数需要相应增加（3 vs 1）以获得足够的训练信号。

---

## 2. 训练参数配置

> 配置文件：[`submit_job.py`](submit_job.py)

### 2.1 核心超参数

> 源码位置：[`submit_job.py` L139-152](submit_job.py#L139-L152)

```python
hyper_parameters={
    "n_epochs": 3,
    "learning_rate": 1e-6,
    "max_prompt_length": 8192,
    "max_length": 16384,
    "batch_size": 128,
    "eval_steps": 2,
    "nnodes_training": 2,
    "nnodes_rollout": 1,
    "save_strategy": "steps",
    "save_steps": save_steps,
    "save_total_limit": 3,
    "lr_scheduler_type": "cosine",
}
```

### 2.2 与 MATH 配置的关键差异

| 参数 | MATH | U-MATH | 差异原因 |
|------|------|--------|---------|
| `n_epochs` | 1 | **3** | U-MATH 训练集仅 700 样本，需更多 epoch 充分学习 |

其他超参数（learning_rate、max_length、batch_size 等）与 MATH 保持一致，因为推理复杂度类似。

### 2.3 实际训练步数计算

```
total_samples = 700
steps_per_epoch = 700 // 128 = 5
total_steps = 5 × 3 = 15 (实际执行 16 步)
```

### 2.4 资源配置

> 源码位置：[`submit_job.py` L153-157](submit_job.py#L153-L157)

```python
resources={
    "charge_type": "mtu_postpaid",
    "mtu_spec_code": "MTU4",
    "mtu_capacity": 24,
}
```

---

## 3. 工具设计配置与跟踪

U-MATH 与 MATH 使用完全相同的 Rollout 处理器和工具，即 [`CalcXRolloutProcessor`](functions/rollout/math_rollout.py#L126)（[`functions/rollout/math_rollout.py` L126](functions/rollout/math_rollout.py#L126)）+ `python_interpreter`。

### 3.1 架构图

```mermaid
flowchart LR
    subgraph RolloutProcessor["CalcXRolloutProcessor"]
        LLM["策略模型<br/>Qwen3.5-9B"]
        Agent["LangGraph<br/>Agent 节点"]
        Tools["LangGraph<br/>Tools 节点"]
    end

    subgraph MCPServer["MCP Server :10086"]
        PI["python_interpreter"]
        CI["code_interpreter.py<br/>execute_python()"]
    end

    Agent -->|"tool_calls"| Tools
    Tools -->|"SSE"| PI
    PI --> CI
    CI -->|"stdout+stderr"| Tools
    Tools -->|"tool results"| Agent
    Agent -->|"final answer"| Output["RolloutOutput"]
```

### 3.2 工具配置（与 MATH 一致）

> 底层执行器详情见 MATH demo 的 [3.2 节](../math/math_demo.md#32-工具python_interpreter)

| 配置项 | 值 | 说明 |
|--------|-----|------|
| 传输协议 | MCP SSE | 通过 `localhost:10086` 暴露 |
| 执行超时 | 30s | 每次工具调用的时间上限 |
| 输出上限 | 8000 字符 | 超出截断，附加 `...(output truncated)...` |
| 预装包 | `math`, `numpy`, `collections`, `itertools`, `fractions`, `decimal`, `random` 等 | `scipy`/`sympy`/`pandas` **不可用** |
| 前处理 | Markdown 围栏剥离 + 末尾裸表达式自动 print | 兼容 LLM 输出格式，模拟 REPL 行为 |
| max_turns | 10 | Agent 最大交互轮数 |

### 3.3 System Prompt（与 MATH 一致）

> 源码位置：[`functions/rollout/math_rollout.py` L114-122](functions/rollout/math_rollout.py#L114-L122)（`DEFAULT_SYSTEM_PROMPT`）

```
You are a math assistant. You have one tool:
- `python_interpreter`: run a Python script (numpy available as np;
  no scipy/sympy) for computation, equation solving, enumeration,
  modular arithmetic, etc.

Always verify your calculations by calling the tool instead of computing
in your head. Think step by step, then put your final answer in \boxed{}.
```

### 3.4 Rollout Metrics 自定义追踪

> 源码位置：[`functions/rollout/math_rollout.py` L245-262](functions/rollout/math_rollout.py#L245-L262)（`_execute_tools` 计时），[L372-384](functions/rollout/math_rollout.py#L372-L384)（`rollout_metrics` 汇总）

U-MATH 与 MATH 共用同一个 `CalcXRolloutProcessor`，工具指标追踪方式完全一致。

Rollout 处理器通过在 `_execute_tools` 中记录每次工具调用耗时，最终汇总写入 `AgentOutput.rollout_metrics`，框架自动聚合到训练面板：

```python
# _execute_tools 中：记录每次工具调用耗时
t0 = time.time()
result = await tool_node.ainvoke(state, config)
durations.append(time.time() - t0)

# rollout 结束后：汇总写入 rollout_metrics
rollout_metrics={
    "latency": time.time() - start_time,
    "tool_call_count": len(tool_call_durations),
    "tool_call_total_time": sum(durations),
    "tool_call_avg_time": avg(durations),
}
```

**自定义扩展**：只需在 `rollout_metrics` 字典中添加新的 key-value，框架会自动聚合为 `trace _ rollout_metrics _ {key} _ avg/sum` 等指标，无需其他配置。

**U-MATH 实际追踪数据**：

| 指标 | 初始值 | 最终值 | 变化趋势 |
|------|--------|--------|---------|
| tool_call_count mean | 1.03 | 1.75 | 工具调用频率增加 ~70% |
| latency mean | 133s | 68s | 推理延迟随训练降低 |
| tool_call_avg_time mean | ~0.1s | ~0.3s | 单次工具调用耗时略增（计算更复杂） |
| num_llm_calls mean | 2.01 | 2.74 | 多轮交互次数增加 |

---

## 4. 奖励设计配置与跟踪

> 实现文件：[`functions/reward/umath_reward.py`](functions/reward/umath_reward.py)（`UMathRewardProcessor` [L323](functions/reward/umath_reward.py#L323)）
> LLM judge：[`_llm_judge()` L201](functions/reward/umath_reward.py#L201)，[`_load_judge_config()` L104](functions/reward/umath_reward.py#L104)，[`_parse_judge_response()` L146](functions/reward/umath_reward.py#L146)

### 4.1 为什么不能复用 MATH 的 Reward？

U-MATH 的答案是自由形式文本，SymPy 精确匹配对以下类型无法正确判断：
- 多部分答案（如"函数为 f(x)=x^2，定义域为 (-∞, +∞)"）
- 文字描述（如"Saddle point"、"None"）
- 符号集合（如用 `\lor` 连接的解集）
- 含积分常数 C 的不定积分

因此 U-MATH 引入了 **LLM-as-judge** 方案，支持 **partial credit**（部分正确给部分分）。

### 4.2 奖励公式

$$
R_{\text{total}} = 0.2 \times R_{\text{format}} + 0.8 \times R_{\text{accuracy}}
$$

### 4.3 子奖励详解

#### Format 子奖励 (权重 0.2)

与 MATH 完全一致：
- 检测 `\boxed{}` 格式
- 有 → +1.0，无 → -1.0

#### Accuracy 子奖励 (权重 0.8)

支持两种评判策略（通过 `rollout_extra["judge_strategy"]` 控制）：

| 策略 | 说明 | 适用场景 |
|------|------|---------|
| `llm_only` (默认) | 使用 LLM-as-judge 评判，支持 0.0~1.0 连续分数 | 所有 U-MATH 样本 |
| `sympy_only` | 仅使用 SymPy 精确匹配，0 或 1 | 显式覆盖（如简单数值答案） |

**LLM Judge 跳过规则**：若模型输出中未找到 `\boxed{}`，直接返回 accuracy=0.0，不调用 LLM judge（节省资源）。

### 4.4 LLM Judge Prompt

> 源码位置：[`functions/reward/umath_reward.py` L49-84](functions/reward/umath_reward.py#L49-L84)（`_JUDGE_PROMPT`）

```
You are a math grading assistant. Given the original problem, compare the
student's answer with the reference answer and decide how much of it is
mathematically correct.

**Original problem:**
{question}

**Reference answer:**
{golden_answer}

**Student's full response (may contain reasoning):**
{model_response}

Scoring rules:
  - 1.0  : fully correct (mathematically equivalent to the reference)
  - 0.0  : fully incorrect or no answer
  - 0 < score < 1.0 : partial credit, ONLY when the reference contains
multiple distinct parts (e.g. several sub-questions, multiple items in a
list, a function plus its domain, or a tuple of independent values).
Set score = (number of parts the student got right) / (total parts).
Single-part questions only get 0.0 or 1.0.

First think briefly about whether the answers match, then output your decision.
Reply with ONLY a single JSON object, no markdown fences, no extra prose. The
"reason" field MUST come before the "score" field:
{"reason": "<one short sentence>", "score": <number between 0.0 and 1.0>}
```

**Prompt 设计要点**：
- 要求 JSON 格式输出，便于结构化解析
- `reason` 字段在 `score` 之前，让模型先推理再给分（Chain-of-Thought）
- 明确 partial credit 的触发条件，避免滥用
- 模型回复截取最后 3000 字符送入 judge，控制 token 消耗

### 4.5 LLM Judge 配置

> 源码位置：[`functions/reward/umath_reward.py` L104-143](functions/reward/umath_reward.py#L104-L143)（`_load_judge_config`）

| 配置项 | 值 | 说明 |
|--------|-----|------|
| Judge 模型 | 通过 `llm_api_config.json` 或环境变量配置 | 推荐使用 qwen-plus / qwen-max |
| 温度 | 0.0 | 确保评判一致性 |
| 最大输出 | 200 tokens | JSON 格式评判结果不需要太长 |
| 超时 | 60s | 单次 judge 调用超时 |
| 重试 | 2 次 | 仅对 timeout 重试 |
| 输出格式 | `response_format: json_object` | 强制 JSON 输出 |

配置优先级：
1. 环境变量 `JUDGE_API_KEY` / `JUDGE_BASE_URL` / `JUDGE_MODEL`（推荐在生产环境使用）
2. 回退到 `llm_api_config.json` 配置文件

**示例：环境变量方式**（推荐，无需文件）：

```bash
export JUDGE_API_KEY="sk-your-api-key"
export JUDGE_BASE_URL="https://dashscope.aliyuncs.com/compatible-mode/v1"
export JUDGE_MODEL="qwen-plus"
```

**示例：`llm_api_config.json` 配置文件**（放在 `extra_demos/umath/` 目录，或用 `JUDGE_*` 环境变量替代）：

```json
{
  "key": "sk-your-api-key",
  "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
  "supported_models": ["qwen-plus", "qwen-max"]
}
```

> 当两种方式同时存在时，环境变量中已设置的字段优先；未设置的字段回退到配置文件。
> 配置文件中 `supported_models` 列表的**最后一个**模型会被用作 judge 模型。

### 4.6 LLM Judge 结果回写与 Debug

> 源码位置：[`functions/reward/umath_reward.py` L345-452](functions/reward/umath_reward.py#L345-L452)（accuracy 子奖励中的 judge 调用与结果写回）
> 采样日志：[L88-100](functions/reward/umath_reward.py#L88-L100)（`_JUDGE_LOG_SAMPLE_RATE`）

LLM judge 的调用结果通过两条路径写回框架，便于调试：

**路径 1：`reward_metrics`（数值指标）**

accuracy 子奖励中写入以下指标到 `RewardOutput.reward.reward_metrics`：

```python
reward_metrics = {
    "judge_latency_s": judge_info["judge_latency_s"],  # judge 调用延迟
    "judge_success": judge_info["judge_success"],        # 1.0=成功 / 0.0=失败
    "method_is_llm": 1.0,                                # 标记使用了 LLM judge
    "partial_credit": 1.0 if 0 < score < 1 else 0.0,    # 是否给了部分分
    f"subject/{subject}": score,                          # 按学科分类准确率
}
```

aggregate 函数最终向训练面板转发以下 5 个指标（可在训练面板以 `trace _ reward_metrics _ reward-1 _ {key} _ avg` 查看）：

| 面板指标 | 说明 |
|---------|------|
| `accuracy` | 正确性分（0~1） |
| `format` | 格式分（-1 或 1） |
| `method_is_judge` / `method_is_llm` | 是否使用了 LLM judge |
| `total` | 加权总分 |

其余指标（`judge_latency_s`、`judge_success`、`partial_credit`、`subject/*`、`strategy/*`）记录在子奖励的 RewardOutput 中，可通过采样日志（路径 3）排查。

**路径 2：`judge_extra`（结构化详情，附加到 RewardOutput）**

每次 LLM judge 调用后，完整的 judge 上下文写入 `RewardOutput.judge_extra`：

```python
judge_extra = {
    "strategy": "llm_only",          # 使用的策略
    "method": "llm_only",            # 实际评估方法
    "final_score": 0.8,              # 最终得分
    "model": "qwen-plus",            # judge 模型名
    "raw_response": '{"reason": "...", "score": 0.8}',  # judge 原始返回
    "reason": "3 of 4 parts correct",  # judge 给出的理由
    "error": None,                   # 错误信息（成功时为 None）
    "prompt": "You are a math...",   # 发送给 judge 的 prompt（截取前 1000 字符）
    "golden_preview": "answer...",   # 标准答案预览（截取前 500 字符）
    "model_response_for_judge":      # 模型回复（截取后 3000 字符送入 judge）
        "The answer is \\boxed{42}",
}
```

该字段记录在 REWARD span 的 `output.value` 中，可在 trajectory UI 的 Reward 区域查看。

**路径 3：采样日志（Worker stdout，便于实时排查）**

由于 trajectory UI 不一定展示 `judge_extra`，代码中实现了**采样日志机制**：按 `UMATH_JUDGE_LOG_SAMPLE_RATE`（默认 5%）概率，将 judge 详情输出到 Worker 日志：

```bash
# 搜索 judge 采样日志
grep "[JUDGE_SAMPLE]" worker.log
```

日志格式：
```
[UMathReward][JUDGE_SAMPLE] rollout_id=xxx strategy=llm_only method=llm_only score=0.8
  model=qwen-plus reason='3 of 4 parts correct' raw_response='{"reason":...}' error=None
  golden=The answer is...
  model_response_for_judge=The answer is \boxed{42}...
```

调整采样率：
```bash
export UMATH_JUDGE_LOG_SAMPLE_RATE=1.0  # 设为 1.0 可记录所有 judge 调用（仅调试时使用）
```

**Debug 实用技巧**：

| 场景 | 排查方法 |
|------|---------|
| Judge 全部失败 | 面板 `method_is_llm` 为 0；日志搜索 `error=` 查看具体错误（通常是配置 / 网络问题） |
| Judge 延迟过高 | 日志搜索 `judge_latency_s` 查看具体数值；考虑换用更快的 judge 模型 |
| 评分偏差 | 设 `UMATH_JUDGE_LOG_SAMPLE_RATE=1.0`，比对 `raw_response` 中的 `reason` 与 `golden_preview` |
| Partial credit 异常 | 日志搜索 `score=0.5` 等非 0/1 分数，检查 judge 是否对单部分答案误给部分分 |
| 格式解析失败 | `_parse_judge_response` 有三级回退：JSON `score` → JSON `verdict` → 文本子串匹配 |

### 4.7 Reward Metrics 追踪汇总

**训练面板可见**（aggregate 级别）：

| 指标 | 说明 |
|------|------|
| `format` | 格式分（-1 或 1） |
| `accuracy` | 正确性分（0~1，支持 partial credit） |
| `total` | 加权总分 |
| `method_is_judge` / `method_is_llm` | 是否使用了 LLM judge（用于统计 judge 调用比例） |

**子奖励级别**（通过采样日志排查）：

| 指标 | 说明 |
|------|------|
| `judge_latency_s` | LLM judge 调用延迟（秒） |
| `judge_success` | LLM judge 是否调用成功（1.0 / 0.0） |
| `partial_credit` | 是否给了部分分（0 或 1） |
| `subject/{subject}` | 按学科分类的准确率（如 `subject/Calculus`） |
| `no_boxed_skip` | 无 `\boxed{}` 时跳过 judge（1.0 / 0.0） |
| `strategy/{strategy}` | 使用的 judge 策略（`strategy/llm_only` 或 `strategy/sympy_only`） |

### 4.8 奖励验证流程图

```mermaid
flowchart TB
    Input["模型输出"] --> BoxedCheck["extract_answer_by_format<br/>检测 boxed 格式"]
    BoxedCheck -->|"有 boxed"| FormatPos["format = +1.0"]
    BoxedCheck -->|"无 boxed"| FormatNeg["format = -1.0<br/>accuracy = 0.0<br/>跳过 judge"]
    FormatPos --> Strategy{"judge_strategy?"}
    Strategy -->|"sympy_only"| Sympy["SymPy 精确匹配<br/>0.0 / 1.0"]
    Strategy -->|"llm_only (默认)"| Judge["LLM Judge 调用<br/>0.0 ~ 1.0"]
    Sympy --> Aggregate["total = 0.2×format + 0.8×accuracy"]
    Judge --> Aggregate
    FormatNeg --> Aggregate
```

---

## 5. 部署推理验证

> 验证脚本：[`test/test_deployed_umath.py`](test/test_deployed_umath.py)（位于 `test/`）

### 5.1 前置条件

验证前需要将训练好的 checkpoint 部署为推理服务，获取**模型 ID**（model code，如 `qwen3.5-9b-xxxx`），用于传入验证脚本的 `--model` 参数。

### 5.2 验证流程

部署后的模型通过以下流程验证：

1. **Rollout**：复用 `CalcXRolloutProcessor` 对 U-MATH 测试集执行多轮 agent 推理
2. **Reward 评估**：使用 format(0.2) + LLM judge accuracy(0.8) 评分
3. **结果导出**：每样本的轨迹、分数、judge 结果导出为 JSONL

### 5.3 使用方式

```bash
# Debug 模式：5 个样本，输出完整轨迹
python test/test_deployed_umath.py

# 自定义样本数
python test/test_deployed_umath.py --loglevel INFO --num 10

# Full eval：全量评估，导出轨迹
python test/test_deployed_umath.py --all

# 指定输出路径
python test/test_deployed_umath.py --all --output results/my_run.jsonl

# 断点续跑（跳过已完成样本）
python test/test_deployed_umath.py --all --resume results/my_run.jsonl

# 并发评估（加速 full eval）
python test/test_deployed_umath.py --all --concurrency 4

# 指定部署模型（替换为上一步获取的模型 ID）
python test/test_deployed_umath.py --model <your-deployed-model-code>
```

### 5.4 命令行参数

| 参数 | 默认值 | 说明 |
|------|--------|------|
| `--loglevel` | DEBUG | 日志级别（DEBUG 输出完整轨迹） |
| `--num` | 5 | 评估样本数（仅 debug 模式） |
| `--model` | 脚本内 MODEL_CODE | 部署模型的模型码 |
| `--all` | False | 全量评估模式 |
| `--output` | 自动生成 | 输出 JSONL 路径 |
| `--resume` | None | 从已有结果文件断点续跑 |
| `--concurrency` | 1 | 并发 rollout 数（配合 --all 使用） |
| `--timeout` | 180 | 单样本超时时间（秒） |

### 5.5 输出示例

**Debug 模式**输出包含完整的多轮对话轨迹：

```
================================================================================
[Sample 1/5] subject=Calculus
================================================================================
Question: Find the limit of ...
Golden:   1/2
┌─── Trajectory ──────────────────────────────────────
│ [0] SYSTEM: You are a math assistant...
│ [1] USER: Find the limit of ...
│ [2] ASSISTANT (tool_call):
│     ↳ python_interpreter(
│       import numpy as np
│       ...
│     )
│ [3] TOOL (python_interpreter): 0.5
│ [4] ASSISTANT: The limit is \boxed{1/2}
└──────────────────────────────────────────────────────
Reward: total=1.000 (format=1.0, accuracy=1.000)
```

**Full eval 模式**输出进度条和运行统计：

```
Evaluating: |████████████████| 200/200 [12:34<00:00, acc=0.721]

SUMMARY  (model=qwen3.5-9b-xxxx, n=200)
Avg total reward:  0.721
Avg accuracy:      0.685
Format rate:       95.0%
Avg latency:       8.32s
Avg tool calls:    2.1
Avg turns:         5.3
```

### 5.6 结果文件

导出的 JSONL 文件每行包含：

```json
{
  "index": 0,
  "subject": "Calculus",
  "question": "...",
  "golden": "1/2",
  "extracted_answer": "\\frac{1}{2}",
  "response": "...(完整最终回复)...",
  "messages": [...(完整多轮轨迹)...],
  "latency": 5.23,
  "tool_calls": 2,
  "turns": 3,
  "format_score": 1.0,
  "accuracy_score": 1.0,
  "total_reward": 1.0,
  "judge_reason": "The student's answer matches the reference.",
  "status": "success"
}
```

---

## 6. 最终效果

> **训练任务 ID**: `ft-202606081254-07ae`  
> **训练任务名**: `cangyu_qwen3.5-9b_umath_full_python_interpreter_16k_llm_judge_test4_reward_question_provided_test2`

### 6.1 关键指标汇总

| 指标 | 初始值 (Step 1) | 最终值 (Step 16) | 提升幅度 |
|------|-----------------|-----------------|---------|
| **训练 reward (total mean)** | 0.301 | **0.944** | +213% |
| **训练 accuracy mean** | 0.412 | **0.936** | +127% |
| **训练 format mean** | -0.146 | **0.979** | 从负分到接近满分（boxed 格式率大幅提升） |
| **验证 reward mean@1** | 0.436 (step 0) | **0.921** (step 16) | +111% |
| **工具调用次数 mean** | 1.03 | **1.75** | +70%，工具使用频率显著增加 |
| **LLM judge 使用率** | 42.7% | **98.9%** | 随 boxed 格式率提升而增长（无 boxed 时跳过 judge） |

### 6.2 训练曲线

#### 训练 Reward

| Step | total reward mean | accuracy mean | format mean |
|------|------------------|---------------|-------------|
| 1 | 0.301 | 0.412 | -0.146 |
| 2 | 0.464 | 0.552 | 0.111 |
| 3 | 0.428 | 0.522 | 0.054 |
| 4 | 0.505 | 0.583 | 0.190 |
| 5 | 0.659 | 0.708 | 0.462 |
| 6 | 0.829 | 0.844 | 0.767 |
| 7 | 0.884 | 0.891 | 0.857 |
| 8 | 0.881 | 0.884 | 0.869 |
| 9 | 0.913 | 0.906 | 0.942 |
| 10 | 0.867 | 0.874 | 0.842 |
| 11 | 0.954 | 0.949 | 0.973 |
| 12 | 0.931 | 0.927 | 0.947 |
| 13 | 0.940 | 0.932 | 0.969 |
| 14 | 0.916 | 0.914 | 0.923 |
| 15 | 0.950 | 0.945 | 0.969 |
| 16 | 0.944 | 0.936 | 0.979 |

![U-MATH 训练 Reward 曲线](charts/train_reward.png)

#### 验证 Reward

| Step | validation reward mean@1 |
|------|--------------------------|
| 0 (baseline) | 0.436 |
| 2 | 0.331 (初始下降) |
| 4 | 0.419 |
| 6 | 0.757 |
| 8 | 0.923 |
| 10 | 0.907 |
| 12 | 0.911 |
| 14 | 0.918 |
| 16 | **0.921** |

![U-MATH 验证 Reward 曲线](charts/val_reward.png)

**注意**：Step 2 出现了短暂的验证 reward 下降（0.436 → 0.331），这是 RL 训练初期常见现象（策略尚未稳定），之后迅速恢复并持续上升。

### 6.3 工具使用趋势

| Step | tool_call_count mean | num_llm_calls mean | num_turns mean |
|------|---------------------|--------------------|----------------|
| 1 | 1.03 | 2.01 | 4.6 |
| 4 | 1.19 | 2.11 | — |
| 8 | 2.00 | 2.99 | — |
| 12 | 1.76 | 2.76 | — |
| 16 | 1.75 | 2.74 | 6.85 |

训练过程中 `python_interpreter` 工具调用频率显著提升，tool_call_count 从 ~1 增长到 ~1.75，说明模型越来越倾向于通过工具验证计算结果。

![U-MATH 工具调用趋势](charts/tool_calls.png)

### 6.4 训练效率指标

| 指标 | 初始值 | 最终值 | 说明 |
|------|--------|--------|------|
| 吞吐量 (tok/s) | 85 | 214 (峰值 313) | 随训练进行逐步提升 |
| 单步耗时 (s) | 879 | 1319 | 因轨迹变长而略增 |
| Trainer idle ratio | 76% | 59% | GPU 利用率逐步改善 |
| Agent 成功率 | 100% | ~99.2% | 极少数样本 rollout 失败 |
| 废弃样本数 | 0 | 0 | 无样本被丢弃 |

![U-MATH 响应长度与对话轮数](charts/response_turns.png)

### 6.5 训练稳定性指标

| 指标 | 趋势 | 说明 |
|------|------|------|
| KL loss | 0.0018 → 0.017 | 增长平缓，策略未出现大幅偏移 |
| KL coefficient | 恒定 0.001 | 固定 KL 惩罚系数 |
| Learning rate | 9.9e-7 → 0 (cosine) | 余弦衰减到 0 |
| Gradient norm | 0.807 → 0.334 | 梯度逐步减小，训练趋于收敛 |
| PG clip fraction | 0.0022 → 0.00005 | 策略更新幅度逐步缩小 |
| Response aborted ratio | 0% | 全程无截断 |
| Prompt clip ratio | 0% → 1.1% | 极少数超长轨迹触发 prompt 截断 |

![U-MATH 学习率与 KL Loss](charts/lr_kl.png)

### 6.6 典型案例（训练前 vs 训练后轨迹对比）

以下案例均来自**验证集**，展示模型经过 Agentic RL 训练后获得的不同能力提升。每个案例给出**完整轨迹**，`[tool]` 返回内容在 CSV 中记录为 `[object Object]`（平台侧未序列化），此处以 `[tool]: (执行结果)` 标注。

> **轨迹来源**：`trajectory-step6_val.csv` / `trajectory-step16_1_val.csv`

---

#### 案例 1：三角函数极值 — 有 boxed 但答案错误 → 训练后修正

> **改进维度**：推理质量（换用更可靠的代数分解策略）
>
> **题目**：求 $r(x) = 3\sin(x) + \sin(3x)$ 在 $[0, \frac{3\pi}{2}]$ 上的最大值和最小值。

<details>
<summary><b>▶ Step 6 轨迹（reward = 0.6, tools = 2, turns = 3）— 答案错误 ❌</b></summary>

```
┌─ [SYSTEM] ────────────────────────────────────────────────────────────────────
│ You are a math assistant. You have one tool:
│ - `python_interpreter`: run a Python script (numpy available as np;
│   no scipy/sympy) for computation, equation solving, enumeration, ...
│ Always verify your calculations by calling the tool instead of computing
│ in your head. Think step by step, then put your final answer in \boxed{}.
└───────────────────────────────────────────────────────────────────────────────

┌─ [USER] ──────────────────────────────────────────────────────────────────────
│ Find the maximum and minimum values of the function
│ r = 3·sin(x) + sin(3·x) in the closed interval [0, 3π/2].
└───────────────────────────────────────────────────────────────────────────────

┌─ [ASSISTANT] → 🔧 python_interpreter (第 1 次) ───────────────────────────────
│ import numpy as np
│ import sympy as sp
│
│ x = sp.symbols('x')
│ r = 3 * sp.sin(x) + sp.sin(3 * x)
│ r_prime = sp.diff(r, x)
│ solutions = sp.solve(r_prime, x)
│
│ # 筛选 [0, 3π/2] 内的临界点
│ valid_solutions = [sol for sol in solutions
│                    if sol.is_real and 0 <= float(sol.evalf()) <= 3*sp.pi/2]
│
│ # 在临界点和端点处求值
│ points_to_check = [0, 3*sp.pi/2] + valid_solutions
│ for point in points_to_check:
│     val = r.subs(x, point).evalf()
│     print(f"Point: {point}, Value: {val}")
└───────────────────────────────────────────────────────────────────────────────

┌─ [TOOL] → (执行结果) ─────────────────────────────────────────────────────────
│ Point: 0, Value: 0
│ Point: 3*pi/2, Value: -2.00000000000000
│ Point: pi/4, Value: 2.82842712474619
│ Point: 3*pi/4, Value: 2.82842712474619
│ Point: pi/2, Value: 2.00000000000000
└───────────────────────────────────────────────────────────────────────────────

┌─ [ASSISTANT] → 🔧 python_interpreter (第 2 次) ───────────────────────────────
│ import numpy as np
│ import sympy as sp
│
│ x = sp.symbols('x')
│ r = 3 * sp.sin(x) + sp.sin(3 * x)
│ r_prime = sp.diff(r, x)
│ solutions = sp.solve(r_prime, x)
│
│ valid_solutions = [sol for sol in solutions
│                    if sol.is_real and 0 <= float(sol.evalf()) <= 3*sp.pi/2]
│
│ points_to_check = [0, 3*sp.pi/2] + valid_solutions
│ values = {}
│ for point in points_to_check:
│     val = r.subs(x, point).evalf()
│     values[point] = val
│     print(f"Point: {point}, Value: {val}")
│
│ max_val = max(values.values())
│ min_val = min(values.values())
│ print(f"Maximum: {max_val}, Minimum: {min_val}")
└───────────────────────────────────────────────────────────────────────────────

┌─ [TOOL] → (执行结果) ─────────────────────────────────────────────────────────
│ Point: 0, Value: 0
│ Point: 3*pi/2, Value: -2.00000000000000
│ Point: pi/4, Value: 2.82842712474619
│ Point: 3*pi/4, Value: 2.82842712474619
│ Point: pi/2, Value: 2.00000000000000
│ Maximum: 2.82842712474619, Minimum: -2.00000000000000
└───────────────────────────────────────────────────────────────────────────────

┌─ [ASSISTANT] → 📝 最终回答 ───────────────────────────────────────────────────
│ r'(x) = 3cos(x) + 3cos(3x)，令 r'(x) = 0：
│   cos(x) + cos(3x) = 0
│
│ 使用恒等式 cos(3x) = 4cos³(x) - 3cos(x)：
│   4cos³(x) - 2cos(x) = 0  →  2cos(x)(2cos²(x) - 1) = 0
│
│ 解出临界点：x = π/4, π/2, 3π/4, 3π/2
│                               ⚠️ 遗漏了 x = 5π/4 ！
│
│ 求值比较：
│   r(0) = 0,  r(π/4) = 2√2,  r(π/2) = 2,
│   r(3π/4) = 2√2,  r(3π/2) = -2
│
│ \boxed{2\sqrt{2}, -2}     ← 最小值错误（真实最小值为 -2√2）
└───────────────────────────────────────────────────────────────────────────────
```

**错误分析**：`sp.solve` 的通解中，`cos(x) = -√2/2` 对应 `x = 3π/4` 和 `x = 5π/4`，但只取了 `3π/4`，遗漏了 `5π/4`（该点在区间内且 `r(5π/4) = -2√2`）。

</details>

<details open>
<summary><b>▶ Step 16 轨迹（reward = 1.0, tools = 2, turns = 3）— 答案正确 ✅</b></summary>

```
┌─ [SYSTEM] / [USER] ── 同上 ──────────────────────────────────────────────────
└───────────────────────────────────────────────────────────────────────────────

┌─ [ASSISTANT] → 🔧 python_interpreter (第 1 次) ───────────────────────────────
│ import numpy as np
│ from scipy.optimize import minimize_scalar
│
│ def r(x): return 3 * np.sin(x) + np.sin(3 * x)
│ def dr(x): return 3 * np.cos(x) + 3 * np.cos(3 * x)
│
│ # 在区间内用多初始点数值搜索临界点
│ critical_points = []
│ for x0 in np.linspace(0, 1.5*np.pi, 100):
│     res = minimize_scalar(lambda x: dr(x), bounds=(0, 1.5*np.pi), method='bounded')
│     if abs(res.fun) < 1e-6:
│         if not any(abs(cp - res.x) < 1e-4 for cp in critical_points):
│             critical_points.append(res.x)
└───────────────────────────────────────────────────────────────────────────────

┌─ [TOOL] → (执行结果) ─────────────────────────────────────────────────────────
│ 临界点和端点: [0, 0.785398, 1.570796, 2.356194, 3.926991, 4.712389]
│ 最大值: (0.785398, 2.828427)
│ 最小值: (3.926991, -2.828427)        ← x ≈ 5π/4
└───────────────────────────────────────────────────────────────────────────────

┌─ [ASSISTANT] → 🔧 python_interpreter (第 2 次) ───────────────────────────────
│ # 用解析方法验证：cos(x) + cos(3x) = 2cos(2x)cos(x) = 0
│ #   cos(x) = 0  → x = π/2, 3π/2
│ #   cos(2x) = 0 → x = π/4, 3π/4, 5π/4        ← 找到了 5π/4
│ points = [0, np.pi/4, np.pi/2, 3*np.pi/4, 5*np.pi/4, 3*np.pi/2]
│ for x in points:
│     print(f"{x:.6f}, {r(x):.6f}")
└───────────────────────────────────────────────────────────────────────────────

┌─ [TOOL] → (执行结果) ─────────────────────────────────────────────────────────
│ 0.000000, 0.000000
│ 0.785398, 2.828427        (= 2√2)
│ 1.570796, 2.000000
│ 2.356194, 2.828427        (= 2√2)
│ 3.926991, -2.828427       (= -2√2)    ← Step 6 遗漏的关键点
│ 4.712389, -2.000000
└───────────────────────────────────────────────────────────────────────────────

┌─ [ASSISTANT] → 📝 最终回答 ───────────────────────────────────────────────────
│ 函数 r(x) = 3sin(x) + sin(3x) 在 [0, 3π/2] 上的极值点为：
│   x = 0, π/4, π/2, 3π/4, 5π/4, 3π/2
│
│ 对应函数值：
│   r(0)=0, r(π/4)=2√2, r(π/2)=2,
│   r(3π/4)=2√2, r(5π/4)=-2√2, r(3π/2)=-2
│
│ \boxed{最大值为 2\sqrt{2}, 最小值为 -2\sqrt{2}}
└───────────────────────────────────────────────────────────────────────────────
```

</details>

> **关键对比**：Step 6 用 $\cos(3x) = 4\cos^3(x) - 3\cos(x)$ 展开后遗漏了临界点 $x = 5\pi/4$；Step 16 先数值搜索再用**和差化积** $\cos(x)+\cos(3x) = 2\cos(2x)\cos(x)$ 解析验证，找到全部 5 个临界点。

---

#### 案例 2：级数收敛域 — 调了工具但没给答案 → 训练后完整输出

> **改进维度**：学会利用工具结果生成回答
>
> **题目**：求级数 $\sum_{n=1}^\infty \frac{x^n}{n \cdot 10^{n-1}}$ 的收敛域。

<details>
<summary><b>▶ Step 6 轨迹（reward = -0.2, tools = 1, turns = 1）— 无最终回答 ❌</b></summary>

```
┌─ [SYSTEM] ── 同案例 1 ──────────────────────────────────────────────────────
│ You are a math assistant. You have one tool: python_interpreter ...
└───────────────────────────────────────────────────────────────────────────────

┌─ [USER] ──────────────────────────────────────────────────────────────────────
│ Find the interval of convergence (or domain of convergence) of the series
│ ∑_{n=1}^∞ x^n / (n · 10^{n-1}).
└───────────────────────────────────────────────────────────────────────────────

┌─ [ASSISTANT] → 🔧 python_interpreter ─────────────────────────────────────────
│ import math
│
│ def term(n, x):
│     return (x**n) / (n * (10**(n-1)))
│
│ # Ratio test: x=10 时极限应为 1
│ x_val = 10
│ ratios = []
│ for n in range(1, 100):
│     a_n = term(n, x_val); a_n1 = term(n+1, x_val)
│     if a_n != 0: ratios.append(abs(a_n1 / a_n))
│ print(f"Ratio test limit for x=10: {ratios[-1]}")
│
│ # x=5 时极限应为 0.5
│ x_val = 5
│ ratios_5 = []
│ for n in range(1, 100):
│     a_n = term(n, x_val); a_n1 = term(n+1, x_val)
│     if a_n != 0: ratios_5.append(abs(a_n1 / a_n))
│ print(f"Ratio test limit for x=5: {ratios_5[-1]}")
│
│ # 端点 partial sums
│ def partial_sum(x, N):
│     return sum(term(n, x) for n in range(1, N+1))
│ for N in [10, 100, 1000]:
│     print(f"  x=10: N={N}, Sum={partial_sum(10, N):.2f}")
│     print(f"  x=-10: N={N}, Sum={partial_sum(-10, N):.2f}")
└───────────────────────────────────────────────────────────────────────────────

┌─ [TOOL] → (执行结果) ─────────────────────────────────────────────────────────
│ Ratio test limit for x=10: 1.0
│ Ratio test limit for x=5: 0.5
│ x=10:  N=10→19.18, N=100→48.26, N=1000→74.71     ← 发散
│ x=-10: N=10→-5.58, N=100→-6.88, N=1000→-6.93     ← 收敛
└───────────────────────────────────────────────────────────────────────────────

  ⚠️ 轨迹在此结束，模型没有生成任何文字回答，也没有 \boxed{} 输出。
  format = -1, accuracy = 0 → reward = 0.2×(-1) + 0.8×0 = -0.2
```

</details>

<details open>
<summary><b>▶ Step 16 轨迹（reward = 1.0, tools = 1, turns = 2）— 完整推导 ✅</b></summary>

```
┌─ [SYSTEM] / [USER] ── 同上 ──────────────────────────────────────────────────
└───────────────────────────────────────────────────────────────────────────────

┌─ [ASSISTANT] → 🔧 python_interpreter ─────────────────────────────────────────
│ import sympy as sp
│
│ x = sp.symbols('x')
│ n = sp.symbols('n', positive=True)
│ a_n = x**n / (n * 10**(n-1))
│ a_n_plus_1 = x**(n+1) / ((n+1) * 10**n)
│
│ ratio = sp.simplify(sp.Abs(a_n_plus_1 / a_n))
│ limit_ratio = sp.limit(ratio, n, sp.oo)
│ print(f"Limit: {limit_ratio}")        # → Abs(x)/10
│
│ # 端点验证
│ term_at_10 = [10**n / (n * 10**(n-1)) for n in range(1, 21)]
│ term_at_m10 = [(-10)**n / (n * 10**(n-1)) for n in range(1, 21)]
│ print(f"x=10 前几项: {term_at_10[:10]}")
│ print(f"x=-10 前几项: {term_at_m10[:10]}")
└───────────────────────────────────────────────────────────────────────────────

┌─ [TOOL] → (执行结果) ─────────────────────────────────────────────────────────
│ Limit: Abs(x)/10
│ x=10 前几项: [10.0, 5.0, 3.333, 2.5, 2.0, 1.667, 1.429, 1.25, 1.111, 1.0]
│                                                 ← 调和级数 10/n，发散
│ x=-10 前几项: [-10.0, 5.0, -3.333, 2.5, -2.0, 1.667, -1.429, 1.25, -1.111, 1.0]
│                                                 ← 交替级数 (-1)^n·10/n，收敛
└───────────────────────────────────────────────────────────────────────────────

┌─ [ASSISTANT] → 📝 最终回答 ───────────────────────────────────────────────────
│ The series is given by ∑ xⁿ/(n·10ⁿ⁻¹).
│
│ Ratio Test:
│   |a_{n+1}/a_n| = |x|·n/(10(n+1)) → |x|/10  as n→∞
│   Converges absolutely when |x|/10 < 1, i.e. |x| < 10.  R = 10.
│
│ Endpoint x = 10:
│   ∑ 10ⁿ/(n·10ⁿ⁻¹) = 10·∑(1/n)  — harmonic series, diverges.
│
│ Endpoint x = -10:
│   ∑ (-1)ⁿ·10/n  — alternating series, bₙ = 10/n ↓ 0, converges.
│
│ \boxed{[-10, 10)}
└───────────────────────────────────────────────────────────────────────────────
```

</details>

> **关键对比**：Step 6 模型"只做不说"——工具已经给出了足够信息（ratio limit、partial sums），但模型没有将其转化为推理和回答。Step 16 学会了**工具验证 → 数学推导 → 结论输出**的完整闭环。

---

#### 案例 3：曲线分析 — 调了工具但无输出 → 训练后结构化 9 要素分析

> **改进维度**：工具结果组织能力
>
> **题目**：Sketch $y = 3x^2 - x^4 - 2$. 求定义域、渐近线、单调区间、凹凸区间、拐点共 9 个特征。

<details>
<summary><b>▶ Step 6 轨迹（reward = -0.2, tools = 1, turns = 1）— 无最终回答 ❌</b></summary>

```
┌─ [SYSTEM] ── 同案例 1 ──────────────────────────────────────────────────────
│ You are a math assistant. You have one tool: python_interpreter ...
└───────────────────────────────────────────────────────────────────────────────

┌─ [USER] ──────────────────────────────────────────────────────────────────────
│ Sketch y = 3x² - x⁴ - 2.
│ Find: (1) domain, (2) vertical asymptotes, (3) horizontal asymptotes,
│ (4) slant asymptotes, (5) intervals of increase, (6) intervals of decrease,
│ (7) concave up intervals, (8) concave down intervals, (9) inflection points.
└───────────────────────────────────────────────────────────────────────────────

┌─ [ASSISTANT] → 🔧 python_interpreter ─────────────────────────────────────────
│ import sympy as sp
│ x = sp.symbols('x')
│ f = 3*x**2 - x**4 - 2
│
│ # 极限（渐近线检测）
│ print(f"Limit x->oo: {sp.limit(f, x, sp.oo)}")       # → -oo
│ print(f"Limit x->-oo: {sp.limit(f, x, -sp.oo)}")     # → -oo
│
│ # 一阶导 & 临界点
│ f_prime = sp.diff(f, x)
│ critical_points = sp.solve(f_prime, x)
│ print(f"Critical points: {critical_points}")           # → [-√6/2, 0, √6/2]
│
│ # 二阶导 & 拐点
│ f_double_prime = sp.diff(f_prime, x)
│ inflection_points_x = sp.solve(f_double_prime, x)
│ print(f"Inflection points x: {inflection_points_x}")   # → [-√2/2, √2/2]
│ inflection_points = [(pt, f.subs(x, pt)) for pt in inflection_points_x]
│ print(f"Inflection points: {inflection_points}")        # → [(-√2/2, -5/4), (√2/2, -5/4)]
│
│ # 逐区间检验单调性
│ critical_sorted = sorted([float(cp) for cp in critical_points])
│ intervals = [(-sp.oo, critical_sorted[0]),
│              (critical_sorted[0], critical_sorted[1]),
│              (critical_sorted[1], critical_sorted[2]),
│              (critical_sorted[2], sp.oo)]
│ for (a, b) in intervals:
│     if a == -sp.oo: test_x = float(b) - 10
│     elif b == sp.oo: test_x = float(a) + 10
│     else: test_x = (float(a) + float(b)) / 2
│     val = f_prime.subs(x, test_x)
│     print(f"({a},{b}): f'={val} -> {'Increasing' if val>0 else 'Decreasing'}")
│
│ # 逐区间检验凹凸性
│ infl_sorted = sorted([float(ip) for ip in inflection_points_x])
│ conc_intervals = [(-sp.oo, infl_sorted[0]),
│                   (infl_sorted[0], infl_sorted[1]),
│                   (infl_sorted[1], sp.oo)]
│ for (a, b) in conc_intervals:
│     if a == -sp.oo: test_x = float(b) - 10
│     elif b == sp.oo: test_x = float(a) + 10
│     else: test_x = (float(a) + float(b)) / 2
│     val = f_double_prime.subs(x, test_x)
│     print(f"Concavity ({a},{b}): f''={val} -> {'Up' if val>0 else 'Down'}")
└───────────────────────────────────────────────────────────────────────────────

┌─ [TOOL] → (执行结果) ─────────────────────────────────────────────────────────
│ Limit x->oo: -oo
│ Critical points: [-sqrt(6)/2, 0, sqrt(6)/2]
│ Inflection points: [(-sqrt(2)/2, -5/4), (sqrt(2)/2, -5/4)]
│ (-oo, -1.22): f'=30 -> Increasing
│ (-1.22, 0): f'=-0.625 -> Decreasing
│ (0, 1.22): f'=0.625 -> Increasing
│ (1.22, oo): f'=-30 -> Decreasing
│ Concavity: (-oo,-0.707) Concave Down, (-0.707,0.707) Concave Up, (0.707,oo) Concave Down
└───────────────────────────────────────────────────────────────────────────────

  ⚠️ 工具已输出完整计算结果，但模型没有生成任何文字回答。
  轨迹在此结束，无 \boxed{} 输出。
```

</details>

<details open>
<summary><b>▶ Step 16 轨迹（reward = 1.0, tools = 1, turns = 2）— 结构化回答 ✅</b></summary>

```
┌─ [SYSTEM] / [USER] ── 同上 ──────────────────────────────────────────────────
└───────────────────────────────────────────────────────────────────────────────

┌─ [ASSISTANT] → 🔧 python_interpreter ─────────────────────────────────────────
│ import sympy as sp
│ x = sp.symbols('x')
│ y = 3*x**2 - x**4 - 2
│
│ print(f"Domain: {sp.Interval(-sp.oo, sp.oo)}")
│ print(f"Limit x->oo: {sp.limit(y, x, sp.oo)}")
│
│ y_prime = sp.diff(y, x)
│ critical_points = sp.solve(y_prime, x)
│ print(f"Critical points: {critical_points}")
│
│ y_double_prime = sp.diff(y_prime, x)
│ inflection_points = sp.solve(y_double_prime, x)
│ print(f"Inflection points: {inflection_points}")
│
│ # 区间单调性/凹凸性检验
│ test_points = [-10, -1, 0, 1, 10]
│ for tp in test_points:
│     print(f"f'({tp}) = {y_prime.subs(x, tp)}")
└───────────────────────────────────────────────────────────────────────────────

┌─ [TOOL] → (执行结果) ─────────────────────────────────────────────────────────
│ Domain: Interval(-oo, oo)
│ Critical points: [-sqrt(6)/2, 0, sqrt(6)/2]
│ Inflection points: [-sqrt(2)/2, sqrt(2)/2]
│ f'(-10) = 3940, f'(-1) = 2, f'(0) = 0, f'(1) = -2, f'(10) = -3940
└───────────────────────────────────────────────────────────────────────────────

┌─ [ASSISTANT] → 📝 最终回答 ───────────────────────────────────────────────────
│ Based on my analysis of the function y = 3x² - x⁴ - 2:
│
│ 1. Domain: (-∞, ∞)
│ 2. Vertical asymptotes: None
│ 3. Horizontal asymptotes: None
│ 4. Slant asymptotes: None
│ 5. Increasing: (-∞, -√6/2) ∪ (0, √6/2)
│ 6. Decreasing: (-√6/2, 0) ∪ (√6/2, ∞)
│ 7. Concave up: (-√2/2, √2/2)
│ 8. Concave down: (-∞, -√2/2) ∪ (√2/2, ∞)
│ 9. Points of inflection: (-√2/2, -5/4), (√2/2, -5/4)
│
│ \boxed{1.(-∞,∞) \\ 2. \\ 3. \\ 4. \\
│        5.(-∞,-√6/2)∪(0,√6/2) \\ 6.(-√6/2,0)∪(√6/2,∞) \\
│        7.(-√2/2,√2/2) \\ 8.(-∞,-√2/2)∪(√2/2,∞) \\
│        9.(-√2/2,-5/4),(√2/2,-5/4)}
└───────────────────────────────────────────────────────────────────────────────
```

</details>

> **关键对比**：Step 6 的工具已经输出了完整的临界点、拐点和区间分析结果，但模型没有将这些结果整理为最终回答。Step 16 则将工具结果**逐条映射**到题目要求的 9 个特征上，生成了完整的结构化输出。

---

#### 案例 4：方程求解 — 两次工具调用后中断 → 训练后三次迭代逐步逼近

> **改进维度**：多轮迭代调试能力
>
> **题目**：圆柱体半径比高度大 $\frac{1}{3}$ 米，体积为 $\frac{74\pi}{9}$ 立方米。求尺寸。

<details>
<summary><b>▶ Step 6 轨迹（reward = -0.2, tools = 2, turns = 2）— 无最终回答 ❌</b></summary>

```
┌─ [SYSTEM] ── 同案例 1 ──────────────────────────────────────────────────────
│ You are a math assistant. You have one tool: python_interpreter ...
└───────────────────────────────────────────────────────────────────────────────

┌─ [USER] ──────────────────────────────────────────────────────────────────────
│ A cylinder has a radius that is 1/3 meter more than the height. The
│ volume is 74π/9 cubic meters. Determine the dimensions of the cylinder.
└───────────────────────────────────────────────────────────────────────────────

┌─ [ASSISTANT] → 🔧 python_interpreter (第 1 次) ───────────────────────────────
│ import sympy
│ h = sympy.symbols('h')
│
│ # π(h+1/3)²h = 74π/9  →  9h³+6h²+h-74 = 0
│ equation = 9*h**3 + 6*h**2 + h - 74
│ solutions = sympy.solve(equation, h)
│ print(f"Solutions for h: {solutions}")
│
│ valid_h = [sol for sol in solutions if sol.is_real and sol > 0]
│ print(f"Valid height(s): {valid_h}")
│
│ if valid_h:
│     h_val = valid_h[0]
│     r_val = h_val + sympy.Rational(1, 3)
│     print(f"Height h: {h_val}")
│     print(f"Radius r: {r_val}")
│     V = sympy.pi * r_val**2 * h_val
│     print(f"Volume: {V}")
└───────────────────────────────────────────────────────────────────────────────

┌─ [TOOL] → (执行结果) ─────────────────────────────────────────────────────────
│ Solutions for h: [-1/3 + (-1/2 + sqrt(3)*I/2)*(37 + 3*sqrt(1473)*I/9)**(1/3)
│                   + 17/(9*(-1/2 + sqrt(3)*I/2)*(37 + 3*sqrt(1473)*I/9)**(1/3)),
│                   ...]    ← Cardano 公式精确解，含复数中间表达式
│ Valid height(s): [（上述根式的正实数根，约 1.8025）]
└───────────────────────────────────────────────────────────────────────────────

┌─ [ASSISTANT] → 🔧 python_interpreter (第 2 次) ───────────────────────────────
│ import numpy as np
│ coeffs = [9, 6, 1, -74]
│ roots = np.roots(coeffs)
│ print(f"Roots: {roots}")
│
│ valid_roots = [r for r in roots if r > 0]
│ h_val = valid_roots[0]
│ r_val = h_val + 1/3
│ print(f"Height h: {h_val}")      # → 1.8025
│ print(f"Radius r: {r_val}")      # → 2.1358
│ V = np.pi * r_val**2 * h_val
│ print(f"Volume: {V}")            # → 25.83 ≈ 74π/9 ✓
│ print(f"Target: {74 * np.pi / 9}")
└───────────────────────────────────────────────────────────────────────────────

┌─ [TOOL] → (执行结果) ─────────────────────────────────────────────────────────
│ Roots: [1.80246489+0j, -1.23456578+1.23400123j, -1.23456578-1.23400123j]
│ Height h: 1.80246489699
│ Radius r: 2.13579823032
│ Volume: 25.83078246 ≈ Target: 25.83078246    ← 验证通过
└───────────────────────────────────────────────────────────────────────────────

  ⚠️ 两次工具调用都成功求出了答案，但模型没有生成最终回答。
  轨迹在此结束，无 \boxed{} 输出。
```

</details>

<details open>
<summary><b>▶ Step 16 轨迹（reward = 1.0, tools = 3, turns = 4）— 三次迭代 + 回答 ✅</b></summary>

```
┌─ [SYSTEM] / [USER] ── 同上 ──────────────────────────────────────────────────
└───────────────────────────────────────────────────────────────────────────────

┌─ [ASSISTANT] → 🔧 python_interpreter (第 1 次) ───────────────────────────────
│ import sympy as sp
│ h = sp.symbols('h')
│ equation = 9*h**3 + 6*h**2 + h - 74
│ solutions = sp.solve(equation, h)
│ print("方程的解:", solutions)
│ for sol in solutions:
│     if sol.is_real and sol > 0:
│         r = sol + sp.Rational(1, 3)
│         print(f"正实数解: h = {sol}")
│         print(f"半径 r = {r}")
│         print(f"体积 V = {sp.pi * r**2 * sol}")
└───────────────────────────────────────────────────────────────────────────────

┌─ [TOOL] → (执行结果) ─────────────────────────────────────────────────────────
│ 方程的解: [-1/3 + (-1/2 + sqrt(3)*I/2)*(37 + 3*sqrt(1473)*I/9)**(1/3) + ...]
│                ← Cardano 公式精确根式，含虚数中间量，无法化简为简洁实数形式
└───────────────────────────────────────────────────────────────────────────────

┌─ [ASSISTANT] → 🔧 python_interpreter (第 2 次) ───────────────────────────────
│ # 换策略：先尝试有理根检验
│ possible_roots = [1, 2, 37, 74, 1/3, 2/3, 37/3, 74/3,
│                   1/9, 2/9, 37/9, 74/9]
│ for root in possible_roots + [-r for r in possible_roots]:
│     if equation.subs(h, root) == 0:
│         print(f"Found rational root: h = {root}"); break
│ else:
│     print("No rational root found")     ← 确认无有理根
│
│ # 转用数值方法
│ sol = sp.nroots(equation)
│ for s in sol:
│     print(f"h ≈ {s}")
└───────────────────────────────────────────────────────────────────────────────

┌─ [TOOL] → (执行结果) ─────────────────────────────────────────────────────────
│ No rational root found
│ h ≈ 1.80246
│ h ≈ -1.23456 + 1.234i    (复数根，排除)
│ h ≈ -1.23456 - 1.234i    (复数根，排除)
└───────────────────────────────────────────────────────────────────────────────

┌─ [ASSISTANT] → 🔧 python_interpreter (第 3 次) ───────────────────────────────
│ # 高精度验证
│ sol = sp.nroots(equation, n=15)
│ real_roots = [r for r in sol if abs(sp.im(r)) < 1e-10 and sp.re(r) > 0]
│ h_val = real_roots[0]
│ r_val = h_val + sp.Rational(1, 3)
│ print(f"Height h: {h_val}")
│ print(f"Radius r: {r_val}")
│
│ V_calc = sp.pi * r_val**2 * h_val
│ V_target = 74 * sp.pi / 9
│ print(f"Difference: {abs(V_calc - V_target)}")    # → ≈ 0
└───────────────────────────────────────────────────────────────────────────────

┌─ [TOOL] → (执行结果) ─────────────────────────────────────────────────────────
│ Height h: 1.80246489699385
│ Radius r: 2.13579823032718
│ Difference: 1.38e-12      ← 体积验证通过
└───────────────────────────────────────────────────────────────────────────────

┌─ [ASSISTANT] → 📝 最终回答 ───────────────────────────────────────────────────
│ 根据计算，圆柱体的高度约为 1.8025 米，半径约为 2.1358 米。
│
│ 具体计算过程：
│ 1. 设高度为 h 米，则半径 r = h + 1/3 米
│ 2. 体积公式：V = πr²h = 74π/9
│ 3. 代入得：π(h + 1/3)²h = 74π/9
│ 4. 化简得三次方程：9h³ + 6h² + h - 74 = 0
│ 5. 求解得正实数解：h ≈ 1.8025 米
│ 6. 半径：r = h + 1/3 ≈ 2.1358 米
│
│ \boxed{h ≈ 1.8025 \text{ 米}, r ≈ 2.1358 \text{ 米}}
└───────────────────────────────────────────────────────────────────────────────
```

</details>

> **关键对比**：Step 6 两次工具调用都成功求出了正确答案（h≈1.8025），但模型没有生成任何文字总结。Step 16 展现了**逐步迭代策略**——精确解复杂→尝试有理根→数值逼近→高精度验证→最终输出。

---

#### 案例总结

| 案例 | Step 6 问题 | Step 16 改进 | 改进维度 |
|------|-----------|-----------|---------|
| 1. 三角函数极值 | 有 `\boxed{}` 但答案错误（遗漏临界点） | 换用和差化积分解，找到全部临界点 | **推理质量** |
| 2. 级数收敛域 | 工具结果正确但没有输出回答 | 工具→推导→端点分析→boxed 完整闭环 | **工具结果利用** |
| 3. 曲线 9 要素 | 工具已输出全部计算结果但不总结 | 逐条映射为结构化 9 点回答 | **结果组织能力** |
| 4. 方程求解 | 两次调用都算出答案但不输出 | 三次迭代 + 体积验证 + 总结回答 | **多轮迭代能力** |

---

## 附录 A：U-MATH vs MATH 配置对比

| 维度 | MATH | U-MATH | 说明 |
|------|------|--------|------|
| 训练数据量 | ~3500 (lv4-5) | 700 | U-MATH 数据少，需更多 epoch |
| n_epochs | 1 | 3 | 补偿数据量差异 |
| Rollout 处理器 | CalcXRolloutProcessor | 同左 | 完全共用 |
| Reward 处理器 | MathRewardProcessor | UMathRewardProcessor | U-MATH 使用 LLM judge |
| accuracy 验证方式 | SymPy 精确匹配 | LLM-as-judge + partial credit | 核心差异 |
| 其他超参 | — | — | learning_rate、max_length 等均一致 |

## 附录 B：目录结构与文件放置（重要）

> 本 demo 的 `extra_demos/umath/` 目录已整理为**可直接运行的完整 demo**。目录内已包含：
> - `functions/` 包（含 `__init__.py`），与 `submit_job.py` 中的 `classpath` 对应；
> - `requirements.txt`、`start.sh` 等运行所需文件；
> - `data/` 与 `scripts/`，提交脚本会检查同级 `data/` 并按需运行 `scripts/` 下的数据脚本生成训练/验证数据（也可按 B.2 手动预下载）。

提交任务时，SDK 会根据 `FunctionComponentModel` 中的 `zipdir` 把 `extra_demos/umath/` 整个打包上传，远程再按 `classpath` 加载模块。因此可直接在 `extra_demos/umath/` 目录下运行。

### B.1 文件放置映射表

| demo 内文件路径 | 说明 |
|--------------|------------------------------------|
| `extra_demos/umath/submit_job.py` | 任务提交入口 |
| `extra_demos/umath/functions/rollout/math_rollout.py` | rollout 处理器 |
| `extra_demos/umath/functions/rollout/code_interpreter.py` | Python 解释器工具 |
| `extra_demos/umath/functions/reward/umath_reward.py` | reward 处理器 |
| `extra_demos/umath/functions/reward/math_verify.py` | 数学答案验证工具 |
| `extra_demos/umath/scripts/download_umath.py` | 数据下载脚本 |
| `extra_demos/umath/test/test_deployed_umath.py` | 部署验证脚本 |
| `extra_demos/umath/data/umath_train.jsonl` | 训练数据 |
| `extra_demos/umath/data/umath_test.jsonl` | 验证数据 |

> 关键约束：rollout / reward 代码**必须**放进 `functions/` 包内（与 classpath 对应），且 `functions/__init__.py` 必须存在。

### B.2 数据准备（可选）

`submit_job.py` 会自动检查同级 `data/` 并按需运行 `scripts/` 下的脚本生成数据。如需手动预下载，在 `extra_demos/umath/` 下执行：

```bash
mkdir -p data
python scripts/download_umath.py
```

### B.3 demo 目录已具备的运行环境文件

| 文件 | 用途 |
|------|------|
| `../../dashscope-1.25.23-py3-none-any.whl` | SDK 本地包（也可改用 PyPI 版本） |
| `./requirements.txt` | 函数组件依赖（langchain、mcp 等） |
| `./start.sh` | 本地启动函数组件服务示例 |
| `./llm_api_config.json` | LLM judge 配置（也可用 `JUDGE_*` 环境变量替代；需自行创建） |

`extra_demos/umath/` 目录结构如下：

```
extra_demos/umath/
├── submit_job.py
├── start.sh
├── requirements.txt
├── llm_api_config.json          # 可选，也可用 JUDGE_* 环境变量
├── functions/
│   ├── __init__.py
│   ├── rollout/
│   │   ├── math_rollout.py
│   │   └── code_interpreter.py
│   └── reward/
│       ├── umath_reward.py
│       └── math_verify.py
├── data/
│   ├── umath_train.jsonl
│   └── umath_test.jsonl
├── scripts/download_umath.py
└── test/test_deployed_umath.py
```

随后即可按 **附录 C** 的清单执行 `python submit_job.py`。

---

## 附录 C：快速上手清单

> 前提：在 `extra_demos/umath/` 目录下执行以下命令。

1. **安装依赖**：`pip install ../../dashscope-1.25.23-py3-none-any.whl && pip install -r requirements.txt`
2. **设置 API Key**：
   ```bash
   export DASHSCOPE_API_KEY=sk-xxx
   # LLM Judge 配置（可选，也可通过 llm_api_config.json）
   export JUDGE_API_KEY=sk-xxx
   export JUDGE_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
   export JUDGE_MODEL=qwen3.6-flash
   ```
3. **准备数据（可选）**：`submit_job.py` 会在提交前自动检查 `data/umath_train.jsonl` 和 `data/umath_test.jsonl`，缺失时自动运行下载脚本；如需提前准备，可手动执行 `python scripts/download_umath.py`
4. **提交训练**：`python submit_job.py`
5. **查看指标**：在训练监控面板查看 reward/accuracy 曲线、judge 使用率
6. **部署验证**：
   ```bash
   # 快速验证（5 样本）
   python test/test_deployed_umath.py --model <your_model_code>
   # 全量评估
   python test/test_deployed_umath.py --all --model <your_model_code> --concurrency 4
   ```
