在 Windows 上搭 AI Agent 流水线(二):黑窗、编码、行尾——机制在偷偷改你的东西

3 阅读11分钟

先说这一层的共同点

上一篇(环境层)的坑,是 Git Bash 的路径世界和 Windows 原生程序对不上。

这一篇讲系统层——不用 Git Bash 也会遇到的那部分。它们的共同点更隐蔽:

Windows 有一堆机制,在"你看不见的层"上生效——进程的控制台归属、文件的编码解释、字节的换行表示。

它们都不在你写的代码里,但都能改掉你代码的行为。五个坑,从最显眼的一个开始。

坑 1:后台跑个命令,为什么总弹黑窗

现象。 一个后台任务跑起来,反复弹出黑窗口,标题长这样:

C:\Users\<user>\...\python.exe
C:\Windows\SYSTEM32\cmd.exe

闪一下、消失,过几秒又闪一下。任务本身是正常工作的,但屏幕上不停弹,人没法干别的。

我试过的(全都没用):把 python 换成绝对路径、调整 PATH 顺序、禁用系统执行别名、加各种"隐藏窗口"参数。

唯一的变化是黑窗标题跟着变了。 这让我误以为方向对了——实际上标题变了只是因为被拉起的那个进程换了人,弹窗的原因一点没动

为什么。 真因跟配置毫无关系,是 Windows 的一个基础机制:

一个自身没有控制台的进程,去创建需要控制台的子进程时,Windows 必然给它新建一个控制台窗口。

拆开说:

  • Windows 程序分两种子系统:GUI 子系统(没有控制台)和 CONSOLE 子系统(有控制台)
  • python.exe 是 CONSOLE 子系统;pythonw.exe 是 GUI 子系统
  • 一个 GUI 子系统进程去启动 CONSOLE 子系统进程时,Windows 必须给后者找个控制台——找不到就新建一个
  • 新建出来的默认可见,标题就是被拉起程序的完整路径

这解释了两个现象:

现象原因
黑窗标题写着 python.exe它只是被拉起的子进程,不一定是根因方
改了 PATH 后标题变了、但还弹换的只是"被拉起的那个",父进程仍然没有控制台

怎么改。 关键认识:要处理的不是"被拉起的那个窗口",而是"让父进程自己持有一个隐藏的控制台"。

子进程会继承父进程的控制台。所以只要父进程有一个"隐藏着的"控制台,所有子进程都不会再新建窗口。

做法结果
GUI 子系统父进程 + SW_HIDE❌ 只隐藏它自己那个窗口,子进程照样弹
CONSOLE 子系统父进程 + SW_HIDE✅ 父进程持有隐藏控制台,子进程全部继承,零弹窗

判断一个 exe 有没有控制台——读 PE 头:

import struct
with open(r"path\to\program.exe", "rb") as f:
    f.seek(0x3C)
    pe_offset = struct.unpack("<I", f.read(4))[0]
    f.seek(pe_offset + 0x5C)
    subsystem = struct.unpack("<H", f.read(2))[0]
print("GUI(无控制台)" if subsystem == 2 else "CONSOLE(有控制台)")

2 = GUI3 = CONSOLE要让它不弹窗,父进程必须是 3。

怎么验证——弹窗一闪而过,肉眼数不准。每 40ms 枚举一次可见顶层窗口,只统计期间新出现的:

import ctypes, time
from ctypes import wintypes
user32 = ctypes.windll.user32
seen = set()
def cb(hwnd, _):
    if user32.IsWindowVisible(hwnd):
        buf = ctypes.create_unicode_buffer(256)
        user32.GetWindowTextW(hwnd, buf, 256)
        seen.add((hwnd, buf.value))
    return True
CB = ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM)
base = set()
for _ in range(75):        # 约 3 秒
    user32.EnumWindows(CB(cb), 0); time.sleep(0.04)
print("新增可见窗口:", len(seen - base))

实测:GUI 子系统的父进程 → 抓到 3 个窗口;换成 CONSOLE 父进程 + SW_HIDE0 个,功能输出字节级一致,耗时还快了约 19%(弹窗本身有成本)。

坑 2:PowerShell 会误读你的脚本

现象。 写个 PowerShell 脚本装定时任务,里面带中文注释和提示语:

# 安装每日任务(每天 9 点执行)
Write-Host "正在安装任务..."

跑起来报错:

Unexpected token '}' in expression or statement.

错误指向的行,代码看起来完全正常。 我去数括号,一对一对得上。

为什么。 编码。

那个文件是 UTF-8 不带 BOM 保存的。而 Windows PowerShell 5.1 遇到没有 BOM 的文件时,会按"本地代码页"去解码——在中文系统上就是 GBK。

于是:中文字符按 GBK 解释出来的字节完全不对,有些字节组合恰好会解出一个引号或特殊符号,把后面的字符串和括号结构撕开了

所以报错会指向一个看起来没问题的位置——因为被破坏的不是那一行,是解码方式。

BOM 是干什么的

文件开头解释器怎么理解
有 BOM(EF BB BF"这是 UTF-8",按 UTF-8 解码
没有 BOM"不知道,按本地代码页猜"

没有它,编码就是一个猜的游戏——而猜的规则是操作系统的区域设置,不是文件本身。

(PowerShell 7 默认按 UTF-8 解码,所以这个问题在新版里不明显。但系统自带的 Windows PowerShell 5.1 还是老行为——而计划任务、自动化脚本经常跑在它上面。)

怎么改。 两条路:

① 保持纯 ASCII(最稳)——注释、提示语、字符串全用英文。看起来土,但不受任何编码设置影响

② 存成带 BOM 的 UTF-8

$content = Get-Content -Raw "script.ps1"
[System.IO.File]::WriteAllText("script.ps1", $content, [System.Text.UTF8Encoding]::new($true))

检测 BOM

head -c 3 script.ps1 | od -An -tx1
# ef bb bf → 有 BOM

改完必须过一次语法校验(关键,能在跑之前拦住):

$errors = $null
[System.Management.Automation.Language.Parser]::ParseFile("C:\path\to\script.ps1", [ref]$null, [ref]$errors)
if ($errors) { $errors | ForEach-Object { Write-Host $_.Message } }

这一步不是可选的——它用的是真正的解析器,能发现"解码坏掉导致的语法错误",而这正是肉眼看不出来的那类。

坑 3:改脚本别用文本编辑器

现象。 我改一个脚本文件里的一行代码。改完看了一眼,内容确实变了,其他部分一模一样

但跑起来行为不对。git diff 一看:整个文件都变了。

为什么。 因为我用了一个**"聪明"的文本编辑工具**。它做了几件我没让它做的事:

它做了什么后果
检测到"这看起来像 CRLF"把整个文件的行尾统一成 CRLF
检测到"这看起来像 UTF-8"按它的判断重新编码
觉得"加个 BOM 更规范"在文件头加了 3 个字节

单独看每件事都"合理",合起来就是:整个文件被重写了。

原理。 一个 .ps1.vbs 文件,在磁盘上就是一串字节。它"看起来是什么内容",取决于读它的人用什么编码去解释。而改一个字节,就可能改变了整个文件的解释方式

所以"只改了一个词"会导致整个文件 diff——不是内容变了,是编码或行尾变了。

怎么改。字节级替换,直接操作 bytes,不经过任何"文本层"处理:

def safe_replace(path, old, new):
    with open(path, "rb") as f:        # "rb" —— 跳过文本模式,不碰编码
        raw = f.read()
    old_b = old.encode("utf-8") if isinstance(old, str) else old
    new_b = new.encode("utf-8") if isinstance(new, str) else new
    # 断言:命中次数必须符合预期,否则不写盘
    assert raw.count(old_b) == 1, f"预期命中 1 处,实际 {raw.count(old_b)} 处"
    with open(path, "wb") as f:
        f.write(raw.replace(old_b, new_b))

四个要点

  1. "rb" / "wb" —— 跳过文本模式
  2. 不碰行尾 —— 因为你根本没把它当"行"看
  3. 改前先探测 —— 记下编码和行尾,改完对照
  4. 断言在写盘之前 —— 命中次数不对就不写,避免"改了一半"

改完三查

git diff --stat    # 行数增减要和预期改动量同量级
wc -l <file>       # 不能塌陷
python -c "raw=open(r'file','rb').read(); print('CRLF',raw.count(b'\r\n'),'LF',raw.count(b'\n'))"

最有效的是那条断言——它在写盘之前就拦住了"命中次数不对"。我遇到过两次断言失败,两次都拦在写盘前,文件得以保全

坑 4:我的脚本把文件改成了 0 字节

现象。 批量改一批文件,写了按行处理的脚本:

lines = content.split("\r\n")      # ← 问题在这一行
lines = [fix(l) for l in lines]
write("\r\n".join(lines))

跑完了,没报错。然后 git diff --stat

2 files changed, 206 deletions(-)
2 files changed, 0 insertions(+), 0 deletions(-)   ← 这两个变成 0 字节

四个文件里,两个被写成 0 字节,两个塌成了一行。

为什么。 行尾不统一,而且同一目录里是混杂的——那批文件里有 370 个 LF、154 个 CRLF。

问题就在 split("\r\n")

文件类型split("\r\n") 的结果
CRLF 文件正常切成 N 行
LF 文件整个文件是"一行"(里面一个 \r\n 都没有)

对一个 LF 文件,这次切分等于什么都没切。 "一行"被当作整篇内容处理,替换完再拼回去——如果替换规则正好吃掉了这一行,结果就是 0 字节

原理。 这不是"哪种行尾更好"的问题。反过来也一样:如果对 CRLF 文件按 \n 切分,每行末尾会多留一个 \r,替换时容易匹配不上,或者写出一堆多余字符。

唯一安全的做法是:先探测,再决定。

def safe_batch_edit(path, transform):
    with open(path, "rb") as f:
        raw = f.read()
    crlf, lf = raw.count(b"\r\n"), raw.count(b"\n")
    is_crlf = crlf > 0 and crlf == lf        # 所有换行都是 CRLF
    text = raw.decode("utf-8").replace("\r\n", "\n")   # 统一成 LF 处理
    out = "\n".join(transform(l) for l in text.split("\n"))
    if is_crlf:
        out = out.replace("\n", "\r\n")      # 原样还原
    with open(path, "wb") as f:
        f.write(out.encode("utf-8"))

关键就是 is_crlf 那个判断——绝不对 LF 文件按 \r\n 切分。

怎么预防(比事后补救便宜得多):改之前先做一次"零改动"试跑。

safe_batch_edit(path, lambda line: line)   # 什么都不改

如果这时 git diff 不为空——说明你的"切分 + 拼回"逻辑本身就会改变文件。 那无论后面改什么,文件都会被动。

我那次要是先跑一遍空改动,四个文件在被写坏之前就会暴露

坑 5:三个副本 md5 不同,内容却完全相同

现象。 三个地方各存一份同样的配置,跑校验和比对:

a1b2c3d4...  .a/config.json
d4e5f6a7...  .b/config.json
d4e5f6a7...  .c/config.json

第一份和另外两份不一样。 我以为它没同步上,又跑了一遍同步脚本——还是不一样。开始怀疑同步脚本有 bug。

为什么。 三份内容完全一样,只是行尾不同——第一份是 LF,另外两份是 CRLF。

md5 是对字节算的。\n\r\n 是不同的字节——所以哪怕"内容一样",md5 也必然不同。

原理:哈希比的是字节,不是"内容"。

层次"一样"的含义
文本层你看到的字符序列一样
字节层磁盘上的字节序列一样

md5 / sha 算的是字节层。 而行尾、BOM、编码,都是字节层的东西——它们在文本层"看不见",但会影响哈希。

所以:"md5 不同"只能推出"字节不同"。要判断"内容不同",还得先排除掉这些"表示层"的差异。

这个坑最坑的地方:它假装成"内容不一致"。我当时的排查方向是"同步脚本是不是有 bug""是不是有个文件被改过""是不是权限问题"——全是错误方向

怎么改。

# ① 比对前先统一行尾(只在管道里做,不动原文件)
for f in .a/config.json .b/config.json .c/config.json; do
  tr -d '\r' < "$f" | md5sum
done

# ② 或者用"忽略行尾"的比对
diff --strip-trailing-cr .a/config.json .b/config.json && echo "内容一致(只差行尾)"

# ③ 判断文件是什么行尾
file config.json

# ④ 把差异定位到具体字节(别一上来就猜"内容不同")
cmp -l .a/config.json .b/config.json | head -5

还有哪些"表示层差异"会骗你

差异表现
行尾(LF / CRLF)md5 不同,内容相同
BOM文件头多 3 个字节,某些解析器直接拒收
编码(UTF-8 / GBK)字节不同,显示也可能不同
末尾换行有的工具补一个 \n,有的不补

共同点你在编辑器里"看到"的是一样的,但字节不一样。

这五个坑的共同点

机制在哪一层生效
1 弹黑窗进程的控制台归属
2 PowerShell 误读文件的编码解释
3 改文件导致整体 diff编码 + 行尾(改的时候被重写)
4 文件变 0 字节字节的换行表示
5 md5 不同字节层 vs 文本层

它们都不在"你写的代码"这一层。 但这五件事——控制台归属、编码、行尾、字节表示——每一个都能改掉你代码的行为

所以这一层的判据是:

当一个文件或进程"看起来对"、但"行为不对"时,往下看一层——看它的字节、它的编码、和它挂在哪。

拿走就能用:系统层检查清单

## Windows 系统层自检

- [ ] 后台任务的**父进程有没有控制台**?(PE 头 Subsystem = 3)
- [ ] `.ps1`**纯 ASCII**,还是带 **BOM**- [ ] 改完 `.ps1` 过了 ParseFile 语法校验吗?
- [ ] 脚本改动走的是**字节级替换**吗?
- [ ] 改前**探测行尾**了吗?改后三查(diff --stat / wc -l / 行尾统计)?
- [ ] 批量改文件前跑过**"零改动"试跑**吗?
- [ ] 比对副本时**去掉行尾**了吗?
- [ ] 弹窗类问题用**枚举计数**验证过吗(不靠肉眼)?

三个通用动作

动作挡什么
改文件走字节级,别用文本编辑器坑 3、4
盯住"编码 / BOM / 行尾"三件套坑 2、4、5
现象一闪而过时,用计数而不是肉眼坑 1

最后

这一层的教训:

文件的内容和文件的编码是两件事。前者你看得见,后者决定它能不能被正确读懂。

下一篇讲流水线层——那些和操作系统无关、但同样不报错的坑。八个,从"工具装好了,模型却不知道它存在"说起。