17-ToolCalling和Sandbox环境如何成为reward的一部分
> 环境反馈一旦进入轨迹,工具调用和沙箱执行就不再是外围功能,而是 reward 的来源之一。 上一篇说明了 agent loop 为什么必须坚持 token-in/token-out:多轮轨迹里既有模型生成 token,也有工具 observation token,训练系统必须用 `response_mask`区分它们。本文继续看这条轨迹为什么会变得更复杂:当模型可以调用工具,环境就不再是训练外部的背景,而会进入 rollout、reward 和 DataProto。 本文的核心判断是:tool calling 让“环境反馈”变成训练样本的一部分,sandbox 则让部分 reward 计算变成受控执行问题。二者不在同一层:tool calling 发生在 rollout 的多轮交互里,sandbox reward 多发生在 reward manager 的评分路径里。把这两层分清,才能看懂 agent RL 里工具响应、工具分数、reward extra info 和最终 actor update 是如何接起来的。 先看环境进入训练系统的位置。读这张图时注意两条路径:上半部分是 rollout 阶段的工具调用,下半部分是 reward 阶段的 sandbox 执行。它们最终都要回到同一个训练 batch。  tool calling 与 sandbox 的系统边界 这张图对应的源码边界是:`AgentLoopWorker`在初始化时按 rollout 配置加载工具,`ToolAgentLoop`在多轮状态机里解析并执行工具;reward manager 则在没有自定义 reward function 时,可以根据 `reward.sandbox_fusion`配置把默认评分函数包装成 sandbox 调用(`verl/experimental/agent_loop/agent_loop.py:412-418`,`verl/experimental/agent_loop/tool_agent_loop.py:93-113`,`verl/trainer/ppo/reward.py:124-157`)。 ## **1. 工具不是字符串,而是 prompt 合同** 很多人第一次看 tool calling,会把它理解成“模型输出一段 JSON,然后程序执行”。这只说对了一半。在训练系统里,工具首先是 prompt 合同:模型能看到哪些工具、每个工具有哪些参数、参数如何被解析、同一条样本是否只允许选择部分工具,都必须在 rollout 前确定。 `rollout.multi_turn`配置里把这件事拆成两条路径:`tool_config_path`用于加载继承 `BaseTool`的有状态工具,`function_tool_path`用于加载 `@function_tool`装饰的轻量函数工具;同时还有 `max_parallel_calls`、`max_tool_response_length`、`tool_response_truncate_side`和 tool parser format 等控制项(`verl/trainer/config/rollout/rollout.yaml:164-220`)。 下面这张图把工具装配过程画出来。它补的是:工具不是在模型输出后才临时出现,而是在 prompt assembly 阶段就已经进入了 chat template。  工具 schema 如何进入 agent loop `AgentLoopWorker.__init__()`会调用 `load_all_tools()`,把 native tools 和 function tools 合并加载到 worker 内部(`verl/experimental/agent_loop/agent_loop.py:412-418`)。`ToolAgentLoop.__init__()`再把工具按名字放进 `self.tools`,把 OpenAI-compatible schema 放进 `self.tool_schemas`,并根据 `multi_turn.format`选择 `ToolParser`(`verl/experimental/agent_loop/tool_agent_loop.py:103-113`)。在单条样本上,`extra_info.tool_selection`还可以把全局工具集过滤成该样本可用的工具子集(`verl/experimental/agent_loop/tool_agent_loop.py:144-155`)。 所以,tool calling 的第一层约束不是执行,而是“模型被允许调用什么”。这个约束和数据集字段、chat template、tool parser 共同决定模型输出是否能被解释成可执行动作。 ## **2. 工具执行有两种合同:有状态生命周期和无状态函数** 当模型输出 tool call 后,系统才进入执行层。下面这张图展示 `ToolAgentLoop`如何从 assistant token 中解析调用、并发执行工具,再把结果变成 tool message。  工具调用执行合同 `ToolParser`负责从 response token 解码出工具调用。Hermes parser 会查找 `...`,把里面的 JSON 解析成 `FunctionCall(name, arguments)`;GPT-OSS 和 Qwen3 coder 路径也各自实现了不同格式的 parser(`verl/experimental/agent_loop/tool_parser.py:44-113`)。 解析之后,`ToolAgentLoop._handle_processing_tools_state()`会取最多 `max_parallel_calls`个 tool call,创建异步任务并 `asyncio.gather()`(`verl/experimental/agent_loop/tool_agent_loop.py:273-286`)。真正执行在 `_call_tool()`:先校验工具名,再把 arguments 当 JSON 解析;如果是 `FunctionTool`,直接调用函数并归一化返回值;否则走 `BaseTool`生命周期,依次 `create()`、`execute()`、`release()`(`verl/experimental/agent_loop/tool_agent_loop.py:376-451`)。 `BaseTool`的接口说明了为什么工具是“环境”而不是普通函数:它可以为一条 trajectory 创建实例,执行时返回 `ToolResponse`、step reward 和 metrics,结束后释放实例(`verl/tools/base_tool.py:24-93`)。相对地,`FunctionTool`更轻量,只要求函数有可推导 JSON schema 的签名和 docstring,返回值会被归一化成 `(ToolResponse, reward, metrics)`(`verl/tools/function_tool.py:44-64`,`verl/tools/function_tool.py:213-258`)。 这两个合同的差别很重要:无状态函数适合简单查询或格式转换;有状态工具适合浏览器、代码环境、游戏环境、仿真器这类需要 per-trajectory state 的环境。 ## **3. 工具响应进入上下文,但不承担 policy loss** 工具执行结束后,系统不会只保存一个 “tool result” 字段。它会把工具返回重新包装成 message,追加到对话上下文,再 tokenize 成 response 区间的一部分。这里最容易误读:工具 observation 在 `responses`里,但它不是模型生成 token。 下面这张图把这一层拆开。看图时注意 response 区间里的两种 token:assistant token 的 mask 是 1,tool observation token 的 mask 是 0。  工具 observation 如何回到训练轨迹 源码里,工具返回可以是 text,也可以带 image/video 字段。`ToolAgentLoop`会构造 role 为 `tool`的 message;如果有图像,还会把 image data 追加到本轮多模态输入。随后它把 tool messages 经过 chat template 转成 token ids,追加到 `prompt_ids`,同时给这些 token 的 `response_mask`追加 0;如果有 rollout logprobs,也给 observation token 补 0.0(`verl/experimental/agent_loop/tool_agent_loop.py:287-374`)。 工具自身的分数也不会丢。`AgentData`里保留 `turn_scores`和 `tool_rewards`,每次工具执行返回的 `tool_reward`会被 append 到 `agent_data.tool_rewards`,最终写入 `AgentLoopOutput.extra_fields`(`verl/experimental/agent_loop/tool_agent_loop.py:50-90`,`verl/experimental/agent_loop/tool_agent_loop.py:181-200`,`verl/experimental/agent_loop/tool_agent_loop.py:334-335`)。 最终 `_postprocess()`会把这些 extra fields 放入 DataProto 的 non-tensor batch:`turn_scores`、`tool_rewards`、`reward_extra_info`、`min_global_steps`、`max_global_steps`等字段都以稳定 key 集合进入下游(`verl/experimental/agent_loop/agent_loop.py:895-986`)。这说明工具结果既影响下一轮上下文,也能影响 reward 和日志,但不会被误当成 actor 自己生成的 token。 ## **4. Sandbox 是 reward 路径里的受控执行** tool calling 让环境反馈进入 rollout;sandbox 则解决另一类问题:有些 reward 不是文本匹配或 reward model 打分,而是要执行代码、跑测试、比较输出。此时训练系统需要一个受控的执行环境,避免把任意生成代码直接跑在 trainer 进程里。 `reward.yaml`里有 `sandbox_fusion`配置,包含 URL、最大并发和每个 sandbox 进程的内存限制(`verl/trainer/config/reward/reward.yaml:85-93`)。当用户没有提供自定义 reward function 时,`load_reward_manager()`会检查 `sandbox_fusion.url`;如果存在,就用 multiprocessing manager 创建 semaphore,把 sandbox URL、并发控制和 memory limit 传给默认 compute score(`verl/trainer/ppo/reward.py:124-157`)。 具体执行在 `sandbox_fusion/utils.py`。`call_sandbox_api()`会把 code、stdin、compile/run timeout、memory limit 和 language 打包发给 sandbox API,并对 504 做重试(`verl/utils/reward_score/sandbox_fusion/utils.py:67-171`)。`check_correctness()`则把多组输入输出提交到线程池并发执行,每个 case 经过 `_process_single_case()`调 sandbox,最后返回 True/False 以及 api error、compile error、runtime error、timeout 等 metadata(`verl/utils/reward_score/sandbox_fusion/utils.py:449-578`)。 这就是 sandbox 和 tool calling 的分工:工具执行发生在轨迹中,会影响后续模型上下文;sandbox reward 更像评分器的执行后端,主要影响 `rm_scores`和 reward extra info。它们都属于“环境反馈”,但一个改变 rollout 轨迹,一个改变训练信号。 ## **小结:环境反馈不是一个外部注释** 本文把 tool calling 和 sandbox 放回训练数据流: ``` tool schema -> model emits tool call -> tool executes with lifecycle -> tool observation enters token trajectory with response_mask=0 -> tool_rewards / reward_extra_info enter non_tensor_batch -> sandbox reward can score executable outputs -> rm_scores / advantage / actor update ``` 到这里,第三组已经说明:rollout 不只是生成文本,而是在维护一个可训练的环境交互轨迹。下一篇会继续看这个设计的代价:一旦 prompt 长度、response 长度、工具等待、sandbox 执行和多轮次数都变成随机变量,agent RL 为什么特别容易出现长尾 rollout 和 GPU 空转。 ## **本文源码索引** - `verl/trainer/config/rollout/rollout.yaml:164-220`:multi-turn、tool config、function tool、parallel calls、tool response 截断和 parser format 配置。 - `verl/experimental/agent_loop/agent_loop.py:412-418`:`AgentLoopWorker`如何按配置加载 native/function tools。 - `verl/experimental/agent_loop/tool_agent_loop.py:93-113`:`ToolAgentLoop`如何建立 tool map、tool schemas 和 parser。 - `verl/experimental/agent_loop/tool_agent_loop.py:144-155`:单样本 `extra_info.tool_selection`如何过滤工具集。 - `verl/experimental/agent_loop/tool_parser.py:44-113`:tool parser 抽象和 Hermes parser 的 tool call 解析。 - `verl/experimental/agent_loop/tool_agent_loop.py:273-286`:工具调用如何受 `max_parallel_calls`控制并发执行。 - `verl/experimental/agent_loop/tool_agent_loop.py:376-451`:`_call_tool()`如何校验、执行 FunctionTool/BaseTool,并截断工具响应。 - `verl/tools/base_tool.py:24-93`:有状态工具的 create/execute/calc_reward/release 生命周期。 - `verl/tools/function_tool.py:44-64`、`213-258`:轻量 function tool 的调用和返回值归一化。 - `verl/experimental/agent_loop/tool_agent_loop.py:287-374`:工具响应如何变成 tool message、token ids 和 `response_mask=0`。 - `verl/experimental/agent_loop/agent_loop.py:895-986`:agent output 如何把 `tool_rewards`、`turn_scores`、`reward_extra_info`写回 DataProto。 - `verl/trainer/ppo/reward.py:124-157`:reward manager 如何接入 sandbox fusion。 - `verl/utils/reward_score/sandbox_fusion/utils.py:67-171`:sandbox API 调用、timeout、memory limit 和重试。 - `verl/utils/reward_score/sandbox_fusion/utils.py:449-578`:`check_correctness()`如何并发运行测试并返回结果 metadata。