starforge-cli 0.1.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- starforge_cli/__init__.py +3 -0
- starforge_cli/api_client.py +589 -0
- starforge_cli/auth.py +349 -0
- starforge_cli/catalog.py +124 -0
- starforge_cli/cli.py +74 -0
- starforge_cli/cli_ui.py +469 -0
- starforge_cli/client_device.py +104 -0
- starforge_cli/commands/__init__.py +1 -0
- starforge_cli/commands/admin.py +140 -0
- starforge_cli/commands/bench.py +94 -0
- starforge_cli/commands/common.py +178 -0
- starforge_cli/commands/dataset.py +150 -0
- starforge_cli/commands/exp.py +213 -0
- starforge_cli/commands/init.py +52 -0
- starforge_cli/commands/jobs.py +223 -0
- starforge_cli/commands/login.py +54 -0
- starforge_cli/commands/plugin.py +243 -0
- starforge_cli/commands/recipe.py +163 -0
- starforge_cli/commands/serve.py +79 -0
- starforge_cli/commands/submit.py +467 -0
- starforge_cli/commands/sweep.py +154 -0
- starforge_cli/config_resolve.py +17 -0
- starforge_cli/data_prep.py +60 -0
- starforge_cli/new_experiment.py +195 -0
- starforge_cli/packing.py +179 -0
- starforge_cli/plugins_lock.py +73 -0
- starforge_cli/project.py +130 -0
- starforge_cli/recipe_lock.py +453 -0
- starforge_cli/scaffold/agent-run.py.tmpl +146 -0
- starforge_cli/scaffold/custom-framework/train.sh +56 -0
- starforge_cli/scaffold/experiment-template/.gitkeep +0 -0
- starforge_cli/scaffold/experiment-template/README.md +36 -0
- starforge_cli/scaffold/experiment-template/config.yaml +44 -0
- starforge_cli/scaffold/project/common/README.md +12 -0
- starforge_cli/scaffold/project/common/__init__.py +0 -0
- starforge_cli/scaffold/project/configs/README.md +103 -0
- starforge_cli/scaffold/project/configs/base/README.md +24 -0
- starforge_cli/scaffold/project/configs/base/distillation_math.yaml +284 -0
- starforge_cli/scaffold/project/configs/base/grpo_lora.yaml +30 -0
- starforge_cli/scaffold/project/configs/base/grpo_math_1B.yaml +470 -0
- starforge_cli/scaffold/project/configs/base/grpo_megatron.yaml +43 -0
- starforge_cli/scaffold/project/configs/base/grpo_noncolocated.yaml +18 -0
- starforge_cli/scaffold/project/configs/base/grpo_sliding_puzzle.yaml +81 -0
- starforge_cli/scaffold/project/configs/base/ppo_math_1B.yaml +454 -0
- starforge_cli/scaffold/project/configs/base/rm.yaml +224 -0
- starforge_cli/scaffold/project/configs/base/sft.yaml +294 -0
- starforge_cli/scaffold/project/configs/models/README.md +16 -0
- starforge_cli/scaffold/project/configs/models/qwen3.5-4b.yaml +12 -0
- starforge_cli/scaffold/project/configs/models/qwen3.5-9b.yaml +10 -0
- starforge_cli/scaffold/project/gitignore +11 -0
- starforge_cli/spec_builder.py +372 -0
- starforge_cli-0.1.6.dist-info/METADATA +40 -0
- starforge_cli-0.1.6.dist-info/RECORD +55 -0
- starforge_cli-0.1.6.dist-info/WHEEL +4 -0
- starforge_cli-0.1.6.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
"""实验 recipe 锁文件(recipe.lock.json)的生成、检查与显式升级。
|
|
2
|
+
|
|
3
|
+
对外只暴露三个稳定动作:inspect / upgrade / require_current。
|
|
4
|
+
锁住的是当前 catalog 的 recipe bundle 与精确 runtime,不是创建它的 SDK 版本。
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Callable, Iterable
|
|
14
|
+
|
|
15
|
+
from starforge_core import __version__ as CORE_VERSION
|
|
16
|
+
from starforge_core.contract import SpecError
|
|
17
|
+
from starforge_core.recipes import Recipe, get_recipe
|
|
18
|
+
|
|
19
|
+
LOCK_FILE = "recipe.lock.json"
|
|
20
|
+
LOCK_VERSION = "forge/recipe-lock/v3"
|
|
21
|
+
_LEGACY_LOCK_VERSION = "forge/recipe-lock/v2"
|
|
22
|
+
_SUPPORTED_LOCK_VERSIONS = (LOCK_VERSION, _LEGACY_LOCK_VERSION)
|
|
23
|
+
_EXPERIMENT_KINDS = ("experiments", "projects", "smoke")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LockState(str, Enum):
|
|
27
|
+
CURRENT = "current"
|
|
28
|
+
CORE_INCOMPATIBLE = "sdk_incompatible"
|
|
29
|
+
RECIPE_STALE = "recipe_stale"
|
|
30
|
+
RUNTIME_REMOVED = "runtime_removed"
|
|
31
|
+
MALFORMED = "malformed"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class LockDiff:
|
|
36
|
+
field: str
|
|
37
|
+
locked: Any
|
|
38
|
+
current: Any
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class LockInspection:
|
|
43
|
+
state: LockState
|
|
44
|
+
path: Path
|
|
45
|
+
recipe_name: str
|
|
46
|
+
framework_version: str
|
|
47
|
+
payload: dict[str, Any] | None
|
|
48
|
+
expected: dict[str, Any] | None
|
|
49
|
+
diffs: tuple[LockDiff, ...]
|
|
50
|
+
message: str
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def is_current(self) -> bool:
|
|
54
|
+
return self.state is LockState.CURRENT
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class RecipeLockError(ValueError):
|
|
58
|
+
def __init__(self, inspection: LockInspection):
|
|
59
|
+
super().__init__(inspection.message)
|
|
60
|
+
self.inspection = inspection
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class RecipeLockManager:
|
|
64
|
+
"""锁文件的唯一入口:解析、分类、升级、提交前校验。"""
|
|
65
|
+
|
|
66
|
+
def inspect(self, exp_dir: Path, recipe_name: str = "") -> LockInspection:
|
|
67
|
+
path = Path(exp_dir) / LOCK_FILE
|
|
68
|
+
if not path.is_file():
|
|
69
|
+
return _malformed(
|
|
70
|
+
path,
|
|
71
|
+
recipe_name,
|
|
72
|
+
f"实验缺少 {LOCK_FILE};用 `sf new` 重建,或 `sf recipe upgrade` 生成",
|
|
73
|
+
)
|
|
74
|
+
try:
|
|
75
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
76
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
77
|
+
return _malformed(path, recipe_name, f"{path} 非法: {exc}")
|
|
78
|
+
if not isinstance(payload, dict):
|
|
79
|
+
return _malformed(path, recipe_name, f"{path} 根节点必须是对象")
|
|
80
|
+
|
|
81
|
+
normalized = _normalize_lock(payload)
|
|
82
|
+
if normalized is None:
|
|
83
|
+
return _malformed(
|
|
84
|
+
path,
|
|
85
|
+
recipe_name,
|
|
86
|
+
f"{path} 不是支持的 recipe lock({_LEGACY_LOCK_VERSION} / {LOCK_VERSION})",
|
|
87
|
+
)
|
|
88
|
+
locked_name = recipe_name.strip() or normalized["recipe_name"]
|
|
89
|
+
if not locked_name:
|
|
90
|
+
return _malformed(path, "", f"{path} 缺少 recipe.name")
|
|
91
|
+
if recipe_name.strip() and normalized["recipe_name"] not in {"", locked_name}:
|
|
92
|
+
return _malformed(
|
|
93
|
+
path,
|
|
94
|
+
locked_name,
|
|
95
|
+
f"{path} 锁定的是 {normalized['recipe_name']!r},与请求的 {locked_name!r} 不一致",
|
|
96
|
+
)
|
|
97
|
+
try:
|
|
98
|
+
recipe = get_recipe(locked_name)
|
|
99
|
+
except SpecError as exc:
|
|
100
|
+
return _malformed(path, locked_name, str(exc))
|
|
101
|
+
|
|
102
|
+
sdk_reason = recipe.core_incompatibility()
|
|
103
|
+
if sdk_reason:
|
|
104
|
+
return LockInspection(
|
|
105
|
+
state=LockState.CORE_INCOMPATIBLE,
|
|
106
|
+
path=path,
|
|
107
|
+
recipe_name=locked_name,
|
|
108
|
+
framework_version=normalized["framework_version"],
|
|
109
|
+
payload=payload,
|
|
110
|
+
expected=None,
|
|
111
|
+
diffs=(),
|
|
112
|
+
message=f"{path}: {sdk_reason}",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
locked_runtime = normalized["framework_version"]
|
|
116
|
+
if locked_runtime and locked_runtime not in recipe.runtime.supported_versions:
|
|
117
|
+
expected = recipe_lock(locked_name, recipe.runtime.default_version)
|
|
118
|
+
return LockInspection(
|
|
119
|
+
state=LockState.RUNTIME_REMOVED,
|
|
120
|
+
path=path,
|
|
121
|
+
recipe_name=locked_name,
|
|
122
|
+
framework_version=locked_runtime,
|
|
123
|
+
payload=payload,
|
|
124
|
+
expected=expected,
|
|
125
|
+
diffs=_diffs(normalized, expected),
|
|
126
|
+
message=(
|
|
127
|
+
f"{path} 锁定的 {recipe.framework}@{locked_runtime} 已从 catalog 移除;"
|
|
128
|
+
f"可用: {', '.join(recipe.runtime.supported_versions)}。"
|
|
129
|
+
f"执行 `sf recipe upgrade {exp_dir.name} --accept-runtime-change`"
|
|
130
|
+
" 或指定 --framework-version"
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
expected = recipe_lock(locked_name, locked_runtime)
|
|
135
|
+
diffs = _diffs(normalized, expected)
|
|
136
|
+
if diffs:
|
|
137
|
+
return LockInspection(
|
|
138
|
+
state=LockState.RECIPE_STALE,
|
|
139
|
+
path=path,
|
|
140
|
+
recipe_name=locked_name,
|
|
141
|
+
framework_version=locked_runtime or expected["framework"]["version"],
|
|
142
|
+
payload=payload,
|
|
143
|
+
expected=expected,
|
|
144
|
+
diffs=diffs,
|
|
145
|
+
message=(
|
|
146
|
+
f"{path} 与当前 core recipe bundle 不一致。"
|
|
147
|
+
f"执行 `sf recipe upgrade {exp_dir.name}` 后重新提交"
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
return LockInspection(
|
|
151
|
+
state=LockState.CURRENT,
|
|
152
|
+
path=path,
|
|
153
|
+
recipe_name=locked_name,
|
|
154
|
+
framework_version=expected["framework"]["version"],
|
|
155
|
+
payload=payload,
|
|
156
|
+
expected=expected,
|
|
157
|
+
diffs=(),
|
|
158
|
+
message=f"{path} 已锁定当前 recipe bundle",
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def require_current(self, exp_dir: Path, recipe_name: str = "") -> str:
|
|
162
|
+
inspection = self.inspect(exp_dir, recipe_name)
|
|
163
|
+
if not inspection.is_current:
|
|
164
|
+
raise RecipeLockError(inspection)
|
|
165
|
+
return inspection.framework_version
|
|
166
|
+
|
|
167
|
+
def write_current(
|
|
168
|
+
self,
|
|
169
|
+
exp_dir: Path,
|
|
170
|
+
recipe_name: str,
|
|
171
|
+
framework_version: str = "",
|
|
172
|
+
) -> dict[str, Any]:
|
|
173
|
+
payload = recipe_lock(recipe_name, framework_version)
|
|
174
|
+
_atomic_write(Path(exp_dir) / LOCK_FILE, payload)
|
|
175
|
+
return payload
|
|
176
|
+
|
|
177
|
+
def upgrade(
|
|
178
|
+
self,
|
|
179
|
+
exp_dir: Path,
|
|
180
|
+
*,
|
|
181
|
+
recipe_name: str = "",
|
|
182
|
+
framework_version: str = "",
|
|
183
|
+
accept_runtime_change: bool = False,
|
|
184
|
+
dry_run: bool = False,
|
|
185
|
+
validate_config: Callable[[Path, Recipe], list[str]] | None = None,
|
|
186
|
+
) -> LockInspection:
|
|
187
|
+
inspection = self.inspect(exp_dir, recipe_name)
|
|
188
|
+
if inspection.state is LockState.MALFORMED:
|
|
189
|
+
raise RecipeLockError(inspection)
|
|
190
|
+
if inspection.state is LockState.CORE_INCOMPATIBLE:
|
|
191
|
+
raise RecipeLockError(inspection)
|
|
192
|
+
|
|
193
|
+
selected = framework_version.strip()
|
|
194
|
+
if inspection.state is LockState.RUNTIME_REMOVED:
|
|
195
|
+
if not selected and not accept_runtime_change:
|
|
196
|
+
raise RecipeLockError(inspection)
|
|
197
|
+
selected = selected or get_recipe(inspection.recipe_name).runtime.default_version
|
|
198
|
+
elif not selected:
|
|
199
|
+
selected = inspection.framework_version
|
|
200
|
+
|
|
201
|
+
recipe = get_recipe(inspection.recipe_name)
|
|
202
|
+
try:
|
|
203
|
+
recipe.runtime.resolve(selected)
|
|
204
|
+
except SpecError as exc:
|
|
205
|
+
raise RecipeLockError(
|
|
206
|
+
LockInspection(
|
|
207
|
+
state=LockState.RUNTIME_REMOVED,
|
|
208
|
+
path=inspection.path,
|
|
209
|
+
recipe_name=inspection.recipe_name,
|
|
210
|
+
framework_version=selected,
|
|
211
|
+
payload=inspection.payload,
|
|
212
|
+
expected=recipe_lock(inspection.recipe_name, recipe.runtime.default_version),
|
|
213
|
+
diffs=inspection.diffs,
|
|
214
|
+
message=str(exc),
|
|
215
|
+
)
|
|
216
|
+
) from exc
|
|
217
|
+
|
|
218
|
+
if validate_config is not None:
|
|
219
|
+
errors = validate_config(Path(exp_dir), recipe)
|
|
220
|
+
if errors:
|
|
221
|
+
raise RecipeLockError(
|
|
222
|
+
LockInspection(
|
|
223
|
+
state=LockState.RECIPE_STALE,
|
|
224
|
+
path=inspection.path,
|
|
225
|
+
recipe_name=inspection.recipe_name,
|
|
226
|
+
framework_version=selected,
|
|
227
|
+
payload=inspection.payload,
|
|
228
|
+
expected=recipe_lock(inspection.recipe_name, selected),
|
|
229
|
+
diffs=inspection.diffs,
|
|
230
|
+
message=(
|
|
231
|
+
f"{inspection.path} 升级后 config 不兼容: "
|
|
232
|
+
+ ";".join(errors)
|
|
233
|
+
),
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
expected = recipe_lock(inspection.recipe_name, selected)
|
|
238
|
+
if not dry_run:
|
|
239
|
+
_atomic_write(inspection.path, expected)
|
|
240
|
+
return LockInspection(
|
|
241
|
+
state=LockState.CURRENT,
|
|
242
|
+
path=inspection.path,
|
|
243
|
+
recipe_name=inspection.recipe_name,
|
|
244
|
+
framework_version=selected,
|
|
245
|
+
payload=expected if not dry_run else inspection.payload,
|
|
246
|
+
expected=expected,
|
|
247
|
+
diffs=_diffs(_normalize_lock(inspection.payload or {}) or {}, expected),
|
|
248
|
+
message=(
|
|
249
|
+
f"{inspection.path} 将升级到当前 recipe bundle"
|
|
250
|
+
if dry_run
|
|
251
|
+
else f"{inspection.path} 已升级到当前 recipe bundle"
|
|
252
|
+
),
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
def upgrade_all(
|
|
256
|
+
self,
|
|
257
|
+
repo_root: Path,
|
|
258
|
+
*,
|
|
259
|
+
framework_version: str = "",
|
|
260
|
+
accept_runtime_change: bool = False,
|
|
261
|
+
dry_run: bool = False,
|
|
262
|
+
validate_config: Callable[[Path, Recipe], list[str]] | None = None,
|
|
263
|
+
) -> list[LockInspection]:
|
|
264
|
+
targets = list(iter_lock_dirs(repo_root))
|
|
265
|
+
planned: list[tuple[Path, LockInspection]] = []
|
|
266
|
+
failures: list[LockInspection] = []
|
|
267
|
+
for exp_dir in targets:
|
|
268
|
+
inspection = self.inspect(exp_dir)
|
|
269
|
+
if inspection.is_current:
|
|
270
|
+
planned.append((exp_dir, inspection))
|
|
271
|
+
continue
|
|
272
|
+
try:
|
|
273
|
+
planned.append((
|
|
274
|
+
exp_dir,
|
|
275
|
+
self.upgrade(
|
|
276
|
+
exp_dir,
|
|
277
|
+
framework_version=framework_version,
|
|
278
|
+
accept_runtime_change=accept_runtime_change,
|
|
279
|
+
dry_run=True,
|
|
280
|
+
validate_config=validate_config,
|
|
281
|
+
),
|
|
282
|
+
))
|
|
283
|
+
except RecipeLockError as exc:
|
|
284
|
+
failures.append(exc.inspection)
|
|
285
|
+
if failures:
|
|
286
|
+
raise RecipeLockBatchError(failures)
|
|
287
|
+
|
|
288
|
+
if dry_run:
|
|
289
|
+
return [item for _, item in planned]
|
|
290
|
+
|
|
291
|
+
backups: list[tuple[Path, bytes | None]] = []
|
|
292
|
+
written: list[LockInspection] = []
|
|
293
|
+
try:
|
|
294
|
+
for exp_dir, inspection in planned:
|
|
295
|
+
if inspection.expected is None or inspection.is_current and inspection.payload == inspection.expected:
|
|
296
|
+
written.append(inspection)
|
|
297
|
+
continue
|
|
298
|
+
path = exp_dir / LOCK_FILE
|
|
299
|
+
previous = path.read_bytes() if path.is_file() else None
|
|
300
|
+
_atomic_write(path, inspection.expected)
|
|
301
|
+
backups.append((path, previous))
|
|
302
|
+
written.append(
|
|
303
|
+
LockInspection(
|
|
304
|
+
state=LockState.CURRENT,
|
|
305
|
+
path=path,
|
|
306
|
+
recipe_name=inspection.recipe_name,
|
|
307
|
+
framework_version=inspection.expected["framework"]["version"],
|
|
308
|
+
payload=inspection.expected,
|
|
309
|
+
expected=inspection.expected,
|
|
310
|
+
diffs=inspection.diffs,
|
|
311
|
+
message=f"{path} 已升级到当前 recipe bundle",
|
|
312
|
+
)
|
|
313
|
+
)
|
|
314
|
+
except Exception:
|
|
315
|
+
_rollback(backups)
|
|
316
|
+
raise
|
|
317
|
+
return written
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class RecipeLockBatchError(ValueError):
|
|
321
|
+
def __init__(self, failures: list[LockInspection]):
|
|
322
|
+
lines = [item.message for item in failures]
|
|
323
|
+
super().__init__("批量升级中止,未写入任何锁文件:\n" + "\n".join(f"- {line}" for line in lines))
|
|
324
|
+
self.failures = failures
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def recipe_lock(recipe_name: str, framework_version: str = "") -> dict[str, Any]:
|
|
328
|
+
recipe = get_recipe(recipe_name)
|
|
329
|
+
selected = framework_version.strip() or recipe.runtime.default_version
|
|
330
|
+
variant = recipe.runtime.resolve(selected)
|
|
331
|
+
return {
|
|
332
|
+
"apiVersion": LOCK_VERSION,
|
|
333
|
+
"recipe": {
|
|
334
|
+
"name": recipe.id,
|
|
335
|
+
"version": recipe.version,
|
|
336
|
+
"digest": recipe.digest,
|
|
337
|
+
"manifest_digest": recipe.manifest_digest,
|
|
338
|
+
"template_digest": recipe.template_digest,
|
|
339
|
+
},
|
|
340
|
+
"framework": {
|
|
341
|
+
"kind": recipe.framework,
|
|
342
|
+
"version": selected,
|
|
343
|
+
"runtime_id": variant.runtime_id,
|
|
344
|
+
},
|
|
345
|
+
"created_by": {"core_version": CORE_VERSION},
|
|
346
|
+
"requires": {"core": recipe.core_requires},
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def write_recipe_lock(exp_dir: Path, recipe_name: str, framework_version: str = "") -> None:
|
|
351
|
+
RecipeLockManager().write_current(exp_dir, recipe_name, framework_version)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def validate_recipe_lock(exp_dir: Path, recipe_name: str) -> str:
|
|
355
|
+
"""提交/校验入口:锁必须已经是当前 bundle,绝不静默改写。"""
|
|
356
|
+
return RecipeLockManager().require_current(exp_dir, recipe_name)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def iter_lock_dirs(repo_root: Path) -> Iterable[Path]:
|
|
360
|
+
root = Path(repo_root)
|
|
361
|
+
for kind in _EXPERIMENT_KINDS:
|
|
362
|
+
base = root / kind
|
|
363
|
+
if not base.is_dir():
|
|
364
|
+
continue
|
|
365
|
+
for path in sorted(base.iterdir()):
|
|
366
|
+
if path.is_dir() and (path / LOCK_FILE).is_file():
|
|
367
|
+
yield path
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _normalize_lock(payload: dict[str, Any]) -> dict[str, Any] | None:
|
|
371
|
+
api = str(payload.get("apiVersion") or "").strip()
|
|
372
|
+
recipe = payload.get("recipe") if isinstance(payload.get("recipe"), dict) else {}
|
|
373
|
+
if api == LOCK_VERSION:
|
|
374
|
+
framework = payload.get("framework") if isinstance(payload.get("framework"), dict) else {}
|
|
375
|
+
requires = payload.get("requires") if isinstance(payload.get("requires"), dict) else {}
|
|
376
|
+
return {
|
|
377
|
+
"apiVersion": api,
|
|
378
|
+
"recipe_name": str(recipe.get("name") or "").strip(),
|
|
379
|
+
"recipe_version": str(recipe.get("version") or "").strip(),
|
|
380
|
+
"digest": str(recipe.get("digest") or "").strip(),
|
|
381
|
+
"manifest_digest": str(recipe.get("manifest_digest") or "").strip(),
|
|
382
|
+
"template_digest": str(recipe.get("template_digest") or "").strip(),
|
|
383
|
+
"framework_kind": str(framework.get("kind") or "").strip(),
|
|
384
|
+
"framework_version": str(framework.get("version") or "").strip(),
|
|
385
|
+
"runtime_id": str(framework.get("runtime_id") or "").strip(),
|
|
386
|
+
"core_requires": str(requires.get("core") or "").strip(),
|
|
387
|
+
}
|
|
388
|
+
if api == _LEGACY_LOCK_VERSION:
|
|
389
|
+
return {
|
|
390
|
+
"apiVersion": api,
|
|
391
|
+
"recipe_name": str(recipe.get("name") or "").strip(),
|
|
392
|
+
"recipe_version": str(recipe.get("version") or "").strip(),
|
|
393
|
+
"digest": str(recipe.get("digest") or "").strip(),
|
|
394
|
+
"manifest_digest": "",
|
|
395
|
+
"template_digest": "",
|
|
396
|
+
"framework_kind": str(recipe.get("framework") or "").strip(),
|
|
397
|
+
"framework_version": str(recipe.get("framework_version") or "").strip(),
|
|
398
|
+
"runtime_id": "",
|
|
399
|
+
"core_requires": "",
|
|
400
|
+
}
|
|
401
|
+
return None
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _diffs(normalized: dict[str, Any], expected: dict[str, Any]) -> tuple[LockDiff, ...]:
|
|
405
|
+
current = _normalize_lock(expected) or {}
|
|
406
|
+
fields = (
|
|
407
|
+
("apiVersion", "apiVersion"),
|
|
408
|
+
("recipe.name", "recipe_name"),
|
|
409
|
+
("recipe.version", "recipe_version"),
|
|
410
|
+
("recipe.digest", "digest"),
|
|
411
|
+
("recipe.manifest_digest", "manifest_digest"),
|
|
412
|
+
("recipe.template_digest", "template_digest"),
|
|
413
|
+
("framework.kind", "framework_kind"),
|
|
414
|
+
("framework.version", "framework_version"),
|
|
415
|
+
("framework.runtime_id", "runtime_id"),
|
|
416
|
+
("requires.core", "core_requires"),
|
|
417
|
+
)
|
|
418
|
+
out: list[LockDiff] = []
|
|
419
|
+
for label, key in fields:
|
|
420
|
+
locked = normalized.get(key, "")
|
|
421
|
+
wanted = current.get(key, "")
|
|
422
|
+
if locked != wanted:
|
|
423
|
+
out.append(LockDiff(field=label, locked=locked, current=wanted))
|
|
424
|
+
return tuple(out)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _malformed(path: Path, recipe_name: str, message: str) -> LockInspection:
|
|
428
|
+
return LockInspection(
|
|
429
|
+
state=LockState.MALFORMED,
|
|
430
|
+
path=path,
|
|
431
|
+
recipe_name=recipe_name,
|
|
432
|
+
framework_version="",
|
|
433
|
+
payload=None,
|
|
434
|
+
expected=None,
|
|
435
|
+
diffs=(),
|
|
436
|
+
message=message,
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _atomic_write(path: Path, payload: dict[str, Any]) -> None:
|
|
441
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
442
|
+
data = json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
|
|
443
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
444
|
+
tmp.write_text(data, encoding="utf-8")
|
|
445
|
+
os.replace(tmp, path)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _rollback(backups: list[tuple[Path, bytes | None]]) -> None:
|
|
449
|
+
for path, previous in reversed(backups):
|
|
450
|
+
if previous is None:
|
|
451
|
+
path.unlink(missing_ok=True)
|
|
452
|
+
else:
|
|
453
|
+
path.write_bytes(previous)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# 多轮 Agent(GRPO)训练骨架(NeMo-RL 0.6.0)。
|
|
3
|
+
# agent 本质就是 GRPO + max_rollout_turns>1 + 一个自定义环境;入口由所选 recipe 明确声明。
|
|
4
|
+
#
|
|
5
|
+
# 这是一个【可运行的最小骨架】:默认指向 NeMo-RL 官方 examples/run_grpo_sliding_puzzle.py 的范式,
|
|
6
|
+
# 你要做的是把下面两个 TODO 换成自己的「环境 + 数据集」:
|
|
7
|
+
# · 完整实战参考(自定义多工具环境 + 随机出题):experiments/agent-grpo_qwen3.5-9b_multitool_v1/run.py
|
|
8
|
+
# · 官方范式(滑块拼图):<NEMO_RL_DIR>/examples/run_grpo_sliding_puzzle.py
|
|
9
|
+
# 环境写法见 common/environments/(实现 EnvironmentInterface),config 的 env.<task>.cfg 由本文件读取。
|
|
10
|
+
import argparse
|
|
11
|
+
import itertools
|
|
12
|
+
import os
|
|
13
|
+
import pprint
|
|
14
|
+
import sys
|
|
15
|
+
from typing import Any, Iterator
|
|
16
|
+
|
|
17
|
+
from omegaconf import OmegaConf
|
|
18
|
+
from torch.utils.data import IterableDataset
|
|
19
|
+
|
|
20
|
+
THIS_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
21
|
+
REPO_ROOT = os.path.abspath(os.path.join(THIS_DIR, "..", ".."))
|
|
22
|
+
if REPO_ROOT not in sys.path:
|
|
23
|
+
sys.path.insert(0, REPO_ROOT)
|
|
24
|
+
|
|
25
|
+
from nemo_rl.algorithms.grpo import MasterConfig, grpo_train, setup
|
|
26
|
+
from nemo_rl.algorithms.utils import get_tokenizer, set_seed
|
|
27
|
+
from nemo_rl.data.interfaces import DatumSpec, LLMMessageLogType
|
|
28
|
+
from nemo_rl.distributed.virtual_cluster import init_ray
|
|
29
|
+
from nemo_rl.models.generation import configure_generation_config
|
|
30
|
+
from nemo_rl.utils.config import (
|
|
31
|
+
load_config,
|
|
32
|
+
parse_hydra_overrides,
|
|
33
|
+
register_omegaconf_resolvers,
|
|
34
|
+
)
|
|
35
|
+
from nemo_rl.utils.logger import get_next_experiment_dir
|
|
36
|
+
|
|
37
|
+
# TODO(1/2):换成你的环境(实现 nemo_rl 的 EnvironmentInterface,放在 common/environments/)。
|
|
38
|
+
# from common.environments.your_env import YourAgentEnv
|
|
39
|
+
|
|
40
|
+
TASK_NAME = "your_task" # 须与 config.yaml 的 env.<TASK_NAME>.cfg 对齐
|
|
41
|
+
STOP_STRINGS = ["</tool>", "</answer>"] # 按你的多轮协议设停止符
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_args():
|
|
45
|
+
parser = argparse.ArgumentParser(description="多轮 Agent GRPO 训练")
|
|
46
|
+
parser.add_argument("--config", type=str, default=None, help="YAML 配置路径")
|
|
47
|
+
args, overrides = parser.parse_known_args()
|
|
48
|
+
return args, overrides
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def generate_datum(tokenizer, env_cfg: dict[str, Any], idx: int) -> DatumSpec:
|
|
52
|
+
# TODO(2/2):构造一条训练样本(题面 + 目标),metadata 会被你的环境读取用于判分/多轮控制。
|
|
53
|
+
question = "TODO: 在这里生成/取一条需要多轮工具调用的题目"
|
|
54
|
+
prompt_text = tokenizer.apply_chat_template(
|
|
55
|
+
[{"role": "user", "content": question}],
|
|
56
|
+
tokenize=False, add_generation_prompt=True, add_special_tokens=False,
|
|
57
|
+
).strip()
|
|
58
|
+
token_ids = tokenizer(prompt_text, return_tensors="pt", add_special_tokens=False)["input_ids"][0]
|
|
59
|
+
message_log: LLMMessageLogType = [
|
|
60
|
+
{"role": "user", "content": prompt_text, "token_ids": token_ids}
|
|
61
|
+
]
|
|
62
|
+
metadata = {
|
|
63
|
+
"num_turns": 0,
|
|
64
|
+
"max_turns": int(env_cfg.get("max_turns", 6)),
|
|
65
|
+
# ... 你的环境判分所需字段(如 target / kb / tolerance)放这里
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
"message_log": message_log,
|
|
69
|
+
"length": len(token_ids),
|
|
70
|
+
"extra_env_info": metadata,
|
|
71
|
+
"loss_multiplier": 1.0,
|
|
72
|
+
"idx": idx,
|
|
73
|
+
"task_name": TASK_NAME,
|
|
74
|
+
"stop_strings": STOP_STRINGS,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class IterableAgentDataset(IterableDataset):
|
|
79
|
+
def __init__(self, tokenizer, env_cfg, length):
|
|
80
|
+
super().__init__()
|
|
81
|
+
self.tokenizer, self.env_cfg, self.length = tokenizer, env_cfg, length
|
|
82
|
+
|
|
83
|
+
def __iter__(self) -> Iterator[DatumSpec]:
|
|
84
|
+
for i in itertools.count():
|
|
85
|
+
yield generate_datum(self.tokenizer, self.env_cfg, i)
|
|
86
|
+
|
|
87
|
+
def __len__(self):
|
|
88
|
+
return self.length
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def main():
|
|
92
|
+
register_omegaconf_resolvers()
|
|
93
|
+
args, overrides = parse_args()
|
|
94
|
+
if not args.config:
|
|
95
|
+
args.config = os.path.join(THIS_DIR, "config.yaml")
|
|
96
|
+
|
|
97
|
+
config = load_config(args.config)
|
|
98
|
+
print(f"已加载配置: {args.config}")
|
|
99
|
+
if overrides:
|
|
100
|
+
print(f"CLI overrides: {overrides}")
|
|
101
|
+
config = parse_hydra_overrides(config, overrides)
|
|
102
|
+
config = OmegaConf.to_container(config, resolve=True)
|
|
103
|
+
config: MasterConfig = MasterConfig(**config)
|
|
104
|
+
print("最终配置:")
|
|
105
|
+
pprint.pprint(config)
|
|
106
|
+
|
|
107
|
+
config.logger["log_dir"] = get_next_experiment_dir(config.logger["log_dir"])
|
|
108
|
+
print(f"日志目录: {config.logger['log_dir']}")
|
|
109
|
+
|
|
110
|
+
init_ray()
|
|
111
|
+
set_seed(config.grpo["seed"])
|
|
112
|
+
|
|
113
|
+
tokenizer = get_tokenizer(config.policy["tokenizer"])
|
|
114
|
+
config.policy["generation"] = configure_generation_config(
|
|
115
|
+
config.policy["generation"], tokenizer
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
env_cfg = config.env[TASK_NAME]["cfg"]
|
|
119
|
+
# TODO:实例化你的环境(CPU actor:num_gpus=0)。
|
|
120
|
+
# env = YourAgentEnv.options(num_gpus=0).remote(cfg=dict(env_cfg))
|
|
121
|
+
raise SystemExit(
|
|
122
|
+
"agent 骨架未完成:请实现 TASK_NAME 对应的环境与 generate_datum,再取消下方训练调用的注释。\n"
|
|
123
|
+
"参考 experiments/agent-grpo_qwen3.5-9b_multitool_v1/run.py。"
|
|
124
|
+
)
|
|
125
|
+
task_to_env = {TASK_NAME: env} # noqa: F821 (实现 env 后取消上方 raise)
|
|
126
|
+
|
|
127
|
+
ds_length = (
|
|
128
|
+
config.grpo["num_prompts_per_step"]
|
|
129
|
+
* config.grpo["num_generations_per_prompt"]
|
|
130
|
+
* config.grpo["max_num_steps"]
|
|
131
|
+
)
|
|
132
|
+
dataset = IterableAgentDataset(tokenizer, env_cfg, ds_length)
|
|
133
|
+
val_dataset = IterableAgentDataset(tokenizer, env_cfg, config.grpo["max_val_samples"])
|
|
134
|
+
|
|
135
|
+
(policy, policy_generation, cluster, dataloader, val_dataloader,
|
|
136
|
+
loss_fn, logger, checkpointer, grpo_state, master_config) = setup(
|
|
137
|
+
config, tokenizer, dataset, val_dataset
|
|
138
|
+
)
|
|
139
|
+
grpo_train(
|
|
140
|
+
policy, policy_generation, dataloader, val_dataloader, tokenizer, loss_fn,
|
|
141
|
+
task_to_env, task_to_env, logger, checkpointer, grpo_state, master_config,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
if __name__ == "__main__":
|
|
146
|
+
main()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# 显式 custom recipe 的训练脚本。
|
|
3
|
+
#
|
|
4
|
+
# 由 Job Capsule runner 内的 CustomAdapter 直接执行。框架来自版本化 recipe,不读取
|
|
5
|
+
# FRAMEWORK 环境变量或 framework 文件,也不会由其他 adapter 失败后回退至此。
|
|
6
|
+
#
|
|
7
|
+
# ── 已经给你准备好的环境变量(契约,别改名)─────────────────────────────────────
|
|
8
|
+
# FORGE_OUT_DIR 产物目录(已 mkdir)。checkpoint / 日志请写这里,
|
|
9
|
+
# 它已按 <用户>/<实验>/<run_id> 隔离好,不会和别人互相覆盖
|
|
10
|
+
# FORGE_EXP_DIR 本实验目录(就是这个文件所在目录)
|
|
11
|
+
# FORGE_WORK_DIR 上传包根目录
|
|
12
|
+
# FORGE_FRAMEWORK / FORGE_RECIPE 固定为 custom / custom
|
|
13
|
+
# FORGE_CLUSTER_NUM_NODES ★服务端权威拓扑:你实际能用几个节点
|
|
14
|
+
# FORGE_CLUSTER_GPUS_PER_NODE ★服务端权威拓扑:每节点几张卡
|
|
15
|
+
# STARFORGE_ENDPOINT/RUN_ID/TOKEN 指标上报凭据(无则为本地直跑,上报自动 no-op)
|
|
16
|
+
# HF_HOME / HF_TOKEN / 各 *_DATA_DIR 由服务端按需注入
|
|
17
|
+
#
|
|
18
|
+
# ── 三件必须做的事 ─────────────────────────────────────────────────────────────
|
|
19
|
+
# ① checkpoint 写进 $FORGE_OUT_DIR —— 写别处的话作业结束就随临时目录一起没了
|
|
20
|
+
# ② 用 common/observability/report.py 上报指标 —— 否则 console 上只有日志、没有曲线:
|
|
21
|
+
# from common.observability.report import StarForgeCallback
|
|
22
|
+
# trainer = SFTTrainer(..., callbacks=[StarForgeCallback()])
|
|
23
|
+
# 手写循环则用 report.init() / report.log({...}, step=i) / report.finish()
|
|
24
|
+
# ③ 遵守 FORGE_CLUSTER_* 拓扑 —— 配额按它记账,watchdog 会做集群级 Ray 用卡对账,
|
|
25
|
+
# 实际占卡超出记账会被告警甚至停止作业
|
|
26
|
+
#
|
|
27
|
+
# ⚠️ 训练依赖必须烘焙进本作业引用的 digest runtime image。需要 TRL / flash-attn
|
|
28
|
+
# 等依赖时发布新的 runtime artifact;新作业自动采用,无需重启长驻集群。
|
|
29
|
+
set -euo pipefail
|
|
30
|
+
|
|
31
|
+
: "${FORGE_WORK_DIR:?FORGE_WORK_DIR is required}"
|
|
32
|
+
: "${FORGE_EXP_DIR:?FORGE_EXP_DIR is required}"
|
|
33
|
+
: "${FORGE_OUT_DIR:?FORGE_OUT_DIR is required}"
|
|
34
|
+
: "${FORGE_FRAMEWORK:?FORGE_FRAMEWORK is required}"
|
|
35
|
+
: "${FORGE_RECIPE:?FORGE_RECIPE is required}"
|
|
36
|
+
: "${FORGE_CLUSTER_NUM_NODES:?FORGE_CLUSTER_NUM_NODES is required}"
|
|
37
|
+
: "${FORGE_CLUSTER_GPUS_PER_NODE:?FORGE_CLUSTER_GPUS_PER_NODE is required}"
|
|
38
|
+
|
|
39
|
+
echo "[train] out_dir : ${FORGE_OUT_DIR}"
|
|
40
|
+
echo "[train] topology : ${FORGE_CLUSTER_NUM_NODES:-?} 节点 × ${FORGE_CLUSTER_GPUS_PER_NODE:-?} 卡"
|
|
41
|
+
|
|
42
|
+
# ── TODO: 换成你自己的启动命令 ────────────────────────────────────────────────
|
|
43
|
+
# 示例(HF accelerate,单节点多卡):
|
|
44
|
+
#
|
|
45
|
+
# exec accelerate launch \
|
|
46
|
+
# --num_processes "${FORGE_CLUSTER_GPUS_PER_NODE:-1}" \
|
|
47
|
+
# "${FORGE_EXP_DIR}/train.py" \
|
|
48
|
+
# --output_dir "${FORGE_OUT_DIR}" \
|
|
49
|
+
# --config "${FORGE_EXP_DIR}/config.yaml"
|
|
50
|
+
#
|
|
51
|
+
# 示例(直接跑一个 python 脚本):
|
|
52
|
+
#
|
|
53
|
+
# exec python "${FORGE_EXP_DIR}/train.py" --output-dir "${FORGE_OUT_DIR}"
|
|
54
|
+
|
|
55
|
+
echo "[train] 还没填训练命令:请编辑 ${FORGE_EXP_DIR}/train.sh" >&2
|
|
56
|
+
exit 1
|
|
File without changes
|