# Adapted from https://github.com/PeterGriffinJin/Search-R1/blob/ceee7b89655ed52f205b9beb98e1190c3eedcfb0/search_r1/llm_agent/generation.py
# This is a unified version supporting both local search and Google search, with optional log probability collection
import asyncio
import re
from qa_em_format import compute_score_em
from slime.rollout.sglang_rollout import GenerateState
from slime.utils.http_utils import post
from slime.utils.types import Sample
# Configuration for Search-R1
SEARCH_R1_CONFIGS = {
# ============== General Configuration ==============
"max_turns": 2,
"topk": 3,
"search_concurrency": 256,
# ============== Search Backend Selection ==============
"search_backend": "local", # Options: "local" or "google"
# ============== Local Search Configuration ==============
# (Only used when search_backend="local")
"local": {
"search_url": "http://127.0.0.1:8000/retrieve", # URL of your local retrieval server
"proxy": None, # Set to your proxy if needed
},
# ============== Google Search Configuration ==============
# (Only used when search_backend="google")
"google": {
"api_key": "your_api_key_here", # Replace with your actual API key
"snippet_only": True, # Set to True to only return snippets
"proxy": None, # Set to your proxy if needed
},
# ============== Log Probability Collection ==============
"return_logprob": True, # Set to True to collect log probabilities for TIS metrics
# ============== Reward Model Configuration ==============
"format_score": 0.2,
}
SEMAPHORE = asyncio.Semaphore(SEARCH_R1_CONFIGS["search_concurrency"])
def _passages2string(retrieval_result):
"""
Convert retrieval results to a formatted string.
This function works with both google_search and local_search results.
"""
format_reference = ""
for idx, doc_item in enumerate(retrieval_result):
content = doc_item["document"]["contents"]
title = content.split("\n")[0]
text = "\n".join(content.split("\n")[1:])
format_reference += f"Doc {idx+1}(Title: {title}) {text}\n"
return format_reference
async def search(query: str) -> str:
"""
Perform search using either local search engine or Google search.
The search backend is determined by SEARCH_R1_CONFIGS["search_backend"].
"""
backend = SEARCH_R1_CONFIGS["search_backend"]
if backend == "local":
from local_search_server import local_search
local_config = SEARCH_R1_CONFIGS["local"]
result = await local_search(
local_config["search_url"],
query,
SEARCH_R1_CONFIGS["topk"],
proxy=local_config["proxy"],
)
elif backend == "google":
from google_search_server import google_search
google_config = SEARCH_R1_CONFIGS["google"]
result = await google_search(
google_config["api_key"],
query,
SEARCH_R1_CONFIGS["topk"],
snippet_only=google_config["snippet_only"],
proxy=google_config["proxy"],
)
else:
raise ValueError(f"Unknown search backend: {backend}. " f"Must be either 'local' or 'google'.")
return _passages2string(result)
# IMPORTANT: When we need to collect log probabilities (logp), we CANNOT do any postprocessing
# on the strings returned from the inference engine (sglang). This is because:
# 1. We don't know how to truncate the corresponding tokens/logp arrays to match the modified string
# 2. Re-tokenizing the postprocessed string may produce different tokens than what the engine generated,
# leading to misalignment between tokens and their log probabilities
# Therefore, postprocess_responses is only used when return_logprob=False.
def postprocess_responses(resp: str) -> str:
"""
Post-process response to ensure tag completeness.
Only used when SEARCH_R1_CONFIGS["return_logprob"] is False.
"""
return (
resp.split("")[0] + ""
if "" in resp
else resp.split("")[0] + "" if "" in resp else resp
)
def postprocess_predictions(prediction: str):
pattern = r"<(search|answer)>(.*?)\1>"
match = re.search(pattern, prediction, re.DOTALL)
if match:
content = match.group(2).strip() # Return only the content inside the tags
action = match.group(1)
else:
content = ""
action = None
return action, content
async def execute_predictions(prediction: str) -> str:
action, content = postprocess_predictions(prediction)
if action == "search":
search_query = content
async with SEMAPHORE:
search_results = await search(search_query)
next_obs = f"\n\n{search_results.strip()}\n\n"
done = False
elif action == "answer":
next_obs = ""
done = True
else:
next_obs = "\nMy previous action is invalid. \
If I want to search, I should put the query between and . \
If I want to give the final answer, I should put the answer between and . Let me try again.\n"
done = False
return next_obs, done
async def generate(args, sample: Sample, sampling_params) -> Sample:
assert not args.partial_rollout, "Partial rollout is not supported for this function at the moment."
state = GenerateState(args)
url = f"http://{args.sglang_router_ip}:{args.sglang_router_port}/generate"
# Handle partial rollout samples: continue generation from existing response
prompt_text = sample.prompt
prompt_tokens_ids = state.tokenizer(prompt_text, add_special_tokens=False)["input_ids"]
sample.tokens = list(prompt_tokens_ids)
sample.loss_mask = []
response = ""
response_token_ids = []
loss_mask = []
rollout_log_probs = [] if SEARCH_R1_CONFIGS["return_logprob"] else None
sample.rollout_top_p_token_ids = None
sample.rollout_top_p_token_offsets = None
# BUGFIX: make the inference engine STOP at the tool/answer boundary.
# Without a stop, sglang keeps emitting tokens after /
# (junk, even fabricated new "Question:"s). The example only trimmed that junk
# via postprocess_responses when return_logprob=False; with return_logprob=True
# (TIS) trimming is disabled to keep token/logp aligned, so the junk stayed in
# the trajectory and got trained on (loss_mask=1) AND broke is_valid_sequence
# (trailing content after -> format invalid -> lower reward).
# Stopping at the tag avoids all of that and keeps token/logp aligned natively.
# slime already sets no_stop_trim=True, so the closing tag is kept in the output.
_stop_tags = ["", ""]
_existing_stop = sampling_params.get("stop") or []
if isinstance(_existing_stop, str):
_existing_stop = [_existing_stop]
sampling_params = {**sampling_params, "stop": list(dict.fromkeys([*_existing_stop, *_stop_tags]))}
for _turn_idx in range(SEARCH_R1_CONFIGS["max_turns"]):
payload = {
"text": prompt_text + response,
"sampling_params": sampling_params,
}
# Add log probability collection if enabled
if SEARCH_R1_CONFIGS["return_logprob"]:
payload["return_logprob"] = True
output = await post(url, payload)
# abort
if output["meta_info"]["finish_reason"]["type"] == "abort":
sample.status = Sample.Status.ABORTED
return sample
cur_response = output["text"]
# Extract tokens and log probs based on configuration
if SEARCH_R1_CONFIGS["return_logprob"]:
# Extract log probs from output - required for TIS metrics
if "output_token_logprobs" not in output["meta_info"]:
raise RuntimeError(
"output_token_logprobs not found in output meta_info. "
"Make sure 'return_logprob': True is set in the payload."
)
# Use token IDs and log probs directly from output_token_logprobs
# This ensures perfect alignment between tokens and log probs
# output_token_logprobs format: [[log_prob, token_id, ...], ...]
cur_response_token_ids = [item[1] for item in output["meta_info"]["output_token_logprobs"]]
cur_response_log_probs = [item[0] for item in output["meta_info"]["output_token_logprobs"]]
else:
# When not collecting log probs, we can safely postprocess the response
cur_response = postprocess_responses(cur_response)
# Tokenize the (possibly postprocessed) response
cur_response_token_ids = state.tokenizer(cur_response, add_special_tokens=False)["input_ids"]
response += cur_response
response_token_ids += cur_response_token_ids
loss_mask += [1] * len(cur_response_token_ids)
sample.append_response_tokens(
args,
tokens=cur_response_token_ids,
log_probs=cur_response_log_probs if SEARCH_R1_CONFIGS["return_logprob"] else None,
trainable=True,
meta_info=output["meta_info"] if "output_token_logprobs" in output["meta_info"] else None,
)
# Add log probs if enabled
if SEARCH_R1_CONFIGS["return_logprob"]:
rollout_log_probs += cur_response_log_probs
if output["meta_info"]["finish_reason"]["type"] == "length":
break
next_obs, done = await execute_predictions(cur_response)
if done:
break
assert next_obs != "", "Next observation should not be empty."
obs_tokens_ids = state.tokenizer(next_obs, add_special_tokens=False)["input_ids"]
response += next_obs
response_token_ids += obs_tokens_ids
loss_mask += [0] * len(obs_tokens_ids)
sample.append_response_tokens(args, tokens=obs_tokens_ids, trainable=False)
# Add dummy log probs for observation tokens if enabled (they won't be used due to loss_mask=0)
if SEARCH_R1_CONFIGS["return_logprob"]:
rollout_log_probs += [0.0] * len(obs_tokens_ids)
# Verify alignment when collecting log probs
assert len(response_token_ids) == len(
rollout_log_probs
), f"Token/logp length mismatch: {len(response_token_ids)} tokens vs {len(rollout_log_probs)} logps"
# Store statistics for wandb logging
sample.tokens = prompt_tokens_ids + response_token_ids
sample.response_length = len(response_token_ids)
sample.response = response
sample.loss_mask = loss_mask
sample.prompt = prompt_text
# Store log probs if enabled
if SEARCH_R1_CONFIGS["return_logprob"]:
sample.rollout_log_probs = rollout_log_probs if rollout_log_probs else None
match output["meta_info"]["finish_reason"]["type"]:
case "length":
sample.status = Sample.Status.TRUNCATED
case "abort":
sample.status = Sample.Status.ABORTED
case "stop":
sample.status = Sample.Status.COMPLETED
return sample
async def reward_func(args, sample, **kwargs):
"""The reward function for retrieval-based question answering.
Args:
args: the arguments
sample: the sample to evaluate
"""
if not isinstance(sample, Sample):
raise TypeError("Sample must be an instance of Sample class.")
score = compute_score_em(
solution_str=sample.prompt + sample.response,
ground_truth=sample.label["ground_truth"],
format_score=SEARCH_R1_CONFIGS["format_score"],
)
return score