Files
wehub-resource-sync a203934033
Lint test / lint (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:34:58 +08:00

255 lines
11 KiB
Python

import asyncio
import json
import random
from concurrent.futures import ALL_COMPLETED, ThreadPoolExecutor, wait
from copy import deepcopy
from tree_rollout import (DataSampleTree, DivergenceStrategyMapping, FinishedReason, SampleStatus,
_increment_tree_idx_depth, _repeat_list_interleave, extract_last_boxed)
from typing import Any, Dict, List, Optional, Union
from swift.infer_engine import RequestConfig
from swift.infer_engine.protocol import ChatCompletionResponse, RolloutInferRequest, RolloutOutput
from swift.rewards import MultiTurnScheduler, multi_turns
class TreeRolloutScheduler(MultiTurnScheduler):
"""
Base class for multi-turn tree-rollout scheduling.
Provides default implementation for multi-turn conversation management.
CUSTOMIZATION:
Implement the required `step()` method and optionally override `check_finished()`
- Uses TreeRolloutScheduler's run() method infrastructure
- Only need to implement turn transition logic in step()
- Optionally customize termination conditions
Attributes:
max_tree_width (int):
For GRPO, it must be equal to num_generations.
max_tree_depth (int):
Controls the maximum number of reasoning turns for a single prompt.
root_divergence (int):
Number of branches generated in the first-round inference at the root node.
max_divergence (int):
Maximum number of branches allowed for each node.
divergence_strategy (str):
Strategy for selecting branch nodes; defaults to logprobs.
"""
def __init__(self, infer_engine=None, max_turns=None, *args, **kwargs):
super().__init__(infer_engine, max_turns, *args, **kwargs)
self.max_tree_width = 8
self.max_tree_depth = max_turns | 6
self.max_divergence = 2
self.divergence_strategy = 'logprobs'
self.root_divergence = 1
self.executor = ThreadPoolExecutor(max_workers=self.max_tree_width)
async def async_infer(self,
infer_requests: List[Union['RolloutInferRequest', Dict[str, Any]]],
request_config: 'RequestConfig',
*,
use_tqdm: Optional[bool] = None,
**kwargs) -> List['RolloutOutput']:
# dedup_requests_by_messages
processed_request = []
seen = set()
uuids = []
for item in infer_requests:
if isinstance(item, dict):
req = RolloutInferRequest(**item)
else:
req = item
msg_key = json.dumps(req.messages, sort_keys=True)
uuids.append(req.uuid)
if msg_key not in seen:
seen.add(msg_key)
processed_request.append(req)
request_config.logprobs = True
outputs = await super().async_infer(processed_request, request_config, use_tqdm=use_tqdm, **kwargs)
assert len(outputs) == len(uuids), '[Tree Rollout] Please check the max_tree_width is equal to num_generations.'
for idx, output in enumerate(outputs):
output.response.id = uuids[idx]
return outputs
async def run(self, infer_request: Union[List[RolloutInferRequest], RolloutInferRequest],
request_config: 'RequestConfig', **kwargs) -> List['RolloutOutput']:
if isinstance(infer_request, RolloutInferRequest):
infer_request = [infer_request]
else:
infer_request = list(infer_request)
request_config.logprobs = True
finished_rollout_by_root: Dict[int, List[RolloutOutput]] = {i: [] for i in range(len(infer_request))}
finished_samples: Dict[int, List[DataSampleTree]] = {i: [] for i in range(len(infer_request))}
samples_to_infer = []
for root_idx in range(len(infer_request)):
samples_to_infer.append(
DataSampleTree(
tree_idx=str(root_idx),
request_id=infer_request[root_idx].uuid,
messages=infer_request[root_idx].messages,
status=SampleStatus.TO_INFER))
# first step
next_infer_step = 1
samples_to_infer = _repeat_list_interleave(samples_to_infer, self.root_divergence)
samples_to_infer = _increment_tree_idx_depth(samples_to_infer, next_infer_step)
while len(samples_to_infer) > 0:
# resolve the error: Request id xxx already running
vllm_inputs = [
RolloutInferRequest(messages=sample.messages, uuid=f'{sample.request_id}-{sample.tree_idx}')
for sample in samples_to_infer
]
# Get model response
tasks = [self.infer_engine.infer_async(request, request_config, **kwargs) for request in vllm_inputs]
outputs: List[ChatCompletionResponse] = await asyncio.gather(*tasks)
assert len(vllm_inputs) == len(
outputs), f'outputs length {len(outputs)} != inputs length {len(vllm_inputs)}'
samples_last_step = deepcopy(samples_to_infer)
samples_to_infer = []
for idx, (sample, output) in enumerate(zip(samples_last_step, outputs)):
assert len(output.choices) == 1, 'vllm should only generate one output'
self.check_finished(sample, output)
# bind the output and request
output.id = sample.request_id
choice = output.choices[0]
child_sample = deepcopy(sample)
child_sample.extend_response(choice)
if child_sample.status == SampleStatus.FINISHED:
finished_samples[child_sample.root_node].append(child_sample)
finished_rollout_by_root[child_sample.root_node].append(
RolloutOutput(
response=output,
messages=deepcopy(child_sample.messages),
response_token_ids=deepcopy(child_sample.all_response_ids),
# If we use intermediate reasoning results when computing the reward,
# but loss_mask is not explicitly set,
# only the loss of the final round of reasoning will be computed.
response_loss_mask=[[1] * len(response_ids)
for response_ids in child_sample.all_response_ids],
rollout_infos={'num_turns': next_infer_step},
))
else:
samples_to_infer.append(child_sample)
# if we have budget, do divergence
if len(samples_to_infer) > 0 and self.max_divergence > 1:
for root_idx in finished_samples.keys():
root_to_infer_samples = [sample for sample in samples_to_infer if sample.root_node == root_idx]
root_finished_samples = finished_samples[root_idx]
budget = self.max_tree_width - len(root_finished_samples) - len(root_to_infer_samples)
if budget > 0 and len(root_to_infer_samples) > 0:
divergence_executor = DivergenceStrategyMapping[self.divergence_strategy]
if not divergence_executor:
raise ValueError(
f"[Tree Rollout] The divergence strategy: {self.divergence_strategy} doesn't exist.")
divergence_samples = divergence_executor.apply(root_idx, root_to_infer_samples, budget,
self.max_divergence - 1)
samples_to_infer.extend(divergence_samples)
# before end loop, if finished_count < max_tree_width, rollback
if len(samples_to_infer) == 0 and any(count < self.max_tree_width
for count in [len(value) for value in finished_samples.values()]):
samples_to_infer = self.roll_back_to_divergence(finished_samples)
# tools call etc
futures = [self.executor.submit(self.step, sample) for sample in samples_to_infer]
wait(futures, return_when=ALL_COMPLETED)
next_infer_step += 1
samples_to_infer = _increment_tree_idx_depth(samples_to_infer, next_infer_step)
# flatten finished outputs
return [traj for lst in finished_rollout_by_root.values() for traj in lst]
def step(self, sample: DataSampleTree, **kwargs):
"""
You need to rewrite or modify this method to customize the next round of prompts, such as tools call.
"""
# Special handling has already been done in the rollback.
if sample.status == SampleStatus.ROLLBACK:
sample.status = SampleStatus.TO_INFER
return
elif sample.status == SampleStatus.FINISH_NEXT_INFER:
prompt = 'In this round of responses, you must generate an answer.'
else:
prompt = 'The answer is not correct, It seems You made a mistake, you need to recheck very carefully.'
sample.messages.append({'role': 'user', 'content': prompt})
def check_finished(self, sample: DataSampleTree, output: ChatCompletionResponse, **kwargs) -> bool:
"""
Rewrite this method to add custom check logic
"""
boxed_answer = extract_last_boxed(output.choices[0].message.content)
if boxed_answer is not None:
sample.status = SampleStatus.FINISHED
sample.finished_reason = FinishedReason.ANSWER
elif sample.status == SampleStatus.FINISH_NEXT_INFER:
sample.status = SampleStatus.FINISHED
sample.finished_reason = FinishedReason.MAX_INFER_STEP
elif sample.depth >= self.max_tree_depth - 1:
sample.status = SampleStatus.FINISH_NEXT_INFER
return sample.status == SampleStatus.FINISHED
def roll_back_to_divergence(
self,
finished_samples: Dict[int, List[DataSampleTree]],
) -> List[DataSampleTree]:
"""
All nodes have completed inference, but there is still budget available, rollback.
"""
sample_to_infer = []
for root_idx, sample_list in finished_samples.items():
if len(sample_list) >= self.max_tree_width:
continue
diff_count = self.max_tree_width - len(sample_list)
result = random.sample(sample_list, min(diff_count, len(sample_list)))
result_copy = deepcopy(result)
# Randomly rollback several inference iterations; The rollback strategy can be optimized subsequently.
for sample in result_copy:
sample.status = SampleStatus.ROLLBACK
truncate_len = sample.response_num
sample.response_truncate(random.randint(1, truncate_len))
sample_to_infer.extend(result_copy)
return sample_to_infer
multi_turns['tree_rollout_scheduler'] = TreeRolloutScheduler