minimind/course/notes/26_agent_rollout_batch_note...

482 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 第26节:Agent Rollout 单条轨迹与批量采样笔记
> 我的问题笔记 | 聚焦 `rollout_single` 为什么是单条轨迹,以及能不能多个样本一起采样
---
## 目录
1. [Q1: `rollout_single` 是只跑一条 message 吗?](#q1-rollout_single-是只跑一条-message-吗)
2. [Q2: 当前 `rollout_batch` 真的是批量生成吗?](#q2-当前-rollout_batch-真的是批量生成吗)
3. [Q3: 底层 `rollout_engine.rollout` 支持 batch 吗?](#q3-底层-rollout_enginerollout-支持-batch-吗)
4. [Q4: 为什么 Agent rollout 没有直接写成 batch?](#q4-为什么-agent-rollout-没有直接写成-batch)
5. [Q5: 如果要改成批量采样,主循环应该长什么样?](#q5-如果要改成批量采样主循环应该长什么样)
6. [Q6: old_logps 和 completion_mask 为什么要右移对齐?](#q6-logprob-mask-align)
7. [一句话总结](#一句话总结)
---
## Q1: `rollout_single` 是只跑一条 message 吗?
不是。
`rollout_single` 一次跑的是 **一个样本的一条 trajectory**,不是一条单独的 message。
**代码** `trainer/train_agent.py:98-119`
```python
def rollout_single(rollout_engine, tokenizer, messages, tools, max_turns=3, max_new_tokens=256, thinking_ratio=0.5, device="cuda"):
...
for turn in range(max_turns):
context = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True, tools=tools, open_thinking=open_thinking)
inputs = tokenizer(context, return_tensors="pt", add_special_tokens=False).to(device)
...
rollout_result = rollout_engine.rollout(
prompt_ids=inputs["input_ids"],
attention_mask=inputs["attention_mask"],
num_generations=1,
max_new_tokens=max_new_tokens,
temperature=0.8,
)
```
这里的 `messages` 是消息列表,可能长这样:
```python
[
{"role": "system", "content": "..."},
{"role": "user", "content": "..."}
]
```
也可能已经包含历史 assistant/tool 消息。
所以它的边界是:
```text
单个样本
-> 单条采样轨迹
-> 轨迹内部最多 max_turns 轮 assistant/tool 交互
```
因为 `context` 是单个字符串:
```python
inputs = tokenizer(context, return_tensors="pt", add_special_tokens=False)
```
所以这里的 tensor 形状通常是:
```text
inputs["input_ids"].shape = [1, prompt_len]
inputs["attention_mask"].shape = [1, prompt_len]
```
这个 `1` 表示 batch size 是 1。
---
## Q2: 当前 `rollout_batch` 真的是批量生成吗?
不是严格意义上的批量生成。
当前 `rollout_batch` 只是外层名字叫 batch,内部还是循环调用 `rollout_single`。
**代码** `trainer/train_agent.py:159-180`
```python
def rollout_batch(rollout_engine, tokenizer, messages_batch, tools_batch, num_gen, max_turns=3, max_new_tokens=256, thinking_ratio=0.5, device="cuda"):
...
for messages, tools in zip(messages_batch, tools_batch):
for _ in range(num_gen):
msgs_copy = [dict(m) for m in messages]
completion, context, prompt_ids, response_ids, response_mask, response_old_logps, turn_outputs, unfinished = rollout_single(
rollout_engine, tokenizer, msgs_copy, tools, max_turns, max_new_tokens, thinking_ratio, device
)
all_completions.append(completion)
...
```
这段实际执行顺序是:
```text
batch 里的第 1 个样本
-> 第 1 条 trajectory
-> 第 2 条 trajectory
-> ...
batch 里的第 2 个样本
-> 第 1 条 trajectory
-> 第 2 条 trajectory
-> ...
```
也就是:
```text
messages_batch 提供多个样本;
num_gen 给每个样本采多条轨迹;
但每条轨迹仍然是单独调用 rollout_single。
```
所以当前写法简单,但生成效率不高。
---
## Q3: 底层 `rollout_engine.rollout` 支持 batch 吗?
支持。
至少 `TorchRolloutEngine.rollout` 本身是按 batched tensor 写的。
**代码** `trainer/rollout_engine.py:75-92`
```python
output_ids = model.generate(
input_ids=prompt_ids.repeat_interleave(num_generations, dim=0),
attention_mask=attention_mask.repeat_interleave(num_generations, dim=0),
max_new_tokens=max_new_tokens,
do_sample=True,
temperature=temperature,
num_return_sequences=1,
pad_token_id=self.tokenizer.pad_token_id,
eos_token_id=self.tokenizer.eos_token_id,
).clone() # [B, P+R]
prompt_len = prompt_ids.size(1)
completion_ids = output_ids[:, prompt_len:] # [B, R]
...
completions = self.tokenizer.batch_decode(completion_ids, skip_special_tokens=True)
```
如果输入是:
```text
prompt_ids.shape = [B, P]
attention_mask.shape = [B, P]
num_generations = G
```
那么这两行:
```python
prompt_ids.repeat_interleave(num_generations, dim=0)
attention_mask.repeat_interleave(num_generations, dim=0)
```
会把 batch 展开成:
```text
[B * G, P]
```
生成结果大致是:
```text
output_ids.shape = [B * G, P + R]
completion_ids.shape = [B * G, R]
per_token_logps.shape = [B * G, R]
completions = 长度 B * G 的字符串列表
```
所以限制不在底层 rollout engine,而在 agent 多轮工具循环的组织方式。
---
## Q4: 为什么 Agent rollout 没有直接写成 batch?
因为 Agent rollout 不是“一次 prompt -> 一次 answer”的普通生成,而是带环境反馈的动态循环。
**代码** `trainer/train_agent.py:132-152`
```python
calls = parse_tool_calls(new_text)
if not calls:
break
unfinished = turn == max_turns - 1
messages.append({"role": "assistant", "content": new_text})
for call in calls:
name, raw = call.get("name", ""), call.get("arguments", {})
...
result = execute_tool(name, raw)
result_str = (json.dumps(result, ensure_ascii=False) if result else '{"error": "tool not found"}')[:2048]
messages.append({"role": "tool", "content": result_str})
observe_context = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=not unfinished, tools=tools, open_thinking=open_thinking)
observe_ids = tokenizer(observe_context, return_tensors="pt", add_special_tokens=False)["input_ids"][0].tolist()
current_len = len(prompt_ids) + len(response_ids)
obs_delta = observe_ids[current_len:]
response_ids.extend(obs_delta)
response_mask.extend([0] * len(obs_delta))
response_old_logps.extend([0.0] * len(obs_delta))
```
这段说明了几个 batch 难点:
| 难点 | 为什么麻烦 |
|------|------------|
| prompt 长度不同 | 多个 `context` 要 padding 成同一个 tensor |
| 是否继续不同 | 有的样本没有 tool call,要结束;有的样本要继续下一轮 |
| tool 数量不同 | 一条 response 里可能 0 个、1 个或多个 tool call |
| messages 会变 | 每条 trajectory 都要追加自己的 assistant/tool 消息 |
| observation 不是 policy token | tool observation 要放进 `response_ids`,但 `response_mask` 要填 0 |
| logprob 长度不同 | policy 生成 token 有 old logprob,环境 observation token 没有 |
所以不能简单地把:
```python
tokenizer(context, return_tensors="pt")
```
改成:
```python
tokenizer(contexts, return_tensors="pt", padding=True)
```
然后就结束了。
真正的问题是:每一轮之后,batch 里的 trajectory 会分化。
---
## Q5: 如果要改成批量采样,主循环应该长什么样?
正确思路是做 **动态 active batch**。
也就是先把每个样本展开成多条 trajectory,然后每一轮只把还没结束的 trajectory 收集起来批量生成。
伪代码:
```python
trajectories = []
for messages, tools in zip(messages_batch, tools_batch):
for _ in range(num_gen):
trajectories.append({
"messages": [dict(m) for m in messages],
"tools": tools,
"active": True,
"open_thinking": random.random() < thinking_ratio,
"prompt_ids": None,
"response_ids": [],
"response_mask": [],
"response_old_logps": [],
"turn_outputs": [],
"unfinished": False,
})
for turn in range(max_turns):
active_trajs = [t for t in trajectories if t["active"]]
if not active_trajs:
break
contexts = [
tokenizer.apply_chat_template(
t["messages"],
tokenize=False,
add_generation_prompt=True,
tools=t["tools"],
open_thinking=t["open_thinking"],
)
for t in active_trajs
]
inputs = tokenizer(
contexts,
return_tensors="pt",
padding=True,
add_special_tokens=False,
).to(device)
rollout_result = rollout_engine.rollout(
prompt_ids=inputs["input_ids"],
attention_mask=inputs["attention_mask"],
num_generations=1,
max_new_tokens=max_new_tokens,
temperature=0.8,
)
for row, traj in enumerate(active_trajs):
new_text = rollout_result.completions[row]
calls = parse_tool_calls(new_text)
if not calls:
traj["active"] = False
continue
traj["messages"].append({"role": "assistant", "content": new_text})
for call in calls:
result = execute_tool(call["name"], call["arguments"])
traj["messages"].append({"role": "tool", "content": json.dumps(result, ensure_ascii=False)})
```
这里最重要的是:
```text
batch 维度不是固定的原始样本 batch;
batch 维度是当前 turn 仍然 active 的 trajectory 集合。
```
实现时还要额外小心:
| 点 | 要保证什么 |
|----|------------|
| `prompt_ids` | 每条 trajectory 只记录最初 prompt 的有效 token |
| padding | 批量 tokenizer 后要用 `attention_mask` 找真实长度 |
| `response_mask` | policy 生成 token 填 1,tool observation delta 填 0 |
| `response_old_logps` | policy token 填 rollout logprob,observation token 填 0.0 |
| `final_context` | 每条 trajectory 各自维护,不要被 batch 内其它样本污染 |
| `num_generations` | 展开成 trajectory 后,rollout 每轮通常用 `num_generations=1` 更好管理 |
---
<a id="q6-logprob-mask-align"></a>
## Q6: old_logps 和 completion_mask 为什么要右移对齐?
核心原因是:Causal LM 的 loss/logprob 不是直接对 `input_ids[:, i]` 计分,而是用第 `i` 个位置的 logits 预测第 `i + 1` 个 token。
**代码** `trainer/train_agent.py:255-285`
```python
for p, r, m, old_lp in zip(prompt_ids_batch, response_ids_batch, response_masks_batch, response_old_logps_batch):
ids = p + r
mask = [0] * len(p) + m
old_logps = [0.0] * max(len(p) - 1, 0) + old_lp
...
full_response_masks = torch.tensor(...)
old_per_token_logps = torch.tensor(...)
res = model_unwrapped(input_ids, attention_mask=full_mask)
logits = res.logits[:, :-1, :]
per_token_logps = F.log_softmax(logits, dim=-1).gather(
2,
input_ids[:, 1:].unsqueeze(-1)
).squeeze(-1)
completion_mask = full_response_masks[:, 1:]
```
这里有三组长度:
| 张量/列表 | 长度 | 含义 |
|---|---:|---|
| `ids = p + r` | `L` | 完整 token 序列 |
| `mask = [0] * len(p) + m` | `L` | 按原始 token 位置标记哪些 response token 参与训练 |
| `per_token_logps` | `L - 1` | 对 `input_ids[:, 1:]` 这些目标 token 的 logprob |
所以 `per_token_logps[:, i]` 对应的不是 `input_ids[:, i]`,而是:
```text
per_token_logps[:, i]
= log P(input_ids[:, i + 1] | input_ids[:, :i + 1])
```
举例:
```text
p = [A, B, C]
r = [D, E]
input_ids = [A, B, C, D, E]
```
模型实际计分的位置是:
```text
per_token_logps:
预测 B, 预测 C, 预测 D, 预测 E
```
### 为什么 `old_logps` 前面补 `len(p) - 1` 个 0?
`old_lp` 只来自 rollout 期间生成的 response token,不包含 prompt 内部 token 的 old logprob。
但是训练时的 `per_token_logps` 包含 prompt 内部的目标位置:
```text
预测 B
预测 C
预测 D
预测 E
```
其中 `预测 B`、`预测 C` 这两个位置数量正好是:
```python
len(p) - 1
```
所以要写成:
```python
old_logps = [0.0] * max(len(p) - 1, 0) + old_lp
```
对上面的例子就是:
```text
old_lp = [old_logp(D), old_logp(E)]
old_logps = [0.0, 0.0, old_logp(D), old_logp(E)]
```
前面的 `0.0` 不是有意义的 old policy 概率,只是占位。它们之后会被 `completion_mask` 盖掉,不参与 policy loss。
注意这里不是补 `len(p)` 个 0。因为 `per_token_logps` 已经丢掉了第一个 token 的目标位置,只剩 `input_ids[:, 1:]`,prompt 内部还需要对齐的目标 token 是 `B, C`,数量是 `len(p) - 1`。
### 为什么 `completion_mask = full_response_masks[:, 1:]`?
`full_response_masks` 是按原始 `input_ids` 位置标的:
```text
input_ids: [A, B, C, D, E]
full_response_masks: [0, 0, 0, 1, 1]
```
但 `per_token_logps` 是按目标 token 位置标的:
```text
per_token_logps: [预测 B, 预测 C, 预测 D, 预测 E]
```
所以 mask 也必须右移成:
```python
completion_mask = full_response_masks[:, 1:]
```
对应结果是:
```text
completion_mask: [0, 0, 1, 1]
```
这样才表示:
```text
预测 B: 不训练
预测 C: 不训练
预测 D: 训练
预测 E: 训练
```
如果错用 `full_response_masks[:, :-1]`,会得到:
```text
[0, 0, 0, 1]
```
这样第一个 response token `D` 的 logprob 会被 mask 掉,loss 位置整体错一格。
最后要把 tool observation 也放进这个逻辑里理解:`m` 里 policy 生成 token 是 `1`,tool observation delta 是 `0`;`old_lp` 里 observation token 也用 `0.0` 占位。真正决定哪些 token 参与 loss 的是右移后的 `completion_mask`。
---
## 一句话总结
`rollout_single` 当前一次只跑 **一条 trajectory**,所以 tensor 是 `[1, seq_len]`。
底层 `rollout_engine.rollout` 支持 `[B, seq_len]` 批量生成,但 Agent rollout 有 tool call、tool observation、提前结束、多轮上下文变长这些动态分支。
因此可以批量采样,但要写成 **按 active trajectory 动态组 batch**,不能只把外层 `for` 循环删掉。