LangChain4j 结构化输出:让模型吐出 Java 对象,JSON 不再手写解析

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 课演示结束 ==========

三个观察:

  1. 两条路线结果一模一样 :name=王小明 / skills=[Java, Spring Boot, MySQL, Redis] / years=5------自我介绍里"做Java后端开发5年"被正确抽成整数 5,技能拆成了数组。
  2. 手写路线这次模型很配合 ,直接吐了裸 JSON,连三反引号代码块壳都没包,stripCodeFence 没派上用场。但生产里模型一"任性"包个壳,没这步 Gson 就炸------这就是为什么剥壳这步不能省。
  3. AiServices 路线:你没写一行 Gson、没剥一个代码块,框架直接把对象递到你手里。这就是"声明式"的舒服。

排查提示:报 400 多半是开了 JSON 模式但提示词里没写 "json";Gson 报 JsonSyntaxException 是模型吐了散文或代码块,检查 stripCodeFence;AiServices 返回的字段是 null,检查 POJO 是不是 public、有没有无参构造和 getter/setter。

五、挑战题:改参数,看看会怎样

  1. ⭐ 换个输入 :把 intro 改成"我是李雷,3 年前端,主要做 React 和 Vue",重跑两条路线,看 years 是不是抽成 3、skills 是不是 React/Vue。答案就在 ResumeExtractApplication 那个 intro 字符串里。
  2. ⭐⭐ 故意制造格式漂移 :把 stripCodeFence 那行注释掉,再临时把提示词里"只输出 JSON"删掉,看 Gson 怎么炸------体会手写路线有多脆弱。答案在 manualDemo 那三步里。
  3. ⭐⭐ 给 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/(控制台工程,内含 Resume JavaBean + ResumeExtractor AiServices 接口 + 两条路线对照的 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 直接跑。

相关推荐
w2sfot2 小时前
【JS加密在线】jsjiami.online(让JavaScript 更安全!)
开发语言·javascript·安全
HSunR2 小时前
ruoyi 若依 自定义注解 参数校验
java·前端·数据库
Wang's Blog2 小时前
Java 项目部署之 Docker工具快速入门: Docker 是什么以及它如何解决部署环境问题
java·开发语言·docker
老木避暑研究所2 小时前
从原始数据到洞察:一次完整的避暑房环境数据采集、清洗与可视化分析
java·开发语言
程序喵大人3 小时前
【C++入门】编译链接模型 - 05 ODR 为什么会让重复定义变成工程炸点
开发语言·c++·编译链接·odr
weixin199701080163 小时前
《淘宝TOP API:落地方案、接口边界与业务踩坑 —— 聚石塔内外价差 10 倍的真相》(附 Python 源码)
开发语言·python
前端 贾公子3 小时前
LangGraph == 图的状态(State)管理 (上)
java·开发语言·数据库
huaweichenai3 小时前
spring boot 实现file文件上传
java·spring boot·后端