
1. 为什么 Java 项目里突然都在聊 Prompt 模板如果你最近半年在写 Java尤其是碰过 Spring Boot 项目大概率会注意到一个现象以前项目里管的是 SQL 模板、Freemarker 模板、Thymeleaf 模板现在开始有人管 Prompt 模板了。这不是赶时髦而是因为大模型能力接入业务系统之后Prompt 本身已经变成了一种需要被工程化管理的资源它跟 SQL 一样写死在代码里迟早要出问题。我最早接触这块是在一个 Spring Boot 的智能客服项目里。当时团队的做法非常原始把提示词直接拼在 Service 方法里用字符串加法拼出一段话然后丢给模型。上线第一周就炸了——运营想改一句引导语开发要重新打包发版不同业务线要复用同一套角色设定结果复制粘贴出七八个版本改一处漏六处更麻烦的是上下文越拼越长Token 消耗失控月底账单直接翻倍。那时候我才意识到Prompt 模板与上下文管理不是锦上添花而是 Java 项目接入 AI 能力后的基础设施。这篇文章想聊的就是这件事在一个标准的 Java / Spring Boot 项目里怎么把 Prompt 模板管起来怎么把上下文Context管明白。关键词里提到的 Spring AI、Spring AI Alibaba、RAG、Agent 这些本质上都绕不开这两个核心问题。不管你是刚学完 Java 基础想找个实战方向还是在做企业级 AI 集成这套思路都能直接抄。我会从为什么需要模板化讲到具体怎么落地包括目录结构、模板引擎选型、上下文窗口的裁剪策略、多轮对话的状态管理以及我在真实项目里踩过的坑。全程用 Spring Boot 的视角来讲代码能跑配置能抄坑能避。提示本文假设你已经有一个能跑的 Spring Boot 工程对 Bean 注入、配置文件、依赖管理这些基础不陌生。如果你还在第一个 Spring Boot 程序阶段建议先把基础打牢再回来看理解会更顺。2. 把 Prompt 当代码管模板化的三个真实动机很多人第一反应是Prompt 不就是一段字符串吗用String.format拼一下不就行了搞什么模板我一开始也这么想直到项目规模上来之后被打脸。把 Prompt 模板化背后有三个非常现实的动机每一个都对应着真金白银的维护成本。2.1 变更频率决定了它不能硬编码业务侧的 Prompt 调整频率远超你的想象。产品经理今天想改角色语气明天想加一条安全约束后天想调整输出格式。如果 Prompt 硬编码在 Java 代码里每次改动都意味着改代码、走 Code Review、跑 CI、打包、发版、重启服务。一套流程走下来改一句话要半天。而如果把 Prompt 抽成独立的模板文件运营或产品甚至可以在不改代码的前提下调整措辞配合配置中心或数据库存储。这就跟当年把 SQL 从代码里抽到 MyBatis 的 XML 里是一个道理——变更频率高的东西必须和编译单元解耦。2.2 复用与组合是刚需一个稍微复杂点的 AI 应用Prompt 从来不是一整块。它通常由几部分组成系统角色设定System、业务规则约束、少样本示例Few-shot、用户输入、历史上下文。这些部分在不同场景下会重新组合。比如角色设定这一段客服场景和写作场景可能都要用只是后面的业务规则不同。如果每处都复制一遍就会出现前面说的改一处漏六处。模板化之后你可以把角色设定做成一个可复用的片段通过占位符或片段引用的方式组合进不同的完整模板里。这就是组合优于复制在 Prompt 工程里的体现。2.3 版本追溯与灰度是工程底线线上出了问题你得知道当时用的是哪版 Prompt。硬编码的话只能靠翻 Git 历史还得对应到具体的发版时间非常痛苦。模板化之后每个模板文件天然带版本Git 管理甚至可以给模板加版本号字段配合灰度发布先让 10% 的流量用新模板观察效果再全量。下面这张表是我总结的硬编码 vs 模板化对比你可以对照自己项目的情况判断维度硬编码在 Java 里模板化管理修改成本改代码、发版、重启改文件/配置热更新复用能力复制粘贴易漏改片段组合单点维护版本追溯翻 Git对应困难模板自带版本清晰灰度能力基本没有可按版本灰度非技术人员参与不可能可开放给运营测试便利性需启动整个应用可单独对模板做单测看到这张表你应该明白为什么我说这是基础设施了。接下来讲具体怎么落地。3. Spring Boot 里 Prompt 模板的落地结构落地这件事核心就两个问题模板放哪、用什么引擎渲染。这两个问题想清楚了剩下的都是体力活。3.1 模板文件的目录组织我推荐把 Prompt 模板统一放在src/main/resources/prompts/目录下按业务域分子目录。这样打包进 jar 之后依然能通过 ClassPath 读取部署简单。结构大概长这样src/main/resources/prompts/ ├── system/ │ ├── role-customer-service.txt │ └── role-writing-assistant.txt ├── business/ │ ├── order-query.txt │ └── refund-policy.txt ├── fewshot/ │ └── sentiment-examples.txt └── fragments/ ├── safety-rules.txt └── output-format-json.txt这里有几个我踩过坑才定下来的规矩。第一用.txt或.st而不是.java避免被编译器处理。第二片段fragments单独放方便被多个完整模板引用。第三文件名用短横线连接别用中文或空格跨平台读取时少很多麻烦。注意如果你用 Spring AI它默认支持从 ClassPath 加载.st格式的模板基于 StringTemplate 引擎。但如果你项目里已经有 Freemarker 或 Thymeleaf完全可以复用不必为了 Prompt 再引入一套新引擎。3.2 模板引擎选型别为了新潮引入不必要的依赖选型这件事我的原则是能用现有的就别加新的。下面是我实际用过的几种方案对比引擎优点缺点适用场景StringTemplate (Spring AI 默认)与 Spring AI 集成好语法简洁生态相对小纯 Spring AI 项目Freemarker成熟、功能强、社区大语法略重已有 Freemarker 的项目ThymeleafSpring 生态原生偏 HTML 场景Web 项目顺带用纯占位符替换零依赖、可控功能弱、无逻辑极简场景我个人的选择是如果项目已经用了 Spring AI直接用它的 StringTemplate 支持否则优先复用项目里已有的模板引擎。曾经有个项目为了 Prompt 单独引入了 Freemarker结果和已有的 Thymeleaf 共存两套语法让新人一脸懵纯属自找麻烦。3.3 一个可运行的模板加载与渲染示例光说结构不够直接上代码。下面是一个不依赖 Spring AI、纯 Spring Boot 就能跑的模板加载器用 Freemarker 做渲染你可以换成任意引擎Component public class PromptTemplateLoader { private final Configuration freemarkerConfig; public PromptTemplateLoader() { this.freemarkerConfig new Configuration(Configuration.VERSION_2_3_32); // 从 ClassPath 的 prompts 目录加载 freemarkerConfig.setClassLoaderForTemplateLoading( getClass().getClassLoader(), prompts); freemarkerConfig.setDefaultEncoding(UTF-8); freemarkerConfig.setTemplateExceptionHandler( TemplateExceptionHandler.RETHROW_HANDLER); } /** * 渲染模板 * param templatePath 相对 prompts 目录的路径如 business/order-query.txt * param params 占位符参数 */ public String render(String templatePath, MapString, Object params) { try { Template template freemarkerConfig.getTemplate(templatePath); StringWriter writer new StringWriter(); template.process(params, writer); return writer.toString(); } catch (Exception e) { throw new IllegalStateException(Prompt 模板渲染失败: templatePath, e); } } }对应的模板文件prompts/business/order-query.txt长这样你是电商平台的订单助手。 当前用户ID${userId} 用户问题${question} 请根据以下规则回答 1. 只回答订单相关问题其他问题礼貌拒绝 2. 涉及金额必须精确到分 3. 如果查询不到订单引导用户核对订单号调用的时候MapString, Object params new HashMap(); params.put(userId, 10086); params.put(question, 我上周买的鞋子到哪了); String prompt loader.render(business/order-query.txt, params);这套东西跑起来之后运营改措辞只需要改 txt 文件重启或配合配置中心热更新即可开发彻底解放。这就是模板化最直接的收益。4. 上下文管理比模板更容易翻车的地方模板管好了只是解决了说什么的问题。上下文管理解决的是记得什么和能装多少的问题这块翻车概率更高因为它直接关系到 Token 成本和回答质量。4.1 上下文到底包含哪些东西很多人以为上下文就是聊天历史其实远不止。在一个完整的 AI 调用里上下文通常包含这几层系统层角色设定、全局规则基本不变会话层当前这轮对话的历史消息多轮业务层从数据库或 RAG 检索出来的相关知识工具层Agent 场景下可调用工具的描述和返回结果当前层用户这一轮的实际输入这五层加起来很容易就超过模型的上下文窗口。我见过最夸张的一次一个客服项目把整张订单表塞进上下文单次请求 3 万多 Token成本高得离谱回答还因为信息过载变得又慢又差。上下文不是越多越好而是要精准。4.2 上下文窗口的裁剪策略模型有上下文窗口上限比如 8K、32K、128K超了要么报错要么被截断。所以必须有一套裁剪策略。我常用的组合拳是系统层永远保留这是底线不能裁。当前层永远保留用户刚说的话裁了就没法回答了。会话层按最近优先 摘要压缩保留最近 N 轮原文更早的用模型或规则压缩成一段摘要。业务层按相关性排序RAG 检索回来的片段按相似度分数排序从高到低塞塞到预算用完为止。工具层按需加载只加载当前意图可能用到的工具描述。下面是一个简单的 Token 预算分配表我在项目里就是这么配的以 8K 窗口为例层级预算占比说明系统层10%角色与规则固定当前层10%用户输入固定会话层40%最近 5 轮原文 更早摘要业务层35%RAG 片段按分数截断工具层5%按需通常很少这个比例不是死的要根据业务调。比如纯问答场景业务层可以给到 50%纯闲聊场景会话层可以给到 60%。关键是心里要有一本账知道 Token 花在哪了。4.3 多轮对话的状态管理多轮对话的状态放哪是个架构问题。常见有三种做法无状态每次请求把完整历史传过来服务端不存。简单但请求体大且客户端要负责维护历史。服务端会话用 Session 或 Redis 存历史按会话 ID 取。适合 Web 应用但要注意过期和并发。混合近期历史放服务端远期历史落库按需加载。我在 Spring Boot 项目里最常用的是第二种用 Redis 存会话历史结构大概是这样Service public class ConversationMemoryService { private final StringRedisTemplate redisTemplate; private static final int MAX_TURNS 10; private static final Duration TTL Duration.ofHours(2); public ConversationMemoryService(StringRedisTemplate redisTemplate) { this.redisTemplate redisTemplate; } public void append(String sessionId, String role, String content) { String key chat:memory: sessionId; String message role :: content; redisTemplate.opsForList().rightPush(key, message); // 只保留最近 MAX_TURNS 轮 redisTemplate.opsForList().trim(key, -MAX_TURNS * 2, -1); redisTemplate.expire(key, TTL); } public ListMapString, String getHistory(String sessionId) { String key chat:memory: sessionId; ListString raw redisTemplate.opsForList().range(key, 0, -1); if (raw null) return List.of(); return raw.stream().map(s - { String[] parts s.split(::, 2); return Map.of(role, parts[0], content, parts[1]); }).toList(); } }这里有个细节值得说用trim保留最近 N 轮而不是无限增长。我见过有项目不裁剪Redis 里的历史越堆越长最后单次请求 Token 爆表还怪模型不行。裁剪是必须的而且要在写入时就裁别等到读取时才处理。提示MAX_TURNS * 2是因为一轮对话包含用户和助手两条消息。这个数字要根据你的 Token 预算反推别拍脑袋定。5. 把模板和上下文串起来一次完整调用的拆解前面讲了模板和上下文各自怎么管现在把它们串起来看一次完整的 AI 调用在 Java 侧是怎么走的。我用一个 Spring Boot 的 Service 来演示逻辑清晰能直接套。5.1 调用链的五个阶段一次完整的调用我习惯拆成五个阶段意图识别先判断用户想干嘛决定用哪个模板、要不要检索。上下文组装按上一节的策略把五层上下文拼起来。模板渲染把组装好的上下文填进模板占位符。模型调用发给模型处理流式或非流式响应。记忆更新把这一轮的用户输入和模型输出写回会话历史。这五步里最容易出问题的是第 2 步和第 3 步的衔接。很多人把上下文直接拼成一个大字符串塞进模板结果模板里的占位符和上下文里的内容混在一起调试时根本分不清哪段是哪段。我的做法是上下文用结构化对象传递模板只负责格式化不负责逻辑。5.2 结构化上下文对象的设计定义一个PromptContext对象把五层上下文都装进去public class PromptContext { private String systemRole; // 系统层 private ListChatMessage history; // 会话层 private ListString retrievedDocs; // 业务层 private ListString toolDescriptions; // 工具层 private String userInput; // 当前层 // 省略 getter/setter }然后模板里这样引用${systemRole} 【相关知识】 #list retrievedDocs as doc - ${doc} /#list 【对话历史】 #list history as msg ${msg.role}: ${msg.content} /#list 【用户问题】 ${userInput}这样职责就清晰了Java 代码负责取什么模板负责怎么排。改排版不用动 Java改取数逻辑不用动模板。这个边界划清楚后期维护省一半力气。5.3 一个完整的 Service 示例把上面的东西组装起来Service public class ChatOrchestrator { private final PromptTemplateLoader templateLoader; private final ConversationMemoryService memoryService; private final RetrievalService retrievalService; private final ChatModel chatModel; public ChatOrchestrator(PromptTemplateLoader templateLoader, ConversationMemoryService memoryService, RetrievalService retrievalService, ChatModel chatModel) { this.templateLoader templateLoader; this.memoryService memoryService; this.retrievalService retrievalService; this.chatModel chatModel; } public String chat(String sessionId, String userInput) { // 1. 意图识别简化这里直接走检索 ListString docs retrievalService.retrieve(userInput, 3); // 2. 组装上下文 PromptContext ctx new PromptContext(); ctx.setSystemRole(你是专业的技术助手回答简洁准确。); ctx.setHistory(memoryService.getHistory(sessionId)); ctx.setRetrievedDocs(docs); ctx.setUserInput(userInput); // 3. 渲染模板 MapString, Object params new HashMap(); params.put(systemRole, ctx.getSystemRole()); params.put(retrievedDocs, ctx.getRetrievedDocs()); params.put(history, ctx.getHistory()); params.put(userInput, ctx.getUserInput()); String prompt templateLoader.render(system/role-assistant.txt, params); // 4. 调用模型 String answer chatModel.call(prompt); // 5. 更新记忆 memoryService.append(sessionId, user, userInput); memoryService.append(sessionId, assistant, answer); return answer; } }这段代码不依赖具体的模型实现ChatModel你可以用 Spring AI 的接口也可以自己封装 HTTP 调用。核心是把流程固定下来模型是可替换的。5.4 流式响应下的上下文处理现在很多场景要求流式输出打字机效果这时候上下文管理有个坑流式响应是分块返回的你得等全部返回完才能把完整回答写进记忆。如果边流边写会写进去一堆碎片。我的做法是流式过程中用一个StringBuilder累积流结束后再一次性写入记忆。同时如果用户在流式过程中又发了新消息要有个机制取消上一轮或排队否则上下文会乱。这块细节多但原则就一条记忆的写入必须是原子的、完整的。6. 踩过的坑与排查链路前面讲的是应该怎么做这一节讲我实际怎么翻车的。这些坑在文档里基本看不到但每一个都让我加班到深夜。6.1 模板占位符和业务数据里的特殊字符打架有一次做订单查询用户输入里带了${这种字符用户复制了一段代码进来结果 Freemarker 渲染时把它当成了占位符直接抛异常。排查了半天才发现是用户输入没转义。根因模板引擎会把${...}当语法解析而用户输入是不可控的。解决渲染前对用户输入做转义或者用模板引擎提供的原样输出语法。Freemarker 里可以用${userInput?html}或配置成不解析用户输入段。更稳妥的做法是把用户输入作为独立参数传入而不是拼进模板字符串。6.2 上下文超长导致的静默截断模型对超长上下文的处理方式不一样有的直接报错有的从头部截断有的从尾部截断。我遇到过一次模型从头部截断把系统角色设定给截没了结果模型完全失忆回答得驴唇不对马嘴。查了半天以为是模型问题最后发现是上下文超了。排查链路先打印实际发送的 Prompt 长度 → 对比模型窗口上限 → 发现超了 → 检查裁剪逻辑 → 发现裁剪时没保护系统层。解决裁剪时给系统层和当前层加保护标记永远不裁。同时加一个前置校验超长时主动报错或降级别让模型静默处理。6.3 Redis 会话历史并发写入错乱多轮对话用 Redis 存历史高并发下出现过消息顺序错乱。原因是多个请求同时rightPush顺序不保证。解决给会话加分布式锁或者用 Redis 的 List 配合 Lua 脚本保证原子性。更简单的做法是按会话 ID 做分片同一会话的请求串行处理。这个坑在压测时才会暴露平时单机测试根本发现不了。6.4 模板热更新后旧请求还在用旧模板我们做了模板热更新运营改了模板立即生效。结果有一次运营改到一半保存了不完整的模板正好有请求进来渲染失败。虽然概率低但线上就是发生了。解决模板更新走先写临时文件校验通过后原子替换的流程别直接覆盖。同时渲染失败要有兜底返回上一版可用模板或友好提示别直接把异常抛给用户。下面这张表汇总了这几个坑问题根因解决方向占位符冲突用户输入含模板语法转义或独立传参静默截断上下文超长保护关键层 前置校验历史错乱并发写入锁或串行化热更新失败模板不完整原子替换 兜底7. 和 Spring AI、RAG、Agent 的衔接聊到这里模板和上下文管理的基本盘就讲完了。但关键词里还有 Spring AI、RAG、Agent这些其实都是建立在这套基础之上的。简单说下怎么衔接避免你走弯路。7.1 Spring AI 帮你省了什么Spring AI 提供了ChatClient、PromptTemplate、Advisor这些抽象把模板渲染和上下文组装做了一层封装。如果你用 Spring AI很多前面手写的代码可以简化。比如它的PromptTemplate直接支持从资源加载模板ChatMemory抽象帮你管会话历史。但要注意Spring AI 的抽象是通用方案不一定贴合你的业务。比如它的默认记忆策略是保留全部历史长对话下会爆 Token你还是得自己实现裁剪。所以我的建议是用 Spring AI 的接口但核心的裁剪和组装逻辑自己控制别完全交给框架。7.2 RAG 场景下上下文管理的特殊性RAG检索增强生成的核心是把检索到的文档塞进上下文。这里的关键是检索质量决定上下文质量。检索回来一堆不相关的片段塞进去只会干扰模型。我的经验是检索数量别贪多3 到 5 个片段通常够用每个片段要做长度限制别把整篇文档塞进去片段之间要有明确分隔符让模型知道这是不同来源。另外检索分数要设阈值低于阈值的宁可不塞让模型基于自身知识回答也比塞垃圾强。7.3 Agent 场景下工具描述也是上下文Agent 场景下模型需要知道有哪些工具可用。这些工具描述名称、功能、参数也是上下文的一部分而且往往很长。如果工具多光描述就占满窗口了。解决思路按意图动态加载工具描述只加载当前任务可能用到的。比如用户问天气就只加载天气工具的描述别把数据库工具、邮件工具全塞进去。这就是前面说的工具层按需加载。8. 一些实操心得最后分享几个我在实际项目里总结的小经验都是文档里不会写的。第一模板命名要能自解释。别用template1.txt、prompt-new.txt这种名字。用role-customer-service-refund.txt这种一看就知道是客服场景的退款模板。半年后回来看你会感谢自己。第二给模板加元数据注释。在模板文件顶部用注释写清楚用途、作者、最后修改时间、依赖哪些参数。渲染时这些注释会被忽略但维护时价值巨大。第三上下文组装要可观测。每次调用把实际发送的 Prompt 长度、各层占比、检索分数记进日志或监控。出问题时这些数据能帮你快速定位。我一般会记录总 Token 数、系统层占比、检索片段数、命中缓存与否。第四别迷信更大的窗口。模型窗口从 8K 涨到 128K不代表你可以无脑塞。窗口越大模型注意力越容易分散回答质量反而可能下降。精准的上下文永远优于冗长的上下文。第五模板和代码要一起做单测。模板渲染失败是低级但致命的错误。写个单测把每个模板用模拟参数渲染一遍确保不抛异常。这个习惯帮我拦下过好几次运营改坏模板的事故。这套东西搭起来之后你会发现 Java 项目接入 AI 能力其实没那么玄乎。核心就是把 Prompt 当资源管把上下文当数据管剩下的都是工程问题。Spring AI 这些框架能帮你省事但底层的思路你得清楚不然框架一出问题你就抓瞎。