
介绍我在 Windows 上使用 WSL2 打造 AI 开发环境的一些思路,源码和 AI Agent 留在 Windows ,WSL2 只当编译测试沙盒,用调度脚本和约束规则串联工作流
原文链接:https://blog.dejavu.moe/posts/windows-wsl2-ai-dev-environment/前言
最近把主力笔电换成 Omarchy Linux 4 用了将近一个月,纯键盘流加平铺式桌面管理器体验很好,开箱即用的 AI Naive OS 风格也不错。有些软件始终找不到替代品,又不想折腾双系统和虚拟机,最终还是回到了 Windows。
前些年一直有「Windows 是最好的 Linux 发行版」的说法 [^1] ,但就我自己从 WSL1 到 WSL2 的使用体验来看,始终绕不开两个问题:
- 跨文件系统访问的 I/O 性能极其糟糕
- 宿主机 AI Agent 与 WSL2 交互麻烦
WSL2 通过
/mnt挂载 Windows 盘符,例如/mnt/d对应 D 盘。这种跨文件系统访问的 I/O 性能相比原生 ext4 文件系统性能损耗很大,涉及大量小文件时尤为明显。我的需求很简单:
本地运行的 Codex、Claude Code 直接操作代码工作区,干净的代码完全存放在 Windows 宿主机磁盘,Git、SSH、GPG、YubiKey 不需要在 WSL2 里再配一套,WSL2 只充当无状态的编译与测试沙盒。Docker 镜像构建和测试走 Docker Desktop 的 WSL2 集成,而非在 WSL2 里单独安装 Docker 引擎。
规范分工
Windows 仓库是唯一权威源码,WSL2 内的副本随时可以清空重建。宿主机与 WSL2 之间靠一个 PowerShell Wrapper 脚本
linux-task.ps1串联。Agent 自动化也这条路径:宿主机改完代码后,在 WSL2 的 ext4 内同步跑构建测试,日志与退出码传回 Windows。本文以我当前的实际环境为例,所有 WSL 虚拟磁盘、镜像与脚本统一放在
D:\WSL,最终的文件目录树看起来像这样:D:\WSL ├── Debian/ │ ├── ext4.vhdx Debian 系统虚拟磁盘 │ └── shortcut.ico ├── DockerDesktop/ │ ├── disk/ │ │ └── docker_data.vhdx # 容器与镜像存储 │ └── main/ │ └── ext4.vhdx # Docker Desktop 运行引擎 ├── Images/ │ └── Debian.wsl # 发行版安装包 ├── Scripts/ │ └── linux-task.ps1 # 跨环境调度脚本 ├── Snapshots/ # 冷备份快照 └── swap.vhdx # 全局交换分区环境准备
下文假设 Windows 用户名为
ice,Debian 用户名为dejavu,源码目录D:\Forgejo,操作时请替换为你的实际路径。启用 WSL2
基础的 Scoop 与 Windows Terminal 配置可参考我之前的文章,本文不再展开。
在 BIOS/UEFI 设置中启用 CPU 虚拟化,然后以管理员身份运行 PowerShell 启用相关功能后重启:
$features = @( 'VirtualMachinePlatform' 'Microsoft-Windows-Subsystem-Linux' ) foreach ($name in $features) { Enable-WindowsOptionalFeature -Online -FeatureName $name -All -NoRestart } Restart-Computer更新 WSL 内核并设置默认版本 [^2] :
wsl --update wsl --set-default-version 2宿主机工具链
Windows 宿主机的 CLI 工具由 Scoop 安装,多语言运行时用 mise 管理。在
$PROFILE中加入 mise 激活代码:scoop install mise notepad $PROFILE加入 pwsh 配置:
if (Get-Command mise -ErrorAction SilentlyContinue) { (&mise activate pwsh) | Out-String | Invoke-Expression }重新打开终端后安装宿主机全局运行时:
mise use --global node@lts pnpm@latest [email protected] uv@latest mise use --global go@latest rust@stable gh@latest codex@latest调整 PATH 优先级
PowerShell
$PROFILE仅对交互式终端生效,Codex 等 AI Agent 和 IDE 依赖系统持久 PATH。此时可执行文件经由 mise 管理,shims 位于%LOCALAPPDATA%\mise\shims。但 Windows 默认 PATH 存在优先级冲突,比如
WindowsApps自带的占位python.exe排在前面,敲python会弹窗让你去 Microsoft Store 下载安装。我还发现 使用 YubiKey 原生 GPG 时,Scoop 安装的 GPG 也会被 Git 自带的 GPG 抢占。因此我需要运行以下脚本调整用户 PATH 优先级,将
mise\shims提到WindowsApps前面,同时加入脚本目录D:\WSL\Scripts:New-Item -ItemType Directory -Force -Path 'D:\WSL\Scripts' | Out-Null $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') $stamp = Get-Date -Format yyyyMMddHHmmss $userPath | Set-Content "$env:USERPROFILE\user-path-backup-$stamp.txt" $priorityDirs = @("$env:LOCALAPPDATA\mise\shims") $gpgBin = 'D:\Scoop\apps\gpg\current\bin' if (Test-Path -LiteralPath (Join-Path $gpgBin 'gpgconf.exe')) { $priorityDirs = @($gpgBin) + $priorityDirs } $rest = @($userPath -split ';' | Where-Object { $_ -and $priorityDirs -notcontains $_.TrimEnd('\') }) $pathParts = @($priorityDirs + $rest) foreach ($dir in @("$env:USERPROFILE\.local\bin", 'D:\WSL\Scripts')) { if (($pathParts | ForEach-Object { $_.TrimEnd('\') }) -notcontains $dir) { $pathParts += $dir } } $newPath = $pathParts -join ';' [Environment]::SetEnvironmentVariable('Path', $newPath, 'User')改完后重启终端,验证效果:
Get-Command python.exe -All | Select-Object Name,Source $py = (Get-Command python.exe).Source if ($py -notlike "*\mise\*") { throw "Python 没有命中 mise,当前来源:$py" } $gpgBin = 'D:\Scoop\apps\gpg\current\bin' if (Test-Path -LiteralPath (Join-Path $gpgBin 'gpgconf.exe')) { Get-Command gpg.exe,gpgconf.exe,gpg-connect-agent.exe -All | Select-Object Name,Source foreach ($name in @('gpg.exe', 'gpgconf.exe', 'gpg-connect-agent.exe')) { if ((Get-Command $name).Source -ine (Join-Path $gpgBin $name)) { throw "$name 没有命中原生 GPG,请检查 PATH 顺序" } } }安装 Debian WSL2
把 Debian 13 的 WSL2 虚拟磁盘固定在
D:\WSL\Debian,下载镜像离线安装:New-Item -ItemType Directory -Force -Path 'D:\WSL\Images' | Out-Null $repo = 'https://raw.githubusercontent.com/microsoft/WSL/master' $catalog = Invoke-RestMethod "$repo/distributions/DistributionInfo.json" $debian = @($catalog.ModernDistributions.Debian) | Where-Object Default | Select-Object -First 1 $imagePath = 'D:\WSL\Images\Debian.wsl' Invoke-WebRequest -Uri $debian.Amd64Url.Url -OutFile $imagePath $hash = (Get-FileHash -LiteralPath $imagePath -Algorithm SHA256).Hash if ($hash -ine $debian.Amd64Url.Sha256) { throw 'SHA256 校验失败' } wsl --install --from-file $imagePath \` --location 'D:\WSL\Debian' --name Debian --version 2 --no-launch wsl --set-default Debian wsl -d Debian创建用户(如
dejavu)并配置权限:wsl -d Debian -u root -- bash -lc "apt update && apt install -y sudo && usermod -aG sudo dejavu"性能与网络配置
编辑 WSL2 全局配置
%USERPROFILE%\.wslconfig,限制资源,将 swap 指向 D 盘,启用镜像网络 [^3] :[wsl2] memory=12GB processors=8 swap=4GB swapFile=D:\\WSL\\swap.vhdx networkingMode=mirrored nestedVirtualization=falseWSL2 内部的 Debian 配置
/etc/wsl.conf:[boot] systemd=true [interop] enabled=true appendWindowsPath=false [user] default=dejavuappendWindowsPath=false用于阻断 Debian 自动继承 Windows PATH,防止误调.exe。保留
enabled=true是为了需要时仍能手动调用宿主程序。改完后运行
wsl --shutdown使配置生效。Linux 工具链
进入 Debian 更新系统并安装基础依赖:
sudo apt update && sudo apt full-upgrade -y sudo apt install -y vim build-essential ca-certificates curl wget git \ openssh-client gnupg unzip zip xz-utils jq file pkg-config procps \ iproute2 dnsutils bubblewrap rsync python3 python3-venv python3-pip配置免密 sudo:
echo "$USER ALL=(ALL:ALL) NOPASSWD: ALL" | sudo tee /etc/sudoers.d/90-$USER-nopasswd sudo chmod 0440 /etc/sudoers.d/90-$USER-nopasswd安装 Linux 端 mise 工具链:
curl -fsSL https://mise.run | sh cat >> ~/.bashrc <<'EOF' export PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:$PATH" EOF source ~/.bashrc mise use --global node@lts pnpm@latest go@latest rust@stableWSL 默认把宿主盘符挂载到
/mnt/c,mise 向上查找时可能误读 Windows 用户目录下的配置。修改~/.config/mise/miserc.toml:ignored_config_paths = ["/mnt/c/Users/ice/.config/mise"]验证,确认列表中没有宿主机配置:
cd /mnt/c/Users/ice && mise configDocker 集成
Docker 采用 Docker Desktop 的 WSL2 后端,WSL2 Debian 中不再单独安装。默认启用闲时自动休眠 (Resource Saver),宿主与子系统共享同一镜像库。
安装 Docker Desktop 并将数据目录指向 D 盘:
New-Item -ItemType Directory -Force -Path 'D:\WSL\DockerDesktop' | Out-Null $installer = Join-Path $env:TEMP 'DockerDesktopInstaller.exe' $url = 'https://desktop.docker.com/win/main/amd64/Docker%20Desktop%20Installer.exe' Invoke-WebRequest $url -OutFile $installer Start-Process $installer -Wait -ArgumentList @( 'install', '--user', '--backend=wsl-2', '--no-windows-containers', '--wsl-default-data-root=D:\WSL\DockerDesktop' )在 Docker Desktop 设置 Resources → WSL Integration 中勾选 Debian。集成后 Docker CLI 会自动挂载到 Linux,在 Debian 内把用户加入
docker组即可:sudo groupadd -f docker sudo usermod -aG docker "$USER"新开终端,验证 Docker CLI 挂载路径、服务端连接,并运行测试容器:
readlink -f "$(command -v docker)" # /mnt/wsl/docker-desktop/cli-tools/usr/bin/docker docker version # Server 一栏显示 Docker Desktop docker run --rm hello-world跨环境调度器
Windows 端的
linux-task.ps1负责派发任务,Debian 端的~/.local/bin/linux-task负责执行。脚本提供五种模式:
模式 运行位置 功能说明 build~/Build/<proj>-<hash>增量同步源码至 ext4 副本,执行依赖安装、编译与测试 direct/mnt/<disk>/...轻量只读检查(如单文件 lint、格式校验) sync~/Build/<proj>-<hash>仅增量同步源码,不执行构建命令 pathN/A输出当前项目在 Linux 中对应的副本路径 clean~/Build/<proj>-<hash>删除该项目的 Linux 副本及依赖缓存 Linux 执行端
保存为
~/.local/bin/linux-task并赋予可执行权限:mkdir -p ~/.local/bin ~/Build cat > ~/.local/bin/linux-task <<'EOF' #!/usr/bin/env bash set -Eeuo pipefail usage() { cat <<'USAGE' Usage: linux-task direct <source-dir> <command> linux-task build <source-dir> <command> linux-task sync <source-dir> linux-task path <source-dir> linux-task clean <source-dir> USAGE } mode="${1:-}" src="${2:-}" cmd="${3:-}" case "$mode" in direct|build|sync|path|clean) ;; *) usage exit 2 ;; esac if [[ -z "$src" ]]; then echo "Missing source directory." >&2 exit 2 fi if [[ ! -d "$src" ]]; then echo "Source directory does not exist: $src" >&2 exit 2 fi src="$(realpath "$src")" project_name="$(basename "$src")" project_hash="$(printf '%s' "$src" | sha256sum | cut -c1-12)" dst="$HOME/Build/${project_name}-${project_hash}" mise="$HOME/.local/bin/mise" if [[ ! -x "$mise" ]]; then echo "mise not found: $mise" >&2 exit 1 fi sync_project() { mkdir -p "$dst" echo "==> Sync" echo "Source: $src" echo "Build : $dst" rsync -rlt \ --delete \ --delete-delay \ --exclude='.git/' \ --exclude='node_modules/' \ --exclude='target/' \ --exclude='.next/' \ --exclude='.turbo/' \ --exclude='.cache/' \ --exclude='.venv/' \ --exclude='venv/' \ --exclude='__pycache__/' \ --exclude='.pytest_cache/' \ --exclude='.mypy_cache/' \ --exclude='.ruff_cache/' \ --exclude='.wsl-runner-meta' \ "$src/" "$dst/" rm -rf -- "$dst/.git" printf 'source=%s\nbuild=%s\n' \ "$src" "$dst" > "$dst/.wsl-runner-meta" } case "$mode" in path) printf '%s\n' "$dst" ;; sync) sync_project ;; clean) case "$dst" in "$HOME"/Build/*) echo "==> Removing $dst" rm -rf -- "$dst" ;; *) echo "Refusing unsafe path: $dst" >&2 exit 1 ;; esac ;; direct) if [[ -z "$cmd" ]]; then echo "direct mode requires a command." >&2 exit 2 fi echo "==> Direct Linux task" echo "Working directory: $src" echo "Command: $cmd" cd "$src" exec "$mise" exec -- bash -c "$cmd" ;; build) if [[ -z "$cmd" ]]; then echo "build mode requires a command." >&2 exit 2 fi sync_project echo "==> Linux build task" echo "Working directory: $dst" echo "Command: $cmd" cd "$dst" exec "$mise" exec -- bash -c "$cmd" ;; esac EOF chmod 755 ~/.local/bin/linux-task bash -n ~/.local/bin/linux-taskWindows 调度端
使用 UTF-8 编码,保存为
D:\WSL\Scripts\linux-task.ps1,注意替换用户名和发行版:#Requires -Version 7.3 [CmdletBinding()] param( [Parameter(Mandatory = $true)] [ValidateSet("direct", "build", "sync", "path", "clean")] [string]$Mode, [string]$Project = (Get-Location).Path, [string]$Command ) $ErrorActionPreference = "Stop" # Keep embedded quotes intact even if the caller prefers Legacy passing. $PSNativeCommandArgumentPassing = "Standard" if (-not (Test-Path -LiteralPath $Project -PathType Container)) { throw "Project directory does not exist: $Project" } $Project = (Resolve-Path -LiteralPath $Project).Path if (($Mode -eq "direct" -or $Mode -eq "build") -and [string]::IsNullOrWhiteSpace($Command)) { throw "$Mode mode requires -Command" } # WSL enters the Windows directory itself and the Linux script resolves # ".", so the project path is never read back through the console. # --exec passes the arguments as-is, without another shell expansion. $WslArgs = @( "-d", "Debian", "-u", "dejavu", "--cd", $Project, "--exec", "/home/dejavu/.local/bin/linux-task", $Mode, "." ) if (-not [string]::IsNullOrWhiteSpace($Command)) { $WslArgs += $Command } # Linux writes UTF-8. Decode it as UTF-8 so captured output such as # \`-Mode path\` keeps non-ASCII paths, then restore the caller's setting. $PreviousEncoding = [Console]::OutputEncoding [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) try { & wsl.exe @WslArgs $ExitCode = $LASTEXITCODE } finally { [Console]::OutputEncoding = $PreviousEncoding } exit $ExitCode参数说明:
--exec:避免传入命令被外层 shell 二次转义展开;--cd:由 WSL 内部定位目录,绕开中文或特殊路径的编码问题;$PSNativeCommandArgumentPassing = "Standard":PowerShell 7.3+ 的原生传参机制,保证嵌套引号原样传入 Linux;- 临时将控制台编码切为 UTF-8,防止 Linux 传回的中文日志乱码。
调度器验证
$ErrorActionPreference = 'Stop' $runner = (Get-Command linux-task.ps1).Source $root = Join-Path ([IO.Path]::GetTempPath()) ('Runner 验收-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) New-Item -ItemType Directory -Path $root | Out-Null Set-Content -LiteralPath (Join-Path $root 'hello.txt') -Value 'hello from Windows' Set-Content -LiteralPath (Join-Path $root '.git') -Value 'gitdir: not-a-real-repo' New-Item -ItemType Directory -Path (Join-Path $root 'node_modules') | Out-Null Set-Content -LiteralPath (Join-Path $root 'node_modules\win-only') -Value 'skip me' try { # build:在 ~/Build 下执行,看不到 .git 和 Windows 的 node_modules & $runner -Mode build -Project $root -Command '[[ "$PWD" == "$HOME/Build/"* ]] && test ! -e .git && test ! -e node_modules/win-only && grep -q "hello from Windows" hello.txt && echo built > only-in-linux.txt' if ($LASTEXITCODE -ne 0) { throw 'build 检查失败' } if (Test-Path -LiteralPath (Join-Path $root 'only-in-linux.txt')) { throw 'Linux 的输出写回了 Windows' } # direct:在 /mnt 下执行;变量和引号原样交给 Bash & $runner -Mode direct -Project $root -Command 'x=1; set -- "a b"; [[ "$PWD" == /mnt/* && -f .git && $x == 1 && $# == 1 ]]' if ($LASTEXITCODE -ne 0) { throw 'direct 检查失败' } # path:中文路径能正确读回 $mirror = & $runner -Mode path -Project $root if ($mirror -notlike "*/$(Split-Path $root -Leaf)-*") { throw "镜像路径读回异常:$mirror" } # 退出码传回 Windows & $runner -Mode build -Project $root -Command 'exit 37' if ($LASTEXITCODE -ne 37) { throw '退出码没有传回 Windows' } Write-Host 'PASS' } finally { & $runner -Mode clean -Project $root | Out-Null Remove-Item -LiteralPath $root -Recurse -Force }配置 AI Agent 约束
本地运行的 Codex 或 Claude Code 位于 Windows 宿主机。为了防止 Agent 误在 Windows 执行 Linux 编译命令,或进入 WSL 内部修改临时构建副本,需要在配置文件中明确约束。
Git Bash 防止路径转换
Claude Code 在 Windows 下默认通过 Git Bash 执行命令。Git Bash 会把
/开头的参数改写为 Windows 盘符路径,导致传给 Linux 的参数被损坏。调用脚本时需要声明MSYS_NO_PATHCONV=1,例如:MSYS_NO_PATHCONV=1 pwsh.exe -NoProfile \ -File 'D:\WSL\Scripts\linux-task.ps1' \ -Mode build -Project 'D:\Forgejo\App' -Command 'pnpm test'全局约束规则
规则按作用域分两层,以 Codex 和 Claude Code 为例:
- 机器全局
- Codex:在
~/.codex/config.toml顶层写developer_instructions;~/.codex/AGENTS.md规范测试与汇报流程(存在非空AGENTS.override.md时优先加载)。- Claude Code:
~/.claude/CLAUDE.md做同样的分工约束,并声明 Git Bash 防路径转换。
- Codex:在
- 项目本地
- 仓库根目录
AGENTS.md:记录项目独有的依赖命令与测试流水线。Claude Code 找不到CLAUDE.md时会自动递归读取;- 已有
CLAUDE.md时用@AGENTS.md引入。
- 已有
- 仓库根目录
参考下面两份英文规则模板,注意替换用户名、发行版和实际路径。
developer_instructions
This machine uses Windows as the canonical development host and Debian WSL2 only as a Linux build/test runtime. Hard environment rules: - The canonical source tree and .git directory live on Windows. - Never treat /home/dejavu/Build as canonical source. - Never edit source in a WSL build mirror and expect those edits to be preserved. - Never perform Git-mutating operations from WSL. Git add, commit, checkout, switch, merge, rebase, reset, pull, and push must run with Windows Git against the canonical Windows repository. - Linux compilation, testing, package execution, Playwright, and Docker workloads should use the configured WSL runner: linux-task.ps1 -Mode build -Project <WindowsProjectPath> -Command '<LinuxCommand>' - Use direct mode only for lightweight Linux checks that do not create large dependency trees or build outputs. - Docker is provided by Docker Desktop through WSL integration. Never install or start a separate Docker Engine, docker.io, docker-ce, dockerd, or containerd daemon inside Debian. - Docker commands in Debian are expected to run as the dejavu user without sudo. - Windows and Linux toolchains must remain isolated. Do not invoke Windows Node, Python, Git, Rust, Go, or mise from WSL, and do not use Linux binaries as the Windows host toolchain.AGENTS.md
由于帖子长度限制,此处开始被截断
请查看原文:https://blog.dejavu.moe/posts/windows-wsl2-ai-dev-environment/#agentsmd
- 收藏支持 1反对打赏




@Showfom 论坛帖子似乎有长度限制?我好想编辑两次后面都被截断了


@芒果 #2 帖子内容长度有限制的

@Showfom #3 怪不得,我还以为出 bug 了





