屏幕上显示着一片纯黑,日志里却不断刷出black_screen_detected=True。很多人第一次用大模型控制《上古卷轴:天际》时,都会卡在这个现象上:游戏明明还在后台运行,AI 却看不到任何画面,动作开始乱发,甚至在原地转圈。Neuro 在这里并不是某个神秘产品,而是我用来承载游戏智能体的核心模块名字:它负责把屏幕图像变成动作指令。这篇文章会把 AI 玩上古卷轴的完整链路拆开,从截图、视觉模型推理、动作执行到黑屏问题的诊断与自动恢复,给出可以直接落地的实现和排查路径。
如果你正在做 AI Agent 应用开发,或者想用多模态大模型控制 GUI,这篇内容同样适用。黑屏不是一个孤立的小 bug,它通常暴露了采集、模型、执行三层之间的协作问题。文章会从最小可运行代码讲起,再逐步进入黑屏根因分类、诊断脚本和状态机恢复,最后给出生产化建议。
1. 先理解 Neuro 的“感知-决策-执行”回路
1.1 Neuro 是什么,为什么用大模型操作游戏
Neuro 这个名字的灵感来自“神经系统”,在游戏 AI 项目里它不是单指某一个神经网络模型,而是整套“感知-决策-执行”循环。感知层拿到屏幕截图,决策层让大模型根据画面和系统提示词输出下一步动作,执行层负责把动作换算成鼠标键盘操作。
上古卷轴这类 3D 游戏相比 2D 游戏,多了一个视角问题。AI 必须理解深度、障碍、地形和 UI,才能不撞墙。这类环境非常适合训练长周期决策,因为地图大、任务多、状态复杂。用大模型当决策层的好处是,不需要针对每个任务写死规则,只要给一段行为约束,模型就能根据画面输出动作。
但这也把风险引了进来:多模态模型并不是可靠的传感器。它可能把暗色场景当成黑屏,可能误解 UI 图标,也可能在连续收到黑图后开始“胡言乱语”。所以 Neuro 架构里,不能只依赖模型判断画面状态,必须在模型之前加一层可计算的图像统计闸门。
1.2 游戏自动化链路中的三层问题
可以把整条链路拆成三层:
| 层 | 输入 | 输出 | 典型问题 |
|---|---|---|---|
| 采集层 | 游戏窗口画面 | OpenCV 图像帧 | 窗口最小化、分辨率变化、黑屏 |
| 决策层 | 图像帧 + 系统提示词 | JSON 动作指令 | 模型幻觉、上下文超长、返回非法 JSON |
| 执行层 | 动作指令 | 鼠标键盘操作 | 按键被系统拦截、持续按键过久 |
黑屏问题可能发生在任意一层。比如采集区域没有对准游戏窗口,截图内容就是黑的;游戏渲染出错,画面本身就是黑的;模型收到正常图像但输出“画面是黑色”,这是模型幻觉。排查时,第一步永远不是换更强的模型,而是先确认黑屏发生在哪一层。
1.3 带黑屏检测的最小流程
为了让黑屏可以量化,我们不会直接让模型判断黑不黑,而是在进入模型前用图像统计做一道闸门。平均亮度低于阈值且标准差很低,说明画面大概率是黑屏;反过来,如果统计正常但模型仍然说黑屏,则是模型幻觉。
最小流程如下:
- 按固定区域截图。
- 计算灰度图的平均亮度和标准差。
- 如果统计值判定为黑屏,则跳过模型推理,直接进入等待或恢复逻辑。
- 如果画面正常,调用视觉语言模型,要求输出 JSON 动作。
- 执行动作,等待一个固定间隔,再回到第 1 步。
这个流程能避免大量无效推理,也给了后续自动恢复一个明确的判断依据。
2. 环境准备:把工具链先固定下来
2.1 硬件与软件环境要求
在学习环境里跑通这个项目,不需要高性能显卡。只要视觉模型能跑起来,CPU 推理慢一点也能验证流程。建议环境如下:
| 软件 | 版本示例 | 用途 |
|---|---|---|
| Python | 3.10+ | 运行整个 Agent |
| OpenCV | 4.9.0 | 图像缩放、灰度转换、亮度统计 |
| Pillow | 10.2.0 | 截图转图像数组 |
| pyautogui | 0.9.54 | 鼠标键盘模拟 |
| OpenAI SDK | 1.35+ | 调用视觉语言模型,兼容本地 Ollama 接口 |
如果使用本地模型,Ollama 默认会提供一个 OpenAI 兼容接口,地址通常是http://127.0.0.1:11434/v1。模型名称以本地实际拉取的视觉模型为准,比如llava:7b、qwen2.5vl:7b这类支持图像输入的模型。落地前要重新确认版本,因为模型仓库更新很快。
2.2 依赖安装与项目结构
先创建虚拟环境并安装依赖:
python -m venv .venv source .venv/bin/activate pip install opencv-python==4.9.0.80 Pillow==10.2.0 numpy==1.26.4 pyautogui==0.9.54 openai==1.35.7项目结构可以这样组织:
skyrim_ai_agent/ ├── agent_config.json ├── requirements.txt ├── main.py ├── core/ │ ├── __init__.py │ ├── screen_capture.py │ ├── black_screen.py │ ├── llm_controller.py │ └── action_executor.py └── logs/main.py负责组装各模块,core目录放具体实现,agent_config.json存所有可变参数。为什么要把参数放配置而不是写死在代码里?因为黑屏检测阈值、模型地址、动作映射都属于运行环境相关,改动配置比改代码安全得多。
2.3 配置文件的字段设计
下面这份agent_config.json是后面所有代码的基础:
{ "capture_region": [0, 0, 1920, 1080], "capture_interval_seconds": 0.5, "black_screen_check": { "brightness_threshold": 12, "std_threshold": 6, "max_black_frames": 10, "recovery_wait": 2.0 }, "llm": { "base_url": "http://127.0.0.1:11434/v1", "model": "llava:7b", "temperature": 0.1, "max_tokens": 256 } }说明一下关键字段:
capture_region:截图的左、上、宽、高。实际项目里应该由窗口定位结果动态填充,而不是写死全屏。brightness_threshold:平均亮度低于多少认为画面偏黑。std_threshold:标准差低于多少认为画面基本没有变化。max_black_frames:连续多少帧黑屏后进入恢复流程。recovery_wait:每次恢复动作之间的等待时间,给游戏渲染留出缓冲。
这些参数直接影响系统灵敏度。阈值调太严,正常暗色场景会被误判为黑屏;调太松,真正的黑屏又会被放过去。建议先用诊断脚本采样几组数据,再定阈值。
3. 实现最小可运行的 AI 玩上古卷轴程序
3.1 截图与黑屏判定
先实现黑屏检测器。这个类不负责判断游戏内容,只负责计算图像统计量。
# core/black_screen.py import cv2 import numpy as np class BlackScreenDetector: def __init__(self, brightness_threshold=12, std_threshold=6): self.brightness_threshold = brightness_threshold self.std_threshold = std_threshold def analyze(self, frame_bgr): gray = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2GRAY) mean_brightness = float(np.mean(gray)) std = float(np.std(gray)) is_black = ( mean_brightness < self.brightness_threshold and std < self.std_threshold ) return { "mean_brightness": round(mean_brightness, 2), "std": round(std, 2), "is_black": is_black, }为什么用两个指标?平均亮度衡量整体明暗,标准差衡量画面是否还有变化。纯黑画面的平均亮度接近 0,标准差也很低;而星空、远处灯光等暗色画面虽然平均亮度低,但标准差会比较高,不会被误判成黑屏。
3.2 截图与窗口定位
最小版本里,我们可以用pyautogui.screenshot按固定区域截图。
# core/screen_capture.py import cv2 import numpy as np import pyautogui class ScreenCapture: def __init__(self, region): # region 格式: [left, top, width, height] self.region = tuple(region) def grab(self): screenshot = pyautogui.screenshot(region=self.region) frame_bgr = cv2.cvtColor(np.array(screenshot), cv2.COLOR_RGB2BGR) return frame_bgr这里有一个重要前提:截图区域必须正好覆盖游戏窗口。实际项目建议先用 pygetwindow 定位窗口句柄,得到窗口矩形,再转成capture_region。如果窗口最小化或移动到副屏,固定区域截图会抓到桌面,图像统计自然就会变成黑屏或低亮度结果。
3.3 调用视觉语言模型输出动作
视觉模型负责把截图变成结构化动作。下面使用 OpenAI SDK 调用兼容接口,方便接本地 Ollama 或在线模型服务。
# core/llm_controller.py import base64 import json import cv2 from openai import OpenAI class LLMController: def __init__(self, llm_config: dict): self.client = OpenAI( base_url=llm_config["base_url"], api_key=llm_config.get("api_key", "unused"), ) self.model = llm_config["model"] self.temperature = llm_config.get("temperature", 0.1) self.max_tokens = llm_config.get("max_tokens", 256) self.prompt = ( "你是 Neuro,正在玩《上古卷轴:天际》。" "根据当前画面输出下一步动作,只输出 JSON。" "action 可选:move_forward, move_back, turn_left, turn_right, jump, interact, wait。" "wait_seconds 表示动作持续秒数。" "如果画面是全黑或游戏正在载入,不要移动,输出 {\"action\":\"wait\",\"wait_seconds\":1.0}。" ) def decide(self, frame_bgr): # 缩到 1024,降低传输带宽和视觉 token 开销 resized = cv2.resize(frame_bgr, (1024, 1024)) _, jpg = cv2.imencode(".jpg", resized, [cv2.IMWRITE_JPEG_QUALITY, 80]) image_b64 = base64.b64encode(jpg.tobytes()).decode("utf-8") resp = self.client.chat.completions.create( model=self.model, temperature=self.temperature, max_tokens=self.max_tokens, messages=[ {"role": "system", "content": self.prompt}, { "role": "user", "content": [ {"type": "text", "text": "请根据截图输出动作 JSON。"}, { "type": "image_url", "image_url": { "url": f"data:image/jpeg;base64,{image_b64}" }, }, ], }, ], response_format={"type": "json_object"}, ) text = resp.choices[0].message.content