【大模型LLM学习】Agentic RL—基于Qwen3-4b训练Travel Planning Agent

【大模型LLM学习】Agentic RL---基于Qwen3-4b训练Travel Planning Agent

0 前言

通义千问的deepresearch系列最新的一篇,高德公开了旅行规划助手的训练方法,论文为《ArenaRL: Scaling RL for Open-Ended Agents via Tournament based Relative Ranking》,并且这种方法不只是可以用于旅行规划助手,还可以扩展到其他Open-ended生成任务,解决开放生成任务里面llm-as-judge打分太随机把奖励信号淹没的问题。

在这篇中记录尝试训练本地的旅行规划助手,需要使用到高德的地理API接口,阿里百炼的Qwen-Max以及websearch接口,以及4张A100-40GB以上的显卡。

1 开放生成任务现有RL使用LLM-as-judge的问题

在《ArenaRL: Scaling RL for Open-Ended Agents via Tournament based Relative Ranking》这篇论文中,指出了一个很有意思的现象------歧视性崩溃。

对于开放生成任务,在做强化学习时,rollout生成一批样本(比如num_generation=8),然后用LLM-as-judge的方式从各个维度打质量分,然后相加,或许许多已有流程都是这么做的,但是这篇论文中发现,这样的打分存在较大的方差,方差把奖励信号淹没了。GRPO里面,组内的方差,和多跑几次的这个方差一样大,仿佛是随机的score。

"随着策略不断优化,生成的轨迹在分布上变得越来越相似。因此,同一组内轨迹的奖励被点式打分方案压缩到一个狭窄范围 (例如,在 0-1 分制上集中在 0.8-0.9),导致奖励无法区分。此外,由于 LLM 裁判固有的噪声 (如解码随机性和长度偏好),奖励结果表现出一定程度的不可靠性,奖励信号与干扰噪声之间的信噪比 (SNR) 极低。"

2 ArenaRL

为此,ArenaRL提出,开放生成任务里面,没有标准答案,也很难有金标准和SOP明确什么样的分数对应的答案应该长什么样,所以直接打分很难打准。但是,如果给定2个答案,区分那个答案好,哪个答案坏是容易的,这种相对好坏可以作为奖励信号。

此外,对于 τ 1 \tau_{1} τ1和 τ 2 \tau_{2} τ2这2个答案在prompt里面顺序不一样,一个在前面是A,一个在后面是B,让模型选哪个好,有可能存在顺序上的歧视与偏见。所以ArenaRL提出,打分时把 τ 1 \tau_{1} τ1和 τ 2 \tau_{2} τ2输入两次给LLM打分,两次顺序反过来,取均值作为分数。

具体地,对于rollout里面N=8时,如何给这N个rollout打分呢?让它们两两PK,取赢的次数作为reward,赢的次数越高,reward越高。
s c o r e ( τ i ) = 1 N − 1 ∑ i ≠ j 1 ( s i > s j ) score(\tau_{i})=\frac{1}{N-1}\sum_{i \neq j}\mathcal {1}({s_i>s_j}) score(τi)=N−11i=j∑1(si>sj)

特别地,两两PK是round-robin的方式,比较次数是O(N*N),文中还提出了其他的一些低复杂度的方法,Seeded Single-Elimination可以实现更好的开销和效果的trade-off。

3 本地实现旅行规划助手

3.1 工具接入

包括6个工具:

  • web_search:网页内容搜索
  • search_flights:航班搜索
  • search_train_tickets:火车票搜索
  • search_weather:天气查询
  • search_navigation:路径导航规划
  • search_poi:搜索keyword地点信息
  • search_around:搜索目标经纬度附近地点信息

Agent使用通义千问的deepresearch里面resum的框架,加入工具即可,支持上下文超长时进行动态压缩。

3.2 本地模型训练

A - 轨迹数据收集和SFT

从ArenaRL提供的训练集中,抽取与旅行规划相关的数据,去掉海外相关数据,剩下551条。使用其中的400条交给Qwen-max来跑出teacher轨迹,过滤掉格式不对的部分(例如tool、answer的tag缺失,网络错误导致多个assistant连着出现等),得到的部分进行DFT的lora训练提升Qwen3-4b的指令遵循能力。这里使用ms-swift框架进行训练,5个epoch收敛:

bash 复制代码
nproc_per_node=4
CUDA_VISIBLE_DEVICES=0,1,2,3 \
NPROC_PER_NODE=$nproc_per_node \
swift sft \
--model /data/coding/Qwen3-4B-Instruct-2507 \
--dataset /data/coding/teacher_traj_top400_legal.jsonl \
--tuner_type lora \
--per_device_train_batch_size 1 \
--per_device_eval_batch_size 1 \
--output_dir /data/coding/travel_finetune/dft_0517/ \
--enable_dft_loss true \
--num_train_epochs 5 \
--lorap_lr_ratio 10 \
--freeze_vit false \
--freeze_aligner false \
--freeze_llm false \
--save_steps 20 \
--eval_steps 20 \
--save_total_limit 2 \
--logging_steps 10 \
--seed 42 \
--max_length 28672 \
--learning_rate 1e-4 \
--init_weights true \
--lora_rank 8 \
--lora_alpha 32 \
--adam_beta1 0.9 \
--adam_beta2 0.95 \
--adam_epsilon 1e-08 \
--weight_decay 0.1 \
--gradient_accumulation_steps 4 \
--max_grad_norm 1 \
--lr_scheduler_type cosine \
--warmup_ratio 0.05 \
--warmup_steps 0 \
--gradient_checkpointing true \
--deepspeed zero3

B - 在线RL

首先需要配置上工具,主要需要修改好orm.py和multi_turn.py,在multi_turn.py中处理rollout,用orm.py进行打分。multi_turn.py部分,相比前面deep search agent,只需要修改工具部分,以及建议把轨迹记录下来,记录到'rollout_infos'键中便于在ORM中读取。

python 复制代码
def check_finished(self, infer_request: 'RolloutInferRequest', response_choice: 'ChatCompletionResponseChoice',
                       current_turn: int) -> bool:
        completion = response_choice.message.content
        tool_calls = self._extract_tool_calls(completion)

        tokenizer = self.tokenizer
        # 统计token总数
        total_tokens = sum(len(tokenizer.encode(msg['content'], add_special_tokens=False)) for msg in infer_request.messages)
        if total_tokens>28*1024: # 超长了
            return True
        elif (tool_calls is None and '<response>' not in completion) or ('<answer>' in completion and '</answer>' in completion):
            # 关键:添加 encoding='utf-8' 强制UTF-8编码
            with open('/data/coding/travel_finetune/grpo_final/end_reason.jsonl', 'a', encoding='utf-8') as f:
                # 1. 写入请求信息:ensure_ascii=False 保证中文正常
                f.write(json.dumps({'infer_request': infer_request.messages, 'turn': current_turn}, ensure_ascii=False) + '\n')
                # 2. 写入结果:删除手动转义,json自动处理双引号
                f.write(json.dumps({'completion': completion, 'turn': current_turn}, ensure_ascii=False) + '\n')
                
                if tool_calls is None and '<response>' not in completion:
                    f.write(json.dumps({'reason': 'no_tool_calls', 'turn': current_turn}, ensure_ascii=False) + '\n')
                else:
                    f.write(json.dumps({'reason': 'has_answer_tags', 'turn': current_turn}, ensure_ascii=False) + '\n')
            return True
        return super().check_finished(infer_request, response_choice, current_turn)

    def step(self, infer_request: 'RolloutInferRequest', response_choice: 'ChatCompletionResponseChoice',
             current_turn: int) -> Dict:
        
        completion = response_choice.message.content
        token_ids = response_choice.token_ids
        loss_mask = [1] * len(token_ids)
        tool_calls = self._extract_tool_calls(completion)
        # assert len(tool_calls) == 1, 'this scheduler is designed for one tool call per turn'
        tool_results = self._execute_tools(tool_calls)
        # append tool result to the completion
        infer_request.messages.append({'role': 'user', 'content': "<response>"+(tool_results[0])+"\n</response>"})
        return {
            'infer_request': infer_request,
            'rollout_infos': {
                'num_turns': current_turn,
                'infer_request_all': infer_request
            }
        }   

重点是orm.py,prompt可以直接参考ArenaRL的judge prompt,因为本次试验num_generation本来就不大,所以下面实现了round-robin的方式。特别的,ms-swift中steps_per_generation=num_generations时,ORM里面可以拿到一批次完整的生成结果:

python 复制代码
class OpenTravalOriginalReward(ORM):
    """
    调试专用:并发 LLM judge + 工具调用格式校验 reward
    新增规则1:每步仅1个<reason>,如果后面不是<answer>,必须是一个<tool>,然后后面必须紧跟着<response>;连续<tool>越多惩罚越重
    新增规则2:<response>为工具调用次数,调用次数少于2次,奖励打折
    """

    def tournament_two_completion(self, comp1, comp2, user_input, traj1, traj2):
        infer_traj_1 = messages_to_text(traj1[2:])  # 跳过system和第一个user
        infer_traj_2 = messages_to_text(traj2[2:])  # 跳过system和第一个user

        content_match = re.search(r'<answer>(.*?)</answer>', comp1, re.DOTALL)
        answer1 = content_match.group(1).strip() if content_match else comp1

        content_match = re.search(r'<answer>(.*?)</answer>', comp2, re.DOTALL)
        answer2 = content_match.group(1).strip() if content_match else comp2

        # 任务1:comp1 vs comp2
        def task1():
            time.sleep(random.uniform(0.5, 8))
            judge_prompt = open_traval_llm_judge_system_prompt_ori.replace('[用户原始提问]', user_input).replace('[LLM Agent A 的完整回答]', answer1).replace('[LLM Agent B 的完整回答]', answer2).replace('[LLM Agent A 的完整推理路径]', infer_traj_1).replace('[LLM Agent B 的完整推理路径]', infer_traj_2)
            with open('/data/coding/travel_finetune/grpo_final/judge_debug_log.jsonl', 'a', encoding='utf-8') as f:
                f.write(json.dumps({"prompt": judge_prompt}, ensure_ascii=False) + '\n')
            try:
                resp = call_openai(judge_prompt, used_model="qwen-plus-2025-09-11")
                with open('/data/coding/travel_finetune/grpo_final/judge_debug_log.jsonl', 'a', encoding='utf-8') as f:
                    f.write(json.dumps({"response": resp}, ensure_ascii=False) + '\n')
                parsed_scores = extract_travel_judge_full_scores_ori(resp)
                return parsed_scores['Agent_A_combined'], parsed_scores['Agent_B_combined']
            except:
                return 0.0, 0.0
        # 任务2:comp2 vs comp1
        def task2():
            time.sleep(random.uniform(0.5, 8))
            judge_prompt = open_traval_llm_judge_system_prompt_ori.replace('[用户原始提问]', user_input).replace('[LLM Agent A 的完整回答]', answer2).replace('[LLM Agent B 的完整回答]', answer1).replace('[LLM Agent A 的完整推理路径]', infer_traj_2).replace('[LLM Agent B 的完整推理路径]', infer_traj_1)
            with open('/data/coding/travel_finetune/grpo_final/judge_debug_log.jsonl', 'a', encoding='utf-8') as f:
                f.write(json.dumps({"prompt": judge_prompt}, ensure_ascii=False) + '\n')
            try:
                resp = call_openai(judge_prompt, used_model="qwen-plus-2025-09-11")
                with open('/data/coding/travel_finetune/grpo_final/judge_debug_log.jsonl', 'a', encoding='utf-8') as f:
                    f.write(json.dumps({"response": resp}, ensure_ascii=False) + '\n')
                parsed_scores = extract_travel_judge_full_scores_ori(resp)
                return parsed_scores['Agent_A_combined'], parsed_scores['Agent_B_combined']
            except:
                return 0.0, 0.0

        # 并发执行两个LLM调用
        with ThreadPoolExecutor(max_workers=2) as executor:
            f1 = executor.submit(task1)
            f2 = executor.submit(task2)
            overall_a1, overall_b1 = f1.result()
            overall_b2, overall_a2 = f2.result()

        return overall_a1 + overall_a2, overall_b1 + overall_b2

    def __call__(self, completions: List[str], user_inputs: List[str], **kwargs) -> List[float]:
        group_size = len(completions)
        rollout_infos = kwargs.get('rollout_infos', {})
        # 从dict list里面获取dict的value
        num_turns = [rollout_info.get('num_turns', 1) for rollout_info in rollout_infos]
        infer_requests = [rollout_info.get('infer_request_all', {}) for rollout_info in rollout_infos]
        infer_request_list = [infer_requests[i].get('messages', []) for i in range(len(infer_requests))]
        print('num_turns:', num_turns)
        print('len(completions):', len(completions))

        wins = [0.0] * group_size

        for i in range(group_size):
            for j in range(i+1, group_size):
                score_i, score_j = self.tournament_two_completion(completions[i], completions[j],user_inputs[0],infer_request_list[i],infer_request_list[j])
                if score_i > score_j:
                    wins[i] += 1
                elif score_i < score_j:
                    wins[j] += 1
                else: # ties
                    wins[i] += 0.5
                    wins[j] += 0.5

        ranks = pd.Series(wins).rank(method="min", ascending=False).tolist()
        max_rank = max(ranks)

        if max_rank == 1:
            group_rewards = [0.0] * group_size
        else:
            group_rewards = [(max_rank - r) / (max_rank - 1) for r in ranks]
        
        # 记录具体的rewards和winrate,便于后续排查,记录到jsonl
        with open('/data/coding/travel_finetune/grpo_final/reward_output.jsonl', 'a', encoding='utf-8') as f:
            json.dump({
                "wins": wins,
                "group_rewards": group_rewards,
                "num_turns": num_turns,
                "user_inputs": user_inputs[0]
            }, f, ensure_ascii=False)
            f.write('\n')
        # ============================================================================
        print('wins = ', wins)
        print('最终总奖励:', group_rewards)
        return group_rewards
multi_turns = {
    'math_tip_trick': MathTipsScheduler,
    'gym_scheduler': GYMScheduler,
    'thinking_tips_scheduler': ThinkingModelTipsScheduler,
    'deep_search_scheduler': DeepSearchScheduler,
    'open_traval_scheduler': OpenTravelScheduler # 记得注册
}

检查无误后,开启rollout服务器负责产生rollout:

python 复制代码
CUDA_VISIBLE_DEVICES=0 \
swift rollout \
    --model /data/coding/travel_finetune/dft_0517/v0-20260517-202958/checkpoint-55-merged \
    --vllm_use_async_engine true \
    --vllm_enable_lora true \
    --multi_turn_scheduler open_traval_scheduler \
    --vllm_max_lora_rank 8 \
    --vllm_max_model_len 32768 \
    --vllm_gpu_memory_utilization 0.8 \
    --max_turns 28 \
    --port 9123

在rollout服务器启动后,可以启动开始RL。为了便于ORM取到完整一批次结果,让steps_per_generation=num_generations。使用上面551条剩余的部分150条做RL:

python 复制代码
CUDA_VISIBLE_DEVICES=1,2,3 \
NPROC_PER_NODE=3 \
swift rlhf \
    --rlhf_type grpo \
    --model /data/coding/travel_finetune/dft_0517/v0-20260517-202958/checkpoint-55-merged \
    --output_dir /data/coding/travel_finetune/grpo_final/ \
    --tuner_type lora \
    --reward_funcs open_traval_ori_reward \
    --multi_turn_scheduler open_traval_scheduler \
    --max_turns 28 \
    --use_vllm true \
    --vllm_mode server \
    --vllm_server_host 127.0.0.1 \
    --vllm_server_port 9124 \
    --vllm_server_pass_dataset true \
    --torch_dtype bfloat16 \
    --dataset /data/coding/trip_plan_rl_dataset.jsonl \
    --overlong_filter true \
    --loss_scale default \
    --split_dataset_ratio 0 \
    --epsilon_high 0.28 \
    --max_completion_length 8192 \
    --max_length 28672 \
    --completion_length_limit_scope total \
    --num_train_epochs 1 \
    --per_device_train_batch_size 1 \
    --learning_rate 1e-4 \
    --gradient_accumulation_steps 1 \
    --steps_per_generation 4 \
    --gradient_checkpointing true \
    --save_steps 5 \
    --overlong_filter true \
    --logging_steps 1 \
    --lora_rank 8 \
    --lora_alpha 32 \
    --warmup_ratio 0.05 \
    --num_generations 4 \
    --temperature 0.8 \
    --deepspeed zero3_offload \
    --log_completions true \
    --log_entropy true \
    --num_iterations 1 \
    --rollout_importance_sampling_mode token_mask

4 demo

4.1 执行过程

4.2 输出结果

以下是为您精心规划的宁波一日游行程,兼顾天一阁博物院、南塘老街和月湖公园的打卡顺序,避开天一阁闭馆时间,并确保充足时间体验小吃和公园环境:

行程概览

  • 日期:2026年5月23日(周六)
  • 天气:小雨,24-32℃,东南风1-3级
  • 主题:文化深度+美食体验+自然休闲

详细行程安排

上午:天一阁博物院(8:30-11:30)
  • 开放时间:8:30-17:30(17:00停止入园),每周一闭馆(今日为周六,正常开放)
  • 推荐入园时间:8:30-9:30(避开人流高峰,享受宁静的园林环境)
  • 游玩时长:约2.5小时
  • 特色体验
    • 参观藏书楼、明州碑林、范式故居等核心展区
    • 游览东园和南园的江南园林景观
    • 在游客中心领取手绘地图,按路线高效游览
  • 交通:地铁1号线至西门口站,步行约10分钟;或乘坐公交9路、26路、371路、512路至"天一阁月湖景区"站
中午:南塘老街(11:30-14:30)
  • 开放时间:9:00-22:00(全天开放,无需门票)
  • 推荐游览时间:11:30-14:30(避开上午人流高峰,享受美食盛宴)
  • 游玩时长:约3小时
  • 特色体验
    • 品尝宁波特色小吃:油赞子、宁波汤圆、十六格蟹黄汤包、潮牛小馄饨等
    • 漫步1.2公里的江南长廊,欣赏青苔斑驳的石板路和雕花老宅
    • 参观余氏宗祠、袁牧之故居等历史文化景点
  • 交通:从天一阁步行约37分钟(2.8公里)至南塘老街;或乘坐地铁2号线至"客运中心站",步行约12分钟
下午:月湖公园(14:30-16:30)
  • 开放时间:8:00-17:30(4月21日-10月21日),全天开放
  • 推荐游览时间:14:30-16:30(避开中午烈日,享受凉爽的公园环境)
  • 游玩时长:约2小时
  • 特色体验
    • 漫步月湖畔,欣赏湖光山色和古建筑群(高丽使馆遗址、银台第、贺秘监祠等)
    • 在柳汀街或九曲桥旁拍照打卡
    • 如果时间充裕,可租脚踩小黄鸭游船游湖
  • 交通:从南塘老街步行约30分钟(2.3公里)至月湖公园;或乘坐地铁2号线至"城隍庙站",步行约7分钟

关键提示

  1. 天一阁闭馆提醒:务必在17:00前完成参观,避免错过闭馆时间。
  2. 天气应对:当天有小雨,建议携带雨具和防滑鞋,但雨天反而适合漫步公园,感受湿润的园林氛围。
  3. 时间管理:各景点之间步行距离较近,但需预留充足时间体验美食和公园环境。
  4. 交通建议:优先选择地铁出行,避免堵车;自驾可停月湖公园地下停车场(首小时免费)。

此行程兼顾文化、美食和自然,让您在一天内深度体验宁波的千年文脉与现代活力!

5 效果和开销

  • 比对Qwen3-4b、Qwen3-4b-DFT和Qwen3-4b-Arena的效果
  • 用Qwen3-4b-DFT、Qwen3-4b-Arena的生成文本分别与Qwen3-4b的生成文本两两比较,LLM-as-judge的方式,统计测试集上这几个Agent哪一个胜率高
  • 测试集包含ArenaRL里面单日旅游规划和多日旅游规划的测试集,每个部分各自抽了20条
模型 胜率
Qwen3-4b-DFT vs Qwen3-4b 50%
Qwen3-4b-Arena vs Qwen3-4b 57.5%
  • 看上去Qwen3-4b-DFT只是提升了指令遵循能力,RL才提升了生成质量
  • 由于成本原因RL只跑了少量步数

成本统计:Qwen-Max/Qwen-Plus和阿里的IQS的searchAPI大约100元,GPU费用大约100元,高德的API官方成本非常非常贵。。。开发者的LBS搜索额度又很低,仅能支持少量训练,瓶颈在高德开放平台。。。

相关推荐
HIT_Weston9 小时前
91、【Agent】【OpenCode】grep 工具提示词(参数内容)
人工智能·agent·opencode
swipe9 小时前
Elasticsearch 全文检索工程教程:倒排索引、IK 分词器与 BM25 从原理到落地
面试·langchain·llm
逆境不可逃9 小时前
【与我学 ClaudeCode】并发篇 之 Background Tasks :守护线程与异步通知队列
人工智能·arcgis·agent
JouYY10 小时前
Agent记忆进阶——从一个实际例子学习知识图谱
llm·agent
nix.gnehc10 小时前
agentic 源码深度拆解:启动流程与会话调用流程全解
人工智能·agent
一条泥憨鱼10 小时前
能够让AI做事的“Skill“有什么奥秘
人工智能·ai·agent·rag·skill·mcp
唐璜Taro10 小时前
LangChain与LangGraph多Agent实战:从工具链到工作流编排(上)
langchain·agent·langgraph
元思未来10 小时前
Hermes Agent 源码探秘 (5):System Prompt 组装 — Agent 的"灵魂"
源码·agent
bryant_meng11 小时前
【CC Switch】The All-in-One API Manager for AI Coding CLIs
人工智能·大模型·tools·codding clis·api key 管理