AI Agent 驱动 Unity 编辑器:命令行批处理实现自动化编译与测试

AI Agent 驱动 Unity 编辑器:命令行批处理实现自动化编译与测试 如果你手里同时攥着一条 Unity CI 流水线和几个 7x24 小时待命的 AI Agent大概率迟早会冒出同一个念头能不能让 AI 直接替我点编辑器里的 “Play” 按钮跑完测试再把报错日志甩给我这个项目就是从这类痛点里长出来的目标是打通一条让 AI Agent 直接从命令行驱动 Unity 编辑器完成编译、跑测试、收集结果并回传的自动化链路。整个过程涉及 Unity 批处理模式、编辑器扩展脚本、进程管理与日志解析踩了不少坑也沉淀出一套可以复用的实践方案。下面按实录顺序拆开讲。1. 需求分析与整体思路为什么非要让 AI 驱动 Unity 编辑器1.1 核心需求拆解这个项目的起点很朴素团队里已经有用 AI Agent 做代码审查、生成测试用例的流程但每次改完代码Agent 只能干等人工去 Unity 编辑器里手动编译、手动跑测试再把结果粘贴给它。这等于让 AI 干瞪眼自动化断在最笨重的一环。于是我们把需求拆成三层:编译驱动层AI Agent 能触发 Unity 编辑器执行脚本编译拿到编译日志。测试驱动层AI Agent 能触发编辑器进入 Play Mode 跑 Edit Mode / Play Mode 测试并拿到测试报告。结果回传层AI Agent 能解析日志把通过的用例、失败的堆栈、异常信息结构化地拿回来供后续代码修复或报告生成使用。这三层缺一不可。只有编译驱动没有测试驱动AI 只能算“半个构建机器人”只有测试驱动没有结果回传AI 还是瞎子的耳朵。真正让 Agent “活”起来的是它能在编译报错后自动重试、在测试失败后精准定位到出错的测试方法形成闭环。1.2 为什么选择命令行批处理模式Unity 编辑器虽然是个 GUI 应用但它从很早开始就提供了一套批处理模式Batch Mode允许你在没有图形界面的环境里以-batchmode -nographics参数启动并执行指定的编辑器静态方法。这套机制本意是给 CI/CD 用的但它的价值远不止于 Jenkins 或 GitLab Runner——它天然适合作为 AI Agent 的“手和脚”。选择命令行批处理模式做主通道有三个关键理由无头环境可跑Agent 所在的机器大概率没有显示器或者跑在容器里。GUI 模式根本起不来批处理模式是唯一选择。进程边界清晰每次编译测试都启动一个独立进程跑完即退环境干净。不像长时间挂着的 Editor 进程内存越吃越多状态越积越乱。日志天然文本化Unity 会把所有输出写到LogFile里文本化的日志是 AI 最擅长解析的东西。你不需要 OCR不需要截屏识别直接丢给模型就行。1.3 需要避开的弯路在最开始设计时我差点走了两条弯路弯路一试图用 Unity Editor 的远程 API 做常驻服务。Unity 虽然有EditorConnection这类东西但它是为设备调试设计的不是为外部进程管理设计的。让一个 Editor 进程长期挂机等命令既不稳定又容易在场景加载、Domain Reload 时状态错乱。弯路二试图直接修改 Unity 生成的.csproj文件来绕过编辑器编译。这看起来更“轻量”但 Unity 的脚本编译有自己的程序集定义Assembly Definition体系直接调 MSBuild 或 Roslyn 会漏掉大量 Unity 特有的原生绑定和程序集引用最后得到的编译结果和编辑器里实际的编译结果不一致毫无意义。绕开这两条弯路之后思路就清晰了老老实实写编辑器扩展脚本用命令行参数把 Agent 的意图传进去在编辑器生命周期的早期执行指定逻辑退场前把结果写到约定好的文件里。2. 工具链架构设计Agent、命令行与编辑器脚本之间的协议约定2.1 总览三个角色怎么配合整个工具链由三个角色组成角色职责载体AI Agent生成命令、解析结果、决策下一步动作任意 Agent 框架AutoGPT、LangChain 等命令行驱动层拼接 Unity 路径与参数启动进程超时管理Python / Bash 脚本编辑器扩展层在 Unity 批处理模式里执行编译、测试、日志输出C# / Unity Editor 脚本三者之间通过两个东西来“通信”命令行参数和约定路径下的日志/结果文件。没有用网络端口没有用数据库维持了极简的依赖关系省掉了大量运维成本。2.2 命令行参数约定在编辑器扩展层我注册了一个静态方法AIEntrypoint.Run然后通过 Unity 的命令行解析库读取自定义参数。约定如下Unity -batchmode -nographics -projectPath /path/to/project \ -executeMethod AIBuildTools.AIEntrypoint.Run \ -aiCommand compile \ -aiOutput /tmp/unity_output.json \ -logFile /tmp/unity_compile.log这里有几个关键参数-executeMethodUnity 启动后会进入编辑器主循环之前执行的静态方法必须是无参静态方法。-aiCommand自定义参数告诉编辑器当前这次调用的意图。可选值有compile、editmode、playmode、all。-aiOutput自定义参数约定结果文件的输出路径。Unity 不会自动识别这个参数需要在代码里手动读取。-logFileUnity 原生参数把 Editor 日志写到指定文件方便后续按行解析。注意-executeMethod方法执行完后Unity 默认会进入正常编辑器生命周期。如果不显式调用EditorApplication.Exit进程不会自己退出会导致超时。这是个隐蔽的坑。2.3 编辑器扩展脚本核心类设计整个扩展层我分成了三个类避免一个文件里堆太多职责AIEntrypoint入口解析命令行参数分发到具体执行器。CompileRunner处理编译相关逻辑返回编译是否通过、错误列表。TestRunner处理测试相关逻辑运行 Edit Mode / Play Mode 测试生成结果。这样的结构让每个类都能单独测试也方便后续加新的指令类型。比如以后想支持-aiCommand assetBundle只需要新增一个BundleRunner就行。2.4 输出文件的 Schema 设计为了让 AI 解析结果时尽量少费 token输出文件我设计成紧凑的 JSON。字段少、层级浅、直接可用{ command: compile, success: false, duration_seconds: 23.5, summary: { errors_count: 2, warnings_count: 5 }, errors: [ { file: Assets/Scripts/PlayerController.cs, line: 47, message: CS0103: The name Rigidbody does not exist in the current context } ], warnings: [] }对于测试命令输出设计成用例粒度方便 Agent 精确跳转{ command: editmode, success: true, summary: { total: 12, passed: 11, failed: 1 }, failures: [ { test_name: PlayerControllerTest.Move_InputZero_VelocityZero, message: Expected: 0, Actual: 0.01, stack_trace: ... } ] }2.5 为什么不在 Agent 内部直接解析 Unity 原始日志第一版我确实试过让 Agent 直接读Editor.log原文。问题是日志量太炸了随便一次编译就是几百行大部分是加载资源的噪声真正的报错夹在中间。拿这种文本去喂给 AI一是 token 消耗大二是模型容易被无关信息干扰三是每次日志格式微调都可能让 Agent 突然失智。所以我在编辑器层做了预处理让 Unity 代码自己把LogType.Error和LogType.Exception过滤出来再附加对应的文件与行号。日志到结构化结果这一步必须在工具链内完成不能丢给 AI。AI 只做决策不做脏活这才是健康的协作方式。3. 实操让 AI Agent 驱动编译与测试的完整实现3.1 搭建编辑器扩展脚本先建一个编辑器脚本目录。我在项目里习惯把工具类放在Assets/Editor/BuildTools/下这样不会被打包进游戏本体。创建AIEntrypoint.csusing System.IO; using UnityEditor; using UnityEngine; namespace AIBuildTools { public static class AIEntrypoint { public static void Run() { var command GetArg(-aiCommand); var outputPath GetArg(-aiOutput); if (string.IsNullOrEmpty(command) || string.IsNullOrEmpty(outputPath)) { Debug.LogError([AIEntrypoint] Missing required args: -aiCommand, -aiOutput); EditorApplication.Exit(1); return; } Debug.Log($[AIEntrypoint] Start command: {command}); object result null; switch (command) { case compile: result CompileRunner.Run(); break; case editmode: result TestRunner.Run(TestRunner.EditMode); break; case playmode: result TestRunner.Run(TestRunner.PlayMode); break; case all: result TestRunner.Run(TestRunner.All); break; default: Debug.LogError($[AIEntrypoint] Unknown command: {command}); EditorApplication.Exit(2); return; } if (result ! null) { File.WriteAllText(outputPath, JsonUtility.ToJson(result, true)); Debug.Log($[AIEntrypoint] Result written to {outputPath}); } EditorApplication.Exit(result ! null (bool)result.GetType().GetProperty(success).GetValue(result) ? 0 : 1); } private static string GetArg(string name) { var args System.Environment.GetCommandLineArgs(); for (int i 0; i args.Length - 1; i) { if (args[i] name) return args[i 1]; } return null; } } }这段代码做了几件事读参数、分发指令、执行、写结果文件、退出进程。注意EditorApplication.Exit的退出码0 表示成功非 0 表示失败这个约定会被外层 Python 进程捕获。实际编译时发现JsonUtility.ToJson对Dictionary的支持不好但反序列化ListT没问题。为此我在数据结构里特意把错误列表设计成ListBuildError而不是Dictionarystring, string主要是为了避开这个坑。3.2 实现编译逻辑编译这一块需要调用 Unity 的 Build Pipeline 接口让它做一次完整的脚本编译检查。我用的是CompilationPipeline类using UnityEditor.Compilation; using System.Collections.Generic; using System.Linq; namespace AIBuildTools { public static class CompileRunner { public class CompileResult { public string command compile; public bool success; public float duration_seconds; public Summary summary new Summary(); public ListErrorInfo errors new ListErrorInfo(); public ListErrorInfo warnings new ListErrorInfo(); [System.Serializable] public class Summary { public int errors_count; public int warnings_count; } [System.Serializable] public class ErrorInfo { public string file; public int line; public string message; } } public static CompileResult Run() { var sw System.Diagnostics.Stopwatch.StartNew(); var result new CompileResult(); var assemblies CompilationPipeline.GetAssemblies(); foreach (var assembly in assemblies) { var diagnostics CompilationPipeline.GetDiagnostics(assembly); foreach (var diagnostic in diagnostics) { var info new CompileResult.ErrorInfo { file diagnostic.file, line diagnostic.line, message diagnostic.message }; if (diagnostic.type DiagnosticType.Error) { result.errors.Add(info); } else if (diagnostic.type DiagnosticType.Warning) { result.warnings.Add(info); } } } sw.Stop(); result.duration_seconds (float)sw.Elapsed.TotalSeconds; result.summary.errors_count result.errors.Count; result.summary.warnings_count result.warnings.Count; result.success result.errors.Count 0; Debug.Log($[CompileRunner] Done. Errors: {result.errors.Count}, Warnings: {result.warnings.Count}); return result; } } }这里有个容易忽略的点CompilationPipeline.GetAssemblies()拿到的是上一次编译之后的程序集状态。也就是说如果你改了某个脚本文件然后立刻调用这段代码它可能拿不到最新的编译结果。解决办法是在跑编译检查之前先触发一次AssetDatabase.Refresh()或者调用CompilationPipeline.RequestScriptCompilation()请求重新编译。我在实际项目中加了一步CompilationPipeline.RequestScriptCompilation(); AssetDatabase.Refresh(); EditorApplication.EnterPlaymode(); // 这里需要等待编译完成但这样又引出一个新问题RequestScriptCompilation是异步的你没法在同步代码里等它完成。真正轮询编译状态还需要更复杂的机制。为了让第一版先跑通我用了最土的办法在命令里加了一个-aiWaitCompile参数如果为 true就用EditorApplication.update事件去轮询编译状态完成后再执行后续逻辑。3.3 实现测试逻辑使用 TestRunnerApiUnity 的测试框架从 2019.2 开始提供了TestRunnerApi这就是我们的测试驱动核心。这个 API 能以编程方式触发测试运行并接收回调比模拟点击 Test Runner 窗口要可靠得多。using UnityEditor.TestTools.TestRunner.Api; using UnityEngine; namespace AIBuildTools { public static class TestRunner { public const string EditMode editmode; public const string PlayMode playmode; public const string All all; private static string _mode; private static TestRunnerApi _api; private static TestRunResult _runResult; public class TestRunResult { public string command; public bool success; public float duration_seconds; public Summary summary new Summary(); public ListFailureInfo failures new ListFailureInfo(); [System.Serializable] public class Summary { public int total; public int passed; public int failed; } [System.Serializable] public class FailureInfo { public string test_name; public string message; public string stack_trace; } } public static TestRunResult Run(string mode) { _mode mode; _runResult new TestRunResult { command mode }; _api new TestRunnerApi(); var filter new Filter { testMode mode PlayMode ? TestMode.PlayMode : TestMode.EditMode }; if (mode All) { filter.testMode TestMode.EditMode; // 这里简化为先跑 Edit ModePlay Mode 另开进程跑避免同进程切换测试模式带来的状态污染 } var progress new ExecutionProgress(); progress.Complete OnComplete; _api.Execute(new ExecutionSettings(filter) { runSynchronously false }); // 进入等待循环防止进程提前退出 while (_runResult.summary.total 0 !_runCompleted) { System.Threading.Thread.Sleep(100); } return _runResult; } } }这里面有个比较难缠的点Execute是异步的但批处理模式下如果你不阻塞主线程进程会直接走到EditorApplication.Exit测试还没跑完就没了。所以必须用一个自旋等待锁住主线程。但 Unity 编辑器脚本里不能随便Thread.Sleep因为它会冻结 Editor 的消息循环。真正稳妥的做法是像前面说的用EditorApplication.update事件驱动轮询配合一个“完成标志位”来退出进程。我最终在AIEntrypoint里改成了状态机模式启动测试 - 每帧检查TestRunnerApi回调是否触发 - 触发后写结果文件 - 退出。别嫌绕这是 Unity 批处理模式里做异步操作的标准解法绕不过去。3.4 外层进程驱动Python 封装编辑器层写完后外层需要一个 Python 脚本把 Unity 进程包起来。它要做的事包括找 Unity 可执行文件路径、拼参数、启动进程、监控超时、读取输出 JSON、把结果回传给 Agent。import subprocess import json import os import sys def run_unity_command(project_path, command, unity_pathNone, timeout300): unity_path unity_path or /Applications/Unity/Hub/Editor/2021.3.30f1/Unity.app/Contents/MacOS/Unity output_path /tmp/unity_ai_result.json log_path /tmp/unity_ai.log cmd [ unity_path, -batchmode, -nographics, -projectPath, project_path, -executeMethod, AIBuildTools.AIEntrypoint.Run, -aiCommand, command, -aiOutput, output_path, -logFile, log_path ] try: proc subprocess.run(cmd, capture_outputTrue, textTrue, timeouttimeout) except subprocess.TimeoutExpired: return { success: False, error: fUnity process timed out after {timeout}s } if os.path.exists(output_path): with open(output_path, r) as f: return json.load(f) # 结果文件没写出来说明执行器连入口都没跑到捞日志最后几行作为线索 if os.path.exists(log_path): with open(log_path, r) as f: lines f.readlines()[-50:] return { success: False, error: Unity process exited without writing expected output, log_tail: lines } return { success: False, error: proc.stderr[-500:] if proc.stderr else Unknown error } if __name__ __main__: project sys.argv[1] command sys.argv[2] result run_unity_command(project, command) print(json.dumps(result, indent2, ensure_asciiFalse))这个脚本有几个隐藏的加分项超时保护Unity 编译一个大项目有可能卡在资源导入上半小时不退出进程挂着不释放。设置timeout参数后到点强制杀进程避免 Agent 等死。分段返回正常结果走 JSON 文件异常结果走日志尾巴Agent 可以根据返回体的结构决定下一步。退出码透传subprocess.run拿到 Unity 的退出码如果非 0 说明有编译错误或测试失败Python 脚本会返回失败状态但不会主动吞掉结果文件。3.5 让 Agent 调用这套工具链工具链本身是“被调用的”真正让它转起来的是给 Agent 写的工具定义Function Calling / Tool Calling。我用的是一个简化版的 JSON Schema 描述工具接口{ name: run_unity_compile_and_test, description: Run Unity compilation and tests in batch mode. Useful when code changes need verification., parameters: { type: object, properties: { project_path: { type: string, description: Absolute path to Unity project root. }, command: { type: string, enum: [compile, editmode, playmode, all], description: Which task to run in Unity. } }, required: [project_path, command] } }把这个 Schema 塞进 Agent 的工具列表后Agent 在修改完 C# 脚本之后就会自主决定调用这个工具然后读取返回的 JSON针对errors数组里第一个错误去定位文件、生成修复方案。整个闭环我们实测跑通后效率提升非常明显以前人工改代码 - 切到 Unity - 等编译 - 跑测试 - 看报告要 15 分钟现在 Agent 自己改代码 - 调工具 - 3 分钟内拿到结果而且能根据失败信息连续重试多次。4. 常见问题与排查技巧实录4.1 Unity 批处理模式卡死不退出进程现象subprocess.run一直等不到进程结束直到超时被杀。排查思路先看LogFile最后 20 行。如果是停在Refreshing native plugins或者Importing assets说明卡在资源导入流程大概率是工程里某个资源损坏或插件初始化阻塞。如果最后一行是[AIEntrypoint] Start command: compile说明入口执行了但没走到EditorApplication.Exit多半是前面的等待逻辑死循环了。解决方案养成跑批处理前先-quit的习惯。有些 Unity 版本即使调用了Exit也必须在命令行里加-quit才能保证彻底退出。在AIEntrypoint.Run第一行加一个“看门狗”逻辑开一个后台线程50 秒后强制EditorApplication.Exit(2)防止自己写的等待循环有 bug 导致永久挂死。4.2 JsonUtility 无法序列化 Dictionary现象构造好包含Dictionarystring, string的结果对象ToJson之后发现这个字段是空的。原因Unity 的JsonUtility只支持[Serializable]的类、结构体、ListT、数组。Dictionary不在支持列表里会直接跳过不报错。解决方案所有要序列化的数据结构都用ListCustomClass。比如错误列表就用ListErrorInfo错误信息用message字段保持扁平不上字典。4.3 测试结果里的堆栈是 IL2CPP 编译后的行号根本定位不到源码现象Play Mode 测试失败后堆栈信息指向PlayerController.cpp:1234而不是PlayerController.cs:47。原因PlayMode测试默认在编辑器的播放模式里跑但某些平台相关的测试会走 IL2CPP 或 Mono 的剥离版本行号映射失真。解决方案在测试运行前强制设置EditorUserBuildSettings的开发构建选项或者干脆只用EditMode测试来做 AI 驱动的快速反馈。Play Mode 测试带上渲染和物理帧循环跑得慢对 Agent 试错循环不友好。真需要 Play Mode 验证时也建议把测试用例覆盖面收敛到最小集合。4.4 多个 Unity 进程同时跑同一个项目Library 锁冲突现象Agent 连续调用编译和测试时偶尔出现第二个进程卡住报错内容里有Failed to open project。原因同一个项目文件夹同时被两个 Unity 进程打开Library 目录下的缓存文件被第二个进程认为损坏于是它不敢继续。解决方案在 Python 脚本层加互斥锁确保同一个项目同一时间只有一个 Unity 进程在跑别指望 Unity 自己处理并发。import fcntl def project_lock(project_path): lock_path project_path.rstrip(/) .unity_ai.lock lock_file open(lock_path, w) fcntl.flock(lock_file, fcntl.LOCK_EX) return lock_file4.5 中文路径导致 JSON 解析失败现象Windows 环境下项目路径含中文字符Unity 输出的 JSON 结果文件用File.ReadAllText读取时乱码导致 Agent 解析不了。解决方案在编辑器脚本里写文件时显式使用 UTF-8 编码File.WriteAllText(outputPath, json, new System.Text.UTF8Encoding(false));不要留默认编码Windows 下默认可能给你存成 GBK。4.6 常见问题速查表问题根因解决方案进程不退出未调用EditorApplication.Exit命令行加-quit代码里加看门狗线程Dictionary序列化丢失JsonUtility不支持改用ListT或[Serializable]类Play Mode 测试太慢每次都要进播放模式物理和渲染开销大AI 迭代阶段优先跑 Edit ModePlay Mode 留到最后并发打开项目报错Library 锁冲突外层加文件锁串行化 Unity 进程中文路径乱码文件编码不是 UTF-8写文件时指定UTF8Encoding(false)编译检查拿到旧结果GetAssemblies是上一次的结果先RequestScriptCompilation并轮询完成后再检查入口方法没执行-executeMethod拼错类名或方法名确认是namespace.ClassName.Method且方法为public static5. 这套工具链的进一步扩展与个人心得工程跑通之后我又给它加了一层“日志摘要”能力Unity 侧把编译错误按文件分组测试失败按测试类分组并且每组只保留前三条详细记录其余用N more合并。这个改动让 Agent 处理大量错误时不会被刷屏能更快聚焦到第一组需要修的问题。另一个值得做的扩展是“智能重试”。Agent 拿到编译失败后通常第一次修复不一定准确。与其让它反复调工具撞运气不如在 Python 脚本里加一个“快速失败判断”如果编译错误里有“缺少引用”这类问题直接告诉 Agent 先检查using语句而不是让它盲目重编。这一点对代码生成类 Agent 特别重要因为它经常生成漏引用、错命名的代码。从我个人的实际使用体会来说这套工具链最容易被低估的收益是它让 Unity 开发的数据流变成了文本流。以前 Unity 项目对 AI 来说是一团黑盒现在它可以把内部状态以标准 JSON 吐出来Agent 和它交互的成本一下子降了一大截。哪怕你暂时没有 AI Agent单是把这套命令行编译测试流程接到 CI 上也能省不少事。最后再分享一个小技巧调试这类批处理脚本时不要直接让 Agent 跑。先在终端手动跑一次run_unity_command检查返回的 JSON 是否正常。等它稳定了再放权给 Agent。不然 Agent 会把脚本的报错当成“代码编译失败”在错误方向上打转浪费大量时间和 token。这套工具链目前已经在团队内部落地配合 Agent 自动修脚本的小场景能稳定跑通“改代码 - 编译 - 跑测试 - 拿报告”的闭环。如果你也在折腾类似的 Unity AI 自动化希望这篇实录能帮你少踩几个坑。