从《矮人要塞》看LLM Agent闭环设计:状态压缩与动作空间实践
2026/8/28 3:51:11 网站建设 项目流程

用 LLM Agent 去玩《矮人要塞》(Dwarf Fortress),听起来像一个极客玩具,但它其实是个非常典型的 Agent 工程问题:怎么把复杂的游戏状态喂给模型,怎么让模型输出变成稳定的游戏动作,怎么在处理几百条指令后还不丢失目标。适合对 Agent 开发、Function Calling、工具调用感兴趣的开发者,也适合想从项目里学状态压缩和动作空间设计的人。最值得关注的不是“模型能不能赢”,而是“Agent 循环能不能稳定转起来”。如果刚接触这类项目,我建议先别急着追求复杂策略,先把观察、决策、执行、反馈这四个环节打通。

1. 先想清楚:LLM Agent 玩 Dwarf Fortress 到底难在哪

1.1 它不是“让模型学会玩”,而是“给模型造一条能交互的回路”

很多人看到这个标题,第一反应是“让大模型直接告诉玩家下一步做什么”。这其实是把问题想简单了。Dwarf Fortress 不是一个有标准输入输出接口的棋类游戏,它更像一个实时演算的模拟世界。玩家需要不断读取游戏状态,做出判断,再通过键盘、鼠标或游戏内指令让操作生效。对 LLM Agent 来说,真正的难点在于:模型本身不会读游戏内存,也不会按鼠标。你需要在外围搭一套系统,让模型能“看到”游戏,也能“操作”游戏。

这也是为什么这类项目会成为 Agent 开发的经典练手场景。它要求你把 Agent 拆成状态获取、动作执行、决策生成、结果验证四个模块,任何一个环节断了,整个循环都会失败。很多 Agent 教程喜欢用天气预报或待办清单当例子,那些场景动作空间小、状态变化少,跑起来很顺利。换到 Dwarf Fortress,问题立刻暴露:日志可能每秒钟新增几十行,动作执行可能失败,模型可能给出无效指令。你马上就会意识到,Agent 的核心不是单一的“聪明模型”,而是稳定可靠的工程闭环。

所以我会把这类项目理解成一次“模拟环境下的 Agent 基础设施开发”,而不是“教 AI 打游戏”。它能复用的经验,也比单一游戏场景大得多:比如状态压缩、工具调用校验、失败重试、长会话记忆,都是做通用 AI Agent 时绕不开的问题。

1.2 难点拆成四件事:观察、决策、执行、反思

如果把一轮 Agent 运行拆开,大致是四个动作。

观察:把游戏当前状态转成模型能理解的输入。Dwarf Fortress 的信息非常杂,有地形、人物、矿物、怪物、任务、季节、天气。你不能把整个游戏日志原样丢给模型,那样 token 消耗会非常快,而且噪声会淹没关键信息。观察环节的核心是“压缩”和“筛选”。

决策:模型基于观察结果给出动作。这里最怕的是模型自由发挥。如果让 LLM 直接输出一段自然语言描述,比如“让一个矿工去挖洞”,执行层很难稳定解析。更稳妥的做法是把它改成有限动作集合,配合参数,让模型做选择题。

执行:把模型输出变成游戏里真实发生的操作。这个环节最考验工程能力。你可以用模拟按键、游戏内命令脚本,或者通过外部工具调用接口。关键不光是执行动作,还要在执行后确认动作是否真的生效。

反思:跑了一段时间后,让模型或脚本对之前的决策做一次总结。比如“过去 10 轮一直派矿工去挖同一条隧道,但那条隧道已经挖通了”,这种问题靠单步决策很难发现,必须靠更长的语境或外部记忆才能修正。

把这四件事放在一个循环里,就是常见的 Agent Loop:读取状态、调用模型得到动作、执行动作、再次读取状态,循环往复。Dwarf Fortress 的长局特性会让这个循环持续很久,所以上下文管理、日志记录和失败恢复会变得非常关键。

1.3 为什么选 Dwarf Fortress 做验证场景,而不是更简单的棋盘游戏

棋盘游戏当然也能跑 LLM Agent,比如让模型下国际象棋,但动作空间小、状态完全可观测,天然适合模型。Dwarf Fortress 不一样,它更接近现实里的开放世界:信息不完整、动作结果有延迟、事件可能并发发生。你很难用一个固定规则函数把所有状态都描述清楚,所以必须靠 Agent 设计来解决不确定性。

另外,Dwarf Fortress 本身有很强的“叙事生成”能力。模型给出的每个决策都会引发新的世界变化,这种动态反馈非常适合测试 Agent 的适应能力。对开发者来说,它比简单 API 调用更锻炼系统设计能力。当然,它的学习曲线不低,第一次跑通可能要处理很多环境问题,但一旦跑通,你会对 Agent 的各个模块有非常具体的感知。

2. 跑通一个最小闭环:先把“看状态、给指令、传到游戏”连起来

2.1 前置环境:游戏、LLM API、Agent 脚本

构建这类 Agent 不需要特别贵的硬件,但要把几样东西准备好。

第一是能运行 Dwarf Fortress 的电脑。这个游戏对显卡要求不高,但对 CPU 单核性能敏感,尤其是在后期地图变复杂后。如果你只是做 Agent 开发验证,用中等配置的机器就够了。

第二是 LLM API 或本地模型接口。如果你只是想快速跑通,推荐用成熟的大模型接口;如果你希望控制成本和隐私,也可以接开源模型的本地部署接口。关键是接口要稳定、返回格式可解析。常见的做法是使用兼容 Chat Completions 的接口,这样后续切换模型时不用改太多代码。

第三是 Agent 脚本。用 Python 最容易,因为生态里有很多现成的工具:解析日志、调用 API、模拟键盘鼠标、读写文件,都能快速实现。如果你更熟悉 Node.js 或 Go,也不是不行,但 Python 在数据处理和 AI 生态上的优势明显。

我建议第一次搭建时,把环境分隔清楚:一个脚本负责读游戏状态,一个脚本负责调用模型,一个脚本负责执行动作。不要把所有逻辑堆在一个文件里,后面排查问题会非常痛苦。

2.2 状态获取:优先走日志和文本输出,而不是一上来就截图

Dwarf Fortress 会输出日志文件,里面记录了游戏事件。最省事的做法是让 Agent 读取日志增量,把新出现的内容转成状态文本。相比屏幕截图,文本日志有几个好处:信息密度高、不需要视觉模型、token 成本低、处理速度快。

具体步骤很简单:写一个脚本监听日志文件变化,读取新增行,做一层过滤和格式化,然后拼成一段状态描述。比如:

  • 当前日期
  • 最近发生的 5 到 10 个事件
  • 当前待处理任务
  • 库存或资源变化
  • 警告或战斗信息

给模型的不是原始日志,而是你整理后的“简报”。这一步很重要,因为原始日志里大量内容是无关的,比如某个矮人心情变化、石头被搬运到哪个位置。对决策来说,真正有价值的信息可能只有几条。

如果你的游戏版本不输出文本日志,或者你希望看到更完整的画面,再考虑截图。截图配合多模态 LLM 是可行的,但要注意:截图识别速度慢、token 消耗大,而且 Dwarf Fortress 的界面元素很多,小字体容易被模型看错。我一般会把截图作为备选方案,先用文本日志把流程跑通。

2.3 动作执行:把“模型意图”变成“游戏按键或命令”

状态读出来以后,模型会返回一个动作。这时候需要一个执行层,把动作文本翻译成游戏内的操作。

动作执行有两种常见路线。第一种是模拟键盘和鼠标,比如用脚本发送快捷键、移动鼠标点击坐标。这种方式最接近真人操作,但脆弱:窗口焦点变化、游戏卡顿、坐标偏移都会导致失效。第二种是通过游戏内命令脚本,例如 Dwarf Fortress 社区常用的第三方扩展工具,通过命令行发送游戏指令。这种方式更稳定,但它依赖你使用的游戏版本和扩展工具版本。

不管选哪种,我都建议在执行层加上“动作白名单”。比如只允许执行 move_to、dig、build、wait、check_inventory、save_game 这样的有限动作。每个动作对应一个函数,里面规定了参数、执行方式、超时时间和验证方式。

给一个最小闭环的伪代码:

while True: state = read_game_state() if not state.has_update(): time.sleep(1) continue action = llm_choose_action(state) if not validate_action(action): log_warning("invalid action", action) continue result = execute_game_action(action) if not result.success: log_error("execute failed", action, result.error) continue record_agent_step(state, action, result)

这个循环不复杂,但每一条都很重要。read_game_state要稳定,llm_choose_action要能处理 API 超时和 JSON 解析异常,execute_game_action要返回成功或失败,而不是只把操作发出去就不管。实际跑起来你会发现,大部分问题都出在执行后的反馈环节。

2.4 第一次验证:先让循环稳定,再让策略聪明

第一次跑通时,不要指望 Agent 玩得好,先看它能不能连续稳定地完成“读状态—选动作—执行—再读状态”这一圈。

我建议做一个最简单的测试:让 Agent 每收到一次状态更新,只输出一个“wait”动作。然后检查两层:第一层,Agent 脚本是否能源源不断读到状态更新;第二层,执行层是否能成功执行 wait,并且不把游戏搞崩。如果 wait 都执行不稳,后面选什么动作都没意义。

等 wait 稳定后,再加入一个简单动作,比如“查看库存”,再扩大动作集合。每一步都看执行成功率和报错率。跑通这个最小闭环后,才算真正拿到一个可以继续扩展的基础设施。此时再去调模型提示词、优化策略,才有意义。

3. Agent 核心设计:状态压缩、动作约束和上下文管理

3.1 状态表示:文本化是首选,视觉输入要慎重

状态表示决定了模型能看到什么,也决定了 Agent 的决策质量。输入材料越干净,模型越容易给出有效动作。我见过很多 Agent 项目,模型输出乱、动作重复、目标丢失,最后排查下来不是提示词问题,而是状态表示里塞了太多无用信息。

文本化状态是我最推荐的方式。你可以把游戏事件组织成一个固定模板,比如:

- 当前季节:春季第 2 月 - 最近事件: - 矿工 A 挖通了一条新通道 - 库房存储空间不足 - 附近发现敌对生物 - 当前目标:扩大地下仓库 - 可用资源:石头 120,木头 45

这样的描述对模型很友好:结构清晰、信息集中、长度可控。然后你可以把这段文本和系统提示词一起发送给 LLM。

截图方案不是不能用,但要谨慎。它适合“模型需要理解空间布局”的场景,比如判断矿道怎么挖,但 Dwarf Fortress 的屏幕信息非常密集,模型经常会被视觉细节干扰。加上截图接口延迟高、token 成本高,不划算。如果要做视觉方案,我建议把截图裁剪成关键区域,或者先做图像预处理,再用多模态模型识别,而不是整张屏幕无脑丢进去。

3.2 动作空间:让模型做选择题,而不是自由写作

自由对话是 LLM 的强项,但 Agent 执行动作时,自由文本是非常差的接口。模型可能说“继续挖矿”,执行层怎么知道“继续”是什么意思?挖哪个方向的矿?所以动作空间设计要尽早做限制。

一个比较稳妥的做法是使用 Function Calling 或结构化 JSON 输出。你定义一组动作函数,每个函数有名字和参数,模型在用户请求里调用对应函数,而不是自由输出。例如:

动作名称参数执行方式
move_totarget, unit_id模拟键盘移动光标
digx, y, z游戏内指令指定地块
buildbuilding_type, location游戏内建造指令
waitduration等待若干游戏内 tick
check_inventoryitem_type读取库存并返回结果
save_game-执行存档

定义好动作表后,还要在代码里写校验器。动作名不在白名单里就拒绝执行;参数缺失或类型错误就要求模型重新生成;执行超时就标记失败。这个过程听起来烦琐,但能大幅提高稳定性。

我在实际项目中经常看到,模型偶尔会编造一个看起来合理但根本没定义的函数。这种情况不能靠提示词完全避免,必须在执行层兜底。

3.3 上下文管理:长会话不丢目标的三个办法

Dwarf Fortress 的一局游戏可能持续几百轮决策,全部历史不能一直塞在上下文里。上下文窗口再大也有上限,而且历史越长,模型越容易忘掉当前目标。处理长会话,常见有三种办法。

第一种是滑动窗口截断。只保留最近 N 条状态和动作,更早的内容直接丢弃。优点是简单,缺点是模型容易丢失长期目标。对短时任务够用。

第二种是摘要记忆。每隔一定轮数,调用一次 LLM,把之前的行动结果压缩成一段摘要,然后放进下一次请求。比如“前 30 轮完成了仓库扩建,但忽略了防御工事,目前敌人威胁增加”。这相当于给模型一个“短期记忆+长期摘要”的混合上下文。

第三种是外部向量库。把重要事件存进向量数据库,每次决策前检索相关事件,只把相似度高的片段放回上下文。这种方式适合复杂长局,但实现成本更高。

我建议先做摘要记忆,性价比最高。摘要本身也是 LLM 调用,会占用 token,但相比全量历史要便宜得多,而且能让模型更专注当前任务。

3.4 接入 LLM API:先解决格式校验,再关注提示词

接入 LLM API 时,大家很容易一开始就花很多时间调提示词,求“答案更聪明”。但对 Agent 场景来说,更重要的是接口层的健壮性:网络超时、返回截断、JSON 格式错误、Function Call 参数缺失,这些都是常见问题。

建议写一层统一的 LLM 封装,对外暴露一个choose_action(state)方法,内部处理调用、重试、解析、校验。比如用 OpenAI 兼容接口时,请求体大致是:

{ "model": "your-model-name", "messages": [ {"role": "system", "content": "你是游戏助手,只能调用可用动作函数。"}, {"role": "user", "content": "当前状态:...请选择下一步动作。"} ], "tools": [ { "type": "function", "function": { "name": "dig", "parameters": { "type": "object", "properties": { "x": {"type": "number"}, "y": {"type": "number"}, "z": {"type": "number"} } } } } ], "tool_choice": "auto", "temperature": 0.3, "max_tokens": 300 }

注意,不同的模型对 tools 格式支持程度不一样,落地时要以你实际调用的接口文档为准。这里只给通用形式。

返回后必须做两件事:一是检查 API 返回是否成功,二是检查模型返回的动作是否能被执行层接受。如果解析失败,可以带着错误信息让模型重新生成一次。重试次数不要太多,一般两三次就够。

4. 实测验证:怎么判断 Agent 是在“玩”,而不是在“随机输出”

4.1 先定义成功指标,再开始测试

没有指标,就没法判断 Agent 到底行不行。刚开始跑的时候,我一般不会看“这局赢没赢”,而是看几个更基础的过程指标。

第一个指标是连续有效动作比例。在所有模型输出中,有多少动作名能通过校验、参数完整、执行成功。比例长期偏低,说明执行层或动作空间设计有问题。

第二个指标是单局生存时长。Dwarf Fortress 里,一个什么都不会的 Agent 也能活一段时间,但如果它频繁做出自杀式决策,比如把矮人派到怪物堆里,存活时长会明显下降。这个指标能反映决策质量,但不能单独看,最好结合日志检查。

第三个指标是目标完成度。比如你给 Agent 设了一个目标“在第一个冬季前挖出 20 格仓库面积”,然后统计它完成多少。这是更关键的业务指标,但需要你预先定义可量化的目标。

第四个指标是错误恢复能力。执行层失败后,Agent 是否能重新读取状态,提出新的动作,而不是死循环或卡住。这个指标在真实使用中很关键,因为所有环境都不可能完美。

建议把这些指标写入日志,每轮记录一次。最终统计时,可以按局、按时间窗口、按事件类型来分析。

4.2 参数调优顺序:先稳定,再效率

调参时,不要一上来就把 temperature 拉高,或者把 max_tokens 设成很大。Agent 场景和对话生成不一样,它更需要稳定、可复现的输出。

我常用的参数设置逻辑是:

  • temperature:决策任务建议低一点,比如 0.2 到 0.4。太高会让模型频繁尝试奇怪动作;太低又可能动作单一。可以在稳定后慢慢调。
  • max_tokens:动作输出通常很短,300 到 500 基本够用。如果模型还要输出分析和中间推理,可以适当调大,但要防止它生成超长文本导致超时。
  • 重试次数:2 到 3 次。第一次失败可能是网络抖动,第二次失败可能是提示词问题,连续三次失败就记录日志,不要无限重试。
  • 并发:如果你是单局 Agent,不需要高并发。跑批量评估时再考虑并发,但一定是先确保单任务稳定,再提高吞吐量。

给一个参数参考表:

参数建议初始值说明
temperature0.3低随机性,适合动作选择
max_tokens400覆盖动作和简短理由
重试次数2网络异常或解析失败时使用
单局最大轮数500防止无限循环
状态读取间隔1 秒根据游戏速度调整

注意,不同模型对温度敏感度不同,参数要以你的实际接口为准。初始值只是起点,不是标准答案。

4.3 常见卡点和排查顺序

测试中会遇到很多卡点。下面列几种最常见的情况,以及我建议的排查顺序。

现象一:Agent 长时间没有动作。先确认是不是状态读取有问题。去看看日志文件有没有新增内容,脚本是不是读到了旧状态。再检查游戏是否暂停了,或者日志写入路径变了。然后看 LLM 调用是否超时。很多时候不是模型不决策,而是根本没人告诉它有新状态。

现象二:模型总是返回同一个动作,比如一直在“挖矿”。先看状态描述里有没有包含目标。如果模型只知道“当前任务挖矿”,不知道仓库已经满了,它当然会继续挖。然后看上下文里是否保留了最近的结果反馈。有时候是执行成功了但回调没写入状态,导致模型看不到效果。

现象三:动作执行了,但游戏里没变化。优先检查执行通道。模拟按键可能被窗口焦点干扰,命令脚本可能因为游戏版本不匹配而静默失败。建议在动作执行前后各截一次图或读一次日志,对比结果。

现象四:模型输出经常解析失败。检查返回内容是否被截断,JSON 里是否有注释或其他格式问题,Function Calling 是否被模型当作普通文本输出。可以开启接口的原始返回日志,定位到底哪一步解析失败。

排查顺序一般是这样:先看输入来源,再看执行通道,最后看模型返回。很多人一遇到问题就改提示词,结果发现是游戏路径拼错了,浪费时间。

4.4 避坑:不要把 Agent 调优等同于“调提示词”

这个坑几乎翻过的人都知道:项目一跑不顺,就去改 system prompt,反复加“你要聪明一点”“你要记住目标”之类的话。但提示词只能影响模型决策,弥补不了状态缺失、动作空间不合理和反馈链路断裂。

举个例子。如果状态描述里没有矮人当前的位置,模型无论怎么提示都可能选错目标。如果动作执行失败后没有把错误信息传回模型,它就只能瞎猜。这时候改提示词没有意义。

正确做法是:先保证工程闭环稳定,再调提示词。也就是说,出现坏结果时,先问自己一个问题:模型拿到信息是否足够决策?执行动作是否可靠?失败是否能被感知?这三个问题都确认了,再怀疑提示词。

5. 从 Demo 到可复用:批量评估、日志设计和成本控制

5.1 小样本优先:先跑 3 局,再扩到 30 局

Agent 项目很容易让人兴奋,一上来就想让 Agent 多跑几局看看表现。但 Dwarf Fortress 单局时间长,状态复杂,跑一局要看很久。我建议先跑 3 局,每局只做最小目标验证,比如“存活到第一个冬季”。跑完 3 局后修掉明显问题,再扩展到 30 局。

批量评估时,尽量保证环境一致。Dwarf Fortress 可以用固定开局或种子世界,让不同次运行有可比性。否则每次地形和事件都不同,Agent 的表现差异可能来自随机性,而不是策略优劣。

另外,批量跑的时候要设计“断点续跑”。如果第 15 局中途崩了,能从上次的记录重新开始,而不是整个任务作废。这个能力在生产环境尤其重要。

5.2 日志和结果格式:给排查留一条完整链路

日志设计越早越好。不要只记录“模型说了什么”,还要记录“模型看到了什么”和“执行后发生了什么”。

我建议每轮 Agent 运行都写一条 JSONL 记录,字段至少包括:

  • 时间戳
  • 局 ID 和轮次号
  • 游戏状态摘要
  • 发送给模型的完整 prompt(如果可能)
  • 模型原始返回
  • 解析后的动作
  • 执行结果
  • 执行耗时
  • token 消耗
  • 错误信息

有了这些日志,你才能回放任何一局的完整轨迹。否则,Agent 出了问题,你手里只有一个“它表现不好”的模糊印象,很难定位。

批量评估结束后,可以用脚本统计成功率、动作分布、错误类型占比,从而快速判断下一步该修哪一块。

5.3 成本与资源:把每次决策的 token 和耗时算清楚

LLM Agent 看起来很酷,但成本是实实在在的。在 Dwarf Fortress 这样的长会话场景里,每轮决策都会调用一次模型,token 消耗会很快增长。

测算成本时,我不建议只算“每阶段 API 价格”,而是把整个链条都算进去。比如一次决策可能包含:状态压缩、主决策、失败重试、定期摘要。每一个环节都会消耗 token。

你需要记录三个数字:

  • 每轮 Action 平均 token 消耗
  • 每轮 Action 平均耗时
  • 单局完整运行的 token 总量和请求次数

有了这些数据,你就能判断优化方向。如果 token 大头在状态描述,就加强状态压缩;如果大头在摘要,就降低摘要频率;如果耗时主要在等待模型返回,可以考虑缓存重复状态或升级网络条件。

5.4 后续扩展方向:从单局 Demo 到通用 Agent 框架

跑通 Dwarf Fortress Agent 后,你会发现很多能力可以迁移到其他场景。比如你设计的状态压缩模板,可以用到文档问答 Agent 上;你写的动作校验层,本质上是一个工具调用网关;你做的摘要记忆,也可以用于客服机器人或自动化运维 Agent。

再往后,你还可以加入规划模块:不只让模型走一步看一步,而是先让模型制定一个短期计划,再逐步执行,并根据反馈修正计划。这个能力在复杂任务里非常有用。

长期来看,这个项目真正的价值,是你亲手搭建了一套“模型-工具-环境”的交互基础设施。以后再遇到新的 Agent 需求,你只需要替换状态解析和动作执行这两层,剩下的循环框架可以直接复用。

这类项目最有意思的地方,是它把“让模型做决定”和“让决定真正生效”两件事放在一起考验。你很难只靠一个聪明的提示词就通关,必须先保证状态、动作和反馈三个环节是闭环。如果只能留一个建议,我会说:先把单任务跑稳,再谈批量和扩展。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询