资讯动态

LangChain4j输出解析器实战:从LLM自然语言到Java结构化对象

发布时间:2026/10/18 9:17:10 来源:尧图企业网站定制
在做AI应用的时候我经常看到有人问模型返回的结果怎么变成对象啊明明生成得挺对但一塞进业务代码里就各种类型对不上。这期是LangChain4j实战教学系列的第006篇专门讲输出解析器——它要解决的核心问题就是把LLM那段自然语言响应转换成结构化数据让Java代码能像用普通DTO一样去操作AI的输出。简单说模型只会说“话”我们的程序需要“类型”输出解析器就是中间那个翻译官。这篇内容适合已经在用LangChain4j、或者正打算拿它做信息抽取、结构化问答、Agent工具调用的Java开发者。后面我会把内置解析器挨个讲透再演示一个嵌套对象的完整解析例子最后把我在真实项目里踩过的坑和排查方法全部列出来。1. 输出解析器是什么为什么值得单独花一篇讲1.1 没有解析器的时候代码为什么这么难受你可以先试想一个场景要做一个“新闻信息抽取”功能把一段文章喂给大模型让它找出标题、发布时间、作者和标签。模型确实能答但它返回的是这样一段话“好的这是一篇关于智能家居的新闻标题是《2025年智能家居趋势报告》发布时间是2025年3月作者是某研究员标签包括AI、IoT、家庭自动化。”这段文本人看着没问题但你的Java程序要怎么处理如果用正则硬抠“标题是xxx”今天能跑明天模型换一种说法或者多输出一个书名号正则就废了。再不要说还有嵌套结构作者还得拆成姓名和职位标签是动态数组。你总不能为每个场景都写一坨字符串切割逻辑吧。更重要的是LLM本身并不是一台“按格式输出”的机器。不给它明确规则时它倾向于自然语言回答。哪怕你告诉它“输出JSON”它也经常在JSON前后加说明文字偶尔还会把字段名写错。这些情况只靠业务代码去兜底成本极高。而输出解析器就是把这些脏活、累活集中起来处理的地方。1.2 解析器在LangChain4j里的位置LangChain4j给输出解析器定义了一个非常干净的接口public interface OutputParserT { T parse(String text); String formatInstructions(); }两个方法各有分工。parse(String text)是核心接收模型返回的原始文本输出你要的类型formatInstructions()很多人会忽略它却非常关键——它生成一段“格式指令”会被拼接到Prompt里告诉模型“你等下要按这个格式输出”。完整的调用链路是这样的原始文案 formatInstructions()→ 组装成Prompt → 发给ChatLanguageModel → 模型返回原始String/AiMessage → 调用parser.parse(text)→ 得到结构化对象。所以在LangChain4j里输出解析器不是一个可有可无的工具它直接参与Prompt的生成又不代替模型思考。它要做的是把“模型想说人话”的冲动约束成“只说数据”再把数据映射成Java类型。1.3 核心链路从模型说话到代码用数据打个比方模型像一个很懂行但说话随意的专家输出解析器就是旁边的同声传译兼记录员。专家先按照记录员给的模板发言记录员再把口语化的表达整理成一张可以进数据库的表格。记录员不会编造内容但他能纠正格式、剔除语气词、把“应该是三月中旬”转成2025-03-15。理解了这条链你就明白了为什么LangChain4j把解析器设计成“接口内置实现”的组合。接口保证接入方式统一内置实现覆盖常见场景遇到特殊格式再自己写。它不是魔法而是一个被框架标准化了的、可替换的适配层。2. 内置输出解析器逐个拆解选型前先搞懂差异2.1 一张表看懂LangChain4j内置解析器LangChain4j内置的解析器并不算多但每个都有明确的使用边界。我把它们整理成一张表方便你对照选择。解析器返回类型适用场景注意事项TextOutputParserString摘要、翻译、重写等纯文本任务只做trim清理不做结构转换MessageOutputParserAiMessage需要保留完整消息对象或元数据依赖ChatLanguageModel返回AiMessageMapOutputParserMapString, String扁平键值对结构如商品参数提取要求模型按“key: value”逐行输出JsonOutputParser泛型T结构化对象、嵌套DTO日常最常用要求模型输出严格JSON文本BeanOutputParser泛型T从JavaBean结构反推格式要求并解析内部基于反射字段命名要注意注意看这几个解析器并不是“谁替代谁”的关系。TextOutputParser处理纯文本JsonOutputParser处理结构化数据MapOutputParser则适合那种你懒得定义DTO、只要几个键值对的轻量场景。我见过不少人一上来就上JsonOutputParser结果一个简单场景硬生生定义出五个类反而把简单问题复杂化了。2.2 TextOutputParser和MapOutputParser轻量场景的两个代表先看TextOutputParser它最简单也最容易理解OutputParserString parser new TextOutputParser(); String raw 好的这是你要的摘要内容。\n\n希望对你有帮助。; String result parser.parse(raw); System.out.println(result);它的实现本质就是去掉首尾空白和多余换行。别小看这一步模型经常在正式内容前后加“好的”“这是你要的”之类的话TextOutputParser虽然不能把这些都删干净但能把肉眼看不到的空格、换行清理掉让日志和后续处理好看一些。MapOutputParser则更“贪心”一点它要把模型输出解析成一个MapString, String。用法如下OutputParserMapString, String parser new MapOutputParser(); MapString, String map parser.parse(name: 张三\nage: 28\ncity: 上海);它要求模型按key: value逐行输出。这个格式简单模型基本不会出错很适合从一段文本里抽取“品牌、型号、价格、库存”这种维度固定的参数。不过缺点也明显值是String数组和嵌套对象表达不了真要复杂结构还是得回到Json解析器。2.3 JsonOutputParser最通用的结构化方案如果你的数据结构有嵌套或者字段类型不止String那就直接用JsonOutputParserT。它的核心思想是让模型先输出一段合法JSON再用你指定的目标类型做反序列化。OutputParserNewsItem parser new JsonOutputParser(NewsItem.class); String rawJson {\title\:\...\,\publishDate\:\2025-03-15\}; NewsItem news parser.parse(rawJson);不过这里有个关键前提模型必须真的输出JSON而不是JSON外面包着一堆话。为了保证这一点JsonOutputParser的formatInstructions()返回的内容会非常强硬大意是“你必须只返回一个符合给定结构的JSON对象禁止任何说明文字”。这就是为什么我建议你在项目里始终把formatInstructions()拼进Prompt而不是自己另写一套格式要求。2.4 自定义OutputParser当内置解析器不够用的时候内置解析器覆盖了80%的场景但剩下20%总会让你头疼。比如模型在JSON外面包了Markdown代码块或者返回了“根据你的要求我们生成如下内容”这类杂质。此时你需要自定义解析器public class CleanJsonOutputParserT implements OutputParserT { private final ObjectMapper objectMapper; private final ClassT type; public CleanJsonOutputParser(ObjectMapper objectMapper, ClassT type) { this.objectMapper objectMapper; this.type type; } Override public T parse(String text) { String cleaned text .replaceAll(json, ) .replaceAll(, ) .trim(); int start cleaned.indexOf({); int end cleaned.lastIndexOf(}); if (start 0 end start) { cleaned cleaned.substring(start, end 1); } try { return objectMapper.readValue(cleaned, type); } catch (JsonProcessingException e) { throw new RuntimeException(解析模型输出失败, e); } } Override public String formatInstructions() { return 你必须返回一个合法JSON对象不要添加任何附加说明或代码块标记。; } }这个自定义解析器的好处是容错能力强模型带点“废话”也能兜住。代价是你要自己维护清洗规则清洗规则太激进又可能误伤数据。所以我的建议是先尽量用Prompt把格式约束好解析器只做最后一层保险而不是把希望全押在解析器身上。3. 实战把LLM响应解析成Java嵌套对象3.1 准备工程与模型实例前面讲了一堆理论现在来一个完整可跑的例子。假设我们要做一个“新闻信息抽取”的Demo输入一段新闻正文输出结构化对象包含标题、发布时间、作者姓名、作者职位、标签列表。先把必要依赖加进pom.xmldependency groupIddev.langchain4j/groupId artifactIdlangchain4j/artifactId version1.0.0-beta1/version /dependency dependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId version2.17.2/version /dependency模型实例部分不同厂商的接入类不同但LangChain4j把它们统一在了ChatLanguageModel接口后面。我这里用一个工厂方法做占位你的项目里替换成实际使用的模型实例即可ChatLanguageModel model ChatModelFactory.create();如果你接的是各家模型服务商提供的OpenAI兼容接口通常只需要配好baseUrl和apiKey再指定模型名。这个操作在系列前面的章节里已经提过这里不再展开。我额外提醒一句不要在生产代码里硬编码密钥用环境变量或配置中心管理。3.2 定义目标模型和Prompt模板先定义两个Java类。用record最简洁record Author(String name, String position) {} record NewsItem( String title, String publishDate, Author author, ListString tags ) {}注意字段名publishDate是驼峰风格name、position、tags都是单层结构JSON天然能映射过来。如果你要接的JSON里字段名是下划线比如publish_date那就得配合Jackson的JsonProperty注解后面第4节我会细说。接下来是PromptTemplate。这里最关键的是把结构定义说明白String template 请从以下新闻正文中提取信息并返回JSON。 新闻正文 {{news}} 要求 1. 只返回一个合法JSON对象不要Markdown代码块不要任何说明文字。 2. JSON结构如下 { title: 标题, publishDate: 发布日期格式yyyy-MM-dd, author: { name: 作者姓名, position: 作者职位 }, tags: [标签1, 标签2] } ; PromptTemplate promptTemplate PromptTemplate.from(template); Prompt prompt promptTemplate.apply(Map.of(news, newsText));你可能注意到我没有在Prompt里放所谓的“例子少样本”因为这个结构已经足够简单。如果字段含义容易混淆或者模型频繁抽错字段那就再加一个“参考示例”让模型照着输出。这招对格式稳定性提升非常明显。3.3 接入解析器并跑通主流程现在把模型、Prompt、解析器串起来。我用JsonOutputParserJsonOutputParserNewsItem parser new JsonOutputParser(NewsItem.class); AiMessage aiMessage model.generate(prompt.toUserMessage()); String text aiMessage.text(); System.out.println(模型原始输出: text); NewsItem newsItem parser.parse(text); System.out.println(解析后对象: newsItem);运行之后你会得到类似下面这样的对象NewsItem[ title2025年智能家居趋势报告, publishDate2025-03-15, authorAuthor[name某研究员, position首席分析师], tags[AI, IoT, 家庭自动化] ]注意我并没有把formatInstructions()手动写进模板因为我在模板里已经写了同样的要求。如果你使用的是BeanOutputParser它会在内部总结字段结构并自动附加格式指令那你就不用在模板里重复写结构了。两种方式各有取舍模板里写死结构可控性强交给解析器生成结构省心但灵活度低。建议你第一次跑的时候先打印一下aiMessage.text()亲眼看看模型到底输出了什么。我敢说你大概率会看到JSON前后有几行“好的这是提取结果”之类的话——这就是解析器要兜底的场景。这也是为什么我强烈建议项目里保留自定义清洗逻辑。3.4 验证结果字段缺失、类型转换失败怎么兜底在一次演示中模型没按模板的日期格式输出返回了“2025年3月15日”结果Jackson反序列化直接报错。这种问题靠解析器硬解是解不出来的得在解析失败的时候加一道“AI修复”流程try { newsItem parser.parse(text); } catch (Exception e) { System.err.println(第一次解析失败尝试让模型修复: e.getMessage()); String fixPrompt 你之前输出了一段JSON但解析失败失败原因{{error}} 原始输出{{original}} 请重新输出一个完全合法、符合之前字段类型的JSON对象。 ; Prompt fix PromptTemplate.from(fixPrompt).apply(Map.of( error, e.getMessage(), original, text )); String fixedText model.generate(fix.toUserMessage()).text(); newsItem parser.parse(fixedText); }这个思路很土但非常有效。大模型面对“你格式错了请重试”时绝大多数情况会认真修正。当然这会产生额外调用所以只建议在解析失败时触发。线上环境最好加个次数限制防止模型反复出错导致死循环。4. 我在实际项目中踩过的坑常见问题与排查清单4.1 模型在JSON前后说废话这是最频繁出现的问题。哪怕你在Prompt里写了“只返回JSON”模型有时候还是忍不住加一句“好的已经为你提取完成”。后果就是JsonOutputParser直接解析失败。我的排查步骤是先打印原始输出确认杂质出现的固定模式。如果杂质总是出现在开头可以用indexOf({)截取如果总是出现在结尾用lastIndexOf(})截取。如果模型经常把“json\n”这种代码块标记包在外面就先替换掉代码块标记再截取。从根本上减少废话的办法是少用“请”“麻烦”这类礼貌词直接下指令“禁止输出任何说明文字”“你必须只返回JSON”。我在实测中发现强硬的指令比客气的指令平均少产生90%的杂质。4.2 JSON里字段名对不上Java对象模型并不是按你的Java字段名生成JSON的它更倾向于生成自然语言风格的键比如publish_date、authorName。要处理这种情况最稳妥的方式是用Jackson注解显式映射record Author( JsonProperty(author_name) String name, JsonProperty(position_title) String position ) {} record NewsItem( JsonProperty(title) String title, JsonProperty(publish_date) String publishDate, JsonProperty(author) Author author, JsonProperty(tags) ListString tags ) {}这里有个容易踩的细节如果record组件已经加了JsonProperty那反序列化的键名就以注解为准Java字段名反而无所谓了。所以你可以让模板里的JSON结构自由一点Java对象这边负责对齐两边不用非得一模一样。4.3 日期格式和枚举值老是对不上日期字段是重灾区。模型经常给出“今天”“2025年3月”“三月底”这种模糊表达。我的做法是在Prompt模板里明确给出一个示例值“publishDate字段必须是ISO8601格式例如2025-03-15不要写年份加月份的文字”。如果模型还是不改可以在Java对象上再加一层格式约束record NewsItem( JsonFormat(pattern yyyy-MM-dd) LocalDate publishDate, ... ) {}这样即使在反序列化阶段出了偏差Jackson也会因为格式不匹配直接报错你就能趁早发现问题而不是让一个“2025年3月15日”的字符串混进数据库。枚举字段同理。假设position字段要求是“分析师、工程师、研究员”三种之一就别让模型自由发挥。Prompt里写清楚候选值Java端用enum接解析不到就抛错。宁可让错误暴露在解析阶段也不要让脏数据流到业务层。4.4 流式输出场景怎么和解析器配合如果你用stream()流式调用模型是一段一段返回文字的这时候你不能在每一段都调用parser.parse——因为半边JSON在任何一个时刻都是不完整的。正确做法是把TokenStream的片段累积起来等onCompleteResponse回调触发后再用解析器处理完整结果。model.stream(prompt.toUserMessage()) .onPartialResponse(token - { accumulated.append(token); }) .onCompleteResponse(aiMessage - { try { NewsItem item parser.parse(aiMessage.text()); // 到这里才做结构化处理 } catch (Exception e) { // 做修复或重试 } }) .onError(error - { // 处理异常 }) .start();有个细节从onPartialResponse累积的字符串和onCompleteResponse里拿到的aiMessage.text()可能并不完全一样后者是完整消息所以解析时最好用后者别用自己拼的字符串。这个坑我踩过一次自己拼接的内容偶尔会丢最后一个字符导致JSON不完整。4.5 模型输出格式怎么调都不稳定时怎么办当你发现某个模型总是输出不稳定不要只盯着解析器优化。解析器只能收拾残局不能无中生有。这时候我通常按下面优先级调整手段效果成本Prompt加示例少样本显著提升格式稳定性低换用更强的模型解决复杂逻辑问题中自定义解析器清洗解决固定杂质问题中多次解析AI修复兜底但增加延迟高注意顺序先通过Prompt解决再换模型最后才考虑加额外的AI修复调用。调Prompt的成本几乎为零而每次多一轮修复调用都意味着更长的响应时间、更多的费用。5. 输出解析器用得好不好差别在这几条经验里5.1 什么时候用内置什么时候自定义我在实际项目里总结了一条经验法则如果模型只要返回一个格式相对固定的对象优先JsonOutputParser如果模型可能在不同情况下返回不同结构比如Agent工具返回“成功”或“错误”两种结构就别指望一个解析器搞定应该先解析成一个通用容器再用业务分支处理如果模型输出里总是混着不少杂质那就直接自定义解析器把清洗和映射合并到一起。别为了“统一”而强行把所有解析都塞进内置解析器。解析器是代码的一部分它该被替换时就要被替换该扩展时就扩展。5.2 我在这几周里总结的8条输出格式经验Prompt里的格式说明一定要写到字段级别别只写“输出JSON”要写清楚每个键的含义、示例值和格式约束。模型输出带废话时先截取JSON区间再做反序列化不要直接在原始字符串上硬解析。始终用indexOf({)和lastIndexOf(})定位JSON主体比正则匹配鲁棒得多。解析失败时把异常信息回传给模型让它重试比自己在代码里写一堆判断靠谱。日期、枚举等特殊类型在Prompt模板里给一个正确示例能减少一半格式错误。不要依赖模型的“记忆”去保持格式一致每次请求都要把格式要求带在Prompt里。流式输出场景下只在完整消息回调后做解析绝不要边收边解析。开发阶段把所有解析前后的文本都打日志方便定位谁在破坏格式。5.3 进阶方向把解析器接入AiServices自动流程如果你已经掌握了手动接入解析器的流程下一步可以研究LangChain4j的AiServices。它支持通过注解声明结构化输出框架会在内部调用模型并自动解析你只需要定义好接口方法。比如interface NewsExtractor { SystemMessage(你是一个信息抽取助手) NewsItem extract(UserMessage String text); } NewsExtractor extractor AiServices.builder(NewsExtractor.class) .chatLanguageModel(model) .build(); NewsItem item extractor.extract(newsText);这种写法省去了手动拼Prompt、手动调parse的过程属于“配置式开发”。不过它背后用的还是输出解析器那套逻辑只是框架帮你把链路串好了。建议你先把手动流程跑通再过渡到这种高级写法。最后再分享一个小技巧当项目里的解析场景多起来以后可以维护一个MapString, OutputParser?键是业务场景名值是解析器实例。想换格式的时候改一个场景的解析器注册项就行不用去业务代码里一个个找。我自己在做一个多分类提取项目的时候就是靠这种注册表方式快速切换不同解析策略的省了很多重复改动。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑