上章思考题答案用户原话里的{orderId}若放进SystemPromptTemplate再渲染StringTemplate 会把它当成变量。缺这个键时ValidationMode.THROW抛IllegalStateException正常咨询被拒绝。用户原话应走UserMessage或ChatClient.user不要进系统模板。版本号写死在模板正文里更换或回滚都要重打镜像。作为渲染变量传入时回滚只改配置。本章之后的示例都把制度版本当变量传入。1. 项目背景1.1 业务场景邻里仓退款接口要回答「订单 LC202610060018 未发货能不能退」店规固定用户问题每次不同。店规已经能渲染成带版本号的字符串调用处却仍是new Prompt再chatModel.call。每个接口都重复这四行取模型、装系统消息、装用户消息、从ChatResponse里挖文本。退款、超时、库存三个接口各写一份系统提示词的默认句渐渐分叉。本章把这四行收成ChatClient。它和RestClient、WebClient一样是一层流式门面先建造客户端再对单次请求填system和user最后call().content()取正文。邻里仓这一周里App 退款、门店退款和超时说明三个接口都要回答同一份店规。差别只在订单号和用户原话。门面让店规有一个建造入口让订单号留在这一次请求里。控制器交给 App 的是一段中文测试和计费要看账单时再从同一次调用的ChatResponse上取Usage。这样退款接口不必再认识Generation也不会为了补一张账单把模型打第二遍。1.2 痛点继续手写Prompt时有人忘了把渲染好的店规放进SystemMessage模型只看见用户原话开始发明优惠券。另一个人每次ChatClient.create一个新实例默认店规无法集中更换。测试要断言文本还得记得getResult().getOutput().getText()写错一层就变成空指针被误判成模型没回答。没有门面时的路径每个接口自己拼Prompt→ 店规复制三份 → 取正文的代码路径不一致 → 测试断言各写各的。门店接口若仍停在上一季的字符串上App 已经换成policy-2026.10抽检会看到两种超时口径。用户拿着同一张订单在两个渠道得到相反结论客服无法解释。把门面收成共享客户端之后制度版本只从模板渲染变量进来渠道差异留在单次system()的参数里。测试断言关键短语和版本号不再逐个接口核对复制来的长文。渲染错误:Mermaid 渲染失败: Parse error on line 5: ...Model.call] call -- usage[两边都有 Usag... ----------------------^ Expecting SEMI, NEWLINE, SPACE, EOF, subgraph, end, acc_title, acc_descr, acc_descr_multiline_value, AMP, COLON, STYLE, LINKSTYLE, CLASSDEF, CLASS, CLICK, DOWN, DEFAULT, NUM, COMMA, NODE_STRING, BRKT, MINUS, MULT, UNICODE_TEXT, direction_tb, direction_bt, direction_rl, direction_lr, direction_td, got CALLBACKNAME2. 项目设计小胖把第 5 章的代码往下翻「不就是少写两个 new 吗窗口已经能喊号了再包一层塑料袋有啥用」小白问「塑料袋如果把店规和用户话装混了呢content()只给我字符串finishReason还在不在我怎么知道这句是被截断的」小胖又说「那我两个都要。门面拿来给控制器模型留给测试。」小白追问「同一道退款题走两条路token 会不会差一截差在系统提示词没带上还是差在门面多包了一层观测测试该断言两条路 token 相等还是只断言都大于 0」大师先对齐形态「ChatClient像RestClient。builder(chatModel).defaultSystem(...).build()是建造相当于给柜台贴上默认话术。prompt().system(...).user(...).call()是这一次的单据。content()只取助手正文适合控制器返回给 App。要账单和结束原因时改用call().chatResponse()里面仍是第 3 章的ChatResponse。门面不替换模型底层还是你注入的DeepSeekChatModel。」技术映射柜台默认话术是defaultSystem这一次的单据是prompt()递给用户的杯子是content()小票仍是chatResponse()上的Usage。小白问默认值和本次覆盖「建造时写了 App 渠道的短句这一次门店请求想改成完整句是再 new 一个客户端还是单次system()能盖过默认」小胖说「再 new 一个最清楚各管各的。」小白反对「两个客户端意味着两份默认顾问、两份观测。我只想换一句店规。」小白又问「DefaultChatClient把顾问和选项收进这次请求本章要不要读完它的源码」大师把范围切开「单次system(String)覆盖这一次的系统文本不需要第二个客户端。顾问列表和选项如何收进Prompt在DefaultChatClient里源码留到第 33 章。今天只建立直觉建造期放稳定的东西请求期放这一单的用户问题和偶尔的覆盖。退款接口的稳定店规用defaultSystem订单号和用户原话用user。」技术映射稳定店规放在ChatClient.Builder.defaultSystem单次覆盖用prompt().system。收集顾问的细节不在本章展开。小白又问空正文「模型若回了一个空字符串content()交给控制器App 会显示一片空白。这和调用失败一样吗我能不能从这一个字符串看出是截断、是拒绝还是真的没有生成」小胖说「空白就当失败让用户再问一次。」小白说「再问一次就是第二次计费。用户只是遇到了截断店规其实已经生成了一半。空白、截断和异常要分开记。门面如果只露出字符串这三件事会挤在同一个返回值里。」大师补上返回值的边界「content()适合已经确认要展示正文的接口。退款这种要判断是否说完的接口用chatResponse()同时看文本、finishReason和Usage。空文本不要自动重试。先看结束原因是不是LENGTH是的话调整maxTokens再决定要不要重放。异常和空文本不是同一条路径异常不会变成空字符串悄悄返回。」小胖看着两条调用「既然底层是同一个模型Usage 应该一模一样。测试写成相等就行。」小白说「门面可能多放一条默认系统消息。你没把同一份店规同时放进两条路径token 就不会相等。相等断言会把实验方法的差别误判成故障。」小白又问打印「两组 token 打在测试输出里构建日志会不会把用户的退款原因也打出来邻里仓的问题里常有手机号和地址。」小胖说「测都测了全打出来最好查。」小白说「测试用的是固定句不是真实客诉。全打出来会把后来有人换上的真实句子也带进构建日志。」大师给出对照方法「两条路径使用同一段已渲染的系统文本和同一句用户问题温度都从模型默认值来不要一边maxTokens(64)另一边不设。用户问题用固定句『这单未发货能不能退』不要用真实客诉。然后只断言两边promptTokens和completionTokens都大于 0正文都非空并且都包含关键短语『未发货可退』。不要断言 token 数值逐字相等。日志只打两组 token 数字和短语是否命中不打完整用户原文。若提示词 token 相差超过一倍先查是不是有一条路径漏了系统消息。」技术映射对照实验锁的是同一份输入和关键短语不是锁死某一次的 token 数字。完整原文不进构建日志。3. 项目实战3.1 环境准备沿用第 2 章工程。spring-ai-starter-model-deepseek已经带上spring-ai-client-chat。密钥仍是DEEPSEEK_API_KEY模型deepseek-v4-flash。店规沿用第 5 章的渲染结果本章不再复制一份 Java 字符串。把「回答中必须出现未发货可退」写进系统模板方便两条路径用同一短语断言。运行前确认三件事。第一容器里只有一个ChatModel否则注入点要改成DeepSeekChatModel避免门面和直接调用打到不同的模型。第二spring.ai.deepseek.chat.model是deepseek-v4-flash不要在对照实验里把一边改成已经废弃的deepseek-reasoner。第三测试类仍用环境变量开关没有密钥就整类跳过构建不因缺密钥失败。PowerShell 里是$env:DEEPSEEK_API_KEY你的密钥。密钥不进源码不进日志。对照实验和控制器演示共用RefundChatService。服务是单例因此建造期的defaultSystem里不要写死某一张订单。下面步骤 1 为了先跑通用了固定订单号步骤 2 立刻改成按入参渲染。阅读时把步骤 2 当成退款接口的最终形态步骤 1 只说明content()怎么取正文。3.2 分步实现步骤 1建造带默认店规的 ChatClient步骤目标退款接口不再每次new Prompt。packagecom.linlicang.aftersale;importorg.springframework.ai.chat.client.ChatClient;importorg.springframework.ai.chat.model.ChatModel;importorg.springframework.stereotype.Service;ServicepublicclassRefundChatService{privatefinalChatClientchatClient;privatefinalChatModelchatModel;publicRefundChatService(ChatModelchatModel){this.chatModelchatModel;StringsystemAfterSalePrompts.renderSystem(LC202610060018,App,简短,policy-2026.10);this.chatClientChatClient.builder(chatModel).defaultSystem(system\n回答中必须出现未发货可退).build();}publicStringanswer(StringuserText){returnthis.chatClient.prompt().user(userText).call().content();}}订单号若每次不同不要写进建造期的defaultSystem。建造期只放渠道和语气订单号放进单次system(rendered)。上面示例为了先跑通用了固定订单号。下一步改成单次覆盖。运行结果文字描述answer(这单未发货能不能退)返回非空中文包含「未发货可退」。没有ChatResponse变量。可能的坑content()在模型返回空文本时可能是 null。控制器要先判空再写给 App。另一个坑是把含订单号的店规放进单例ChatClient的默认系统消息下一单用户会看到上一单的订单号。这一步在业务上的含义是退款接口的调用代码不再关心Prompt和Generation。店员培训时只要记住两层。建造客户端时写入与订单无关的语气和渠道。用户按下发送时只传入这一句问题。若把LC202610060018写进单例后一个用户问自己的订单回答里仍可能出现这个号码。验收时连续用两个订单号各问一次第二段回答里不得出现第一段的订单号。这比单看某一段「像客服」更能发现串单。步骤 2单次 system 覆盖默认店规步骤目标同一客户端服务不同订单号。publicStringanswerForOrder(StringorderId,StringuserText){StringsystemAfterSalePrompts.renderSystem(orderId,App,简短,policy-2026.10);returnthis.chatClient.prompt().system(system\n回答中必须出现未发货可退).user(userText).call().content();}运行结果文字描述连续问两个订单号两段回答各自包含对应订单号。默认系统消息被这一次的system盖住。可能的坑有人以为system()是在默认店规后面追加。它是这一次的系统文本不是拼接。需要保留默认句时自己把默认句和本次句拼好再传入。覆盖不是追加这和柜台换一张今日告示一样。新告示贴上之后旧告示不应还露在旁边。若你希望「简短」和「必须出现未发货可退」同时生效把两句拼成这一次的系统文本再传入。拼好之后在测试里打印最终系统文本确认订单号只有一个制度版本只有policy-2026.10。不要靠模型的回答来反推提示词里到底有几句店规模型会省略也会复述。打印请求侧的字符串才能知道发出去的是什么。步骤 3同一问题走 ChatModel.call对照 Usage步骤目标证明门面没有换成另一个模型。publicUsagePaircompare(StringorderId,StringuserText){StringsystemAfterSalePrompts.renderSystem(orderId,App,简短,policy-2026.10)\n回答中必须出现未发货可退;varbyClientthis.chatClient.prompt().system(system).user(userText).call().chatResponse();varbyModelthis.chatModel.call(neworg.springframework.ai.chat.prompt.Prompt(java.util.List.of(neworg.springframework.ai.chat.messages.SystemMessage(system),neworg.springframework.ai.chat.messages.UserMessage(userText))));returnnewUsagePair(byClient.getMetadata().getUsage(),byModel.getMetadata().getUsage(),byClient.getResult().getOutput().getText(),byModel.getResult().getOutput().getText());}publicrecordUsagePair(org.springframework.ai.chat.metadata.UsageclientUsage,org.springframework.ai.chat.metadata.UsagemodelUsage,StringclientText,StringmodelText){}运行结果文字描述控制台打印两组promptTokens和completionTokens都大于 0。两段正文都含「未发货可退」。数字通常接近不要求相等。若客户端一侧的提示词 token 明显更大检查是否默认系统消息和单次系统消息被同时发送。以你使用的 Spring AI 2.0 行为为准在测试里打印消息条数不要凭猜测写相等断言。可能的坑call().content()之后再想拿Usage已经晚了。对照实验用chatResponse()。流式接口本章不用。对照实验要锁住输入再放开输出的措辞。两边使用同一段系统文本、同一句用户问题、同一次默认温度。然后只比较三件事正文都非空都含「未发货可退」提示词 token 和完成 token 都大于 0。把两组数字打印出来。若客户端一侧的提示词 token 比直接调用大出一倍先数系统消息的条数常见原因是默认店规和单次店规被一起送出。数字接近就可以结束本章不必改断言去追求相等。相等会把采样差异误判成故障退款接口的构建会变成偶发失败。测试部门验收时看三行输出客户端正文、模型正文、两组 token。两段正文都含短语、都不含「优惠券」token 都大于 0即算通过。不要把某一次的全文贴进断言。模型下周更换措辞时测试仍应保持绿色。开发若要排查串单再临时打开系统文本的打印查完关掉避免订单号长期留在构建日志里。验收记录写明模型名是deepseek-v4-flash并附在测试说明里。3.3 完整代码清单RefundChatService、UsagePair和下一节测试。源码对照spring-ai-client-chat/.../chat/client/ChatClient.javaspring-ai-client-chat/.../chat/client/DefaultChatClient.java只建立直觉逐行阅读放在第 33 章示例继续放在samples/linlicang-aftersale-boot。目录落地前以本章代码块为准。3.4 测试验证packagecom.linlicang.aftersale;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;SpringBootTestEnabledIfEnvironmentVariable(namedDEEPSEEK_API_KEY,matches.)classRefundChatServiceTest{AutowiredprivateRefundChatServicerefundChatService;TestvoidcontentIsNotBlankAndContainsPhrase(){Stringtextthis.refundChatService.answerForOrder(LC202610060018,这单未发货能不能退);System.out.println(text);assertThat(text).isNotBlank();assertThat(text).contains(未发货可退);assertThat(text).doesNotContain(优惠券);}TestvoidclientAndModelBothReportUsage(){UsagePairpairthis.refundChatService.compare(LC202610060018,这单未发货能不能退);System.out.println(clientPromptpair.clientUsage().getPromptTokens());System.out.println(modelPromptpair.modelUsage().getPromptTokens());assertThat(pair.clientUsage().getPromptTokens()).isPositive();assertThat(pair.modelUsage().getPromptTokens()).isPositive();assertThat(pair.clientText()).contains(未发货可退);assertThat(pair.modelText()).contains(未发货可退);}}$env:DEEPSEEK_API_KEY你的密钥 mvn -DtestRefundChatServiceTest test无密钥时整个类跳过。预期两条测试通过控制台有正文和两组 token。4. 项目总结4.1 优点与缺点对比项每个接口手写 PromptChatClient默认店规容易复制分叉defaultSystem集中一处取正文多层 getcontent()账单总在 ChatResponse 上需要时改chatResponse()换模型改每个 new换注入的 ChatModel学习成本低要分清建造期和请求期优点退款接口的调用代码缩短店规有一个默认入口。content()让控制器不必了解Generation。底层仍是DeepSeekChatModelUsage没有丢。单次system()可以覆盖默认店规不必为每个订单 new 客户端。缺点content()把finishReason藏起来截断会被当成正常正文。建造期写入订单号会造成串单。两条路径的 token 不一定相等错误的相等断言会制造假红。门面让人以为顾问已经生效。本章还没挂顾问。4.2 适用场景适用控制器只需要一段客服正文。多个接口共享店规少量请求要覆盖语气。需要证明门面和直接调用打到同一个模型。团队已经熟悉RestClient的建造方式。测试用关键短语而不是全文做断言。不适用必须在返回前判断LENGTH。这种接口用chatResponse()不要停在content()。需要流式推到页面。那是第 7 章的stream()。4.3 注意事项建造期只放与订单无关的店规。订单号放进单次system()或用户消息。要Usage就调用chatResponse()不要调用两次call()去补账单。注入ChatModel时容器里只能有一个聊天模型否则要改回DeepSeekChatModel。不要断言两次生成的全文相同。4.4 常见踩坑第二单回答里出现第一单订单号。根因是订单号写进了defaultSystem客户端是单例。处理订单号改到单次system()。截断的半句话返回给了 App。根因是只用content()没看finishReason。处理退款接口改用chatResponse()非STOP不展示。对照测试偶发失败在 token 相等。根因是把采样差异写成了等式。处理只断言大于 0 和关键短语数字打印出来供人工看。4.5 思考题defaultSystem和单次system()同时存在时你希望请求里有几条系统消息怎样用测试把条数打印出来而不是猜控制器只拿到content()的字符串。运维要按 token 计费时账单在哪一层采集才不会把同一次调用打两次模型答案在第 7 章开头。延伸阅读与资源10倍开发者的 Dify 魔法书从零构建全栈 AI 应用后端工程师转型AI第一课-Ollama 与私有化大模型实战大型语言模型(LLM) vLLM 高性能推理落地实战Agent开发之LlamaIndex 实战修炼与源码进阶大语言模型Transformers 实战修炼与源码剖析