pull/791/head
baihaochen 2026-06-12 15:11:31 +08:00
parent 5012b232ff
commit 3233d9872c
14 changed files with 2504 additions and 90 deletions

21
agentic/__init__.py Normal file
View File

@ -0,0 +1,21 @@
from .data_analysis_env import (
AGENTIC_SYSTEM_PROMPT,
AgenticToolEnv,
average_agentic_metrics,
extract_final_answer,
format_agentic_user_prompt,
get_agentic_tools,
parse_tool_calls,
score_agentic_trajectory,
)
__all__ = [
"AGENTIC_SYSTEM_PROMPT",
"AgenticToolEnv",
"average_agentic_metrics",
"extract_final_answer",
"format_agentic_user_prompt",
"get_agentic_tools",
"parse_tool_calls",
"score_agentic_trajectory",
]

View File

@ -0,0 +1,566 @@
import ast
import contextlib
import io
import json
import math
import multiprocessing as mp
import os
import re
import statistics
from collections import Counter
from types import SimpleNamespace
import numpy as np
import pandas as pd
AGENTIC_SYSTEM_PROMPT = (
"你是一个面向运营数据分析的 Agent。你需要根据用户问题选择合适工具,"
"优先检索指标口径,再用 Python 对给定 CSV 数据进行计算;最终答案必须简洁,"
"包含关键结论和必要数值。工具调用必须使用 <tool_call> 包裹的 JSON。"
)
AGENTIC_TOOL_SCHEMAS = [
{
"type": "function",
"function": {
"name": "retriever",
"description": "检索指标口径、表字段说明或业务规则,返回最相关的知识片段。",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "检索查询"},
"top_k": {"type": "integer", "description": "返回片段数量", "default": 3},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "python_executor",
"description": "在受限 Python 环境中使用 pandas/numpy 读取给定 CSV 并完成数据分析。",
"parameters": {
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "Python 代码。把最终结果赋值给 result 或 answer。",
}
},
"required": ["code"],
},
},
},
{
"type": "function",
"function": {
"name": "calculator",
"description": "执行确定性数学表达式计算,适合简单四则运算、比例和幂运算。",
"parameters": {
"type": "object",
"properties": {
"expression": {"type": "string", "description": "数学表达式,如 1280 * 0.15 * 3.2"}
},
"required": ["expression"],
},
},
},
]
def get_agentic_tools(names=None):
if names is None:
names = ["retriever", "python_executor", "calculator"]
name_set = set(names)
return [tool for tool in AGENTIC_TOOL_SCHEMAS if tool["function"]["name"] in name_set]
def json_dumps(data):
return json.dumps(data, ensure_ascii=False, default=str)
def format_agentic_user_prompt(sample):
table_lines = []
for table in sample.get("tables", []):
table_lines.append(
f"- {table.get('name', '')}: path={table.get('path', '')}; {table.get('description', '')}"
)
tool_names = ", ".join(sample.get("tools", ["retriever", "python_executor", "calculator"]))
return (
f"用户问题:{sample.get('question', '')}\n\n"
f"可用数据表:\n" + "\n".join(table_lines) + "\n\n"
f"可用工具:{tool_names}\n"
"要求:先确认指标口径,再调用工具计算;最终答案只输出结论,不要编造数据。"
)
def parse_tool_calls(text):
calls = []
for match in re.findall(r"<tool_call>(.*?)</tool_call>", text or "", re.DOTALL):
raw = match.strip()
try:
data = json.loads(raw)
args = data.get("arguments", {})
if isinstance(args, str):
try:
args = json.loads(args)
except Exception:
args = {}
calls.append(
{
"name": data.get("name", ""),
"arguments": args if isinstance(args, dict) else {},
"raw": raw,
"valid_json": True,
}
)
except Exception:
calls.append({"name": "", "arguments": {}, "raw": raw, "valid_json": False})
return calls
def strip_thinking(text):
text = text or ""
return re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
def extract_final_answer(turn_outputs):
if isinstance(turn_outputs, str):
turn_outputs = [turn_outputs]
if not turn_outputs:
return ""
text = strip_thinking(turn_outputs[-1])
if "</tool_call>" in text:
text = text.split("</tool_call>")[-1]
text = re.sub(r"<tool_call>.*?</tool_call>", "", text, flags=re.DOTALL)
return text.strip()
def _tokenize(text):
return re.findall(r"[\w\u4e00-\u9fff]+", str(text).lower())
def _overlap_score(query, text):
q = Counter(_tokenize(query))
d = Counter(_tokenize(text))
return sum(min(q[token], d[token]) for token in q)
def _safe_number(value, default=0.0):
try:
return float(value)
except Exception:
return default
class _SafeArithmetic(ast.NodeVisitor):
allowed_binops = {
ast.Add: lambda a, b: a + b,
ast.Sub: lambda a, b: a - b,
ast.Mult: lambda a, b: a * b,
ast.Div: lambda a, b: a / b,
ast.FloorDiv: lambda a, b: a // b,
ast.Mod: lambda a, b: a % b,
ast.Pow: lambda a, b: a ** b,
}
allowed_unary = {
ast.UAdd: lambda a: +a,
ast.USub: lambda a: -a,
}
allowed_funcs = {
"abs": abs,
"round": round,
"sqrt": math.sqrt,
"log": math.log,
"log10": math.log10,
"ceil": math.ceil,
"floor": math.floor,
"pow": pow,
}
def visit_Expression(self, node):
return self.visit(node.body)
def visit_Constant(self, node):
if isinstance(node.value, (int, float)):
return float(node.value)
raise ValueError("calculator only supports numeric constants")
def visit_BinOp(self, node):
op = type(node.op)
if op not in self.allowed_binops:
raise ValueError(f"operator not allowed: {op.__name__}")
return self.allowed_binops[op](self.visit(node.left), self.visit(node.right))
def visit_UnaryOp(self, node):
op = type(node.op)
if op not in self.allowed_unary:
raise ValueError(f"operator not allowed: {op.__name__}")
return self.allowed_unary[op](self.visit(node.operand))
def visit_Call(self, node):
if not isinstance(node.func, ast.Name) or node.func.id not in self.allowed_funcs:
raise ValueError("function not allowed")
args = [self.visit(arg) for arg in node.args]
return self.allowed_funcs[node.func.id](*args)
def generic_visit(self, node):
raise ValueError(f"node not allowed: {type(node).__name__}")
def safe_calculate(expression):
normalized = (
str(expression)
.replace("^", "**")
.replace("×", "*")
.replace("÷", "/")
.replace("−", "-")
.replace("(", "(")
.replace(")", ")")
)
tree = ast.parse(normalized, mode="eval")
return _SafeArithmetic().visit(tree)
def _jsonable(value):
if isinstance(value, pd.DataFrame):
return value.to_dict("records")
if isinstance(value, pd.Series):
return value.to_dict()
if isinstance(value, np.generic):
return value.item()
if isinstance(value, dict):
return {str(k): _jsonable(v) for k, v in value.items()}
if isinstance(value, (list, tuple, set)):
return [_jsonable(v) for v in value]
return value
def _validate_python_ast(code):
banned_nodes = (ast.Import, ast.ImportFrom, ast.Global, ast.Nonlocal, ast.Lambda, ast.ClassDef)
banned_names = {
"__import__",
"eval",
"exec",
"compile",
"open",
"input",
"globals",
"locals",
"vars",
"dir",
"getattr",
"setattr",
"delattr",
"os",
"sys",
"subprocess",
"socket",
"requests",
"urllib",
"pathlib",
"shutil",
}
banned_attrs = {
"to_csv",
"to_excel",
"to_pickle",
"to_parquet",
"write",
"remove",
"unlink",
"rmdir",
"mkdir",
"rename",
"replace",
"system",
"popen",
}
tree = ast.parse(code, mode="exec")
for node in ast.walk(tree):
if isinstance(node, banned_nodes):
raise ValueError(f"python_executor does not allow {type(node).__name__}")
if isinstance(node, ast.Name) and node.id in banned_names:
raise ValueError(f"name not allowed: {node.id}")
if isinstance(node, ast.Attribute) and node.attr in banned_attrs:
raise ValueError(f"attribute not allowed: {node.attr}")
return tree
class _PandasProxy:
def __init__(self, repo_root, allowed_paths):
self.repo_root = repo_root
self.allowed_paths = {os.path.abspath(path) for path in allowed_paths}
def read_csv(self, path, *args, **kwargs):
candidates = []
raw = str(path)
if os.path.isabs(raw):
candidates.append(os.path.abspath(raw))
candidates.append(os.path.abspath(raw))
candidates.append(os.path.abspath(os.path.join(self.repo_root, raw)))
allowed_by_basename = {os.path.basename(path): path for path in self.allowed_paths}
if raw in allowed_by_basename:
candidates.append(allowed_by_basename[raw])
for candidate in candidates:
if candidate in self.allowed_paths:
return pd.read_csv(candidate, *args, **kwargs)
raise PermissionError(f"read_csv path is not in sample tables: {raw}")
def __getattr__(self, name):
return getattr(pd, name)
def _python_worker(code, repo_root, allowed_paths, queue):
try:
tree = _validate_python_ast(code)
output = io.StringIO()
safe_builtins = {
"abs": abs,
"all": all,
"any": any,
"bool": bool,
"dict": dict,
"enumerate": enumerate,
"float": float,
"int": int,
"len": len,
"list": list,
"max": max,
"min": min,
"pow": pow,
"print": print,
"range": range,
"round": round,
"set": set,
"sorted": sorted,
"str": str,
"sum": sum,
"tuple": tuple,
"zip": zip,
}
local_vars = {}
global_vars = {
"__builtins__": safe_builtins,
"pd": _PandasProxy(repo_root, allowed_paths),
"np": np,
"math": math,
"json": json,
"statistics": statistics,
}
with contextlib.redirect_stdout(output):
exec(compile(tree, "<agentic_python_executor>", "exec"), global_vars, local_vars)
value = local_vars.get("result", local_vars.get("answer", output.getvalue().strip()))
queue.put({"ok": True, "result": _jsonable(value), "error": "", "artifacts": []})
except Exception as exc:
queue.put({"ok": False, "result": "", "error": str(exc)[:500], "artifacts": []})
class AgenticToolEnv:
def __init__(self, sample, repo_root=None, timeout=3):
self.sample = sample
self.repo_root = os.path.abspath(repo_root or os.getcwd())
self.timeout = timeout
self.allowed_paths = [self._resolve_table_path(table.get("path", "")) for table in sample.get("tables", [])]
def _resolve_table_path(self, path):
if os.path.isabs(path):
return os.path.abspath(path)
return os.path.abspath(os.path.join(self.repo_root, path))
def execute(self, name, arguments=None):
arguments = arguments or {}
if name == "retriever":
return self._retriever(arguments)
if name == "calculator":
return self._calculator(arguments)
if name == "python_executor":
return self._python_executor(arguments)
return {"ok": False, "result": "", "error": f"unknown tool: {name}", "artifacts": []}
def _retriever(self, arguments):
query = str(arguments.get("query", self.sample.get("question", "")))
top_k = max(1, min(int(arguments.get("top_k", 3) or 3), 8))
docs = self.sample.get("documents", [])
scored = []
for doc in docs:
text = f"{doc.get('title', '')} {doc.get('text', '')}"
scored.append((_overlap_score(query, text), doc))
scored.sort(key=lambda item: item[0], reverse=True)
result = []
for score, doc in scored[:top_k]:
result.append(
{
"id": doc.get("id", ""),
"title": doc.get("title", ""),
"text": doc.get("text", ""),
"score": score,
}
)
return {"ok": True, "result": result, "error": "", "artifacts": []}
def _calculator(self, arguments):
expression = arguments.get("expression", "")
try:
value = safe_calculate(expression)
except Exception as exc:
return {"ok": False, "result": "", "error": str(exc)[:500], "artifacts": []}
return {"ok": True, "result": value, "error": "", "artifacts": []}
def _python_executor(self, arguments):
code = str(arguments.get("code", ""))
if not code.strip():
return {"ok": False, "result": "", "error": "empty code", "artifacts": []}
queue = mp.Queue(maxsize=1)
proc = mp.Process(target=_python_worker, args=(code, self.repo_root, self.allowed_paths, queue))
proc.start()
proc.join(self.timeout)
if proc.is_alive():
proc.terminate()
proc.join()
return {"ok": False, "result": "", "error": f"python_executor timeout>{self.timeout}s", "artifacts": []}
if queue.empty():
return {"ok": False, "result": "", "error": "python_executor returned no result", "artifacts": []}
return queue.get()
def validate_tool_call(call, available_names):
if not call.get("valid_json", False):
return False
name = call.get("name", "")
args = call.get("arguments", {})
if name not in available_names:
return False
if name == "retriever":
return bool(args.get("query"))
if name == "calculator":
return bool(args.get("expression"))
if name == "python_executor":
return bool(args.get("code"))
return False
def _f1(pred_items, gold_items):
pred = set(pred_items)
gold = set(gold_items)
if not pred and not gold:
return 1.0
if not pred or not gold:
return 0.0
inter = len(pred & gold)
if inter == 0:
return 0.0
precision = inter / len(pred)
recall = inter / len(gold)
return 2 * precision * recall / (precision + recall)
def _numbers(text):
return [float(x.replace(",", "")) for x in re.findall(r"[-+]?\d+(?:,\d{3})*(?:\.\d+)?", str(text))]
def score_answer(final_answer, sample):
checks = sample.get("checks", {}) or {}
score_parts = []
contains = checks.get("contains") or []
if contains:
score_parts.append(float(all(str(item).lower() in final_answer.lower() for item in contains)))
if "number" in checks:
target = _safe_number(checks.get("number"))
tolerance = _safe_number(checks.get("tolerance"), 1e-3)
nums = _numbers(final_answer)
score_parts.append(float(any(abs(num - target) <= tolerance for num in nums)))
if not score_parts and sample.get("answer"):
score_parts.append(float(str(sample["answer"]).lower() in final_answer.lower()))
return sum(score_parts) / len(score_parts) if score_parts else 0.0
def repetition_penalty(text, n=3, cap=0.5):
toks = _tokenize(text)
grams = [tuple(toks[i:i + n]) for i in range(len(toks) - n + 1)]
return min(cap, (len(grams) - len(set(grams))) * cap * 2 / len(grams)) if grams else 0.0
def score_agentic_trajectory(turn_outputs, sample, repo_root=None, unfinished=False, execute_tools=True):
if isinstance(turn_outputs, str):
turn_outputs = [turn_outputs]
turn_outputs = turn_outputs or []
available_names = set(sample.get("tools", ["retriever", "python_executor", "calculator"]))
expected_tools = sample.get("expected_tools", [])
env = AgenticToolEnv(sample, repo_root=repo_root)
calls = []
tag_errors = 0
for turn in turn_outputs:
tag_errors += abs(str(turn).count("<tool_call>") - str(turn).count("</tool_call>"))
calls.extend(parse_tool_calls(turn))
valid_flags = [validate_tool_call(call, available_names) for call in calls]
valid_count = sum(valid_flags)
invalid_count = len(calls) - valid_count
selected_tools = [call.get("name", "") for call, ok in zip(calls, valid_flags) if ok]
tool_f1 = _f1(selected_tools, expected_tools)
exec_success = 1.0
if execute_tools and calls:
successes = []
for call, ok in zip(calls, valid_flags):
if not ok:
successes.append(False)
continue
result = env.execute(call["name"], call["arguments"])
successes.append(bool(result.get("ok")))
exec_success = sum(successes) / len(successes) if successes else 0.0
final_answer = extract_final_answer(turn_outputs)
answer_score = 0.0 if unfinished else score_answer(final_answer, sample)
task_success = float(answer_score >= 0.999)
schema_valid_rate = valid_count / len(calls) if calls else 0.0
invalid_action_rate = invalid_count / len(calls) if calls else 0.0
format_score = 1.0 if tag_errors == 0 and all(call.get("valid_json") for call in calls) else 0.0
turn_count = len(turn_outputs)
expected_turns = max(1, int(sample.get("max_turns", 4)))
turn_efficiency = max(0.0, 1.0 - max(0, turn_count - expected_turns) / expected_turns)
repeat_penalty = repetition_penalty(final_answer)
reward = (
2.5 * task_success
+ 1.2 * answer_score
+ 0.8 * tool_f1
+ 0.6 * schema_valid_rate
+ 0.6 * exec_success
+ 0.4 * format_score
+ 0.2 * turn_efficiency
- 0.8 * invalid_action_rate
- 0.3 * tag_errors
- repeat_penalty
- (0.5 if unfinished else 0.0)
)
reward = max(min(reward, 5.0), -3.0)
return reward, {
"reward_total": reward,
"task_success": task_success,
"answer_score": answer_score,
"tool_selection_f1": tool_f1,
"schema_valid_rate": schema_valid_rate,
"exec_success_rate": exec_success,
"invalid_action_rate": invalid_action_rate,
"format_score": format_score,
"turn_efficiency": turn_efficiency,
"repetition_penalty": repeat_penalty,
"num_tool_calls": len(calls),
"avg_turns": turn_count,
}
def average_agentic_metrics(metrics):
if not metrics:
return {}
keys = sorted({key for row in metrics for key in row})
return {
key: sum(float(row.get(key, 0.0)) for row in metrics) / len(metrics)
for key in keys
}

View File

@ -0,0 +1,40 @@
import json
from torch.utils.data import Dataset
from agentic.data_analysis_env import AGENTIC_SYSTEM_PROMPT, format_agentic_user_prompt, get_agentic_tools
def load_agentic_jsonl(path):
rows = []
with open(path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
rows.append(json.loads(line))
return rows
class AgenticRLDataset(Dataset):
def __init__(self, jsonl_path, tokenizer=None, max_length=2048):
super().__init__()
self.samples = load_agentic_jsonl(jsonl_path)
self.tokenizer = tokenizer
self.max_length = max_length
def __len__(self):
return len(self.samples)
def __getitem__(self, index):
sample = self.samples[index]
tools = get_agentic_tools(sample.get("tools"))
messages = [
{"role": "system", "content": AGENTIC_SYSTEM_PROMPT},
{"role": "user", "content": format_agentic_user_prompt(sample)},
]
return {
"id": sample.get("id", str(index)),
"messages": messages,
"tools": tools,
"sample": sample,
}

View File

@ -0,0 +1,183 @@
# Agentic DataAnalysis:面向运营数据分析的 Agentic RL 系统
## 项目定位
面向运营数据分析场景,构建一个 300M 级 Agentic LLM:模型需要先理解指标口径,再调用 `retriever`、`python_executor`、`calculator` 等工具完成多步分析,最终输出可验证的业务结论。
这条线不重新预训练,直接使用已有 `SearchLM-300M` 预训练权重:
```text
out/pretrain_searchlm_1024_mla.pth
```
后续主流程:
```text
Agent 轨迹 SFT -> Agentic GRPO/CISPO -> 离线评测 -> Demo 展示
```
## 数据构造
生成运营数据表、指标文档、任务、标准答案和专家工具轨迹:
```bash
python scripts/build_agentic_data_tasks.py \
--output_dir dataset/agentic_data \
--train_size 30000 \
--dev_size 1000 \
--test_size 1000
```
输出:
- `dataset/agentic_data/tables/orders.csv`
- `dataset/agentic_data/tables/marketing.csv`
- `dataset/agentic_data/tables/users.csv`
- `dataset/agentic_data/train.jsonl`
- `dataset/agentic_data/dev.jsonl`
- `dataset/agentic_data/test.jsonl`
- `dataset/agentic_data/manifest.json`
训练前校验:
```bash
python scripts/validate_agentic_data.py \
--data dataset/agentic_data/train.jsonl
```
## SFT 冷启动
把专家轨迹转换成现有 `SFTDataset` 支持的 conversations 格式:
```bash
python scripts/build_agentic_sft.py \
--input dataset/agentic_data/train.jsonl \
--output dataset/agentic_data/sft.jsonl
```
使用已有 300M 预训练模型进行 Agent SFT:
```bash
cd trainer
deepspeed --num_gpus=7 train_full_sft_deepspeed.py \
--model_profile SearchLM-300M \
--from_weight pretrain_searchlm \
--save_weight agent_sft \
--data_path ../dataset/agentic_data/sft.jsonl \
--dtype float16 \
--save_interval 200
```
完整 14GB 通用 SFT 数据建议先训练一轮,再进入 Agent SFT。服务器可直接使用一键脚本:
```bash
bash scripts/run_general_sft_300m.sh
```
脚本默认使用 7 张 GPU、每卡 batch 1、梯度累积 8,有效 batch 为 56;如果检测到
`checkpoints/general_sft_14g_1024_mla_latest`,会自动加 `--from_resume 1`。日志写入 `logs/`。
常用覆盖参数:
```bash
USE_WANDB=1 SAVE_INTERVAL=200 bash scripts/run_general_sft_300m.sh
```
通用 SFT 完成后,将下面 Agent SFT 命令中的 `--from_weight` 改为 `general_sft_14g`。
## Agentic RL
使用 trajectory-level reward 优化多步工具调用行为:
```bash
cd trainer
deepspeed --num_gpus=7 train_agent_deepspeed.py \
--model_profile SearchLM-300M \
--from_weight agent_sft \
--save_weight agent_grpo \
--data_path ../dataset/agentic_data/train.jsonl \
--num_generations 6 \
--batch_size 1 \
--accumulation_steps 4 \
--dtype float16 \
--save_interval 25
```
断点续训:
```bash
deepspeed --num_gpus=7 train_agent_deepspeed.py \
--model_profile SearchLM-300M \
--from_weight agent_sft \
--save_weight agent_grpo \
--data_path ../dataset/agentic_data/train.jsonl \
--num_generations 6 \
--batch_size 1 \
--accumulation_steps 4 \
--dtype float16 \
--save_interval 25 \
--from_resume 1
```
`../checkpoints/` 保存完整 DeepSpeed 训练状态;`../out/agent_grpo_1024_mla.pth` 是轻量推理权重。
## Reward 指标
奖励函数位于 `agentic/data_analysis_env.py`,按整条轨迹评分:
- `task_success`:最终答案是否命中标准答案中的文本和数值校验。
- `answer_score`:文本 contains 与数值 tolerance 的平均得分。
- `tool_selection_f1`:调用工具集合与期望工具集合的 F1。
- `schema_valid_rate`:工具名、JSON 和必填参数是否合法。
- `exec_success_rate`:工具能否成功执行。
- `invalid_action_rate`:非法 action 占比。
- `avg_turns`:平均交互轮数。
- `format_score`:`<tool_call>` 标签和 JSON 是否闭合合法。
## 离线评测
直接跑模型生成:
```bash
python scripts/eval_agentic_data.py \
--data dataset/agentic_data/test.jsonl \
--weight agent_grpo \
--save_dir out \
--output reports/agentic_grpo_pred.jsonl \
--json_output reports/agentic_grpo_metrics.json \
--bad_cases_output reports/agentic_grpo_bad_cases.jsonl
```
也可以评测已有预测文件:
```bash
python scripts/eval_agentic_data.py \
--data dataset/agentic_data/test.jsonl \
--pred reports/agentic_grpo_pred.jsonl \
--json_output reports/agentic_grpo_metrics.json
```
建议固定对比:
```text
pretrain_searchlm -> agent_sft -> agent_grpo
```
核心简历指标使用固定 test split,不使用训练集结果。
## Demo
```bash
streamlit run scripts/agentic_data_demo.py -- \
--data dataset/agentic_data/dev.jsonl
```
Demo 展示问题、数据表、指标文档、工具轨迹、最终答案和 reward 分解,适合面试时说明 Agent 行为如何被训练和评估。
## 简历表达
可以写成:
> 构建面向运营数据分析的 Agentic RL 系统,基于 300M Decoder-only 预训练模型完成工具轨迹 SFT 与 GRPO/CISPO 后训练;设计 retriever、python executor、calculator 交互环境和 trajectory-level reward,在固定测试集上评估任务成功率、工具选择准确率、无效 action 率、Python 执行成功率和平均交互轮数。

View File

@ -1,129 +1,69 @@
{%- if tools %}
{{- '<|im_start|>system
' }}
{{- '<|im_start|>system\n' }}
{%- if messages[0].role == 'system' %}
{{- messages[0].content + '
' }}
{{- messages[0].content + '\n\n' }}
{%- endif %}
{{- "# Tools
You may call one or more functions to assist with the user query.
You are provided with function signatures within <tools></tools> XML tags:
<tools>" }}
{{- '# Tools\n\nYou may call one or more functions to assist with the user query.\n\nYou are provided with function signatures within <tools></tools> XML tags:\n<tools>' }}
{%- for tool in tools %}
{{- "
" }}
{{- '\n' }}
{{- tool | tojson }}
{%- endfor %}
{{- "
</tools>
For each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:
<tool_call>
{"name": <function-name>, "arguments": <args-json-object>}
</tool_call><|im_end|>
" }}
{{- '\n</tools>\n\nFor each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:\n<tool_call>\n{"name": <function-name>, "arguments": <args-json-object>}\n</tool_call><|im_end|>\n' }}
{%- else %}
{%- if messages[0].role == 'system' %}
{{- '<|im_start|>system
' + messages[0].content + '<|im_end|>
' }}
{{- '<|im_start|>system\n' + messages[0].content + '<|im_end|>\n' }}
{%- endif %}
{%- endif %}
{%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}
{%- for message in messages[::-1] %}
{%- set index = (messages|length - 1) - loop.index0 %}
{%- if ns.multi_step_tool and message.role == "user" and message.content is string and not(message.content.startswith('<tool_response>') and message.content.endswith('</tool_response>')) %}
{%- set ns.multi_step_tool = false %}
{%- set ns.last_query_index = index %}
{%- endif %}
{%- endfor %}
{%- for message in messages %}
{%- if message.content is string %}
{%- set content = message.content %}
{%- else %}
{%- set content = '' %}
{%- endif %}
{%- if (message.role == "user") or (message.role == "system" and not loop.first) %}
{{- '<|im_start|>' + message.role + '
' + content + '<|im_end|>' + '
' }}
{%- elif message.role == "assistant" %}
{%- if message.role == 'user' or (message.role == 'system' and not loop.first) %}
{{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>\n' }}
{%- elif message.role == 'assistant' %}
{%- set reasoning_content = '' %}
{%- if message.reasoning_content is string %}
{%- set reasoning_content = message.reasoning_content %}
{%- else %}
{%- if '</think>' in content %}
{%- set reasoning_content = content.split('</think>')[0].rstrip('
').split('<think>')[-1].lstrip('
') %}
{%- set content = content.split('</think>')[-1].lstrip('
') %}
{%- endif %}
{%- endif %}
{%- if true %}
{{- '<|im_start|>' + message.role + '
<think>
' + reasoning_content.strip('
') + '
</think>
' + content.lstrip('
') }}
{%- elif '</think>' in content %}
{%- set reasoning_content = content.split('</think>')[0].rstrip('\n').split('<think>')[-1].lstrip('\n') %}
{%- set content = content.split('</think>')[-1].lstrip('\n') %}
{%- endif %}
{{- '<|im_start|>assistant\n<think>\n' + reasoning_content.strip('\n') + '\n</think>\n\n' + content.lstrip('\n') }}
{%- if message.tool_calls %}
{%- for tool_call in message.tool_calls %}
{%- if (loop.first and content) or (not loop.first) %}
{{- '
' }}
{%- if (loop.first and content) or not loop.first %}
{{- '\n' }}
{%- endif %}
{%- if tool_call.function %}
{%- set tool_call = tool_call.function %}
{%- endif %}
{{- '<tool_call>
{"name": "' }}
{{- tool_call.name }}
{{- '", "arguments": ' }}
{{- '<tool_call>\n{"name": "' + tool_call.name + '", "arguments": ' }}
{%- if tool_call.arguments is string %}
{{- tool_call.arguments }}
{%- else %}
{{- tool_call.arguments | tojson }}
{%- endif %}
{{- '}
</tool_call>' }}
{{- '}\n</tool_call>' }}
{%- endfor %}
{%- endif %}
{{- '<|im_end|>
' }}
{%- elif message.role == "tool" %}
{%- if loop.first or (messages[loop.index0 - 1].role != "tool") %}
{{- '<|im_end|>\n' }}
{%- elif message.role == 'tool' %}
{%- if loop.first or messages[loop.index0 - 1].role != 'tool' %}
{{- '<|im_start|>user' }}
{%- endif %}
{{- '
<tool_response>
' }}
{{- content }}
{{- '
</tool_response>' }}
{%- if loop.last or (messages[loop.index0 + 1].role != "tool") %}
{{- '<|im_end|>
' }}
{{- '\n<tool_response>\n' + content + '\n</tool_response>' }}
{%- if loop.last or messages[loop.index0 + 1].role != 'tool' %}
{{- '<|im_end|>\n' }}
{%- endif %}
{%- endif %}
{%- endfor %}
{%- if add_generation_prompt %}
{{- '<|im_start|>assistant
' }}
{{- '<|im_start|>assistant\n' }}
{%- if open_thinking is defined and open_thinking is true %}
{{- '<think>
' }}
{{- '<think>\n' }}
{%- else %}
{{- '<think>
</think>
' }}
{{- '<think>\n\n</think>\n\n' }}
{%- endif %}
{%- endif %}
{%- endif %}

View File

@ -10,6 +10,7 @@ ngrok==1.4.0
nltk==3.8
numpy==1.26.4
openai==1.59.6
pandas==2.2.3
# peft==0.7.1
psutil==5.9.8
pydantic==2.11.5

View File

@ -0,0 +1,97 @@
import argparse
import json
import os
import sys
import pandas as pd
import streamlit as st
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from agentic.data_analysis_env import extract_final_answer, score_agentic_trajectory
from dataset.agentic_dataset import load_agentic_jsonl
DEFAULT_DATA = "dataset/agentic_data/dev.jsonl"
def trajectory_to_text(sample):
chunks = []
for item in sample.get("expert_trajectory", []):
if item.get("role") == "assistant" and item.get("tool_call"):
chunks.append("<tool_call>\n" + json.dumps(item["tool_call"], ensure_ascii=False, indent=2) + "\n</tool_call>")
elif item.get("role") == "assistant":
chunks.append(str(item.get("content", "")))
return "\n---\n".join(chunks)
def parse_turns(text):
if "\n---\n" in text:
return [part.strip() for part in text.split("\n---\n") if part.strip()]
return [text.strip()] if text.strip() else []
def parse_args():
parser = argparse.ArgumentParser(description="Agentic DataAnalysis Streamlit demo")
parser.add_argument("--data", default=DEFAULT_DATA)
parser.add_argument("--repo_root", default=".")
return parser.parse_args()
def main():
args = parse_args()
st.set_page_config(page_title="Agentic DataAnalysis", layout="wide")
st.title("Agentic DataAnalysis")
rows = load_agentic_jsonl(args.data) if os.path.exists(args.data) else []
if not rows:
st.error(f"数据不存在或为空:{args.data}")
return
sample_ids = [row.get("id", str(i)) for i, row in enumerate(rows)]
selected = st.sidebar.selectbox("样本", sample_ids)
sample = rows[sample_ids.index(selected)]
st.subheader("问题")
st.write(sample.get("question", ""))
left, right = st.columns([1.1, 1.0])
with left:
st.subheader("数据表")
st.dataframe(pd.DataFrame(sample.get("tables", [])), use_container_width=True)
st.subheader("指标文档")
st.dataframe(pd.DataFrame(sample.get("documents", [])), use_container_width=True)
with right:
st.subheader("标准答案")
st.write(sample.get("answer", ""))
st.json(sample.get("checks", {}))
st.subheader("期望工具")
st.write(", ".join(sample.get("expected_tools", [])))
st.subheader("轨迹与奖励")
default_text = trajectory_to_text(sample)
trajectory_text = st.text_area("轨迹文本(用 --- 分隔多个 assistant turn 可手动评测模型输出)", value=default_text, height=360)
turn_outputs = [
part
for part in parse_turns(trajectory_text)
if "<tool_response>" not in part
]
reward, parts = score_agentic_trajectory(turn_outputs, sample, repo_root=args.repo_root)
c1, c2, c3, c4, c5 = st.columns(5)
c1.metric("Reward", f"{reward:.3f}")
c2.metric("Success", f"{parts['task_success']:.3f}")
c3.metric("Tool F1", f"{parts['tool_selection_f1']:.3f}")
c4.metric("Invalid", f"{parts['invalid_action_rate']:.3f}")
c5.metric("Turns", f"{parts['avg_turns']:.1f}")
st.subheader("Reward 分解")
st.dataframe(pd.DataFrame([parts]).T.rename(columns={0: "score"}), use_container_width=True)
st.subheader("Final Answer")
st.write(extract_final_answer(turn_outputs))
if __name__ == "__main__":
main()

View File

@ -0,0 +1,488 @@
import argparse
import csv
import json
import os
import random
import sys
from collections import defaultdict
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from agentic.data_analysis_env import AgenticToolEnv, get_agentic_tools
CHANNELS = ["抖音", "微信", "百度", "小红书", "自然流量"]
CATEGORIES = ["美妆", "数码", "服饰", "食品", "家居"]
PROVINCES = ["广东", "浙江", "北京", "上海", "四川", "湖北"]
METRIC_DOCS = [
{
"id": "metric_refund_amount",
"title": "退款金额",
"text": "退款金额使用 orders 表的 refund_amount 字段求和;按渠道、品类、省份或时间过滤后再聚合。",
},
{
"id": "metric_net_revenue",
"title": "净收入",
"text": "净收入 = GMV - 退款金额。GMV 使用 orders.gmv,退款金额使用 orders.refund_amount。",
},
{
"id": "metric_refund_rate",
"title": "退款率",
"text": "退款率 = 退款金额 / GMV。按品类或渠道对 refund_amount 与 gmv 分别求和后再相除。",
},
{
"id": "metric_roas",
"title": "ROAS",
"text": "ROAS = GMV / 广告花费。GMV 来自 orders 表,广告花费来自 marketing 表的 spend 字段。",
},
{
"id": "metric_new_users",
"title": "新增用户",
"text": "新增用户使用 users 表的 new_users 字段求和;活跃用户使用 active_users 字段求和。",
},
{
"id": "schema_orders",
"title": "orders 表字段",
"text": "orders 包含 month、quarter、channel、category、province、gmv、refund_amount、orders_count。",
},
{
"id": "schema_marketing",
"title": "marketing 表字段",
"text": "marketing 包含 month、quarter、channel、spend、impressions、clicks。",
},
{
"id": "schema_users",
"title": "users 表字段",
"text": "users 包含 month、quarter、channel、province、active_users、new_users。",
},
]
def mkdir(path):
os.makedirs(path, exist_ok=True)
def write_csv(path, rows):
if not rows:
raise ValueError(f"empty csv rows: {path}")
with open(path, "w", encoding="utf-8", newline="") as f:
writer = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
writer.writeheader()
writer.writerows(rows)
def relpath(path):
cwd = os.getcwd()
try:
return os.path.relpath(path, cwd)
except ValueError:
return path
def quarter_of(month):
return f"Q{(month - 1) // 3 + 1}"
def generate_tables(output_dir, seed):
rng = random.Random(seed)
table_dir = os.path.join(output_dir, "tables")
mkdir(table_dir)
orders = []
for month in range(1, 13):
season = 1.0 + (0.18 if month in {6, 11, 12} else 0.0) + (0.08 if month in {1, 2} else 0.0)
for channel_idx, channel in enumerate(CHANNELS):
for category_idx, category in enumerate(CATEGORIES):
for province_idx, province in enumerate(PROVINCES):
base = 42000 + channel_idx * 4600 + category_idx * 3100 + province_idx * 1800
gmv = round((base + rng.uniform(-5200, 6200)) * season, 2)
refund_rate = 0.025 + 0.006 * category_idx + 0.003 * channel_idx + rng.uniform(0.0, 0.018)
refund_amount = round(gmv * refund_rate, 2)
orders_count = max(1, int(gmv / (90 + category_idx * 12 + rng.uniform(-8, 8))))
orders.append(
{
"month": month,
"quarter": quarter_of(month),
"channel": channel,
"category": category,
"province": province,
"gmv": gmv,
"refund_amount": refund_amount,
"orders_count": orders_count,
}
)
marketing = []
for month in range(1, 13):
for channel_idx, channel in enumerate(CHANNELS):
spend = round(18000 + channel_idx * 3500 + month * 420 + rng.uniform(-1800, 2200), 2)
clicks = max(1, int(spend / (2.2 + channel_idx * 0.25 + rng.uniform(-0.2, 0.25))))
impressions = clicks * rng.randint(18, 36)
marketing.append(
{
"month": month,
"quarter": quarter_of(month),
"channel": channel,
"spend": spend,
"impressions": impressions,
"clicks": clicks,
}
)
users = []
for month in range(1, 13):
for channel_idx, channel in enumerate(CHANNELS):
for province_idx, province in enumerate(PROVINCES):
active_users = max(100, int(5200 + month * 110 + channel_idx * 520 + province_idx * 260 + rng.uniform(-420, 520)))
new_users = max(20, int(active_users * (0.08 + channel_idx * 0.008 + rng.uniform(0.0, 0.04))))
users.append(
{
"month": month,
"quarter": quarter_of(month),
"channel": channel,
"province": province,
"active_users": active_users,
"new_users": new_users,
}
)
paths = {
"orders": os.path.join(table_dir, "orders.csv"),
"marketing": os.path.join(table_dir, "marketing.csv"),
"users": os.path.join(table_dir, "users.csv"),
}
write_csv(paths["orders"], orders)
write_csv(paths["marketing"], marketing)
write_csv(paths["users"], users)
return {"orders": orders, "marketing": marketing, "users": users, "paths": paths}
def table_specs(paths):
return [
{
"name": "orders",
"path": relpath(paths["orders"]),
"description": "订单聚合表,包含 GMV、退款金额和订单数。",
},
{
"name": "marketing",
"path": relpath(paths["marketing"]),
"description": "投放聚合表,包含广告花费、曝光和点击。",
},
{
"name": "users",
"path": relpath(paths["users"]),
"description": "用户活跃聚合表,包含活跃用户和新增用户。",
},
]
def sum_field(rows, field, **filters):
total = 0.0
for row in rows:
if all(row.get(key) == value for key, value in filters.items()):
total += float(row[field])
return round(total, 2)
def top_group(rows, group_key, value_field, filters):
agg = defaultdict(float)
for row in rows:
if all(row.get(key) == value for key, value in filters.items()):
agg[row[group_key]] += float(row[value_field])
key, value = max(agg.items(), key=lambda item: item[1])
return key, round(value, 2)
def refund_rate_by_category(rows, month):
agg = defaultdict(lambda: {"gmv": 0.0, "refund": 0.0})
for row in rows:
if row["month"] == month:
agg[row["category"]]["gmv"] += float(row["gmv"])
agg[row["category"]]["refund"] += float(row["refund_amount"])
rates = {
category: values["refund"] / values["gmv"]
for category, values in agg.items()
if values["gmv"] > 0
}
category, rate = max(rates.items(), key=lambda item: item[1])
return category, round(rate, 4)
def monthly_gmv_by_channel(orders, month, channel):
return sum(float(row["gmv"]) for row in orders if row["month"] == month and row["channel"] == channel)
def marketing_spend(marketing, month, channel):
return sum(float(row["spend"]) for row in marketing if row["month"] == month and row["channel"] == channel)
def build_tool_call(name, arguments):
return {"name": name, "arguments": arguments}
def make_sample(sample_id, question, answer, checks, tables, expected_tools, actions, task_type, max_turns=4):
sample = {
"id": sample_id,
"question": question,
"tables": tables,
"documents": METRIC_DOCS,
"tools": ["retriever", "python_executor", "calculator"],
"expected_tools": expected_tools,
"answer": answer,
"checks": checks,
"max_turns": max_turns,
"metadata": {"task_type": task_type},
}
env = AgenticToolEnv(sample)
trajectory = []
for action in actions:
trajectory.append({"role": "assistant", "tool_call": action})
result = env.execute(action["name"], action["arguments"])
trajectory.append({"role": "tool", "name": action["name"], "content": result})
trajectory.append({"role": "assistant", "content": answer})
sample["expert_trajectory"] = trajectory
sample["tool_schemas"] = get_agentic_tools(sample["tools"])
return sample
def task_top_refund_channel(sample_id, tables, data, rng):
quarter = rng.choice(["Q1", "Q2", "Q3", "Q4"])
channel, value = top_group(data["orders"], "channel", "refund_amount", {"quarter": quarter})
question = f"2024年{quarter}退款金额最高的渠道是什么?请给出退款金额。"
code = (
f'orders = pd.read_csv("{tables[0]["path"]}")\n'
f'df = orders[orders["quarter"] == "{quarter}"]\n'
'agg = df.groupby("channel")["refund_amount"].sum().sort_values(ascending=False)\n'
'result = {"channel": agg.index[0], "refund_amount": round(float(agg.iloc[0]), 2)}'
)
answer = f"2024年{quarter}退款金额最高的渠道是{channel},退款金额为{value:.2f}元。"
return make_sample(
sample_id,
question,
answer,
{"contains": [channel], "number": value, "tolerance": 0.02},
tables,
["retriever", "python_executor"],
[
build_tool_call("retriever", {"query": "退款金额 指标口径 orders refund_amount", "top_k": 2}),
build_tool_call("python_executor", {"code": code}),
],
"top_refund_channel",
)
def task_net_revenue(sample_id, tables, data, rng):
month = rng.randint(1, 12)
channel = rng.choice(CHANNELS)
gmv = sum_field(data["orders"], "gmv", month=month, channel=channel)
refund = sum_field(data["orders"], "refund_amount", month=month, channel=channel)
value = round(gmv - refund, 2)
question = f"2024年{month}月{channel}渠道的净收入是多少?"
code = (
f'orders = pd.read_csv("{tables[0]["path"]}")\n'
f'df = orders[(orders["month"] == {month}) & (orders["channel"] == "{channel}")]\n'
'result = {"net_revenue": round(float(df["gmv"].sum() - df["refund_amount"].sum()), 2)}'
)
answer = f"2024年{month}月{channel}渠道的净收入为{value:.2f}元。"
return make_sample(
sample_id,
question,
answer,
{"contains": [channel], "number": value, "tolerance": 0.02},
tables,
["retriever", "python_executor"],
[
build_tool_call("retriever", {"query": "净收入 GMV 退款金额 计算口径", "top_k": 2}),
build_tool_call("python_executor", {"code": code}),
],
"net_revenue",
)
def task_refund_rate_category(sample_id, tables, data, rng):
month = rng.randint(1, 12)
category, rate = refund_rate_by_category(data["orders"], month)
percent = round(rate * 100, 2)
question = f"2024年{month}月退款率最高的品类是什么?退款率是多少?"
code = (
f'orders = pd.read_csv("{tables[0]["path"]}")\n'
f'df = orders[orders["month"] == {month}]\n'
'agg = df.groupby("category")[["refund_amount", "gmv"]].sum()\n'
'agg["refund_rate"] = agg["refund_amount"] / agg["gmv"]\n'
'top = agg.sort_values("refund_rate", ascending=False).iloc[0]\n'
'result = {"category": agg.sort_values("refund_rate", ascending=False).index[0], "refund_rate": round(float(top["refund_rate"] * 100), 2)}'
)
answer = f"2024年{month}月退款率最高的品类是{category},退款率为{percent:.2f}%。"
return make_sample(
sample_id,
question,
answer,
{"contains": [category], "number": percent, "tolerance": 0.02},
tables,
["retriever", "python_executor"],
[
build_tool_call("retriever", {"query": "退款率 指标口径 refund_amount gmv", "top_k": 2}),
build_tool_call("python_executor", {"code": code}),
],
"refund_rate_category",
)
def task_roas(sample_id, tables, data, rng):
month = rng.randint(1, 12)
channel = rng.choice([c for c in CHANNELS if c != "自然流量"])
gmv = monthly_gmv_by_channel(data["orders"], month, channel)
spend = marketing_spend(data["marketing"], month, channel)
value = round(gmv / spend, 2)
question = f"2024年{month}月{channel}渠道的ROAS是多少?"
code = (
f'orders = pd.read_csv("{tables[0]["path"]}")\n'
f'marketing = pd.read_csv("{tables[1]["path"]}")\n'
f'gmv = orders[(orders["month"] == {month}) & (orders["channel"] == "{channel}")]["gmv"].sum()\n'
f'spend = marketing[(marketing["month"] == {month}) & (marketing["channel"] == "{channel}")]["spend"].sum()\n'
'result = {"roas": round(float(gmv / spend), 2)}'
)
answer = f"2024年{month}月{channel}渠道的ROAS为{value:.2f}。"
return make_sample(
sample_id,
question,
answer,
{"contains": [channel], "number": value, "tolerance": 0.02},
tables,
["retriever", "python_executor"],
[
build_tool_call("retriever", {"query": "ROAS GMV 广告花费 指标口径", "top_k": 2}),
build_tool_call("python_executor", {"code": code}),
],
"roas",
)
def task_new_users(sample_id, tables, data, rng):
month = rng.randint(1, 12)
channel = rng.choice(CHANNELS)
province, value = top_group(data["users"], "province", "new_users", {"month": month, "channel": channel})
question = f"2024年{month}月{channel}渠道新增用户最多的省份是哪个?新增用户数是多少?"
code = (
f'users = pd.read_csv("{tables[2]["path"]}")\n'
f'df = users[(users["month"] == {month}) & (users["channel"] == "{channel}")]\n'
'agg = df.groupby("province")["new_users"].sum().sort_values(ascending=False)\n'
'result = {"province": agg.index[0], "new_users": int(agg.iloc[0])}'
)
answer = f"2024年{month}月{channel}渠道新增用户最多的省份是{province},新增用户数为{int(value)}。"
return make_sample(
sample_id,
question,
answer,
{"contains": [province], "number": int(value), "tolerance": 0.01},
tables,
["retriever", "python_executor"],
[
build_tool_call("retriever", {"query": "新增用户 users new_users 字段口径", "top_k": 2}),
build_tool_call("python_executor", {"code": code}),
],
"new_users",
)
def task_incremental_gmv(sample_id, tables, data, rng):
month = rng.randint(1, 12)
channel = rng.choice([c for c in CHANNELS if c != "自然流量"])
pct = rng.choice([10, 12, 15, 20])
gmv = monthly_gmv_by_channel(data["orders"], month, channel)
spend = marketing_spend(data["marketing"], month, channel)
roas = gmv / spend
incremental = round(spend * pct / 100 * roas, 2)
question = f"如果2024年{month}月{channel}渠道广告花费增加{pct}%,且ROAS保持不变,预计GMV增加多少?"
code = (
f'orders = pd.read_csv("{tables[0]["path"]}")\n'
f'marketing = pd.read_csv("{tables[1]["path"]}")\n'
f'gmv = orders[(orders["month"] == {month}) & (orders["channel"] == "{channel}")]["gmv"].sum()\n'
f'spend = marketing[(marketing["month"] == {month}) & (marketing["channel"] == "{channel}")]["spend"].sum()\n'
'result = {"spend": round(float(spend), 2), "roas": round(float(gmv / spend), 4)}'
)
expression = f"{spend:.2f} * {pct} / 100 * {roas:.6f}"
answer = f"预计GMV增加{incremental:.2f}元。"
return make_sample(
sample_id,
question,
answer,
{"contains": ["GMV"], "number": incremental, "tolerance": 0.05},
tables,
["retriever", "python_executor", "calculator"],
[
build_tool_call("retriever", {"query": "ROAS GMV 广告花费 预算增加 计算口径", "top_k": 2}),
build_tool_call("python_executor", {"code": code}),
build_tool_call("calculator", {"expression": expression}),
],
"incremental_gmv",
max_turns=5,
)
TASK_BUILDERS = [
task_top_refund_channel,
task_net_revenue,
task_refund_rate_category,
task_roas,
task_new_users,
task_incremental_gmv,
]
def write_jsonl(path, rows):
with open(path, "w", encoding="utf-8") as f:
for row in rows:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
def build_split(name, size, tables, data, seed):
rng = random.Random(seed)
rows = []
for i in range(size):
task_fn = TASK_BUILDERS[i % len(TASK_BUILDERS)]
sample_id = f"agentic_{name}_{i:06d}"
rows.append(task_fn(sample_id, tables, data, rng))
rng.shuffle(rows)
return rows
def parse_args():
parser = argparse.ArgumentParser(description="Build Agentic DataAnalysis tasks")
parser.add_argument("--output_dir", default="dataset/agentic_data")
parser.add_argument("--train_size", type=int, default=30000)
parser.add_argument("--dev_size", type=int, default=1000)
parser.add_argument("--test_size", type=int, default=1000)
parser.add_argument("--seed", type=int, default=42)
return parser.parse_args()
def main():
args = parse_args()
mkdir(args.output_dir)
data = generate_tables(args.output_dir, args.seed)
tables = table_specs(data["paths"])
splits = {
"train": build_split("train", args.train_size, tables, data, args.seed + 1),
"dev": build_split("dev", args.dev_size, tables, data, args.seed + 2),
"test": build_split("test", args.test_size, tables, data, args.seed + 3),
}
for split, rows in splits.items():
write_jsonl(os.path.join(args.output_dir, f"{split}.jsonl"), rows)
manifest = {
"task": "AgenticDataAnalysis",
"seed": args.seed,
"splits": {split: len(rows) for split, rows in splits.items()},
"tables": tables,
"tools": [tool["function"]["name"] for tool in get_agentic_tools()],
"task_types": [fn.__name__.replace("task_", "") for fn in TASK_BUILDERS],
}
with open(os.path.join(args.output_dir, "manifest.json"), "w", encoding="utf-8") as f:
json.dump(manifest, f, ensure_ascii=False, indent=2)
print(json.dumps(manifest, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()

View File

@ -0,0 +1,86 @@
import argparse
import json
import os
import sys
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from agentic.data_analysis_env import AGENTIC_SYSTEM_PROMPT, format_agentic_user_prompt, get_agentic_tools
from dataset.agentic_dataset import load_agentic_jsonl
def assistant_tool_message(tool_call):
return {
"role": "assistant",
"content": "",
"reasoning_content": "",
"tools": "",
"tool_calls": json.dumps(
[
{
"type": "function",
"function": {
"name": tool_call.get("name", ""),
"arguments": tool_call.get("arguments", {}),
},
}
],
ensure_ascii=False,
),
}
def normal_message(role, content):
return {
"role": role,
"content": content,
"reasoning_content": "",
"tools": "",
"tool_calls": "",
}
def convert_sample(sample):
tools = get_agentic_tools(sample.get("tools"))
conversations = [
{
"role": "system",
"content": AGENTIC_SYSTEM_PROMPT,
"reasoning_content": "",
"tools": json.dumps(tools, ensure_ascii=False),
"tool_calls": "",
},
normal_message("user", format_agentic_user_prompt(sample)),
]
for item in sample.get("expert_trajectory", []):
if item.get("role") == "assistant" and item.get("tool_call"):
conversations.append(assistant_tool_message(item["tool_call"]))
elif item.get("role") == "tool":
conversations.append(normal_message("tool", json.dumps(item.get("content", {}), ensure_ascii=False)))
elif item.get("role") == "assistant":
conversations.append(normal_message("assistant", str(item.get("content", ""))))
return {"id": sample.get("id", ""), "conversations": conversations}
def parse_args():
parser = argparse.ArgumentParser(description="Build Agentic DataAnalysis SFT conversations")
parser.add_argument("--input", required=True)
parser.add_argument("--output", required=True)
parser.add_argument("--limit", type=int, default=0)
return parser.parse_args()
def main():
args = parse_args()
rows = load_agentic_jsonl(args.input)
if args.limit > 0:
rows = rows[:args.limit]
os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True)
with open(args.output, "w", encoding="utf-8") as f:
for sample in rows:
f.write(json.dumps(convert_sample(sample), ensure_ascii=False) + "\n")
print(json.dumps({"input": args.input, "output": args.output, "samples": len(rows)}, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@ -0,0 +1,237 @@
import argparse
import json
import os
import sys
import time
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from agentic.data_analysis_env import (
AgenticToolEnv,
average_agentic_metrics,
extract_final_answer,
format_agentic_user_prompt,
get_agentic_tools,
parse_tool_calls,
score_agentic_trajectory,
)
from dataset.agentic_dataset import load_agentic_jsonl
def apply_eval_model_profile(args):
if not args.model_profile:
return
path = args.model_profiles_path
with open(path, "r", encoding="utf-8") as f:
profiles = json.load(f)
if args.model_profile not in profiles:
raise ValueError(f"Unknown model_profile={args.model_profile}. Available: {', '.join(profiles)}")
profile = profiles[args.model_profile]
for key, value in profile.items():
if hasattr(args, key):
setattr(args, key, value)
def load_model(args):
import torch
from transformers import AutoTokenizer
from model.model_minimind import MiniMindForCausalLM
from model.model_minimind_mla import MiniMindMLAForCausalLM
from trainer.trainer_utils import build_lm_config, get_model_suffix, resolve_attention_type
tokenizer = AutoTokenizer.from_pretrained(args.tokenizer_path)
config = build_lm_config(
hidden_size=args.hidden_size,
num_hidden_layers=args.num_hidden_layers,
max_seq_len=args.max_total_len,
use_moe=bool(args.use_moe),
attention_type=resolve_attention_type(args),
kv_lora_rank=args.kv_lora_rank,
q_lora_rank=args.q_lora_rank,
rope_dim=args.rope_dim,
num_attention_heads=args.num_attention_heads,
num_key_value_heads=args.num_key_value_heads,
intermediate_size=args.intermediate_size,
)
model = MiniMindMLAForCausalLM(config) if args.attention_type == "mla" or args.use_mla else MiniMindForCausalLM(config)
suffix = get_model_suffix(config)
weight_path = os.path.join(args.save_dir, f"{args.weight}_{config.hidden_size}{suffix}.pth")
weights = torch.load(weight_path, map_location=args.device)
model.load_state_dict(weights, strict=False)
dtype = torch.float16 if args.dtype == "float16" else torch.float32
if args.dtype == "bfloat16":
dtype = torch.bfloat16
model = model.to(args.device)
if args.device != "cpu" and args.dtype != "float32":
model = model.to(dtype=dtype)
return model.eval(), tokenizer
def generate_once(model, tokenizer, messages, tools, args):
import torch
prompt = tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True,
tools=tools,
open_thinking=bool(args.open_thinking),
)
inputs = tokenizer(prompt, return_tensors="pt", add_special_tokens=False).to(args.device)
if inputs["input_ids"].size(1) > args.max_total_len:
inputs["input_ids"] = inputs["input_ids"][:, -args.max_total_len:]
inputs["attention_mask"] = inputs["attention_mask"][:, -args.max_total_len:]
st = time.time()
with torch.no_grad():
generated = model.generate(
inputs["input_ids"],
attention_mask=inputs["attention_mask"],
max_new_tokens=args.max_new_tokens,
do_sample=bool(args.do_sample),
temperature=args.temperature,
top_p=args.top_p,
pad_token_id=tokenizer.pad_token_id,
eos_token_id=tokenizer.eos_token_id,
)
text = tokenizer.decode(generated[0][inputs["input_ids"].size(1):], skip_special_tokens=True)
return text, time.time() - st
def run_episode(model, tokenizer, sample, args):
tools = get_agentic_tools(sample.get("tools"))
messages = [
{"role": "system", "content": "你是一个面向运营数据分析的 Agent。"},
{"role": "user", "content": format_agentic_user_prompt(sample)},
]
env = AgenticToolEnv(sample, repo_root=args.repo_root, timeout=args.tool_timeout)
turn_outputs = []
elapsed = 0.0
unfinished = False
for turn in range(args.max_turns):
text, dt = generate_once(model, tokenizer, messages, tools, args)
elapsed += dt
turn_outputs.append(text)
calls = parse_tool_calls(text)
if not calls:
break
unfinished = turn == args.max_turns - 1
messages.append({"role": "assistant", "content": text})
for call in calls:
result = env.execute(call.get("name", ""), call.get("arguments", {}))
messages.append({"role": "tool", "content": json.dumps(result, ensure_ascii=False)})
if unfinished:
break
reward, parts = score_agentic_trajectory(
turn_outputs,
sample,
repo_root=args.repo_root,
unfinished=unfinished,
execute_tools=True,
)
return {
"id": sample.get("id", ""),
"question": sample.get("question", ""),
"prediction": extract_final_answer(turn_outputs),
"turn_outputs": turn_outputs,
"reward": reward,
"metrics": parts,
"latency_sec": elapsed,
}
def eval_predictions(args, rows):
pred_map = {}
with open(args.pred, "r", encoding="utf-8") as f:
for line in f:
if line.strip():
item = json.loads(line)
pred_map[item.get("id")] = item
outputs = []
for sample in rows:
item = pred_map.get(sample.get("id"), {})
turns = item.get("turn_outputs") or [item.get("prediction") or item.get("response") or item.get("output") or ""]
reward, parts = score_agentic_trajectory(turns, sample, repo_root=args.repo_root)
outputs.append(
{
"id": sample.get("id", ""),
"question": sample.get("question", ""),
"prediction": extract_final_answer(turns),
"turn_outputs": turns,
"reward": reward,
"metrics": parts,
}
)
return outputs
def parse_args():
parser = argparse.ArgumentParser(description="Evaluate Agentic DataAnalysis model or predictions")
parser.add_argument("--data", required=True)
parser.add_argument("--pred", default="")
parser.add_argument("--output", default="reports/agentic_eval_predictions.jsonl")
parser.add_argument("--json_output", default="reports/agentic_eval_metrics.json")
parser.add_argument("--bad_cases_output", default="reports/agentic_eval_bad_cases.jsonl")
parser.add_argument("--max_samples", type=int, default=0)
parser.add_argument("--repo_root", default=".")
parser.add_argument("--device", default="cuda")
parser.add_argument("--save_dir", default="out")
parser.add_argument("--weight", default="agent_grpo")
parser.add_argument("--tokenizer_path", default="model")
parser.add_argument("--dtype", default="float16", choices=["float16", "bfloat16", "float32"])
parser.add_argument("--hidden_size", default=1024, type=int)
parser.add_argument("--num_hidden_layers", default=24, type=int)
parser.add_argument("--num_attention_heads", default=8, type=int)
parser.add_argument("--num_key_value_heads", default=4, type=int)
parser.add_argument("--intermediate_size", default=3264, type=int)
parser.add_argument("--use_moe", default=0, type=int, choices=[0, 1])
parser.add_argument("--attention_type", default="mla", choices=["gqa", "mha", "mqa", "mla"])
parser.add_argument("--use_mla", default=0, type=int, choices=[0, 1])
parser.add_argument("--kv_lora_rank", default=192, type=int)
parser.add_argument("--q_lora_rank", default=384, type=int)
parser.add_argument("--rope_dim", default=None, type=int)
parser.add_argument("--max_total_len", default=2500, type=int)
parser.add_argument("--max_new_tokens", default=512, type=int)
parser.add_argument("--max_turns", default=4, type=int)
parser.add_argument("--tool_timeout", default=3, type=int)
parser.add_argument("--temperature", default=0.8, type=float)
parser.add_argument("--top_p", default=0.9, type=float)
parser.add_argument("--do_sample", default=1, type=int)
parser.add_argument("--open_thinking", default=0, type=int)
parser.add_argument("--model_profile", type=str, default="")
parser.add_argument("--model_profiles_path", type=str, default="configs/searchlm_profiles.json")
return parser.parse_args()
def main():
args = parse_args()
apply_eval_model_profile(args)
rows = load_agentic_jsonl(args.data)
if args.max_samples > 0:
rows = rows[: args.max_samples]
if args.pred:
outputs = eval_predictions(args, rows)
else:
model, tokenizer = load_model(args)
outputs = [run_episode(model, tokenizer, sample, args) for sample in rows]
os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True)
os.makedirs(os.path.dirname(args.json_output) or ".", exist_ok=True)
os.makedirs(os.path.dirname(args.bad_cases_output) or ".", exist_ok=True)
with open(args.output, "w", encoding="utf-8") as f:
for item in outputs:
f.write(json.dumps(item, ensure_ascii=False) + "\n")
metrics = average_agentic_metrics([item["metrics"] for item in outputs])
metrics["samples"] = len(outputs)
with open(args.json_output, "w", encoding="utf-8") as f:
json.dump(metrics, f, ensure_ascii=False, indent=2)
with open(args.bad_cases_output, "w", encoding="utf-8") as f:
for item in outputs:
if item["metrics"].get("task_success", 0) < 1:
f.write(json.dumps(item, ensure_ascii=False) + "\n")
print(json.dumps(metrics, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()

78
scripts/run_general_sft_300m.sh Executable file
View File

@ -0,0 +1,78 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DATA_PATH="${DATA_PATH:-${REPO_ROOT}/dataset/sft_t2t.jsonl}"
WEIGHT_PATH="${WEIGHT_PATH:-${REPO_ROOT}/out/pretrain_searchlm_1024_mla.pth}"
SAVE_WEIGHT="${SAVE_WEIGHT:-general_sft_14g}"
NUM_GPUS="${NUM_GPUS:-7}"
EPOCHS="${EPOCHS:-1}"
MICRO_BATCH="${MICRO_BATCH:-1}"
ACCUMULATION_STEPS="${ACCUMULATION_STEPS:-8}"
LEARNING_RATE="${LEARNING_RATE:-1e-5}"
SAVE_INTERVAL="${SAVE_INTERVAL:-500}"
LOG_INTERVAL="${LOG_INTERVAL:-20}"
NUM_WORKERS="${NUM_WORKERS:-8}"
USE_WANDB="${USE_WANDB:-0}"
FORCE_RESUME="${FORCE_RESUME:-auto}"
if [[ ! -s "${DATA_PATH}" ]]; then
echo "SFT dataset not found or empty: ${DATA_PATH}" >&2
exit 1
fi
if [[ ! -s "${WEIGHT_PATH}" ]]; then
echo "Pretrained checkpoint not found: ${WEIGHT_PATH}" >&2
echo "Expected from_weight=pretrain_searchlm -> out/pretrain_searchlm_1024_mla.pth" >&2
exit 1
fi
if ! command -v deepspeed >/dev/null 2>&1; then
echo "deepspeed command not found. Install it in the server training environment first." >&2
exit 1
fi
mkdir -p "${REPO_ROOT}/logs" "${REPO_ROOT}/out" "${REPO_ROOT}/checkpoints"
LATEST_FILE="${REPO_ROOT}/checkpoints/${SAVE_WEIGHT}_1024_mla_latest"
RESUME=0
if [[ "${FORCE_RESUME}" == "1" ]]; then
RESUME=1
elif [[ "${FORCE_RESUME}" == "auto" && -s "${LATEST_FILE}" ]]; then
RESUME=1
fi
STAMP="$(date +%Y%m%d_%H%M%S)"
LOG_FILE="${REPO_ROOT}/logs/${SAVE_WEIGHT}_${STAMP}.log"
echo "repo_root=${REPO_ROOT}"
echo "data_path=${DATA_PATH}"
echo "weight_path=${WEIGHT_PATH}"
echo "save_weight=${SAVE_WEIGHT}"
echo "num_gpus=${NUM_GPUS}"
echo "effective_batch=$((NUM_GPUS * MICRO_BATCH * ACCUMULATION_STEPS))"
echo "resume=${RESUME}"
echo "log_file=${LOG_FILE}"
WANDB_ARGS=()
if [[ "${USE_WANDB}" == "1" ]]; then
WANDB_ARGS+=(--use_wandb)
fi
cd "${REPO_ROOT}/trainer"
deepspeed --num_gpus="${NUM_GPUS}" train_full_sft_deepspeed.py \
--model_profile SearchLM-300M \
--from_weight pretrain_searchlm \
--save_weight "${SAVE_WEIGHT}" \
--data_path "${DATA_PATH}" \
--epochs "${EPOCHS}" \
--batch_size "${MICRO_BATCH}" \
--accumulation_steps "${ACCUMULATION_STEPS}" \
--learning_rate "${LEARNING_RATE}" \
--dtype float16 \
--num_workers "${NUM_WORKERS}" \
--log_interval "${LOG_INTERVAL}" \
--save_interval "${SAVE_INTERVAL}" \
--from_resume "${RESUME}" \
"${WANDB_ARGS[@]}" 2>&1 | tee "${LOG_FILE}"

View File

@ -0,0 +1,69 @@
import argparse
import json
import os
import sys
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from agentic.data_analysis_env import score_agentic_trajectory
from dataset.agentic_dataset import load_agentic_jsonl
REQUIRED_FIELDS = ["id", "question", "tables", "documents", "tools", "expected_tools", "answer", "checks"]
def trajectory_to_turn_outputs(sample):
turns = []
for item in sample.get("expert_trajectory", []):
if item.get("role") == "assistant" and item.get("tool_call"):
turns.append("<tool_call>\n" + json.dumps(item["tool_call"], ensure_ascii=False) + "\n</tool_call>")
elif item.get("role") == "assistant":
turns.append(str(item.get("content", "")))
return turns
def validate_sample(sample):
errors = []
for field in REQUIRED_FIELDS:
if field not in sample:
errors.append(f"missing field: {field}")
for table in sample.get("tables", []):
path = table.get("path", "")
if not path:
errors.append("table path is empty")
elif not os.path.exists(path):
errors.append(f"table path not found: {path}")
if not sample.get("expert_trajectory"):
errors.append("missing expert_trajectory")
if not errors:
reward, parts = score_agentic_trajectory(trajectory_to_turn_outputs(sample), sample)
if parts.get("task_success", 0) < 1:
errors.append(f"expert trajectory not successful: reward={reward:.3f}, parts={parts}")
return errors
def parse_args():
parser = argparse.ArgumentParser(description="Validate Agentic DataAnalysis JSONL")
parser.add_argument("--data", required=True)
parser.add_argument("--max_errors", type=int, default=20)
return parser.parse_args()
def main():
args = parse_args()
rows = load_agentic_jsonl(args.data)
total_errors = 0
for row in rows:
errors = validate_sample(row)
if errors:
total_errors += len(errors)
if total_errors <= args.max_errors:
print(json.dumps({"id": row.get("id"), "errors": errors}, ensure_ascii=False))
summary = {"data": args.data, "samples": len(rows), "errors": total_errors}
print(json.dumps(summary, ensure_ascii=False))
if total_errors:
raise SystemExit(1)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,576 @@
"""
DeepSpeed Agentic DataAnalysis GRPO/CISPO 入口。
推荐在 trainer/ 目录启动:
deepspeed --num_gpus=7 train_agent_deepspeed.py --model_profile SearchLM-300M
"""
import os
import sys
__package__ = "trainer"
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
import argparse
import json
import math
import random
import warnings
from contextlib import nullcontext
import torch
import torch.distributed as dist
import torch.nn.functional as F
from torch.utils.data import DataLoader, DistributedSampler
from agentic.data_analysis_env import average_agentic_metrics, parse_tool_calls, score_agentic_trajectory
from dataset.agentic_dataset import AgenticRLDataset
from trainer.rollout_engine import compute_per_token_logps, create_rollout_engine
from trainer.trainer_utils import (
Logger,
SkipBatchSampler,
add_deepspeed_args,
add_model_profile_args,
apply_model_profile,
assert_no_training_output_collision,
build_lm_config,
get_cuda_peak_memory_gb,
init_model,
is_main_process,
load_deepspeed_checkpoint,
load_deepspeed_config,
log_training_setup,
reduce_metrics,
resolve_attention_type,
save_deepspeed_checkpoint,
save_model_weights,
setup_seed,
)
warnings.filterwarnings("ignore")
def init_deepspeed_runtime(args, deepspeed):
deepspeed.init_distributed(dist_backend="nccl")
local_rank = int(os.environ.get("LOCAL_RANK", args.local_rank if args.local_rank >= 0 else 0))
if torch.cuda.is_available():
torch.cuda.set_device(local_rank)
args.device = f"cuda:{local_rank}"
else:
args.device = "cpu"
args.training_backend = "DeepSpeed-ZeRO"
setup_seed(42 + (dist.get_rank() if dist.is_initialized() else local_rank))
return local_rank
def collate_fn(batch):
return {
"id": [x["id"] for x in batch],
"messages": [x["messages"] for x in batch],
"tools": [x["tools"] for x in batch],
"sample": [x["sample"] for x in batch],
}
def rollout_single(args, rollout_engine, tokenizer, messages, tools, sample):
all_outputs = []
prompt_ids = None
response_ids = []
response_mask = []
response_old_logps = []
final_context = ""
unfinished = False
open_thinking = random.random() < args.thinking_ratio
for turn in range(args.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(args.device)
context_ids = inputs["input_ids"][0].tolist()
if prompt_ids is None:
prompt_ids = context_ids
rollout_result = rollout_engine.rollout(
prompt_ids=inputs["input_ids"],
attention_mask=inputs["attention_mask"],
num_generations=1,
max_new_tokens=args.max_gen_len,
temperature=args.temperature,
)
new_ids = rollout_result.completion_ids[0].tolist()
new_logps = rollout_result.per_token_logps[0].tolist()
pairs = [
(token_id, logp)
for token_id, logp in zip(new_ids, new_logps)
if token_id != tokenizer.pad_token_id and token_id != tokenizer.eos_token_id
]
new_ids = [token_id for token_id, _ in pairs]
new_logps = [logp for _, logp in pairs]
new_text = rollout_result.completions[0]
all_outputs.append(new_text)
response_ids.extend(new_ids)
response_mask.extend([1] * len(new_ids))
response_old_logps.extend(new_logps)
final_context = context + new_text
calls = parse_tool_calls(new_text)
if not calls:
break
unfinished = turn == args.max_turns - 1
messages.append({"role": "assistant", "content": new_text})
from agentic.data_analysis_env import AgenticToolEnv
env = AgenticToolEnv(sample, repo_root=args.repo_root, timeout=args.tool_timeout)
for call in calls:
result = env.execute(call.get("name", ""), call.get("arguments", {}))
result_str = json.dumps(result, ensure_ascii=False)[: args.max_observation_chars]
messages.append({"role": "tool", "content": result_str})
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))
final_context = observe_context
if unfinished:
break
return (
final_context,
prompt_ids or [],
response_ids,
response_mask,
response_old_logps,
all_outputs,
unfinished,
)
def rollout_batch(args, rollout_engine, tokenizer, messages_batch, tools_batch, samples):
contexts = []
prompt_ids_batch = []
response_ids_batch = []
response_masks_batch = []
response_old_logps_batch = []
turn_outputs_batch = []
unfinished_batch = []
for messages, tools, sample in zip(messages_batch, tools_batch, samples):
for _ in range(args.num_generations):
messages_copy = [dict(message) for message in messages]
context, prompt_ids, response_ids, response_mask, old_logps, turn_outputs, unfinished = rollout_single(
args,
rollout_engine,
tokenizer,
messages_copy,
tools,
sample,
)
contexts.append(context)
prompt_ids_batch.append(prompt_ids)
response_ids_batch.append(response_ids)
response_masks_batch.append(response_mask)
response_old_logps_batch.append(old_logps)
turn_outputs_batch.append(turn_outputs)
unfinished_batch.append(unfinished)
return contexts, prompt_ids_batch, response_ids_batch, response_masks_batch, response_old_logps_batch, turn_outputs_batch, unfinished_batch
def calculate_agentic_rewards(args, turn_outputs_batch, samples, unfinished_batch):
rewards = []
metrics = []
for idx, turn_outputs in enumerate(turn_outputs_batch):
sample_idx = idx // args.num_generations
reward, parts = score_agentic_trajectory(
turn_outputs,
samples[sample_idx],
repo_root=args.repo_root,
unfinished=unfinished_batch[idx],
execute_tools=True,
)
rewards.append(reward)
metrics.append(parts)
return torch.tensor(rewards, dtype=torch.float32, device=args.device), average_agentic_metrics(metrics)
def pack_rollout_samples(args, tokenizer, prompt_ids_batch, response_ids_batch, response_masks_batch, response_old_logps_batch):
packed = []
for prompt_ids, response_ids, response_mask, old_logps in zip(
prompt_ids_batch,
response_ids_batch,
response_masks_batch,
response_old_logps_batch,
):
ids = prompt_ids + response_ids
mask = [0] * len(prompt_ids) + response_mask
old_per_token = [0.0] * max(len(prompt_ids) - 1, 0) + old_logps
if len(ids) > args.max_total_len:
ids = ids[-args.max_total_len:]
mask = mask[-args.max_total_len:]
old_per_token = old_per_token[-(len(ids) - 1):]
target_len = max(len(ids) - 1, 0)
if len(old_per_token) < target_len:
old_per_token += [0.0] * (target_len - len(old_per_token))
elif len(old_per_token) > target_len:
old_per_token = old_per_token[-target_len:]
packed.append((ids, mask, old_per_token))
max_len = max(len(ids) for ids, _, _ in packed)
input_ids = torch.tensor(
[ids + [tokenizer.pad_token_id] * (max_len - len(ids)) for ids, _, _ in packed],
device=args.device,
dtype=torch.long,
)
full_response_masks = torch.tensor(
[mask + [0] * (max_len - len(mask)) for _, mask, _ in packed],
device=args.device,
dtype=torch.float32,
)
old_per_token_logps = torch.tensor(
[old + [0.0] * ((max_len - 1) - len(old)) for _, _, old in packed],
device=args.device,
dtype=torch.float32,
)
return input_ids, full_response_masks, old_per_token_logps
def train_epoch(args, lm_config, model_engine, tokenizer, loader, iters, rollout_engine, ref_model, start_step=0, wandb=None):
for step, batch in enumerate(loader, start=start_step + 1):
contexts, prompt_ids_batch, response_ids_batch, response_masks_batch, response_old_logps_batch, turn_outputs_batch, unfinished_batch = rollout_batch(
args,
rollout_engine,
tokenizer,
batch["messages"],
batch["tools"],
batch["sample"],
)
input_ids, full_response_masks, old_per_token_logps = pack_rollout_samples(
args,
tokenizer,
prompt_ids_batch,
response_ids_batch,
response_masks_batch,
response_old_logps_batch,
)
full_mask = (input_ids != tokenizer.pad_token_id).long()
rewards, reward_stats = calculate_agentic_rewards(args, turn_outputs_batch, batch["sample"], unfinished_batch)
res = model_engine(input_ids, attention_mask=full_mask)
aux_loss = res.aux_loss if getattr(lm_config, "use_moe", False) and res.aux_loss is not None else torch.tensor(0.0, device=args.device)
per_token_logps = F.log_softmax(res.logits[:, :-1, :], 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:]
token_counts = completion_mask.sum(dim=1)
valid_rows = token_counts > 0
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)
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 = (
((per_token_loss * completion_mask).sum(dim=1)[valid_rows] / token_counts[valid_rows].clamp(min=1)).mean()
if valid_rows.any()
else per_token_loss.sum() * 0.0
)
loss = policy_loss + aux_loss
model_engine.backward(loss)
model_engine.step()
if step % args.log_interval == 0 or step == iters:
reward_val = rewards.mean().item()
avg_len_val = token_counts.float().mean().item()
kl_ref_val = ((ref_per_token_logps - per_token_logps) * completion_mask).sum().item() / max(token_counts.sum().item(), 1)
lr_list = model_engine.get_lr()
current_lr = lr_list[0] if lr_list else args.learning_rate
metrics = reduce_metrics(
{
"reward": reward_val,
"avg_len": avg_len_val,
"kl_ref": kl_ref_val,
"policy_loss": policy_loss.item(),
"aux_loss": aux_loss.item(),
**reward_stats,
}
)
peak_memory_gb = get_cuda_peak_memory_gb()
memory_text = f", peak_memory: {peak_memory_gb:.2f} GB" if peak_memory_gb is not None else ""
Logger(
f"Epoch:[{args.current_epoch + 1}/{args.epochs}]({step}/{iters}), "
f"Reward:{metrics['reward']:.4f}, Success:{metrics.get('task_success', 0):.4f}, "
f"ToolF1:{metrics.get('tool_selection_f1', 0):.4f}, Invalid:{metrics.get('invalid_action_rate', 0):.4f}, "
f"Exec:{metrics.get('exec_success_rate', 0):.4f}, Turns:{metrics.get('avg_turns', 0):.2f}, "
f"KL:{metrics['kl_ref']:.4f}, Loss:{metrics['policy_loss']:.4f}, LR:{current_lr:.8f}"
f"{memory_text}"
)
if wandb and is_main_process():
log_data = {
"reward": metrics["reward"],
"kl_ref": metrics["kl_ref"],
"policy_loss": metrics["policy_loss"],
"aux_loss": metrics["aux_loss"],
"avg_response_len": metrics["avg_len"],
"learning_rate": current_lr,
}
log_data.update({key: value for key, value in metrics.items() if key not in log_data})
wandb.log(log_data)
if args.debug_mode and is_main_process() and step % args.debug_interval == 0:
for i, sample_id in enumerate(batch["id"][:2]):
Logger(f"[DEBUG] sample={sample_id}")
for j in range(args.num_generations):
idx = i * args.num_generations + j
Logger(f"[DEBUG] gen={j}, reward={rewards[idx].item():.4f}, unfinished={unfinished_batch[idx]}")
Logger("\n---\n".join(turn_outputs_batch[idx]))
Logger("=" * 80)
if step % args.save_interval == 0 or step == iters:
model_engine.eval()
save_deepspeed_checkpoint(
model_engine,
lm_config,
weight=args.save_weight,
epoch=args.current_epoch,
step=step,
wandb=wandb,
save_dir="../checkpoints",
)
if is_main_process():
ckp = save_model_weights(lm_config, model_engine, save_dir=args.save_dir, weight=args.save_weight)
Logger(f"Lightweight model saved: {ckp}")
model_engine.train()
rollout_engine.update_policy(model_engine)
del contexts, prompt_ids_batch, response_ids_batch, response_masks_batch, response_old_logps_batch
del turn_outputs_batch, unfinished_batch, input_ids, full_response_masks, old_per_token_logps
del per_token_logps, ref_per_token_logps, rewards, grouped_rewards, mean_r, std_r, advantages
del completion_mask, token_counts, res, loss, policy_loss, aux_loss
def parse_args():
parser = argparse.ArgumentParser(description="Agentic DataAnalysis GRPO/CISPO with DeepSpeed ZeRO")
parser.add_argument("--save_dir", type=str, default="../out")
parser.add_argument("--save_weight", default="agent_grpo", type=str)
parser.add_argument("--epochs", type=int, default=1)
parser.add_argument("--batch_size", type=int, default=1)
parser.add_argument("--learning_rate", type=float, default=3e-7)
parser.add_argument("--device", type=str, default="cuda:0" if torch.cuda.is_available() else "cpu")
parser.add_argument("--dtype", type=str, default="float16", choices=["float16", "bfloat16", "float32"])
parser.add_argument("--num_workers", type=int, default=4)
parser.add_argument("--accumulation_steps", type=int, default=4)
parser.add_argument("--grad_clip", type=float, default=1.0)
parser.add_argument("--log_interval", type=int, default=1)
parser.add_argument("--save_interval", type=int, default=25)
parser.add_argument("--hidden_size", default=768, type=int)
parser.add_argument("--num_hidden_layers", default=8, type=int)
parser.add_argument("--num_attention_heads", default=8, type=int)
parser.add_argument("--num_key_value_heads", default=4, type=int)
parser.add_argument("--intermediate_size", default=None, type=int)
parser.add_argument("--use_moe", default=0, type=int, choices=[0, 1])
parser.add_argument("--attention_type", default="gqa", choices=["gqa", "mha", "mqa", "mla"])
parser.add_argument("--use_mla", default=0, type=int, choices=[0, 1])
parser.add_argument("--kv_lora_rank", default=128, type=int)
parser.add_argument("--q_lora_rank", default=256, type=int)
parser.add_argument("--rope_dim", default=None, type=int)
parser.add_argument("--max_seq_len", default=1536, type=int)
parser.add_argument("--max_gen_len", type=int, default=512)
parser.add_argument("--max_total_len", type=int, default=2500)
parser.add_argument("--max_turns", type=int, default=4)
parser.add_argument("--max_observation_chars", type=int, default=2048)
parser.add_argument("--tool_timeout", type=int, default=3)
parser.add_argument("--repo_root", type=str, default="..")
parser.add_argument("--data_path", type=str, default="../dataset/agentic_data/train.jsonl")
parser.add_argument("--num_generations", type=int, default=6)
parser.add_argument("--beta", type=float, default=0.05)
parser.add_argument("--loss_type", type=str, default="cispo", choices=["grpo", "cispo"])
parser.add_argument("--epsilon", type=float, default=0.2)
parser.add_argument("--epsilon_high", type=float, default=5.0)
parser.add_argument("--temperature", type=float, default=0.8)
parser.add_argument("--thinking_ratio", type=float, default=0.1)
parser.add_argument("--from_weight", default="agent_sft", type=str)
parser.add_argument("--from_resume", default=0, type=int, choices=[0, 1])
parser.add_argument("--overwrite_output", default=0, type=int, choices=[0, 1])
parser.add_argument("--use_wandb", action="store_true")
parser.add_argument("--wandb_project", type=str, default="Agentic-DataAnalysis-RL-DS")
parser.add_argument("--use_compile", default=0, type=int, choices=[0, 1])
parser.add_argument("--debug_mode", action="store_true")
parser.add_argument("--debug_interval", type=int, default=20)
parser.add_argument("--rollout_engine", type=str, default="torch", choices=["torch", "sglang"])
parser.add_argument("--sglang_base_url", type=str, default="http://localhost:8998")
parser.add_argument("--sglang_model_path", type=str, default="../model")
parser.add_argument("--sglang_shared_path", type=str, default="./sglang_ckpt_agentic")
add_model_profile_args(parser)
add_deepspeed_args(parser)
return parser.parse_args()
if __name__ == "__main__":
args = parse_args()
model_profile = apply_model_profile(args)
try:
import deepspeed
except ImportError as exc:
raise RuntimeError("DeepSpeed未安装。服务器环境执行: pip install deepspeed") from exc
local_rank = init_deepspeed_runtime(args, deepspeed)
os.makedirs(args.save_dir, exist_ok=True)
lm_config = build_lm_config(
hidden_size=args.hidden_size,
num_hidden_layers=args.num_hidden_layers,
max_seq_len=max(args.max_seq_len + args.max_gen_len, args.max_total_len),
use_moe=bool(args.use_moe),
attention_type=resolve_attention_type(args),
kv_lora_rank=args.kv_lora_rank,
q_lora_rank=args.q_lora_rank,
rope_dim=args.rope_dim,
num_attention_heads=args.num_attention_heads,
num_key_value_heads=args.num_key_value_heads,
intermediate_size=args.intermediate_size,
)
assert_no_training_output_collision(
lm_config,
weight=args.save_weight,
save_dir=args.save_dir,
checkpoint_dir="../checkpoints",
from_resume=args.from_resume,
overwrite=args.overwrite_output,
)
model, tokenizer = init_model(lm_config, args.from_weight, device=args.device)
if args.use_compile == 1:
model = torch.compile(model)
Logger("torch.compile enabled before DeepSpeed initialize")
train_ds = AgenticRLDataset(args.data_path, tokenizer, max_length=args.max_total_len)
train_sampler = DistributedSampler(train_ds) if dist.is_initialized() else None
loader_for_count = DataLoader(train_ds, batch_size=args.batch_size, sampler=train_sampler, collate_fn=collate_fn)
iters = len(loader_for_count)
total_optimizer_steps = max(1, math.ceil(iters / args.accumulation_steps) * args.epochs)
ds_config = load_deepspeed_config(args, total_optimizer_steps)
model_engine, _, _, _ = deepspeed.initialize(
model=model,
model_parameters=model.parameters(),
config=ds_config,
)
ckp_data = None
if args.from_resume == 1:
ckp_data = load_deepspeed_checkpoint(
model_engine,
lm_config,
weight=args.save_weight,
save_dir="../checkpoints",
)
ref_model, _ = init_model(lm_config, args.from_weight, device=args.device)
ref_model = ref_model.eval().requires_grad_(False)
if torch.cuda.is_available() and args.dtype in {"float16", "bfloat16"}:
ref_dtype = torch.float16 if args.dtype == "float16" else torch.bfloat16
ref_model = ref_model.to(dtype=ref_dtype)
device_type = "cuda" if "cuda" in args.device else "cpu"
amp_dtype = torch.bfloat16 if args.dtype == "bfloat16" else torch.float16
autocast_ctx = nullcontext() if device_type == "cpu" or args.dtype == "float32" else torch.cuda.amp.autocast(dtype=amp_dtype)
rollout_engine = create_rollout_engine(
engine_type=args.rollout_engine,
policy_model=model_engine,
tokenizer=tokenizer,
device=args.device,
autocast_ctx=autocast_ctx,
sglang_base_url=args.sglang_base_url,
sglang_model_path=args.sglang_model_path,
sglang_shared_path=args.sglang_shared_path,
)
rollout_engine.update_policy(model_engine)
wandb = None
if args.use_wandb and is_main_process():
import swanlab as wandb
wandb_id = ckp_data.get("wandb_id") if ckp_data else None
wandb.init(
project=args.wandb_project,
name=f"Agentic-RL-DS-{args.attention_type}-{args.hidden_size}",
id=wandb_id,
resume="must" if wandb_id else None,
)
log_training_setup(
args,
lm_config,
stage="agentic_rl_deepspeed",
dataset_len=len(train_ds),
iters=iters,
tokens_per_sample=args.max_total_len,
extra={
"model_profile": args.model_profile if model_profile else "",
"deepspeed_config": args.deepspeed_config,
"zero_stage": ds_config.get("zero_optimization", {}).get("stage", "none"),
"num_generations": args.num_generations,
"rollout_engine": args.rollout_engine,
"loss_type": args.loss_type,
"beta": args.beta,
"max_turns": args.max_turns,
"total_optimizer_steps": total_optimizer_steps,
},
)
start_epoch = ckp_data.get("epoch", 0) if ckp_data else 0
start_step = ckp_data.get("step", 0) if ckp_data else 0
for epoch in range(start_epoch, args.epochs):
args.current_epoch = epoch
train_sampler and train_sampler.set_epoch(epoch)
setup_seed(42 + epoch + local_rank)
indices = torch.randperm(len(train_ds)).tolist()
skip = start_step if (epoch == start_epoch and start_step > 0) else 0
batch_sampler = SkipBatchSampler(train_sampler or indices, args.batch_size, skip)
loader = DataLoader(
train_ds,
batch_sampler=batch_sampler,
num_workers=args.num_workers,
pin_memory=True,
collate_fn=collate_fn,
)
if skip > 0:
Logger(f"Epoch [{epoch + 1}/{args.epochs}]: 跳过前{start_step}个step,从step {start_step + 1}开始")
train_epoch(args, lm_config, model_engine, tokenizer, loader, len(loader) + skip, rollout_engine, ref_model, skip, wandb)
if dist.is_initialized():
dist.destroy_process_group()

View File

@ -71,6 +71,29 @@ def validate_sft_supervision(train_ds, max_samples=32):
)
def validate_chat_template(tokenizer):
try:
rendered = tokenizer.apply_chat_template(
[
{"role": "user", "content": "template check"},
{"role": "assistant", "content": "ok"},
],
tokenize=False,
add_generation_prompt=False,
)
except Exception as exc:
raise RuntimeError(
"Tokenizer chat template is invalid. Check model/chat_template.jinja and "
"model/tokenizer_config.json before loading the SFT dataset."
) from exc
required_markers = ("<|im_start|>user", "<|im_start|>assistant", "<|im_end|>")
missing = [marker for marker in required_markers if marker not in rendered]
if missing:
raise RuntimeError(f"Tokenizer chat template is missing required markers: {missing}")
if is_main_process():
Logger("Tokenizer chat template validation passed")
def init_deepspeed_runtime(args, deepspeed):
deepspeed.init_distributed(dist_backend="nccl")
local_rank = int(os.environ.get("LOCAL_RANK", args.local_rank if args.local_rank >= 0 else 0))
@ -129,7 +152,12 @@ def train_epoch(args, lm_config, model_engine, loader, iters, start_step=0, wand
}
)
if step % args.save_interval == 0 or step == iters:
should_save = step % args.save_interval == 0 or step == iters
del input_ids, labels, res, loss, aux_loss
if should_save:
if torch.cuda.is_available():
torch.cuda.empty_cache()
model_engine.eval()
save_deepspeed_checkpoint(
model_engine,
@ -144,10 +172,13 @@ def train_epoch(args, lm_config, model_engine, loader, iters, start_step=0, wand
if is_main_process():
ckp = save_model_weights(lm_config, model_engine, save_dir=args.save_dir, weight=args.save_weight)
Logger(f"Lightweight model saved: {ckp}")
if torch.cuda.is_available():
torch.cuda.empty_cache()
# rank0 导出轻量权重时,其他 rank 必须等待,避免提前销毁 NCCL process group。
if dist.is_initialized():
dist.barrier()
model_engine.train()
del input_ids, labels, res, loss, aux_loss
def parse_args():
parser = argparse.ArgumentParser(description="Full SFT with DeepSpeed ZeRO")
@ -222,6 +253,7 @@ if __name__ == "__main__":
)
model, tokenizer = init_model(lm_config, args.from_weight, device=args.device)
validate_chat_template(tokenizer)
if args.use_compile == 1:
model = torch.compile(model)
Logger("torch.compile enabled before DeepSpeed initialize")