上章思考题答案
- 共用一个
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 字面量。版本号的配置键由运维在发布单里填写,开发提供键名,测试在无密钥用例里用两个不同版本各渲染一次,断言输出不同。这样季度切换不必等模型调用就能验收。
2. 项目设计
小胖说:「这不就是请假条抬头吗?姓名、部门、日期空着,填上就能交。用加号拼一拼,至于上模板引擎吗?」
小白问:「抬头漏了日期,人事收不收?你现在漏了制度版本,模型照样收,还把空格子念给用户听。引擎和加号的差别是失败时机。失败若发生在 HTTP 之后,钱已经花了,用户也已经看到残句。」
小胖又说:「那我用String.format,少一个参数会抛异常,也挺严。请假条用格子纸就行,不必买印刷机。」
小白追问:「格式串里第四个%s和第三个对调了,异常不会出现,只是渠道和订单号互换。你怎么发现?另外系统提示词和用户提示词要不要各自分开渲染?用户说的话里如果本来就有百分号或花括号,格式化会不会把用户原话吃掉?」
大师把渲染收进 Spring AI 已有的类型:「PromptTemplate默认用StTemplateRenderer,占位符是{orderId}这种花括号,不是%s。SystemPromptTemplate继承它,createMessage()产出的是SystemMessage,而PromptTemplate.createMessage()产出的是UserMessage。店规用系统模板,用户原话用用户模板,不要都生成用户消息。渲染器的ValidationMode有三种:THROW是默认,缺变量抛IllegalStateException;WARN只打日志仍渲染;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 通常已传递带入,写上是为了锁定本章用到的模块):
<dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-template-st</artifactId></dependency><dependency><groupId>org.springframework.ai</groupId><artifactId>spring-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{privatestaticfinalStringLOCATION="prompts/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(value==null||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:渲染结果里四个变量都已替换
步骤目标:系统提示词是纯文本,可被断言,且不再包含花括号。
Stringsystem=AfterSalePrompts.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.chatClient=ChatClient.create(chatModel);}publicStringanswer(StringorderId,Stringchannel,Stringtone,StringpolicyVersion,StringuserText){Stringsystem=AfterSalePrompts.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。
Map<String,Object>extra=newjava.util.HashMap<>();extra.put("orderId","LC202610060018");extra.put("channel","store");extra.put("tone","完整");extra.put("policyVersion","policy-2026.10");extra.put("operatorId","clerk-9");Stringrendered=AfterSalePrompts.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、content)
3.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(){Stringsystem=AfterSalePrompts.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");}}@SpringBootTest@EnabledIfEnvironmentVariable(named="DEEPSEEK_API_KEY",matches=".+")classPolicyAnswerServiceTest{@AutowiredprivatePolicyAnswerServicepolicyAnswerService;@TestvoidanswerEchoesPolicyVersion(){Stringanswer=this.policyAnswerService.answer("LC202610060018","App","简短","policy-2026.10","这单超时未发货,能不能赔?");System.out.println(answer);assertThat(answer).contains("policy-2026.10");assertThat(answer).doesNotContain("优惠券");}}mvn -Dtest=AfterSalePromptsTest test $env:DEEPSEEK_API_KEY="你的密钥" mvn -Dtest=PolicyAnswerServiceTest test无密钥时渲染测试仍然全绿。这是本章最重要的回归:模板坏了,不需要花钱就能发现。
4. 项目总结
4.1 优点与缺点
| 对比项 | 字符串拼接 / String.format | SystemPromptTemplate + 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 实战修炼与源码剖析