资讯动态

第5章:Spring AI 2.0提示词模板与系统提示词

发布时间:2026/10/13 8:43:36 来源:尧图企业网站定制
上章思考题答案共用一个DeepSeekChatModelBean、换Prompt上的 options能分开模型名和温度分不开网关超时、连接池占用和日志级别。用户链路和质检链路至少要在超时和限流上分开否则推理调用会占满用户链路的连接。应显示「本模型未返回推理」不要显示空白。空白无法区分「模型没给」和「页面没绑定字段」。排障时前者是供应商行为后者是我们的序列化遗漏。1. 项目背景1.1 业务场景邻里仓同时服务 App 和门店。App 用户要短句门店店员要能对顾客转述的完整句。制度每季度发一版回答末尾要带上制度版本例如policy-2026.10。订单号必须出现在提示词里避免模型向用户再次索要已经在请求路径上的单号。开发手里现在有三段复制粘贴的系统提示词区别只是渠道和语气改制度时要改三处。1.2 痛点字符串拼接能工作到第一次漏换。有人用 orderId订单号为空时提示词里出现订单null模型仍侃侃而谈。另一处用String.format占位符和参数错位渠道名跑进了订单号的位置。测试只断言最终回答「看起来像客服」发现不了系统提示词里的版本号还是上个季度。更隐蔽的是模板里写了{policyVersion}调用方忘了传渲染器若静默留下花括号模型会把{policyVersion}读成要用户填写的空栏。没有模板对象时的路径复制三份字符串 → 漏改变量 → 空订单号仍去调用模型 → 回答无法追溯制度版本。季度切换时App 文案已经是policy-2026.10门店文案还停在policy-2026.07。两边都「看起来像客服」抽检对不上版本客诉无法判断当时生效的是哪一版超时规则。本章用一份模板把版本号变成必填变量缺了就不要打到 DeepSeek。发布时只改注入的版本号不改 Java 字面量。版本号的配置键由运维在发布单里填写开发提供键名测试在无密钥用例里用两个不同版本各渲染一次断言输出不同。这样季度切换不必等模型调用就能验收。否是订单号 渠道 语气 制度版本SystemPromptTemplate变量齐全?抛 IllegalStateException 不调用模型SystemMessageChatClientDeepSeek2. 项目设计小胖说「这不就是请假条抬头吗姓名、部门、日期空着填上就能交。用加号拼一拼至于上模板引擎吗」小白问「抬头漏了日期人事收不收你现在漏了制度版本模型照样收还把空格子念给用户听。引擎和加号的差别是失败时机。失败若发生在 HTTP 之后钱已经花了用户也已经看到残句。」小胖又说「那我用String.format少一个参数会抛异常也挺严。请假条用格子纸就行不必买印刷机。」小白追问「格式串里第四个%s和第三个对调了异常不会出现只是渠道和订单号互换。你怎么发现另外系统提示词和用户提示词要不要各自分开渲染用户说的话里如果本来就有百分号或花括号格式化会不会把用户原话吃掉」大师把渲染收进 Spring AI 已有的类型「PromptTemplate默认用StTemplateRenderer占位符是{orderId}这种花括号不是%s。SystemPromptTemplate继承它createMessage()产出的是SystemMessage而PromptTemplate.createMessage()产出的是UserMessage。店规用系统模板用户原话用用户模板不要都生成用户消息。渲染器的ValidationMode有三种THROW是默认缺变量抛IllegalStateExceptionWARN只打日志仍渲染NONE不检查。生产用THROW。缺变量时不要调用 DeepSeek省下一次必然胡答的费用。」技术映射请假条是模板字符串填写动作是render店规成文是SystemMessage缺栏拒收是ValidationMode.THROW。小胖问「用户那句话要不要也做模板有时我们想在用户话前面加一句『请简短回答』。」小白说「那句是店规应放进系统模板不要混进用户原文。用户原文一旦进了带校验的模板用户打出的花括号会被当成缺变量正常咨询会被拒绝。」小白再问「WARN能不能只在本地打开我调试时不想因为少写一个键就中断。」大师说「本地也不要打开WARN。调试阶段更应该看见缺了哪个键。WARN会让残缺提示词进入模型你看到的回答无法代表生产。缺键就改调用方。用户原文用UserMessage或ChatClient.user原样发送不经过SystemPromptTemplate。代码评审看到ValidationMode.WARN或NONE就要求改回THROW不把模式选择留到上线当天。」小白问多余变量「调用方多传了operatorId模板里没有这个坑会不会失败内部用户和 App 用户的语气都要填空字符串算缺变量吗」小胖说「多传就忽略呗空字符串也算我传了。」小白继续「空字符串会让句子变成『语气』。模型收到的是残句。这和没传不是同一类错。校验如果只看键在不在空字符串能混过去。」大师补充源码行为「StTemplateRenderer.validate比较的是模板里的标识符和 map 的键。多出来的键不会导致失败缺的键在THROW下抛IllegalStateException消息里带缺失变量名。它不把空白字符串当成缺失。所以除了渲染器业务还要拒绝空白的订单号、渠道、语气和制度版本。这道检查放在渲染之前异常信息用业务词而不是等模型回答后再猜。」技术映射引擎保证「键存在」业务保证「值不是空白」。两者都要有缺一不可。小胖问调用「渲染完我还是拿字符串去new Prompt吗前面大纲里写了 ChatClient。」小白问「ChatClient 会不会再渲染一次把已经替换好的订单号又当成模板用户原话里如果出现花括号会不会在发送时被吃掉」小白补了一句「制度版本如果写死在.st文件里季度切换就要发一版镜像。如果只作为变量传入回滚是改配置还是改文件我需要这个差别写进发布说明。」大师把边界划清「本章渲染在进入客户端之前完成。ChatClient.create(chatModel)拿到的系统文本已经是普通字符串里面不该再有花括号。chatClient.prompt().system(rendered).user(userText).call().content()只负责发送。第 6 章再讲这个门面和DeepSeekChatModel.call的差别。今天只把它当作发送器。若你把未渲染的{orderId}交给system()客户端不会替你查 map。用户原话不要放进SystemPromptTemplate否则用户输入里的花括号会被当成变量。版本号作为变量传入模板文件只保留坑位这样回滚版本不必重打镜像。测试在无密钥的用例里锁住坑位都已被替换。」技术映射模板渲染在ChatClient之外完成。客户端发送的是最终SystemMessage文本。3. 项目实战3.1 环境准备在第 2 章工程上增加显式依赖Starter 通常已传递带入写上是为了锁定本章用到的模块dependencygroupIdorg.springframework.ai/groupIdartifactIdspring-ai-template-st/artifactId/dependencydependencygroupIdorg.springframework.ai/groupIdartifactIdspring-ai-client-chat/artifactId/dependency版本由spring-ai-bom管理。模板文件放在src/main/resources/prompts/after-sale-system.st你是邻里仓{channel}渠道的售后客服语气{tone}。 制度版本{policyVersion}。订单号{orderId}。 只讨论该订单的超时与退款不要发明补偿。回答末尾单独一行写制度版本。占位符使用单花括号。不要写成${orderId}那是 Spring 配置的占位符不是 StringTemplate 的。3.2 分步实现步骤 1缺变量时渲染失败且不创建客户端调用步骤目标少传policyVersion时抛异常测试能看到变量名。packagecom.linlicang.aftersale;importjava.util.Map;importorg.springframework.ai.chat.prompt.SystemPromptTemplate;importorg.springframework.ai.template.ValidationMode;importorg.springframework.ai.template.st.StTemplateRenderer;importorg.springframework.core.io.ClassPathResource;publicfinalclassAfterSalePrompts{privatestaticfinalStringLOCATIONprompts/after-sale-system.st;privateAfterSalePrompts(){}publicstaticSystemPromptTemplatesystemTemplate(){returnSystemPromptTemplate.builder().resource(newClassPathResource(LOCATION)).renderer(StTemplateRenderer.builder().validationMode(ValidationMode.THROW).build()).build();}publicstaticStringrenderSystem(StringorderId,Stringchannel,Stringtone,StringpolicyVersion){requireText(orderId,orderId);requireText(channel,channel);requireText(tone,tone);requireText(policyVersion,policyVersion);returnsystemTemplate().render(Map.of(orderId,orderId,channel,channel,tone,tone,policyVersion,policyVersion));}privatestaticvoidrequireText(Stringvalue,Stringname){if(valuenull||value.isBlank()){thrownewIllegalArgumentException(name 不能为空);}}}运行结果文字描述render(Map.of(orderId, LC1, channel, App, tone, 简短))抛出IllegalStateException消息中包含policyVersion。不会出现 HTTP 日志。renderSystem(, App, 简短, policy-2026.10)抛出IllegalArgumentException消息是orderId 不能为空。可能的坑Map.of不允许 null 值空白字符串却允许。所以空白要在requireText里挡。若有人改成ValidationMode.WARN缺变量只打日志调用仍会发出测试必须断言模式是THROW或者直接断言异常类型。步骤 2渲染结果里四个变量都已替换步骤目标系统提示词是纯文本可被断言且不再包含花括号。StringsystemAfterSalePrompts.renderSystem(LC202610060018,App,简短,policy-2026.10);System.out.println(system);运行结果文字描述输出含App、简短、policy-2026.10、LC202610060018不含{orderId}这类残留。末尾要求模型回写制度版本那是给模型的指令不是渲染错误。可能的坑资源文件编码不是 UTF-8渠道中文变成乱码。ClassPathResource按字节读取后渲染器使用平台默认字符集的路径要避开。把.st文件存成 UTF-8并在构建里固定project.build.sourceEncoding。乱码时模型仍会回答测试若只断言非空就发现不了。步骤 3把渲染结果交给 ChatClient步骤目标用户看到的回答带有制度版本且系统文本来自模板而不是 Java 字符串拼接。packagecom.linlicang.aftersale;importorg.springframework.ai.chat.client.ChatClient;importorg.springframework.ai.chat.model.ChatModel;importorg.springframework.stereotype.Service;ServicepublicclassPolicyAnswerService{privatefinalChatClientchatClient;publicPolicyAnswerService(ChatModelchatModel){this.chatClientChatClient.create(chatModel);}publicStringanswer(StringorderId,Stringchannel,Stringtone,StringpolicyVersion,StringuserText){StringsystemAfterSalePrompts.renderSystem(orderId,channel,tone,policyVersion);returnthis.chatClient.prompt().system(system).user(userText).call().content();}}这里注入ChatModel。第 2 章若只注册了DeepSeekChatModel它可以赋给ChatModel。若以后有第二个模型改回注入DeepSeekChatModel避免接错 Bean。运行结果文字描述用户问题为「这单超时未发货能不能赔」时回答末尾有一行policy-2026.10。回答中出现订单号。没有优惠券字样。控制台先打印渲染后的系统提示词再打印回答便于测试核对版本号确实进了提示词而不只是模型碰巧说对。可能的坑ChatClient的system()不会第二次渲染。若你把模板原文而不是renderSystem的返回值传进去用户会看到模型讨论花括号。代码评审时搜索system({)这类残留。另一个坑是在用户模板里也放{orderId}又在系统模板里放一次两边不一致。订单号只放系统模板用户模板只放用户原话。发送前再做一次本地断言仍然不访问网络渲染结果必须同时包含订单号和制度版本且indexOf({)为 -1。这一步放在PolicyAnswerService.answer里、chatClient.prompt()之前。断言失败抛IllegalStateException调用计数保持为 0。测试可以用一个假的ChatModel验证「渲染失败时call未被调用」不必等待第 6 章再补这道闸。假的模型只要实现ChatModel.call并让测试失败即可例如抛AssertionError(不应访问模型)。缺变量的用例因此同时锁住两件事异常类型以及模型零调用。步骤 4多传变量不影响渲染缺变量不会打到模型步骤目标把「多传」和「少传」都变成测试防止以后有人把校验改成 WARN。MapString,Objectextranewjava.util.HashMap();extra.put(orderId,LC202610060018);extra.put(channel,store);extra.put(tone,完整);extra.put(policyVersion,policy-2026.10);extra.put(operatorId,clerk-9);StringrenderedAfterSalePrompts.systemTemplate().render(extra);运行结果文字描述渲染成功文本含store和完整不含clerk-9模板未引用它。少传任何一个必需键则抛IllegalStateException。此步骤不调用ChatClient。可能的坑有人以为多传的operatorId会自动出现在提示词里。不会。要给模型看的字段必须写进.st文件。这能防止把内部工号误送进模型也要求你记得把该送的字段写进模板。步骤 5渲染结果先过本地闸门再允许构造 ChatClient 请求步骤目标把「缺变量不打模型」从口头约定变成PolicyAnswerService里的一行检查。在answer方法拿到system字符串后、调用chatClient前增加if(system.indexOf({)0){thrownewIllegalStateException(系统提示词仍含未替换占位符);}这条闸门覆盖两种失误有人绕过renderSystem把模板原文传了进来或者ValidationMode被改成WARN后花括号残留。无密钥测试构造一个假的ChatModel其call直接抛AssertionError。缺policyVersion时断言异常是IllegalStateException或IllegalArgumentException并且假模型的call计数为 0。假模型不必实现流式方法以外的全部默认行为编译器要求补齐的方法可以抛UnsupportedOperationException。运行结果文字描述AfterSalePromptsTest继续不访问网络。新增的闸门测试在占位符残留时失败信息包含「未替换」。连网测试只有在闸门通过后才会出现 DeepSeek 的耗时。日志里先看到渲染后的制度版本再看到回答末行的同一版本。两个版本不一致时优先查是不是模型没遵守末行指令而不是模板没填上。模板没填上会被闸门拦住。可能的坑制度正文合法地需要展示花括号例如 JSON 示例。闸门会误伤。售后提示词里不要放 JSON 示例结构化输出放到第 8 章。另一坑是检查indexOf({)时用户问题里的花括号被拼进了system。不要把用户原文追加进系统模板。3.3 完整代码清单src/main/resources/prompts/after-sale-system.stAfterSalePromptsPolicyAnswerService下一节测试源码对照spring-ai-model/.../chat/prompt/PromptTemplate.javaspring-ai-model/.../chat/prompt/SystemPromptTemplate.javaspring-ai-commons/.../template/TemplateRenderer.javaspring-ai-commons/.../template/ValidationMode.javaspring-ai-template-st/.../st/StTemplateRenderer.javaspring-ai-client-chat/.../chat/client/ChatClient.java本章只使用create、prompt、system、user、call、content3.4 测试验证不访问网络的测试锁住渲染。访问网络的测试锁住「回答带回版本号」。packagecom.linlicang.aftersale;importjava.util.Map;importorg.junit.jupiter.api.Test;importorg.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.boot.test.context.SpringBootTest;importstaticorg.assertj.core.api.Assertions.assertThat;importstaticorg.assertj.core.api.Assertions.assertThatThrownBy;classAfterSalePromptsTest{TestvoidrendersAllFieldsWithoutPlaceholders(){StringsystemAfterSalePrompts.renderSystem(LC202610060018,App,简短,policy-2026.10);System.out.println(system);assertThat(system).contains(LC202610060018,App,简短,policy-2026.10);assertThat(system).doesNotContain({orderId},{channel},{tone},{policyVersion});}TestvoidmissingPolicyVersionDoesNotRender(){assertThatThrownBy(()-AfterSalePrompts.systemTemplate().render(Map.of(orderId,LC202610060018,channel,App,tone,简短))).isInstanceOf(IllegalStateException.class).hasMessageContaining(policyVersion);}TestvoidblankOrderIdRejectedBeforeRender(){assertThatThrownBy(()-AfterSalePrompts.renderSystem( ,App,简短,policy-2026.10)).isInstanceOf(IllegalArgumentException.class).hasMessageContaining(orderId);}}SpringBootTestEnabledIfEnvironmentVariable(namedDEEPSEEK_API_KEY,matches.)classPolicyAnswerServiceTest{AutowiredprivatePolicyAnswerServicepolicyAnswerService;TestvoidanswerEchoesPolicyVersion(){Stringanswerthis.policyAnswerService.answer(LC202610060018,App,简短,policy-2026.10,这单超时未发货能不能赔);System.out.println(answer);assertThat(answer).contains(policy-2026.10);assertThat(answer).doesNotContain(优惠券);}}mvn -DtestAfterSalePromptsTest test $env:DEEPSEEK_API_KEY你的密钥 mvn -DtestPolicyAnswerServiceTest test无密钥时渲染测试仍然全绿。这是本章最重要的回归模板坏了不需要花钱就能发现。4. 项目总结4.1 优点与缺点对比项字符串拼接 / String.formatSystemPromptTemplate THROW缺变量可能变成null或错位抛异常并带变量名系统与用户角色靠人记得createMessage()的类型不同制度版本散落在多段 Java 字符串一个.st文件多传内部字段容易被拼进句子模板未引用就不会出现空字符串往往照样拼接需要业务再拦一层优点渠道、语气、版本、订单号有固定坑位改制度只改文件。缺变量在调用 DeepSeek 之前失败测试不依赖网络。系统模板和用户模板的产物类型不同减少角色放错。未写入模板的内部字段不会自动泄露给模型。渲染结果是普通字符串ChatClient不必理解模板语法。缺点花括号语法和 Spring 的${}容易混用。校验默认只看键不看空白业务必须再写requireText。多传不会报错写错键名时表现为「我传了怎么没生效」。WARN模式看起来能跑生产上会把残缺提示词发给模型。4.2 适用场景适用同一套制度要服务 App 和门店两种语气。回答必须带回可追溯的制度版本。订单号来自可靠的请求参数不希望模型再追问。测试希望在 CI 里不花钱就守住提示词结构。有内部字段工号、成本绝不能进提示词。不适用提示词完全固定、没有任何变量。直接new SystemMessage(...)更短。需要模型按 JSON Schema 输出。模板只解决输入结构化输出在第 8 章。4.3 注意事项生产渲染器保持ValidationMode.THROW。不要在排障时临时改成NONE后提交。.st文件使用 UTF-8。构建参数project.build.sourceEncoding设为 UTF-8。不要在模板里写密钥、成本价、内部链接。ChatClient.system(String)收到的应是渲染结果。模板原文留在资源文件里。StringTemplate 的默认分隔符是{和}。若制度正文本身要出现花括号需要换分隔符或转义否则会被当成变量。4.4 常见踩坑用户看到「制度版本 {policyVersion}」。根因是把未渲染的模板交给了system()或校验被改成WARN后仍调用了模型。处理恢复THROW在发送前断言结果不含{。App 和门店回答的版本号不一致。根因是两份复制的 Java 字符串只改了一份。处理删除 Java 里的提示词字面量只保留一个.st。订单号为空仍产生了赔偿说明。根因是只依赖渲染器空白字符串算「键已提供」。处理requireText放在render之前并对该异常做单测。4.5 思考题用户原话里包含{orderId}四个字符。若有人把用户原话放进SystemPromptTemplate再渲染会发生什么用户原话应该走哪一个类型制度版本每个季度更换。版本号放在模板文件正文里和作为渲染变量传入对发布和回滚有什么差别你选哪一种答案在第 6 章开头。延伸阅读与资源10倍开发者的 Dify 魔法书从零构建全栈 AI 应用后端工程师转型AI第一课-Ollama 与私有化大模型实战大型语言模型(LLM) vLLM 高性能推理落地实战Agent开发之LlamaIndex 实战修炼与源码进阶大语言模型Transformers 实战修炼与源码剖析

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑