客服路由要的不是一段看着点头的话。它要能落库的 yes/no、稳定的分类键和一个按阈值处理的档位。

Spring AI 2.1.0-RC2 上对照跑了四条英文工单。注入的 DecisionModel 一次返回三种强类型答案:坏耳机工单的缺陷 yes 概率为 0.995838,分类为 product,满意度最近档为 unhappy。ChatModel 虽然四次都按约定只输出 yes/no,却把这条明确坏了的工单答成了 no。能解析,不等于判对。

环境和依赖

这次用的是 Spring Boot 4.2.0-RC2、Spring AI 2.1.0-RC2、Ollama 0.40.15、JDK 21.0.12.1。Chat 对照是 qwen3:0.6b,temperature=0,关闭 thinking;Decision 是 d1-omni:600m。两种模型都先预热,再分别计时。

RC2 的发布条目包含 DecisionModel API、OllamaDecisionModel、SystemOneDecisionModel,以及文档过滤、重排和工具检索的配套实现。本文只测三问调用和文档重排。

Ollama 的 starter 同时带来 ChatModel 和 DecisionModel 自动配置。BOM 版本统一为 2.1.0-RC2。

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.ai</groupId>
      <artifactId>spring-ai-bom</artifactId>
      <version>2.1.0-RC2</version><type>pom</type><scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependency>
  <groupId>org.springframework.ai</groupId>
  <artifactId>spring-ai-starter-model-ollama</artifactId>
</dependency>

Ollama 本地运行无需密钥。下载两个模型,启动 ollama serve,其中第一个模型支持 decision 能力。

ollama pull d1-omni:600m
ollama pull qwen3:0.6b
ollama show d1-omni:600m
Capabilities
  decision

配置如下。chat 的 model 在 RC2 的 properties 上;同时指定 decision 模型名。Reranker 这一小段还需要 spring-ai-rag 依赖。

spring:
  main:
    web-application-type: none
  ai:
    ollama:
      base-url: http://localhost:11434
      chat:
        model: qwen3:0.6b
        options:
          temperature: 0.0
      decision:
        model: d1-omni:600m

一次提问,拿回 yes/no、分类和档位

把三种问题放进同一个 DecisionPrompt。每个问题有名字,返回的答案按同名 getter 取出。YesNo 的 whenYes 和 whenNo 要写清边界,Choice 给每个键补一段定义,Score 的档位按低到高排列。

DecisionResponse response = decisionModel.call(DecisionPrompt.builder()
        .text(message)
        .question("defect", YesNoQuestion.builder()
                .text("Does the customer report a defect?")
                .whenYes("Part of the item does not work as it should")
                .whenNo("The item works, and the message is about something else")
                .build())
        .question("topic", ChoiceQuestion.builder()
                .text("What is the message about?")
                .option("delivery", "Where an order is, or when it will arrive")
                .option("product", "A problem with an item the customer received")
                .option("account", "Login, address or payment details")
                .option("other", "None of the above")
                .build())
        .question("satisfaction", ScoreQuestion.of(
                "How satisfied is the customer?", "unhappy", "neutral", "happy"))
        .build());

YesNoAnswer defect = response.getYesNo("defect");
ChoiceAnswer topic = response.getChoice("topic");
ScoreAnswer satisfaction = response.getScore("satisfaction");

boolean yes = defect.isYes();
double probability = defect.getProbability();
String route = topic.getValue();
int level = satisfaction.getNearestLevel(); // unhappy=0, neutral=1, happy=2
double expected = satisfaction.getValue();

isYes() 默认以 0.5 为界,isYes(0.9) 则把自动处理门槛抬到 0.9。T-101 的缺陷概率是 0.995838,在 0.5 和 0.9 这两个门槛下都判 yes,但概率本身就进了结果,不必从文本里猜置信口吻。

Choice 的 getRankedOptions() 给出选项排序,Score 的 getNearestLevel() 给最接近的整数档位。后者是 0 到档位总数减 1,不是 1 到 5。getValue() 返回概率加权的期望档位,它可能落到两个档位之间,不能当最近档位下标。

坏耳机这条工单的核心输出如下,满意度概率也来自实际响应:

DECISION id=T-101 defectYes=true probability=0.995838 topic=product

同一响应的满意度概率为 [0.936743, 0.058952, 0.004304],加权期望档位 0.067561,最近档 0 对应 unhappy。

这不是把 Chat 的字符串截成 Boolean。YesNoAnswer、ChoiceAnswer、ScoreAnswer 各有自己的取值方法,返回键、概率和档位直接可以进入业务判断。

Chat 对照:严格回复不等于正确分诊

对照那一路只回答 yes/no,同样关闭思考,温度固定为 0。

ChatResponse response = chatModel.call(new Prompt(new UserMessage("""
        Read this customer support ticket.
        Does it report a defect? A defect means a part does not work as it should.
        Answer with exactly one lowercase word: yes or no.
        Do not explain. Ticket: %s
        """.formatted(message)),
        OllamaChatOptions.builder()
                .model("qwen3:0.6b")
                .disableThinking()
                .build()));

String plainAnswer = response.getResult().getOutput().getText().trim();
boolean strict = plainAnswer.equals("yes") || plainAnswer.equals("no");
boolean yes = plainAnswer.equals("yes");

调用时还需显式指定模型。仅传 OllamaChatOptions.builder().disableThinking().build() 时,空 model 会让底层请求回退到 Ollama 的 mistral 默认模型,而不是配置文件里的 qwen3:0.6b,实际请求得到 model 'mistral' not found。本次预热和逐工单的 Chat 调用都显式写了 model。

四条工单如下。T-101 明确坏件;T-102 问配送;T-103 改邮箱且产品正常;T-104 是无损坏退货,不能算缺陷。对照计时已排除两个模型预热的耗时。

工单 Chat 原文 Chat 严格单字解析 Chat 延迟 Decision:缺陷概率 / 分类 / 最近满意度档 Decision 延迟
T-101 左侧耳机无声 no true 189 ms yes 0.995838 / product / 0 276 ms
T-102 包裹两周未到 no true 231 ms no 0.297024 / delivery / 0 298 ms
T-103 修改账户邮箱 no true 249 ms no 0.005511 / account / 2 278 ms
T-104 完好键盘想退货 no true 269 ms no 0.167491 / product / 0 279 ms

本轮合计如下。四路 Decision 均得到正确预期缺陷标签,并完成 T-101 的 product、T-102 的 delivery、T-103 的 account 分路;Chat 的 T-101 则答错。这里只比较这四个样例、这两枚小模型、这一次本地运行,不能把它换算成任意模型的准确率。

指标 ChatModel DecisionModel
每批调用 4 次,只问缺陷 4 次,问缺陷、分类、满意度
总延迟 938 ms 1131 ms
平均延迟 234.5 ms 282.8 ms
缺陷 yes/no 可解析 4/4 4/4,且有概率
四条预期缺陷标签 3/4 4/4
输入 token 299 756
完成 token 8 0
分类与满意度 没有 每批都有

本次原始汇总和 Reranker 输出:

CHAT_TOTAL calls=4 elapsedMs=938 avgMs=234.5 strictParse=4/4 promptTokens=299 completionTokens=8
DECISION_TOTAL calls=4 elapsedMs=1131 avgMs=282.8 typedAnswers=4/4 promptTokens=756 completionTokens=0
RAG_RERANK id=policy-answer score=0.32255170447979487
RAG_RERANK id=policy-topic score=0.022950820209939476
RAG_RERANK_SUMMARY ranked=2/2 elapsedMs=148

这一轮 Decision 总耗时比 Chat 高约 20.6%,但两路使用的模型和任务都不同,不能把差值归因于 DecisionModel 接口或多问两项。Chat 报告 8 个完成 token,Decision 接口报告 0;Ollama 本地没有 token 账单,两者消耗的是本机计算资源,不能用这两个计数推导云服务费用折扣。没有传入 OpenAI 密钥,OpenAI Decision 本轮未测。

概率也不是对正确率的承诺。模型不同、问题措辞不同,概率不能互相直接套阈值。这里的 0.9 门槛只是一个能跑的示例:isYes(0.9) 为 true 才进入自动处理,低于门槛则走人工队列,不让“接近五五开”的结果直接改工单。

顺手接上同主题文档重排

DecisionDocumentReranker 不传模型实现细节,直接复用同一个 DecisionModel。它逐个文档问“这段是否回答查询”,把 yes 概率写入 decision.rerank.score。本次只放两段相关主题候选,不引入向量库。

List<Document> ranked = DecisionDocumentReranker.builder(decisionModel)
        .build()
        .process(new Query("How long do I have to refund defective headphones?"), documents);

ranked.forEach(document -> System.out.printf("%s %.6f%n", document.getId(),
        document.getMetadata().get(DecisionDocumentReranker.SCORE_METADATA_KEY)));

带 30 天退货时限的 policy-answer 得 0.322552,只说退货和损坏不同的 policy-topic 得 0.022951,答案段排第一。两段的绝对得分都不高,重排比较的是同一批候选项的相对顺序,不把 0.322552 当 32.26% 的正确率。真正进 prompt 前列的,是“是否答到了查询”这个二次判断,而不只是“话题相同”。

— 感谢阅读 —
广告

一起交流

分享你的思考,让讨论更进一步。