Engineering · ScreenCam for Windows

把一个 macOS 录屏编辑器搬到 Windows:ScreenCam Windows 版的架构

WinUI 3 做外壳,WebView2 里跑 React 编辑器,C++ 原生桥负责采集、合成和编码。下面讲这几层怎么分工,以及几次走不通之后改了什么。

2026-10-22main @ 7954c0d719 commits · 2026-05-28 起
  1. 目标:行为一致,不逐行翻译
  2. 全景:四层、三种语言
  3. .cam 包:跨平台的契约
  4. 录制管线
  5. 编辑器:WebView2 + React
  6. 唯一的 reducer 在 C#
  7. 预览像素:一个盖在网页上的原生窗口
  8. 导出:和预览同一张渲染图
  9. 去掉 FFmpeg
  10. 截图
  11. 验收:证据而不是感觉
  12. 回头看

01目标:行为一致,不逐行翻译

ScreenCam 先有 macOS 版:录屏时把屏幕画面、光标轨迹、按键、摄像头和两路音频分开存,进编辑器后再自动缩放跟随光标、加背景和边框、做镜头运动,最后导出成片。Windows 版从 2026 年 5 月 28 日的一份技术设计开始,到今天 main 上 719 个提交。

设计文档第一句定了方向:不按 SwiftUI/AppKit 代码逐行翻译。要一致的是产品行为、工程边界、工作区格式、镜头规划和导出结果;平台层用 Windows 自己的能力重写。这条原则后面每个决定都会回到:macOS 是行为的参照(仓库的 AGENTS.md 写作 “upstream behavior is the source of truth”),Windows 不照搬实现。

148k
行 C#(src/)
30k
行 C++ 原生桥
58k
行 TS/TSX 编辑器
80k
行 C# 测试 + 123 个 Playwright 用例文件

02全景:四层、三种语言

最初的设计是“WinUI 3 + XAML 写全部界面”。实际落地后,编辑器界面换成了 WebView2 里的 React,其余照原计划:

ScreenCam.App.WinUI.exe(MSIX 包内,一个进程) Web 编辑器 · WebView2 React 19 · Zustand 5 · Radix · Tailwind · Vite 单文件打包 时间线 / 检查器 / 播放条 / 草稿状态 / 11 个 control domain EditorHostAdapter postMessage {version:1, type, requestId, payload} WinUI 3 宿主 · C# MainWindow:WebView2 托管、消息分发、预览调度(60 Hz) 录制工具栏 · 区域选择 HUD(Win32 分层窗口)· EditorDialogWindow 全局热键 · Store 购买 · 崩溃转储 · Named Pipe 控制服务 App / Core · C# EditorDocument · EditorCommand Reducer · TimelineReconciler CameraEvaluator(弹簧镜头) Capture / Audio / Input / Export RecordingController · QpcTimebase WASAPI · Raw Input + 低级钩子 导出规划 · 音频混音 · GIF P/Invoke · C ABI · 只传句柄和描述符 ScreenCam.NativeBridge.dll · C++20 sc_wgc_* 采集 · sc_render_frame D3D11 合成 · sc_mf_* 编解码 sc_camera_dshow_* · sc_preview_* 子窗口交换链 screencam.exe(CLI) recording start/stop/… → Named Pipe,每行一个 JSON workspace inspect/apply/ export… 在进程内直接跑 Recording.cam/ manifest.json recording/media/*.mp4|m4a recording/events/*.json edit/*.json macOS 与 Windows 共用的工作区格式
图 1 运行时分层。虚线框是 WinUI 进程。WebView2 只通过带版本号的消息和宿主交互;C# 到 C++ 走 C ABI,过边界的是句柄、时间戳和不可变的描述结构,不传整帧像素数组。

语言边界按“谁需要贴近硬件”来划:C# 负责 UI、编排、JSON、撤销重做和授权;C++ 负责 WGC、D3D11、Media Foundation 和高频时间戳。设计文档特意没选 C++/CLI 做核心边界,用的是 C ABI 加 P/Invoke。这样原生 DLL 可以单独版本化:各项原生能力探测时都会先调 sc_bridge_get_contract_version,和 C# 侧写死的期望版本(目前是 69)比对,不一致就把对应能力标成不可用,而不是靠文件名或时间戳去猜。

03.cam 包:跨平台的契约

两个平台之间真正共享的是 .cam 这个目录格式,而不是代码。编辑器、CLI、agent 工作流都围着它转,Windows 版从第一天就用 macOS 的布局作为准绳:

Recording.cam/
  manifest.json            # format / schemaVersion / 各文件相对路径
  project.json
  recording/
    manifest.json          # 采集证据:displayID、captureRect、nominalFrameRate…
    events/  cursor.json  keyboard.json
    media/   display-0.mp4  camera-0.mp4  system-0.m4a  microphone-0.m4a
    diagnostics/           # Windows 独有:capture-target、encoder、audio、input…
  edit/
    timeline.json  camera.json  frame.json  cursor.json
    motion.json    audio.json   preview-audio.json

几条兼容规则决定了 Windows 能往里加什么:

用了 3D 镜头(spatialCamera)的工程会把 schemaVersion 和 minimumReaderSchemaVersion 升到 3,其余保持 2,老版本读到新特性时会明确拒绝,而不是读错。

04录制管线

录制是整个项目风险最高的部分,所以排在编辑器前面做。研究文档里定的验收线不是“能录”,而是 CPU、GPU、内存、队列深度、音频漂移、丢帧都有界。管线的形状:

QpcTimebase 所有流都用 QueryPerformanceCounter 打时间戳,录制开始时建立同一个零点 WGC 显示器 / 窗口 item FramePool ×3 缓冲 BGRA8 / HDR 时 FP16 GPU 裁剪 / 缩放 HDR→SDR 色调映射 MF SinkWriter 硬件 H.264 · ≤8 帧在途 WASAPI loopback + 麦克风 20 ms 轮询 落盘到临时 spool 停止时对齐 缺口补静音 · 重叠丢弃 MF AAC writer 48 kHz · 每路一个文件 摄像头 只开用户选中的 id WinRT FrameReader 或 DirectShow 图 MF camera writer ≤1080p30 H.264 输入 Raw Input + LL 钩子 两路事件合并 光标位置 50 ms 采样 事件 JSON cursor / keyboard 停止 → RecordingPackageBuilder → 原子写入 Recording.cam → 生成全 I 帧的拖动代理(scrub proxy)放到 LocalAppData 缓存
图 2 录制管线。四路流互不等待,各自写自己的文件;它们唯一的共同点是同一个 QPC 时间基准,同步在停止和编辑阶段完成。

视频:纹理一直待在 GPU 上

采集用 Windows.Graphics.Capture,帧池用 CreateFreeThreaded 建,不依赖 UI 线程的 DispatcherQueue。帧池 3 个缓冲:一个留给读取端,两个空给 DWM。区域录制没有单独的 API:采整块显示器,再用 CopySubresourceRegion 在 GPU 上裁,需要缩放时走着色器。从采集到编码器,像素不回 CPU。

帧节奏:FrameArrived 只拨一个事件,读取端带 100 ms 截止时间等它。总是拿最新的一帧,没到时间的旧帧直接关掉,相邻输出帧至少间隔 3/4 个帧周期。时间戳用的是 DWM 合成时刻(SystemRelativeTime),不是轮询拿到帧的时刻。背压的硬上限在编码器:Media Foundation 在途样本默认最多 8 个,再多就有界地等 2 秒。

HDR 显示器按 FP16 scRGB 采集,但录制输出始终是 SDR 8-bit:按系统的 SDR 白点(至少 80 nit)归一,再统一压高光。H.264 用 High profile、无 B 帧,GOP 取 min(fps, 30),码率按“像素 × 帧率 × 0.06–0.08 bpp”估算,1080p60 大约 8.7 Mbps。

有一个实现细节能看出 Windows 媒体栈的真实情况:编码完成后还要原地修补 MP4 里的颜色标签(VUI / avcC / colr),因为 NVIDIA 的 MFT 会在 VUI 里写 0。修完才能保证各种播放器按 BT.709 解释颜色。

音频、输入、摄像头:各自独立的旁路文件

系统声音用 WASAPI loopback,麦克风用 WASAPI 共享模式,每 20 ms 轮询一次,每个包带上 GetBuffer 给的 QPC 时间,先写进磁盘上的临时 spool。停止时 AudioSidecarTimeline 把时间戳换算成采样位置:超过 10 ms 的缺口补静音,重叠部分丢掉。系统没声音时 loopback 不出包、首包晚到、时钟漂移,这三种情况都靠这一步解决。设备中途拔掉,录制继续,对应时段是静音。

键鼠事件用 Raw Input(RIDEV_INPUTSINK,独立消息线程),同时开 WH_KEYBOARD_LL / WH_MOUSE_LL 低级钩子兜底,两路合并。光标位置不靠钩子,按 50 ms 采样 GetCursorPos / GetCursorInfo,连光标形状一起记。

摄像头有一条写进仓库规则的隐私约束:只按用户明确选中的设备 id 打开,不存在“默认摄像头”或“第一个枚举到的”兜底。设备不在就不录摄像头并报警告,绝不换一个开。普通设备走 WinRT MediaFrameReader;OBS 虚拟摄像头这类只注册了 DirectShow 的设备,走原生 DirectShow 图(Source → Sample Grabber → Null Renderer)。枚举设备时只读元数据,不激活滤镜:以前用 FFmpeg -list_devices 枚举会激活第三方驱动,有的驱动会在录制开始前就崩掉。

录制界面不进画面

录制工具栏、区域选择的遮罩和手柄、摄像头气泡都设置了 SetWindowDisplayAffinity(WDA_EXCLUDEFROMCAPTURE),录出来的视频里没有它们。区域选择 HUD 没用 XAML,是一组 Win32 分层窗口(遮罩、输入层、4 条边框、4 条内描边、8 个手柄),用 UpdateLayeredWindow 画,用完缓存起来复用。命中测试和区域状态机放在 C# 的 RecordExportSelectionHudViewModel 里,录屏和截图的选区共用这一份;网页侧只渲染快照、转发输入,不允许再写一套 JS 几何状态机。

05编辑器:WebView2 + React

这是整个项目最大的一次转向。6 月 19 日的录制/导出 MVP 里,编辑器是纯 WinUI 写的;6 月 23 日的 RFC 决定把编辑器界面整体搬进 WebView2。理由写得很直接:复杂的编辑器布局、时间线交互、预览叠层和检查器控件,用一大棵 XAML 控件树写太慢;WinUI 应该只管窗口、文件、WebView2 托管、原生求值和导出、系统集成。旧的 XAML 编辑器代码在 10 月 17 日删干净(-2,825 行)。

ScreenCam Windows 编辑器界面:顶栏、右侧摄像头检查器、底部多轨时间线(录制、摄像头、标注、系统音频、麦克风、缩放)
真实应用里这块由原生 D3D11 子窗口出像素
(浏览器 mock 模式下是空的)
图 3 编辑器在浏览器 mock 模式下的样子(?scenario=camera-media&locale=zh-Hans)。顶栏、检查器、时间线全是 React;时间线上的录制、摄像头、标注、两路音频和两段缩放(一段 2.2×,一段 3D 50 mm f/2.8)都来自 mock 数据。预览区为什么是空的,见第 7 节。

RFC 给编辑器定了六层,每层只认识下一层的接口:

层负责
Editor UIReact 组件、CSS、直接操作、快捷键
Editor RuntimeZustand store(controls / exporting / playback / selection / ui / workspace 六个 slice)、拖拽控制器、草稿与提交
Host AdapterEditorHostAdapter:唯一一个碰 window.chrome.webview 的地方
Mock Host1,492 行的 editorMockBridge.ts,伪造宿主,支持十几种场景
WebView2 Host注入的 payload、真实消息桥、虚拟主机媒体 URL
Native / Core.cam 读写、命令、求值、渲染、导出

mock 层是一等公民。RFC 原话:一个功能如果只能在 WinUI 宿主里手动点出来、没法在浏览器 mock 里跑,就不算“编辑器就绪”。这给了项目两条独立的迭代回路:界面和交互在普通浏览器里用 npm run dev 和 Playwright 迭代,几秒一轮;需要真实媒体和原生能力时,再进 WinUI。123 个 Playwright 用例文件大多跑在 mock 宿主上。

宿主侧的几个工程细节:

06唯一的 reducer 在 C#

把界面搬进网页,最大的风险是网页慢慢长出第二套业务逻辑。RFC 的规定是:任何改文档的操作,要么本身是 EditorCommandEnvelope,要么是一个临时 action、在 ScreenCam.App 里立刻翻译成命令,“临时 action 不能变成第二个 reducer”。

落到代码上,权威的 EditorCommandReducer.Apply(1,900 行,纯函数,对不可变记录做 with 拷贝)只在 C# 里。网页里有草稿,但草稿不会落盘。一次拖动滑杆的完整往返:

Web 运行时(React) WinUI 宿主 / App 原生预览(D3D11) 拖动中:applyDraft,界面先动 evaluateFrame {frameSettingsDraft…} 在一次性文档副本上套用草稿 (preview reducer,不进撤销栈) sc_render_frame → Present 松手:commit,记下 requestId=42 editorAction settings.frame.update #42 ActionFactory → EditorCommand Reducer.Apply(线程池,单写锁) 快照入撤销栈 · 250 ms 防抖落盘 workspacePatched rev=n+1 requestId=42 rev 正好是 n+1:打补丁,确认 #42 跳号:请求整份快照 (revision-gap) 用已提交的文档重画
图 4 草稿与提交。拖动过程中,网页把草稿随 evaluateFrame 发给宿主,宿主套在一次性副本上渲染预览,文档本身不变;松手才发 editorAction。宿主回的补丁带连续修订号,网页发现跳号就整份重拉。

撤销/重做存的是整个 EditorDocument 快照,带同一个 undoGroupId 的编辑合成一步。时间线校正(TimelineReconciler)也在 C#:录制片段最短 500 ms,缩放片段最短 1 s,键盘片段最短 120 ms。镜头运动是弹簧模型,默认质量 2.6、刚度 160、阻尼 42,参数和 macOS 保持一致,镜头、命令、时间线求值都有共享的 JSON fixture 做回归。

唯一的例外是 mock 宿主:它为了脱离 C# 跑起来,自己在 TS 里实现了一遍变更。所以 mock 只用来验证界面和交互,文档语义的测试都在 C# 侧。

07预览像素:一个盖在网页上的原生窗口

编辑器搬进 WebView2 之后,预览一开始也在网页里:DOM 的 <video> 加 CSS 叠层。问题很快暴露:预览和导出各算各的,CSS 的圆角、阴影、裁剪、镜头运动和 D3D 导出总差那么一点。中间有过一个过渡方案,原生渲染一帧,转成 PNG 给网页 <img> 显示。每帧要经过 GPU 回读、PNG 编码、写文件、网页解码,只能当诊断工具用。

最后定下的不变式是:

同一条求值代码路径 + 同一个原生合成器 → 预览把纹理 Present 出来,导出把纹理送进编码器。

预览成了一个原生子窗口:C++ 侧建一个 WS_CHILD 窗口,挂一条 FLIP_DISCARD、双缓冲的 DXGI 交换链,放在编辑器 HWND 里、WebView2 之上(HWND_TOP)。网页把预览区的矩形通过 nativePreviewBounds 报给宿主,宿主移动子窗口跟上。所以图 3 里浏览器 mock 的预览区是空的:真实应用里那块由原生窗口出像素。

编辑器 HWND(WinUI Window) WebView2:整个编辑器界面 预览子窗口 · 交换链 拖拽卡片 · 分层弹窗 导出 / 裁剪对话框 “空域”问题(airspace) 子 HWND 永远盖在 WebView 内容上面, 网页里的菜单、弹层画不到它上面。 对应的处理 · 弹层和预览重叠时,按引用计数隐藏子窗口, 网页用最后一帧的静态图顶替 · 导出、裁剪等对话框改成独立的顶层窗口 (EditorDialogWindow,owner = 编辑器) · 拖拽时的卡片用分层弹窗画在预览之上 · 子窗口默认 WS_EX_TRANSPARENT, 只在标注和手柄处按点接收输入, 指针事件转回网页,复用同一个拖拽控制器
图 5 编辑器窗口的叠层。原生子窗口换来零拷贝、和导出同源的预览像素,代价是网页内容永远画不到它上面。后面几条处理都是在解决这一个问题。

这个“空域”问题牵出了一串设计。对话框原来是 WinUI 的 ContentDialog,打开时要先把预览窗口藏起来,太高的对话框还会被裁切;现在每个对话框都是 owner 指向编辑器的独立顶层窗口,打开时禁用编辑器,任何关闭路径(按钮、Esc、Alt+F4、显示失败)都要重新启用并激活编辑器。新窗口都经过 WindowRevealGate:先用 DWM cloak 藏着,等内容加载完、尺寸稳定一帧、合成器提交确认、DwmFlush 之后才显示,避免首帧空白闪一下(验收要求首个可见帧和稳定帧的像素差 ≤1%)。

8 月 21 日试过另一条路:用 XAML 的 SwapChainPanel 代替子 HWND,这样 XAML 和网页内容可以盖住预览。实验记录很诚实:方向有用,但“显示节奏不可观测、指针到 Present 的延迟其实没量到、真实混合 DPI 拖动在手头硬件上跑不了、108 个模态叠层组合没有一个完全达标”,结论是 NO-GO,子 HWND 继续是默认方案,并列出了下次再试之前必须先满足的六个条件。

渲染图与调度

预览按 60 Hz 调度,挂在 CompositionTarget.Rendering 上,按 QPC 截止时间走,错过的拍子直接跳过、不补发;同时只跑一个 tick,新的意图覆盖旧的待处理意图。解码用 Media Foundation Source Reader,开 DXVA 硬件解码,直接出 NV12 纹理。合成在 sc_render_frame 里一次完成,层序和 macOS 导出端对齐:

1 背景(壁纸 / 渐变 / 网格噪声)、阴影、内边距 2 合成蒙版(macOS 同款超椭圆圆角) 3 录制画面 × 镜头变换(缩放 / 平移 / 3D) 4 镜头运动模糊(220 ms 快门) 5 边框 / 设备外壳 6 光标(或 3D 镜头里的光标材质)、点击效果 7 效果层:马赛克、聚焦、文字、图片标注 8 摄像头画中画 9 按键浮层 → Present 或送编码器
图 6 一帧的合成顺序。边框在镜头变换之后,所以会跟着画面一起缩放;摄像头画中画在效果层之上,按键浮层最后画。macOS 预览用的是单独的图层画摄像头,那里正是 macOS 预览和导出最容易不一致的地方,Windows 把画中画也放进了同一张渲染图。

缓存有三档预算:合成和效果纹理 256 MiB,C# 渲染图侧的随机访问 BGRA 缓存 64 MiB,倒放用的预取另有 288 MiB 的 NV12 平面缓存(1080p 下约 70 帧,60 fps 时略多于 1 秒)。倒放预取的异步工作线程默认是关的,因为实测更慢,只能用环境变量打开。代码里留着这种“做了但量出来不划算就关掉”的痕迹,比假装它有用诚实。

预览声音反而是网页做的:三个 <audio> 元素(系统声、麦克风、背景音乐)跟播放头同步,漂移超过 140 ms 就重新 seek。点击音效由宿主播放。

08导出:和预览同一张渲染图

导出和预览调用的是同一个 NativeCompositionRenderGraph.Create,一个把纹理 Present,一个把纹理交给编码器,有一道 NativePreviewExportParityGate 锁住这个约定。导出流程分 7 个阶段,以 IAsyncEnumerable 报进度:media → capability → plan → render → encode → audio → finalize。取消走 CancellationToken,没走完的路径都在 finally 里 Abort;临时文件有日志记录,崩溃后下次启动会清理。

EncoderPlanner 自动 = HEVC 候选 1:HEVC 硬件 MFT 先跑 3 帧探测编码(有缓存) 候选 2:H.264 硬件 MFT 记录前一个失败的原因 失败 SinkWriter 单遍 视频 + 混好的 AAC faststart moov 移到文件头 MP4 要求硬件编码(RequireHardware),都起不来就报 encoderUnavailable;GIF 允许软件回退。
图 7 导出编码器选择。只有后面还有回退候选时才做探测编码,避免为唯一的选项白白多编 3 帧。

参数上:VBR,GOP = 2 × fps,不用 B 帧,码率按“宽 × 高 × 帧率 × 每像素比特”算,H.264 乘 1.6、HEVC 乘 1.0。音频把系统声、麦克风、背景音乐、点击音混成一条 48 kHz 立体声 AAC(192 kbps),和视频在同一遍 SinkWriter 里写完;变速片段用 WSOLA 做时间伸缩,不变调。

导出可以转到后台:对话框把任务交给一个原生的悬浮条,任务渲染的是导出那一刻的 EditorDocument 不可变快照,所以导出期间继续编辑不会影响正在出的片子。

09去掉 FFmpeg

回头看,FFmpeg 的进出是这个项目最能说明问题的一条线。最早的 PR #1 用 FFmpeg gdigrab 抓桌面、libx264 软编码,产品流程能跑通,但违反了录制管线的每一条硬规则,只被当作参考。之后 WGC + Media Foundation 成了录制主路径,但 FFmpeg 仍然在包里干杂活:摄像头回退、ffprobe 读媒体信息、导出时的封装。

5/286/19 8/119/1110/1610/22 技术设计 录制/导出 MVP WGC + MF WebView2 编辑器 6/23,+40k 行 锁定原生合成 PNG → 子窗口交换链 SwapChainPanel NO-GO 上架准备 Pro 购买 · 包体瘦身 FFmpeg → MF 单遍导出 · 停止打包 今天
图 8 五个月的几个转折点。提交量:6 月 188、8 月 139、9 月 52、10 月 338。

9 月瘦身时量过一次:FFmpeg 和 ffprobe 在压缩后的 MSIX 里占约 28 MB,整个 MSIX 54 MB。更大的问题不是体积,是隐藏的依赖:开发机的 PATH 上恰好有 FFmpeg 时,测试全绿也可能掩盖一个没声明的外部可执行文件依赖。10 月 16 日一天之内,导出改成 Media Foundation 单遍写音视频,摄像头不再依赖 FFmpeg,媒体信息改由 Media Foundation 读取,最后停止打包 FFmpeg,并加了一道守卫:scripts/assert-no-bundled-media-tools.ps1 在源码和最终包上都要通过,打包脚本自动跑。FFmpeg 现在只是开发和验收脚本从 PATH 里找的对照工具,规则里写明“验收必须在没有开发工具的 PATH 上,用实际出货的包跑”。

10截图

截图复用了大部分基础设施,但在启动延迟上下了功夫。流程是:从缓存里借一个已经初始化好的 WebView 选区窗口,同时冻结屏幕(WGC 抓一帧),选区页 selector.html 加载和冻结并行进行,等冻结的图片解码并绘制完才显示选区。验收把“请求到选区出现”和“确认到操作卡片出现”分开计时,规则里说得明白:包更小不算端到端延迟变短的证据。

两处具体的取舍:

11验收:证据而不是感觉

读这个仓库最有收获的一份文件是 AGENTS.md。它不是风格指南,是一条条“曾经被骗过”的验收规则。从名字就看得出,它是写给编码代理的完成标准,人也一样适用。几条有代表性的:

工具链跟着这些规则长出来:CursorRasterGate 对点击效果做像素比对,EditorVisualGate 是一个 11,000 行的 DOM/几何闸门,NativeCompositionCache.Gate 拿 macOS 导出的超椭圆圆角 PNG 和原生蒙版的路径比对,UiResponsivenessBench 量 UI 线程上最长的单个工作项。测试没有用 xUnit,18 个测试项目跑在自己写的一个小 runner 上,由 scripts/test.ps1 统一驱动。


12回头看

把这五个月压成几条可以带走的经验:

  1. 共享格式,不共享代码。Swift 核心没法高质量地嵌进 Windows 应用,所以 C# 重写了一遍模型、reducer 和镜头规划,再用 macOS 导出的黄金数据锁住行为。跨平台靠的是 .cam 和测试数据,不是一个公共库。
  2. 界面用 Web,像素和真相留在原生。WebView2 让编辑器界面的迭代速度接近前端项目,但 reducer 只在 C#,预览像素只来自 D3D11。网页只有草稿,没有决定权。
  3. 预览和导出必须是同一段代码。macOS 自己就因为预览用 CALayer、导出用 CIImage 而在画中画上有偏差,Windows 没有重复这个分叉。代价是子 HWND 的空域问题,以及围绕它的一整套对话框、弹层和指针路由设计。
  4. 系统媒体栈够用,但要验证。Media Foundation 替掉了 FFmpeg,包更小,依赖更清楚;但也要自己修 NVIDIA 写错的颜色标签、自己做硬件编码探测和回退。
  5. 验收标准写成文件。一个快速迭代、大量借助代理写代码的项目,最容易出的问题是“测试过了,但用起来不对”。把每次被骗的经过写进 AGENTS.md,再长成闸门工具,比任何一次性的代码评审都更持久。