LangChain4j 结构化输出:让模型吐出 Java 对象,JSON 不再手写解析
作者:鱼宵 | LangChain4j实战精通营 · 第 3 篇
前两课你拿到的回答都是 chatModel.chat(...) 返回的一段 String。聊天场景这没问题------用户看一段话就够了。但真实业务不是这么玩的:让你从一段简历自我介绍里抽出姓名、技能、年限,你要的是一个 Resume 对象好塞进数据库、传给下游接口,不是一段小作文。
我第一回干这事,是跟模型说"请输出 JSON",然后拿 Gson 自己 parse。结果模型高兴起来拿三反引号代码块把答案一包,Gson 直接炸给我看。这一课就把这两条路放在一起实测:一条是我手写解析的"原始人路线",一条是 AiServices 定义个接口就直接拿回 Java 对象的"优雅人路线" 。代码全在仓库 lesson-03/,clone 下来跑一遍,你会看懂为什么生产里没人愿意手写第二条。
一、核心原理:自由文本怎么变成 Java 对象
一句话:模型只会吐文本,业务要的是对象,中间必须有一道"解析"。这道解析你可以手写,也可以让框架帮你写。
1. 痛点:实习生写字潦草
你让实习生(模型)整理一份简历,他给你交回一段小作文"我叫王小明,干了五年 Java......"------你要的不是作文,是一张填好的表格。你得自己从作文里把名字、年限抠出来。这就是"手写解析"的脏活:模型输出是自由文本,字段格式还不保证。
2. 三道保险,各防什么
生产里让模型吐 JSON,一般叠三道保险:
| 保险 | 在哪设 | 防什么 |
|---|---|---|
temperature(0.0) |
builder | 模型"自由发挥"写出不合规 JSON------抽取要确定事实,不要文采 |
responseFormat(ResponseFormat.JSON) |
builder | 服务端在解码层强制吐合法 JSON,不合法就重采样 |
| 提示词写死字段说明 | prompt | 告诉模型有哪些字段、什么类型(name/skills/years) |
注意一个坑:DeepSeek 开 JSON 模式时,提示词里必须出现 "json" 这个词,否则接口直接报 400。本课两处提示词都写了"输出 JSON",就是这个原因。
还要清醒一点:JSON 模式只保证"语法合法",不保证"语义正确"------年限抽成 6 而不是 5,它管不了,那靠提示词和 few-shot 兜底。
3. AiServices:你只定义接口,框架派个临时工
最优雅的玩法是 AiServices------你只写一个 Java 接口,不写实现类,框架在运行时用**动态代理(JDK Proxy)**给你生成实现:
java
interface ResumeExtractor {
@SystemMessage("你是简历信息抽取器...只输出 JSON...")
Resume extract(@UserMessage("抽取:{{intro}}") @V("intro") String intro);
}
ResumeExtractor e = AiServices.builder(ResumeExtractor.class).chatModel(chatModel).build();
Resume r = e.extract("我叫王小明..."); // 直接拿对象!
打个比方:你(业务代码)只写一份"岗位描述"(接口 + 注解),外包公司(AiServices)派个临时工来干活------拼提示词、调模型、把 JSON 解析成 Resume,全是临时工的事。魔法在于返回类型是 Resume 不是 String :框架一看你要 POJO,就自动套用 PojoOutputParser 帮你解析。你一行 Gson 都不用写。
二、动手:跑通两条路线
环境沿用前两课:JDK 17、
DEEPSEEK_API_KEY已配好。
powershell
cd langchain4j-journey\lesson-03
$env:JAVA_HOME="C:\Program Files\Java\jdk-17"
mvn clean install -DskipTests
java -Dfile.encoding=UTF-8 -jar target\lesson-03-1.0.0.jar
想单跑某条路线:
powershell
java -Dfile.encoding=UTF-8 -jar target\lesson-03-1.0.0.jar manual # 只跑手写
java -Dfile.encoding=UTF-8 -jar target\lesson-03-1.0.0.jar aisservice # 只跑 AiServices
三、关键代码:四个文件,两条路线对照
本课模型配置 LlmConfig 有两个和聊天课不一样的地方,先贴它:
java
package com.langchain4j.lesson03;
import dev.langchain4j.model.chat.request.ResponseFormat;
import dev.langchain4j.model.openai.OpenAiChatModel;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 模型配置:本课两个特殊点------temperature 压到 0.0,打开 JSON 模式。
*/
@Configuration
public class LlmConfig {
@Bean
public OpenAiChatModel openAiChatModel() {
return OpenAiChatModel.builder()
.baseUrl(System.getenv().getOrDefault("LLM_BASE_URL", "https://api.deepseek.com"))
.apiKey(System.getenv("DEEPSEEK_API_KEY"))
.modelName(System.getenv().getOrDefault("LLM_MODEL", "deepseek-chat"))
.maxTokens(400)
// 温度 0.0:信息抽取要确定性,不要发散(聊天课是 0.7,对照记忆)
.temperature(0.0)
// JSON 模式:服务端强制吐合法 JSON(DeepSeek 要求提示词里必须出现 "json")
.responseFormat(ResponseFormat.JSON)
.build();
}
}
目标 JavaBean Resume(被自动解析的 POJO 三条件:public 类 + 无参构造 + getter/setter,字段名和 JSON key 对上):
java
package com.langchain4j.lesson03;
import java.util.List;
/** 简历信息:我们希望模型最终吐出的形状。 */
public class Resume {
private String name; // 姓名
private List<String> skills; // 技能列表
private int years; // 工作年限
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public List<String> getSkills() { return skills; }
public void setSkills(List<String> skills) { this.skills = skills; }
public int getYears() { return years; }
public void setYears(int years) { this.years = years; }
@Override
public String toString() {
return "Resume{name='" + name + "', skills=" + skills + ", years=" + years + '}';
}
}
AiServices 接口------注意它没有实现类:
java
package com.langchain4j.lesson03;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
import dev.langchain4j.service.V;
/**
* 简历抽取器接口:你只定义"长什么样",框架运行时动态生成实现。
* 返回类型是 Resume(不是 String)------框架一看要 POJO,自动用 PojoOutputParser 解析。
*/
public interface ResumeExtractor {
@SystemMessage("你是简历信息抽取器。从用户给的自我介绍文本中抽取结构化信息,字段:"
+ "name(姓名,字符串)、skills(技能列表,字符串数组)、years(工作年限,整数,单位年)。"
+ "严格输出 JSON,不要输出任何多余解释。")
Resume extract(@UserMessage("从下面的自我介绍中抽取简历信息(JSON):\n{{intro}}") @V("intro") String intro);
}
主程序里两条路线并排跑,脏活全在左边:
java
package com.langchain4j.lesson03;
import com.google.gson.Gson;
import dev.langchain4j.service.AiServices;
import dev.langchain4j.model.chat.ChatModel;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 第 3 课主程序:同一任务两条路线对照。
* manual ------ 手写:chat() 拿回 String → 剥三反引号代码块壳 → Gson 解析
* aisservice ------ AiServices:定义接口,框架动态代理,直接拿 Resume
*/
@SpringBootApplication
public class ResumeExtractApplication implements CommandLineRunner {
private final ChatModel chatModel;
public ResumeExtractApplication(ChatModel chatModel) {
this.chatModel = chatModel;
}
public static void main(String[] args) {
SpringApplication.run(ResumeExtractApplication.class, args);
}
@Override
public void run(String... args) {
String intro = "我叫王小明,做Java后端开发5年,熟悉Spring Boot、MySQL、Redis。";
System.out.println("【待抽取的自我介绍】" + intro);
String mode = args.length > 0 ? args[0] : "all";
switch (mode) {
case "manual" -> manualDemo(intro);
case "aisservice" -> aiServicesDemo(intro);
default -> { manualDemo(intro); aiServicesDemo(intro); }
}
System.out.println("========== LangChain4j 第 3 课演示结束 ==========");
}
/** 路线一:手写解析。三步脏活全在这。 */
private void manualDemo(String intro) {
System.out.println("========== 玩法一:手写 JSON 解析(String 返回 + Gson) ==========");
// 提示词必须出现 "json"(DeepSeek JSON 模式的硬要求)
String prompt = "你是简历信息抽取器。请从下面自我介绍中抽取信息,输出 JSON,字段:"
+ "name(姓名,字符串)、skills(技能,字符串数组)、years(工作年限,整数)。只输出 JSON。\n"
+ "自我介绍:" + intro;
// ① 拿回自由文本 String
String raw = chatModel.chat(prompt);
System.out.println("【模型原始输出(String)】\n" + raw);
// ② 模型爱用三反引号代码块包答案,Gson 不认这个壳,先剥掉(脏活)
String json = stripCodeFence(raw);
// ③ 自己用 Gson 解析成对象
Resume resume = new Gson().fromJson(json, Resume.class);
System.out.println("【Gson 解析成对象】" + resume);
}
/** 剥掉模型可能输出的三反引号代码块包裹,只留中间 JSON 本体。 */
private String stripCodeFence(String raw) {
String fence = "`" + "``"; // 拼成三反引号(避免源码里直接出现三个反引号字面量)
String s = raw.trim();
if (s.startsWith(fence)) {
// 去掉开头的围栏(可能带 json 字样)
s = s.replaceFirst("^" + java.util.regex.Pattern.quote(fence) + "(json|JSON)?", "").trim();
// 去掉结尾的围栏
if (s.endsWith(fence)) {
s = s.substring(0, s.length() - fence.length()).trim();
}
}
return s;
}
/** 路线二:AiServices。不碰 String、不碰 Gson,直接拿对象。 */
private void aiServicesDemo(String intro) {
System.out.println("========== 玩法二:AiServices 自动解析(接口直接返回 Resume) ==========");
// build() 那一刻,框架用动态代理生成 ResumeExtractor 的实现类
ResumeExtractor extractor = AiServices.builder(ResumeExtractor.class)
.chatModel(chatModel)
.build();
// 像调普通 Java 方法一样调------背后拼提示词、调模型、解析 JSON 全包了
Resume resume = extractor.extract(intro);
System.out.println("【AiServices 直接返回的 Resume 对象】" + resume);
}
}
看出差别了吗:手写路线有三步(拿 String → 剥壳 → Gson),AiServices 路线就一行 extractor.extract(intro)。
四、实测输出:两条路线拿到一模一样的对象
以下为 2026-10-05 本机真实运行(DeepSeek 实测,已省略启动 banner)。
text
【待抽取的自我介绍】我叫王小明,做Java后端开发5年,熟悉Spring Boot、MySQL、Redis。
========== 玩法一:手写 JSON 解析(String 返回 + Gson) ==========
【模型原始输出(String)】
{"name": "王小明", "skills": ["Java", "Spring Boot", "MySQL", "Redis"], "years": 5}
【Gson 解析成对象】Resume{name='王小明', skills=[Java, Spring Boot, MySQL, Redis], years=5}
========== 玩法二:AiServices 自动解析(接口直接返回 Resume) ==========
【AiServices 直接返回的 Resume 对象】Resume{name='王小明', skills=[Java, Spring Boot, MySQL, Redis], years=5}
========== LangChain4j 第 3 课演示结束 ==========
三个观察:
- 两条路线结果一模一样 :
name=王小明 / skills=[Java, Spring Boot, MySQL, Redis] / years=5------自我介绍里"做Java后端开发5年"被正确抽成整数5,技能拆成了数组。 - 手写路线这次模型很配合 ,直接吐了裸 JSON,连三反引号代码块壳都没包,
stripCodeFence没派上用场。但生产里模型一"任性"包个壳,没这步 Gson 就炸------这就是为什么剥壳这步不能省。 - AiServices 路线:你没写一行 Gson、没剥一个代码块,框架直接把对象递到你手里。这就是"声明式"的舒服。
排查提示:报 400 多半是开了 JSON 模式但提示词里没写 "json";Gson 报
JsonSyntaxException是模型吐了散文或代码块,检查stripCodeFence;AiServices 返回的字段是 null,检查 POJO 是不是 public、有没有无参构造和 getter/setter。
五、挑战题:改参数,看看会怎样
- ⭐ 换个输入 :把
intro改成"我是李雷,3 年前端,主要做 React 和 Vue",重跑两条路线,看years是不是抽成 3、skills 是不是 React/Vue。答案就在ResumeExtractApplication那个intro字符串里。 - ⭐⭐ 故意制造格式漂移 :把
stripCodeFence那行注释掉,再临时把提示词里"只输出 JSON"删掉,看 Gson 怎么炸------体会手写路线有多脆弱。答案在manualDemo那三步里。 - ⭐⭐ 给 Resume 加个字段 :加一个
String city(城市),同时改ResumeExtractor的@SystemMessage和手写路线的提示词,重跑验证 AiServices 自动带上新字段。答案在Resume.java和那两处提示词。
六、生产环境进阶:三个加分项
1. 格式漂移必须兜底。 开了 JSON 模式也不代表 100% 不出错------生产要剥壳 + 解析失败重试/降级。模型偶尔包代码块、加废话,没有兜底线上就 500。
2. maxTokens 别太小。 JSON 被截断成半截,Gson 必炸。抽取复杂对象时把 maxTokens 调大(本课 400),别抠那点 token 钱。
3. JSON 模式只管语法不管语义。 字段抽错(年限 5 抽成 6)它管不了,靠提示词写清楚字段定义 + few-shot 给示例兜底。
七、面试回答模板
面试官:把 LLM 输出变成 Java 对象有哪几条路?
一句话:手写路线(提示词要 JSON + 自己 Gson 解析)可控但脏;JSON 模式 response_format 让服务端强制吐合法 JSON;AiServices 声明返回 POJO,框架动态代理自动解析,最优雅。展开:手写要自己剥三反引号代码块壳、自己处理格式漂移;AiServices 你只定义接口和注解,返回类型是 POJO 时框架自动套 PojoOutputParser。(指向本课第一节 + lesson-03 两条路线实测)
追问:AiServices 的动态代理原理?你只定义接口,
AiServices.builder(接口.class).chatModel(模型).build()那一刻用 JDK Proxy 生成实现类。调方法时它在背后:拼 @SystemMessage + @UserMessage 模板变量 → 调模型 → 看返回类型,是 POJO 就用 PojoOutputParser 反射映射成对象。你一行解析代码都不用写。(指向本课第 3 小节 +ResumeExtractor.java)
追问:模型输出不按 JSON 格式来怎么办?三道保险叠:temperature=0 压确定性、responseFormat(JSON) 服务端强制合法、提示词写死字段。再加兜底:剥代码块壳 + 解析失败重试/降级。注意 JSON 模式只保语法合法,不保语义正确,字段抽错靠提示词和 few-shot。(指向本课第 2 小节 + 第六节)
追问:DeepSeek 开 JSON 模式有什么特殊要求?提示词里必须出现 "json" 这个词,否则直接 400。本课两处提示词都写了"输出 JSON"。(指向本课
LlmConfig+ 两处提示词)
八、总结表
| 坑 | 现象 | 解法 |
|---|---|---|
| 模型输出带三反引号代码块壳 | Gson 直接解析炸 | 手写路线先 stripCodeFence 剥壳 |
| 开 JSON 模式但提示词没写 json | DeepSeek 直接 400 | 提示词里必须出现 "json" 字样 |
| POJO 不规范 | AiServices 字段给 null | public 类 + 无参构造 + getter/setter + 字段名和 JSON key 对上 |
| temperature 太高 | 模型自由发挥写出不合规 JSON | 抽取任务压到 0.0 |
| maxTokens 太小 | JSON 半截,Gson 必炸 | 抽取复杂对象调大(本课 400) |
| 旧教程签名对不上 | 编译报错 | 1.21.0 用 AiServices.builder(类).chatModel(模型).build() |
九、关于这个系列
本文是「Java 后端实战精通营 」系列第 3 篇,原则:实战驱动、由浅到深、面试向,每篇文章的结论都可以亲手验证。
👉 LangChain4j 实战精通营(10 课):https://gitee.com/j67mk2/langchain4j-journey
- 本文对应源码位置 :
lesson-03/(控制台工程,内含ResumeJavaBean +ResumeExtractorAiServices 接口 + 两条路线对照的ResumeExtractApplication)
系列文章一览(按发布顺序):
| 篇 | 主题 |
|---|---|
| 1 | LangChain4j 第一个对话程序:手写 LLM 调用链路,看清模型怎么"开口" |
| 2 | LangChain4j 提示词工程:模板 + few-shot + 温度,提示词从写死到复用 |
| 3 | LangChain4j 结构化输出:让模型吐出 Java 对象,JSON 不再手写解析 |
| 4 | LangChain4j 对话记忆:让模型记住聊过什么,三种方式实测对比 |
| 5 | LangChain4j 流式输出:打字机效果 + SSE,回答不再干等三秒 |
| 6 | LangChain4j 工具调用:Function Calling 让模型动手查数据,多工具连调实测 |
| 7 | LangChain4j 向量检索:文本怎么变成可搜索的坐标 |
| 8 | LangChain4j RAG 流水线:手写问答全流程,给 AI 开卷考试 |
| 9 | LangChain4j Agent:多步推理自主调用,放手让"实习生"自己安排工作 |
| 10 | LangChain4j 企业智能客服:RAG + 工具 + 记忆 + 流式 + 兜底,验收 5 项全过 |
下一篇预告:《LangChain4j 对话记忆:让模型记住聊过什么,三种方式实测对比》------到现在每次调用都是"一问一答、问完就忘",你上一句说过的话下一句它根本不记得。下一课用三个对照实验实测:带记忆、无记忆、窗口溢出,看清"记忆"到底是怎么回事。
跑完有任何报错,把终端输出发评论区,一起排查。
标签建议 :LangChain4j、结构化输出、AiServices
摘要建议(≤256 字):模型只会吐自由文本,业务要的却是能塞进数据库的 Java 对象。本文把两条路线放一起实测:手写路线要拿 String、剥代码块壳、Gson 解析,脏活一堆;AiServices 只定义一个接口,框架动态代理直接返回 Resume 对象。讲清 temperature=0 + responseFormat(JSON) 三道保险各防什么,附 3 道改参数挑战题与面试回答模板,源码在 lesson-03 可 clone 直接跑。