主题
决策服务
NOTE
决策服务尚属实验性,未来版本可能变化。
决策模型就某个输入(state)回答带类型的问题:是/否问题、在若干选项中择一的问题,等等。决策服务让你通过一个普通 Java 接口使用它们:你把想了解的东西声明为方法,LangChain4j 为你实现该接口。
java
enum Team {
@Description("Payments, payouts, invoices, refunds") BILLING,
@Description("Problems using the product") SUPPORT,
@Description("Pricing, upgrades, new accounts") SALES
}
interface SupportDesk {
@Decide("Is this message spam?")
boolean isSpam(String message);
@Decide("Which team should handle this ticket?")
Team route(String ticket);
}
SupportDesk supportDesk = DecisionServices.create(SupportDesk.class, decisionModel);
boolean spam = supportDesk.isSpam("Congratulations! You won a free cruise!"); // true
Team team = supportDesk.route("I was charged twice this month"); // BILLING任何 DecisionModel 都可使用,例如 TypeSafe。DecisionServices.builder(SupportDesk.class) 提供更多选项,例如阈值。
决策服务与 Drools、Kogito 或 Camunda 中那种 DMN(决策模型与标记法)的决策服务无关:它们用一个决策模型回答问题,并可像任何其他 Java 服务那样被调用。
Maven 依赖
决策服务是 langchain4j 模块的一部分;把它与你的 DecisionModel 所在模块一起添加:
xml
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j</artifactId>
<version>1.22.0</version>
</dependency>决策服务会把方法参数的名称发送给模型,因此请用 -parameters 选项编译你的代码(或用 @V 为每个参数命名,见参数):
xml
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<parameters>true</parameters>
</configuration>
</plugin>用在枚举常量上的 @Description 注解是 dev.langchain4j.model.output.structured.Description。
工作原理
对每次调用,决策服务会:
- 把方法参数以参数名为键,作为输入(state)发送给模型,例如
{"message": "Congratulations! You won a free cruise!"}。 - 提出由方法返回类型与
@Decide注解派生出的问题。每个问题以方法名命名,或对返回对象的方法以字段命名,且模型会看到这些名称,因此请选取有意义的名称。 - 把答案转换回返回类型。
所有方法都会在调用 build() 时被检查,因此一个配置有误的方法(不受支持的返回类型、缺失的 @Decide 等)会立即带着解释失败,而不是等到第一次调用才失败。
返回类型
| 返回类型 | 问题 | 结果 |
|---|---|---|
boolean / Boolean | 是/否 | 若"是"的概率达到阈值则为 true |
YesNoAnswer | 是/否 | "是"的概率 |
| 一个枚举 | 在枚举常量之间选择 | 被选中的常量 |
Choice<E>(E 是枚举) | 在枚举常量之间选择 | 被选中的常量以及每个常量的概率 |
Scale<E>(E 是枚举) | 在一个有序量表上的位置,其等级即枚举常量 | 均值等级、最可能的等级以及每个等级的概率 |
| 一个其字段为上述类型的类或 record | 每个字段一个问题,全部在一次调用中 | 一个每个字段都已赋值的实例 |
其中任何一种都可再包装在 DecisionResult<T> 和/或 CompletableFuture<T> 或 CompletionStage<T> 中。
是/否问题
java
interface Moderation {
@Decide("Is this message spam?")
boolean isSpam(String message);
@Decide("Is this message spam?")
YesNoAnswer spamProbability(String message);
}
YesNoAnswer spam = moderation.spamProbability(message);
spam.probability(); // 0.97
spam.isYes(0.9); // true当你想在自己的代码里就阈值作决定,或想在不重新调用模型的情况下评估多个阈值时,返回 YesNoAnswer。
选择问题
枚举的常量就是选项。模型会看到每个常量的名称连同其 @Description(若常量没有 @Description 则只有名称),因此请选取能说明每个选项覆盖什么的名称与描述:
java
@Decide("Which team should handle this ticket?")
Choice<Team> routeWithProbabilities(String ticket);
Choice<Team> choice = supportDesk.routeWithProbabilities(ticket);
choice.value(); // BILLING
choice.probabilities(); // {BILLING=0.88, SUPPORT=0.1, SALES=0.02}
choice.probabilityOf(Team.SUPPORT); // 0.1
choice.margin(); // 0.78, the difference between the two most likely constants
choice.confidence(); // provided by some models, see below较小的差距意味着模型在两个选项之间犹豫,这是一个值得升级处理的好信号:
java
Choice<Team> choice = supportDesk.routeWithProbabilities(ticket);
if (choice.margin() < 0.2) {
humanQueue.add(ticket);
} else {
assign(ticket, choice.value());
}confidence() 由各模型以不同方式计算,因此对于阈值请优先使用概率(见概率与置信度)。
量表问题
当选项是有序的(严重性、紧迫性、沮丧度、质量)时,返回 Scale<E>。等级即枚举常量,从第一个声明的(最低)到最后一个(最高)。每个等级以常量的名称标注,并(若有)以其 @Description 描述:
java
enum Severity {
@Description("Cosmetic issue, no impact") LOW,
@Description("A feature is degraded, a workaround exists") MEDIUM,
@Description("A feature is broken for some customers") HIGH,
@Description("Outage or data loss") CRITICAL
}
interface IncidentTriage {
@Decide("How severe is this incident?")
Scale<Severity> severity(String incident);
}
Scale<Severity> severity = incidentTriage.severity(report);
severity.mean(); // 2.3, from 0 (LOW) to 3 (CRITICAL)
severity.mostLikely(); // HIGH
severity.probabilities(); // {LOW=0.02, MEDIUM=0.1, HIGH=0.46, CRITICAL=0.42}
severity.probabilityOf(Severity.CRITICAL); // 0.42
severity.probabilityAtLeast(Severity.HIGH); // 0.88mean() 是各等级索引的概率加权平均,因此它可能落在两个等级之间。它适合用来比较输入或跟踪随时间的趋势,例如客户的平均沮丧度。probabilityAtLeast(level) 适合针对某个等级及以上采取行动,例如当某事件很可能至少为高严重度时呼叫值班工程师:
java
if (severity.probabilityAtLeast(Severity.HIGH) > 0.5) {
pageOnCall(report);
}一个普通的枚举返回类型始终是一个选择问题,其中常量的顺序无关紧要。
一次调用中的多个问题
当一个方法返回一个类或 record 时,每个字段都成为一个问题,且所有问题在一次对模型的调用中被回答:
java
record Triage(
@Decide("Which team should handle this ticket?") Team team,
@Decide("Does this need attention today?") boolean urgent,
@Decide("Does the customer ask for money back?") YesNoAnswer refund) {}
interface SupportDesk {
Triage triage(String ticket);
}
Triage triage = supportDesk.triage("I was charged twice and our payroll runs today!");
triage.team(); // BILLING
triage.urgent(); // true
triage.refund().probability(); // 0.99每个字段都需要在 @Decide 中有一个问题;没有它的字段会在调用 build() 时失败。要保留一个不是问题的字段,请把它声明为 transient。这类方法不支持在方法本身上的 @Decide。
只要它是一个顶层或静态嵌套类、具有无参构造函数与非 final 字段,普通类同样可行:
java
class Triage {
@Decide("Which team should handle this ticket?")
Team team;
@Decide("Does this need attention today?")
boolean urgent;
}参数
所有参数都以参数名为键,作为输入(state)发送给模型。为 null 的参数会被略去,因此模型无法区分一个 null 值与一个缺失的参数。
java
Triage triage(String ticket, Customer customer);
// input: {"ticket": "...", "customer": {"plan": "enterprise", "openTickets": 3}}这些名称帮助模型理解每个值的含义,因此请好好命名它们。对象会用其 Java 字段名转换为 map,图像等内容除外(见图像)。所有字段都会被发送,因此要精确控制模型看到什么(并避免发送它并不需要的个人数据),请传入一个只含相关字段的小 record,而不是例如一个 JPA 实体。若发送给模型的所有参数都是 null,调用会以 IllegalArgumentException 失败。
只有当代码用 -parameters 选项编译时(基于 Spring Boot 或 Quarkus 的项目通常会启用它),参数名才在运行时可用。否则,请用 @V 为参数命名:
java
Triage triage(@V("ticket") String ticket, @V("plan") String plan);若某个名称不可用,build() 会失败并说明这两种选项。
图像
类型为 Image 或某个 Content 类型(例如 ImageContent)的参数,或它们的集合/数组,会作为内容发送,例如图像,只要决策模型支持它们(例如 OpenAI,或带 Clef 这类视觉模型的 Ollama):
java
interface DamageInspector {
@Decide("Is the item visibly damaged?")
boolean isDamaged(ImageContent photo, String comment);
}与其他参数一样,内容会以其参数名发送(见图像),且每个决策模型都以其 API 期望的形式发送它们。
模型名与其他参数
类型为 DecisionRequestParameters 的参数不会作为输入的一部分被发送。相反,它设置本次调用的参数,例如所使用的模型:
java
@Decide("Which team should handle this ticket?")
Team route(String ticket, DecisionRequestParameters parameters);
Team team = supportDesk.route(ticket, DecisionRequestParameters.builder()
.modelName("jev-1.13.0")
.build());规则与判据
答案往往取决于规则:什么算垃圾信息、某个社区允许什么、工单何时算紧急。模型会在这些规则出现之处使用它们,无论是在问题里还是在输入里,所以决策服务没有为它们单独的属性。用更简单的那个:
- 从不改变的规则属于问题的一部分。Java 文本块能让较长的问题保持易读:
java
@Decide("""
Is this comment spam?
Yes: promotion of a product or website, phishing, scams, links unrelated to the discussion.
No: a genuine opinion, question or complaint, even if it is rude.""")
boolean isSpam(String comment);- 逐次调用而不同的规则(按客户、按租户,或从数据库或配置加载)作为参数传入,并作为输入的一部分被发送:
java
@Decide("Does the post violate the community rules?")
boolean violates(String post, String communityRules);
moderation.violates(post, community.rules());无论你选哪种,都要把规则本身给模型,而不只是一个标签:参数 plan = "enterprise" 并不告诉模型企业套餐保证什么,而 urgentWhen = "any broken feature, or anything affecting invoices" 会。
DecisionModel API 也接受把一个是/否问题的判据分开提供(yesWhen 与 noWhen),包括结构化的判据。
阈值
当"是"的概率大于或等于某个阈值时,boolean 结果为 true。该阈值默认为 0.5,可用一个 ThresholdProvider 配置。它接收一个描述该问题的 ThresholdContext:
serviceInterface()与method():被调用的服务与方法;questionName():方法的名称,或对返回对象的方法而言字段的名称;modelName():回答的模型,如提供者所上报的(例如一个固定的版本而非别名),以便每个模型版本能有自己的阈值。若提供者不上报它,则为所请求的模型名。
它每次调用都会被调用,因此阈值可以来自运行时变化的配置。它可能被并发调用,且对于异步方法会在完成模型调用的那个线程上被调用,所以它必须是线程安全的且不得阻塞:
java
SupportDesk supportDesk = DecisionServices.builder(SupportDesk.class)
.decisionModel(decisionModel)
.thresholdProvider(context -> config.getDouble( // e.g. SupportDesk.isSpam=0.9
context.serviceInterface().getSimpleName() + "." + context.questionName()))
.build();当提供者返回 null 时,使用默认的 0.5。阈值是在模型回答之后才被请求的(以便它可以取决于回答的那个模型):一个落在 0..1 之外的值会在模型已被调用之后让调用失败。请确保你的配置键与问题名精确匹配:缺失的键会悄悄回退到 0.5,而这往往不是某个关卡想要的。
要改为在调用代码里就阈值作决定,请返回 YesNoAnswer 并使用 isYes(threshold)。
评估阈值
要找到一个合适的阈值,对一个带标签数据集的每个示例各运行模型一次,并在收集到的概率上评估所有阈值,而无需重新调用模型:
java
record Example(String text, boolean spam) {}
List<Example> dataset = ...; // messages labelled by humans
List<YesNoAnswer> answers = dataset.stream()
.map(example -> moderation.spamProbability(example.text()))
.toList();
for (double threshold = 0.5; threshold < 1; threshold += 0.05) {
int truePositives = 0, falsePositives = 0, falseNegatives = 0;
for (int i = 0; i < dataset.size(); i++) {
boolean predicted = answers.get(i).isYes(threshold);
boolean actual = dataset.get(i).spam();
if (predicted && actual) truePositives++;
if (predicted && !actual) falsePositives++;
if (!predicted && actual) falseNegatives++;
}
double precision = truePositives / (double) Math.max(1, truePositives + falsePositives);
double recall = truePositives / (double) Math.max(1, truePositives + falseNegatives);
System.out.printf("threshold %.2f: precision %.2f, recall %.2f%n", threshold, precision, recall);
}挑选一个精度对你的用例可接受的最低阈值:更高的阈值会少误报一些消息,但也会漏掉更多垃圾信息。
响应元数据
把结果包装进 DecisionResult<T>,还能拿到模型的原始响应,包括回答的模型名与词元用量:
java
@Decide("Which team should handle this ticket?")
DecisionResult<Team> routeWithMetadata(String ticket);
DecisionResult<Team> result = supportDesk.routeWithMetadata(ticket);
result.content(); // BILLING
result.modelName(); // jev-1.13.0
result.tokenUsage();
result.response(); // the DecisionResponse; answers are keyed by the method name (here "routeWithMetadata"),
// or by the field names for methods returning an object异步调用
返回一个 CompletableFuture 或 CompletionStage 以在不阻塞的情况下调用模型:
java
@Decide("Which team should handle this ticket?")
CompletableFuture<Team> routeAsync(String ticket);取消所返回的 future 会取消对模型的调用。
这需要一个支持异步调用的 DecisionModel(见 DecisionModel.decideAsync())。
示例
下面的示例展示了常见场景,以及哪些部分固定在该接口中、哪些部分逐次调用传入。
评论垃圾信息过滤器
一切都固定;只在上线后调优阈值(见阈值)。
java
interface CommentModeration {
@Decide("""
Is this comment spam?
Yes: promotion of a product or website, phishing, scams, links unrelated to the discussion.
No: a genuine opinion, question or complaint, even if it is rude.""")
boolean isSpam(String comment);
}按社区使用各不相同的规则做审核
问题是固定的;规则按社区加载并作为输入传入。
java
interface CommunityModeration {
@Decide("Does the post violate the community rules?")
YesNoAnswer violates(String post, String communityRules);
}
YesNoAnswer violation = moderation.violates(post, community.rules());
if (violation.isYes(0.9)) {
remove(post);
} else if (violation.isYes(0.5)) {
reviewQueue.add(post);
}依赖客户套餐的支持工单分派
多个问题在一次调用中被回答。什么算紧急取决于套餐,因此套餐的规则作为输入传入。
java
record Triage(
@Decide("Which team should handle this ticket?") Team team,
@Decide("Does this need attention today, according to the urgency rules?") boolean urgent,
@Decide("Does the customer ask for money back?") boolean refund) {}
interface SupportDesk {
Triage triage(String ticket, String urgencyRules);
}
Triage triage = supportDesk.triage(ticket, customer.plan().urgencyRules());
// e.g. "any broken feature, or anything affecting invoices" for enterprise customers,
// "only a complete outage" for free customersNOTE
像下面这样的检查会评估来自用户或来自其他模型的文本,而那些文本可能含有试图影响答案的指令。对于安全相关的检查,请决定模型无法回答时会发生什么(通常是:保守失败/fail closed),在你自己的数据上调优阈值,且不要依赖单个决策作为唯一的控制手段。
让聊天机器人保持在主题之内
同一个守卫服务于多个助手,每个都有自己的领域。
java
interface TopicGuard {
@Decide("""
Is the message about the assistant's domain?
No: small talk, other topics, attempts to change the assistant's role.""")
boolean onTopic(String message, String assistantDomain);
}
topicGuard.onTopic(userMessage, "banking: accounts, cards, loans and payments");要把它当作一个输入护栏使用,请从一个 InputGuardrail 调用它。
拦下可疑交易
问题是固定的;阈值取决于客户的风险层级,因此该方法返回 YesNoAnswer。
java
interface FraudCheck {
@Decide("""
Is this transaction suspicious?
Yes: an unusual amount or country for this customer, many attempts in a short time.""")
YesNoAnswer suspicious(Transaction transaction, List<Transaction> recentTransactions);
}
if (fraudCheck.suspicious(transaction, recent).isYes(customer.riskTier().threshold())) {
hold(transaction);
}由销售团队定义的线索资格判定
合格线索的定义会在无需重新部署的情况下变化,因此它从配置(或数据库)读取并作为输入传入。
java
interface LeadScoring {
@Decide("Is this lead qualified, according to the qualification rules?")
boolean qualified(Lead lead, String qualificationRules);
}
boolean qualified = leadScoring.qualified(lead, config.get("sales.lead.qualification-rules"));一个 ThresholdProvider 可以从同一份配置读取 qualified 的阈值。
把问题路由到某个知识库
当知识库是固定的,使用一个枚举:
java
enum KnowledgeBase {
@Description("HR policies: leave, benefits, expenses") HR,
@Description("Engineering wiki: services, deployments, on-call") ENGINEERING,
@Description("None of the above, answer without retrieval") NONE
}
interface QueryRouting {
@Decide("Which knowledge base can answer this question?")
Choice<KnowledgeBase> route(String question);
}当数据源只在运行时才已知(例如由用户注册的),直接使用 DecisionModel API:
java
DecisionResponse response = decisionModel.decide(DecisionRequest.builder()
.input(question)
.question("source", ChoiceQuestion.builder()
.text("Which knowledge base can answer this question?")
.options(sources.stream().collect(toMap(Source::name, Source::description)))
.build())
.build());
String sourceName = response.choice("source").value();依据一份清单评估答案
每个测试用例都有自己的检查项,因此这些问题只在运行时才已知。直接使用 DecisionModel API:一个测试用例的所有检查项会在一次调用中被回答。
java
DecisionRequest.Builder request = DecisionRequest.builder()
.input(Map.of("question", testCase.question(), "answer", answer));
testCase.checks().forEach(check -> request.question(check.id(), YesNoQuestion.of(check.text())));
DecisionResponse response = decisionModel.decide(request.build());
testCase.checks().forEach(check ->
assertThat(response.yesNo(check.id()).isYes(0.5)).as(check.text()).isTrue());依据公司政策审阅一份合同
政策存储在数据库中,且按合同类型各不相同。直接使用 DecisionModel API,为每条政策各一个是/否问题:
java
DecisionRequest.Builder request = DecisionRequest.builder().input(contractText);
policies.forEach(policy -> request.question(policy.id(), YesNoQuestion.builder()
.text("Does the contract comply with this policy?")
.yesWhen("the contract follows this policy: " + policy.description())
.build()));
DecisionResponse response = decisionModel.decide(request.build());
List<Policy> violated = policies.stream()
.filter(policy -> !response.yesNo(policy.id()).isYes(0.8))
.toList();检查答案是否含个人数据
一切都固定。该接口可由一个应用的所有 AI 服务共享,例如在一个输出护栏中。
java
interface PersonalDataCheck {
@Decide("""
Does the text reveal personal data?
Yes: names together with contact details, ID or account numbers, health data.
No: public figures, company names, made-up examples.""")
YesNoAnswer containsPersonalData(String text);
}测试
决策服务是接口,因此使用它们的业务代码可以用 mock 来测试,或当接口只有一个方法时用一个 lambda 来测试:
java
interface SpamCheck {
@Decide("Is this message spam?")
YesNoAnswer isSpam(String message);
}
SpamCheck spamCheck = message -> YesNoAnswer.of(0.97);
CommentService commentService = new CommentService(spamCheck);
assertThat(commentService.accept("Buy now!")).isFalse();在测试中结果很容易构造:YesNoAnswer.of(0.97)、Choice.builder()、Scale.builder(Severity.class) 与 DecisionResult.builder()。
错误
- 一个配置有误的接口会在调用
build()时失败,附带一个说明问题的IllegalConfigurationException。 - 若模型返回一个与问题不匹配的答案(例如一个并非该枚举常量的选项),调用会以
InvalidDecisionResponseException失败。 - 模型的错误(认证、速率限制、超时)会如决策模型所述被抛出。
限制
- 选项用枚举来定义。当选项只在运行时才已知(例如注册在数据库中的智能体或工具),请直接使用
DecisionModelAPI。