
如果你的工作里既有 Unity 项目又用得上 AI Agent那么“让 Agent 自己调 Unity 编辑器编译、跑测试”这个需求早晚会找上门。前段时间我在自己的工具链项目里把这个闭环彻底打通了过程说不上轻松Unity 的命令行模式向来不是给人准备的但修完之后AI Agent 已经能稳定驱动编辑器完成编译检测、EditMode 测试甚至能根据失败信息改代码再拉一轮编译。这篇文章把整个方案、踩过的坑、代码实现都记录下来给有同样需求的同学一个参考。1. 为什么折腾AI Agent 驱动 Unity 编译测试的出发点1.1 一个典型场景让 Agent 自己修代码先说动机。我手里有一个长期维护的 Unity 项目最近在做一个实验性功能让 AI Agent 直接接管代码修改工作。Agent 改完 C# 脚本之后最自然的验证方式是什么当然是立刻编译、立刻跑测试。但问题在于AI Agent 是一个纯命令行工具它没法打开 Unity 编辑器手动点一下 Play也没法用眼睛看 Console 窗口。它唯一能触达 Unity 的通道就是 shell调用 Unity 可执行文件、传参数、读输出。这个诉求听起来简单实际跑起来完全是另一回事。我第一版实现非常天真直接用 Python 的subprocess.run调 Unity 的命令行模式然后指望退出码告诉我对错。结果第一次调用就让 Agent 卡了几分钟进程直接挂住Agent 等不到结果就报错退出。后来我逐步排查才发现Unity 批处理模式的问题远比我想象的多退出码不可靠、日志格式混乱、异步 API 配合-quit会提前退出、编译失败时入口方法根本不会被调用……每一个坑单拎出来都能让人折腾半天。1.2 修复的核心目标从“能跑”到“可靠可解析”既然要做就不能停留在“能跑通”的程度。我在动手之前定了几条硬性目标可重复Agent 不管调用几次只要代码没变结果必须一致。可解析不能让 Agent 去读一坨人类友好的日志它需要结构化的结果最好是一段 JSON 或者一个明确的 XML。可超时进程必须能稳定退出不能因为某个资源导入让 Agent 空等。可分类编译失败、测试失败、环境异常这三类情况要能区分开Agent 才能据此决定下一步动作。后面所有的修复工作本质都是围绕这四点展开的。2. 整体方案从 Unity 命令行到 Agent 可解析的输出2.1 Unity 批处理模式基础Unity 从很早的版本就提供了批处理模式batchmode用来在命令行环境下执行构建、跑测试、导资源等操作。最基础的一条命令长这样Unity.exe -batchmode -nographics \ -projectPath D:/Work/MyProject \ -logFile D:/Work/MyProject/Temp/agent_run.log \ -quit几个关键参数的作用分别是-batchmode让 Unity 以非交互模式启动不打开任何窗口。-nographics不初始化图形设备适合在服务器或者 CI 环境下跑既能省资源也能避免很多图形依赖报错。-projectPath指定要操作的项目路径。-logFile指定日志输出文件。如果传-Unity 会把日志写到 stdout但我后面会讲到在 Windows 下直接写 stdout 有个大坑所以建议始终指向一个文件。-quit执行完命令后自动退出。如果还要执行自定义逻辑就得配合-executeMethodUnity.exe -batchmode -nographics \ -projectPath D:/Work/MyProject \ -executeMethod AiBuildRunner.RunTests \ -logFile D:/Work/MyProject/Temp/agent_test.log-executeMethod会调用指定静态类里的静态方法这是 Unity 给外部工具预留的扩展入口。只要把方法写在 Editor 程序集里就能在批处理模式下执行各种编辑器操作。跑测试的话Unity Test Framework 还提供了更直接的参数Unity.exe -batchmode -nographics \ -projectPath D:/Work/MyProject \ -runTests -testPlatform EditMode \ -testResults D:/Work/MyProject/Temp/test_results.xml \ -testFilter MyNamespace \ -logFile D:/Work/MyProject/Temp/agent_test.log-runTests会自动执行 EditMode 或 PlayMode 测试并把结果写到-testResults指定的 XML 文件里。2.2 三层架构Agent、封装脚本、Editor 扩展直接让 Agent 去拼 Unity 命令肯定不现实我最终搭了一套三层结构第一层是 Agent 自身它只跟一个封装脚本打交道。Agent 会调用类似python unity_agent_runner.py --task test --project D:/Work/MyProject这样的命令然后读脚本返回的 JSON 字符串。Agent 不需要知道 Unity 装在哪、要不要加-batchmode、日志文件放哪这些细节全部被封装脚本吞掉了。第二层是一个 Python 写的封装脚本。它的职责很纯粹拼接 Unity 命令行、处理超时、清理残留锁文件、解析编译日志、解析测试 XML最后把结果整理成 JSON 输出。为什么用 Python因为 Agent 生态里 Python 脚本的执行成本最低而且subprocess、xml.etree.ElementTree、re这些标准库足够应付全部需求不需要额外依赖。第三层是一个 Unity Editor 扩展脚本。它的作用是提供-executeMethod的入口把测试结果以结构化格式写到指定文件同时设置进程退出码。封装脚本负责“调配”Editor 扩展负责“执行”两边各司其职。2.3 为什么不用现成的 CI 插件有人会问Jenkins、GitLab CI 甚至一些 Unity CI 服务早就解决了自动化编译测试的问题为什么还要自己造轮子核心原因是定位不同。CI 系统解决的是“定时或提交后自动跑任务”它产出的是一份人类看的报告状态存在于网页端。但 AI Agent 需要的是“同步的、实时的、可编程的结果”它要在拿到结果后立刻决定下一步动作比如修改某个测试失败的函数然后重新跑。如果走 CIAgent 得频繁轮询 API、等任务排队迭代周期被拉得很长。直接本地调 Unity 命令行一次完整的编译加测试大概几分钟这个粒度对 Agent 来说刚好。3. 修复实录五个踩过的坑3.1 坑一进程挂死Unity 不退出这是最开始遇到、也是最闹心的一个问题。Agent 调用 Unity 批处理模式后进程一直不结束无论等多久都卡在那里最后只能手动 kill。排查下来发现有两种典型情况。第一种是-executeMethod的方法内部抛了异常而开发者没有在方法里显式调用EditorApplication.Exit。Unity 在批处理模式下如果没有明确退出指令异常后可能会一直挂在那里虽然日志里已经打印了堆栈但进程不会自己结束。第二种情况是我在测试阶段碰到的在-executeMethod里调用TestRunnerApi.Execute执行异步测试同时命令行里又加了-quit。-quit会等当前批处理流程结束就退出但异步测试根本没跑完结果就是两种状态互相拉扯测试没结束不能退但-quit已经让 Unity 开始准备退出了整个进程陷入一个奇怪的挂起状态。解决办法说起来很简单做起来要点自觉-executeMethod里所有代码路径最终都必须走到EditorApplication.Exit(code)外层再多套一层try/catchcatch 到任何异常都立刻记录日志并退出。异步场景下不要依赖-quit而是完全自己控制退出时机。public static void RunAndExit() { int code 0; try { // 业务逻辑 } catch (Exception e) { Debug.LogError([AiBuildRunner] 执行失败: e); code 1; } EditorApplication.Exit(code); }3.2 坑二编译失败却被当成成功这个坑非常隐蔽我一开始完全没料到。现象是项目里某个脚本故意写了一个语法错误然后我用-executeMethod AiBuildRunner.RunTests去跑结果进程正常退出退出码是 0日志里也没有明显的错误提示。但实际上RunTests这个方法压根没有被调用原因其实不难理解-executeMethod指定的方法必须存在于编译后的 Editor 程序集里。如果项目里有代码编译失败整个程序集都生成不了Unity 在批处理模式下找不到这个方法于是只往日志里写了一条类似Method AiBuildRunner.RunTests not found的记录然后正常退出退出码依然是 0。在部分 Unity 版本里这条错误信息甚至藏在日志中间不仔细看根本发现不了。这个问题的本质是用-executeMethod做入口时前置条件是项目必须能编译通过但如果你就是为了检查编译是否通过这就成了死循环。我的修复方案是把流程拆成两个阶段。第一阶段先不带-executeMethod只跑-batchmode -quit让 Unity 完整走一遍资源导入和脚本编译。第二阶段扫描日志用正则把编译错误行提取出来。如果错误列表为空才进入第二阶段用-executeMethod或者-runTests去执行真正的测试任务。这样编译失败和测试失败就能被明确区分开。第一阶段的命令长这样Unity.exe -batchmode -nographics \ -projectPath D:/Work/MyProject \ -logFile D:/Work/MyProject/Temp/compile.log \ -quitPython 侧的正则提取编译错误行import re compile_error_pattern re.compile( r^(?Pfile.?)\((?Pline\d),(?Pcol\d)\):\s*error\s(?PcodeCS\d):\s*(?Pmessage.)$, re.MULTILINE, ) errors [] for line in log_text.splitlines(): m compile_error_pattern.match(line.strip()) if m: errors.append(m.groupdict())3.3 坑三测试结果文件静默丢失用-runTests -testResults跑完测试后我一度发现 XML 结果文件有时候生成了有时候没生成。最诡异的是明明命令执行成功日志里也显示测试跑完了但结果文件就是不在。后来排查发现两个细节。第一是路径问题我当时图省事传了一个相对路径Temp/test_results.xml但批处理模式的当前工作目录并不一定是项目根目录所以 Unity 把结果写到了别的地方。第二是父目录问题如果指定的目录不存在Unity 不会自动创建而是选择什么都不写也不报错非常坑人。修复方案很粗暴但有效所有传给 Unity 的路径都用绝对路径并且在调用之前用 Python 的Path.mkdir(parentsTrue, exist_okTrue)把父目录建好。别嫌这一步啰嗦这种“文件没生成但进程照样返回 0”的静默失败对 Agent 来说是致命的它会把“测试未执行”误判成“测试全部通过”。3.4 坑四日志编码与输出干扰Windows 环境下Unity 批处理模式的输出编码是一笔糊涂账。如果直接让 Unity 把日志写到 stdout再用 Python 的subprocess.run去捕获经常会拿到一堆乱码或者编码异常。这主要是因为 Unity 在 Windows 下写控制台用的编码和控制台代码页、Python 默认的 UTF-8 解码方式经常对不上。我后来彻底放弃了走管道读 stdout 的方案统一指定-logFile指向一个固定文件然后用 Python 直接读文件。这样编码完全由 Unity 控制Agent 侧用 UTF-8 读取即可。日志文件的好处还有一个stdout 里会混入各种资源导入进度条输出但日志文件里最主要的还是 Unity 自己的 Log 信息清洗负担小很多。需要注意的是Unity 的日志文件偶尔会同时包含正常信息、警告和错误而且顺序并不严格。所以解析的时候不能只找“有没有 error 关键字”而是要结合具体的 format 匹配。比如编译错误行是文件路径(行,列): error CSxxxx: 描述测试失败信息则要看 XML 文件。3.5 坑五上次崩溃留下的锁文件阻塞启动Unity 打开项目时会生成一个锁文件Temp/UnityLockfile用来防止多个 Unity 实例同时操作同一个项目。但如果上一次批处理进程被kill -9或者电脑断电锁文件会残留下来。下次再启动同一个项目时Unity 会认为有另一个实例正在运行于是要么一直等待要么直接报错退出。这个问题在 Agent 场景下特别容易触发因为 Agent 的超时逻辑会强制 kill 卡住的进程而 kill 掉的进程通常来不及清理锁文件。我最初的解决办法是在调用前无脑删锁文件但后来发现有风险如果真的有另一个合法进程在跑同一个项目删掉锁文件会导致两个实例同时写入项目轻则资源损坏重则工程崩溃。更稳的做法是脚本先检查锁文件是否存在。如果存在再检查系统里有没有同名项目的 Unity 进程。比如在 Windows 下用tasklist排查或者通过进程启动参数匹配projectPath。只有当确认没有存活进程时才删锁文件。如果你的 Agent 保证不会真的并发调用同一个项目那这个检查可以简化但我还是建议至少留一个--force参数来控制删除行为不要默认无脑删。4. 落地实现Editor 扩展 Agent 封装脚本4.1 Unity Editor 侧批处理入口与测试执行我写的 Editor 扩展相对简洁核心就两个文件。先看入口using System; using UnityEditor; using UnityEngine; public static class AiBuildRunner { public static void RunTests() { int code 0; try { string resultFile Environment.GetEnvironmentVariable(UNITY_TEST_RESULT_FILE); if (string.IsNullOrEmpty(resultFile)) { resultFile Temp/agent_test_result.xml; } resultFile System.IO.Path.GetFullPath(resultFile); System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(resultFile)); var listener new TestListener(resultFile); var api ScriptableObject.CreateInstanceTestRunnerApi(); api.RegisterCallbacks(listener); var filter new Filter { testMode TestMode.EditMode }; api.Execute(new ExecutionSettings(filter)); // 注意不要在调用后立刻 EditorApplication.Exit等回调结束再退出 } catch (Exception e) { Debug.LogError([AiBuildRunner] 测试执行异常: e); EditorApplication.Exit(1); } } }注意几个细节。方法名和类名要保持单一职责RunTests只负责编排。测试结果文件路径通过环境变量传入这样封装脚本可以动态控制输出位置。在api.Execute之后不能立刻退出因为TestRunnerApi.Execute是异步的只有等RunFinished回调触发时才是安全退出时机。监听器负责把结果写到文件并退出using System.IO; using UnityEditor.TestTools.TestRunner.Api; using UnityEngine; public class TestListener : ICallbacks { private readonly string _resultFile; public TestListener(string resultFile) { _resultFile resultFile; } public void RunStarted(ITestAdaptor testsToRun) { } public void TestStarted(ITestAdaptor test) { } public void TestFinished(ITestResult result) { } public void RunFinished(TestResult result) { File.WriteAllText(_resultFile, result.ToXml().OuterXml); int code (result.FailCount result.ErrorCount 0) ? 2 : 0; Debug.Log($[AiBuildRunner] 测试完成共 {result.TotalCount} 个用例失败 {result.FailCount} 个错误 {result.ErrorCount} 个); EditorApplication.Exit(code); } }result.ToXml()返回的是System.Xml.XmlNodeOuterXml能拿到完整 XML这个格式和 NUnit3 的测试结果格式兼容Python 侧用标准库解析即可。这里还有一个隐藏问题万一测试执行过程中 Unity 真的挂住了RunFinished永远不会触发进程也不会退出。因为我已经在封装脚本里做了硬超时控制所以编辑器侧可以不实现看门狗但如果你打算在纯编辑器环境下使用建议加一个EditorApplication.update里的计时器超时就强制EditorApplication.Exit(3)。4.2 Agent 封装脚本调用、超时与结果解析封装脚本是整个链路的枢纽。它的主要流程是解析参数、检查锁文件、跑编译阶段、提取编译错误、如果没有错误则跑测试、解析测试 XML、输出 JSON。import argparse import json import os import re import subprocess import time import xml.etree.ElementTree as ET from pathlib import Path class UnityAgentRunner: def __init__(self, unity_exe, project_path): self.unity_exe unity_exe self.project_path Path(project_path).resolve() self.temp_dir self.project_path / Temp self.temp_dir.mkdir(parentsTrue, exist_okTrue) def _remove_stale_lock(self, forceFalse): lock self.temp_dir / UnityLockfile if not lock.exists(): return if force or not self._is_unity_process_alive(): lock.unlink() print(f[runner] 已清理残留锁文件: {lock}) else: raise RuntimeError(检测到同名 Unity 进程正在运行拒绝清理锁文件) def _is_unity_process_alive(self): # 这里可以通过 tasklist / ps 检查是否有 Unity.exe 进程 # 简单起见跨平台可省略但 Windows 下建议用 tasklist return False def _run_unity(self, extra_args, log_name, timeout): log_file self.temp_dir / log_name cmd [ self.unity_exe, -batchmode, -nographics, -projectPath, str(self.project_path), -logFile, str(log_file), ] extra_args print(f[runner] 执行命令: { .join(cmd)}) start time.time() proc subprocess.run(cmd, capture_outputTrue, textTrue, timeouttimeout) elapsed time.time() - start log_text log_file.read_text(encodingutf-8, errorsreplace) return proc, log_file, log_text, elapsed def run_compile_check(self, timeout600): proc, log_file, log_text, elapsed self._run_unity([-quit], compile.log, timeout) errors self._parse_compile_errors(log_text) ok len(errors) 0 result { stage: compile, ok: ok, exit_code: proc.returncode, errors: errors, elapsed_seconds: round(elapsed, 1), } return result def _parse_compile_errors(self, log_text): pattern re.compile( r^(?Pfile.?)\((?Pline\d),(?Pcol\d)\):\s*error\s(?PcodeCS\d):\s*(?Pmessage.)$, re.MULTILINE, ) errors [] for m in pattern.finditer(log_text): errors.append(m.groupdict()) # 去重同一行报多个错误时只保留第一个常见于编辑器但重复项会影响 Agent 判断 seen set() unique [] for e in errors: key (e[file], e[line], e[code]) if key not in seen: seen.add(key) unique.append(e) return unique def run_tests(self, timeout900): result_file self.temp_dir / test_results.xml env os.environ.copy() env[UNITY_TEST_RESULT_FILE] str(result_file) cmd [ -executeMethod, AiBuildRunner.RunTests, -testPlatform, EditMode, ] # 这里不传 -quit退出时机由 Editor 的 RunFinished 回调控制 log_file self.temp_dir / test.log unity_args [ -batchmode, -nographics, -projectPath, str(self.project_path), -logFile, str(log_file), -executeMethod, AiBuildRunner.RunTests, ] proc subprocess.run( [self.unity_exe] unity_args, capture_outputTrue, textTrue, timeouttimeout, envenv, ) # 测试结果不一定生成需要判断 summary { stage: test, exit_code: proc.returncode, } if result_file.exists(): summary.update(self._parse_test_xml(result_file)) else: summary[ok] False summary[error] test result file not found return summary def _parse_test_xml(self, result_file): tree ET.parse(result_file) root tree.getroot() total int(root.attrib.get(total, 0)) passed int(root.attrib.get(passed, 0)) failed int(root.attrib.get(failed, 0)) skipped int(root.attrib.get(skipped, 0)) failures [] for tc in root.iter(test-case): failure tc.find(failure) if failure is not None: failures.append({ name: tc.attrib.get(fullname, ), message: failure.findtext(message, ).strip(), }) return { ok: failed 0, total: total, passed: passed, failed: failed, skipped: skipped, failures: failures, }输出 JSON 的格式大致如下Agent 拿到之后可以非常直接地判断{ stage: test, ok: false, exit_code: 2, total: 85, passed: 83, failed: 2, skipped: 0, failures: [ { name: MyNamespace.PlayerPrefsTests.SaveFloat_Should_Persist, message: Expected: 3.5 But was: 2.5 } ] }我建议在封装脚本里增加--compile-only、--test-only、--full三种模式方便 Agent 在排查问题时只跑必要的阶段。编译检查通常几十秒全量测试可能要几分钟Agent 可以根据上下文决定调用哪个粒度。4.3 退出码与错误分类约定一个容易被忽略但很重要的点Agent 判断任务是否成功不能只依赖进程退出码。我见过很多 Unity 版本在编译失败或测试失败时都返回 0 的情况所以约定得明确退出码含义Agent 决策建议0编译通过 / 测试全部通过进入下一轮开发1编译阶段发生异常或 Editor 入口执行异常检查项目环境、脚本是否完整2测试执行完成但有用例失败读取 failures 列表定位失败用例3超时被杀或结果文件缺失用更大超时重跑或者检查日志实际使用中我会把退出码、JSON 结果、日志文件路径三者一起返回给 Agent。退出码给 Agent 一个快速判断JSON 给 Agent 具体的失败信息日志文件路径帮助 Agent 在必要时做详细排查。5. 常见问题排查速查表与经验心得5.1 常见问题速查表把前面踩过的坑整理成一张表遇到问题可以直接对照现象常见原因处理办法进程挂死不退出executeMethod 内异常未捕获 / 异步测试与 -quit 冲突所有路径显式调用 EditorApplication.Exit外层套 try/catch脚本加超时退出码恒为 0部分 Unity 版本对异常、编译失败不更新退出码不依赖退出码以日志和结果文件为准-executeMethod 方法不执行项目编译失败程序集未生成先跑编译检查阶段确认无 error CS 再执行测试testResults 文件没生成使用了相对路径 / 父目录不存在绝对路径 预创建目录日志中文乱码Windows 控制台编码与 Python 解码不一致统一用 -logFile 写文件不读 stdout第二个实例启动异常Temp/UnityLockfile 残留确认无进程后清理锁文件首次调用特别慢资源导入和着色器编译超时设长或提前热机导入一遍测试结果文件存在但为空测试阶段被截断 / API 未返回结果检查 test.log 尾部内容5.2 几条独家心得第一结果文件永远比 stdout 可靠。Unity 的 stdout 在不同系统、不同版本下格式差异太大而且会混入大量进度信息。相比之下日志文件和测试 XML 是相对稳定的产物解析起来更省心。我现在所有关键结果都走文件Agent 也只读文件stdout 只作为辅助参考。第二错误信息一定要完整喂给 Agent不要只给摘要。刚开始我为了省 token只把失败用例的名字传给 Agent让它猜问题。后来发现 Agent 根本无从下手因为测试失败信息里往往包含具体的期望值和实际值这是定位问题的关键。现在我的规则是编译错误最多给前 50 条完整信息测试失败最多给前 20 条完整 message再配合日志文件路径让 Agent 按需深挖。第三超时参数要区分首次运行和增量运行。Unity 项目第一次用批处理模式启动时要经历完整的资源导入可能要几分钟甚至更久如果超时设成 60 秒百分之百会被误杀。我现在的做法是脚本启动时先检查项目的Library目录里有没有生成好的缓存元数据如果不存在就把默认超时调整为 900 秒后续增量运行再走正常的 300 秒。这个小细节让很多初学者少走弯路。第四关于锁文件别懒也别莽。无脑删锁文件在本地开发环境可能没问题但放到 Agent 自动调度场景就会埋雷。我最稳妥的做法是封装脚本接收一个--lock-mode参数默认值是auto只在没有存活进程时清理手动排查时可以用--lock-mode force跳过检查。顺手给封装脚本加上--check子命令专门用来检查项目和锁文件状态排查问题的时候非常管用。第五尽量让 Unity Editor 侧的逻辑保持简单。TestRunnerApi虽然灵活但毕竟涉及异步回调一旦出问题不好调试。能用-runTests命令行参数解决的场景就用它只有当你需要对测试结果做二次加工比如额外采集覆盖率、自定义报告格式时才值得引入-executeMethod加ICallbacks的组合。工具链这种东西越简单越不容易在深夜翻车。这次把工具链修完最大的感受是Unity 的批处理模式并不是为普通开发者准备的友好接口但只要你掌握了退出时机、日志路径、编译前置检查这几个关键点它完全可以变成一个可靠的工具链底座。现在我的 Agent 已经能在几十秒内完成“改代码、编译、跑测试、反馈结果”的完整循环这个能力对日常开发效率的提升是非常直接的。如果你也在搞 Unity 自动化或者 Agent 接入希望这篇实录能帮你少踩几个坑。