LLM Agent实战:用大模型控制《矮人要塞》的完整指南

📅 2026/8/27 22:30:18
LLM Agent实战:用大模型控制《矮人要塞》的完整指南
欢迎阅读这篇技术博客。最近在调研 LLM Agent 落地场景时我把目光放到了一个非常硬核的游戏上《Dwarf Fortress》矮人要塞。这款游戏以极复杂的模拟系统、糟糕的入门体验和丰富的玩法闻名甚至很多玩家说“上手难度比编程还高”。但正因为它的状态空间巨大、反馈延迟高、任务目标多样非常适合用来测试 Agent 的记忆能力、决策能力和工具调用能力。本文会完整拆解我设计的一个 Dwarf Fortress 玩家 Agent从整体架构到核心代码再到常见坑点。如果你正在学习 LLM 应用开发、Agent 框架或者单纯想做一个有点意思的 AI 项目这篇实战笔记应该能提供不少可复用的思路。1. 背景与核心概念1.1 为什么选择 Dwarf FortressDwarf Fortress 是一款免费的模拟经营/ Roguelike 游戏Steam 版发售后又吸引了一波新玩家。游戏内每一个矮人都有自己的性格、技能、社交关系地图中的石头、树木、水流都会逐步模拟还会出现天气变化、动物迁徙、敌对势力入侵等复杂事件。和很多回合制游戏不同Dwarf Fortress 没有明确的“胜利条件”。玩家要管理一个矮人聚落从采矿、种植、酿造、打造装备到应对各种突发事件。对于一个 AI Agent 来说这是一个极具挑战性的环境状态空间极大地图、物品、单位、任务、技能、心情信息量远超普通游戏。动作空间也很广键盘快捷键非常多Steam 版还有鼠标操作。反馈周期长一个指令可能影响几十个游戏内“帧”之后的状态很难用简单的奖励函数驱动。没有标准 API除了 DFHack 这类插件普通 Agent 获取游戏状态需要通过截图、日志、内存读取等多种方式组合。正因为如此Dwarf Fortress 比围棋、Atari 游戏更适合用来研究“真实世界中的 Agent 决策”而不是单纯的“感知-行动”模式匹配。1.2 什么是 LLM AgentLLM Agent 可以理解为一个“具备感知、思考、行动能力的 LLM 程序”。普通 LLM 调用只是“输入文本输出文本”Agent 则会在一个循环中不断做以下事情从环境中获取当前状态。把状态转换成文本或结构化的上下文。让 LLM 根据上下文和目标生成下一步决策。把决策转换成真实的动作。观察动作执行后的结果进入下一轮。在 Dwarf Fortress 这个场景中Agent 需要把屏幕上的像素信息变成 LLM 能理解的文本再把 LLM 输出的意图变成键盘或鼠标事件。换句话说这就是一个“视觉-语言-动作”闭环系统。1.3 本文的核心范围本文不会去写一个能通关 Dwarf Fortress 的完整 AI那是顶级研究团队才能完成的事。我会聚焦在最基础、但也是最重要的闭环上如何获取游戏状态并转换给 LLM。如何设计 Agent 的“思考”流程让 LLM 输出可执行的动作。如何执行动作并引入反馈。以及在这个过程中最常见的坑和最佳实践。你可以把本文当作一个“LLM Agent 实战脚手架”。换成其他游戏或模拟环境思路也完全适用。2. 整体架构从游戏状态到 LLM 决策再到行动2.1 分层设计在开始写代码之前我建议先把 Agent 拆成四个相对独立的模块感知层Perception负责从游戏窗口中获取屏幕截图、读取 DFHack 日志或游戏提示信息最终输出结构化的状态文本。理解层Understanding把感知层的数据整理成 LLM 的 prompt。比如提取当前选中的矮人、当前任务、最近的日志、库存信息。决策层Decision调用 LLM生成一个包含动作类型和参数的 JSON 指令。行动层Action解析 JSON调用键盘/鼠标控制库或者直接通过 DFHack 命令执行动作。这样分层有个好处每一层都可以单独替换。比如你不一定用 OCR可以用 DFHack 提供的 JSON 接口不一定要用 OpenAI可以换成本地部署的 Qwen、Llama 等模型。2.2 核心循环Agent 运行时的主循环可以写成伪代码while True: state perceive() context build_context(state, memory) decision llm_reason(context, goal) if decision[type] action: execute_action(decision) else: update_goal(decision) memory.add(decision) sleep(0.5)这个循环看起来简单真正需要考虑的是感知是否稳定会不会出现 OCR 误读。上下文是否超长如何裁剪。LLM 输出是不是结构化的解析失败怎么办。动作执行后如何确认有没有生效是否需要重试。记忆如何组织避免 Agent 忘记目标。后面我会逐一展开。2.3 为什么不能用纯游戏脚本有些人可能会说Dwarf Fortress 本身可以通过 DFHack 写脚本或者用按键宏为什么还需要 LLM这是两个层面的问题。DFHack 脚本适合“规则明确”的自动化比如自动砍树、自动挖矿。但游戏里很多决策是模糊的比如“矮人心情不好了应该建一个酒馆还是种一片花”“敌人来了应该是发武器还是撤退”这些问题没有固定答案需要结合当前资源和长期目标来推理。LLM 在这里承担的是“策略决策”角色。它不是简单映射而是把自然语言描述的复杂目标分解成子任务。比如目标建立一个稳定的铁矿来源 子任务1. 派采矿工去指定区域 2. 建造熔炉 3. 设置焦炭生产 4. 保证铁矿储运这个过程适合 LLM但执行层面仍然需要脚本、快捷键和 DFHack 来快速完成重复操作。3. 环境准备与版本说明3.1 运行环境本文示例以 Windows Python 3.10 环境为例但代码本身跨平台性较好。如果你使用 Linux屏幕截图和键盘模拟部分需要替换成对应库。需要准备的工具和依赖大致如下Dwarf Fortress 游戏本体推荐 Steam 版窗口模式运行。Python 3.10。mss高性能屏幕截图。pytesseractOCR 识别底层依赖 Tesseract OCR。opencv-python图像预处理提升 OCR 准确率。pyautogui模拟键盘鼠标。openai调用 OpenAI 兼容的 LLM 接口。pydantic定义结构化的 LLM 输出格式。可选requests如果你不使用 SDK也可以直接调用 HTTP API。版本方面我没有写死因为 LLM 生态更新很快。你要是用本地模型只需保证 OpenAI SDK 和你的服务端兼容即可。3.2 游戏端设置为了减少 OCR 压力建议在游戏设置里将分辨率固定在一个较小的窗口比如 1280x720。使用经典 ASCII 或青灰色配色避免花哨的彩色字体干扰识别。打开游戏内日志显示让 Agent 能读到“Urist cancels mine: Strange mood”这类信息。暂停游戏让 Agent 有充足时间决策。Dwarf Fortress 默认是实时模拟但对 Agent 来说初期最好以“暂停-思考-执行-恢复”的方式运转否则动作未执行完游戏状态就变了。3.3 安装依赖下面命令可以快速安装核心依赖pip install mss pytesseract opencv-python pyautogui openai pydantic另外Tesseract OCR 是系统级依赖。Windows 用户需要从官方 GitHub 下载安装器安装后把tesseract.exe所在目录加入系统 PATH。如果你使用中文界面需要额外下载中文语言包。不过这里建议游戏使用英文中文识别难度高很多。如果你的 LLM 接口不是 OpenAI也可以通过设置base_url指向本地模型服务例如 vLLM 或 Ollama 的兼容接口。这部分需要按实际服务地址调整我不在这里写死。4. 核心实现感知、理解、决策与行动4.1 感知层截图与 OCR感知层是最容易被低估的部分。Dwarf Fortress 的 ASCII 字体比较特殊而且界面信息密集直接 OCR 会出现大量误识别。一个比较实用的做法是先截取屏幕然后通过 OpenCV 把灰度图和二值化处理再交给 Tesseract。代码片段如下import mss import cv2 import numpy as np import pytesseract pytesseract.pytesseract.tesseract_cmd rC:\Program Files\Tesseract-OCR\tesseract.exe def capture_game_text(): with mss.mss() as sct: monitor sct.monitors[1] # 主屏幕 sct_img sct.grab(monitor) img np.array(sct_img) img cv2.cvtColor(img, cv2.COLOR_BGRA2GRAY) _, thresh cv2.threshold(img, 150, 255, cv2.THRESH_BINARY) text pytesseract.image_to_string(thresh, config--psm 6) return text这里重点解释几个参数mss.monitors[1]通常代表主屏幕。如果游戏窗口不在主屏需要指定monitor区域。二值化阈值 150 是为了过滤掉文字背景实际值需要根据你的游戏界面调整。--psm 6表示按文本块识别适合游戏日志这类整体文本。OCR 结果不会很干净通常需要清洗。我会过滤掉空行、无关字符并把关键信息截断到一定长度避免给 LLM 的上下文过大。4.2 理解层构造上下文识别出来的文本只是“原始素材”。Agent 需要知道当前目标、已有记忆、上次行动结果才能判断下一步。通常构造上下文时我会分成三块系统提示告诉 LLM 它是 Dwarf Fortress 的玩家助手只能输出结构化指令。当前状态最新的 OCR 文本以及一些关键状态量比如“当前季节”“矮人数量”“存储食物量”。记忆片段最近 5 轮的动作和结果或长期目标。下面是一个简单的上下文构造例子def build_context(ocr_text, memory, goal): context { system: You are an AI assistant controlling Dwarf Fortress. You must output JSON only. Allowed actions: mine, chop, build, move, wait., goal: goal, recent_state: ocr_text[:2000], recent_actions: memory[-5:], } return context这里允许的动作是我为了演示简化的。真正游戏里动作类型非常多比如assign_worker、set_designation、build_stockpile等。你完全可以按自己的需求扩展。4.3 决策层让 LLM 输出结构化 JSONLLM 的决策质量直接决定 Agent 是否可用。为了防止模型输出长串自然语言我强烈建议用函数调用function calling或 JSON mode 来约束输出。以 OpenAI 兼容接口为例可以在请求中定义response_format{type: json_object}同时把动作类型和参数描述清楚。下面是一个使用openaiSDK 的示例from openai import OpenAI import json client OpenAI( api_keyyour-api-key, base_urlhttp://localhost:8000/v1, # 可按实际服务调整 ) def decide(context): prompt f Current game state: {context[recent_state]} Goal: {context[goal]} Recent actions: {context[recent_actions]} Output a JSON object like: {{thought: 你需要说明为什么这么做, action: mine, params: {{x: 10, y: 20}}}} resp client.chat.completions.create( modelyour-model-name, messages[ {role: system, content: You are a Dwarf Fortress agent. Output JSON only.}, {role: user, content: prompt}, ], response_format{type: json_object}, ) return json.loads(resp.choices[0].message.content)这里有个细节如果你使用本地模型response_format可能不被支持。这时你需要自己做 JSON 解析失败就重新请求一次。为了提升稳定性建议用pydantic定义动作结构然后让 LLM 生成 JSON再用 Pydantic 校验。这个方法在工程上更靠谱。from pydantic import BaseModel from typing import Optional class Action(BaseModel): thought: str action: str params: dict retry: Optional[bool] False def parse_action(raw_json: str) - Action: data json.loads(raw_json) return Action(**data)4.4 行动层把决策变成按键Dwarf Fortress 的操作以键盘快捷键为主。比如矿井标记通常是d-d砍树是d-t等待是空格。我们可以把动作映射为一系列按键序列import pyautogui import time KEY_MAP { mine: [d, d, return], chop: [d, t], build: [b], wait: [space], } def execute_action(action: Action): if action.action not in KEY_MAP: print(fUnknown action: {action.action}) return keys KEY_MAP[action.action] for key in keys: pyautogui.press(key) time.sleep(0.2)如果你使用 DFHack行动层会更简单可以直接通过 RCON 或 JSON 接口发送命令。但为了避免依赖特定插件本文先用基础按键方案。按键执行有一个严重问题游戏是否真的响应了按键如果游戏窗口没聚焦按键会落到其他程序。所以在执行动作之前应该先把 Dwarf Fortress 窗口激活。import pygetwindow as gw def focus_window(title): wins gw.getWindowsWithTitle(title) if wins: wins[0].activate() time.sleep(0.3)4.5 记忆与目标管理简单的“感知-决策-行动”很快会遗忘最初目标。Dwarf Fortress 里一个任务可能需要多个步骤比如“建造一个酒馆”首先要选址然后画房间最后放桌椅。每一步都需要依赖上一步的结果。我建议维护一个非常简单的记忆结构class Memory: def __init__(self, capacity10): self.capacity capacity self.items [] def add(self, item): self.items.append(item) if len(self.items) self.capacity: self.items.pop(0) def get_recent(self, n5): return self.items[-n:]在每一轮决策时把最近几步的动作和观察结果喂给 LLM它就能意识到“我刚才已经指定了矿井区域接下来该派工人了”。更聪明的做法是引入“子任务栈”。比如 LLM 输出{action: decompose_goal, subgoals: [chop_trees, build_workshop]}Agent 就把这些子目标压栈完成一个弹出一个。这本质上是一种手动规划成本低且很实用。5. 实战案例让 Agent 自动完成一个早期任务为了让你更直观地看到效果我们做一个最简单的闭环自动砍树。Dwarf Fortress 开局时通常周围有树我们可以通过 Agent 自动标记一些树然后等待矮人执行。5.1 任务定义目标砍掉地图上 10 棵树获取木材。这个任务可以拆成子目标识别地图上树木的位置。切换到砍树标记模式。依次标记若干棵树。确认矮人是否开始干活。5.2 感知树木位置这里有一个技术难点OCR 虽然能识别文字但很难输出精确的游戏坐标。Dwarf Fortress 的 ASCII 地图坐标由屏幕上的位置决定不同的 UI 缩放会导致像素偏移。一个简化办法是直接在 OCR 文本中寻找“tree”或“☼”符号并把它们的位置映射为屏幕坐标然后直接用鼠标点击。但这需要大量坐标标注非常繁琐。更实用的方案是使用 DFHack 的命令ls来查看地图上单位的坐标。但为了保持代码通用性我们先假设感知层已经通过 OCR 得到了类似下面的文本Trees in viewport: (12,15) oak (12,17) oak (18,26) pine然后让 LLM 输出要标记的树列表。5.3 完整示例代码下面是一个简化版的 Agent 主循环它会截取游戏文本。调用 LLM让它返回一个动作。执行这个动作。把动作和结果记录到记忆里。import os import json import time from openai import OpenAI # 请替换为你的配置 client OpenAI( api_keyos.environ.get(LLM_API_KEY), base_urlos.environ.get(LLM_BASE_URL), ) SYSTEM_PROMPT You are an AI agent controlling Dwarf Fortress. You must output a JSON object with this format: {thought: brief reasoning, action: chop|wait|finish, params: {count: 10}} When the task is done, output {action: finish}. def get_game_text(): # 这里省略 OCR 实现 return Current location: forest, many trees visible. def call_llm(game_text, history): messages [ {role: system, content: SYSTEM_PROMPT}, ] for h in history: messages.append(h) messages.append({role: user, content: fGame state: {game_text}}) resp client.chat.completions.create( modelos.environ.get(LLM_MODEL, qwen2.5:7b), messagesmessages, response_format{type: json_object}, ) return json.loads(resp.choices[0].message.content) def execute_game_action(action): if action[action] chop: # 模拟执行砍树快捷键 print(Executing chop command...) # pyautogui.press(d) # pyautogui.press(t) return chopped trees elif action[action] wait: print(Waiting...) time.sleep(1) return waited elif action[action] finish: print(Task finished!) return finished else: return funknown action: {action[action]} def main(): history [] max_rounds 20 for i in range(max_rounds): text get_game_text() decision call_llm(text, history) history.append({role: assistant, content: json.dumps(decision)}) result execute_game_action(decision) history.append({role: user, content: result}) if decision[action] finish: break time.sleep(0.5) if __name__ __main__: main()这段代码最大的价值是展示了“决策-执行-反馈-再决策”的基本循环。你可以把它扩展到更复杂的任务只要把get_game_text()换成真实的截图 OCR把execute_game_action()换成完整的按键序列或 DFHack 调用。5.4 结果说明运行后预期你会看到类似输出Executing chop command... Executing chop command... Task finished!如果 Agent 一直输出chop说明它没有感知到“树已经被砍完”的变化。这时候你需要在反馈中加入更明确的计数器比如“当前木材数量0已标记树木3剩余目标10”。这个反馈是 LLM 判断任务是否完成的关键。6. 常见问题与排查思路在实际开发过程中我踩过不少坑。下面整理成一份排查指南方便你遇到问题时快速定位。问题现象常见原因解决思路OCR 识别结果乱码字体颜色/背景对比度不足二值化阈值不合适调整图像预处理参数试用不同配色主题只识别指定区域LLM 输出不是合法 JSON模型本身不支持 JSON modeprompt 约束不够强改用函数调用用 Pydantic 强校验解析失败后重试一次Agent 重复执行同一个动作反馈信息不完整LLM 不知道结果已生效在每次执行后把“成功/失败/资源变化”写进上下文动作按键无效果游戏窗口未聚焦或快捷键映射错误使用pygetwindow激活窗口确认游戏内快捷键设置上下文越来越长历史信息无限制累计超过模型窗口做滑动窗口裁剪只保留最近几轮用摘要替代旧历史API 调用超时网络问题或本地模型推理慢增加超时重试把模型部署在本地降低每轮 token 数Agent 规划完全无逻辑系统提示词不够明确缺少任务分解指令在系统提示里加入“先分解子任务再执行”的约束游戏画面卡死游戏暂停模式状态未知Agent 等待加载在执行动作前检查游戏是否处于暂停状态必要时按空格切换排查时最重要的原则是“先看输入再看输出”。如果 LLM 决策不对先检查喂给它的游戏状态文本是否准确如果动作执行不对先检查按键事件是否送达。7. 最佳实践与工程建议7.1 状态文本的“结构化”优先不要把所有 OCR 文本一股脑塞给 LLM。比如与其让模型在长段落里找“当前木材数量”不如直接在上下文中用一行写清楚wood: 12 trees_marked: 3 dwarves_available: 5这样能显著降低幻觉概率。结构化状态也方便你写重试逻辑比如检测到wood数值没变化就重新执行上一动作。7.2 限制动作空间降低决策成本Dwarf Fortress 的快捷键可能有几百个但每个任务通常只需要 5 到 10 个动作。与其让 LLM 自己“发明”动作不如在每个任务开始时给它一个“允许动作列表”。这样解析更可靠。上下文更短。模型不容易输出完全无法执行的动作。7.3 引入“观察-执行-校验”循环Agent 不仅要执行动作还要确认动作是否真的改变了世界。比如标记砍树后可以等待 5 秒再检查木材数量是否增加。如果没增加就重新执行一次。这个“感知-动作-再感知”的闭环能大幅提升稳定性。def verify_change(before, after, keywood): return after.get(key, 0) before.get(key, 0)7.4 控制成本和延时每个决策都调用一次 LLM长任务会消耗大量 token。建议每轮决策前判断是否真的需要 LLM。比如“标记树木”这种重复动作可以在 LLM 规划完成后用脚本循环执行。用“分层策略”LLM 负责规划脚本负责执行。这样既节省 token又保证动作精准。设置 max_rounds 和超时防止 Agent 陷入死循环。7.5 日志与可回放调试 Agent 时日志比什么工具都重要。我会把每一轮的截图、OCR 文本、LLM 原始输出、动作执行结果都保存到本地。def save_debug_round(round_id, data): with open(fdebug/{round_id}.json, w, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse, indent2)有了日志你可以在 Agent 表现异常时回溯找出是哪一步决策出了问题。7.6 安全与权限边界虽然这是一个游戏项目但涉及屏幕截图、按键模拟和 API 调用时仍然要注意权限边界只操作你本机已有权限的游戏进程。不要把 API Key 硬编码进代码使用环境变量或配置文件并加入.gitignore。运行 Agent 时保持游戏窗口独立避免误操作其他应用。如果通过 DFHack 发送命令先在小范围测试防止破坏存档。8. 总结与学习路线通过本文我们完成了一个“用 LLM Agent 玩 Dwarf Fortress”的最小闭环。核心是把游戏状态变成文本让 LLM 输出结构化动作再通过按键回放执行并引入记忆和校验来避免盲目决策。如果你希望把这个项目继续深化下一步可以尝试以下方向接入 DFHack 的 Lua API让 Agent 能直接查询和修改游戏内部状态摆脱 OCR 的不稳定性。引入长期记忆比如用向量数据库保存历史任务和结果让 Agent 能跨游戏局学习策略。把决策层换成 ReAct 或 Plan-and-Execute 模式让 Agent 先写计划再逐步执行。尝试将多个大模型混合使用比如用 7B 小模型做快速动作识别用大模型做复杂规划。这个项目的难点从来不在“调用 LLM API”而在于如何把不确定的视觉输入和模糊的自然语言指令转化为稳定、可追踪、可回退的游戏操作。希望这篇教程能给你一些启发。如果你在搭建过程中遇到其他奇怪的问题欢迎按上面的排查清单逐项检查。