上章思考题答案
- 聊天级密钥 B 生效。
DeepSeekChatAutoConfiguration.deepSeekApi先取chatProperties.getApiKey(),有文本才用它,否则回退commonProperties.getApiKey()。URL 同样先聊天级再连接级。 - 多个聊天 Starter 且都不写
spring.ai.model.chat时,各自的matchIfMissing = true可能同时成立,容器里出现多个ChatModel,注入点启动失败或注入到非预期实现。应显式配置spring.ai.model.chat=deepseek。
1. 项目背景
1.1 业务场景
用户第一句是「超时了」。客服必须反问订单号。用户第二句只说「LC202610060018」,不再重复「超时」。若系统把第二句当成一次全新对话,模型会追问「你想咨询什么」,体验像换了一个人。邻里仓要求这两轮连在同一次调用的消息列表里:店规、第一轮用户、第一轮客服、第二轮用户,按顺序交给 DeepSeek。
1.2 痛点
把两轮拼成一个大字符串,测试无法断言「第二轮请求里仍然带有订单号之前的那句客服反问」。模型也可能把店规和用户原话混在一起,用户写「忽略上文」时,制度文本和攻击文本没有边界。另外,调用成功但finishReason是LENGTH时,客服话术被截断,用户看到半句「您可以」,后面没有动作。
没有消息对象时的路径:字符串拼接 → 无法区分角色 → 无法单独断言 token → 截断被当成正常答复。邻里仓试运行时就有一单:用户第二句只发了订单号,机器人回复「请先说明你要咨询什么」。值班把日志里的整段字符串搜出来,分不清店规、第一轮反问和订单号各自在哪。测试无法复现,因为他们的用例把两轮合成了一句。本章把四条消息拆开,让失败能定位到「漏了助手历史」而不是「模型今天不聪明」。订单号使用虚构的LC202610060018,不要拿生产单号写进示例和日志。测试夹具放在src/test/resources,文件里只有这一条单号和两句固定用户话,方便以后换成回放,而不改生产数据。
2. 项目设计
小胖说:「第二句把订单号补上不就行了?跟食堂第二次窗口喊『还是刚才那份』一样,师傅记得住。」
小白问:「进程重启之后师傅还记得吗?就算记得,测试怎么证明你真的把第一轮带上了,而不是模型自己猜的?只看最终句子里有订单号,证明不了历史消息的角色。」
小胖又说:「那我在第二句前面拼上『上一句客服说:请提供订单号』。跟把小票订在第二张菜单上一样。」
小白追问:「拼进 user 角色之后,模型会把它当成用户说过的话。用户没说过的句子,出现在 user 里,审计对不上。结束原因你打算放哪?截断的半句『您可以』算不算这轮已经完成?」
小白补了一句:「第一轮如果本身就因为 token 上限停了,你把半句助手回复塞进第二轮,第二轮会沿着半句话往下编。历史要带,但带之前得看finishReason。」
大师把四条消息分开:「Prompt持有List<Message>。店规用SystemMessage,用户两句用UserMessage,中间那句反问用AssistantMessage,因为它是模型曾经生成的内容,不是用户说的。第二轮不是新的系统,而是更长的同一列表。本章先在内存里手写这个列表。跨重启的保存放到第 9 章的ChatMemory,不要现在就上数据库。」
技术映射:一次多轮请求是一个Prompt,角色边界是Message,还不是ChatMemory。
小胖不放心:「列表一长,我会把店规只放在第一轮。第二轮用户都报了订单号,还背店规不是浪费 token?」
小白说:「第二轮若没有SystemMessage,模型只看见订单号和上一句反问,店规里的『不要编造赔偿』会丢。省下的 token 换不回一次胡答。测试要断言两轮的第一条都是SYSTEM。」
小白再问:「助手历史若是截断的半句,带进第二轮会不会让模型顺着半句编完?带历史之前要不要先看finishReason?」
大师说:「要看。第一轮不是STOP就不要构造第二轮。历史的价值是完整上下文,不是把残句继续喂回去。本章的内存列表只活在一次请求的组装过程里,重启之后没有。跨重启是后面的记忆仓库,现在不要提前接数据库。测试可以先用固定字符串『请提供订单号』充当助手历史,证明列表形状正确,再在有密钥时换成模型真实输出。两步不要并成一个只能夜间跑的用例。」
小白问覆盖:「yml 里温度是 0.2。这一次超时咨询想把maxTokens收到 64,是改 yml 还是只改这一次?」
小胖说:「改 yml 最简单,改完再改回去。」
小白反对:「改 yml 会影响同时在跑的问候接口。有没有只活在这一次请求上的选项?」
大师说明合并规则:「DeepSeekChatModel在prompt.getOptions()为空时,用模型自己的getOptions()填上。你若构造new Prompt(messages, options),这次请求使用你传入的ChatOptions,不改 Bean 上的默认值。源码方法是buildRequestPrompt。注意:传入的 options 不会自动和默认 options 做字段级合并。你这次没写的温度,不会魔法般保留 yml 里的 0.2,除非你自己先mutate()再改。」
技术映射:请求级ChatOptions挂在Prompt上。它替换默认选项,不是和默认选项逐字段合并。
小胖看着响应:「我只要最后一句话。token 让运维看就行。」
小白说:「截断的半句话也是一句话。测试如果只断言非空,会把LENGTH放过去。我还要知道第一轮如果已经截断,第二轮是否还允许把那半句放进历史。」
小白补问:「getResults()是列表。超时咨询会不会一次返回多条Generation?测试只取getResult()会不会漏掉后面的文本?」
大师把响应拆开:「ChatResponse.getResults()是列表,普通对话取getResult()即可,它就是第一条。本章的超时咨询不会主动要多候选,看到列表大小不是 1 时先记下来,不要默默拼接。正文是Generation.getOutput().getText()。Generation.getMetadata().getFinishReason()对应协议里的finish_reason。ChatResponse.getMetadata().getUsage()给出getPromptTokens()和getCompletionTokens()。本章打印这三项,并断言第二轮的提示词 token 不少于第一轮,因为消息列表更长。第一轮若不是STOP,测试应失败在第一轮,不要用截断文本去构造第二轮的AssistantMessage。历史要完整,完整的前提是上一轮真的结束了。」
技术映射:最后一句话是Generation,停机原因是finishReason,两轮成本差看Usage。
3. 项目实战
沿用第 2 章的 Spring Boot 工程和DeepSeekChatModel注入。不要新起一套客户端。
3.1 环境准备
与第 2 章相同:spring-ai-starter-model-deepseek,DEEPSEEK_API_KEY,模型deepseek-v4-flash。把 yml 的max-tokens暂时调到 256,避免两轮回答被截断。请求级实验会单独把某一次调用的maxTokens降到 16,用来观察LENGTH。
3.2 分步实现
步骤 1:先跑第一轮,拿到助手原文
步骤目标:第一轮必须向用户要订单号,并把助手原文保存下来,供第二轮放进AssistantMessage。
packagecom.linlicang.aftersale;importjava.util.ArrayList;importjava.util.List;importorg.springframework.ai.chat.messages.AssistantMessage;importorg.springframework.ai.chat.messages.Message;importorg.springframework.ai.chat.messages.SystemMessage;importorg.springframework.ai.chat.messages.UserMessage;importorg.springframework.ai.chat.model.ChatResponse;importorg.springframework.ai.chat.prompt.Prompt;importorg.springframework.ai.deepseek.DeepSeekChatModel;importorg.springframework.ai.deepseek.DeepSeekChatOptions;publicclassTimeoutDialogue{staticfinalStringRULE=""" 你是邻里仓售后客服。用户没给订单号时,只向用户索取订单号,不要编造赔偿。 用户已经给出订单号时,用一句话确认「已记录该订单的超时咨询」。 """;privatefinalDeepSeekChatModelchatModel;publicTimeoutDialogue(DeepSeekChatModelchatModel){this.chatModel=chatModel;}publicChatResponsefirstTurn(){Promptprompt=newPrompt(List.of(newSystemMessage(RULE),newUserMessage("超时了")));returnthis.chatModel.call(prompt);}publicChatResponsesecondTurn(StringassistantAskedForOrderId){List<Message>messages=newArrayList<>();messages.add(newSystemMessage(RULE));messages.add(newUserMessage("超时了"));messages.add(newAssistantMessage(assistantAskedForOrderId));messages.add(newUserMessage("LC202610060018"));returnthis.chatModel.call(newPrompt(messages));}}运行结果(文字描述):第一轮正文里出现「订单号」一类措辞。第二轮正文确认订单LC202610060018,并且不再把「请提供订单号」当作唯一动作。控制台同时打印两轮的finishReason。
可能的坑:第二轮如果忘了放AssistantMessage,模型仍可能因为系统提示词而再次索要订单号。测试要断言第二轮用户消息的文本就是订单号,助手历史消息存在,而不是只看最终自然语言。
步骤 2:打印 finishReason 和两侧 token
步骤目标:让测试能对两轮分别取数。
packagecom.linlicang.aftersale;importorg.springframework.ai.chat.metadata.Usage;importorg.springframework.ai.chat.model.ChatResponse;importorg.springframework.ai.chat.model.Generation;publicfinalclassTurnReport{privateTurnReport(){}publicstaticStringformat(Stringname,ChatResponseresponse){Generationgeneration=response.getResult();Usageusage=response.getMetadata().getUsage();returnname+" finishReason="+generation.getMetadata().getFinishReason()+" promptTokens="+usage.getPromptTokens()+" completionTokens="+usage.getCompletionTokens()+" text="+generation.getOutput().getText();}}运行结果(文字描述):第二轮promptTokens大于第一轮。两轮finishReason在max-tokens: 256下多为STOP。数字不要写死。
可能的坑:有人用getTotalTokens()比较两轮,忽略了完成 token 的波动。比较历史是否带上,应看promptTokens。完成 token 受回答长短影响,不能用来证明消息列表变长。
步骤 3:只对第二轮收紧 maxTokens,观察 LENGTH
步骤目标:证明请求级ChatOptions能覆盖这一次,且LENGTH必须被测试抓住。
publicChatResponsesecondTurnTruncated(StringassistantAskedForOrderId){DeepSeekChatOptionstight=DeepSeekChatOptions.builder().model(DeepSeekApi.ChatModel.DEEPSEEK_V4_FLASH).temperature(0.2).maxTokens(16).build();List<Message>messages=List.of(newSystemMessage(RULE),newUserMessage("超时了"),newAssistantMessage(assistantAskedForOrderId),newUserMessage("LC202610060018"));returnthis.chatModel.call(newPrompt(messages,tight));}需要补充导入org.springframework.ai.deepseek.api.DeepSeekApi。
运行结果(文字描述):finishReason为LENGTH或供应商返回的等价截断原因。正文明显短于未截断的第二轮。yml 里的max-tokens: 256没有被这次调用改掉,下一次不传 options 的调用仍然用 256。
可能的坑:只 new 了maxTokens(16)却没写model时,DeepSeekChatOptions构造器会把模型填成默认值,一般仍是deepseek-v4-flash,碰巧能工作。若默认模型以后变了,这次「只想改 token」会连模型一起换。要保留 yml 模型时,先取chatModel.getOptions(),确认它是DeepSeekChatOptions再mutate().maxTokens(16).build()。
DeepSeekChatOptionstight=((DeepSeekChatOptions)this.chatModel.getOptions()).mutate().maxTokens(16).build();这才是「只改这一次的最大 token」。
步骤 4:在测试里固定角色序列,而不只比较 token
步骤目标:当promptTokens因模型tokenizer波动只多了一点时,仍能证明四条消息都发出去了。
给TimeoutDialogue增加一个包可见方法,返回第二轮将要发送的Prompt,测试不调用模型也能断言结构:
publicPromptsecondTurnPrompt(StringassistantAskedForOrderId){returnnewPrompt(List.of(newSystemMessage(RULE),newUserMessage("超时了"),newAssistantMessage(assistantAskedForOrderId),newUserMessage("LC202610060018")));}运行结果(文字描述):不访问网络的测试打印角色序列system,user,assistant,user,最后一条文本精确等于LC202610060018。连网测试在此基础上再比较两轮promptTokens。这样 CI 每天都能抓住「漏放助手消息」,不必等夜间任务。
可能的坑:把secondTurnPrompt和真正call用的列表写成两份,以后只改一处。应让secondTurn直接return this.chatModel.call(secondTurnPrompt(...)),列表只有一个来源。另一个坑是断言助手文本等于某一句固定中文。第一轮措辞会变,结构测试应传入一个固定的假历史句,例如「请提供订单号」,不要依赖上一轮的真实模型输出。真实模型输出留给连网测试。
步骤 5:把两轮调用的报告收成测试可保存的文本
步骤目标:夜间连网任务留下一份能附在缺陷单上的记录,而不是只留红绿。
TurnReport.format已经包含名字、结束原因和两侧 token。测试在打印后把两行写进断言消息,失败时 JUnit 的输出里就能看到数字。不要把订单号以外的用户隐私写进报告。本章示例订单号LC202610060018是虚构的。
再加一条不连网的结构测试,输入固定的助手历史「请提供订单号」,断言secondTurnPrompt的说明列表大小为 4,下标 2 的类型是ASSISTANT,下标 3 的文本是订单号。这条测试放在TimeoutDialogueTest同一个类里会强迫整个类依赖密钥。应拆到没有@SpringBootTest的TimeoutDialoguePromptTest,直接new TimeoutDialogue(null)不可行,因为构造器要模型。把secondTurnPrompt改成静态方法,结构测试就不必启动容器。
运行结果(文字描述):mvn -Dtest=TimeoutDialoguePromptTest test在无密钥时通过,输出角色序列。连网测试另跑,失败时报告里能看到两轮的promptTokens。若第二轮 token 没有增加,先看结构测试是否真的包含四条消息,再怀疑供应商用量。
可能的坑:静态方法和实例方法各维护一份消息列表。只允许静态方法创建列表,实例方法只负责call。否则结构测试绿了,真实调用仍是旧列表。
联调时把两轮TurnReport和角色序列贴在同一张缺陷单上。只有 token 没有角色序列时,无法区分「历史没带上」和「tokenizer 把增量算得很小」。结构测试每天跑,连网测试按密钥任务跑。测试数据只用虚构订单号。若第二轮回答里没有该订单号,先看发出去的UserMessage文本,再决定是不是模型没听话。角色序列期望值写成注释留在测试方法上方:system, user, assistant, user。改列表顺序的人必须同时改这行注释和断言。
3.3 完整代码清单
TimeoutDialogue、TurnReport以及下一节测试。源码对照:
spring-ai-model/.../chat/messages/SystemMessage.java.../UserMessage.java.../AssistantMessage.java.../chat/prompt/Prompt.java.../chat/model/ChatResponse.java.../chat/metadata/Usage.javamodels/spring-ai-deepseek/.../DeepSeekChatModel.java中的buildRequestPrompt
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;importorg.springframework.ai.chat.model.ChatResponse;importstaticorg.assertj.core.api.Assertions.assertThat;@SpringBootTest@EnabledIfEnvironmentVariable(named="DEEPSEEK_API_KEY",matches=".+")classTimeoutDialogueTest{@AutowiredprivateTimeoutDialoguedialogue;@TestvoidsecondTurnKeepsHistoryAndCostsMorePromptTokens(){ChatResponsefirst=this.dialogue.firstTurn();Stringasked=first.getResult().getOutput().getText();System.out.println(TurnReport.format("first",first));assertThat(first.getResult().getMetadata().getFinishReason()).isEqualTo("STOP");assertThat(asked).contains("订单");ChatResponsesecond=this.dialogue.secondTurn(asked);System.out.println(TurnReport.format("second",second));assertThat(second.getResult().getOutput().getText()).contains("LC202610060018");assertThat(second.getMetadata().getUsage().getPromptTokens()).isGreaterThan(first.getMetadata().getUsage().getPromptTokens());}@TestvoidtinyMaxTokensIsNotStop(){ChatResponsefirst=this.dialogue.firstTurn();ChatResponsetruncated=this.dialogue.secondTurnTruncated(first.getResult().getOutput().getText());System.out.println(TurnReport.format("truncated",truncated));assertThat(truncated.getResult().getMetadata().getFinishReason()).isNotEqualTo("STOP");}}TimeoutDialogue需要注册成 Bean。在类上加@Component,或在测试配置里@Bean。预期输出两行TurnReport,第二行promptTokens更大。截断用例的finishReason不是STOP。
若供应商把截断原因写成小写length,断言改为isNotEqualToIgnoringCase的反向:使用assertThat(...).isNotEqualToIgnoringCase("STOP")。不要为了绿而删掉这条断言。
4. 项目总结
4.1 优点与缺点
| 对比项 | 拼成一个字符串 | 多条 Message 组成 Prompt |
|---|---|---|
| 角色审计 | 无法区分谁说过 | MessageType可断言 |
| 多轮成本 | 看不出历史有没有带上 | 比较两轮promptTokens |
| 截断 | 半句话也像成功 | finishReason可单独断言 |
| 请求级参数 | 通常要改全局配置 | Prompt携带ChatOptions |
| 代码量 | 少 | 每轮多一个列表 |
优点:
- 店规、用户、助手三分开,测试可以检查列表结构,而不只检查最终散文。
- 第二轮 token 上升是「历史真的发出去了」的旁证。
finishReason把截断从内容问题提升为协议问题。- 一次请求的
maxTokens可以收紧,不影响 Bean 默认值。
缺点:
- 手写列表在轮次变多后容易漏放某一条。
- 请求级 options 是替换而不是深度合并,漏写模型名会落到默认模型。
- 助手原文若被业务改写后再放回
AssistantMessage,模型看到的历史和用户看到的历史不一致。 - 本章列表存在内存调用栈上,进程一停就没了。
4.2 适用场景
适用:
- 客服必须记住本轮里刚刚索取的订单号。
- 测试要证明历史消息被发送,而不是模型猜中。
- 某一条接口需要更短的回答,其它接口保持 yml 里的
max-tokens。 - 事故里要区分正常结束和截断。
- 审计要求保留角色,而不是一大段文本。
不适用:
- 需要跨进程保存几十轮历史。那是
ChatMemoryRepository,不是更长的局部变量。 - 回答必须是结构化 JSON。本章只处理自然语言和元数据,JSON 在第 8 章。
4.3 注意事项
- 把助手回复放进下一轮时,使用
AssistantMessage,不要包成UserMessage。 - 比较成本用
promptTokens。completionTokens抖动大。 finishReason的字面值以供应商返回为准,测试里至少区分STOP与非STOP。- 系统提示词两轮都要带上。只在第一轮带,第二轮模型会失去店规。
4.4 常见踩坑
- 第二轮又索要订单号。根因是构造
Prompt时漏了AssistantMessage,或把助手话放进了 user。处理:在测试里遍历prompt.getInstructions(),断言角色序列为 system、user、assistant、user。 - 只有截断用例失败,正常用例通过。根因是
maxTokens(16)的 options 没带上原来的模型,打到了另一个不支持该参数组合的名字。处理:从getOptions().mutate()改一个字段。 - 生产把 LENGTH 的半句话发给了用户。根因是网关只判断 HTTP 200。处理:
finishReason不是STOP时返回「请再试一次」并打点,不展示截断正文。
4.5 思考题
- 从
chatModel.getOptions()取出的对象如果不是DeepSeekChatOptions,mutate().maxTokens(16)还能否编译通过?这提示你在多模型工程里应依赖哪个接口? - 第二轮
promptTokens比第一轮只多了 1。历史明明多了两句话。你首先怀疑消息没带上,还是怀疑 token 统计本身的粒度?你要加哪一条断言才能区分这两种情况?
答案在第 4 章开头。
延伸阅读与资源
10倍开发者的 Dify 魔法书:从零构建全栈 AI 应用
后端工程师转型AI第一课-Ollama 与私有化大模型实战
大型语言模型(LLM) vLLM 高性能推理落地实战
Agent开发之LlamaIndex 实战修炼与源码进阶
大语言模型Transformers 实战修炼与源码剖析