AI Agent驱动Unity编译测试:从人肉搬运到自动化闭环
最近被一件事反复折腾AI Agent 生成的 Unity 代码看起来头头是道可我这边的工作量一点没少——把它贴进编辑器、等编译、跑测试、把红色报错再贴回去。人肉搬运工当久了我决定把 Unity 工具链彻底打通让 AI Agent 能够直接驱动 Unity 编辑器的编译与测试流程。这篇文章就是这次修复的完整复盘包含 Editor 扩展脚本、命令行批处理参数、Agent 工具定义和真实迭代循环的现场记录希望能帮到所有正在把 AI 编程接入 Unity 工作流的人。如果你试过让 AI 写 Unity 脚本大概率经历过这种尴尬代码在对话窗口里逻辑通顺、注释齐全一粘进工程立刻冒出一堆编译错误。问题不在 AI 写代码的水平而在于它写完以后根本不具备“自我验证”的手段。这次的修复实录就是要解决这个验证闭环的问题。1. 把 AI Agent 接进 Unity 工具链先想清楚为什么不用人肉搬运1.1 我每天在 AI 对话窗口和 Unity 编辑器之间来回搬运的日子最早我用 AI 编程助手写 Unity 代码时工作流是这样的先在对话里描述需求Agent 吐出一段 C#我复制到 VS 或 Rider 里看一眼切回 Unity 等编译如果有报错就复制错误信息回对话窗口让 Agent 继续修。这个流程看起来没什么问题实际操作时非常折磨。一次多轮修复往往要反复搬运十几趟。Agent 改一个空引用我要等 Unity 重新编译跑完测试再把堆栈信息格式化后贴回去。最难受的是 Unity 编译期间 CPU 被占满我什么也干不了只能盯着进度条发呆。后来我认真算了笔账一个 30 分钟的 AI 辅助开发任务至少有 20 分钟浪费在“搬运代码和报错”上真正思考的时间少得可怜。这件事让我意识到问题不在 Agent 能不能写代码而在工具链断了一环。Agent 能输出文本但它摸不到 Unity 编辑器。只要它无法自己触发编译和测试我这个人肉中间商就永远被绑在流程里。所以这次我给自己定了个目标让 AI Agent 能直接驱动 Unity 的编译和测试自己拿到日志和测试结果自己决定下一步操作。1.2 三条候选路径为什么我选了命令行批处理想实现外部程序驱动 Unity我大致评估过三条路。第一条是在 Unity 编辑器里常驻一个本地服务监听 HTTP 或 Socket 请求由服务去调用 Editor API。这个方案看起来最灵活但维护成本不低而且存在一个致命的脆弱点如果工程脚本出现编译错误编辑器状态会变得不稳定你的服务可能连跑起来的机会都没有。第二条是绕开 Unity直接用 Roslyn 编译工程里的 C# 代码。这方案看起来很干净但 Unity 的工程结构比普通 .NET 项目复杂得多涉及 asmdef、预编译指令、依赖的 UnityEngine 程序集、引擎生成的 csproj 引用关系。强行用 Roslyn 编译大概率收获一堆“找不到 UnityEngine 命名空间”的假报错对 Agent 判断问题没有任何帮助。第三条就是我现在用的方案直接走 Unity 官方支持的命令行批处理模式。Unity 提供了-batchmode -projectPath -executeMethod -runTests这一系列参数专门用于在没有图形界面的场景下执行编译、跑测试、导出包。官方的东西有稳定的日志输出和退出码测试框架也原生支持命令行调用外面再套一层宿主脚本就能把 Unity 变成 Agent 可以随时使用的“工具”。三条路对比下来命令行批处理是我个人最推荐的方案理由有三稳定、官方支持、日志解析方便。下表是我当时的评估记录方案实现成本稳定性对 Agent 的友好度我的结论编辑器内常驻服务高低编译错误会导致服务不可用中不推荐Roslyn 间接编译中中依赖工程结构解析低假报错多不推荐命令行批处理低高官方参数稳定高日志和结果文件易解析推荐1.3 打通链路前需要准备的清单在开始写代码之前我把需要的环境列了个清单。Unity 版本我用的是 2021.3 LTS 的某个次版本这个链路在 2020 LTS 到 2022 LTS 上都验证过参数基本一致不过不同小版本的日志格式会略有差别后面解析时要注意。工程方面需要装 Unity Test Framework 包这个在 Package Manager 里搜“Test Framework”就能装跑 EditMode 测试至少要装上它。宿主脚本我选了 Python 3.8 以上原因很直接subprocess 处理子进程顺手解析 XML 有内置的 ElementTree不需要额外折腾。当然你习惯 Node 也行核心逻辑是一样的。还有一个容易被忽略的东西Unity 许可证。命令行批处理模式同样需要激活许可证我在一台没登录过 Unity 的新机器上跑批处理结果卡在许可证激活界面后来用离线激活方式才算通过。所以如果你打算在 CI 或远端机器上跑提前确认许可证没问题否则 Agent 调用再多次也是白搭。2. 给 Unity 开一扇外部可控的门Editor 脚本与命令行参数设计2.1 -executeMethod 使用前必须避开的坑-executeMethod参数是 Unity 官方提供的一个入口允许你指定一个静态方法在编辑器启动后自动执行。第一次用的时候我踩了个不小的坑方法必须是static的而且不能有参数。我一开始写了个带实例状态的方法结果执行时毫无反应日志里也看不到任何报错排查了半天才发现是这问题。另一个容易踩的坑是命名空间。如果你的 Editor 脚本写在某个命名空间里命令行参数里就必须带上完整的命名空间前缀比如MyTools.EditorTools.Probe只写类名会导致 Unity 找不到方法。还有一点方法所在的程序集必须能被 Unity 编译通过如果工程脚本本身有编译错误-executeMethod根本不会执行——这个特性看起来是个限制但后面反而被我用来做编译探针了。我当时最基础的命令长这样/c/Program Files/Unity/Hub/Editor/2021.3.45f1/Editor/Unity.exe \ -batchmode \ -projectPath D:/Projects/Demo \ -executeMethod EditorTools.Probe \ -logFile D:/Projects/Demo/Logs/build.log \ -quit注意-logFile要写绝对路径如果目录不存在Unity 不会帮你自动创建日志会静默丢失。我第一次跑的时候命令返回的成功但什么日志都没写出来查了半天才发现是日志目录没建。2.2 用编译探针快速判断脚本编译是否通过刚说-executeMethod在编译失败时不会执行这个特性非常有用。我可以写一个只负责打日志的“探针”方法Agent 每次调用它时只要看日志里有没有探针打印的内容就能判断当前工程脚本是否编译通过。探针方法我写得尽量简单里面只输出一些关键信息#if UNITY_EDITOR using UnityEngine; public static class EditorTools { public static void Probe() { Debug.Log([EditorTools] Probe executed. Compilation OK.); Debug.Log([EditorTools] Unity Version: Application.unityVersion); } } #endifProbe()被调用前Unity 会先自动做一次全量脚本编译。如果 C# 代码有问题日志里会出现“Scripts have compiler errors”这类关键字Probe 里的内容则不会出现。所以 Agent 只需要看两件事日志里有没有compiler errors以及有没有[EditorTools] Probe executed就能判断编译状态。这么做的好处是快。完整执行一次 BuildPlayer 动辄要几分钟甚至更久但一个探针方法在编译通过后几秒就能执行完非常适合 Agent 在迭代修复时反复调用。真正的出包验证可以留给关键节点再触发日常循环完全没必要每次都构建整个项目。2.3 让 Unity 原生测试框架跑出可解析的结果编译探针只能告诉你脚本能不能编译但业务逻辑对不对还得靠测试。Unity Test Framework 提供了一套完整的命令行测试参数不需要写额外代码就能跑 EditMode 和 PlayMode 测试。我经常用的命令是这样/c/Program Files/Unity/Hub/Editor/2021.3.45f1/Editor/Unity.exe \ -batchmode \ -projectPath D:/Projects/Demo \ -runTests \ -testPlatform EditMode \ -testFilter ScoreManagerTests \ -testResults D:/Projects/Demo/Logs/TestResults.xml \ -logFile D:/Projects/Demo/Logs/test.log \ -quit-testPlatform我大部分情况选 EditMode因为 EditMode 测试不进入 Play 模式不用加载场景跑起来快很多特别适合 Agent 修小 bug 的场景。PlayMode 测试更接近真实运行时但速度慢而且依赖场景资源适合在关键节点统一跑。-testFilter参数可以指定单个测试类甚至单个测试方法的名字这让 Agent 能只跑跟它改动相关的测试而不是每次把整个工程几百上千个测试全跑一遍。下面会专门讲这个参数对 Agent 反馈效率的影响。测试结果会写入指定的 XML 文件格式是 NUnit 标准的里面有total、passed、failed、result这些字段宿主脚本解析起来非常方便。我通常只看这个 XML 里的失败用例和异常信息把它们原样传给 AgentAgent 就能直接开始改代码。3. 把扳手塞进 Agent 手里宿主脚本与工具调用定义3.1 宿主脚本的最简实现拼命令、开子进程、回读结果Unity 命令本身写好了接下来要解决的是怎么把它变成 Agent 能用的“工具”。我在中间加了一层宿主脚本Agent 只需要调用脚本暴露的函数脚本负责拼命令、启动 Unity 进程、读取日志和测试结果然后把精简后的信息返回给 Agent。宿主脚本的骨架其实很简单核心就是一个run_unity函数。我一开始的实现大概长这样import subprocess import xml.etree.ElementTree as ET from pathlib import Path UNITY_EXE rC:/Program Files/Unity/Hub/Editor/2021.3.45f1/Editor/Unity.exe def run_unity(args, timeout600): cmd [UNITY_EXE, -batchmode, -projectPath, args[project_path]] cmd [-executeMethod, EditorTools.Probe] if args.get(test_filter): cmd [-runTests, -testPlatform, EditMode, -testFilter, args[test_filter], -testResults, str(Path(args[project_path]) / Logs / TestResults.xml)] cmd [-logFile, str(Path(args[project_path]) / Logs / run.log), -quit] result subprocess.run(cmd, capture_outputTrue, textTrue, timeouttimeout) return parse_result(args[project_path], result)parse_result会读取日志和测试 XML提取关键信息返回给 Agent。这里有个非常重要的设计原则返回给 Agent 的信息不能只是“成功”或“失败”两个字要把测试名、异常类型、堆栈关键行全都带上Agent 才能根据这些信息定位问题。3.2 面向 Agent 的工具 Schema 长什么样宿主脚本写好后还需要让 Agent 知道怎么调用它。如果你用的是支持 Function Calling 的模型本质上是给 Agent 一个 JSON 描述告诉它工具的名称、功能和参数格式。我给这个工具起的名字叫unity_run_tests它的 Schema 大概是这样{ name: unity_run_tests, description: 在指定Unity工程中运行编译探针与EditMode单元测试返回编译状态和测试结果, parameters: { type: object, properties: { project_path: { type: string, description: Unity工程根目录的绝对路径 }, test_filter: { type: string, description: 要运行的测试类名或方法名例如 ScoreManagerTests不传则只跑编译探针 } }, required: [project_path] } }这个 Schema 看起来简单但有一个细节很关键描述信息要足够具体。Agent 是靠这段描述来理解工具用途的如果描述写成“运行测试”这种笼统的说法它可能乱传参数写成“编译探针”和“单元测试”的组合它才知道要先验证编译、再选相关测试跑。我当时用的是 OpenAI 兼容的 Function Calling 接口宿主脚本把问答接口串起来Agent 在对话里决定调用工具然后宿主脚本执行、把结果回传相当于完成了一次完整的工具调用循环。3.3 为什么 testFilter 是让你少跑冤枉路的关键参数在测试工具的参数里我特意加了一个test_filter这个参数帮我省了大量时间。刚开始我没有这个参数Agent 每次调用工具都会跑全量测试一个小模块的改动要跑完整个工程的测试遇到慢的工程一次跑五六分钟都是常事。加了test_filter之后Agent 修改了ScoreManager的代码就会主动传ScoreManagerTests只跑这一个测试类。测试结果返回得快Agent 的迭代速度也提起来了用户体验完全不一样。执行流程从“慢→全量→模糊反馈”变成了“快→定向→精准反馈”。还有一个设计心得宿主脚本返回的结果就是 Agent 下一步决策的依据我们可以把这套思路总结成“反馈即提示词”。一开始我只返回“测试失败”Agent 看到这种信息只能瞎猜连续几次都在改错地方。后来我在返回结果里带上完整的异常堆栈和失败的测试名Agent 定位问题的速度立刻上来了。所以宿主脚本的解析逻辑宁愿写复杂一点也要把最有价值的信息传给 Agent。4. 实操现场一次由 Agent 独立完成的修复-重测循环4.1 准备一个有 bug 的模块和一条会失败的测试为了验证这套链路是否真的能跑通我特意造了一个简单但包含逻辑缺陷的模块再配一条会失败的单元测试。模块是ScoreManager负责维护玩家名字和分数的映射public class ScoreManager { private readonly Dictionarystring, int _scores new Dictionarystring, int(); public void SetScore(string name, int score) { _scores[name] score; } public int GetScore(string name) { return _scores[name]; // Bug: 查询不存在的名字会抛 KeyNotFoundException } }测试类用 Unity Test Framework 的 NUnit 来写using NUnit.Framework; public class ScoreManagerTests { [Test] public void GetScore_ForMissingName_ShouldReturnZero() { var manager new ScoreManager(); manager.SetScore(Alice, 100); Assert.AreEqual(0, manager.GetScore(Bob)); } }这条测试期望查询不存在的名字时返回 0但当前GetScore直接用索引取值遇到 “Bob” 会抛KeyNotFoundException测试必然失败。接下来我把这个工程交给 Agent让它自己跑测试、自己改代码、自己验证。4.2 Agent 第一次调工具编译探针与测试结果回传我向 Agent 发出的指令很简单“检查工程里的 ScoreManager 模块跑相关测试如果失败就修复并重新验证。”Agent 响应后第一件事就是调用unity_run_tests工具参数是project_path加test_filter: ScoreManagerTests。宿主脚本收到请求后拼出上面的命令行启动 Unity 批处理进程。因为工程脚本本身能编译探针日志显示了[EditorTools] Probe executed测试也正常跑完写入了 XML 文件。宿主脚本解析后返回给 Agent 的结果大概是这样的result: Failed total: 1, passed: 0, failed: 1 failure: ScoreManagerTests.GetScore_ForMissingName_ShouldReturnZero message: System.Collections.Generic.KeyNotFoundException : The given key Bob was not present in the dictionary. at ScoreManager.GetScore(System.String name)Agent 拿到这段信息后不需要我再贴什么它自己就能定位到ScoreManager.GetScore方法里用了直接索引字典的写法。整个过程中我只做了一件事向 Agent 下达任务剩下的是工具链自己在转。4.3 Agent 改完代码后的第二次验证与最终结果Agent 分析出问题后很快给出了修复方案把直接索引改成了TryGetValuepublic int GetScore(string name) { return _scores.TryGetValue(name, out int score) ? score : 0; }代码改完后Agent 再次调用unity_run_tests这次返回的结果就是result: Passed total: 1, passed: 1, failed: 0整个修复-测试循环我全程没有碰 Unity 编辑器也没有复制任何代码。Agent 通过工具驱动了 Unity 的编译探针、执行了单元测试、读取了测试结果、修改了源码、再次验证并确认通过。这就是“让 AI Agent 直接驱动 Unity 编辑器编译与测试”的最终效果。4.4 回读测试结果时我踩的编码坑这个循环跑通之前我在解析 TestResults.xml 时卡了一个多小时原因是编码问题。Unity 生成的 XML 文件不是普通的 UTF-8而是带 BOMByte Order Mark的 UTF-8。用 Python 的普通open函数以utf-8编码读取时第一个字符会带着一个不可见的\ufeff导致字符串匹配失败。后来我在读取文件时改用utf-8-sig编码问题立刻消失。这个坑非常典型如果你也写宿主脚本解析 Unity 测试结果强烈建议所有 XML 和日志文件统一用utf-8-sig读取能省掉一大半编码问题。日志文件本身偶发也会出现编码不统一的情况处理的时候可以先把读取逻辑封装成一个函数后续复用起来会省心很多。5. 常见问题与排查技巧实录5.1 Unity 进程锁让你批处理打不开工程用批处理模式跑 Unity 的时候最常遇到的问题就是“打不开”工程。现象是命令执行完没有任何输出日志文件也没生成或者报一个“Project path does not exist”之类的错。我排查了一圈发现大部分情况是 Unity 编辑器进程还占着工程锁。Unity 工程打开后会在 Library 目录里生成一些锁文件如果之前的一个 Unity 进程没完全退出新的批处理进程就抢不到工程访问权。解决方法是先确认没有残留的 Unity 进程再跑批处理。Windows 上我一般用taskkill /F /IM Unity.exe但这个方法很粗暴如果编辑器里有没保存的场景强制杀进程会导致丢失所以我在自动化跑测试前都会先确认工程已经提交到版本控制或者至少关键资源已保存。Agent 驱动的自动化流程里干净的环境比什么都重要。5.2 编译错误时退出码为 0别全信退出码还有一个特别坑的现象有些 Unity 版本在脚本编译失败时批处理进程的退出码依然是 0或者返回一个不明确的非 0 值全依赖于退出码判断会漏掉大量编译错误。我后来总结了一套更稳的判断规则以日志关键字为主、退出码为辅、探针执行为兜底。具体来说我判断一次调用是否成功会按顺序检查三件事第一日志里有没有compiler errors关键字有就是编译失败第二日志里有没有[EditorTools] Probe executed没有就说明探针方法没跑起来大概率是编译或初始化出了问题第三测试 XML 里的result字段是Passed还是Failed。这三个信号组合起来基本能覆盖各种异常情况。如果只盯着退出码写自动化你会发现 Agent 偶尔会收到错误的“成功”信号然后继续下一步最后在验收时才发现东西根本没编过整个反馈链就断了。5.3 路径空格、参数顺序、日志截断命令行批处理的另一个高频坑是路径带空格。Unity 安装路径在 Windows 上默认就在C:\Program Files下面空格几乎是必然存在的所有参数里的路径字符串必须加双引号。我在宿主脚本里用 Python 的subprocess.run传命令列表时会自动处理空格但如果你用 shell 字符串拼接就得自己留意转义问题。参数顺序方面我的经验是通用参数放前面功能参数放后面-quit放最后面。-projectPath和-batchmode写在前面-runTests、-executeMethod写在中间-quit收尾。这样排列在大多数 Unity 版本上都能稳定解析我自己在几个新旧版本上测试过顺序乱的时候确实有些版本会忽略部分参数。日志截断也是实际运行中会遇到的问题。大工程编译日志动辄几 MB如果 Agent 每次处理全量日志既浪费 token又容易淹没关键信息。我在宿主脚本里加了过滤逻辑只保留包含[EditorTools]、Exception、Failed、Error、compiler这些关键字的行控制在几十行以内再返回给 Agent效果好了很多。5.4 问题速查表把我在这次修复实录里遇到过的典型问题整理成了速查表方便你直接对照排查问题现象可能原因解决方案批处理进程退出但无日志日志目录不存在提前创建日志目录使用绝对路径-executeMethod 方法没执行方法不是 static、带参数或命名空间不匹配检查方法签名和完整命名空间日志显示 compiler errors工程脚本编译失败让 Agent 检查报错堆栈并修复 C# 代码测试结果 XML 首字符乱码文件是 UTF-8 with BOMPython 读取时用utf-8-sig编码批处理打不开工程Unity 进程残留锁文件taskkill 清理 Unity 进程再重新执行测试结果全绿但业务逻辑不对测试覆盖不全补齐测试用例后再交给 Agent 修复退出码为 0 但仍失败个别版本编译失败不反映在退出码结合日志关键字和探针日志综合判断5.5 还有两个值得注意的细节批处理模式跑 EditMode 测试时测试里如果引用了场景资源可能会出现路径找不到或资源未加载的问题。EditMode 测试默认不加载场景遇到这种情况我建议要么把资源路径写死要么改用 PlayMode 测试不要在这上面硬耗。还有一个容易被忽视的点Agent 在多次迭代调用工具时容易在没有限制的情况下反复发起调用一旦遇到某个老生常谈的编译错误它可能笨拙地重试十几次白白浪费时间和计算资源。我在宿主脚本里给 Agent 的调用次数加了上限到上限后强制终止把当前的日志状态原样返回。这个保护机制在长时间无人值守的自动化任务里尤其重要。6. 在此基础上还能继续做的事6.1 把工具封装成 MCP Server让桌面端 Agent 直接摸到 Unity宿主脚本这套方案本质上是一个“命令行封装”可以进一步升级成 MCP Server。MCP 是现在不少 AI 编程工具支持的协议相当于给 Agent 提供了一组标准化的本地工具接口。把unity_run_tests封装成 MCP tool 之后像 Claude Desktop 这类支持 MCP 的客户端就能直接调用 Unity 编译和测试不需要我再单独写一套对接逻辑。我实际试过把宿主脚本通过 MCP 暴露给桌面端 Agent体验确实比在对话里贴代码再复制报错舒服得多。Agent 可以自己决定什么时候跑测试什么时候需要看日志整个流程围绕工具节点自动推进。不过要注意MCP 工具本质上授予了 Agent 调用本机命令的权限安全边界要比普通对话严格很多建议运行在专门的测试机或隔离环境里。6.2 双 Agent 复核写代码的和跑测试的分工这套工具链跑顺之后我还试过一个更有意思的模式用两个 Agent 做分工。一个 Agent 负责写代码和修复功能另一个 Agent 只负责跑测试、检查结果如果测试挂了就把失败信息抛回给写的 Agent。两个 Agent 之间不直接对话只通过测试结果交接相当于模拟了一个“开发测试”的最小团队。这个模式的好处是职责清晰跑测试的 Agent 不会因为和写代码的 Agent 共享上下文而带上偏见只认测试结果。坏处是多一个 Agent 就多一层延迟和成本适合稍微复杂一点的任务。对于单个小模块的修复单个 Agent 加工具链已经足够用了。6.3 我的最终建议与这套链路的边界折腾完这一整套链路我的最大体会是工具链越自动化Agent 的可靠性越高但千万别把最后的验证职责也完全交给 Agent。无论 Agent 跑多快代码合入版本库之前我个人一定会至少瞄一眼改动内容尤其是涉及资源加载、序列化字段、渲染管线的部分自动化测试覆盖不到的地方太多了。再提醒一句Agent 能直接驱动 Unity 编译和测试意味着它有了更高的执行权限。哪怕是测试专用的机器也要做好基本的安全边界比如不把生产库的连接信息放在工程里、不对 Agent 开放编辑器之外的文件系统权限。技术是帮我们省事的不是给我们添麻烦的。对了最后分享一个我在实际使用中觉得最值的配置把unity_run_tests这个工具简化到极致只留编译探针和单测两个能力反而比一开始设计的花哨参数好用得多。Agent 需要的是清晰简单的工具边界而不是一个什么都能干但很难理解的黑盒子。如果你正准备往这个方向做建议先跑通最简版本再逐步加功能这个路线最不容易翻车。