在实际动作游戏或策略游戏的 AI 逻辑里,最难的往往不是“看到玩家并走过去”,而是让 AI 根据当前战况决定“下一步应该站在哪里”。Unreal 的 EQS(Environment Query System)就是为解决这类问题而设计的:它把环境中的候选位置抽象成可查询、可过滤、可评分的对象,AI 在行动前先执行一次环境查询,再根据排序结果决定移动、攻击或撤退。Unity 6 没有内置 EQS,但复刻一个足够轻量的版本并不复杂,核心只需要四块:候选点生成、过滤、评分、排序。这篇内容会从零实现一套可在 Unity 6 中运行的 EQS 示例,并把查询结果接入 NavMeshAgent 的移动流程。
1. 为什么 AI 需要 EQS:从 Unreal 到 Unity 的问题映射
1.1 没有查询系统的 AI 是如何“瞎走”的
很多 Unity 项目里的 AI 移动决策会写成这样:提前在场景里摆好若干空物体作为巡逻点,或者当 AI 需要找掩体时,从当前位置向外随机偏移一段距离,再把结果丢给 NavMeshAgent。这种写法在固定场景里能跑通,但只要场景发生变化,或者玩家位置导致某个预设点失效,AI 就会出现三种典型行为:走到墙里、站在原地发呆、反复在两个点之间抖动。
EQS 的逻辑和这种“拍脑袋选点”完全不同。它把“在哪里挑选候选点”抽象成 Generator,把“这个点是否可用”抽象成 Filter 测试,把“这个点相对于目标有多好”抽象成 Score 测试。AI 每次做决策时,只需要提供查询上下文,例如“查询者是谁、目标是谁、当前位置在哪”,系统就会生成一批候选点,过滤掉不可用点,再按分数排序输出结果。
1.2 EQS 的核心概念与 Unity 复刻建议
Unreal 的 EQS 有完整的查询资产、生成器和测试节点体系。Unity 侧没有同等封装,但可以用 C# 接口和 MonoBehaviour 组合出类似结构。理解概念对应关系,比直接抄代码更重要。
| Unreal EQS 概念 | 作用 | Unity 复刻建议 |
|---|---|---|
| UEnvQuery 查询资产 | 配置生成器、测试和权重 | 一个 EQSRunner 组件,持有 Generator 和 Test 列表 |
| Generator 生成器 | 产生候选点 | 实现Generate(output, context)方法的 MonoBehaviour |
| Test 测试 | 过滤候选点或给候选点打分 | 实现Filter和Score方法的 MonoBehaviour |
| Context 上下文 | 提供查询者和目标的位置信息 | 使用EQSQueryContext或自定义数据结构 |
| QueryResult 查询结果 | 排序后的候选点集合 | 使用List<EQSItem>,按分数降序排列 |
这套映射不会和 Unreal 完全一致,但已经足够覆盖多数 AI 场景:掩体选择、狙击点评估、巡逻点生成、逃跑方向选择。
1.3 一个典型场景:AI 寻找掩体
假设玩家在掩体后方射击,AI 需要选择一个既能躲避玩家视线、又不会离玩家太远的掩体。传统做法很难同时满足两个条件,因为“距离合适”和“对方看不见我”是两个互相冲突的评估维度。EQS 的做法是先生成一批候选点,再用距离测试计算“这个点距离目标 8 到 12 米”的得分,用视线测试过滤掉“站在这里会被玩家直接看到”的点,最后对剩余点做遮挡加分。这样,AI 的移动目标就不是随机点,而是经过环境验证的“最优解”。
2. 在 Unity 6 中搭建 EQS 的基础框架
2.1 环境准备:Unity 6 项目与 AI 导航依赖
建议使用 Unity 6 的 3D 模板创建项目,内置渲染管线或 URP 都可以。EQS 本身不依赖渲染管线,但下面的示例会让 AI 在地形上移动,所以需要 NavMeshAgent 和一个已经烘焙好的导航网格。
在 Unity 6 中,导航相关模块已经从内置模块调整为com.unity.ai.navigation包。新建项目后,如果找不到Window > AI > Navigation菜单,说明包还没有安装。在 Package Manager 搜索 “AI Navigation” 并安装即可。安装后,场景中需要烘焙 NavMesh:把地面设置成Navigation Static,打开 Navigation 窗口,点击 Bake。没有 NavMesh,NavMeshAgent 就无法寻路,EQS 选出的候选点也无法到达。
环境检查清单:
- Unity 6 项目已创建,模板为 3D。
com.unity.ai.navigation包已安装。- 场景地面已标记 Navigation Static 并成功烘焙。
- 场景中有 NavMeshAgent 对象和玩家目标对象。
- 测试脚本所在目录有脚本引用,命名空间一致。
2.2 EQS 查询的执行链路
一次 EQS 查询按以下顺序执行:
- 构建查询上下文,包含查询者 Transform、目标 Transform、查询者位置和目标位置。
- 遍历所有 Generator,向候选集合中添加候选点。
- 遍历所有 Filter 测试,删除不满足条件的候选点。
- 遍历所有 Score 测试,对剩余候选点计算加权总分数。
- 按分数从高到低排序,返回结果。
整个过程是同步的。在编辑器里使用没问题,但如果查询频率很高,或者候选点很多,就要考虑把过滤和评分放入 Job System。这一点在第 5 章专门讨论。
2.3 最小接口设计
为了不依赖具体项目,先定义三个基础类型:候选点结构体、查询上下文、生成器与测试接口。
using System; using System.Collections.Generic; using UnityEngine; namespace EQSLite.Core { [Serializable] public struct EQSItem { public Vector3 position; public Vector3 direction; public float score; public EQSItem(Vector3 position, Vector3 direction) { this.position = position; this.direction = direction; this.score = 0f; } } public class EQSQueryContext { public Transform querier; public Transform target; public Vector3 querierPosition; public Vector3 targetPosition; public float time; } public interface IEQSGenerator { void Generate(List<EQSItem> output, EQSQueryContext context); } public interface IEQSTest { bool IsFilter { get; } float Weight { get; } bool Filter(EQSItem item, EQSQueryContext context); float Score(EQSItem item, EQSQueryContext context); } }这里把EQSItem设计成结构体而不是类,主要原因是候选点通常会批量生成,用结构体可以减少小对象在堆上分配,降低 GC 压力。后续如果需要附加更多数据,可以在结构体里增加字段,或者用字典保存附加信息。
EQSQueryContext是查询的输入上下文,包含位置和目标引用。实际项目中可以换成自己的黑板对象,只要保证查询时能拿到所需数据即可。
3. 从零实现一个可运行的 EQS 查询流程
3.1 生成器:在掩体周边生成候选点
第一个功能是生成候选点。示例实现一个围绕查询者,在指定半径内随机采样的生成器。因为 AI 最终要移动,所以每个候选点都用NavMesh.SamplePosition做一次地形校验,尽可能保证点落在可走区域。
using System.Collections.Generic; using UnityEngine; using UnityEngine.AI; using EQSLite.Core; public class CoverPointGenerator : MonoBehaviour, IEQSGenerator { public int sampleCount = 32; public float radius = 8f; public float minDistance = 2f; public float maxDistance = 12f; public Vector3 heightOffset = new Vector3(0f, 0.2f, 0f); public void Generate(List<EQSItem> output, EQSQueryContext context) { if (context.querier == null) return; int generated = 0; int attempts = 0; while (generated < sampleCount && attempts < sampleCount * 4) { attempts++; Vector2 circle = Random.insideUnitCircle * radius; Vector3 candidate = context.querierPosition + new Vector3(circle.x, 0f, circle.y) + heightOffset; if (NavMesh.SamplePosition(candidate, out NavMeshHit hit, 1f, NavMesh.AllAreas)) { float distance = Vector3.Distance(hit.position, context.querierPosition); if (distance < minDistance || distance > maxDistance) continue; output.Add(new EQSItem(hit.position, hit.normal)); generated++; } } } }这段代码有两个关键点。第一,attempts < sampleCount * 4是保护条件,避免采样区域里大量位置不可走时无限循环。第二,NavMesh.SamplePosition的maxDistance参数不能太大,否则候选点可能被修正到离原始采样点很远的位置,导致采样区域失真。
如果希望每次查询结果稳定,在生成前可以调用Random.InitState(seed)固定种子,否则同一场景每次运行时随机点会变化。这个技巧第 5 章会继续讲。
3.2 过滤测试:去掉被遮挡和不可用点
过滤测试只做一件事:候选点不满足条件时直接标记为不可用。示例实现一个视线过滤测试,检查候选点与目标之间是否有障碍物遮挡。如果 AI 要选择射击位置,这个测试能排除掉“虽然很近但躲在墙后”的位置。
using UnityEngine; using EQSLite.Core; public class LineOfSightFilter : MonoBehaviour, IEQSTest { public LayerMask blockMask; public Vector3 rayHeightOffset = Vector3.up * 0.5f; public bool IsFilter => true; public float Weight => 1f; public bool Filter(EQSItem item, EQSQueryContext context) { if (context.target == null) return true; Vector3 start = item.position + rayHeightOffset; Vector3 end = context.targetPosition + rayHeightOffset; return !Physics.Linecast(start, end, blockMask); } }这里把射线起点抬高 0.5 米,是为了模拟角色上半身的视线,不是从脚底发射。blockMask需要配置成场景中的墙体和障碍物层。如果目标为 null,就不过滤,逻辑上可以理解成“没有目标时不限制位置”,但实际项目中更推荐让过滤返回 false,避免查询出无效点。
类似地,可以再实现一个距离过滤测试,把离目标过近或过远的点直接去掉,避免 AI 跑到玩家脸上或者走到完全够不着的位置。
3.3 评分测试:距离和遮挡的多目标打分
过滤测试决定“能不能用”,评分测试决定“哪个更好”。一个常见需求是:AI 希望找到距离目标 10 米左右的点。距离太近不够安全,太远又不利于反击。这个需求用距离评分测试实现。
using UnityEngine; using EQSLite.Core; public class DistanceScorer : MonoBehaviour, IEQSTest { public AnimationCurve curve = AnimationCurve.Linear(0f, 0f, 1f, 1f); public float optimalDistance = 10f; public float tolerance = 3f; public float weight = 1f; public bool IsFilter => false; public float Weight => weight; public float Score(EQSItem item, EQSQueryContext context) { float distance = Vector3.Distance(item.position, context.targetPosition); float t = Mathf.InverseLerp( optimalDistance - tolerance, optimalDistance + tolerance, distance); return curve.Evaluate(Mathf.Clamp01(t)); } }optimalDistance是理想距离,tolerance是允许波动的范围。当候选点正好在 10 米时,t = 0.5,曲线值由AnimationCurve决定。这里用曲线而不是直接用线性距离,是为了让项目策划或开发者可以在不修改代码的情况下调整“距离与得分的映射关系”。
再实现一个遮挡评分器:如果候选点与目标之间有障碍物,说明这个位置具有一定遮挡价值,得 1 分,否则得 0 分。
using UnityEngine; using EQSLite.Core; public class CoverScoreTest : MonoBehaviour, IEQSTest { public LayerMask coverMask; public Vector3 offsetUp = Vector3.up * 0.5f; public float weight = 1f; public bool IsFilter => false; public float Weight => weight; public float Score(EQSItem item, EQSQueryContext context) { if (context.target == null) return 0f; Vector3 start = item.position + offsetUp; Vector3 end = context.targetPosition + offsetUp; return Physics.Linecast(start, end, coverMask) ? 1f : 0f; } }实际项目中,遮挡评分还可以考虑“遮挡物到候选点的距离”。障碍物离候选点太近,AI 贴墙移动容易卡住,反而应该降分。这里保持最简单版本,后面可以根据项目继续加分层级。
3.4 查询运行器:组合生成器与测试
生成的候选点、过滤测试和评分测试需要由统一入口调度。EQSRunner负责执行整条查询链路,并返回排序后的结果。
using System.Collections.Generic; using UnityEngine; using EQSLite.Core; public class EQSRunner : MonoBehaviour { public EQSQueryContext sharedContext = new EQSQueryContext(); public List<MonoBehaviour> generators = new List<MonoBehaviour>(); public List<MonoBehaviour> tests = new List<MonoBehaviour>(); private List<EQSItem> candidates = new List<EQSItem>(64); private List<EQSItem> result = new List<EQSItem>(64); public List<EQSItem> lastResult; public List<EQSItem> Run(EQSQueryContext context) { candidates.Clear(); result.Clear(); if (context.querier != null) context.querierPosition = context.querier.position; if (context.target != null) context.targetPosition = context.target.position; for (int i = 0; i < generators.Count; i++) { var generator = generators[i] as IEQSGenerator; if (generator != null) generator.Generate(candidates, context); } for (int i = 0; i < candidates.Count; i++) { var item = candidates[i]; bool passed = true; for (int j = 0; j < tests.Count; j++) { var test = tests[j] as IEQSTest; if (test != null && test.IsFilter && !test.Filter(item, context)) { passed = false; break; } } if (passed) { item.score = 0f; result.Add(item); } } for (int i = 0; i < result.Count; i++) { var item = result[i]; float totalScore = 0f; for (int j = 0; j < tests.Count; j++) { var test = tests[j] as IEQSTest; if (test != null && !test.IsFilter) totalScore += test.Score(item, context) * test.Weight; } item.score = totalScore; result[i] = item; } result.Sort((a, b) => b.score.CompareTo(a.score)); lastResult = result; return result; } }这里使用两个私有 List 复用内存,避免每帧查询都产生新 List。需要特别注意的是,result作为返回值,在下一次Run时会被清理并复用。如果外部要长期保存结果,应该拷贝到自己的 List,而不是直接持有引用。
sharedContext字段只是方便在 Inspector 里查看;实际调用时应该传入包含当前查询者的上下文,或者在Run内动态更新。
4. 把 EQS 查询结果接入 AI 移动决策
4.1 设计一个最小 AI 状态机
EQS 查询本身不决定 AI 行为,它只是输出一个候选位置列表。真正让 AI 动起来的是上层状态机。设计一个简单但完整的状态流程:
- 等待查询冷却时间。
- 执行 EQS 查询。
- 把最优候选点设为 NavMeshAgent 目的地。
- 到达后进入掩体状态,停留一段时间。
- 冷却结束后重新查询。
using System.Collections.Generic; using UnityEngine; using UnityEngine.AI; using EQSLite.Core; [RequireComponent(typeof(NavMeshAgent))] public class EQSAIController : MonoBehaviour { public EQSRunner eqs; public NavMeshAgent agent; public Transform playerTarget; public EQSQueryContext queryContext = new EQSQueryContext(); public float queryInterval = 2f; public float inCoverTime = 3f; public float minMoveDistance = 1.5f; private enum AIState { WaitForQuery, MoveToResult, InCover } private AIState state = AIState.WaitForQuery; private float timer; private Vector3 currentCoverPosition; private List<EQSItem> queryResult; void Start() { agent = GetComponent<NavMeshAgent>(); timer = queryInterval; } void Update() { switch (state) { case AIState.WaitForQuery: timer -= Time.deltaTime; if (timer <= 0f) { RunQuery(); timer = queryInterval; } break; case AIState.MoveToResult: if (!agent.pathPending && agent.remainingDistance <= agent.stoppingDistance) { state = AIState.InCover; timer = inCoverTime; } break; case AIState.InCover: timer -= Time.deltaTime; if (timer <= 0f) state = AIState.WaitForQuery; break; } } void RunQuery() { if (eqs == null || playerTarget == null) return; queryContext.querier = transform; queryContext.target = playerTarget; queryContext.querierPosition = transform.position; queryContext.targetPosition = playerTarget.position; queryResult = eqs.Run(queryContext); if (queryResult == null || queryResult.Count == 0) { state = AIState.WaitForQuery; return; } Vector3 destination = queryResult[0].position; if (Vector3.Distance(destination, currentCoverPosition) < minMoveDistance && state == AIState.MoveToResult) { state = AIState.InCover; return; } currentCoverPosition = destination; agent.SetDestination(destination); state = AIState.MoveToResult; } }minMoveDistance的作用是防止 AI 在两个相近候选点之间来回切换。Agent进入 Move 状态后,如果新目标离当前目标太近,直接进入 InCover,而不是再次发起寻路。这个机制对减少抖动非常重要。
4.2 查询结果为空时的降级策略
EQS 查询结果可能为空,例如所有候选点都被过滤测试排除。最常见的错误处理是直接让 AI 原地等待,但生产环境中应该有一个降级路径。
常见的降级策略包括:
- 使用上一次有效结果,直到新结果可用。
- 关闭部分过滤条件后重新查询。
- 直接移动到玩家目标的反向最近 NavMesh 点。
- 进入“找不到位置”状态,然后播放搜索动画。
降级逻辑放到RunQuery返回后判断最合适。不要在EQSRunner内部处理,因为不同 AI 的降级策略差异很大。
4.3 可视化调试:在 Scene 视图查看候选点
EQS 这类系统最怕看不见内部过程。在EQSRunner中增加OnDrawGizmosSelected,把上次查询结果画出来。
private void OnDrawGizmosSelected() { if (lastResult == null || lastResult.Count == 0) return; for (int i = 0; i < lastResult.Count; i++) { var item = lastResult[i]; float t = Mathf.Clamp01(item.score); Gizmos.color = Color.Lerp(Color.red, Color.green, t); Gizmos.DrawSphere(item.position, 0.25f); } if (lastResult.Count > 0) { Gizmos.color = Color.cyan; Gizmos.DrawLine(transform.position, lastResult[0].position); } }这样选中场景里的 EQSRunner 对象后,就能看到候选点的分数分布。红色表示低分,绿色表示高分,青色连线指向最优选择。这一步对第 5 章排查问题非常有用。
5. 常见问题与排查路径
5.1 候选点全部被过滤,查询结果为空
现象:AI 不移动,Scene 视图里没有任何点,或生成的点全部消失。
排查顺序:
- 检查
EQSQueryContext是否填充了querier和target。如果target为 null,视线过滤会进入预置逻辑。 - 检查过滤测试的
LayerMask是否包含了地面或墙体的错误层。Physics.Linecast如果打到了不该打的地面层,过滤结果会异常。 - 检查
NavMesh.SamplePosition是否成功。候选点如果都在未烘焙区域,生成数为 0。 - 把过滤测试的
IsFilter返回临时改成 false,观察生成点是否恢复。
常见原因表:
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 生成器没有输出 | 未烘焙 NavMesh 或 LayerMask 错误 | 检查 Navigation 窗口、Gizmos | 重新 Bake 导航网格 |
| 点全部被过滤 | Linecast 误击地面 | 临时关闭过滤测试 | 调整 blockMask 层级 |
| 查询偶尔为空 | 随机采样区域不可走 | 在 OnDrawGizmos 中绘制所有原始点 | 增加采样尝试次数 |
5.2 查询结果抖动,AI 在两个点之间反复移动
现象:AI 明明已经到达一个掩体,下一秒又跑回原来的点。
原因通常是查询频率太高、随机采样导致每次候选点不同、两个候选点得分非常接近。
解决方案有三层:
第一层,增加查询冷却时间。把queryInterval从 0.5 秒提高到 2 秒以上,避免每帧查询。
第二层,增加最小切换距离。在EQSAIController.RunQuery中,如果新结果和当前目标距离过近,不执行移动。
第三层,固定随机种子。同一个 AI 在查询时使用稳定种子,避免每次生成的候选点集合差异过大。
Random.InitState(Mathf.RoundToInt(transform.position.x * 100 + transform.position.z));注意随机种子不能全局统一,否则多个 AI 的候选点会完全相同,看起来像排队。
5.3 性能消耗过高,出现 GC 和卡顿
现象:候选点数量提升到 64 或 128 后,帧率明显下降,Profiler 里出现频繁的 GC Alloc。
原因可能来自三处:
EQSRunner内部使用 List,但外部每帧调用时又拷贝了列表。Physics.Linecast高频调用,每个候选点都发射射线。- 评分或过滤逻辑里使用了 LINQ,例如
Where、OrderByDescending。
优化方向:
// 不推荐:每次查询都产生新分配 public List<EQSItem> RunWithLinq(EQSQueryContext context) { return generated .Where(item => allTests.All(t => t.Filter(item, context))) .OrderByDescending(item => allTests.Sum(t => t.Score(item, context))) .ToList(); }推荐做法是复用 List,避免 LINQ,并把射线检测放进RaycastCommand配合 Job System。起步阶段可以先限制候选点数量,例如 32 个点、每 2 秒查询一次,绝大多数手机项目可以接受。
性能预算参考:
| 场景规模 | 候选点数量 | 查询频率 | 建议 |
|---|---|---|---|
| 小型 Demo | 16 | 每秒 2 次 | 直接同步查询,无需优化 |
| 中型关卡 | 64 | 每秒 1 次 | 复用 List,控制采样范围 |
| 大型开放场景 | 256 | 每 2 秒 1 次 | 使用 Job System 并行执行射线检测 |
5.4 Unity 6 中导航 API 和包依赖问题
Unity 6 项目中,如果脚本报错:
The type or namespace name 'NavMesh' does not exist in the namespace 'UnityEngine.AI'原因通常是没有安装 AI Navigation 包。打开 Package Manager,搜索AI Navigation,安装后重新编译。安装完成后,NavMeshAgent、NavMeshHit、NavMesh.AllAreas这些 API 都可以继续使用。
需要区分的是,UnityEngine.AI.NavMesh仍然保留,但导航网格的烘焙流程已经迁移到 AI Navigation 包。旧项目升级到 Unity 6 后,最好重新 Bake 一次 NavMesh,否则部分区域的可行走数据可能不一致。
5.5 Gizmos 看不到查询点
如果OnDrawGizmosSelected没有生效,先确认是否选中了EQSRunner所在物体。OnDrawGizmosSelected只在物体被选中时绘制,调试阶段比较适合。如果想一直显示,用OnDrawGizmos代替。另外lastResult在非运行时为空,必须在查询完成后才能看到点。
6. 最佳实践与扩展方向
6.1 学习环境与生产环境的差异
在编辑器里调试 EQS,可以依赖 Gizmos、随机种子和同步查询,因为方便观察数据流。但进入生产环境后,这套代码还需要考虑性能、可配置性和异常保护。
| 维度 | 学习环境 | 生产环境 |
|---|---|---|
| 查询结果 | 同步返回 List | 可异步、可缓存结果 |
| 调试信息 | Gizmos 常开 | 用宏或开关控制 |
| 随机种子 | 固定种子便于复现 | 随机种子避免 AI 行为雷同 |
| 候选点数量 | 可大范围采样 | 按性能预算限制 |
| 上下文来源 | 手动填充 | 从行为树黑板或 ECS 组件读取 |
| 过滤测试 | 单个 LayerMask | 支持多 Layer 组合和 Editor 配置 |
不要把编辑器调试用的lastResult暴露给运行时 AI 使用。运行时应该把查询结果拷贝到 AI 控制器自己的字段中。
6.2 接入 EQS 时的检查清单
新功能接入 EQS 前,按下面清单逐项确认:
- 查询上下文中的
querierPosition和targetPosition已正确赋值。 - 生成器产生的点已经通过
NavMesh.SamplePosition或类似方式校验。 - 过滤测试返回 false 时,确认候选点会被移除,而不是只降低分数。
- 评分测试返回值的范围已经归一化到 0 到 1。
- 多个评分测试的权重总和经过设计,而不是随意填。
- 查询结果按分数降序排列,索引 0 是最优项。
- 查询冷却时间和最小切换距离已配置。
- 查询结果为空时,存在降级策略。
- 编辑器调试开关和生产环境编译宏已分离。
- 候选点和测试数量在性能预算范围内。
6.3 扩展方向:Job System、行为树和编辑器工具
当前示例是同步查询,适合小型项目和初学理解。随着 AI 数量和场景复杂度提升,可以按以下路径扩展:
第一,把过滤和评分计算改成RaycastCommand和NativeArray,配合 Job System 并行执行。Physics.Raycast在候选点很多时是主要瓶颈,改用批量命令可以明显降低 CPU 消耗。
第二,把查询配置改成 ScriptableObject。同一个 EQSRunner 可以在 Inspector 里引用不同的 Query 资产,例如“进攻查询”“逃跑查询”“巡逻查询”。这样不同 AI 可以复用同一套执行代码,只需要换配置。
第三,把查询结果接入行为树。Unity 中常用的行为树插件都能自定义 Action 或 Task,可以直接在 Task 中调用EQSRunner.Run,把结果写入黑板变量,再由移动 Task 读取。
第四,结合 Unity ECS。EQS 查询结果是典型的临时数据,在 ECS 架构中更适合放在NativeList<EQSItem>中,由 Job 处理,避免主线程同步等待。
建议新手先不要直接跳到 Job System 优化,而是先完成一个完整的同步链路:生成候选点、过滤、评分、移动到最优点。把这条链路跑通,再根据 Profiler 数据决定是否优化。EQS 的价值不在于代码多么炫酷,而在于让 AI 的环境决策变得可组合、可调试、可复用。