时间:2026-03-14 16:44
人气:
作者:admin
很多人第一次看 π0.5,都会卡在几个几乎一样的问题上:
prefix 和 suffix 到底是什么?是不是就是前半段和后半段 token?π0.5 要把 state 写进 prompt,而不是像 π0 一样走连续分支?x_t、u_t、v_t 分别是什么?如果你也有这些问题,这篇文章就是写给你的。
我会尽量避免“论文翻译腔”和“术语套术语”,而是直接沿着 openpi 代码,把 π0.5 的关键实现一层层拆开讲清楚。你可以把它当成一篇偏源码导读、但尽量照顾非论文型读者的长文。
面向读者:
- 想入门具身智能 / VLA
- 听说过
π0、π0.5,但还没真正看懂- 想从
openpi开源代码出发,把“论文概念”和“工程实现”对应起来本文目标:
- 不只讲概念,也不只贴源码
- 用一条完整主线,把
openpi里和π0.5相关的核心代码串起来- 重点讲清楚:
π0.5的模型架构- 训练时输入输出是什么
- 前向过程怎么走
- loss 怎么算
- 推理时怎么从噪声一步步得到动作
π0.5和π0到底改了什么,为什么这些改动可能有效
π0.5,和论文完整版 π0.5,不是一回事很多人第一次看 π0.5,会同时接触三样东西:
openpi 开源仓库然后很容易开始困惑:
π0.5 可以先输出高层语义子任务,再输出低层连续动作openpi 仓库里,很多地方看起来又像是“只有 flow matching 连续动作头”这个困惑不是你看错了,而是这三者本来就不完全等价。
openpi 的 README 里已经写得很明确:
Note that, in this repository, we currently only support the flow matching head for both π0.5 training and inference.
也就是说:
π0.5
π0.5
所以这篇文章会坚持一个原则:
凡是能用源码确认的,就按源码说;凡是来自论文和博客补充的,我会明确标出来。
可以先把 openpi 里的 π0.5 理解成这样一套系统:
输入图像、任务文本、机器人当前状态,再加上一段“当前还带噪声的动作序列”,模型学习预测“应该朝哪个方向把这段动作去噪”。训练时学这个方向,推理时从纯噪声开始反复更新 10 步左右,最后得到可执行的动作 chunk。
如果你有扩散模型的基础,可以把它粗略理解成:
但 π0.5 和常见扩散还有明显不同,后面会细讲。
π0.5 不是单纯“机器人模仿学习模型”,而是一套异构数据协同训练配方
图 1 对应论文 Fig.1。它最想表达的是:
π0.5 的目标不是只学“某个固定机器人做某个固定任务”这些数据包括:
但请注意,这张图表达的是论文整体 recipe,不是开源仓库全部实现都一比一放出来了。
π0.5 的两阶段训练思路
这张图特别重要,因为它解释了很多“初看很矛盾”的地方。
论文版 π0.5 是这么设计的:
所以你之前疑惑的“论文里为什么说预训练 action 用 FAST,但开源代码又是连续动作 flow matching”,答案是:
两者都对,只是分别对应论文 recipe 的不同阶段。
更进一步地说:
π0.5:离散动作和连续动作两套能力都用上了openpi:主要开放了连续动作这条 flow matching 头这点必须先讲清楚,不然后面的源码很容易越看越乱。
openpi 里,应该优先读哪些文件?如果你想从源码真正读懂 π0.5,我建议优先看这几条线:
openpi/src/openpi/models/pi0.pyopenpi/src/openpi/models/pi0_config.pyopenpi/src/openpi/models/gemma.pyopenpi/src/openpi/models/tokenizer.pyopenpi/src/openpi/models/model.pyopenpi/src/openpi/transforms.pyopenpi/src/openpi/policies/droid_policy.pyopenpi/src/openpi/policies/libero_policy.pyopenpi/src/openpi/training/config.py读法建议是:
model.py 搞懂统一输入格式transforms.py 和 policies/*.py 搞懂数据怎么被整理pi0_config.py 搞懂 π0 和 π0.5 的开关pi0.py 把训练和推理串起来这样最顺。
在 openpi 里,模型统一吃 Observation 和 Actions。
源码里的定义大概是:
imageimage_maskstatetokenized_promptactions用人话说,就是:
对应源码:
@at.typecheck
@struct.dataclass
class Observation(Generic[ArrayT]):
"""Holds observations, i.e., inputs to the model.
...
"""
# Images, in [-1, 1] float32.
images: dict[str, at.Float[ArrayT, "*b h w c"]]
# Image masks, with same keys as images.
image_masks: dict[str, at.Bool[ArrayT, "*b"]]
# Low-dimensional robot state.
state: at.Float[ArrayT, "*b s"]
# Tokenized prompt.
tokenized_prompt: at.Int[ArrayT, "*b l"] | None = None
# Tokenized prompt mask.
tokenized_prompt_mask: at.Bool[ArrayT, "*b l"] | None = None
很多人第一次看模型代码,会忽略“模型前面还有一层数据适配”。
实际上,这层非常关键。
例如 DROID policy 的输入适配:
@dataclasses.dataclass(frozen=True)
class DroidInputs(transforms.DataTransformFn):
# Determines which model will be used.
model_type: _model.ModelType
def __call__(self, data: dict) -> dict:
gripper_pos = np.asarray(data["observation/gripper_position"])
if gripper_pos.ndim == 0:
# Ensure gripper position is a 1D array, not a scalar, so we can concatenate with joint positions
gripper_pos = gripper_pos[np.newaxis]
state = np.concatenate([data["observation/joint_position"], gripper_pos])
...
match self.model_type:
case _model.ModelType.PI0 | _model.ModelType.PI05:
names = ("base_0_rgb", "left_wrist_0_rgb", "right_wrist_0_rgb")
它做的事很简单,但很重要:
state所以可以把这层理解成:
“数据集字段世界” 到 “模型输入世界” 的翻译器。
π0.5 和 π0 的第一大差异:state 怎么进模型官方在配置文件里直接写了总结:
# Pi05 has two differences from Pi0:
# - the state input is part of the discrete language tokens rather than a continuous input that is part of the suffix
# - the action expert uses adaRMSNorm to inject the flow matching timestep
先看第一条:
π0state tokenπ0.5这是理解 π0.5 的第一把钥匙。
这两个词在 openpi 源码里非常核心。
你可以把它理解成:
条件上下文部分
里面包括:
π0.5 默认设定下,还包括离散化后的 state tokens它回答的是:
你可以把它理解成:
动作生成部分
里面包括:
π0:一个连续 state token + 一串 noisy action tokensπ0.5:一串 noisy action tokens它更像是:
“当前这一步动作去噪过程中的中间变量”
所以我更喜欢把它类比成:
prefix:题目suffix:当前答案草稿模型每一步都在基于题目,去修正这个答案草稿。
π0.5 是怎么把 state 写进 prompt 的?先直接看代码:
if state is not None:
# This is the Pi05 format, where the state is part of the discrete language input.
discretized_state = np.digitize(state, bins=np.linspace(-1, 1, 256 + 1)[:-1]) - 1
state_str = " ".join(map(str, discretized_state))
full_prompt = f"Task: {cleaned_text}, State: {state_str};\nAction: "
tokens = self._tokenizer.encode(full_prompt, add_bos=True)
这段代码可以拆成 4 步:
[-1, 1]这是这个离散化过程的前提。
也就是把连续值变成一个 0~255 左右的整数。
比如变成:
Task: pick up the fork, State: 128 93 44 211 17 ...;
Action:
于是 state 就和自然语言 prompt 一起进入了统一的 token 空间。
π0.5 要这么改?把 state 写进文本到底有什么好处?这是一个很值得展开的问题。
我觉得可以从三个角度理解:
如果 state 也进入离散 token 空间,那么:
这样模型就更像在处理一个统一的多模态序列,而不是“图像一支、语言一支、state 一支、动作一支”完全割裂的结构。
机器人当前 state 并不是纯底层量,它经常和语义决策有关:
把这些信息放进 prompt 后,模型在处理“任务语义”时,也能同步利用当前状态。
π0.5 的 heterogeneous co-training 思路π0.5 的重要方向之一,是让模型能跨数据源学习:
这时候,把更多信息转成 token,往往更容易和大模型的 sequence modeling 框架接起来。
当然,这也不是“绝对更好”,而是一个设计取向:
用 token 化的方式,把 state 拉进 VLM 语义空间。
这个问题不解释清楚,后面一定会乱。
既然 π0.5 都把 state 写进 prompt 变成 token 了,那 action 呢?
论文里又说过 FAST tokenizer,那 action 在开源版里是不是也是 token?
在 openpi 这套开源版 π0.5 代码里,action 不是 token,而是连续向量。
这点非常重要。
也就是说,在开源仓库的 π0.5 flow-matching 路径里:
因为你看到的是三套事情叠在一起:
π0π0.5π0-FAST所以不要把它们混成一句“π0.5 就是 action token 模型”。
更准确的说法是:
论文版 π0.5 结合了离散动作训练与连续动作推理;而 openpi 开源版主要开放了连续动作 flow matching 这部分。
论文对这个问题写得其实挺清楚:
论文转换稿里也有明确表述:
Robotic data uses the FAST action tokenizer to represent actions as discrete tokens.
Second, a post-training stage … uses flow matching to represent the action distribution.
所以,如果你读源码时发现:
openpi 的 pi0.py 里 action 明明是连续向量不要怀疑自己看错了。
这是因为:
你现在看到的是开源仓库里偏“post-training / continuous action head”的那部分代码。
在 transforms.py 里,TokenizePrompt 决定了 state 是否一起进入文本:
@dataclasses.dataclass(frozen=True)
class TokenizePrompt(DataTransformFn):
tokenizer: _tokenizer.PaligemmaTokenizer
discrete_state_input: bool = False
def __call__(self, data: DataDict) -> DataDict:
if (prompt := data.pop("prompt", None)) is None:
raise ValueError("Prompt is required")
if self.discrete_state_input:
if (state := data.get("state", None)) is None:
raise ValueError("State is required.")
else:
state = None
...
tokens, token_masks = self.tokenizer.tokenize(prompt, state)
这里的逻辑是:
discrete_state_input=False
discrete_state_input=True
π0.5 那条 state 离散化逻辑所以 π0.5 的“state 写进 prompt”其实并不是写死在模型里,而是通过数据变换层控制的。
pi05_libero 居然把 discrete_state_input=False 关掉了在 training/config.py 里,你会看到这个配置:
TrainConfig(
name="pi05_libero",
model=pi0_config.Pi0Config(pi05=True, action_horizon=10, discrete_state_input=False),
这意味着什么?
意味着:
pi05=Truediscrete_state_input=False 了也就是说,不是所有名叫 pi05_* 的配置都会采用“state 写进 prompt”这个默认策略。
这说明一件很重要的工程事实:
π0.5 在开源代码里更像是一类模型变体,而不是一个只有单一固定输入范式的纯理论对象。
如果你是做实际实验的人,这个细节特别重要:
π0.py 里到底做了什么?核心类在这里:
class Pi0(_model.BaseModel):
def __init__(self, config: pi0_config.Pi0Config, rngs: nnx.Rngs):
super().__init__(config.action_dim, config.action_horizon, config.max_token_len)
self.pi05 = config.pi05
paligemma_config = _gemma.get_config(config.paligemma_variant)
action_expert_config = _gemma.get_config(config.action_expert_variant)
# TODO: rewrite gemma in NNX. For now, use bridge.
llm = nnx_bridge.ToNNX(
_gemma.Module(
configs=[paligemma_config, action_expert_config],
embed_dtype=config.dtype,
adarms=config.pi05,
)
)
它其实是一个统一实现:
π0 和 π0.5 都用这个类config.pi05 来切换差异分支从结构上,可以把它理解成三部分:
把多路相机图像变成 image tokens。
负责融合图像、语言和动作上下文。
这是动作侧的专门分支:
所以整个模型并不是一个“纯文本 LLM 套壳”,而是一个:
图像编码器 + 多模态 Transformer 主干 + 动作专家头
embed_prefix() 和 embed_suffix()这两个函数几乎就是整个模型的灵魂。
embed_prefix():条件部分怎么构造embed_prefix() 主要做两件事:
tokenized_prompt 通过词嵌入表变成 text embeddings最后把两者拼接,形成 prefix_tokens。
你可以把 prefix 理解成:
“当前场景 + 当前任务 + (可能还有离散 state)”
embed_suffix():动作部分怎么构造这才是最关键的差异点。
代码如下:
if not self.pi05:
# add a single state token
state_token = self.state_proj(obs.state)[:, None, :]
tokens.append(state_token)
...
action_tokens = self.action_in_proj(noisy_actions)
# embed timestep using sine-cosine positional encoding with sensitivity in the range [0, 1]
time_emb = posemb_sincos(timestep, self.action_in_proj.out_features, min_period=4e-3, max_period=4.0)
if self.pi05:
# time MLP (for adaRMS)
time_emb = self.time_mlp_in(time_emb)
time_emb = nnx.swish(time_emb)
time_emb = self.time_mlp_out(time_emb)
time_emb = nnx.swish(time_emb)
action_expert_tokens = action_tokens
adarms_cond = time_emb
else:
# mix timestep + action information using an MLP (no adaRMS)
time_tokens = einops.repeat(time_emb, "b emb -> b s emb", s=self.action_horizon)
action_time_tokens = jnp.concatenate([action_tokens, time_tokens], axis=-1)
action_time_tokens = self.action_time_mlp_in(action_time_tokens)
action_time_tokens = nnx.swish(action_time_tokens)
action_time_tokens = self.action_time_mlp_out(action_time_tokens)
action_expert_tokens = action_time_tokens
adarms_cond = None
这段代码非常值得慢慢看。
π0 和 π0.5 在 suffix 中的区别,一句话版π0state 投影成一个 tokenπ0.5state tokenadarms_cond 去调制 action expert 的归一化层这两条路线的差异,其实就是:
π0.5 的第二个关键改动:为什么要把时间步 t 改成 AdaRMSNorm 注入?这部分是很多人第一次看时最容易“看懂代码、没懂直觉”的地方。
先看底层实现。
在 gemma.py 里,RMSNorm 如果拿到 cond,就会从普通 RMSNorm 变成一种自适应 RMSNorm:
class RMSNorm(nn.Module):
@nn.compact
def __call__(self, x, cond):
dtype = x.dtype # original dtype, could be half-precision
var = jnp.mean(jnp.square(x.astype(jnp.float32)), axis=-1, keepdims=True) # compute variance in float32
normed_inputs = jnp.asarray(x * jnp.reciprocal(jnp.sqrt(var + 1e-06))) # compute normalization in float32
if cond is None:
# regular RMSNorm
scale = self.param("scale", nn.initializers.zeros_init(), (x.shape[-1]))
normed_inputs = normed_inputs * (
1 + scale
) # scale by learned parameter in float32 (matches Flax implementation)
return normed_inputs.astype(dtype), None # return in original dtype
# adaptive RMSNorm
modulation = nn.Dense(x.shape[-1] * 3, kernel_init=nn.initializers.zeros, dtype=dtype)(cond)
scale, shift, gate = jnp.split(modulation[:, None, :], 3, axis=-1)
你可以把它粗略理解成:
cond 做动态调制而在 π0.5 里,这个 cond 就是时间步 t 经过 MLP 后得到的表示。
意味着:
π0:时间信息主要在输入层就混进 action token 了π0.5:时间信息可以逐层影响 action expert 的内部计算这很像是在告诉模型:
“你现在是在去噪初期、中期还是末期?不同阶段,应该用不同的内部处理方式。”
因为 flow matching 的不同 t,本来就对应完全不同的去噪语义:
t=1:动作还非常像噪声,需要更粗的修正t=0:动作已经接近干净,需要更细的修正如果时间条件只在最开始拼一下,网络后面层数多了以后可能会被冲淡;
而如果时间条件能进入每一层的归一化,它就能持续影响整个动作专家的处理过程。
这就是我理解里 π0.5 在实现层面非常漂亮的一点。
t?只看当前 noisy action 不行吗?这也是理解 flow matching 的核心。
batch_shape = actions.shape[:-2]
noise = jax.random.normal(noise_rng, actions.shape)
time = jax.random.beta(time_rng, 1.5, 1, batch_shape) * 0.999 + 0.001
time_expanded = time[..., None, None]
x_t = time_expanded * noise + (1 - time_expanded) * actions
u_t = noise - actions
...
v_t = self.action_out_proj(suffix_out[:, -self.action_horizon :])
return jnp.mean(jnp.square(v_t - u_t), axis=-1)
这里定义了 4 个非常重要的量:
noise从高斯分布采样的一段噪声动作。
tflow matching 的时间步,从 0 到 1。
这段代码里不是均匀采样,而是:
Beta(1.5, 1)[0.001, 0.999]也就是说,训练时会随机抽取不同的 t,让模型见过不同去噪阶段。
x_t定义为:
x_t = t * noise + (1 - t) * actions
它表示:
真实动作和噪声在时间步 t 下的线性混合状态。
u_t定义为:
u_t = noise - actions
这就是理论上对应的目标速度场。
v_t模型预测出来的速度场。
训练目标就是:
MSE(v_t, u_t)
x_t 还不够?因为不同的 (noise, actions) 组合,在不同的 t 下,完全可能混出同一个 x_t。
举个非常直观的例子。
假设某个维度里:
情况 A:
noise = 1action = 0t = 0.5x_t = 0.5u_t = 1 - 0 = 1情况 B:
noise = 0.5action = 0t = 1.0x_t = 0.5u_t = 0.5 - 0 = 0.5你会发现:
x_t 一样,都是 0.5所以模型不能只看“当前这个 noisy action 长什么样”,还得知道:
“我现在是在整条噪声到动作路径上的哪一段?”
而这个信息,就是 t。
这也是为什么:
t 不是一个可有可无的小辅助变量把真实动作想成一条“干净轨迹”,把噪声想成一条“乱轨迹”。
训练时,模型反复看各种中间状态:
模型学会的不是“一步直接输出正确动作”,而是:
“在任何一个中间状态下,我都知道应该往哪边走。”
于是推理时就可以:
这就是 π0 / π0.5 里 flow matching 的核心直觉。
为了帮助第一次看的人建立完整脑图,我把训练过程压缩成一条“工程视角”的流程。
输入通常包括:
通过 policy transform,整理成:
imageimage_maskstatepromptactionsπ0:只 token 化 promptπ0.5:默认还会把离散化后的 state 写进 prompt 一起 token 化源码里有统一 padding:
class PadStatesAndActions(DataTransformFn):
"""Zero-pads states and actions to the model action dimension."""
model_action_dim: int
def __call__(self, data: DataDict) -> DataDict:
data["state"] = pad_to_dim(data["state"], self.model_action_dim, axis=-1)
if "actions" in data:
data["actions"] = pad_to_dim(data["actions"], self.model_action_dim, axis=-1)
return data
这点很重要,因为不同机器人平台动作维度并不一致。
preprocess_observation() 会做:
noise 和 tnoise ~ N(0, I)t ~ Beta(1.5, 1),再裁到 [0.001, 0.999]x_t = t*noise + (1-t)*actionsu_t = noise - actionsprefix_tokens由:
组成。
suffix_tokens由:
π0:state token + noisy action tokensπ0.5:noisy action tokens组成,同时注入时间步。
模型输出 suffix_out。
v_t只取动作那部分输出,做线性投影。
loss = MSE(v_t, u_t)
至此,一次训练前向结束。
上面那 12 步是拆开讲的。
如果你更习惯“从输入一路看到输出”,那下面这段伪代码会更直观。
# =========================
# 输入
# =========================
# obs:
# - images: 多路图像
# - prompt: 任务文本
# - state: 当前机器人状态
# actions:
# - 真实动作序列 [B, H, A]
# =========================
# Step 1. 数据预处理
# =========================
obs = preprocess_observation(obs)
# π0:
# prompt 只包含任务文本
# π0.5:
# prompt 可能包含 "Task: ..., State: ...; Action: "
tokenized_prompt = tokenize_prompt(prompt, maybe_state_for_pi05)
# state 和 actions padding 到统一 action_dim
state = pad_state(state)
actions = pad_actions(actions)
# =========================
# Step 2. 构造 flow matching 训练样本
# =========================
noise = sample_gaussian(shape=actions.shape)
t = sample_time_from_beta_distribution() # t in (0, 1)
# 当前混合状态:在真实动作和纯噪声之间插值
x_t = t * noise + (1 - t) * actions
# 训练目标:速度场
u_t = noise - actions
# =========================
# Step 3. 构造 prefix
# =========================
image_tokens = SigLIP(images)
language_tokens = GemmaEmbed(tokenized_prompt)
prefix_tokens = concat(image_tokens, language_tokens)
# =========================
# Step 4. 构造 suffix
# =========================
action_tokens = action_in_proj(x_t)
time_emb = posemb_sincos(t)
if model == "pi0":
state_token = state_proj(state)
action_tokens = action_time_mlp(concat(action_tokens, repeat(time_emb)))
suffix_tokens = concat(state_token, action_tokens)
adarms_cond = None
if model == "pi05":
# state 不再单独走 suffix,它已经在 prompt 里
suffix_tokens = action_tokens
adarms_cond = time_mlp(time_emb) # 用来做 AdaRMS 条件调制
# =========================
# Step 5. Transformer 前向
# =========================
prefix_out, suffix_out = PaliGemma_with_ActionExpert(
prefix_tokens,
suffix_tokens,
attn_mask=build_mask(prefix_tokens, suffix_tokens),
adarms_cond=adarms_cond
)
# 只取 suffix 最后 H 个 action token 的输出
v_t = action_out_proj(suffix_out[:, -H:])
# =========================
# Step 6. 训练损失
# =========================
loss = MSE(v_t, u_t)
# =========================
# 输入
# =========================
# obs:
# - 当前图像
# - 当前任务 prompt
# - 当前 state
obs = preprocess_observation(obs)
# 从纯噪声动作开始
x_t = sample_gaussian(shape=[B, H, A])
t = 1.0
dt = -1.0 / num_steps
# prefix 不变,先缓存
prefix_tokens = embed_prefix(obs)
kv_cache = build_prefix_kv_cache(prefix_tokens)
while t >= -dt / 2:
suffix_tokens = embed_suffix(obs, x_t, t)
# 模型预测当前速度方向
v_t = model(prefix_cached=kv_cache, suffix_tokens=suffix_tokens, t=t)
# Euler 更新:沿着预测方向走一小步
x_t = x_t + dt * v_t
t = t + dt
# 最终 x_t 就是干净动作 x_0
actions = x_t
这一段如果你吃透了,后面很多局部细节就不容易迷路。
这也是很多人第一次读时容易混淆的地方。
obsx_t(由真实动作和噪声混出来)tobsx_tt区别在于:
x_t 来自真实动作 + 噪声x_t 最开始纯噪声,后面来自模型自己迭代更新v_t然后外层采样器做 Euler 更新:
x_t <- x_t + dt * v_t
反复执行若干次,直到 t≈0,最终拿到干净动作。
来看 sample_actions():
# note that we use the convention more common in diffusion literature, where t=1 is noise and t=0 is the target
# distribution. yes, this is the opposite of the pi0 paper, and I'm sorry.
dt = -1.0 / num_steps
batch_size = observation.state.shape[0]
if noise is None:
noise = jax.random.normal(rng, (batch_size, self.action_horizon, self.action_dim))
...
def step(carry):
x_t, time = carry
...
v_t = self.action_out_proj(suffix_out[:, -self.action_horizon :])
return x_t + dt * v_t, time + dt
...
x_0, _ = jax.lax.while_loop(cond, step, (noise, 1.0))
return x_0
把它翻成大白话就是:
初始化:
x_1 = noiset = 1图像和文本的 prefix 不会在 10 步迭代中改变,所以先编码并缓存 KV。
这是非常重要的工程优化,不然每一步都重算图像和文本很浪费。
因为:
x_t 在变t 在变所以 suffix 每一步都要重新构造。
v_tx_t = x_t + dt * v_t
最后得到 x_0,也就是干净动作。
源码里默认:
num_steps = 10论文里也提到:
这和很多扩散模型动辄几十上百步相比,已经算很快了。
原因在于:
当然,这并不意味着“10 步是理论最优”,而是一个工程上效果和速度的折中。
while time >= -dt/2 为什么不是 time > 0?这属于“只有认真看源码才会注意到”的小细节。
代码是:
def cond(carry):
x_t, time = carry
# robust to floating-point error
return time >= -dt / 2
因为:
dt = -1 / num_stepstime 可能不会精确落到 0如果你直接写 time > 0:
而 -dt/2 相当于留了一个容差带,避免数值误差影响步数。
这类处理很朴素,但很有工程味。
这点其实很重要,但很多介绍文章都一笔带过。
在 π0 / π0.5 里:
直觉上可以这样理解:
所以从结构上看,它更像:
一个共享多模态 Transformer,在同一序列里同时容纳条件和动作生成变量。
而不是那种严格分离的 encoder-decoder 两塔结构。
很多读者第一次看模型时会默认:
但实际工程里远没这么简单。
例如 DROID 输出层最后只保留前 8 维:
@dataclasses.dataclass(frozen=True)
class DroidOutputs(transforms.DataTransformFn):
def __call__(self, data: dict) -> dict:
# Only return the first 8 dims.
return {"actions": np.asarray(data["actions"][:, :8])}
而很多 π0.5 配置内部 action dim 是 32。
这意味着:
这个设计对跨平台训练很重要。
你也能从论文里看到类似思想:
这也是很多“跨 embodiment”模型的常见工程套路。
看 LiberoOutputs:
def __call__(self, data: dict) -> dict:
# Only return the first N actions -- since we padded actions above to fit the model action
# dimension, we need to now parse out the correct number of actions in the return dict.
# For Libero, we only return the first 7 actions (since the rest is padding).
# For your own dataset, replace `7` with the action dimension of your dataset.
return {"actions": np.asarray(data["actions"][:, :7])}
这进一步说明:
换句话说:
模型内部的动作表示,不等于机器人最终实际执行的控制接口。
中间有一层“统一表示空间”。
π0.5 相比 π0,到底好在哪里?如果只从开源代码能确认的部分来讲,我认为至少有三点。
π0:
π0.5:
这对需要更强语义理解和跨场景泛化的任务,可能是有帮助的。
π0:
π0.5:
这对 flow matching 这种“不同去噪阶段处理逻辑差异显著”的问题,直觉上更合理。
π0.5 的整体方向即便开源版只开放了 flow matching 头,它在结构上仍然能看出那条主线:
换句话说,π0.5 在开源实现里不是“完全新模型”,而是:
在 π0 连续动作 VLA 基础上,往更强 generalization 方向做的一次结构升级。
π0.5 还比开源版多了什么?这部分很值得单独列出来,帮助读者建立正确预期。
π0.5 额外强调的能力包括:推理时先输出:
这种高层子任务
再根据这个子任务输出低层连续动作。
包括:
而这些在 openpi 开源仓库里,并不是都以“完整 recipe”形式展开了。
所以如果你的目标是:
那你不能只盯着当前仓库里 pi0.py 这套 flow head 代码。
这里我把读源码时最常见的误区集中总结一下。
π0.5 就是 action token 模型不对。
π0.5 在 pretraining 阶段会用离散动作 tokenπ0.5 重点开放的是连续 flow matching 动作头不对。
它们在模型里的角色不同:
表示方式不同是合理的。
不对。
suffix 更像“当前动作去噪轨迹的中间变量”,不是简单的 observation 补充。
不对。
模型输出的是 v_t,也就是当前速度场。
最终动作是外层采样器迭代积分出来的。
x_t 就能判断怎么去噪也不对。
还必须知道当前时间步 t,否则同一个 x_t 可能对应不同速度方向。
如果要把整篇文章收束成一句最核心的话,我会这样说:
openpi 里的 π0.5,本质上是一个把图像、语言和机器人状态融合成条件上下文,再通过 flow matching 连续生成动作序列的多模态 Transformer。相比 π0,它最关键的两点改进是:把 state 从连续 token 改成了离散 prompt token,以及把时间步 t 从输入级拼接升级成了 AdaRMS 级别的逐层条件调制。
如果你已经把这句话真正看懂了,那么:
prefix / suffixx_t / u_t / v_tπ0 / π0.5 的核心差异基本就都打通了。
openpi GitHub / READMEπ0 官方博客π0.5 官方博客π0.5: a Vision-Language-Action Model with Open-World Generalization本文引用了你本地论文转换目录中的图片,主要包括:
当前图片链接使用的是相对路径,如果你后续要发布到线上平台,可以:
如果要我用一句话总结这篇文章,我会说:
π0.5 最值得学的,不只是“它比 π0 更强”,而是它代表了一种很典型的当代 VLA 设计思路。
这种思路大概是:
从这个意义上看,π0.5 有意思的地方,不只是某个 benchmark 分数,也不只是“state 改成写进 prompt”这种局部技巧,而是它把下面这几件事放进了同一个系统里:
这也是为什么,哪怕你暂时不打算直接复现 π0.5,认真读一遍 openpi 这套代码,依然很值得。
因为你学到的不是某个单点 trick,而是一种更完整的具身模型思路:
一边继承 VLM 的语义能力,一边保留机器人控制所需要的连续动作表达。
如果你后面继续深入,我个人建议优先顺着这三条线往下读:
pi0.py 里 embed_prefix() / embed_suffix() / compute_loss() / sample_actions()tokenizer.py 和 transforms.py 里 state 是怎么进入 prompt 的π0.5 中 FAST 预训练、high-level subtask 和 post-training flow head 的完整关系等这三条线彻底打通之后,你再回头看 π0、π0.5、π0-FAST,很多原本“像是术语差异”的地方,就会真正变成你脑子里能跑起来的一套系统。