import os
import re
import math
import copy
import json
import time
import json5
import random
import aiohttp
import asyncio
import datetime
import argparse
import traceback
import numpy as np
from tqdm import tqdm
from openai import AsyncOpenAI
from collections import Counter
from transformers import AutoTokenizer
from tools.tool_search import Search
from tools.tool_visit import Visit
_rr_index = 0
AGENT_LLM_BASE_URL_POOL = [
"http://localhost:8000/v1",
# You can add more LLM API URLs here to balance the workload.
]
AGENT_LLM_API_KEY = 'EMPTY'
def today_date():
return datetime.date.today().strftime("%Y-%m-%d")
def read_jsonl(file_path):
result = []
with open(file_path, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if line:
result.append(json.loads(line))
return result
SYSTEM_PROMPT = """You are a deep research assistant. Your core function is to conduct thorough, multi-source investigations into any topic. You must handle both broad, open-domain inquiries and queries within specialized academic fields. For every request, synthesize information from credible, diverse sources to deliver a comprehensive, accurate, and objective response. When you have gathered sufficient information and are ready to provide the definitive response, you must enclose the entire final answer within tags.
# Tools
You may call one or more functions to assist with the user query.
You are provided with function signatures within XML tags:
{"type": "function", "function": {"name": "search", "description": "Perform Google web searches then returns a string of the top search results. Accepts multiple queries.", "parameters": {"type": "object", "properties": {"query": {"type": "array", "items": {"type": "string", "description": "The search query."}, "minItems": 1, "description": "The list of search queries."}}, "required": ["query"]}}}
{"type": "function", "function": {"name": "visit", "description": "Visit webpage(s) and return the summary of the content.", "parameters": {"type": "object", "properties": {"url": {"type": "array", "items": {"type": "string"}, "description": "The URL(s) of the webpage(s) to visit. Can be a single URL or an array of URLs."}, "goal": {"type": "string", "description": "The specific information goal for visiting webpage(s)."}}, "required": ["url", "goal"]}}}
For each function call, return a json object with function name and arguments within XML tags:
{"name": , "arguments": }
Current date: """
def get_next_base_url():
global _rr_index
url = AGENT_LLM_BASE_URL_POOL[_rr_index % len(AGENT_LLM_BASE_URL_POOL)]
_rr_index += 1
return url
async def call_llm(sem, messages):
max_tokens = 32*1024
async with sem:
for retry in range(10):
AGENT_LLM_BASE_URL = get_next_base_url()
client = AsyncOpenAI(
api_key=AGENT_LLM_API_KEY,
base_url=AGENT_LLM_BASE_URL,
)
try:
response = await client.chat.completions.create(
model="",
messages=messages,
stop=["\n", ""],
temperature=0.6,
top_p=0.95,
presence_penalty=1.1,
logprobs=True,
top_logprobs=16,
max_tokens=max_tokens
)
result_text = response.choices[0].message.content
response_logprobs = response.choices[0].logprobs.content
result_tokens = []
result_toplogprobs = []
for item in response_logprobs:
result_tokens.append(item.token)
result_toplogprobs.append(item.top_logprobs)
break
except Exception as e:
print(f"[Tongyi-DeepResearch async error] {e}")
if "time out" not in str(e).lower():
max_tokens = max_tokens / 2
else:
return None, None
all_token_entropies = [] # [(token, entropy), ...]
for tok, toplogprobs in zip(result_tokens, result_toplogprobs):
logprob_values = np.array([tlp.logprob for tlp in toplogprobs], dtype=np.float64)
probs = np.exp(logprob_values)
probs = probs / probs.sum()
entropy = -np.sum(probs * logprob_values)
all_token_entropies.append((tok, entropy))
def find_pattern_last_idx(pattern):
last_index = None
for i in range(len(result_tokens) - len(pattern) + 1):
if result_tokens[i:i + len(pattern)] == pattern:
last_index = i + len(pattern) - 1
return last_index
if "" in ''.join(result_tokens):
think_start_pattern = ['']
think_end_pattern = ['']
tool_call_start_pattern = ['']
tool_call_end_pattern = ['']
think_start = find_pattern_last_idx(think_start_pattern)
think_end = find_pattern_last_idx(think_end_pattern)
tool_call_start = find_pattern_last_idx(tool_call_start_pattern)
tool_call_end = find_pattern_last_idx(tool_call_end_pattern)
entropies = [entropy for _, entropy in all_token_entropies]
think_ppl = np.exp(np.mean(entropies[think_start+1: think_end])) if think_end else -1
tool_call_ppl = np.exp(np.mean(entropies[tool_call_start+1: tool_call_end])) if tool_call_start and tool_call_end else -1
all_ppl = np.exp(np.mean(entropies))
step_ppl = {
'think_ppl': think_ppl,
'tool_call_ppl': tool_call_ppl,
'all_ppl': all_ppl
}
else:
step_ppl = {
'think_ppl': -1,
'tool_call_ppl': -1,
'all_ppl': -1
}
return result_text, step_ppl
async def call_tool(sem, tool_name: str, tool_args: dict):
if tool_name == "search":
async with sem['search']:
return await search.call(tool_args)
elif tool_name == "visit":
async with sem['visit']:
return await visit.call(tool_args)
else:
await asyncio.sleep(1)
return f'Tool {tool_name} does not exist.'
def count_tokens(messages):
token_ids = tokenizer.apply_chat_template(messages, tokenize=True)
return len(token_ids)
def get_initial_rollouts(question, existing_rollouts, initial_rollout_num):
all_rollouts = [item for item in existing_rollouts if item['question'] == question]
initial_rollouts = []
for rollout in all_rollouts:
if rollout['termination'] == 'answer':
initial_rollouts.append(rollout)
if len(initial_rollouts) == initial_rollout_num:
break
if len(initial_rollouts) != initial_rollout_num:
for rollout in all_rollouts:
if rollout not in initial_rollouts and rollout['termination'] != "max_length_exceeded" and rollout['termination'] != "llm_error_occurred":
initial_rollouts.append(rollout)
if len(initial_rollouts) == initial_rollout_num:
break
if len(initial_rollouts) != initial_rollout_num:
for rollout in all_rollouts:
if rollout not in initial_rollouts and rollout['termination'] != "llm_error_occurred":
initial_rollouts.append(rollout)
if len(initial_rollouts) == initial_rollout_num:
break
return initial_rollouts
async def rollout_single_traj(llm_sem, tool_sem, data, messages, args, max_turn_given=None, rollout_type='traj_level_rollout'):
max_context_length = args.max_context_length
max_turn = args.max_turn if not max_turn_given else max_turn_given
max_turn = int(max_turn)
question = data['question']
answer = data['answer']
record = copy.deepcopy(messages)
termination = 'max_turn_exceeded'
prediction = '[No Prediction]'
llm_response_time = -1
search_time = -1
visit_time = -1
for turn in range(max_turn):
previous_turn_time_consumption = {
'llm_response_time': llm_response_time,
'search_time': search_time,
'visit_time': visit_time
}
llm_response_time = -1
search_time = -1
visit_time = -1
if count_tokens(record) > max_context_length:
termination = 'max_length_exceeded'
break
tik = time.time()
llm_response, step_ppl = await call_llm(llm_sem, record)
toc = time.time()
llm_response_time = toc - tik
if llm_response is None:
return {'question': question, 'answer': answer, 'rollout': record, 'termination': "llm_error_occurred", 'prepand_msg': messages, 'rollout_type': rollout_type}
record.append({"role": "assistant", "content": llm_response, "previous_turn_time_consumption": previous_turn_time_consumption, 'step_ppl': step_ppl})
if '' in llm_response and '' in llm_response:
tool_call_str = llm_response.split('')[-1].split('')[0]
try:
tool_call = json5.loads(tool_call_str)
tool_name = tool_call['name']
tool_args = tool_call['arguments']
tik = time.time()
tool_response = await call_tool(tool_sem, tool_name, tool_args)
toc = time.time()
if tool_name == 'search':
search_time = toc - tik
else:
visit_time = toc - tik
print("======================================")
print(f"Call `{tool_name}`: {tool_args}")
print(f"Tool call {tool_name} invocation success with length {len(tool_response)}")
print(tool_response)
except Exception as e:
tool_response = 'Error: Tool call is not a valid JSON. Tool call must contain a valid "name" and "arguments" field.'
print(f"Tool call error {e}")
record.append({"role": "user", "content": f"\n{tool_response}\n"})
else:
prediction = llm_response.strip()
prediction = prediction.split("")[-1].split("")[0].strip()
termination = 'answer'
break
return {'question': question, 'answer': answer, 'prediction': prediction, 'rollout': record, 'termination': termination, 'prepand_msg': messages, 'rollout_type': rollout_type}
def branch_high_uncertainty_steps(rollout, partial_sampling_topk, partial_sampling_mode):
branch_step = []
if partial_sampling_mode != "mixed_ppl":
for i, msg in enumerate(rollout):
if msg.get("step_ppl", None):
branch_step.append({'step_id': i, 'step_ppl': msg['step_ppl'][partial_sampling_mode]})
branch_step = sorted(branch_step, key=lambda x: x['step_ppl'], reverse=True)[:partial_sampling_topk]
else:
tool_call_sampling_topk = math.ceil(partial_sampling_topk / 2)
think_sampling_topk = math.floor(partial_sampling_topk / 2)
tool_call_branch_step = []
think_branch_step = []
for i, msg in enumerate(rollout):
if msg.get("step_ppl", None):
tool_call_branch_step.append({'step_id': i, 'step_ppl': msg['step_ppl']['tool_call_ppl']})
tool_call_branch_step = sorted(tool_call_branch_step, key=lambda x: x['step_ppl'], reverse=True)[:tool_call_sampling_topk]
for i, msg in enumerate(rollout):
if msg.get("step_ppl", None):
think_branch_step.append({'step_id': i, 'step_ppl': msg['step_ppl']['think_ppl']})
think_branch_step = sorted(think_branch_step, key=lambda x: x['step_ppl'], reverse=True)[:think_sampling_topk]
branch_step.extend(tool_call_branch_step)
branch_step.extend(think_branch_step)
return branch_step
async def main(args):
llm_sem = asyncio.Semaphore(args.max_llm_workers)
tool_sem = {
'search': asyncio.Semaphore(args.max_search_workers),
'visit': asyncio.Semaphore(args.max_visit_workers)
}
dataset = read_jsonl(args.qa_file_path)
full_traj_rollout_output_file_path = os.path.join(args.output_dir, f"{args.qa_file_path.split('/')[-1].replace('.jsonl', '')}_{args.model_path.split('/')[-1]}_1_none_initial_rollout.jsonl")
if args.partial_sampling_mode == 'none':
output_file_path = full_traj_rollout_output_file_path
else:
output_file_path = os.path.join(args.output_dir, f"{args.qa_file_path.split('/')[-1].replace('.jsonl', '')}_{args.model_path.split('/')[-1]}_{args.initial_rollout_num}_{args.partial_sampling_mode}_{args.partial_sampling_topk}_{args.partial_sampling_rounds}_{args.partial_sampling_times_per_pos}.jsonl")
visited_counter = Counter()
if os.path.exists(full_traj_rollout_output_file_path):
existing_rollouts = read_jsonl(full_traj_rollout_output_file_path)
for visited_data in existing_rollouts:
question = visited_data['question']
visited_counter[question] += 1
# continue partial rollout
fully_visited_question = []
visited_initial_rollouts = []
if os.path.exists(output_file_path) and output_file_path != full_traj_rollout_output_file_path:
if "browsecomp_en" in args.qa_file_path:
initial_num = 200
elif "browsecomp_zh" in args.qa_file_path:
initial_num = 289
elif "gaia" in args.qa_file_path:
initial_num = 103
elif "hle_search" in args.qa_file_path:
initial_num = 157
else:
raise ValueError(f"Unknown dataset {args.qa_file_path}")
visited_rollouts = read_jsonl(output_file_path)
visited_initial_rollouts = visited_rollouts[:initial_num*args.initial_rollout_num]
visited_rollouts = visited_rollouts[initial_num*args.initial_rollout_num:]
visited_rollouts_counter = Counter()
for visited_data in visited_rollouts:
question = visited_data['question']
visited_rollouts_counter[question] += 1
fully_visited_question = [question for question, count in visited_rollouts_counter.items() if count == args.sampling_budget - args.initial_rollout_num]
fully_visited_rollouts = [rollout for rollout in visited_rollouts if rollout['question'] in fully_visited_question]
os.remove(output_file_path)
with open(output_file_path, "a") as f:
for r in visited_initial_rollouts:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
for r in fully_visited_rollouts:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
# submit task
tasks = []
if args.partial_sampling_mode == 'none': # traj-level rollout
pending_counter = Counter()
for data in dataset:
question = data['question']
total_count = visited_counter[question] + pending_counter[question]
need_to_submit = args.sampling_budget - total_count
for _ in range(need_to_submit):
cur_date = today_date()
messages = [
{"role": "system", "content": SYSTEM_PROMPT + str(cur_date)},
{"role": "user", "content": question}
]
tasks.append(rollout_single_traj(llm_sem, tool_sem, data, messages, args))
pending_counter[question] += 1
else: # partial-sampling rollout
partial_sampling_mode = args.partial_sampling_mode
sampling_budget = args.sampling_budget
max_turn = args.max_turn
initial_rollout_num = args.initial_rollout_num
partial_sampling_topk = args.partial_sampling_topk
partial_sampling_rounds = args.partial_sampling_rounds
partial_sampling_times_per_pos = args.partial_sampling_times_per_pos
assert sampling_budget >= initial_rollout_num + initial_rollout_num * partial_sampling_topk * partial_sampling_rounds * partial_sampling_times_per_pos, "Sampling budget is not set correctly."
try:
assert all(visited_counter[data['question']] >= args.initial_rollout_num for data in dataset), "Initial rollouts are not sufficient."
except:
raise ValueError
for i, data in tqdm(enumerate(dataset), total=len(dataset), desc="Detecting branching point ..."):
question = data['question']
if question in fully_visited_question:
continue
if not visited_initial_rollouts:
initial_rollouts = get_initial_rollouts(question, existing_rollouts, initial_rollout_num)
# save initial rollouts first
with open(output_file_path, "a") as f:
for r in initial_rollouts:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
else:
initial_rollouts = [v_initial_rollouts for v_initial_rollouts in visited_initial_rollouts if v_initial_rollouts['question'] == question]
assert len(initial_rollouts) == initial_rollout_num, "Initial rollouts are not sufficient."
for sampling_round in range(partial_sampling_rounds): # TODO: multi-round partial rollout, now only support one round
for r in initial_rollouts:
tmp_tasks_count = 1
partial_completion_count = None
branch_step = branch_high_uncertainty_steps(r['rollout'], partial_sampling_topk, partial_sampling_mode)
if len(branch_step) != partial_sampling_topk:
if partial_completion_count is None:
partial_completion_count = 0
partial_completion_count += len(branch_step)
tasks.extend(
[rollout_single_traj(
llm_sem, tool_sem, data, r['rollout'][:int(b['step_id'])],
args, max_turn-(int(b['step_id'])-2)/2,
'partial_rollout'
)
for b in branch_step
for sampling_times in range(partial_sampling_times_per_pos)]
)
tmp_tasks_count += len(branch_step) * partial_sampling_times_per_pos
sampling_budget_per_initial_rollout = int(sampling_budget / initial_rollout_num)
if sampling_budget_per_initial_rollout > 1 + partial_sampling_topk * partial_sampling_rounds * partial_sampling_times_per_pos:
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": question}
]
if partial_completion_count is not None:
tasks.extend(
[rollout_single_traj(llm_sem, tool_sem, data, messages, args, None, 'traj_level_rollout')
for count in range(sampling_budget_per_initial_rollout-1-partial_completion_count*partial_sampling_rounds*partial_sampling_times_per_pos)]
)
tmp_tasks_count += sampling_budget_per_initial_rollout-1-partial_completion_count*partial_sampling_rounds*partial_sampling_times_per_pos
else:
tasks.extend(
[rollout_single_traj(llm_sem, tool_sem, data, messages, args, None, 'traj_level_rollout')
for count in range(sampling_budget_per_initial_rollout-1-partial_sampling_topk*partial_sampling_rounds*partial_sampling_times_per_pos)]
)
tmp_tasks_count += sampling_budget_per_initial_rollout-1-partial_sampling_topk*partial_sampling_rounds*partial_sampling_times_per_pos
assert tmp_tasks_count == sampling_budget_per_initial_rollout, "Sampling budget mismatch."
print(f"Total number of tasks: {len(tasks)}")
# process task
with open(output_file_path, "a") as f, open('./inference_error.txt', 'a') as log:
for future in tqdm(asyncio.as_completed(tasks), total=len(tasks), desc=f"No Blocking Rollout ..."):
try:
result = await future
f.write(json.dumps(result, ensure_ascii=False) + "\n")
f.flush()
os.fsync(f.fileno())
except Exception as e:
exception_type = type(e).__name__
exception_message = str(e)
traceback_info = ''.join(traceback.format_tb(e.__traceback__))
error_message = f'{exception_type}: {exception_message}\n' \
f'Traceback:\n{traceback_info}'
print(f"[ERROR]: {error_message}")
log.write(f"[ERROR]: {error_message}" + "\n\n")
log.flush()
os.fsync(log.fileno())
if __name__ == "__main__":
parser = argparse.ArgumentParser()
# basic info
parser.add_argument("--qa_file_path", type=str, default="./data/browsecomp_200.jsonl")
parser.add_argument("--output_dir", type=str, default="./deepresearch_results")
parser.add_argument("--model_path", type=str, default="./llm_ckpt/tongyi-deepresearch-30b-a3b")
# for rollout
parser.add_argument("--max_llm_workers", type=int, default=32)
parser.add_argument("--max_search_workers", type=int, default=32)
parser.add_argument("--max_visit_workers", type=int, default=32)
parser.add_argument("--sampling_budget", type=int, default=8)
parser.add_argument("--max_turn", type=int, default=100)
parser.add_argument("--max_context_length", type=int, default=128*1024)
# for partial sampling
parser.add_argument("--initial_rollout_num", type=int, default=1)
parser.add_argument("--partial_sampling_mode", type=str, default="tool_call_ppl", choices=["none", "all_ppl", "think_ppl", "tool_call_ppl", "mixed_ppl"])
parser.add_argument("--partial_sampling_topk", type=int, default=2)
parser.add_argument("--partial_sampling_rounds", type=int, default=1)
parser.add_argument("--partial_sampling_times_per_pos", type=int, default=3)
args = parser.parse_args()
tokenizer = AutoTokenizer.from_pretrained(args.model_path)
search = Search()
visit = Visit()
asyncio.run(main(args))