你想计划一次果阿(Goa)之旅。你有五天时间,预算为 25,000 卢比,并且明确偏好海滩和海鲜。通常,这意味着要打开十个浏览器标签页,阅读过时的论坛帖子,并手动拼凑行程。相反,想象一下发送一个单一的 POST 请求,就能得到一个包含用餐建议、活动列表和精确预算分配的结构化逐日计划。这就是这个项目所提供的。
我们将使用 Spring Boot 和 Azure OpenAI 构建一个 REST API。该 API 接收目的地、预算、时长和兴趣。它返回干净的 JSON,前端或移动应用可以立即进行渲染。无需爬虫,无需硬编码行程。只需通过提示词(prompt)让 AI 模型充当旅行规划师即可。
API 返回的内容
响应不是一段需要你用正则表达式(regex)去拆分的 Markdown 文本。它是一个结构化的 JSON 对象,包含每日活动、用餐建议和预算明细。对于果阿之旅,你可能会收到第一天的部分内容,其中分配了 500 卢比用于在海滩小屋吃早餐,上午在 Palolem 游玩,晚上在特定区域享用海鲜晚餐。每一天都带有时间段、预估成本以及像“海滩”或“美食”之类的标签。这种结构非常重要,因为现代旅游应用不希望解析段落,它们需要可以映射到 RecyclerView 或 React 组件的对象。
技术栈及其适用性
该项目使用带有 Spring AI 的 Spring Boot 3.5。Spring AI 是关键部分。它提供了一个统一的 ChatModel 抽象,因此你无需针对 Azure OpenAI 编写原始的 HTTP 客户端。你只需更换依赖项和属性,而无需更改服务代码。
你需要在构建文件中添加四个依赖项:
spring-boot-starter-web用于 REST 层。spring-ai-starter-model-azure-openai通过 Spring AI 的接口连接到 LLM。springdoc-openapi用于自动生成 Swagger 文档。Lombok用于减少请求和响应 POJO 中的样板代码。
Spring AI 位于你的业务逻辑和 LLM 提供商之间。这种定位是有意为之的。它能保持你的 @Service 类简洁且与提供商无关。
使用 PromptTemplates 进行提示词工程
在 Java 字符串中硬编码提示词是导致软件难以维护的快捷方式。如果产品团队决定 AI 的语气应该更随和,或者拒绝超过一定阈值的预算估算,你不应该需要重新编译你的服务。
Spring AI 提供了 PromptTemplate。你可以将提示词骨架存储在资源文件中或专门的模板字符串中,为 {destination}、{budget}、{days} 和 {interests} 等变量留下占位符。在运行时,服务会创建一个 Prompt 对象并注入用户的值。
将系统消息(system messages)与用户消息(user messages)分开。使用系统消息来定义角色(persona)。例如,你可以告诉模型它是一个专门从事印度目的地旅游、注重预算,并且严格要求仅返回 JSON(不带 Markdown 围栏)的旅行规划师。使用用户消息来传递具体的旅行细节。这种分离有助于你以后在不改变 API 合约的情况下对角色进行 A/B 测试。
服务层:与 Azure OpenAI 通信
@Service 类只有一个任务:构建提示词、调用模型、清理响应并解析结果。
注入 Spring AI 的 ChatClient 或 ChatModel。使用传入的请求值渲染 PromptTemplate,然后调用聊天方法。响应以 String 形式返回。这里正是许多教程停止、而真正的生产级代码开始的地方。
LLM 有时会添加礼貌性的前言。你可能会收到一个以“这是您的行程”开头,然后是包裹在三个反引号中的 JSON 的响应。如果你尝试直接使用 Jackson 进行反序列化,你的应用就会崩溃。添加一个小的辅助方法,扫描原始字符串,找到第一个左大括号和最后一个右大括号,并仅提取 JSON 有效负载。然后验证提取的代码块。在将对象返回给控制器之前,检查所需字段是否存在以及数值是否合理。
这种防御性解析不是可选的。它是演示 demo 与可靠 API 之间的分界线。
像成熟系统一样处理错误
外部 API 会失败。Azure OpenAI 会返回速率限制错误、身份验证失败或瞬时的 500 错误。如果你让这些错误以堆栈跟踪(stack traces)的形式直接抛给用户,你会失去可信度。
使用 @RestControllerAdvice 全局拦截异常。将 Spring AI 异常、HttpClientErrorException 以及通用的 RuntimeException 映射为一致的错误响应。返回一个包含清晰消息、HTTP 状态码(例如针对速率限制返回 429)以及足够详细信息的 JSON 主体,以便客户端重试或记录问题。用户应该看到类似“服务暂时繁忙,请在 30 秒后重试”的消息,而不是满屏的 Java 类名。
切勿硬编码密钥
你的 Azure OpenAI API 密钥不应该出现在提交到 Git 的 application.properties 中。请将其外部化。在 Spring 配置中使用引用的环境变量,例如 ${AZURE_OPENAI_KEY} 和 ${AZURE_OPENAI_ENDPOINT}。在开发时保留一个本地的 .env 文件,将其添加到 .gitignore 中,并通过 Spring Boot 的松散绑定(relaxed binding)进行加载。如果密钥泄露,你只需在一个地方进行轮换,而无需重新构建你的构建产物(artifact)。
通过 Swagger 进行测试
springdoc-openapi 依赖项会在运行时暴露一个 Swagger UI 端点。应用程序启动后,在浏览器中打开 /swagger-ui.html。你可以直接填写 Goa 的示例:目的地为 "Goa",预算为 25000,天数为 5,兴趣为 "beaches, food"。点击 execute,观察 JSON 格式的行程单出现。这让你能够在编写单元测试之前,验证提示词(prompt)的更改、验证序列化,并与前端开发人员共享一个实时的测试环境(playground)。
无需重写代码即可更换供应商
初创公司经常更换供应商。也许是 Azure 额度到期了,或者你想通过运行本地 Ollama 实例来降低成本。由于 Spring AI 抽象了 ChatModel 接口,这种更换是机械化的。只需将 Maven 依赖从 spring-ai-starter-model-azure-openai 更改为另一个 starter,在属性文件中更新新的端点和密钥,然后保持你的 service 类不变即可。移动端应用看到的 API 契约保持完全一致。
这种可移植性使得该架构对于实际产品特别有用。你并不是在与 Azure “结婚”。你只是将其作为一个引擎,接入到一个整洁的 Spring 流水线中。
核心总结
AI 模型并不是你的应用程序。它是一个返回不可预测文本的外部服务。对待它的严谨程度应与对待支付网关或第三方天气 API 一致。将凭据外部化。验证每一个响应。在解析之前清理负载(payload)。全局处理错误,这样你的用户就永远不会看到堆栈跟踪(stack trace)。
让 AI 去处理在 25,000 卢比预算下构建 Goa 行程的创意工作。你负责处理底层的“管道”工作(plumbing)。当两者保持分离时,你才能获得一个真正可以交付(ships)的系统。
启发本文的原始教程可以在这里找到。
有兴趣讨论 Spring AI 及类似项目吗?加入 GyaanSetu 学习社区。
