Files
wehub-resource-sync 85742ab165
CPU Test / Lint - next (push) Waiting to run
Dashboard / Chromatic (push) Waiting to run
CPU Test / Lint - fast (push) Waiting to run
CPU Test / Build documentation (push) Waiting to run
CPU Test / Test (Store, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (Utilities, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (Weave, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (AgentOps, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (LLM proxy, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (Others, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (Store, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (Utilities, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (Weave, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (AgentOps, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (LLM proxy, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (Others, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (Store, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (Utilities, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (Weave, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (AgentOps, latest, Python 3.13) (push) Waiting to run
CPU Test / Test (LLM proxy, latest, Python 3.13) (push) Waiting to run
CPU Test / Test (Others, latest, Python 3.13) (push) Waiting to run
CPU Test / Test (Store, latest, Python 3.13) (push) Waiting to run
CPU Test / Lint - slow (push) Waiting to run
CPU Test / Lint - JavaScript (push) Waiting to run
CPU Test / Test (AgentOps, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (LLM proxy, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (Others, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (Utilities, latest, Python 3.13) (push) Waiting to run
CPU Test / Test (Weave, latest, Python 3.13) (push) Waiting to run
CPU Test / Test (JavaScript) (push) Waiting to run
Deploy Documentation / deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:44:17 +08:00

397 lines
14 KiB
Python

# Copyright (c) Microsoft. All rights reserved.
"""Train the 20 Questions agent with Agent-lightning + Tinker.
This script adapts the reinforcement-learning loop from the Tinker Cookbook to
Agent-lightning's rollout architecture. Instead of invoking the official Tinker
`do_group_rollout` helper, we enqueue tasks through Agent-lightning so every
trajectory is executed by the same CrewAI flow used at evaluation time.
Before running, configure credentials by copying `examples/tinker/.env.example`
to `examples/tinker/.env` and populating:
- `OPENAI_API_KEY` / `OPENAI_BASE_URL` for the answerer and search helpers.
- `TINKER_API_KEY` so the player model can be fine-tuned via the Tinker API.
- `WANDB_API_KEY` if you want metrics streamed to Weights & Biases.
Typical entry points:
```bash
# Quickly validate the wiring with an in-memory store/LLM proxy
dotenv run python q20_train.py dryrun
# Distributed training (store, algorithm, runners)
agl store --port 4747
dotenv run python q20_train.py algo --search
dotenv run python q20_train.py runner --n-runners 4
```
Training consumes the `q20_nouns.csv` dataset in this directory and logs
Agent-lightning rewards alongside the standard Tinker training metrics.
"""
from __future__ import annotations
import argparse
import asyncio
import os
import socket
import traceback
from typing import Any, Literal, TypedDict, cast
import pandas as pd
from agl_tinker.env import AGLDatasetBuilder
from agl_tinker.llm import create_llm_proxy
from agl_tinker.train import Config
from agl_tinker.train import main as entrypoint
from crewai import LLM as CrewLLM
from q20_agent import AnswererResponse, SearchTool, TwentyQuestionsFlow
from rich.console import Console
import agentlightning as agl
def _find_available_port() -> int:
"""Find an available port by binding to port 0.
Returns:
An available port number.
"""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("", 0))
return s.getsockname()[1]
class Q20Task(TypedDict):
"""Type definition for a 20 Questions task.
Attributes:
category: The category of the entity to guess.
answer: The secret entity.
search_enabled: Whether the player can use the search tool.
"""
category: str
answer: str
search_enabled: bool
LLM_TIMEOUT = 120.0
console = Console()
@agl.rollout
async def q20_agent(task: Q20Task, llm: agl.LLM, rollout: agl.Rollout) -> None:
"""Rollout function for the 20 Questions agent during training.
Args:
task: The 20 Questions task containing category, answer, and search settings.
llm: The LLM being trained (player model).
rollout: Rollout metadata from Agent-lightning.
"""
answer_llm_setting = os.getenv("ANSWERER_LLM", "gpt-5-mini")
search_llm_setting = os.getenv("SEARCH_LLM", "gpt-4.1")
player_llm = CrewLLM(model="openai/" + llm.model, base_url=llm.endpoint, api_key="dummy", timeout=LLM_TIMEOUT)
answer_llm = CrewLLM(
model="openai/" + answer_llm_setting,
base_url=os.getenv("OPENAI_BASE_URL"),
api_key=os.getenv("OPENAI_API_KEY"),
reasoning_effort="low",
response_format=AnswererResponse,
timeout=LLM_TIMEOUT,
)
if task["search_enabled"]:
search_tool = SearchTool(
model=CrewLLM(
model="openai/" + search_llm_setting,
base_url=os.getenv("OPENAI_BASE_URL"),
api_key=os.getenv("OPENAI_API_KEY"),
reasoning_effort="none",
timeout=LLM_TIMEOUT,
)
)
else:
search_tool = None
flow = TwentyQuestionsFlow(player_llm=player_llm, answer_llm=answer_llm, search_tool=search_tool)
try:
await flow.kickoff_async(cast(Any, task))
agl.emit_reward(1.0 if flow.state.correct else 0.0)
except Exception:
console.print(f"Error in q20_agent: {traceback.format_exc()}")
raise
# Above, the exception is re-raised, so the rollout will appear failed, but reward will be none.
# The handling below is another approach that will make the rollout appear succeeded, but with 0 reward.
# I think algorithm should handle the case instead.
# agl.emit_exception(e)
# agl.emit_reward(0.0)
def dry_run(model: Literal["qwen4b", "qwen30b"]):
"""Run a quick dry-run test of the 20 Questions training setup.
Uses in-memory store and processes 4 sample tasks to verify the setup works.
"""
store = agl.LightningStoreThreaded(agl.InMemoryLightningStore())
if model == "qwen4b":
model_name = "Qwen/Qwen3-4B-Instruct-2507"
elif model == "qwen30b":
model_name = "Qwen/Qwen3-30B-A3B-Instruct-2507"
else:
raise ValueError(f"Invalid model: {model}")
llm_proxy = create_llm_proxy(model_name, "qwen3_instruct", store=store)
trainer = agl.Trainer(
n_runners=2,
initial_resources={"llm": llm_proxy.as_resource()},
store=store,
)
try:
asyncio.run(llm_proxy.start())
sampled_csv = pd.read_csv("q20_nouns.csv").sample(n=4, random_state=42) # type: ignore
sampled_csv["search_enabled"] = False
dataset = sampled_csv.to_dict(orient="records") # type: ignore
trainer.dev(q20_agent, cast(agl.Dataset[Q20Task], dataset))
finally:
asyncio.run(llm_proxy.stop())
async def algo(search: bool, model: Literal["qwen4b", "qwen30b"], port: int, ci: bool = False):
"""Run the training algorithm for 20 Questions.
Args:
search: Whether to enable the search tool for the player.
model: Model variant to use ("qwen4b" or "qwen30b").
port: Port where the Agent-lightning store is running.
"""
raw_data = pd.read_csv("q20_nouns.csv") # type: ignore
raw_data["search_enabled"] = search
train_data, test_data = raw_data[raw_data["split"] == "train"], raw_data[raw_data["split"] == "test"] # type: ignore
train_dataset = cast(agl.Dataset[Q20Task], train_data.to_dict(orient="records")) # type: ignore
test_dataset = cast(agl.Dataset[Q20Task], test_data.to_dict(orient="records")) # type: ignore
if model == "qwen4b":
model_name = "Qwen/Qwen3-4B-Instruct-2507"
renderer_name = "qwen3_instruct"
elif model == "qwen30b":
model_name = "Qwen/Qwen3-30B-A3B-Instruct-2507"
renderer_name = "qwen3_instruct"
else:
raise ValueError(f"Invalid model: {model}")
experiment_name = f"q20_{'search' if search else 'no_search'}_{model}"
llm_proxy_port = _find_available_port()
if ci:
train_dataset = cast(agl.Dataset[Q20Task], train_dataset[:2]) # type: ignore
test_dataset = cast(agl.Dataset[Q20Task], test_dataset[:2]) # type: ignore
group_size = 2
batch_size = 2
n_epochs = 1
else:
group_size = 16
batch_size = 16
n_epochs = 10
config = Config(
learning_rate=1e-4,
dataset_builder=AGLDatasetBuilder(
train_dataset=train_dataset,
val_dataset=test_dataset,
batch_size=batch_size,
shuffle=True,
group_size=group_size,
seed=17,
n_epochs=n_epochs,
),
lora_rank=16,
renderer_name=renderer_name,
model_name=model_name,
log_path=f"logs/{experiment_name}",
concurrency=32,
eval_every=4,
wandb_project="AgentLightningQ20",
wandb_name=experiment_name,
store_address=f"http://localhost:{port}",
llm_proxy_port=llm_proxy_port,
adapter_from_llm_proxy=False,
llm_proxy_retry_attempts=5,
)
await entrypoint(config)
def algo_verl(search: bool, model: Literal["qwen25", "qwen3"], port: int):
"""Alternatively, you can use VERL to train the 20 Questions agent locally.
Use this as a substitute for the `algo` function when Tinker service is not available.
Args:
search: Whether to enable the search tool for the player.
model: Specifies the model variant ('qwen25' or 'qwen3').
port: Port where the Agent-lightning store is running.
"""
store = agl.LightningStoreClient(f"http://localhost:{port}")
if model == "qwen25":
model_name = "Qwen/Qwen2.5-3B-Instruct"
elif model == "qwen3":
model_name = "Qwen/Qwen3-4B-Instruct-2507"
else:
raise ValueError(f"Invalid model: {model}")
experiment_name = f"q20_{'search' if search else 'no_search'}_{model}"
verl_config = {
"algorithm": {
"adv_estimator": "grpo",
"use_kl_in_reward": False,
},
"data": {
"train_batch_size": 16,
"max_prompt_length": 8192,
"max_response_length": 1024,
},
"actor_rollout_ref": {
"rollout": {
"tensor_model_parallel_size": 1,
"n": 8,
"log_prob_micro_batch_size_per_gpu": 4,
"multi_turn": {"format": "hermes"},
"name": "vllm",
"gpu_memory_utilization": 0.8,
},
"actor": {
"ppo_mini_batch_size": 16,
"ppo_micro_batch_size_per_gpu": 2,
"optim": {"lr": 5e-7},
"use_kl_loss": False,
"kl_loss_coef": 0.0,
"entropy_coeff": 0,
"clip_ratio_low": 0.2,
"clip_ratio_high": 0.3,
"fsdp_config": {
"param_offload": True,
"optimizer_offload": True,
},
},
"ref": {
"log_prob_micro_batch_size_per_gpu": 4,
"fsdp_config": {"param_offload": True},
},
"model": {
"path": model_name,
"use_remove_padding": True,
"enable_gradient_checkpointing": True,
"enable_activation_offload": True,
},
},
"trainer": {
"n_gpus_per_node": 1,
"val_before_train": True,
"critic_warmup": 0,
"logger": ["console", "wandb"],
"project_name": "AgentLightningQ20VERL",
"experiment_name": experiment_name,
"nnodes": 1,
"test_freq": 4,
"total_epochs": 10,
},
}
verl = agl.VERL(verl_config)
# Use the data recorded at the proxy side
adapter = agl.LlmProxyTraceToTriplet()
verl.set_adapter(adapter)
verl.set_store(store)
raw_data = pd.read_csv("q20_nouns.csv") # type: ignore
raw_data["search_enabled"] = search
train_data, test_data = raw_data[raw_data["split"] == "train"], raw_data[raw_data["split"] == "test"] # type: ignore
train_dataset = cast(agl.Dataset[Q20Task], train_data.to_dict(orient="records")) # type: ignore
test_dataset = cast(agl.Dataset[Q20Task], test_data.to_dict(orient="records")) # type: ignore
verl.run(train_dataset=train_dataset, val_dataset=test_dataset)
def runner(port: int = 4747, n_runners: int = 2):
"""Run rollout runners that execute the 20 Questions game.
Args:
port: Port where the Agent-lightning store is running.
n_runners: Number of parallel runners to spawn.
"""
# Run only the runners without algorithm
store = agl.LightningStoreClient(f"http://localhost:{port}")
trainer = agl.Trainer(
algorithm=None,
store=store,
strategy={"type": "cs", "managed_store": False, "n_runners": n_runners, "role": "runner"},
)
trainer.fit(q20_agent)
def _run_dryrun(args: argparse.Namespace) -> None:
dry_run(model=args.model)
def _run_algo(args: argparse.Namespace) -> None:
asyncio.run(algo(search=args.search, model=args.model, port=args.port, ci=args.ci))
def _run_runner(args: argparse.Namespace) -> None:
runner(port=args.port, n_runners=args.n_runners)
def _run_algo_verl(args: argparse.Namespace) -> None:
algo_verl(search=args.search, model=args.model, port=args.port)
def main() -> None:
"""Entry point for the 20 Questions training script."""
parser = argparse.ArgumentParser(description="Run the Q20 AgentLightning experiments.")
subparsers = parser.add_subparsers(dest="command", required=True)
dryrun_parser = subparsers.add_parser("dryrun", help="Run the in-memory dry run.")
dryrun_parser.add_argument(
"--model", choices=("qwen4b", "qwen30b"), default="qwen30b", help="Model variant to train."
)
dryrun_parser.set_defaults(func=_run_dryrun)
algo_parser = subparsers.add_parser("algo", help="Launch the full training algorithm.")
algo_parser.add_argument("--port", type=int, default=4747, help="Port for the AgentLightning store.")
algo_parser.add_argument("--search", action="store_true", help="Enable search tool.")
algo_parser.add_argument(
"--model",
choices=("qwen4b", "qwen30b"),
default="qwen30b",
help="Model variant to train.",
)
algo_parser.add_argument("--ci", action="store_true", help="Run in CI mode (smaller dataset, smaller batch).")
algo_parser.set_defaults(func=_run_algo)
algo_verl_parser = subparsers.add_parser("verl", help="Launch the full training algorithm with VERL.")
algo_verl_parser.add_argument("--port", type=int, default=4747, help="Port for the AgentLightning store.")
algo_verl_parser.add_argument(
"--model",
choices=("qwen25", "qwen3"),
default="qwen3",
help="Model variant to train.",
)
algo_verl_parser.add_argument("--search", action="store_true", help="Enable search tool.")
algo_verl_parser.set_defaults(func=_run_algo_verl)
runner_parser = subparsers.add_parser("runner", help="Run only the rollout runners.")
runner_parser.add_argument("--port", type=int, default=4747, help="Port for the AgentLightning store.")
runner_parser.add_argument("--n-runners", type=int, default=2, help="Number of runners to use.")
runner_parser.set_defaults(func=_run_runner)
args = parser.parse_args()
agl.setup_logging()
args.func(args)
if __name__ == "__main__":
main()