# T2 星港总调度官养成计划（真实训练版）

## 题目背景

星港是一座用于向太空输送物资的集散港。总调度台每天都会收到大量自然语言请求：查天气、排日程、做翻译、算表达式、查路线、写邮件、看股票、订酒店。真实值班里经常会一次下发多件彼此独立的任务，因此本题的公开/隐藏评测都强调“并行工具调用”。

总调度长不希望值班时继续临时调用大模型，而是希望你先离线构造训练数据，再训练出一个真正可提交、可复现、可评测的 `Qwen3-0.6B` 工具调用模型。本题不再单独提供数据合成脚本；你需要参考 `T0/T1` 中通过 Prompt 扩写样本的方式，自行准备训练集，并基于提供的部分训练骨架完成 full fine-tuning。正式评测时，会像 `T0/T1` 一样直接加载你提交的完整模型目录，测试它在公开集和隐藏集上的真实性能。

## 题目描述

这道题分成两个离线阶段和一个在线评测阶段：

1. 参考 `T0/T1` 的 Prompt 方法离线构造训练数据
2. 基于提供的部分训练代码完成 `Qwen3-0.6B` 全量微调训练
3. 提交训练结果并接受模型实测

其中：

1. 你需要把 `seed_tasks` 扩写成自己的训练集，并把训练集一并提交。这里的 `seed_tasks` 指的是出题方提供的一小批人工种子样本，用来告诉你这道题覆盖哪些工具、参数形态和并行调度模式；它不是最终训练集，也不是让你原样提交的标准答案库
2. 本题不再单独提供 `synthesize_data.py`；A 项会同时检查最终提交的 `artifacts/tool_agent_train.jsonl` 和 `artifacts/tool_agent_sft_train.jsonl`
3. `train_tool_agent.py` 会提供大部分样板代码，考生只需要在 `run_full_finetuning` 中补齐少量关键训练逻辑；训练代码本身不单独计分
4. 评测器不会替你重新跑完整数据构造和完整训练流程，而是会检查你提交的训练数据和模型产物，并像 `T0/T1` 一样直接启动 `Qwen3-0.6B` 做推理
5. 若用户请求显式要求并行处理多个独立子任务，模型必须直接连续输出多个 `<tool_call>{...}</tool_call>`，并在每个调用对象里给出与 `arguments` 同级的 `wait_for_result`

工具全集共 `8` 类：

- `weather`
- `calendar`
- `translation`
- `calculator`
- `map_route`
- `email`
- `stock_quote`
- `hotel_booking`

## 工具特性与等待策略

注意：这些规则会体现在评测标注中，但不会在用户 query 里直接提示。

| 工具 | 作用 | 默认 `wait_for_result` | 说明 |
| --- | --- | --- | --- |
| `weather` | 查询天气 | `false` | 只读查询，可直接发射，无需等待回调结果 |
| `calendar` | 创建或查询日程提醒 | `true` | 涉及日程写入或确认，默认需要等待结果 |
| `translation` | 翻译文本 | `false` | 纯文本转换，可直接发射 |
| `calculator` | 计算数学表达式 | `true` | 后续流程通常依赖计算结果，默认需要等待 |
| `map_route` | 查询路线 | `true` | 路线结果通常被后续步骤消费，默认需要等待 |
| `email` | 发送邮件 | `true` | 需要确认邮件发送动作是否成功，默认需要等待 |
| `stock_quote` | 查询股票行情 | `false` | 只读查询，可直接发射 |
| `hotel_booking` | 预订酒店 | `true` | 属于真实写操作，默认需要等待结果 |

因此，本题当前固定的等待策略可以概括为：

1. `weather / translation / stock_quote` 默认 `wait_for_result=false`
2. `calendar / calculator / map_route / email / hotel_booking` 默认 `wait_for_result=true`

这里额外说明一个设计取舍：在真实系统里，某个工具调用是否需要等待结果，往往不仅和工具本身有关，也和当前 query 有关，理论上可以由模型动态决定。本题为了化简问题，没有把这部分开放成“可按 query 自由决策”的调度问题，而是把 `wait_for_result` 固定绑定到工具类型。这样做的目的，是把考察重点收敛到：

1. 是否能正确生成一个或多个 Qwen 风格 `<tool_call>`
2. 是否能在并行场景下稳定覆盖所有需要的工具
3. 是否能把工具名、关键参数和顶层 `wait_for_result` 字段一起学对

本题不允许自选模型基座。训练与评测基座固定为 `Qwen3-0.6B`，并且训练方式统一为 `full fine-tuning`。你需要围绕它完成离线训练，并满足两点：

1. 能在本地离线训练
2. 能在评测时被直接加载并输出正确的工具调用结果

你必须使用全量微调，不能改成 LoRA、冻结部分层、朴素贝叶斯、线性分类器或其它非 `Qwen3-0.6B full fine-tuning` 方案。

## 你需要实现

本题允许多文件提交。`T2` 目录下至少应包含以下内容：

1. `train_tool_agent.py`
   这是官方提供的部分训练骨架；你只需要补齐少量 full fine-tuning 主逻辑，正式评分不直接计算训练代码分数
2. `artifacts/tool_agent_train.jsonl`
   这是你实际提交的原始训练集
3. `artifacts/tool_agent_sft_train.jsonl`
   这是按真实 Qwen 工具调用模板整理后的 SFT 训练集
4. `artifacts/model/`
   这是唯一允许提交的最终模型目录；位置固定，目录内文件结构应与原始 `Qwen3-0.6B` 保存格式一致

我们已在 `artifacts/` 中各放入 `1` 条样例数据，方便你理解格式；这两份样例文件不能直接提交，若训练集或 SFT 集只包含 `1` 条数据，A 项会直接记 `0` 分。

`tool_agent_common.py` 作为辅助文件给出，不需要修改。

## 评测数据与模型实际输入

评测器会使用以下数据文件：

1. `../data/T2_seed_tasks.jsonl`
   用于离线构造训练数据
2. `../data/T2_public_eval_queries.jsonl`
   用于公开评测
3. `../data/T2_public_eval_queries.final.jsonl`
   用于隐藏评测

其中，`../data/T2_seed_tasks.jsonl` 可以理解为“扩写起点数据”：

- 它只提供少量种子任务，帮助你理解题目覆盖的工具类型、参数槽位和并行样式
- 你的工作不是直接复用这几条样本，而是基于它们继续扩写、改写和组合，生成足够规模的训练集
- A 项看的是你最终提交的数据分布与质量，不是看你是否原样保留了这些 seed 样本

其中，`T2_public_eval_queries.jsonl` / `T2_public_eval_queries.final.jsonl` 的每条评测标注样本至少会包含：

- `query`
- `answer_json`

并且公开/隐藏评测会大量使用并行任务。对应样本通常还会包含：

- `tool_names`
- `no_wait_tools`
- `wait_tools`
- `answer_json.tool_calls`

其中，`no_wait_tools` 和 `wait_tools` 是可选汇总字段，可由 `answer_json.tool_calls` 自动推导。

需要注意，`answer_json`、`tool_names`、`no_wait_tools`、`wait_tools` 这些字段是评测器内部用来比对答案的标注信息，不会作为模型输入喂给你提交的模型。

正式评测时，模型实际只会收到：

- 固定的 system prompt
- 单条用户 `query`
- 通过 `tools=` 传入的真实工具 schema

你的目标是：输入 `query`，由你提交的训练后模型输出真实 Qwen 工具调用结果。正式评测关注 3 件事：

1. 输出的多个 `tool_call.name` 多重集是否正确
2. 每个 `tool_call` 是否都显式给出了与 `arguments` 同级的 `wait_for_result`
3. 每个 `tool_call.arguments` 是否包含正确的关键参数

这里的“关键参数命中”不是要求预测出的 `arguments` 与标注 JSON 做逐字段、逐顺序的全文字面一致比较；官方实现会在工具名和 `wait_for_result` 都匹配的前提下，检查标注样本中给出的关键值是否都出现在你预测的 `arguments` 中。若样本显式给了 `must_contain`，则以该字段为准；否则默认检查 `answer_json.arguments` 里的叶子值。

正式评测时，评测器会使用固定的 system prompt，并通过 `tools=` 把真实工具 schema 传入 Qwen chat template；模型输出应为“一个或多个 `<tool_call>{...}</tool_call>`”，且每个调用对象都要包含 `name / wait_for_result / arguments`。`wait_for_result` 的正确取值遵循上面公开给出的工具等待策略，但不会在 query 中直接提示。官方评测的生成长度上限固定为 `256` 个新增 token，也就是最多允许模型在输入 prompt 之后继续生成 `256` 个 token；这不是“输入+输出加起来的总长度上限”。考生不需要再实现单独的推理入口。

## 数据构造方式

本题不再提供独立的 `synthesize_data.py`。数据构造阶段需要你最终提交 `artifacts/tool_agent_train.jsonl`，并基于它整理出 `artifacts/tool_agent_sft_train.jsonl`。具体扩写方式建议直接参考前两题里已经用过的 Prompt 方法：

1. 以 `seed_tasks` 为起点，让大模型做表述改写，不只换同义词，也要改时间、城市、预算、收件人、股票代码等参数组合。可以把它理解成“给扩写器的 few-shot 起始样本”，而不是最终可直接提交的训练集
2. 显式构造双工具、三工具、四工具并行请求，而不是只做单工具分类样本
3. 让样本里同时覆盖 `wait_for_result=false` 和 `wait_for_result=true` 两类调度策略
4. 保留一部分高质量样本做本地验证，再把最终训练集写入 `artifacts/tool_agent_train.jsonl`

评测器不会回放你的 Prompt，也不会重跑数据构造脚本；它只检查你最终提交的数据分布、格式合法性和语义多样性，其中 `tool_agent_train.jsonl` 主要用于检查原始训练样本分布，`tool_agent_sft_train.jsonl` 还会额外检查 Qwen 工具调用格式是否合法。

## Qwen 模板说明

本题的关键不是自己拼字符串模拟 function call，而是正确使用 Qwen 的 chat template 和 `tools=` 参数。

训练/推理时都应使用同一套工具 schema：

```python
messages = [
    {"role": "system", "content": TOOL_AGENT_SYSTEM_PROMPT},
    {"role": "user", "content": query},
]

rendered = tokenizer.apply_chat_template(
    messages,
    tools=QWEN_TOOLS,
    tokenize=False,
    add_generation_prompt=True,
    enable_thinking=False,
)
```

其中：

1. `tools=QWEN_TOOLS` 用来把真实工具 schema 传给 Qwen chat template，而不是手工把 schema 写进 prompt
2. 训练阶段应把 assistant 目标写成一个或多个 `<tool_call>{...}</tool_call>`
3. 每个 `tool_call` 里都必须包含 `name`、`wait_for_result`、`arguments`
4. `wait_for_result` 必须与 `arguments` 同级，不能错误地塞进 `arguments` 内部
5. 并行场景下，应连续输出多个 `<tool_call>`，不要包成 markdown，也不要输出解释文字

一个合法的 assistant 目标示例如下：

```text
<tool_call>{"name":"email","wait_for_result":true,"arguments":{"to":"王老师","subject":"作业延期","body":"申请延期一天提交"}}</tool_call>
<tool_call>{"name":"weather","wait_for_result":false,"arguments":{"city":"北京","date":"明天"}}</tool_call>
```

对应的 SFT 样本应写成：

```json
{
  "messages": [
    {"role": "system", "content": "固定 system prompt"},
    {"role": "user", "content": "并行处理：给王老师发邮件，再查北京明天天气。"},
    {
      "role": "assistant",
      "content": "<tool_call>{\"name\":\"email\",\"wait_for_result\":true,\"arguments\":{\"to\":\"王老师\",\"subject\":\"作业延期\",\"body\":\"申请延期一天提交\"}}</tool_call>\n<tool_call>{\"name\":\"weather\",\"wait_for_result\":false,\"arguments\":{\"city\":\"北京\",\"date\":\"明天\"}}</tool_call>"
    }
  ]
}
```

## 与传统工具调用的区别

很多同学熟悉的“传统工具调用”更接近下面这种设置：

1. 一条用户请求只对应一个工具
2. 模型直接输出单个 `<tool_call>`
3. 评测主要看首个工具名是否命中
4. 即使用户一句话里提到多个任务，也往往按“选一个主工具”处理

本题不是这个口径，而是更接近真实调度场景：

1. 一条请求里可能同时包含多个彼此独立的子任务
2. 并行场景下，模型必须连续输出多个 `<tool_call>`，每个子任务单独对应一个调用
3. 每个 `<tool_call>` 除了 `name` 和 `arguments` 外，还必须包含与 `arguments` 同级的 `wait_for_result`
4. 评测不是只看首个工具名，而是同时检查工具多重集、`wait_for_result`、以及每个调用里的关键参数

因此，不能把本题理解成“8 分类单标签路由”或“传统单工具 function calling”。如果训练集里几乎都是单工具样本，或者没有系统训练 `wait_for_result` 这层显式调度标记，即使基座模型能偶尔选对工具，也很难在公开/隐藏评测中拿到高分。

## 输出格式

### 数据构造阶段

本题不再提供独立的 `synthesize_data.py`。你需要最终提交：

```text
T2/artifacts/tool_agent_train.jsonl
T2/artifacts/tool_agent_sft_train.jsonl
```

文件中每行可以是单工具样本，也可以是并行样本。

单工具样本示例：

```json
{
  "query":"帮我查上海明天天气",
  "tool_name":"weather",
  "wait_for_result":false,
  "answer_json":{"name":"weather","wait_for_result":false,"arguments":{"city":"上海","date":"明天"}}
}
```

并行样本示例：

```json
{
  "query":"并行处理：给王老师发邮件，再查北京明天天气。",
  "tool_names":["email","weather"],
  "no_wait_tools":["weather"],
  "wait_tools":["email"],
  "answer_json":{
    "no_wait_tools":["weather"],
    "wait_tools":["email"],
    "tool_calls":[
      {"name":"email","wait_for_result":true,"arguments":{"to":"王老师","subject":"作业延期","body":"申请延期一天提交"}},
      {"name":"weather","wait_for_result":false,"arguments":{"city":"北京","date":"明天"}}
    ]
  }
}
```

### 训练阶段

你需要提交：

```text
T2/artifacts/tool_agent_sft_train.jsonl
T2/artifacts/model/
```

其中：

1. `tool_agent_sft_train.jsonl` 必须是标准 SFT `messages` 结构，并使用真实 Qwen 工具调用模板
2. assistant 内容必须是一个或多个 `<tool_call>`；每个 `<tool_call>` 都要带有与 `arguments` 同级的 `wait_for_result`
3. 训练配置中的 `base_model` 必须固定为 `Qwen3-0.6B`
4. 训练配置中的 `fine_tuning_type` 必须固定为 `full`
5. 最终模型目录只允许有一份，且必须固定放在 `artifacts/model/`
6. 该目录应通过 `save_pretrained()` 保存，目录内文件结构应与原始 `Qwen3-0.6B` 保持一致
7. 上述 `base_model` / `fine_tuning_type` 是训练脚本中的固定配置约束，不要求额外提交单独的说明文件
8. `train_tool_agent.py` 只是部分训练骨架，不直接计分；真正计分的是训练数据质量和模型性能
9. 如果评测器没有检测到完整模型目录，会跳过公开集与隐藏集的模型性能环节
### 推理阶段

正式评测不再依赖考生提交自定义推理入口。评测脚本会直接从 `artifacts/model/` 加载完整模型目录，并在固定 system prompt + `tools=` 的 Qwen chat template 下测试真实性能。

## 样例

### 训练数据样例 1

```json
{"query":"帮我看一下明天上海天气","name":"weather","wait_for_result":false,"arguments":{"city":"上海","date":"明天"}}
```

### 对应写入 `tool_agent_train.jsonl` 的样例 1

```json
{
  "query":"帮我查上海明天天气",
  "tool_name":"weather",
  "wait_for_result":false,
  "answer_json":{"name":"weather","wait_for_result":false,"arguments":{"city":"上海","date":"明天"}}
}
```

说明：这表示你已经离线完成了数据构造，并把训练样本写进了 `artifacts/tool_agent_train.jsonl`。

### 正式推理样例输入 2

```text
帮我订下周三上海两晚酒店预算800
```

### 正式推理样例输出 2

```text
<tool_call>{"name":"hotel_booking","wait_for_result":true,"arguments":{"city":"上海","check_in":"下周三","nights":2,"budget":800}}</tool_call>
```

说明：正式评测时测的是你提交模型的真实工具调用性能；公开/隐藏集主判分信号是多个 `tool_call.name` 多重集、`wait_for_result` 和关键参数是否同时命中。

## 数据范围

### 可见数据

1. `../data/T2_seed_tasks.jsonl`：`24` 条种子任务，`8` 类工具各 `3` 条。它的作用是提供扩写起点，不是最终训练集规模要求
2. `../data/T2_public_eval_queries.jsonl`：`12` 条公开评测请求，主打并行工具调用
3. 建议先保留约 `100` 条高质量训练样本做本地验证，并覆盖全部 `8` 类标签
4. 建议让训练集包含足够比例的“无需等待结果/需要等待结果”混合并行样本，否则公开集很容易失分

### 隐藏数据

1. 文件路径：`../data/T2_public_eval_queries.final.jsonl`
2. 考生包中的该文件默认为空，占位用；正式评测前会由出题方覆盖为真实隐藏数据
3. 隐藏集会提高 `stock_quote` 与 `hotel_booking` 的占比，并继续采用并行工具调用，用来检查你提交模型是否真的具备泛化能力

## 评分说明

总分 `30` 分：

1. A. 数据分布和多样性：`10` 分
2. B. 公开集模型性能：`12` 分
3. C. 隐藏集模型性能：`8` 分

其中：

1. A 部分主要检查 `tool_agent_train.jsonl` / `tool_agent_sft_train.jsonl` 的样本量、合法率、工具覆盖、并行结构、混合等待比例和语义多样性
2. B 部分使用 `12` 条公开评测样本逐题计分，每题 `1` 分
3. C 部分使用 `8` 条隐藏评测样本逐题计分，每题 `1` 分
4. 评测真正依赖的是训练数据和最终模型目录；模型清单、训练报告若存在只作为可选调试信息读取
5. 提供的训练骨架不单独计算分数

### A 项显式计分阈值

A 项不是主观印象分，而是按固定阈值线性计算。若想让对应子项拿满，至少需要满足以下条件：

1. `tool_agent_train.jsonl` 样本数达到 `100` 条
2. `tool_agent_train.jsonl` 合法率达到 `0.98`
3. 合法样本里的 query 去重率达到 `0.95`
4. 工具覆盖达到全部 `8` 类
5. `tool_agent_sft_train.jsonl` 的合法率达到 `0.98`
6. 合法训练样本中的并行样本占比达到 `0.25`
7. 合法训练样本中的“同时包含 wait/no-wait 工具”的混合并行样本占比达到 `0.18`
8. `semantic_cluster_ratio` 达到 `0.30`

如果某项没有达到上面的满分阈值，会按比例线性折算，而不是直接清零。

### 语义多样性指标

`semantic_cluster_ratio` 用来近似衡量你提交的训练 query 是否只是“同一句话反复改几个词”，还是确实覆盖了较多不同表达模式。官方实现逻辑如下：

1. 从 `tool_agent_train.jsonl` 中抽取所有合法样本的 `query`
2. 去掉空串，并按文本去重
3. 如果去重后的 query 超过 `128` 条，则按均匀步长采样到最多 `128` 条
4. 用 `Qwen3-Embedding-0.6B` 对这些 query 编码
5. 对每条 query 的最后一层 hidden states 做 attention-mask 加权平均，再做 L2 normalize，得到句向量
6. 按顺序做一个简单的在线聚类：
   - 第一条 query 先作为第一个 cluster center
   - 后续每条 query 与所有已有 center 做 cosine 相似度
   - 如果最大相似度 `>= 0.88`，则归入对应 cluster，并更新该 cluster center
   - 否则新建一个 cluster
7. 最终计算：

```text
semantic_cluster_ratio = 聚类数 / 采样数
```

这个值越高，表示 query 在 embedding 空间里越分散，说明语义表达越多样；越低则说明很多 query 在语义上过于接近。

在 A 部分计分时，这一项不会直接按 ratio 记分，而是先换算成：

```text
semantic_component = min(1.0, semantic_cluster_ratio / 0.30)
```

也就是说：

1. 当 `semantic_cluster_ratio >= 0.30` 时，语义多样性这一小项记满
2. 当 `semantic_cluster_ratio < 0.30` 时，按比例线性扣分
3. 这一小项在 A 部分 `10` 分里占 `16%`，满分贡献是 `1.6` 分

注意，这个指标只衡量 query 表达层面的语义分散度，不直接看工具是否选对，也不直接看参数是否命中；它的目的只是防止训练集退化成大量近乎重复的句式改写。

### 运行方式

推荐顺序如下：

```bash
python T2/train_tool_agent.py
python T2/evaluate.py
```

### 提示

1. 评测器不会替你重新跑完整的数据构造和完整训练流程，所以你必须把训练集和训练后模型一并提交。
2. 本题不再提供数据合成脚本；请直接参考 `T0/T1` 的 Prompt 扩写方法，自行准备 `artifacts/tool_agent_train.jsonl`。
3. 正式评测主要看 `artifacts/` 中的真实产物能否被官方脚本直接加载，以及模型本身在公开集和隐藏集上的表现。
4. 训练代码必须按 full fine-tuning 书写，不应出现 LoRA、冻结层或其它参数高效微调分支。
5. 训练时最好使用和官方一致的固定 system prompt，并通过 `tools=` 使用真实 Qwen 工具模板，让模型直接输出带有外层 `wait_for_result` 的 `<tool_call>...</tool_call>`；不要依赖 query 中出现显式等待策略提示。
6. `tool_agent_sft_train.jsonl` 会被检查格式合法性；同时会结合 `Qwen3-Embedding-0.6B` 评估语义多样性。
7. 官方评测的生成长度上限固定为 `256` 个新增 token，即 `max_new_tokens=256`；它限制的是模型新生成的输出长度，不是整段输入输出拼接后的总 token 数。
8. 请避免在评测时依赖在线 API 或远程服务；提交的模型目录必须能离线加载。
9. `artifacts/` 里提供的 `1` 条样例仅用于说明格式；如果你的 `tool_agent_train.jsonl` 或 `tool_agent_sft_train.jsonl` 最终仍然只有 `1` 条，A 项直接记 `0` 分。
