主题
使用 Jackson 3
LangChain4j 在很多地方读写 JSON:它与 LLM 提供者交换的请求与响应、AI 服务解析的结构化输出、你持久化的聊天记忆等等。默认情况下,它使用 Jackson 2 来做这件事。关于 LangChain4j 如何使用 JSON、以及如何接入你自己的 mapper,参见 JSON。
如果你的应用在用 Jackson 3,你可以让 LangChain4j 改用它。
开启它
添加一个依赖:
xml
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-jackson3</artifactId>
<version>1.22.0-beta32</version>
</dependency>这就是全部了,对包括 langchain4j-agentic 在内的每个模块都如此。LangChain4j 通过 ServiceLoader 找到该模块,并把它的 JSON 路由给 Jackson 3。无需配置,也无需调用任何 API;如果你移除该依赖,一切又回到 Jackson 2。
该支持拆分为两个 artifact,而上面的 langchain4j-jackson3 会同时引入二者,所以除非你有理由不这么做,否则就加它:
| Artifact | 覆盖范围 | 依赖于 |
|---|---|---|
langchain4j-core-jackson3 | langchain4j-core 中的一切:提供者的请求与响应、结构化输出、聊天记忆序列化、智能体状态 | langchain4j-core |
langchain4j-jackson3 | 上述内容,外加 InMemoryEmbeddingStore 持久化,它位于 langchain4j 模块 | langchain4j-core-jackson3 与 langchain4j |
仅当你不借助 langchain4j 模块、而是直接使用某个框架集成来构建模型(例如某个直接构建模型的框架集成)时,才单独添加 langchain4j-core-jackson3。将它与 langchain4j-jackson3 并加是不必要的,但也无害。
保持不变的部分
切换 JSON 库很容易在不经意间改变行为,所以本模块竭力避免这一点。Jackson 3 改了好几个默认值,而几乎每一个会改变 LangChain4j 读写内容的默认值都被设回了 Jackson 2 的行为。少数有意保留的差异列在有意为之的不同之下。
| 设置 | Jackson 3 默认 | 本模块的做法 |
|---|---|---|
ALLOW_FINAL_FIELDS_AS_MUTATORS | 禁用 | 启用 |
USE_GETTERS_AS_SETTERS | 禁用 | 启用 |
SORT_PROPERTIES_ALPHABETICALLY | 启用 | 禁用 |
FAIL_ON_TRAILING_TOKENS | 启用 | 禁用 |
FAIL_ON_NULL_FOR_PRIMITIVES | 启用 | 禁用 |
READ_ENUMS_USING_TO_STRING | 启用 | 禁用:枚举按 name() 读取 |
WRITE_ENUMS_USING_TO_STRING | 启用 | 禁用:枚举按 name() 写入 |
DETECT_PARAMETER_NAMES | 启用 | 禁用,结构化输出与工具参数除外(见下文) |
FAIL_ON_UNKNOWN_PROPERTIES | 禁用 | 启用:在结构化输出、工具参数、聊天记忆、智能体状态与存储的嵌入存储中,目标类型没有的字段会导致失败;提供者响应仍会忽略未知字段 |
FAIL_ON_EMPTY_BEANS | 禁用 | 启用:一个无可写入内容的对象——例如字段为 private 且没有 getter 的对象——会失败,而不是被发送为 {} |
第一条最为要紧:若没有它,一个 final 集合字段会被留空而非填充,且不会有任何提示。
DETECT_PARAMETER_NAMES 只在你的代码用 -parameters 编译时才有影响,而 Spring Boot 与 Quarkus 项目通常都是如此。对于结构化输出与工具参数,会使用构造函数参数名,正如当 classpath 上有 jackson-module-parameter-names 时 Jackson 2 编解码器所做的那样(在 Spring Boot 与 Quarkus 中它是在的):一个只有带参构造函数的类,例如一个 Lombok @AllArgsConstructor 类,可以被读取。聊天记忆、智能体状态与其他编解码器不使用参数名,与 Jackson 2 一致。
无论编译器设置如何,要明确选择一个构造函数,请为它加注解:
- 用
@JsonCreator(mode = JsonCreator.Mode.DELEGATING),以通过一个单参构造函数读取一个像"abc"这样的普通值; - 用
@JsonCreator,并为每个参数加上@JsonProperty("name"),以通过一个带参构造函数读取一个对象。
两者在任一 Jackson 版本下都有效。
有意为之的不同
出于"恢复它们得不偿失"的考虑,有少数差异被有意保留:
- 日期与时间以 ISO-8601 字符串写出。
java.util.Date与Calendar会变成"1970-01-01T00:00:00.000Z"而非纪元毫秒。当 classpath 上有jackson-datatype-jsr310(如 Spring Boot 与 Quarkus 应用)时,Jackson 2 也会把Instant、OffsetDateTime与Duration写成数字;本模块把它们写成 ISO-8601 字符串,而 LLM 读它们要可靠得多。对Date、Instant与Duration,数字仍能被读取。 java.time.Month被写成从 1 开始的数字(一月为1)。在有jackson-datatype-jsr310时,Jackson 2 写"JANUARY"并把数字读成从 0 开始的位置,所以1是FEBRUARY。两个版本都能读取名称。- 对枚举,
""被读作null,与缺失值相同。这也适用于必填枚举:像缺失字段一样,""给出null而非报错。Jackson 2 则会失败,但提供者会发送它——一个 OpenAI 兼容服务器对工具调用返回"type": ""正是暴露此问题的场景——而 LLM 对一个可选枚举可能回答""。对象、映射或列表的""仍会失败,与 Jackson 2 一致。 - 一个
private单参构造函数不会被用来读取普通值。 Jackson 2 会用它;在 Jackson 3 下,请用@JsonCreator(mode = JsonCreator.Mode.DELEGATING)注解它。 - 对于结构化输出与工具参数,当一个类同时具有无参构造函数与带参构造函数,且用
-parameters编译时,会通过带参构造函数来创建它。 Jackson 2 会用无参构造函数然后设置字段。只有当那个构造函数做的不只是赋值字段时,这才有影响。 - 一个像
xValue且有 gettergetXValue()的字段会被写成"xValue"。 Jackson 2 从 getter 派生出"xvalue":对结构化输出、工具结果与智能体状态,它同时写"xValue"与"xvalue",而对提供者请求只写"xvalue"。由 Jackson 2 写出的、带有这样一个重复键的智能体状态,在本模块下读取会失败。
失败会带上一个 LangChain4j 类型。 默认情况下,一个 JSON 失败会表现为一个包装了 Jackson 2 自身异常的 RuntimeException——这意味着要对它作出反应的代码必须了解 Jackson 2。有了本模块,读写 JSON 失败时会改抛 JsonReadException 或 JsonWriteException,二者都是 LangChain4jException,并把该库的异常保留为 cause。
这是一次有意为之的推进而非不一致:带类型的异常是 LangChain4j 下一个大版本的方向,而 Jackson 2 的编解码器在此之前保持原样,好让既有代码继续工作。如果你通过 Jackson 类型来捕获 JSON 失败,那正是你添加本模块时唯一需要重新审视的地方:
java
- } catch (JsonParseException e) {
+ } catch (JsonReadException e) {捕获 RuntimeException 在两种情况下都有效。
有两处会不经过 catch 块就到达应用代码,因此在切换之前值得检查:
一个工具参数错误处理器。 当模型产出的工具参数不是有效 JSON 时,LangChain4j 会把失败交给你通过 AiServices.toolArgumentsErrorHandler(...) 注册的处理器。在 Jackson 2 下,那个失败是 Jackson 自身的 JsonParseException;在本模块下它是 JsonReadException。一个依据类型分支的处理器会不再匹配,而且没有任何警告,因为处理器仍被调用、只是走了另一个分支:
java
AiServices.builder(Assistant.class)
.toolArgumentsErrorHandler((error, context) -> {
- if (error instanceof JsonParseException) {
+ if (error instanceof JsonReadException) {
return ToolErrorHandlerResult.text("Please return valid JSON.");
}
throw error;
})如果你想让一个处理器在两种情况下都有效,请对消息、或对 RuntimeException 进行匹配。
OutputParsingException 的 cause。 当结构化输出无法解析时,无论哪种情况 LangChain4j 都抛 OutputParsingException——这个类型不变。变的是它底下的异常,从 Jackson 2 的 JsonProcessingException 变为 Jackson 3 的 StreamReadException。检查 getRootCause() 的代码需要同样对待。
你已经存储的数据仍可读取。 由 Jackson 2 写出的聊天记忆与 InMemoryEmbeddingStore 文件,能被 Jackson 3 正确读取,而 Jackson 3 写出的内容与 Jackson 2 本会写出的逐字节相同。这有测试覆盖,因此会一直保持。
移除 Jackson 2
添加本模块本身并不会移除 Jackson 2——它仍作为一个普通的传递依赖到来,二者可以无限期共存。如果你想彻底去掉它,通常能做到,因为一旦本模块存在,LangChain4j 就不再加载任何 Jackson 2 类。
它能否真正被移除,取决于你使用了哪些模块:
| 模块 | 能排除 Jackson 2 吗? |
|---|---|
langchain4j-core、langchain4j | 能——它们自带 Jackson 2 编解码器,但那些只是回退,本模块在 classpath 上时它们永不被加载 |
| 提供者模块——OpenAI、Anthropic、Mistral、Ollama、Gemini 及其他 | 能 |
| 嵌入存储、网页搜索、代码执行 | 能 |
langchain4j-agentic | 能 |
langchain4j-guardrails | 不能——JsonExtractorOutputGuardrail 有意用一个普通的 Jackson 2 ObjectMapper 来解析,好让护栏保持原有的严格 |
langchain4j-mcp | 不能——Jackson 的 JsonNode 出现在其公共 API 中,因此该模块在每条路径上都会加载 Jackson 2 |
langchain4j-vespa | 不能——它的 HTTP 客户端使用 Retrofit 自带的 Jackson 2 转换器 |
| 任何使用厂商 SDK 的——AWS、Azure、Google | 不能——该 SDK 本身就依赖 Jackson 2 |
Maven 的排除只作用于它所写在的那个依赖上,所以它们要加在你声明的每一个 LangChain4j 依赖上,而不只是第一个:
xml
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j</artifactId>
<version>1.22.0</version>
<exclusions>
<exclusion>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</exclusion>
<exclusion>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- and the same on langchain4j-open-ai, and on any other LangChain4j module you use -->不要排除 com.fasterxml.jackson.core:jackson-annotations。它保留了 2.x 的坐标,但它是共享的:Jackson 3 依赖它并读取那些注解。
这套配置不只是"被认为可行",它在每个 pull request 上都会被构建并运行:integration-tests/integration-tests-jackson3 是一个完全按这种方式组装的应用——core、主模块、一个提供者、agents 与本模块,并从整个依赖图中排除了 Jackson 2——其测试覆盖 AI 服务结构化输出、工具调用、聊天记忆持久化、嵌入存储持久化与智能体状态持久化。如果其中任何一项开始需要 Jackson 2,那些测试就会因找不到该类而失败。
如果你使用 MCP、Vespa 或某个云 SDK,Jackson 2 会留下。这一 opt-in 仍会为 LangChain4j 自身序列化的所有内容提供单一的 Jackson 3 代码路径。
如果你编写一个提供者或一个 DTO
若你要贡献一个传输类型(wire type),有两件事要知道。
命名应放在编解码器上,而不是类型上。 @JsonNaming 位于 Jackson 2 的 databind 包,因此 Jackson 3 完全看不到它——字段名会悄无声息地输出为 camelCase。请改在编解码器上设置命名:
java
ProviderJson.codec(ProviderJsonSpec.builder()
.propertyNaming(ProviderJsonSpec.PropertyNaming.SNAKE_CASE)
.build());如果单个字段需要不同的名字,@JsonProperty("...") 在两个版本下都有效,因为它来自 jackson-annotations——两个版本共享的那个 artifact。
一个基于 builder 的 DTO 需要在其 builder 上带 @JsonCreator 与字段可见性。 @JsonDeserialize(builder = ...) 也是一个 databind 注解,所以在 Jackson 3 下,该 DTO 改由通过那个接收 builder 的构造函数上的 @JsonCreator 来构建。Jackson 3 通过写 builder 的 private 字段来填充它,因此该 builder 还需要 @JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)。没有它,响应会无错误地解析,但字段会保持 null。三者都必须具备。
一个 builder 方法是否运行,取决于它身上的注解。 这是需要内化的部分,因为它是静默的,且规则不是你直觉的那样。Jackson 2 通过调用 builder 的 setter 再调用 build() 来填充它。Jackson 3 只有在 setter 带有属性标记时才调用它,否则直接写字段,且从不调用 build():
| 在 builder 的 setter 上 | Jackson 2 | Jackson 3 |
|---|---|---|
@JsonSetter("odd-name")、@JsonProperty、裸 @JsonSetter | 调用 | 调用 |
单独的 @JsonAlias | 调用 | 不调用——该别名被忽略 |
| 无注解 | 调用 | 不调用——字段被直接写入 |
build() | 调用 | 从不调用 |
所以两个编解码器会在同一个类型内逐个属性地产生分歧,这正是此事值得定为一条规则、而非靠警觉的原因:
把类型所保证的东西放在两条路径都会经过的地方。 一个默认值应放在 builder 的字段上,而不是 build() 里。一次防御性拷贝或任何归一化应放在构造函数里,而不是某个 setter 里。一个别名需要同时加在 builder 的字段与其 setter 上的 @JsonAlias。
其中每一项都在此出过错:一个在 build() 里设默认值的类型丢掉了每一个 mistral-ai 工具调用;setter 上的 @JsonAlias 丢掉了来自 vLLM、OpenRouter 与 Groq 的 reasoning 内容;而一个未加注解的 setter 里的 unmodifiableList(...) 导致同一个响应在一个编解码器下返回可变、在另一个下不可变。它们都没有抛异常。
支持该 opt-in 的模块会声明一个 jackson3 Maven profile,把 langchain4j-jackson3 放到该模块的测试 classpath 上,从而让其现有测试针对 Jackson 3 运行。CI 在每个 pull request 上运行它们全部,你也可以用同样的方式运行单个模块:
bash
mvn test -Pjackson3 -pl langchain4j-your-module在相信结果之前,请检查该模块的 pom.xml 确实声明了那个 profile:Maven 会忽略所选模块未声明的 profile,所以上面那条命令会在把一切都跑在 Jackson 2 上的情况下报告成功。添加该 profile 是迁移一个模块的一部分。
该 profile 把 langchain4j-core-jackson3 放到测试 classpath 上,而不是 langchain4j-jackson3,因为后者依赖 langchain4j 模块,会对那些本身构建在该模块之上的模块造成依赖图成环。InMemoryEmbeddingStore 持久化——即需要完整 artifact 的那部分——由 langchain4j-jackson3 自己的测试与 integration-tests/integration-tests-jackson3 覆盖。
langchain4j-open-ai 还带有一个 OpenAiBuilderCreatorParityTest,它把每个通过其 builder 构建的、基于 builder 的 DTO 与同一个 DTO 从 {} 解析出的结果作比较。这正是上文缺失的 build() 调用所造成的差异,因此该测试能一次性捕获整个 OpenAI 传输模型中的此类问题,而不是一次一个字段。同一个测试在某个基于 builder 的 DTO 缺少 @JsonCreator、或其 builder 缺少 @JsonAutoDetect 时也会失败。
如果你接入自己的 JSON
要使用 Jackson 3,你并不需要这一节——添加依赖就够了。它是为那些向 LangChain4j 提供自己配置好的 JSON mapper、而非让 LangChain4j 自行挑选的框架准备的,这正是 langchain4j-jackson3 自己所做的。
LangChain4j 没有单一的 JSON 入口。它在下面每个位置都向 ServiceLoader 询问一个编解码器,以便每个都能被分别应答:
| Service 接口 | 决定 LangChain4j 如何读写 |
|---|---|
dev.langchain4j.spi.json.JsonCodecFactory | 通用 JSON——AI 服务的结构化输出、模型的工具参数 |
dev.langchain4j.spi.json.ProviderJsonCodecFactory | 与 LLM 提供者交换的请求与响应 |
dev.langchain4j.spi.json.StateJsonCodecFactory | 类型事先未知、因而携带类型名的状态——智能体状态 |
dev.langchain4j.spi.data.message.ChatMessageJsonCodecFactory | 你持久化的聊天记忆 |
dev.langchain4j.spi.agent.tool.ToolSpecificationJsonCodecFactory | ToolSpecification.toJson() 与 ToolSpecification.fromJson(String) |
dev.langchain4j.spi.prompt.structured.StructuredPromptFactory | @StructuredPrompt 模板 |
dev.langchain4j.spi.store.embedding.inmemory.InMemoryEmbeddingStoreJsonCodecFactory | InMemoryEmbeddingStore.serializeToJson() |
最后一个位于 langchain4j;其余位于 langchain4j-core。它们全都标注了 @Internal,在这里意味着它们是面向集成而非应用的,且可能在次要版本之间变化。
框架自身的实现胜出。 如果已有东西提供了其中某一个——Quarkus 提供了四个——添加本模块并不会把它夺走。Jackson 3 的工厂声明的优先级低于其他一切,因此它们只作用于其他任何东西都未认领的那些 service。这一切静默发生:不会记录警告,因为优先级决定了使用哪个实现。这意味着在这样的框架上,该 opt-in 在设计上是部分的:提供者流量与智能体状态迁到 Jackson 3,而框架自身认领的那些 service 仍留在它自己的编解码器上。如果你想让整个应用都用 Jackson 3,请移除框架的这些注册。
要么全部实现,要么清楚你略过了哪些。 每一个都被独立解析,而某个没有注册实现的就回退到 Jackson 2。只应答其中一部分而不应答其余的并不是错误,也不会产生警告——它产生的是这样一个应用:比如聊天记忆由你的 mapper 写、而智能体状态由另一个库写。如果你有意略过某一个,回退就是你所得到的。
如果你已经与较旧版本集成,其中有两个值得再看一眼:ProviderJsonCodecFactory 与 StateJsonCodecFactory 是新增的,所以一个不了解它们的现有集成会继续工作,却在悄悄地为提供者流量与智能体状态使用 Jackson 2。