ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Unity编译测试自动化:AI Agent驱动的闭环修复实践

Unity编译测试自动化:AI Agent驱动的闭环修复实践 在 Unity 项目上摸爬滚打久了很多人都会到同一个瓶颈编译、跑测试、改错、再编译这套循环实在太费人了。特别是项目规模上来之后一次全量编译加 Editor 测试可能要吃掉十几分钟你还得盯着控制台一条条看日志。我最近做的事情就是把这套循环整体外包给 AI Agent——让 Agent 直接驱动 Unity 编辑器完成编译和测试并且通过读日志、改代码、重新编译形成闭环。这篇文章就记录一下这个工具链从设计到踩坑再到跑通的完整过程核心思路是人机职责重新分配人定方向Agent 做执行循环。这套东西不适合只做纯展示的小 Demo 项目更适合有一定代码量、编译时间和测试成本都明显偏高的中型以上 Unity 工程。如果你手头恰好有几个模块经常因为低级错误反复编译失败又希望把这些脏活自动化那这篇文章的思路和代码可以直接拿去做底子。我会把 Unity 侧改造、Agent 侧调度、通信机制、日志解析和自动修复闭环全部分享出来顺便把我们当时踩过的坑和最后保留的解决方案一起放进问题速查表。1. 整体设计与方案选型1.1 先把需求说清楚AI Agent 到底要接管哪几步我最初的想法很简单就是让 Agent 在收到指令后自动完成编译、跑测试、查看失败原因、修改代码这个循环。听起来跟 CI 里的流水线差不多但 CI 通常只会告诉你哪一步失败了不会回退到成功状态去定位是哪个脚本写错了。我们要的是 Agent 能读取编译错误和测试断言然后自动给出修复建议甚至直接改代码。在这里面有个关键前提Agent 需要拿到的不是编译通过/失败这种布尔结果而是结构化的错误信息。比如编译失败时需要知道是哪个 CS 脚本、哪个文件路径、哪一行出了什么错误测试失败时需要知道是哪个 Test 类、哪个方法、断言失败的具体消息。这些信息只要给全Agent 才能做下一步决策。所以在设计的第一版需求清单里我列了这几项命令行启动 Unity 编辑器并执行编译同步返回编译结果和日志可选的单元测试执行与结果回收Agent 可写一个变更文件触发重新编译整个过程可超时控制不能把编辑器卡死。1.2 为什么不让 Agent 直接调命令行可能有人会问Unity 本来就支持命令行批处理模式直接用 subprocess 调用 Unity.exe 不就行了理论上可以但实际操作几天之后我就放弃了。原因有几个第一Unity 批处理模式的启动时间非常长冷启动加导入资源往往要几十秒到几分钟如果 Agent 每改一行代码就重启一次编辑器效率低到没法用。第二命令行调用的错误信息只能通过 stdout 和日志文件拿解析起来非常容易踩编码和格式的坑尤其是中文日志在部分 Windows 环境下的乱码问题凭空增加了复杂度。第三编译失败后 Unity 进程的退出码并不总是能精确映射到错误类型很多时候你拿到的只是一个失败状态具体原因还得靠人去捞日志。所以我后来改成了驻留进程方案Unity 编辑器保持运行Agent 通过本地 HTTP 接口或者 Standard Input 向编辑器内已加载的脚本发送命令编辑器收到后执行编译或测试再把结果序列化成 JSON 回传。这样避免了反复冷启动也让 Agent 拿到的数据干净得多。1.3 工具链最终形态与通信链路最终跑通的方案可以概括为三层Agent 控制层、进程调度层、Unity 编辑器驻留层。Agent 控制层是跑在 Python 环境里的决策系统它的任务不是直接写 C# 代码操作 Unity而是根据上一步的执行结果决定下一步动作。进程调度层负责维护 Unity 子进程的生命周期包括启动、保活、超时杀掉、日志重定向。Unity 编辑器驻留层是一个常驻的 C# 编辑器脚本内部监听 HTTP 端口接收 Agent 发来的指令并调用 Unity Editor API 执行。这里用 HTTP 而不是命名管道或者标准输入主要是考虑后续扩展性。HTTP 的好处是 Agent 不一定要和 Unity 跑在同一台机器上远程开发时可以直接通过局域网访问。而且 Unity 侧的 HttpListener 使用起来足够简单不需要额外引入第三方库避免包依赖给编辑器带来负担。实际测试中localhost 下的响应延迟基本在毫秒级完全够用。我们当初也考虑过 MQTT 或者 Redis 队列但对于单机单编辑器的场景来说这属于过度设计加大维护成本不说还拖慢整个链路。关于通信链路的设计后面第 2 部分会详细讲 Unity 侧脚本的写法第 3 部分讲 Python 控制器的具体实现。2. Unity 端工具链改造2.1 BatchMode 基础三个必须掌握的入口参数在改造之前先把 Unity 批处理模式的基础知识过了下。打开 Unity 的可执行文件时至少需要关注下面三个参数参数-batchmode表示以批处理模式运行不显示图形界面。这里有个易踩坑的点如果把该参数和-nographics一起用那么任何需要图形 API 的测试几乎都会直接报错包括一些 UI 测试和依赖 RenderTexture 的测试因此在跑比如 EditMode 测试时我通常不附加-nographics只有在纯编译或者纯逻辑类任务才舍得关掉图形设备。参数-executeMethod是入口来源它要的是一个静态方法签名格式为命名空间类名.方法名比如EditorAgentHost.RunCommand。需要注意的是 Unity 在调用时不会创建类的实例所以方法必须声明为 public static它在所有程序集编译完成后才会被调用。参数-quit是执行完-executeMethod后是否立即退出编辑器。早期我让它一直挂着为的就是避免反复冷启动。这也是开头那个驻留方案的雏形。还有一个很容易被忽略的参数是-logFile可以指定日志输出位置。建议不要使用默认的Editor.log路径而是显式指向项目目录下的自定义路径这样后续 Agent 读取日志时不需要额外的路径探测。2.2 编译脚本设计返回码、日志、产物统一收敛Unity 里触发编译有两个常见口径。一个是调用BuildPipeline.BuildPlayer这个偏打包另一个是直接触发编辑器脚本编译对应的方法是通过EditorApplication.CommitBuildModifications或者手动触发AssetDatabase.Refresh。任务一里我主要是判断当前工程是否能在编辑器环境下编译通过所以不会走到打包那一步只需要重新编译所有程序集并检查是否有编译错误。编辑器提供了一个非常有用的回调EditorApplication.update每一帧都会执行但我不推荐在编译时用轮询去做状态机因为轮询的粒度太粗调试起来也不方便。更稳的做法是注册EditorApplication.delayCall在编译完成后主动进入执行队列。关键代码如下在工具类里维护一个静态字符串用来保存待执行的命令等编译状态变化后再触发真正的执行。具体来说要判断EditorApplication.isCompiling是否为 false并且EditorApplication.isUpdating为 false此时说明程序集刷新已经完成可以继续走后续逻辑。public static class EditorAgentCommands { private static string _pendingCommand; public static void ExecutePending() { if (_pendingCommand null) { return; } if (EditorApplication.isCompiling || EditorApplication.isUpdating) { EditorApplication.delayCall ExecutePending; return; } string cmd _pendingCommand; _pendingCommand null; HandleCommand(cmd); } }这里其实藏了一个非常关键的坑不能直接在delayCall里执行编译逻辑因为delayCall只是延迟到下一帧序列它不保证程序集刷新完成。所以在执行前必须再判断一次编译状态如果还在编译就继续递归延迟。编译错误的获取我采用的是CompilationPipeline.GetAllCompilerMessages这个接口可以一次性拿到所有程序集的编译信息包括错误、警告、文件名、行号、列号以及错误 ID。var messages CompilationPipeline.GetAllCompilerMessages(); var errors messages .Where(m m.type CompilerMessageType.Error) .Select(m new { m.file, m.line, m.column, m.message, m.errorCode });这条数据会序列化成 JSON 传给 Agent。这里有个细节一定要把file字段的路径转换成相对路径否则 Agent 在自动修改代码时拿到的是一串本机绝对路径换台机器就根本没法用了。转换方法很简单就是用Path.GetRelativePath相对项目根目录做一次归一化。如果后续要让 Agent 直接调用本机编辑器打开对应行可以使用相对路径拼出完整路径但原则上 Agent 修代码应该用相对路径而不是硬编码的绝对路径。2.3 让 Agent 触发编译的主动通道驻留进程方案的核心其实在宿主脚本上对外提供刷新编译的入口。这个入口需要一个静态方法让外部能通过命令行或者 HTTP 请求触发。但-executeMethod只能从命令行调用我们不能让 Agent 每次修改完代码再去启动一个新的 Unity 进程。所以我做了一个折中方案驻留进程通过 HTTP 监听接收命令收到命令之后不是直接执行而是把命令字符串存到_pendingCommand马上调用AssetDatabase.Refresh随后挂到EditorApplication.update上等待。确认编译状态结束后再从 pending 里取出来继续跑。这样 Agent 修改完 .cs 文件后只需要给 HTTP 服务发一个 build 请求Unity 就会重新编译顺手把CompilationPipeline.GetAllCompilerMessages的结果返回来。这段逻辑对应到模板化的伪代码大概是这样的private static HttpListener _listener; public static void StartAgentHost(string port) { if (_listener ! null _listener.IsListening) { return; } _listener new HttpListener(); _listener.Prefixes.Add($http://127.0.0.1:{port}/); _listener.Start(); _listener.BeginGetContext(OnContext, null); }这里有一个经验教训默认的 HttpListener 是无状态单例模式如果多个请求并发到达BeginGetContext会自动排队处理但如果你手动创建了多个HttpListener实例就会报拒绝访问之类的错误。而且 Unity 里用 HttpListener 有个隐性门槛Windows 下监听非 localhost 地址需要管理员权限所以建议只监听 127.0.0.1避免以后换机器跑 Agent 时遇到权限问题。端口的选择也有讲究。不要写死在脚本里最好通过环境变量或者启动参数传进来这样多个 Unity 工程同时跑 Agent 时不会互相抢占端口。我这边是约定端口从 19523 开始往后递增不同项目分配不同端口全局维护一份端口映射表。3. Agent 端控制器实现3.1 进程调度起停、超时、看门狗Unity 侧的驻留脚本只是提供能力真正做决策的 Agent 在 Python 控制器里。控制器首先要管理 Unity 进程的启动和停止。我一开始用的是最简单的subprocess.Popen直接启动有图形界面的编辑器但做了一次之后就发现问题了如果 Unity 长时间挂机编译时会占用大量内存多跑几个小时后变得非常卡顿而且 Agent 这边难以感知进程是否正常。所以后来补上了一套简单看门狗逻辑Python 控制器每隔 30 秒检查一次 Unity 进程是否还在如果没有了就立刻重新拉起。这中间还有一个数据库层面的状态标记保证同一时刻只有一个 Unity 实例在跑避免两个进程同时操作同一个项目目录导致 Library 目录锁冲突。Python 侧的进程启动函数大概长这样import subprocess import os def start_unity(unity_path: str, project_path: str, port: int): env os.environ.copy() env[AGENT_UNITY_PORT] str(port) proc subprocess.Popen( [ unity_path, -projectPath, project_path, -batchmode, -logFile, f{project_path}/Logs/agent_editor.log, ], envenv, stdoutsubprocess.PIPE, stderrsubprocess.PIPE, ) return proc有几点值得展开第一-batchmode并不是必须在命令行写死的也可以在启动后通过脚本设置Application.runInBackground但实际效果差不多。第二Unity 进程启动之后会有很长的资源导入阶段此时即使进程存在也不代表它已经就绪。所以启动后要轮询 HTTP 接口的/health等它返回 200 才能下发任务。第三进程必须加一个超时保护比如任务执行超过 15 分钟就直接杀进程重新拉起不然编译器卡死会让整个队列都停摆。这里我用到的是subprocess.Popen配合超时器的方案而不是subprocess.run因为是长期驻留场景run的阻塞模型不适合做保活控制。3.2 日志采集与错误定位正则版还是 LLM 版当 Agent 驱动编译后Unity 侧会返回结构化 JSON但实际运行时还有很多外围信息在编辑器日志里例如程序集加载失败、资源导入警告等。这些信息单靠 Unity 侧脚本可以直接拼接到 JSON 里但日志里还有一些跨模块的问题比如某个脚本依赖的 DLL 没有生成、TMP 资源导入报错等。我最开始直接在 Python 控制器里读日志文件末尾的 200 行然后按常用正则切出文件路径和错误码。这样做确实能覆盖大部分编译错误但遇到同一条错误跨多行时正则就很容易漏。后来我给日志解析加了一层缓存逻辑只有当 Unity 侧 JSON 的 error 列表为空时才回退到日志正则解析模式避免重复捞数据。大概结构是这样的re_compile_error re.compile(rAssets\.cs(?:\((\d),(\d)\))?:\s*(?:error|warning)\s(\w):\s*(.*)) def parse_compile_messages(log_text: str): result [] for match in re_compile_error.finditer(log_text): result.append({ file: match.group(1), line: int(match.group(2) or 0), column: int(match.group(3) or 0), error_code: match.group(4), message: match.group(5) }) return result不过说实话正则版只能解决定位错误位置这个层面真正要判定错误原因、给出修复建议还是得靠 LLM。我们的实现方式是先把 Unity 侧返回的结构化错误丢给大模型让它用中文或者英文输出修改后的代码 diff再让用户确认。这里有个安全设计Agent 不会直接覆盖文件而是生成 diff经过一个白名单检查只允许修改 Assets 和 Packages 目录不允许碰 Library、Temp、Logs 等机器相关目录之后才应用。3.3 自动修复闭环编译失败后 Agent 如何自迭代自动修复闭环是整个工具链里最像AI 干活的部分。过程是这样的Agent 拿到编译错误列表后会给大模型一段上下文包括错误信息、文件内容、项目命名空间、相关引用路径要求输出一个最小修复补丁。我这边的大语言模型调用会附带一个系统提示强制要求只输出统一 diff 格式不能输出解释性文字。拿到 diff 之后Python 控制器会校验它是否在允许目录范围内然后对原文件做备份再应用补丁。改完之后过 3 秒发送 HTTP 请求让 Unity 重新编译。如果新的编译结果还有错就把错误列表 前一轮的修改记录作为上下文继续喂给模型。这个迭代循环我们最多允许 5 次超过 5 次还在编译失败就认为是模型无法独立解决的问题转人工。这里有一个特别重要的细节每轮修复前必须把场景里打开的脚本重新同步到磁盘。因为 Unity 的源文件如果有未保存的修改Agent 改盘上的文件其实不会让编辑器状态立即更新必须通过AssetDatabase.SaveAssets或者直接重启编辑器。实际过程中我们干脆规定 Agent 每次改文件前先让编辑器重新加载一次场景防止脏状态影响判断。还有一个让我踩了很久的坑Unity 的编译错误有时候是延迟暴露的第一轮编译失败了改完错误后重新编译会看到剩下的错误已经不是同一批了有点像连环报错。所以修复循环必须支持错误列表发生变化这种模式而不是固定只针对第一轮的错误否则会把 Agent 卡死在无意义的修改里。4. 关键踩坑与工程化细节4.1 编辑器进程锁死与超时处理做这套工具链最容易碰到的问题就是 Unity 编辑器进程锁死。触发锁死的场景很多某个资源导入时间过长、shader 编译卡住、HTTP 上下文一直没回调、甚至垃圾回收时 Unity 主线程阻塞了十几秒。如果 Agent 一直等这个请求的响应整个流水线就冻住了。我的处理策略是双超时Python 侧对每个 HTTP 调用的等待时间设置有 600 秒上限Unity 侧代理接口在收到指令后会记录一个 TimeStamp如果没在 5 分钟内完成任务就主动写入一个超时标记并返回错误结果。这么做的目的不是解决进程卡死而是让 Agent 快速感知异常走杀进程重拉这条恢复路径。杀进程之前我会先从项目目录里的Logs/agent_editor.log抓取最后 100 行用于诊断是哪里卡住。这样就算重置了编辑器也能留下现场线索。另外我养成了一个好习惯杀掉 Unity 编辑器进程后等待 10 秒再重新拉起因为 Unity 强制崩溃后有时进程没有完全释放文件句柄马上重启会导致资源导入冲突。proc.terminate() try: proc.wait(timeout10) except subprocess.TimeoutExpired: proc.kill() proc.wait()这段代码看着简单但救了太多次命。之前曾经出现过 terminate 杀不掉 Unity 的情况因为次世代渲染进程有一些残留的子进程所以proc.kill()之后还得再检查进程列表。4.2 编译输出的两种检查方式测试框架跑完以后Unity 侧返回的数据结构里既有 EditMode 测试结果也有 PlayMode 测试结果。我一开始只是把所有测试结果打到一行 JSON 里Agent 再拿 JSON 去让大模型分析逻辑上没毛病但响应体非常大动辄几百 KB。后来我改成精简模式只保留失败用例的堆栈前 500 字符、断言消息前 200 字符、通过的用例只统计数量。编译输出的检查方式说白了有两种操作模式。第一种是静态检查也就是在改动代码后先看重新编译有没有失败这一步通过 Unity 侧脚本从CompilationPipeline.GetAllCompilerMessages拿错误和警告信息不涉及场景加载和资源导入速度最快。第二种是动态检查也就是真正进入 PlayMode 跑测试用例这个会慢很多因为要启动游戏逻辑、加载场景。我最终在 Agent 的自动修复循环里形成了这样的约定先静态检查只有静态检查通过时才进入动态测试阶段如果动态测试跑挂Agent 拿到的上下文是测试堆栈加当前场景名它需要靠堆栈反推是代码逻辑问题还是场景配置问题。这个思路对普通 C# 脚本报错很有效对依赖场景 GameObject 的测试就复杂得多Agent 得学会读取场景配置。目前我们只是把场景里挂载的根节点列表和关键 MonoBehaviour 参数送进上下文足够覆盖大部分情况。4.3 日志乱码与编码陷阱很多 Windows 环境下的 Unity 日志默认使用系统 ANSI 编码中文字符非常容易变成乱码。这坑在刚开始做日志解析时几乎天天遇到尤其是 Models 节点下面如果引入了中文路径解析出来的错误信息就完全不可读。我的解决方案是在 Python 侧读取日志时用errorsreplace来兜底同时在 Unity 侧输出错误信息时统一转成 UTF-8 的 JSON 格式这样解析循环里就不会出现两种编码打架的情况。如果确实遇到无法修复的乱码就直接把原始字节段丢弃宁可少一段日志也不让错误定位被污染。另外日志文件的滚动模式也能影响解析。Unity 默认的日志是不会自动滚动的跑久了文件体积很大Agent 读取最后 200 行还好如果要分析全量日志就会非常慢。建议在启动参数里或脚本初始化时指定一个固定大小的循环日志文件。我这边是用 PowerShell 定期清理后来觉得麻烦直接在代码里加了日志文件裁剪函数超过 5MB 就备份旧文件并重新开始写。4.4 资源导入与测试运行顺序冲突最后要单独说一个很隐蔽的坑。Unity 的AssetDatabase.Refresh和测试运行框架会竞争资源导入线程如果在刷新还未完全结束时立刻跑测试很多临时资源会加载不到测试用例直接报Prefab 找不到之类的问题。最开始我没有意识到这点很多自动跑批的测试偶发失败排查了很久。后来在 C# 侧引入了AssetDatabase.IsDoneImporting判断并且等待 2 秒缓冲期确保资源导入彻底结束再进入测试状态机。public static void WaitForImportDone() { while (!AssetDatabase.IsDoneImporting) { Thread.Sleep(200); } Thread.Sleep(2000); }这里千万不要在主线程直接用while死等因为在编辑模式下AssetDatabase.IsDoneImporting要等导入线程跑完才返回主线程死等会导致 UI 失去响应更严重的是可能阻塞导入线程的回调。建议放到协程或者自定义队列里处理。实际落地时我在EditorApplication.update里做这个状态机每帧检查一次比Thread.Sleep可靠得多。5. 常见问题速查表排查过一轮之后我把比较典型的坑整理成了表格这对我后续做机器人流程自动化很有用也方便团队其他人快速定位问题。现象可能原因解决方法编辑器启动后 HTTP 服务不响应端口被占用或脚本被安全策略阻止修改端口或检查Listen前缀使用netstat -ano查端口占用编译结果只返回 0 条错误但显示失败编译失败发生在CompilationPipeline可感知范围之外切换成日志解析模式读取agent_editor.log尾部并按正则提取Agent 修改代码后编译无变化Unity 工作区有未保存的脚本触发前先调用AssetDatabase.SaveAssets并延迟 2 秒等待刷新日志信息出现大量乱码Windows ANSI 编码与 UTF-8 冲突Python 读取时用errorsreplaceUnity 侧统一 JSON UTF-8 输出多个任务并发执行时 Library 目录冲突两个 Unity 进程同时操作同一工程加互斥锁保证同一时间只有一个进程操作该工程PlayMode 测试偶发 Prefab 找不到资源导入未完成导致测试启动过早等待AssetDatabase.IsDoneImporting为真后再运行测试队列长时间跑批后编辑器内存过高编辑器开着图形窗口资源未释放换成-batchmode启动定期重启编辑器进程测试堆栈信息缺失导致 Agent 无从分析只取了精简堆栈失败用例单独保存完整堆栈文件路径返回给 Agent 读取这张表是我在实战中沉淀下来的花了大半周才把各种异常都摸透。尤其是那条Agent 修改代码后编译无变化现在几乎每个接入这套系统的人都会问。实际上大多是源文件在编辑器里被标记了脏状态或者旧程序集没有被刷新直接把进程重启一遍最省心。注意如果遇到 Unity 在批处理模式下 UI 测试全部报错首先检查-nographics是否被开启不要盲目认为是测试代码本身有问题。还有一个容易被忽略的隐患是环境变量传递。启动 Unity 子进程时如果 Python 当前环境里设置了特殊的LIB_PATH或者DOTNET_ROOT会直接影响 Unity 的托管 DLL 解析。我这边处理方式是启动时清空与 .NET 相关的环境变量只保留系统默认值。别小看这个细节项目里有一段时间老是报FileNotFound异常排查到最后就是因为环境中被注入了错误的DOTNET_ROOT导致 Unity 找不到运行时。6. 这套工具链后续还能怎么扩展整个系统跑稳之后我的使用频率高了不少现在每天进公司第一件事就是给 Agent 发一条消息检查今天的改动跑一遍编译和主要测试有失败直接把修复补丁提给我。它会在后台把事情先做完等我到工位的时候编译结果和测试报告已经整齐摆在桌面上。如果你想在这个基础上继续扩展我个人觉得有几个方向很值得做一是把 AI Agent 接入到 Git 提交环节让它能自动分析 diff 对测试的影响范围从而做到只跑受影响模块的测试而不是全量跑二是把结果上报到企业微信或者 Slack这样出差时也能收到质量反馈三是把 Unity 侧的信令系统改成消息队列让多个工程共享同一个 Agent 队列排队执行编译任务避免同一台机器上多个 Unity 互抢资源四是把自定义的 code review 规则做成配置文件Agent 在修改代码之后直接套用规则做一次静态检查提前发现问题。我个人在实际操作中的体会是这套工具链给到开发者的不只是省时间更大的价值是让编译-测试-修复这个循环变成真正可观察、可回放的过程。以前出问题经常靠人脑回忆改了什么导致挂掉现在每一步都有日志、有 diff、有测试返回复盘起来非常轻松。最后再分享一个小技巧日志文件不要放在项目目录下建议放到系统临时目录这样既不会污染版本控制也能避免大文件让 IDE 变卡。希望这份实录能帮你少走点弯路。
返回列表