基于Spring AI与LLM构建推箱子AI智能体:从提示词工程到实战部署
在实际项目开发中我们常常会遇到一些看似简单、但实现起来却需要精细逻辑控制的“小游戏”类问题比如经典的“推箱子”和“移红点”谜题。这些问题的核心在于状态空间搜索和路径规划它们不仅是算法面试的常客更是检验一个AI模型或算法框架逻辑推理与规划能力的绝佳试金石。如今随着Spring AI、LangChain等框架的兴起以及DeepSeek、Kimi等大模型在代码生成和逻辑推理上的突破开发者拥有了前所未有的工具来快速构建和测试这类智能体AI Agent。然而将前沿AI能力应用于解决具体问题时我们往往会发现从“模型能聊天”到“程序能正确运行并解决一个明确约束的问题”中间隔着巨大的工程鸿沟。本文将以“使用Spring AI构建一个能解决推箱子问题的AI智能体”为主线带你完整走通从环境搭建、问题定义、提示词工程、代码实现到结果验证的全过程。这不是一个简单的API调用演示而是一个深入集成逻辑推理与程序执行的实战项目。你将了解到如何让大模型理解复杂的游戏规则和状态如何引导它生成可执行且正确的解决方案以及在此过程中必然会遇到的典型问题与排查方法。无论你是想探索AI在复杂规划问题上的应用还是希望将Spring AI等框架用于实际的自动化决策场景这篇文章都将提供一份可复现的详细指南。1. 理解问题为什么推箱子和移红点是AI的“试金石”在深入代码之前我们必须先厘清我们要解决的问题究竟是什么以及为什么它适合用来检验AI的规划能力。1.1 推箱子Sokoban问题的核心挑战推箱子是一个经典的仓库番游戏。其状态可以用一个二维网格表示包含以下元素墙壁 (#)不可穿越的障碍物。空地 (.)可以移动的区域。箱子 ($)可以被玩家推动的物体。目标点 (*)箱子需要被推到的位置。玩家 ()控制的对象。箱子在目标点上 (*)有时用*表示。玩家在目标点上 ()有时用表示。规则玩家可以向上、下、左、右四个方向移动。如果移动方向是空地或目标点则直接移动。如果移动方向是一个箱子并且箱子的下一个位置是空地或目标点则玩家可以推动箱子一起移动一格。目标是将所有箱子推到所有目标点上。挑战状态空间巨大每一步都有多个移动选择解空间随步数指数级增长。死锁检测箱子被推到墙角或两个箱子并排靠墙导致无解AI需要能识别并避免这种状态。动作序列规划需要生成一系列有序的动作如上 右 下 左而不仅仅是单步决策。全局优化可能存在多条路径需要找到较优步数较少的解。对于AI来说它不仅要理解这些符号代表的语义和规则还要能进行前瞻性的搜索和规划这直接考验了其逻辑推理和序列生成能力。1.2 移红点问题的抽象“移红点”可能指代一类更广泛的谜题例如“华容道”、“滑动拼图”或特定的点灯游戏。我们可以将其抽象为在一个特定结构的棋盘上有一个或多个红色标记点通过一系列符合规则的操作如滑动、交换、点击等将所有红点移动到目标位置。其挑战与推箱子类似但规则可能更复杂或更简单。它同样要求AI理解状态、规则并生成动作序列。1.3 AI作为求解器从生成文本到生成解决方案传统的解决方法会使用搜索算法如BFS、DFS、A*并手动编写启发函数。而我们今天要做的是让一个大语言模型LLM来扮演这个“求解器”的角色。我们的任务不是教AI算法而是通过精心设计的提示词Prompt和程序交互引导LLM输出一个正确的、可解析的动作序列。这其中的关键转变在于LLM的输出不再是展示性的文本而是要被后续程序严格执行的指令。一个错误的动作可能导致整个状态进入死锁因此对输出的准确性和可靠性要求极高。这正是测试“前沿AI”工程化能力的绝佳场景。2. 环境准备与项目初始化我们将使用Spring AI作为与大模型交互的核心框架它提供了统一的API可以方便地切换不同的模型提供商如OpenAI、Ollama、Azure OpenAI等。本项目以Ollama本地运行Meta Llama 3.1 8B模型为例兼顾可访问性和推理能力。2.1 基础环境要求确保你的开发环境满足以下条件组件要求说明JDK17 或 21Spring AI 3.x 需要 JDK 17构建工具Maven 3.6 或 Gradle 8本文使用 MavenOllama最新稳定版用于本地运行大模型 官网下载模型llama3.1:8b或qwen2.5:7b在Ollama中拉取指令见下文2.2 初始化Spring Boot项目使用 Spring Initializr 创建项目选择以下依赖Spring Web提供HTTP接口方便测试。Spring AI核心AI依赖。Lombok简化Java Bean代码可选但推荐。生成的pom.xml关键依赖部分如下parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.3.0/version !-- 使用与Spring AI兼容的最新版本 -- relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-ollama-spring-boot-starter/artifactId /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency /dependencies注意Spring AI的版本由Spring Boot的Bill of Materials (BOM)管理通常无需单独指定。确保你的spring-boot-starter-parent版本与Spring AI兼容3.2.x 或 3.3.x通常对应Spring AI 1.x。2.3 配置Ollama与模型安装并启动Ollama根据你的操作系统下载安装Ollama安装后它通常会作为服务自动启动。可以通过命令行ollama --version验证。拉取模型打开终端执行以下命令拉取一个适合推理的模型。Llama 3.1 8B是一个不错的平衡选择。ollama pull llama3.1:8b这个过程会下载数GB的模型文件请确保网络通畅和磁盘空间充足。验证模型运行运行ollama run llama3.1:8b在交互式命令行中输入简单问题确认模型能正常回复。配置Spring AI连接Ollama在src/main/resources/application.yml中添加配置。spring: ai: ollama: base-url: http://localhost:11434 # Ollama默认服务地址 chat: options: model: llama3.1:8b # 指定使用的模型 temperature: 0.1 # 降低随机性使输出更确定对于规划任务很重要temperature参数设置为接近0的值如0.1可以大幅减少模型回答的随机性这对于需要稳定、正确输出动作序列的任务至关重要。3. 核心模块设计与实现我们的系统主要分为三个部分游戏状态管理、AI求解器、交互控制器。我们将采用经典的“提示词工程”方法让LLM根据当前游戏状态输出下一步动作。3.1 定义游戏状态与规则引擎首先我们需要用代码定义推箱子的地图、状态和规则。这是后续所有操作的基础。1. 定义地图元素枚举package com.example.sokobanai.model; import lombok.Getter; Getter public enum Cell { WALL(#), FLOOR(.), BOX($), TARGET(*), PLAYER(), BOX_ON_TARGET(*), // 注意字符可能与TARGET相同需在逻辑中区分 PLAYER_ON_TARGET(); private final char symbol; Cell(char symbol) { this.symbol symbol; } public static Cell fromSymbol(char c) { for (Cell cell : values()) { if (cell.symbol c) { return cell; } } throw new IllegalArgumentException(Unknown cell symbol: c); } }2. 定义游戏状态类这个类负责存储当前地图、玩家位置并封装核心的游戏逻辑如移动、验证、胜利判断等。package com.example.sokobanai.model; import lombok.Data; import java.awt.Point; import java.util.ArrayList; import java.util.List; Data public class GameState { private Cell[][] grid; private Point playerPos; private int width; private int height; private ListPoint targetPositions; // 从字符串数组初始化地图常见关卡格式 public GameState(String[] level) { this.height level.length; this.width level[0].length(); this.grid new Cell[height][width]; this.targetPositions new ArrayList(); this.playerPos new Point(); for (int y 0; y height; y) { for (int x 0; x width; x) { char c level[y].charAt(x); Cell cell Cell.fromSymbol(c); grid[y][x] cell; if (cell Cell.PLAYER || cell Cell.PLAYER_ON_TARGET) { playerPos.setLocation(x, y); } if (cell Cell.TARGET || cell Cell.BOX_ON_TARGET || cell Cell.PLAYER_ON_TARGET) { targetPositions.add(new Point(x, y)); } } } } // 将当前状态渲染为字符串用于展示和发送给AI public String render() { StringBuilder sb new StringBuilder(); for (int y 0; y height; y) { for (int x 0; x width; x) { sb.append(grid[y][x].getSymbol()); } sb.append(\n); } return sb.toString(); } // 核心移动逻辑 public boolean move(String direction) { int dx 0, dy 0; switch (direction.toUpperCase()) { case 上: case U: dy -1; break; case 下: case D: dy 1; break; case 左: case L: dx -1; break; case 右: case R: dx 1; break; default: return false; } int newX playerPos.x dx; int newY playerPos.y dy; // 检查边界和墙壁 if (newX 0 || newX width || newY 0 || newY height) { return false; } Cell targetCell grid[newY][newX]; if (targetCell Cell.WALL) { return false; } // 如果目标是箱子需要检查箱子后面一格 if (targetCell Cell.BOX || targetCell Cell.BOX_ON_TARGET) { int boxNewX newX dx; int boxNewY newY dy; if (boxNewX 0 || boxNewX width || boxNewY 0 || boxNewY height) { return false; } Cell behindBoxCell grid[boxNewY][boxNewX]; if (behindBoxCell ! Cell.FLOOR behindBoxCell ! Cell.TARGET) { return false; // 箱子后面是墙或另一个箱子推不动 } // 移动箱子 grid[boxNewY][boxNewX] (behindBoxCell Cell.TARGET) ? Cell.BOX_ON_TARGET : Cell.BOX; grid[newY][newX] (targetCell Cell.BOX_ON_TARGET) ? Cell.TARGET : Cell.FLOOR; } // 移动玩家 Cell oldPlayerCell grid[playerPos.y][playerPos.x]; grid[playerPos.y][playerPos.x] (oldPlayerCell Cell.PLAYER_ON_TARGET) ? Cell.TARGET : Cell.FLOOR; grid[newY][newX] (targetCell Cell.TARGET || targetCell Cell.BOX_ON_TARGET) ? Cell.PLAYER_ON_TARGET : Cell.PLAYER; playerPos.setLocation(newX, newY); return true; } // 判断是否胜利所有目标点上都有箱子 public boolean isWin() { for (Point target : targetPositions) { Cell cell grid[target.y][target.x]; if (cell ! Cell.BOX_ON_TARGET) { return false; } } return true; } }3.2 构建AI求解器Agent这是连接LLM与游戏逻辑的核心。我们将设计一个提示词模板将当前游戏状态、规则和历史动作作为上下文输入给模型并要求它输出下一个动作。1. 创建提示词模板服务package com.example.sokobanai.service; import com.example.sokobanai.model.GameState; import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.chat.prompt.PromptTemplate; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.util.HashMap; import java.util.Map; Service public class AISolverService { Autowired private ChatModel chatModel; // Spring AI 会自动注入配置好的模型如Ollama private static final String PROMPT_TEMPLATE 你是一个专业的推箱子游戏求解器。请根据当前游戏状态分析并只输出下一步最优动作。 游戏规则 1. 地图符号#代表墙.代表空地$代表箱子*代表目标点代表玩家*也代表箱子在目标点上代表玩家在目标点上。 2. 玩家可以向上、下、左、右移动一格。 3. 如果玩家移动方向是箱子并且箱子前方是空地或目标点则可以推动箱子。 4. 目标是将所有箱子$推到所有目标点*上。 当前游戏状态地图 {currentState} 历史动作序列最近5步 {history} 请严格遵循以下输出格式 1. 首先用一句话简要分析当前局面指出关键障碍或目标。 2. 然后在新的一行输出动作后面紧跟一个动作指令。指令只能是以下四种之一上、下、左、右。 3. 不要输出任何其他解释、Markdown格式或额外符号。 示例输出 当前需要将左上角的箱子向右推一步以避开墙角。 动作右 ; public String getNextAction(String currentState, String history) { MapString, Object promptVariables new HashMap(); promptVariables.put(currentState, currentState); promptVariables.put(history, history); PromptTemplate promptTemplate new PromptTemplate(PROMPT_TEMPLATE, promptVariables); Prompt prompt promptTemplate.create(); // 调用AI模型 String aiResponse chatModel.call(prompt).getResult().getOutput().getContent(); // 解析响应提取动作指令 return parseActionFromResponse(aiResponse); } private String parseActionFromResponse(String response) { // 简单的解析逻辑查找“动作”后面的词 String[] lines response.split(\n); for (String line : lines) { if (line.trim().startsWith(动作)) { String action line.trim().substring(3).trim(); // 取出“动作”后面的部分 if (action.equals(上) || action.equals(下) || action.equals(左) || action.equals(右)) { return action; } } } // 如果解析失败可以记录日志并返回一个默认动作或抛出异常 throw new RuntimeException(无法从AI响应中解析出有效动作。响应内容\n response); } }提示词设计要点角色设定明确告诉模型它扮演的角色。规则清晰用简洁的语言复述游戏规则。状态输入将渲染后的地图字符串作为变量传入。历史上下文提供最近几步动作帮助模型避免循环。输出格式强制约束这是最关键的一步。明确要求模型先分析再输出并且动作指令必须严格遵循指定格式和词汇。这极大地提高了后续程序解析的成功率。示例给出一个输出示例让模型更好地理解格式要求。3.3 创建游戏控制循环与API接口我们需要一个控制器来管理游戏生命周期初始化关卡 - 循环调用AI求解器 - 执行动作 - 检查状态 - 直到胜利或失败。1. 游戏服务类package com.example.sokobanai.service; import com.example.sokobanai.model.GameState; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.util.LinkedList; Service Slf4j public class GameService { Autowired private AISolverService aiSolverService; private GameState currentGame; private LinkedListString actionHistory; private static final int MAX_STEPS 200; // 防止无限循环 // 初始化一个简单关卡 public void initLevel() { String[] level { #####, #$*#, ##### }; // 一个非常简单的关卡一步即可解决 this.currentGame new GameState(level); this.actionHistory new LinkedList(); log.info(游戏初始化。初始状态\n{}, currentGame.render()); } // 运行一轮AI求解 public String runSingleStep() { if (currentGame.isWin()) { return 游戏已胜利; } if (actionHistory.size() MAX_STEPS) { return 步数超过限制可能陷入循环。; } String currentStateStr currentGame.render(); String historyStr String.join( - , actionHistory.subList( Math.max(0, actionHistory.size() - 5), actionHistory.size())); // 最近5步历史 try { String nextAction aiSolverService.getNextAction(currentStateStr, historyStr); log.info(AI建议动作{}, nextAction); boolean moveSuccess currentGame.move(nextAction); if (!moveSuccess) { log.error(AI建议的动作 {} 在当前状态下无效, nextAction); return AI建议了无效动作 nextAction; } actionHistory.add(nextAction); log.info(执行动作成功。当前状态\n{}, currentGame.render()); if (currentGame.isWin()) { return String.format(胜利共用了 %d 步。动作序列%s, actionHistory.size(), String.join( - , actionHistory)); } return 执行动作: nextAction 。游戏继续。; } catch (Exception e) { log.error(调用AI或执行动作时发生错误, e); return 求解过程出错 e.getMessage(); } } // 获取当前状态 public String getCurrentState() { return currentGame ! null ? currentGame.render() : 游戏未初始化; } // 获取历史动作 public ListString getActionHistory() { return new ArrayList(actionHistory); } }2. 提供REST API接口package com.example.sokobanai.controller; import com.example.sokobanai.service.GameService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; RestController RequestMapping(/api/sokoban) public class GameController { Autowired private GameService gameService; PostMapping(/init) public String initGame() { gameService.initLevel(); return 游戏初始化成功。\n当前状态\n gameService.getCurrentState(); } PostMapping(/step) public String runStep() { return gameService.runSingleStep(); } GetMapping(/state) public String getState() { return gameService.getCurrentState(); } GetMapping(/history) public ListString getHistory() { return gameService.getActionHistory(); } }4. 运行验证与结果分析完成代码编写后我们可以启动项目并进行测试。4.1 启动与测试启动Spring Boot应用运行主类的main方法。初始化游戏使用curl或 Postman 调用POST /api/sokoban/init。curl -X POST http://localhost:8080/api/sokoban/init返回结果应包含初始地图。单步执行调用POST /api/sokoban/step让AI决策并移动一步。curl -X POST http://localhost:8080/api/sokoban/step查看状态和历史通过GET /api/sokoban/state和/history查看进度。4.2 预期输出与调试对于一个简单关卡#$*#AI应该能很快输出“右”或“左”取决于目标点位置然后游戏胜利。关键检查点Ollama服务是否正常观察应用启动日志确认Spring AI成功连接Ollama。如果连接失败检查Ollama是否运行在http://localhost:11434。AI响应格式查看AISolverService中的日志确认AI返回的文本是否包含“动作右”这样的格式。如果格式不符需要调整提示词。游戏逻辑是否正确手动调用几次/step观察地图状态变化是否符合推箱子规则。胜利条件判断当所有箱子都在目标点上时调用/step应返回胜利信息。4.3 尝试更复杂的关卡将GameService中的initLevel方法替换为更复杂的关卡测试AI的规划能力。例如String[] level { #####, # #, #$ #, ### $##, # $ $ #, # # #, # * #, ##### };这是一个中等难度的关卡。观察AI能否在有限的步数内找到解决方案或者是否会陷入循环。5. 常见问题排查与优化策略在实际运行中你几乎一定会遇到以下问题。这里提供系统的排查思路和优化方案。5.1 AI相关问题排查表问题现象可能原因检查与解决方式启动报错无法连接Ollama1. Ollama服务未启动。2. 配置的base-url端口错误。3. 防火墙或网络策略阻止。1. 终端执行ollama serve查看服务状态。2. 确认application.yml中的base-url与Ollama运行地址一致默认11434。3. 使用curl http://localhost:11434/api/tags测试Ollama API是否可达。调用AI时超时或响应极慢1. 模型首次加载需要时间。2. 硬件CPU/内存不足。3. 提示词过长或复杂。1. 首次调用耐心等待模型加载。2. 考虑使用更小的模型如llama3.2:3b或优化硬件。3. 精简提示词移除不必要的描述。AI返回的动作格式不正确无法解析1. 提示词中对输出格式的约束不够强。2.temperature参数过高导致输出随机。3. 模型能力不足无法遵循指令。1.强化提示词在提示词中使用“必须”、“只能”、“严格遵循”等词并给出更清晰的示例。2.降低temperature在Ollama配置中设置为0.1或0。3.后处理与重试在parseActionFromResponse方法中增加更健壮的解析逻辑如正则表达式如果解析失败可以记录警告并尝试让AI重新生成或返回一个安全默认动作如“等待”。AI建议的动作无效撞墙、推不动1. AI对规则理解有偏差。2. 当前状态描述地图字符串可能被AI误解。3. 缺乏全局规划只看到局部。1.在提示词中加入更详细的规则和无效动作示例。2.改进状态表示可以考虑用文字辅助描述如“P代表玩家”。3.引入思维链Chain-of-Thought要求AI在输出动作前先一步步分析“玩家当前位置是(x,y)向左一格是墙不能走向上一格是空地可以走……”。这能显著提升推理准确性。AI陷入循环重复相同动作1. 提示词中未提供足够的历史信息。2. AI没有“记忆”或“避圈”能力。1.在提示词中提供历史动作序列如我们代码中的{history}明确告知“避免重复之前的动作”。2.在游戏服务层实现循环检测如果检测到状态重复或动作序列出现短循环主动中断并提示AI“你似乎陷入了循环请尝试不同的策略”。5.2 工程优化与最佳实践引入状态验证与回滚在GameService中执行AI动作前可以先模拟移动验证动作是否合法。如果不合法则不执行并反馈给AI“上一个建议动作无效请重新思考”。可以保存历史状态栈当AI走入死胡同时允许回退到上一步。实现更强大的求解器模式当前的“单步决策”模式对AI要求很高。可以改为“多步规划”模式要求AI一次性输出一个完整的动作序列如“右下左上”。然后由程序逐个验证和执行遇到失败则请求重新规划。结合传统搜索算法让AI负责“高层策略”如“优先将某个箱子推到角落的目标点”而由A*算法负责具体的路径寻找。这是一种混合智能Hybrid AI的思路。提示词工程优化Few-Shot Prompting在提示词中提供2-3个从状态到正确动作的完整示例让模型通过示例学习。输出格式化要求模型以严格的JSON格式输出如{analysis: ..., action: 右}这比解析自然语言稳定得多。分步思考强制模型输出思考过程这不仅能提高准确性也便于调试。生产环境考量超时与重试调用AI接口必须设置合理的超时时间并实现重试机制。限流与降级如果使用云端AI服务需要考虑API调用频率限制。本地模型则需考虑GPU内存管理。日志与监控详细记录AI的请求和响应、游戏状态变迁这是排查诡异问题的唯一依据。单元测试为GameState的移动逻辑、胜利判断等编写详尽的单元测试确保游戏引擎本身绝对正确。6. 扩展方向从推箱子到通用AI智能体本项目虽然以推箱子为例但其架构模式可以扩展到更广泛的AI智能体AI Agent应用。更换问题领域只需替换GameState和规则引擎同样的AISolverService框架可以用于解决“移红点”、数独、N皇后、路径规划等任何定义明确的状态空间搜索问题。集成更强大的模型将application.yml中的配置从ollama改为openai或azure-openai即可无缝切换至GPT-4等更强大的模型以应对更复杂的规划任务。实现自动化工作流这正是搜索材料中提到的“类似Coze的工作流功能”的雏形。你可以将多个这样的“决策节点”串联起来每个节点负责解决一个子问题共同完成一个复杂任务。加入工具调用Function Calling让AI不仅能输出动作还能调用你预先定义好的工具函数例如“计算两点距离”、“查询数据库”、“发送HTTP请求”。Spring AI对此有很好的支持这能将AI的推理能力与外部系统和数据连接起来构建真正实用的AI应用。通过这个项目你不仅学会了如何用Spring AI和LLM解决一个具体的规划问题更重要的是掌握了一套将模糊的AI能力转化为可靠、可测试、可集成的软件组件的方法论。这正是在实际项目中应用“前沿AI”所需要跨越的工程鸿沟。