LangChain4j 1.22.0 已经可以直接调用 OpenAI Decisions API:langchain4j-open-ai 里新增 OpenAiDecisionModel,langchain4j-open-ai-official 里新增 OpenAiOfficialDecisionModel,两者都实现 1.21.0 引入的 DecisionModel 接口。已经在用 DecisionModel 或 Decision Services 的代码,换一个实现类就能把问题交给 gpt-6-luna,Decision Services 的方法参数里也可以直接放图片。两个类都标注了 @Experimental,OpenAI 的 Decisions API 也还在公测,接口后面还会变。

之前那篇介绍 Agent 决策模型的文章讲的是 Liquid d1、Strands Decider 这类可以自己部署的决策模型(d1 采用 LFM Open License v1.0,年收入 1000 万美元及以上的法人实体商用有许可门槛)。这篇只讲 Java 代码怎么通过 LangChain4j 调 OpenAI 托管的决策接口。

Decisions API 收什么、回什么

按 OpenAI 官方文档,Decisions API 是一个独立端点 POST /v1/decisions,请求体只有三个字段:model、input、questions。model 目前只能填 gpt-6-luna;input 是一段文本,或者包含 input_text 与 input_image 片段的 user 消息;questions 是一组带唯一 name 的问题,响应里的 answers 数组按这个 name 回传答案。

问题类型有三种。predicate 返回条件成立的概率 probability;choice 从你给出的选项里选一个,同时返回各选项概率和 confidence;score 按从低到高排列的档位打分,score 是各档位下标(从 0 开始)的概率加权平均,所以可能落在两个档位之间。某个问题被拒答时,该问题的答案类型是 refusal,其他问题照常返回。

图片只接受内联的 base64 data URL,HTTP/HTTPS 图片地址和 file_id 都不支持。价格按 2026 年 10 月 9 日官方文档的公测期标价,gpt-6-luna 在 /v1/decisions 上只收输入 token 的钱,每 100 万 token 0.10 美元,没有输出 token 和缓存读写费用,地区处理溢价和长上下文倍率另算。Java SDK 需要 openai-java 4.78.0 及以上。

两个实现类的依赖和构建参数

Maven Central 上 1.22.0 的坐标如下。官方 SDK 模块用的还是 beta 版本号 1.22.0-beta32,别写成 1.22.0:

<dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j-open-ai</artifactId>
    <version>1.22.0</version>
</dependency>

<!-- 或者,基于 OpenAI 官方 Java SDK -->
<dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j-open-ai-official</artifactId>
    <version>1.22.0-beta32</version>
</dependency>

用 langchain4j-bom 1.22.0 管理版本时可以省掉 <version>,BOM 会给 langchain4j-open-ai 配稳定版、给 langchain4j-open-ai-official 配 beta 版。

下面的 Java 代码都是带 import 的完整类,放在默认包里就能编译。

OpenAiDecisionModel 走 LangChain4j 自己的 HTTP 客户端,构建方式和同模块的其他 OpenAI 模型一致:

import dev.langchain4j.model.decision.DecisionModel;
import dev.langchain4j.model.openai.OpenAiDecisionModel;
import dev.langchain4j.model.openai.OpenAiDecisionModelName;

import java.time.Duration;

public class OpenAiDecisionModelSetup {

    static DecisionModel create() {
        return OpenAiDecisionModel.builder()
                .apiKey(System.getenv("OPENAI_API_KEY"))
                .modelName(OpenAiDecisionModelName.GPT_6_LUNA) // 也可以传字符串 "gpt-6-luna"
                .timeout(Duration.ofSeconds(10))
                .maxRetries(1)
                .build();
    }
}

它的 builder 提供 httpClientBuilder、baseUrl(默认 https://api.openai.com/v1)、apiKey、organizationId、projectId、modelName、timeout、maxRetries、logRequests、logResponses、logger、customHeaders(Map 或 Supplier<Map>)、customQueryParams 和 listeners。不设 timeout 时连接超时 15 秒、读超时 60 秒;maxRetries 默认 2,表示首次调用失败后再重试 2 次。OpenAiDecisionModelName 枚举目前只有 GPT_6_LUNA 一个值。

OpenAiOfficialDecisionModel 包装官方 SDK,同步调用走 client.decisions().create(...),异步调用走 client.async().decisions().create(...):

import dev.langchain4j.model.decision.DecisionModel;
import dev.langchain4j.model.openaiofficial.OpenAiOfficialDecisionModel;

public class OpenAiOfficialDecisionModelSetup {

    static DecisionModel create() {
        return OpenAiOfficialDecisionModel.builder()
                .apiKey(System.getenv("OPENAI_API_KEY"))
                .modelName("gpt-6-luna")
                .build();
    }
}

它的 builder 是 baseUrl、apiKey、organizationId、openAIClient、modelName(只收 String)、timeout(默认 60 秒)、maxRetries(由 SDK 自己重试,默认 3)、proxy、customHeaders 和 listeners。传入预先配置好的 openAIClient 后,其余客户端参数都会被忽略。SDK 抛出的异常会按 HTTP 状态码映射成 LangChain4j 的异常类型。PR 里说明这个类目前只面向 OpenAI 本身,Microsoft Foundry、GitHub Models 和 Azure 的配置项暂时没有放进来。

两个类都没有默认模型名。builder 和请求参数里都没设 modelName 时,调用会抛 IllegalArgumentException,提示在 modelName(...) 或 DecisionRequestParameters.builder().modelName(...) 里设置。

一次请求问三个问题

下面的请求把同一张工单同时交给三类问题。LangChain4j 的 YesNoQuestion、ChoiceQuestion、ScaleQuestion 分别映射成 OpenAI 的 predicate、choice、score:

import dev.langchain4j.model.decision.DecisionModel;
import dev.langchain4j.model.decision.request.ChoiceQuestion;
import dev.langchain4j.model.decision.request.DecisionRequest;
import dev.langchain4j.model.decision.request.ScaleQuestion;
import dev.langchain4j.model.decision.request.YesNoQuestion;
import dev.langchain4j.model.decision.response.ChoiceAnswer;
import dev.langchain4j.model.decision.response.DecisionResponse;
import dev.langchain4j.model.decision.response.ScaleAnswer;

import java.util.List;

public class TicketTriage {

    static void triage(DecisionModel decisionModel, String ticket) {
        DecisionRequest request = DecisionRequest.builder()
                .input(ticket)
                .question("team", ChoiceQuestion.builder()
                        .text("Which team should handle this ticket?")
                        .option("billing", "Payments, payouts, invoices, refunds")
                        .option("support", "Problems using the product")
                        .option("sales", "Pricing, upgrades, new accounts")
                        .build())
                .question("urgent", YesNoQuestion.builder()
                        .text("Does this need attention today?")
                        .yesWhen("money is blocked or the customer cannot work")
                        .build())
                .question("severity", ScaleQuestion.builder()
                        .text("How severe is the incident?")
                        .level("Minor", "No customer is affected")
                        .level("Major", "Some customers are affected")
                        .level("Critical", "No customer can use the product")
                        .build())
                .build();

        DecisionResponse response = decisionModel.decide(request);

        ChoiceAnswer team = response.choice("team");
        String teamName = team.value();      // 选中的选项名,例如 "billing"
        double margin = team.margin();       // 最可能的两个选项之间的概率差

        if (response.isRefused("urgent")) {
            escalateToHuman(ticket);
        } else if (response.yesNo("urgent").isYes(0.8)) {
            notifyOnCallTeam(ticket);
        }

        ScaleAnswer severity = response.scale("severity");
        double mean = severity.mean();                    // 0 到 2 之间的加权平均
        List<Double> perLevel = severity.probabilities(); // 按档位下标排列

        System.out.printf("team=%s margin=%.2f severity=%.2f %s%n", teamName, margin, mean, perLevel);
    }

    static void escalateToHuman(String ticket) {
        // 业务代码:转人工
    }

    static void notifyOnCallTeam(String ticket) {
        // 业务代码:通知值班
    }
}

映射细节都在 DecisionMapper 里。yesWhen / noWhen 会拼接到 predicate 的 instructions 后面;ChoiceQuestion 的选项名作为 value 发送,描述放进 description;ScaleQuestion 的每个档位作为 label 发送。ScaleQuestion.Builder.level(label, description) 和 ScaleQuestion.levelDescriptions() 是 1.22.0 新加的,第二个参数会成为 OpenAI 档位的 description,对应官方文档里 rubric 的写法。

decideAsync(request) 返回 CompletableFuture<DecisionResponse>,两个实现都支持真正的非阻塞调用。响应里的概率、置信度和分数如果只是在 1e-6 以内越界,会被当作舍入误差截回区间;超出更多、出现未知答案类型或字段缺失时,抛 InvalidDecisionResponseException。

图片输入

1.22.0 给 DecisionRequest.Builder 加了 input(List<? extends Content>),可以把文字和图片放在一起。下面的方法读入一张本地照片,组装成请求:

import dev.langchain4j.data.message.ImageContent;
import dev.langchain4j.data.message.TextContent;
import dev.langchain4j.model.decision.request.DecisionRequest;
import dev.langchain4j.model.decision.request.YesNoQuestion;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Base64;
import java.util.List;

public class ImageInput {

    static DecisionRequest damagedRequest(Path photo) throws IOException {
        String base64Image = Base64.getEncoder().encodeToString(Files.readAllBytes(photo));

        return DecisionRequest.builder()
                .input(List.of(
                        TextContent.from("The customer says the package arrived like this."),
                        ImageContent.from(base64Image, "image/jpeg", ImageContent.DetailLevel.AUTO)))
                .question("damaged", YesNoQuestion.of("Is the item visibly damaged?"))
                .build();
    }
}

Map 输入的值现在也可以是 Content(只限 map 的顶层)。OpenAI 的两个实现会把 map 编成一条 user 消息:普通值变成带名字的文本片段,例如 comment: "...";图片前面先放一个只有名字的文本片段,例如 photo:,再跟图片本身。PR #6613 说明,不带图片的 map 也改成了这种逐项标注的格式,以前是整体序列化成一段 JSON 文本。两个类的 javadoc 还写着 map 以 JSON 文本发送,这和 1.22.0 里 DecisionMapper 的实际代码对不上,以代码为准。

OpenAI 实现会在发请求之前拦下两类图片:通过 http/https URL 引用的图片,以及 MEDIUM、ULTRA_HIGH 两个细节级别,都抛 UnsupportedFeatureException。支持的细节级别是 LOW、HIGH、AUTO。ImageContent.from(base64, mimeType) 不带第三个参数时默认是 LOW,所以文中两个图片示例都显式传了 AUTO,让服务端自己选细节级别。ImageContent.from(Path, mimeType) 会先把文件读成 base64,也能直接使用。不支持图片的 TypeSafeDecisionModel 会把文字内容拼起来,遇到图片直接拒绝。

Decision Services:方法参数收图片,Scale 枚举的 @Description 变成档位描述

Decision Services 是 1.21.0 加入 langchain4j 模块的接口式用法:在接口方法上写 @Decide,用 DecisionServices.create(...) 生成实现。1.22.0 的 PR #6613 让 Image 或 Content 类型的参数(包括 ImageContent、它们的集合与数组)按内容发给模型:

import dev.langchain4j.data.message.ImageContent;
import dev.langchain4j.model.decision.DecisionModel;
import dev.langchain4j.service.decision.Decide;
import dev.langchain4j.service.decision.DecisionServices;

interface DamageInspector {

    @Decide("Is the item visibly damaged?")
    boolean isDamaged(ImageContent photo, String comment);
}

public class DamageCheck {

    static boolean check(DecisionModel decisionModel, String base64Image) {
        DamageInspector inspector = DecisionServices.create(DamageInspector.class, decisionModel);
        return inspector.isDamaged(
                ImageContent.from(base64Image, "image/jpeg", ImageContent.DetailLevel.AUTO),
                "Arrived like this");
    }
}

参数会以参数名为键发送,所以要用 -parameters 编译,或者给每个参数加 @V。传 Image 时框架会转成细节级别为 AUTO 的 ImageContent,想换级别就直接传 ImageContent。没有内容型参数的方法,发出去的请求和以前一样。

另一处改动在 Scale<E> 返回类型上。枚举常量的名字现在作为档位标签,常量上的 @Description(dev.langchain4j.model.output.structured.Description)作为档位描述,内部调用的就是 ScaleQuestion.Builder.level(label, description)。以前是拼成 "NAME: description" 一段文字。结果仍按档位下标映射回枚举,调用方代码不用改,变化的只是发给模型的请求:

import dev.langchain4j.model.decision.DecisionModel;
import dev.langchain4j.model.output.structured.Description;
import dev.langchain4j.service.decision.Decide;
import dev.langchain4j.service.decision.DecisionServices;
import dev.langchain4j.service.decision.Scale;

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);
}

public class IncidentPaging {

    static void handle(DecisionModel decisionModel, String report) {
        IncidentTriage incidentTriage = DecisionServices.create(IncidentTriage.class, decisionModel);

        Scale<Severity> severity = incidentTriage.severity(report);
        if (severity.probabilityAtLeast(Severity.HIGH) > 0.5) {
            pageOnCall(report);
        }
    }

    static void pageOnCall(String report) {
        // 业务代码:呼叫值班工程师
    }
}

拒答要当成单独的分支处理

1.22.0 新增了 RefusalAnswer 和 DecisionResponse.isRefused(name)。对被拒答的问题调用 yesNo、choice、scale 这些类型化读取方法会抛 ContentFilteredException,所以做安全类检查时,先判断 isRefused 再读概率。框架内置组件各有各的处理:DecisionModelInputGuardrail、DecisionModelOutputGuardrail 把拒答视为检查不通过;DecisionModelChatModelRouter、DecisionModelQueryRouter、DecisionModelFilteringToolProvider 按各自的 FallbackStrategy 处理;DecisionScoringModel 和 Decision Services 直接把 ContentFilteredException 抛给调用方。

Spring Boot 和 Quarkus

langchain4j-spring 的 1.22.0-beta32 版本已经加入 OpenAI 决策模型的自动配置。Spring Boot 4 用 langchain4j-open-ai-spring-boot4-starter 或 langchain4j-open-ai-official-spring-boot4-starter,Spring Boot 3 用去掉 4 的同名 starter。设置 API key 后会创建对应的 bean:

langchain4j.open-ai.decision-model.api-key=${OPENAI_API_KEY}
langchain4j.open-ai.decision-model.model-name=gpt-6-luna

# 官方 SDK starter 用这一组
# langchain4j.open-ai-official.decision-model.api-key=${OPENAI_API_KEY}
# langchain4j.open-ai-official.decision-model.model-name=gpt-6-luna

容器里的 DecisionModelListener bean 会自动注册到决策模型上。langchain4j-open-ai 的 starter 默认用 Spring 的 RestClient,它不支持非阻塞调用,decideAsync(...) 会抛 AsyncNotSupportedException;需要异步时,提供一个名为 openAiDecisionModelHttpClientBuilder 的 HttpClientBuilder bean,例如 JdkHttpClient.builder()。

Quarkus 用户要留意 PR #6611 的说明:自定义 OpenAiClient 需要覆盖新增的 OpenAiClient.decision(...) 方法,否则 OpenAiDecisionModel 第一次调用就会失败。

同一版本里的另外两项

langchain4j-bedrock 新增 BedrockBatchChatModel,基于 AWS Bedrock 批量推理,使用 Converse 调用类型:submit 把请求写成一个 JSONL 文件上传到 S3 并创建 CreateModelInvocationJob,retrieve、cancel、list 分别对应查询、停止和列出任务。构建时需要 modelId、roleArn、outputS3Uri,jobTimeout 只接受 24 到 168 小时。Bedrock 批量推理不支持工具调用和结构化输出,这两类请求会被 UnsupportedFeatureException 拒绝。software.amazon.awssdk:bedrock 和 software.amazon.awssdk:s3 是可选依赖,用这个类时要自己加上。

langchain4j-anthropic 给 AnthropicChatModel、AnthropicStreamingChatModel、AnthropicBatchChatModel 加了 cacheAutomatically 和 cacheTtl 两个选项,默认都关闭。cacheAutomatically(true) 会在请求顶层发送 cache_control,由 Anthropic 把缓存断点放在最后一个块上并随对话前移,适合历史不断增长的多轮对话和工具调用循环。cacheTtl 取 "5m"(默认)或 "1h",常量是 AnthropicChatRequestParameters.CACHE_TTL_5M / CACHE_TTL_1H,对所有缓存断点统一生效。按 PR 的说法,它是叠加在 cacheSystemMessages、cacheTools 上面用的。如果对话开头每次都变,每次请求都付了缓存写入的钱却读不回缓存,开自动缓存反而更贵。

资料来源

文中代码在 LangChain4j 1.22.0 / open-ai-official 1.22.0-beta32、JDK 21.0.12.1、Maven 3.9.9 下编译通过(开启了 -parameters,Decision Services 的两段代码依赖它),没有实际调用 API,行为描述依据官方文档和源码。

— 感谢阅读 —

一起交流

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