File size: 10,646 Bytes
f0273c6 4e22c33 f0273c6 a7525f2 4e22c33 f0273c6 4e22c33 f0273c6 e7168a9 f0273c6 a7525f2 f0273c6 4e22c33 e7168a9 81a1081 f0273c6 a7525f2 f0273c6 a7525f2 e7168a9 a7525f2 f0273c6 1c2fa85 a7525f2 f0273c6 4e22c33 f0273c6 2a4d2a5 f0273c6 81a1081 f0273c6 e7168a9 f0273c6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 | """OpenCode-only async GRPO on LFM2.5-2.6B with a LoRA adapter.
All APIs verified against the released wheels (trl 1.16.0, openenv 0.10.0):
- HarnessRolloutOutcome fields: env_reward, tool_call_count, tool_failure_count, ...
- _AsyncRolloutLoop requires eos_token_ids; max_tool_calling_iterations defaults to
max_turns=8 in loop-owning mode, so it is set explicitly.
- AsyncGRPOConfig is TrainingArguments-derived (no use_vllm/vllm_mode); the vLLM
server is given as vllm_server_base_url; dtype defaults to float32, so set it.
- With an injected rollout_worker, the trainer never assigns a versioned lora_name,
so adapter-only sync cannot address the policy through the capture proxy: the
vLLM server runs WITHOUT --enable-lora and the trainer streams merged weights
(its default when the server lacks --enable-lora). Checkpoints still contain
only the LoRA adapter.
Reward: correctness (1/0) + tool-efficiency bonus of at most 0.1 on correct
answers, the recipe the article's LFM runs used. outcome.tool_call_count comes
from the harness's own ATIF trace.
"""
import os
import threading
from functools import partial
from datasets import Dataset
from huggingface_hub import HfApi
from peft import LoraConfig
from transformers import AutoTokenizer, TrainerCallback
from trl.experimental.async_grpo.async_grpo_config import AsyncGRPOConfig
from trl.experimental.async_grpo.async_grpo_trainer import AsyncGRPOTrainer
from trl.experimental.async_grpo.openenv_harness import (
CaptureContractError,
HarnessRolloutWorker,
_HarnessRolloutLoop,
)
from harbor_env.harness import HarborSessionFactory # via .pth to OpenEnv/envs
MODEL = "LiquidAI/LFM2.5-2.6B"
VLLM_URL = os.environ.get("VLLM_URL", "http://127.0.0.1:8001") # serve_policy.sh
ENV_URL = os.environ.get("ENV_URL", "http://127.0.0.1:8000") # serve_env.sh
TRAIN_SPLIT = os.environ.get("TRAIN_SPLIT", "/app/prepared/datasets/train")
SANDBOX = os.environ.get("SANDBOX", "hf-sandbox") # Harbor backend
MAX_STEPS = int(os.environ.get("MAX_STEPS", "1000"))
ADAPTER_REPO = os.environ.get("ADAPTER_REPO", "") # push checkpoints here
TOOL_CALL_BUDGET = 32 # reference for the efficiency bonus; tune to the task pool
MAX_BONUS = 0.1
class UploadCheckpointsCallback(TrainerCallback):
"""Push every checkpoint to the Hub, in the background.
transformers v5 removed PushToHubCallback: periodic _save_checkpoint writes
only to local disk (save_model is called with _internal_call=True, which
skips the push), and `hub_strategy="every_save"` is vestigial. Without this
callback a crashed job loses the whole run.
"""
def __init__(self, repo_id):
self.repo_id = repo_id
self._threads = []
def on_save(self, args, state, control, **kwargs):
if not (self.repo_id and state.is_world_process_zero):
return control
ckpt = os.path.join(args.output_dir, f"checkpoint-{state.global_step}")
t = threading.Thread(target=self._upload, args=(ckpt,), daemon=True)
t.start()
self._threads.append(t)
return control
def _upload(self, ckpt):
try:
HfApi().upload_folder(
repo_id=self.repo_id,
folder_path=ckpt,
path_in_repo=os.path.basename(ckpt),
# Keep adapter + trainer state, skip multi-GB optimizer shards.
ignore_patterns=["optimizer.pt", "optimizer/**", "*.pth"],
)
print(f"[hub] uploaded {ckpt}", flush=True)
except Exception as exc: # never let an upload hiccup kill training
print(f"[hub] checkpoint upload failed: {exc}", flush=True)
def on_train_end(self, args, state, control, **kwargs):
for t in self._threads:
t.join(timeout=600)
return control
class ResilientHarnessRolloutLoop(_HarnessRolloutLoop):
"""Score a contract-violating capture as unscorable instead of killing the worker.
TRL makes CaptureContractError fatal: never train on a corrupted capture.
Right in principle — but in practice the corruption source is transient infra
(a dropped websocket between worker and env server), and one bad capture used
to kill a 25h run at step 551. Dropping the rollout keeps bad data out of
training just the same. To keep the safety property for SYSTEMATIC corruption
(e.g. every capture broken, as in the 401 incident), re-raise after a full
two groups' worth of consecutive contract errors.
"""
MAX_CONSECUTIVE_CONTRACT_ERRORS = 16
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._contract_errors = 0
def _run_session(self, prompt, group_id=0):
import logging
try:
result = super()._run_session(prompt, group_id)
except CaptureContractError as exc:
self._contract_errors += 1
if self._contract_errors >= self.MAX_CONSECUTIVE_CONTRACT_ERRORS:
raise
logging.warning("capture contract violation; scoring unscorable (%s)", exc)
return self._EMPTY_ROLLOUT, None
self._contract_errors = 0
return result
class ResilientHarnessRolloutWorker(HarnessRolloutWorker):
_loop_cls = ResilientHarnessRolloutLoop
class HeartbeatCallback(TrainerCallback):
"""Touch a file at every optimizer step; run_job.sh's watchdog kills the
job if it goes stale. The first full run stalled silently for 13h when the
rollout path died — steps just stopped, and nothing noticed."""
PATH = "/tmp/rl-heartbeat"
def on_step_end(self, args, state, control, **kwargs):
with open(self.PATH, "w") as f:
f.write(str(state.global_step))
return control
def correctness_plus_efficiency(outcome):
"""1/0 correctness, plus <= 0.1 for a correct answer in fewer tool calls.
A wrong answer stays 0, so the bonus can never be earned by giving up early.
Returning None keeps an unverified rollout (dead sandbox) unscorable.
"""
reward = outcome.env_reward
if reward is None or reward <= 0:
return reward
bonus = MAX_BONUS * max(0.0, (TOOL_CALL_BUDGET - outcome.tool_call_count) / TOOL_CALL_BUDGET)
return reward + min(bonus, MAX_BONUS)
def make_factory():
return partial(
HarborSessionFactory,
server_url=ENV_URL,
split=TRAIN_SPLIT,
llm_url=VLLM_URL,
model=MODEL,
harness="opencode", # OpenCode-only run
sandbox=SANDBOX,
reward_key="correctness,reward",
)
def make_rollout_worker(dataset, tokenizer):
return ResilientHarnessRolloutWorker(
harness_session_factory=make_factory(),
harness_adapter=None, # let the harness own the loop (black-box capture)
rollout_reward_fn=correctness_plus_efficiency,
model_name=MODEL,
dataset=dataset,
processing_class=tokenizer,
eos_token_ids=[tokenizer.eos_token_id], # required loop arg
reward_funcs=[],
num_generations=8, # one GRPO group = 8 rollouts of one task, one harness
max_inflight_tasks=8,
vllm_server_url=VLLM_URL,
max_tokens=4096, # same cap in training and eval — do not regress this
max_tool_calling_iterations=32, # loop default would cap the agent at 8 turns
temperature=1.0,
top_p=1.0, # no truncation: keeps sampled dist. == policy dist.
top_k=0, # 0 = disabled in current vLLM
chat_template_kwargs={"enable_thinking": False},
)
def main():
tokenizer = AutoTokenizer.from_pretrained(MODEL)
# LFM2.5-2.6B's chat_template.jinja doesn't byte-match any template TRL's
# add_response_schema recognizes, and the rollout-loop ctor requires a
# response parser. TRL ships one purpose-built for LFM2.5's Python-style
# tool calls; set it directly. (Only the ctor needs it: in loop-owning mode
# the harness, not TRL, parses responses.)
from trl.chat_template_utils import lfm2_2_5_template
tokenizer.response_template = lfm2_2_5_template
# The trainer dataset is discovered from the env server, not loaded from the
# Hub: HarborSessionFactory.create() only accepts instructions the server
# itself serves, and prompt_rows() is its blessed export (one row per task,
# shared by all num_generations of a group).
discovery = make_factory()()
dataset = Dataset.from_list(discovery.prompt_rows())
print(f"discovered {len(dataset)} tasks from {ENV_URL} split={TRAIN_SPLIT}")
worker = make_rollout_worker(dataset, tokenizer)
config_kwargs = {
"output_dir": "./checkpoints",
"vllm_server_base_url": VLLM_URL,
"dtype": "bfloat16", # config default is float32
"bf16": True,
"learning_rate": 1e-6,
"max_steps": MAX_STEPS,
"logging_steps": 1,
# Checkpoints ARE your LoRA adapters. Never set save_strategy="no".
"save_strategy": "steps",
"save_steps": 100,
"save_total_limit": 12,
"report_to": [],
"seed": 42,
}
if ADAPTER_REPO:
# push_to_hub covers the END-of-training push; periodic checkpoints are
# pushed by UploadCheckpointsCallback (transformers v5 dropped that path).
config_kwargs.update(push_to_hub=True, hub_model_id=ADAPTER_REPO)
config = AsyncGRPOConfig(**config_kwargs)
# LoRA on attention + MLP projections. Module names verified against the
# LFM2.5-2.6B safetensors index (self_attn.{q,k,v,out}_proj, feed_forward.w{1,2,3}).
# Short-conv projections (conv.in_proj/out_proj) are left out for portability.
peft_config = LoraConfig(
r=32,
lora_alpha=64,
lora_dropout=0.0,
target_modules=["q_proj", "k_proj", "v_proj", "out_proj", "w1", "w2", "w3"],
task_type="CAUSAL_LM",
)
trainer = AsyncGRPOTrainer(
model=MODEL,
args=config,
train_dataset=dataset, # required by the ctor even with an injected worker
processing_class=tokenizer,
rollout_worker=worker,
peft_config=peft_config,
callbacks=[UploadCheckpointsCallback(ADAPTER_REPO), HeartbeatCallback()],
)
resume = os.environ.get("RESUME_FROM", "") or None
if resume:
print(f"resuming from {resume}")
trainer.train(resume_from_checkpoint=resume)
trainer.save_model("./checkpoints/final") # adapter_model.safetensors + adapter_config.json
if ADAPTER_REPO:
trainer.push_to_hub()
if __name__ == "__main__":
main()
|