LLM Agent实战:如何让智能体学会玩《矮人要塞》

发布时间:2026/8/28 21:12:38
LLM Agent实战:如何让智能体学会玩《矮人要塞》 在实际工程里把 LLM Agent 接到 Dwarf Fortress矮人要塞里是一个比大多数教程项目都难、但收益也非常明显的练手方向。Dwarf Fortress 没有对外提供干净 API没有内置强化学习接口甚至没有一个可视化得分Agent 要在未结构化的字符界面和复杂模拟规则下自己观察、规划、执行并承担后果。这篇文章会围绕“Building an LLM Agent to Play Dwarf Fortress”这个项目把整体架构拆开说明环境怎么搭、Agent 循环怎么写、记忆和行动空间如何设计、运行后如何验证效果、遇到常见问题从哪一层查起。目标不是还原某个完整成品而是给出一条可复现的工程路径让读者能从零跑通一个最小闭环。1. 为什么偏偏选《矮人要塞》作为 LLM Agent 的试验场1.1 矮人要塞的复杂度来自哪里Dwarf Fortress 是一款以“深度模拟”闻名的游戏。它并不仅仅是一个需要操作角色的冒险游戏而是一整套并行运行的模拟系统地形有岩层和矿脉世界有季节和天气矮人有心情、需求、社交关系要塞有工作分配、库存、建筑、水流、温度等等。玩家通过键盘菜单、方向键、多层级界面来下达指令系统以回合、刻tick或实时方式持续更新状态。这种复杂度给 Agent 带来的核心挑战是观察不直接、行动不原子、结果不即时。观察不直接Agent 看到的不是“你的角色位于坐标 (10, 20)”而是一屏字符画、菜单项、光标位置和零散的日志。行动不原子一次“挖掘”往往需要进入挖掘模式、移动光标、按下设计键、确认再等待矮人角色执行。结果不即时Agent 下达指令后角色可能还在路上或者被路径阻挡或者因为工具不足而挂起。这意味着不能用“输入状态、输出动作、获得奖励”的标准强化学习范式来简单处理。Agent 必须把一长串低层操作组织成高层意图同时还要在每次动作后重新解析游戏界面确认自己前一步到底有没有生效。1.2 与常见 Agent 基准环境的差异很多 LLM Agent 项目选择 Atari、Minecraft、网页导航或文本冒险作为测试环境。和这些环境相比Dwarf Fortress 的难度集中在“状态表达”和“动作映射”两层。环境观测方式动作空间奖励信号主要难点Atari像素帧有限离散动作游戏分数视觉表征、策略学习Minecraft图像加物品栏离散加连续动作自定义任务长程规划、工具使用网页导航HTML DOM 或截图点击、输入、跳转任务完成判定页面结构理解、多步操作Dwarf Fortress字符界面、光标、菜单、日志键盘菜单序列需要自定义状态解析、动作组合、失败归因Dwarf Fortress 和文本冒险游戏有一点相似信息是符号化的。但它又比纯文本冒险更难因为状态并不都在文本里很多信息分布在字符画、光标、菜单层级和等待队列中。与此同时它的动作也不是自然语言命令而是键盘序列。Agent 必须把“挖一条通往南方的通道”翻译成一系列带确认步骤的输入事件。1.3 从这条赛道能得到什么做这个项目最大的收获不是学会玩 Dwarf Fortress而是理解一个通用 Agent 在真实环境落地时必过的四道门槛观测压缩、工具封装、上下文管理、失败恢复。观测压缩游戏一屏信息量远超过直接塞给模型的范围必须抽取成结构化 JSON。工具封装任何 Agent 都不能直接操作底层环境要有一层白名单动作映射。上下文管理连续几十轮观察和动作会让上下文膨胀必须设计截断和摘要。失败恢复动作执行失败、任务卡住、模型输出不合法都是运行期常态。把这套能力沉淀下来之后很容易迁移到其他自动化、机器人控制、游戏 AI 或工作流编排场景。这也是这个项目即使很小也非常值得动手的原因。2. 先拆开 LLM Agent 的骨架再映射到矮人要塞2.1 Agent 循环、记忆、工具、规划四个部分一个可运行的 LLM Agent 通常由四个部分协同工作感知器负责把环境状态转换成模型可读的输入决策器由 LLM 担任负责根据当前状态和任务目标输出下一步打算执行器负责把决策转成真实动作并检查执行结果记忆系统负责保存对话历史、任务状态和关键事件。四者的关系是一个循环感知环境、生成动作、执行动作、观察结果再进入下一轮。Dwarf Fortress 的 Agent 也是这个结构只是每一部分都要针对游戏特性重新设计。感知器需要解析 DFHack 查询结果、游戏界面菜单和日志文件。决策器输出一个带有动作类型、参数、思考过程和记忆更新的 JSON。执行器把 JSON 动作映射成键盘序列或白名单 DFHack 指令。记忆系统则在每一轮后追加事件并在上下文过长时做摘要压缩。2.2 感知、决策、执行在 DF 中的映射在实际项目中感知、决策、执行并不是三个松散的模块而是有明确输入输出协议的三个组件。感知层应该输出统一的观察对象比如角色位置、光标位置、当前打开的菜单、最近的游戏通知、当前任务列表。这些字段不能随意拼接因为模型需要稳定的结构才能稳定决策。决策层接收系统提示、历史记忆和最近观察输出一个严格 JSON。推荐最少包含三个字段{ thought: 当前目标是挖一条向南的通道光标已经移动到目标位置下一步进入挖掘设计模式, action: { type: press_key, params: { key: d } }, memory_update: 已进入挖掘模式准备选择挖掘区域 }执行层拿到这个 JSON 后根据 action.type 查表执行。执行层永远不执行自由文本这是最关键的安全边界。否则模型一句话可能被游戏内容、日志文本或用户输入注入导致不可控行为。2.3 harness 与 agent 的区别在项目讨论里harness 和 agent 经常混用但这里的边界很重要。Agent 是决策核心它只负责“根据输入输出 JSON”harness 是围绕 Agent 搭建的工程外壳包括环境适配层、工具层、安全过滤、日志、重试、评估器。很多 Agent 项目跑不起来不是模型能力不够而是 harness 的观测不完整。比如模型明明输出了“移动光标到 (5, 5)”但 harness 没有提供光标当前坐标模型只能凭感觉行动。再比如模型执行了挖掘指令但 harness 没有把“没有矮人被分配这项工作”这条反馈传给模型Agent 就会一直重复同一个动作。因此后续所有章节的实现重点都放在 harness 的设计上怎么读取状态、怎么定义动作、怎么记录记忆、怎么把反馈送回模型。模型只是最后一个步骤。3. 环境准备从游戏本体到可编程接口3.1 需要的软件和版本对齐参与这个项目建议准备以下软件和工具软件用途版本注意事项Dwarf FortressAgent 运行的游戏环境选择稳定版记录具体版本号DFHack读取游戏内存状态、执行 Lua 脚本必须与 Dwarf Fortress 版本严格匹配Python 3.10 或更高编写 Agent 主循环如果没有特殊依赖3.10 以上均可Ollama 或等价 LLM 服务本地运行模型避免网络延迟按官方文档安装即可PyAutoGUI 或 xdotool向游戏窗口发送键盘事件Linux 优先 xdotoolWindows 可用 PyAutoGUIGit拉取工具和示例代码可选但推荐版本对齐是第一优先级。DFHack 是构建在 Dwarf Fortress 内存结构和内部接口上的工具游戏版本更新后DFHack 往往需要单独适配。如果版本不匹配启动时会出现加载失败或运行时报错。3.2 安装 DFHack 并验证命令通路DFHack 的安装方式建议直接从官方发布包入手。以发布包方式安装时解压到游戏根目录后游戏启动器会加载插件。# 示例下载与当前游戏版本匹配的 DFHack 发布包 wget https://github.com/DFHack/dfhack/releases/download/xxx/dfhack-xxx.zip unzip dfhack-xxx.zip -d /path/to/dwarf-fortress/安装后先确认 DFHack 的命令行工具dfhack-run可用。这个命令可以在游戏运行状态下从外部调用 DFHack 内部的 Lua 脚本和内置命令。# 进入游戏在后台运行后在另一个终端执行 /path/to/dwarf-fortress/dfhack-run version /path/to/dwarf-fortress/dfhack-run ls如果dfhack-run ls能列出 DFHack 中的可用命令说明环境已经打通后续 Agent 的状态查询和部分调试动作可以通过它完成。注意DFHack 的编译安装方式比较复杂新手不建议一开始就走源码编译。先用发布包跑通功能后续需要自定义插件时再研究编译。3.3 Python 环境与本地模型接入Agent 主循环用 Python 编写。可以先创建虚拟环境并安装依赖。python3 -m venv .venv source .venv/bin/activate pip install requests pyyaml openai pydanticopenai库不是必须的但很多本地模型服务兼容 OpenAI 接口使用它可以降低切换模型的成本。如果使用 Ollama可以直接用 HTTP 接口请求也可以按官方说明配置 OpenAI 兼容地址。启动 Ollama 并拉取一个适合中文和 JSON 输出的模型ollama serve ollama pull qwen2.5:7b模型选择不需要一上来就追求最大。7B 级别的模型在动作空间受限、观察结构清晰时已经可以完成“移动光标、按下一个键、确认挖掘”这类简单闭环。更大的模型能把复杂规划做得好一些但对硬件要求也更高。3.4 打通读状态和写动作两条通道读状态优先走 DFHack 的 Lua 接口。比如读取当前光标坐标可以在 Lua 脚本中读取游戏全局对象。-- status.lua具体字段名以当前 DFHack 文档为准 local df require(df) local cursor df.global.cursor print((cursor%d,%d,%d):format(cursor.x, cursor.y, cursor.z))调用方式/path/to/dwarf-fortress/dfhack-run lua path/to/status.lua写动作则要面向窗口发送键盘事件。以 Linux 为例使用 xdotool 发送方向键和菜单键xdotool key Up xdotool key d xdotool key Return这一步的关键不是命令本身而是焦点问题。游戏窗口必须获得焦点否则按键会落到终端或桌面。实际项目里可以给游戏进程设置固定窗口标题并在发送按键前同步激活窗口。4. 定义 Agent 的感知、行动空间和记忆4.1 感知层把游戏状态变成结构化 JSON不能把 Dwarf Fortress 的整屏文本发给模型。字符界面信息噪声太大上下文消耗也极高。更好的做法是把需要的信息抽取出来组织成固定字段。一个适合初期的观察结构如下字段来源作用长度建议positionDFHack 查询角色当前位置短cursorDFHack 查询当前光标坐标短current_menu界面解析当前处于哪个菜单状态短seasonDFHack 查询当前季节短queued_jobsDFHack 查询当前队列中的任务中announcements游戏通知库最近发生的重要事件中recent_actionsAgent 自身日志上几轮动作和反馈中Python 侧可以定义数据结构来强制校验from dataclasses import dataclass, asdict dataclass class Observation: position: str cursor: str current_menu: str season: str queued_jobs: list announcements: list def to_json(self) - str: import json return json.dumps(asdict(self), ensure_asciiFalse)观察结构越稳定模型输出 JSON 的动作参数就越稳定。每次升级游戏版本或修改解析脚本时都需要重新验收观察字段是否完整。4.2 行动空间先限制动作再放开自由操作Dwarf Fortress 的完整键盘操作太庞大Agent 一开始绝不能接触全部按键。推荐的做法是定义白名单动作让模型只能选择固定类型。初期行动空间可以这样设计动作类型参数说明move_cursorkeys按顺序移动光标如 Up、Down、Left、Rightpress_keykey按下单个菜单键如 d、b、e、yrun_dfhackcommand执行白名单内的 DFHack 调试命令waitseconds等待若干秒用于角色执行任务finish_taskreason声明当前子任务完成abortreason放弃当前子任务并说明原因执行层只接受这个表里面的动作类型。模型如果输出了其他类型执行层要拒绝并返回错误反馈而不是尝试“智能理解”。ALLOWED_ACTIONS {move_cursor, press_key, run_dfhack, wait, finish_task, abort} def validate_action(action: dict) - bool: return action.get(type) in ALLOWED_ACTIONS限制动作空间的目的是降低模型的决策难度同时提高可审计性。Agent 真的能稳定完成“挖掘一格”之后再逐步放开更多操作比如建造、种植、移动物品。4.3 记忆层短期上下文与长期事件存档Agent 运行过程中观察和动作会不断累积。如果每一轮都完整保留很快会超过模型的上下文窗口。需要分离短期记忆和长期记忆。短期记忆保留最近 N 轮的观察、动作、反馈通常可以设定为 10 到 20 轮。长期记忆则把重要事件写入文件或 SQLite例如“第 5 天食物开始不足”“挖掘任务完成到第 3 格”“角色被动物攻击”。长期记忆的写入逻辑也可以由模型触发。在 Agent JSON 输出中加入memory_update字段harness 检查该字段非空后写入记忆存储。这样模型自己决定哪些信息值得记住。一个简单实现可以是def save_memory(event: str, path: str memory.jsonl) - None: with open(path, a, encodingutf-8) as f: f.write(json.dumps({event: event}, ensure_asciiFalse) \n)在每轮系统提示中把最近几条长期记忆和最近几轮短期记忆合并后发给模型。记忆不是越多越好而是越关键越好。5. 核心实现一个最小可运行的 Agent 循环5.1 配置文件和模型加载把模型地址、动作间隔、日志路径、记忆文件放入配置文件避免在代码里写死。# config.yaml model: provider: ollama base_url: http://localhost:11434/api/chat name: qwen2.5:7b temperature: 0.3 timeout: 60 agent: observation_interval: 1.5 short_memory_size: 15 log_file: agent-output.log memory_file: memory.jsonl dfhack: run_cmd: /path/to/dwarf-fortress/dfhack-run action: allowed_types: - move_cursor - press_key - run_dfhack - wait - finish_task - abort温度设置为 0.3 左右比较合适。温度太高模型输出动作不稳定温度太低模型可能过于保守总是输出同一个动作。运行一段时间后可以根据失败率再调整。5.2 主循环观察、规划、执行、反思主循环是整个 Agent 的核心。它按固定节奏执行四步拉取观察、调用模型、解析动作、执行动作。import json import time import yaml import requests from dataclasses import asdict def load_config(path: str) - dict: with open(path, r, encodingutf-8) as f: return yaml.safe_load(f) def fetch_observation() - dict: # 这一层由 DFHack 查询、界面解析和日志解析共同完成 return { position: (20, 30, 0), cursor: (20, 32, 0), current_menu: play, season: spring, queued_jobs: [], announcements: [挖掘通道任务尚未开始], } def parse_action_content(content: str) - dict: start content.find({) end content.rfind(}) 1 if start -1 or end 0: raise ValueError(no valid json object in model output) return json.loads(content[start:end]) def call_llm(cfg: dict, history: list[dict], observation: dict) - dict: payload { model: cfg[model][name], messages: history [{role: user, content: json.dumps(observation, ensure_asciiFalse)}], stream: False, options: {temperature: cfg[model][temperature]}, } resp requests.post(cfg[model][base_url], jsonpayload, timeoutcfg[model][timeout]) resp.raise_for_status() content resp.json()[message][content] return parse_action_content(content) def execute_action(cfg: dict, action: dict) - str: action_type action[type] params action.get(params, {}) if action_type move_cursor: for key in params.get(keys, []): run_xdotool(key) return cursor moved if action_type press_key: run_xdotool(params[key]) return key pressed if action_type wait: time.sleep(float(params.get(seconds, 1))) return waited if action_type run_dfhack: subprocess.run([cfg[dfhack][run_cmd], params[command]]) return dfhack command executed if action_type finish_task: return task finished if action_type abort: return task aborted return unknown action def build_short_memory(history: list[dict], n: int) - list[dict]: return history[-n:] def main() - None: cfg load_config(config.yaml) history [] system_prompt build_system_prompt() while True: observation fetch_observation() recent build_short_memory(history, cfg[agent][short_memory_size]) response call_llm(cfg, [{role: system, content: system_prompt}] recent, observation) action response[action] if not validate_action(action): history.append({role: user, content: 非法动作请重新输出}) continue feedback execute_action(cfg, action) history.append({role: user, content: json.dumps(observation, ensure_asciiFalse)}) history.append({role: assistant, content: json.dumps(response, ensure_asciiFalse)}) save_log(action, feedback, cfg[agent][log_file]) time.sleep(cfg[agent][observation_interval]) if __name__ __main__: main()这个版本的代码没有处理所有异常但它已经构成一个最小闭环。实际项目中还需要在call_llm和execute_action外层补上异常捕获、超时重试、日志记录和失败后的动作恢复。5.3 Prompt 模板与 JSON 校验Prompt 设计直接决定模型输出质量。不要简单说“你是助手”而是要给出角色定位、目标、观察结构、动作白名单和输出格式要求。你是控制 Dwarf Fortress 游戏角色的 Agent。 当前任务从要塞大厅向北挖一条 3 格通道然后返回大厅。 你只能选择以下动作类型 - move_cursor: 移动光标参数 keys 是方向键数组 - press_key: 按下单个菜单键参数 key - wait: 等待角色执行参数 seconds - finish_task: 任务完成 - abort: 任务放弃 严格输出 JSON不要输出 Markdown 代码块格式如下 { thought: 你的推理, action: {type: ..., params: {...}}, memory_update: 值得记录的事件没有则留空 }模型输出后harness 要尽量宽容地解析。常见情况是模型把 JSON 包在代码块里或者带了多余文字。可以用正则提取第一个{到最后一个}之间的内容再交给 JSON 解析器。必要时可以引入 json5 库修复 JSON 尾逗号问题def parse_action_content(content: str) - dict: import re match re.search(r\{.*\}, content, re.DOTALL) if not match: raise ValueError(no json object found) try: return json.loads(match.group(0)) except json.JSONDecodeError: import json5 return json5.loads(match.group(0))校验解析成功后还要校验动作字段是否在白名单内、参数是否齐全。缺少params的动作直接拒绝并把错误提示写进下一轮历史帮助模型自我修正。6. 运行验证从“能跑”到“完成任务”6.1 最小验证任务的设计建议把第一个验收任务设置得非常小控制角色移动一段距离或者完成一次挖掘并返回起点。以“从大厅向北挖 3 格通道并返回”为例Agent 需要完成这些子步骤步骤预期动作验证点1观察当前角色和光标位置observation 中能读到坐标2进入挖掘模式press_key(d)再 press_key(d)3把光标移动到挖掘位置move_cursor 方向键4指定挖掘区域并确认press_key(e)press_key(y)5等待角色执行任务wait几秒6检查任务是否完成查询 queued_jobs 和 announcements7返回大厅通过 move_cursor 加 press_key 移动角色这个任务足以验证 Agent 是否具备基础工程闭环能力读取状态、生成动作、执行动作、根据反馈调整。6.2 结果检查与日志记录运行 Agent 时需要同时记录模型原始输出、解析后的 action、执行反馈和观察快照。这样即使 Agent 跑偏也能从日志还原当时的决策路径。tail -f agent-output.log日志格式可以保持 JSON Lines方便后续分析{time: 2025-01-01T12:00:00Z, observation: {cursor: (20, 32, 0)}, response: {thought: ..., action: {type: press_key, params: {key: d}}}, feedback: key pressed}记录日志不仅用于排查也是后续做评估和回放的基础。不要只记录成功动作失败动作和非法输出更值得记录。6.3 用任务成功率衡量 Agent 能力Dwarf Fortress 没有内置得分所以任务完成情况需要自己定义。常用的评估维度有任务完成率规定时间或动作次数内完成目标的比例。平均动作数完成一个任务消耗的步数越少说明决策越高效。无效动作率模型输出 action 类型不在白名单内、JSON 解析失败、参数缺失的比例。重复动作率同一状态下连续输出相同动作的比例用来发现反馈缺失。运行 10 次同一任务统计这些指标。如果无效动作率高于 20%优先检查 prompt 和 JSON 解析如果重复动作率高优先检查观察字段是否把前一步执行结果传给模型。7. 常见问题与排查路径7.1 操作执行后游戏没有反应这是最常见的阶段性问题。可能原因有三个游戏窗口没有焦点按键事件发送太快游戏来不及响应当前界面状态与模型预期不一致。检查方式先在游戏窗口手动点击再单独执行一条xdotool key d观察游戏是否进入挖掘模式。如果单条按键生效排除焦点问题如果没有生效检查按键映射或输入法。解法是给每次按键之间增加小间隔并在发送键之前激活窗口。把动作执行抽象成独立函数方便统一加延时import time import subprocess def run_xdotool(key: str) - None: subprocess.run([xdotool, windowactivate, GAME_WINDOW_ID, key, key]) time.sleep(0.2)7.2 模型输出一直不合法 JSON观测模型输出内容可以分两种原因。一是温度过高导致输出随意二是 prompt 示例不足或没有强调“不要输出代码块”。解法是降低温度、在 prompt 中给出一个完整 JSON 示例、解析时用正则提取代码块内的 JSON并增加错误反馈。当解析失败时不直接把原始输出吞掉而是把错误信息拼进下一轮 promptif action not in parsed: history.append({role: user, content: 上次输出缺少 action 字段请重新输出严格 JSON})7.3 Agent 反复执行同一动作反复执行同一个动作通常不是模型问题而是反馈缺失。Agent 执行完press_key(d)后如果下一轮 observation 没有反映“已经进入挖掘模式”模型就会认为动作没有生效再次执行。排查时看日志里连续几轮的 observation 是否完全相同。如果完全一致说明执行反馈没有正确回流如果 observation 已经变化但模型仍输出相同动作说明 prompt 对目标的拆解不足。解法是在 observation 中显式加入last_action_result字段把上一轮执行结果直接告诉模型。7.4 上下文越用越长效果减退循环运行几轮后如果 history 无限增长模型会越来越关注旧信息响应变慢且容易混乱。短期记忆必须做截断只保留最近 10 到 20 轮。长期记忆也应做摘要。每隔若干轮让模型把已有长期记忆压缩成一小段摘要再配合最近的完整事件一起使用。生产级项目还可以引入向量存储但初期使用 JSONL 文件加摘要即可。7.5 DFHack 与游戏版本不兼容DFHack 和 Dwarf Fortress 的版本需要严格匹配。启动游戏出现插件加载错误或dfhack-run命令不存在时先检查版本。检查方式查看 DFHack 发布包主页确认它对应的游戏版本号再查看游戏自身的发布版本。两者不一致就直接更换匹配的发布包不要试图用旧版插件跑新版游戏。8. 优化方向、安全边界与下一步8.1 用更完整的观测接口减少幻觉屏幕解析方式虽然通用但容易受到菜单状态和字符集影响稳定性有限。更可靠的方向是利用 DFHack 的 socket 接口或自定义远程插件直接读取游戏内存对象。这样 observation 的字段可以做到精准稳定Agent 不必从字符界面“猜”当前状态。代价是开发成本更高。建议先从键盘脚本加 DFHack 命令行查询跑通确认效果后再升级到自定义插件。8.2 记忆压缩与任务规划当任务从“挖 3 格通道”升级为“建设一个能过冬的要塞”时Agent 无法在一段上下文中完成全部规划。这时需要把任务分层高层规划者负责制定阶段目标低层执行者负责具体键盘操作。记忆压缩也应该分层。10 轮内的原始历史给执行者每百轮生成的摘要给规划者城市状态、资源库存等关键数据单独保存不随对话一起传入。8.3 多 Agent 与自动化评估可以设计两个 Agent 配合一个负责执行一个负责审计输出。审计 Agent 不执行动作只检查执行 Agent 的 action 是否与目标一致并给出修正建议。这能缓解单一 Agent 跑偏后无人发现的问题。自动化评估则需要回放日志。把 Agent 每轮的 observation、action、feedback 记录下来用评估脚本统计任务完成率、无效动作率、平均步数和重复率。没有评估脚本后续任何优化都缺少判断依据。8.4 安全边界LLM Agent 接入外部环境时必须设置动作白名单和命令白名单。模型只能选择ALLOWED_ACTIONS中的动作DFHack 执行命令也要限定为调试必需的少数几条不能允许模型自由拼接 shell 命令。同时要注意游戏内部文本本身可能成为注入来源。角色名、任务说明、日志内容如果包含了类似“忽略系统提示”的诱导文字直接拼进 prompt 可能影响模型行为。实践上不要把用户可控或游戏可控文本无条件当成普通提示词处理必要时先用提示词隔离再做动作白名单过滤。任何情况下执行层不解释自然语言指令。动手做这个项目时建议遵循一条路线先完成一次人工按钮验证再写死脚本跑通动作接到模型后只开放最小动作集跑通第一个任务后再逐步扩充观察字段和记忆机制。Dwarf Fortress 的挑战和收益都来自它的复杂性但复杂性不应该一次性压在模型肩上而应该由 harness 一层层消化。