尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

LangChain4j实战:让AI Agent输出结构化JSON数据,告别闲聊式响应

LangChain4j实战:让AI Agent输出结构化JSON数据,告别闲聊式响应 1. 从闲聊到精准数据为什么我们需要结构化输出最近在折腾几个AI应用的原型发现一个挺普遍的问题当你让大模型帮你处理一些稍微复杂点的任务时它给你的回复十有八九是一段“人类友好”的自然语言。比如你问它“帮我分析一下这份合同里的关键条款包括甲方、乙方、合同金额和截止日期。” 它可能会给你一段非常流畅、甚至带点格式的文本回复“好的已为您分析。这份合同的甲方是XX公司乙方是YY公司合同总金额为100万元人民币约定的项目截止日期是2024年12月31日。请注意其中还涉及了保密条款和违约责任……”这段回复读起来很舒服对吧但如果你是一个开发者想把这段信息塞进你自己的数据库里或者传递给下一个自动化流程比如自动生成付款提醒、更新项目看板你就傻眼了。你得写一堆复杂的正则表达式或者用更高级的NLP模型去“猜”和“抠”出“XX公司”、“100万元”、“2024-12-31”这些关键信息。这个过程不仅繁琐、容易出错而且极度脆弱——模型换个说法你的解析逻辑可能就崩了。这就是“闲聊式”输出的痛点它适合人看不适合机器用。而在企业级应用、自动化工作流Agent中我们需要的是机器可读、结构清晰、格式固定的数据。JSONJavaScript Object Notation正是解决这个问题的银弹。它是一种轻量级的数据交换格式简单、清晰、被几乎所有编程语言原生支持。如果我们能让AI模型直接返回JSON比如{“partyA”: “XX公司”, “partyB”: “YY公司”, “amount”: 1000000, “currency”: “CNY”, “deadline”: “2024-12-31”}那么后续的处理就变成了简单的JSON解析稳定、高效、零歧义。所以结构化输出的核心价值就是在AI的“灵活性”与程序的“确定性”之间架起一座桥梁。它让大模型的强大理解与生成能力能够以标准化的方式无缝嵌入到现有的、依赖确定数据格式的软件系统中。而LangChain4j作为Java生态中连接大模型与应用的明星框架提供了一套优雅且强大的工具来实现这一点。今天我们就来深入聊聊如何利用LangChain4j让你的Agent不再“闲聊”而是精准地“吐”出你想要的JSON数据。2. LangChain4j 结构化输出核心StructuredPrompt与OutputParser在LangChain4j中实现结构化输出的核心思想是“约定大于配置”。它通过两个关键组件协同工作StructuredPrompt结构化提示和OutputParser输出解析器。简单理解StructuredPrompt负责“教”模型应该输出什么样的格式而OutputParser则负责“确保”模型输出的内容能被正确解析成我们想要的Java对象。2.1StructuredPrompt给模型的格式说明书StructuredPrompt是一个注解你把它加在你自定义的一个接口上。这个接口的每一个方法都代表了你希望模型返回的JSON对象中的一个字段。LangChain4j在背后会将这些方法签名和注解信息转换成一段清晰的指令插入到最终发送给大模型的提示词Prompt中。举个例子假设我们要让AI从一段产品描述中提取信息。我们首先定义一个“产品信息”的结构import dev.langchain4j.model.input.structured.StructuredPrompt; StructuredPrompt({ “请从以下产品描述中提取关键信息并以JSON格式返回。”, “描述{{it}}” // {{it}} 是一个占位符运行时会被实际的用户输入替换 }) public interface ProductInfo { String productName(); String brand(); Double price(); ListString keyFeatures(); }注意这个ProductInfo接口方法名直接对应JSON键productName()对应JSON中的”productName”键。返回类型定义数据类型String,Double,ListString清晰地指明了每个字段期望的数据类型。StructuredPrompt注解这里定义了给模型的“系统指令”。{{it}}是LangChain4j的模板变量代表用户输入的实际文本。当这个接口被使用时LangChain4j生成的最终Prompt会类似于你是一个信息提取助手。请严格按照以下JSON格式输出不要添加任何其他解释。 格式{“productName”: “string”, “brand”: “string”, “price”: number, “keyFeatures”: [“string”, “string”, …]} 请从以下产品描述中提取关键信息并以JSON格式返回。 描述用户输入的实际产品描述文本这种指令非常明确大大提高了模型返回合规JSON的概率。2.2OutputParser从文本到对象的转换器即使有清晰的指令大模型偶尔也可能“放飞自我”在JSON前后加上一些说明文字或者格式略有瑕疵。OutputParser的作用就是处理这些“不完美”的响应将其规整并反序列化成我们定义的Java接口的代理实例。在LangChain4j中你通常不需要直接操作OutputParser。当你通过AiServices创建AI服务时框架已经为你集成了默认的解析逻辑。它的工作流程大致如下获取模型的原始文本响应。尝试在响应中定位JSON字符串通常通过查找第一个{和最后一个}。使用JSON库如Jackson将定位到的JSON字符串解析成一个MapString, Object。根据你定义的接口如ProductInfo将Map中的值映射到接口方法的返回值上并动态创建一个实现了该接口的代理对象。当你调用代理对象的方法时实际上是从这个内存中的Map里取值。这个过程对开发者是透明的你拿到手的就是一个标准的Java对象可以直接调用getter即接口方法。2.3 两者协作一个完整的流程视图让我们把这两个组件串起来看看一次完整的结构化调用是如何发生的开发者定义结构你创建了一个带有StructuredPrompt的接口MyStructure。构建AI服务你使用AiServices.builder()创建服务并指定模型和这个接口。用户发起请求用户输入一段文本例如“帮我分析这个句子苹果公司发布了售价999美元的iPhone 15特点是灵动岛和USB-C接口。”框架组装PromptLangChain4j将StructuredPrompt中的指令模板和用户输入结合生成最终Prompt发送给大模型。模型返回文本大模型返回类似“{“productName”: “iPhone 15”, “brand”: “苹果公司”, “price”: 999, “keyFeatures”: [“灵动岛”, “USB-C接口”]}”的文本理想情况下。框架解析响应OutputParser提取出JSON部分并创建MyStructure的代理实例。开发者使用结果你获得一个MyStructure对象调用productName()直接得到“iPhone 15”。这个流程将不确定的自然语言输出转化为了高度确定的、类型安全的Java对象是构建可靠AI Agent的基石。3. 实战构建一个合同条款提取Agent光说不练假把式。我们现在就来构建一个真实的、微型的Agent它的唯一任务就是从一段非结构化的合同文本中提取出结构化的关键条款信息。我们将使用OpenAI的GPT-4模型你也可以替换为其他兼容模型如Ollama本地模型。3.1 环境准备与依赖引入首先创建一个新的Maven或Gradle项目。核心依赖是langchain4j-open-ai它包含了LangChain4j核心以及OpenAI客户端的集成。我们也会用lombok来简化POJO的创建。Mavenpom.xml关键依赖dependencies !-- LangChain4j OpenAI 集成 -- dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-open-ai/artifactId version0.30.0/version !-- 请使用最新版本 -- /dependency !-- Lombok 用于生成Getter/Setter等 -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId version1.18.30/version scopeprovided/scope /dependency !-- 日志框架可选但推荐 -- dependency groupIdorg.slf4j/groupId artifactIdslf4j-simple/artifactId version2.0.9/version /dependency /dependenciesGradlebuild.gradle关键依赖dependencies { implementation ‘dev.langchain4j:langchain4j-open-ai:0.30.0’ compileOnly ‘org.projectlombok:lombok:1.18.30’ annotationProcessor ‘org.projectlombok:lombok:1.18.30’ implementation ‘org.slf4j:slf4j-simple:2.0.9’ }注意版本号请务必查询LangChain4j官方GitHub仓库使用最新的稳定版。这个领域迭代非常快。3.2 定义数据结构合同条款POJO我们需要明确要从合同里提取什么。定义一个ContractClause接口并使用StructuredPrompt注解。这里我选择用接口而不是类是因为LangChain4j的AiServices默认期望与接口配合工作来创建动态代理。import dev.langchain4j.model.input.structured.StructuredPrompt; import java.time.LocalDate; import java.util.List; StructuredPrompt({ “你是一个专业的合同分析助手。请从以下合同文本中精确提取以下关键条款信息。请确保金额只提取数字日期格式为YYYY-MM-DD。除了以下信息不要提取其他内容。”, “合同文本{{it}}” }) public interface ContractClause { // 甲方名称 String partyA(); // 乙方名称 String partyB(); // 合同总金额数字 Double totalAmount(); // 币种如 CNY, USD String currency(); // 合同签署日期 LocalDate signingDate(); // 项目截止日期 LocalDate deadline(); // 关键责任条款列表 ListString keyResponsibilities(); // 付款方式描述 String paymentTerms(); }关键点解析LocalDate类型LangChain4j的OutputParser能够处理常见的Java类型包括LocalDate。它会尝试将模型返回的日期字符串如“2023-10-01”自动转换。这比我们手动用String接收再解析要安全方便得多。ListString类型对于列表型字段模型需要返回一个JSON数组。指令中“关键责任条款列表”的表述会引导模型将多条责任总结为数组项。清晰的指令指令中强调了“精确提取”、“金额只提取数字”、“日期格式”这些都能有效约束模型输出减少后续清洗工作。{{it}}占位符这是固定的代表整个用户输入。3.3 创建并配置AI服务接下来我们创建AI服务。你需要一个OpenAI的API Key。import dev.langchain4j.service.AiServices; import dev.langchain4j.model.openai.OpenAiChatModel; public class ContractExtractionAgent { public static void main(String[] args) { // 1. 创建OpenAI模型实例 // 请将”your-api-key-here“替换成你的真实API Key OpenAiChatModel model OpenAiChatModel.builder() .apiKey(System.getenv(“OPENAI_API_KEY”)) // 推荐从环境变量读取 .modelName(“gpt-4o”) // 使用GPT-4或GPT-3.5-turbo。结构化输出推荐GPT-4更稳定。 .temperature(0.0) // 温度设为0使输出确定性最高最适合结构化任务 .logRequests(true) // 开启请求日志调试时非常有用 .logResponses(true) .build(); // 2. 创建AI服务将模型与我们定义的接口绑定 ContractClause extractor AiServices.create(ContractClause.class, model); // 3. 准备一段合同文本 String contractText “”” 本合同由甲方委托方上海云智科技有限公司与乙方受托方北京数创未来人工智能实验室于2024年5月15日共同签署。 甲方委托乙方进行‘智能客服系统升级项目’的开发与实施。合同总金额为人民币贰拾伍万元整¥250,000.00。 乙方需在2024年11月30日前完成全部开发、测试并交付上线。 乙方的主要责任包括1. 完成系统架构设计与核心模块开发2. 提供为期一年的免费技术维护3. 对甲方人员进行两次系统操作培训。 付款方式合同签订后7个工作日内甲方向乙方支付合同总价的50%作为预付款项目验收合格后15个工作日内支付剩余的50%。 “””; // 4. 调用服务进行提取 ContractClause result extractor.extract(contractText); // 注意接口方法名需要定义这里假设为extract // 5. 输出结果 System.out.println(“甲方: “ result.partyA()); System.out.println(“乙方: “ result.partyB()); System.out.println(“合同金额: “ result.totalAmount() “ “ result.currency()); System.out.println(“签署日期: “ result.signingDate()); System.out.println(“截止日期: “ result.deadline()); System.out.println(“关键责任: “ result.keyResponsibilities()); System.out.println(“付款方式: “ result.paymentTerms()); } }等等这里有个问题ContractClause是一个接口我们并没有定义extract这个方法。AiServices需要知道调用哪个方法来触发AI分析。我们需要修改接口增加一个方法。3.4 完善接口与调用修正后的ContractClause接口import dev.langchain4j.model.input.structured.StructuredPrompt; import dev.langchain4j.service.SystemMessage; import java.time.LocalDate; import java.util.List; // 可以添加一个系统消息进一步固定模型角色但非必须 // SystemMessage(“你是一个严谨的法律文档分析AI只输出JSON不进行任何额外解释。”) StructuredPrompt({ “你是一个专业的合同分析助手。请从以下合同文本中精确提取以下关键条款信息。请确保金额只提取数字日期格式为YYYY-MM-DD。除了以下信息不要提取其他内容。”, “合同文本{{it}}” }) public interface ContractClause { // 这个方法才是真正被AiServices调用的入口点。 // 参数String contractText会替换掉 StructuredPrompt 中的 {{it}} ContractClause extractClauses(String contractText); // 以下是提取的字段 String partyA(); String partyB(); Double totalAmount(); String currency(); LocalDate signingDate(); LocalDate deadline(); ListString keyResponsibilities(); String paymentTerms(); }相应地主程序中的调用也需要修改// 4. 调用服务进行提取 ContractClause result extractor.extractClauses(contractText); // 调用我们定义的方法 // 5. 输出结果 System.out.println(“提取结果:”); System.out.println(“甲方: “ result.partyA()); System.out.println(“乙方: “ result.partyB()); System.out.println(“合同金额: “ result.totalAmount() “ “ result.currency()); System.out.println(“签署日期: “ result.signingDate()); System.out.println(“截止日期: “ result.deadline()); System.out.println(“关键责任: “ result.keyResponsibilities()); System.out.println(“付款方式: “ result.paymentTerms());现在运行这个程序。如果一切顺利你将看到控制台打印出结构化的信息提取结果: 甲方: 上海云智科技有限公司 乙方: 北京数创未来人工智能实验室 合同金额: 250000.0 CNY 签署日期: 2024-05-15 截止日期: 2024-11-30 关键责任: [完成系统架构设计与核心模块开发 提供为期一年的免费技术维护 对甲方人员进行两次系统操作培训] 付款方式: 合同签订后7个工作日内甲方向乙方支付合同总价的50%作为预付款项目验收合格后15个工作日内支付剩余的50%。成功一段杂乱的非结构化合同文本被我们的小Agent精准地转换成了一个结构化的Java对象。你可以轻松地将这个result对象序列化成JSON存入数据库或者传递给工作流中的下一个处理单元。4. 进阶技巧与避坑指南上面的例子跑通了基本流程但在实际生产中你会遇到各种边界情况和挑战。下面分享一些我踩过坑后总结的进阶技巧。4.1 处理复杂嵌套结构与可选字段现实中的数据模型很少是扁平的。比如合同金额可能包含明细参与方可能不止两个。LangChain4j同样支持嵌套对象的定义。示例定义包含嵌套对象的条款import dev.langchain4j.model.input.structured.StructuredPrompt; import java.time.LocalDate; import java.util.List; StructuredPrompt(“从文本中提取合同信息{{it}}”) public interface ComplexContract { ComplexContract analyze(String text); // 嵌套对象甲方信息 Party partyA(); // 嵌套对象乙方信息 Party partyB(); // 合同金额明细也是一个嵌套对象 AmountDetail amountDetail(); LocalDate deadline(); } // 定义”参与方“子结构 interface Party { String name(); String unifiedSocialCreditCode(); // 统一社会信用代码可能为空 String contactPerson(); } // 定义”金额明细“子结构 interface AmountDetail { Double total(); String currency(); Double taxRate(); // 税率可能为空 Double taxAmount(); // 税额可能为空 }关键点嵌套接口Party和AmountDetail本身也是接口。LangChain4j会递归地处理这些嵌套结构。可选字段处理对于模型中可能不存在的字段如taxRate如果文本中没有提及大模型返回的JSON中可能没有这个键或者值为null。LangChain4j的代理会处理这种情况调用方法时可能返回null。你需要在自己的业务逻辑中做好空值判断。4.2 枚举Enum类型的映射对于固定类别的字段使用Enum类型是更安全的选择。例如合同状态可以是“DRAFT“, “SIGNED“, “TERMINATED“。StructuredPrompt(“提取合同状态{{it}}”) public interface ContractStatusInfo { ContractStatusInfo extract(String text); ContractStatus status(); // 使用枚举 } // 定义枚举 enum ContractStatus { DRAFT, UNDER_REVIEW, SIGNED, IN_EFFECT, TERMINATED, UNKNOWN }LangChain4j会尝试将模型返回的字符串匹配到枚举值上。如果匹配失败可能会抛出异常。为了健壮性可以定义一个UNKNOWN枚举项作为兜底或者在接口方法中使用String类型接收再手动转换。4.3 指令工程提高输出稳定性的关键大模型对指令非常敏感。StructuredPrompt里的文字直接决定了输出质量。以下是一些撰写指令的黄金法则角色明确开头就固定AI的角色如“你是一个专业的合同分析AI“。任务清晰明确指出要做什么“提取以下信息“、“总结为以下几点“。格式强制必须包含“以JSON格式返回“、“只输出JSON不要有任何其他文字“这类强约束语句。字段说明对于容易混淆的字段可以在指令中附加简短说明。例如“currency使用三位字母代码如CNY代表人民币USD代表美元“。示例驱动Few-Shot对于极其复杂的结构可以在指令中给一两个输入输出的例子这是最强大的约束方式。虽然StructuredPrompt不支持直接内嵌复杂示例但你可以把例子写在指令文本里。优化后的指令示例你是一个顶尖的法律文档分析AI。你的任务是从用户提供的合同文本片段中精确提取指定的结构化信息并输出为一个纯净的JSON对象不要有任何额外的介绍、总结或解释性文字。 输出必须严格遵守以下JSON格式 { “partyA”: “字符串甲方全称”, “partyB”: “字符串乙方全称”, “totalAmount”: 数字仅提取金额数字如250000, “currency”: “字符串货币代码如CNY”, “signingDate”: “字符串日期格式必须为YYYY-MM-DD”, “deadline”: “字符串日期格式必须为YYYY-MM-DD”, “keyResponsibilities”: [“字符串数组每条责任简明扼要”], “paymentTerms”: “字符串描述付款方式” } 请注意 1. 金额只提取纯数字忽略‘元’、‘人民币’等文字和‘¥’、‘$’等符号。 2. 日期必须统一转换为‘YYYY-MM-DD’格式。 3. 如果某项信息在文本中未找到则在JSON中将其值设为null。 现在请分析以下合同文本 {{it}}4.4 错误处理与模型“不听话”怎么办即使指令再完美模型也可能返回非JSON内容或者JSON格式错误。LangChain4j的OutputParser内部会尝试修复如提取首尾{}之间的内容但并非万能。实战中的处理策略使用try-catch包裹调用最基础的保护。try { ContractClause result extractor.extractClauses(someText); // 处理结果 } catch (Exception e) { log.error(“解析合同失败文本: {}“, someText, e); // 降级策略存入待人工审核队列或使用更简单的正则进行二次提取 }启用详细日志创建模型时设置.logRequests(true).logResponses(true)。当解析失败时查看日志里模型实际返回了什么这是调试指令最关键的依据。降级与重试降级对于不重要的场景可以准备一个“默认值”或“未知”对象作为回退。重试对于重要任务可以捕获异常后尝试用更简化的指令或换一个模型如从gpt-3.5-turbo切换到gpt-4重新请求一次。注意设置重试次数和退避策略避免无限循环和API费用暴涨。后置校验与清洗即使解析成功数据也可能有误。例如金额单位弄错、日期格式不对。在将结果入库或进入下一流程前增加一道业务规则的校验逻辑。比如检查金额是否在合理范围内日期是否在未来。4.5 性能与成本考量模型选择gpt-4系列在遵循复杂指令和输出格式上远胜于gpt-3.5-turbo但价格更贵速度更慢。对于格式简单、任务明确的情况gpt-3.5-turbo可能就足够了。需要进行测试和权衡。温度Temperature参数务必设置为0或接近0的值如0.1。这个参数控制输出的随机性。对于结构化输出任务我们需要的是确定性而不是创造性。Token消耗StructuredPrompt中的指令会作为系统或用户消息的一部分发送占用Token。指令越长、越详细每次请求的成本就越高。需要在指令的清晰度和简洁性之间找到平衡。批量处理如果需要处理大量文档避免在循环中同步调用API这会导致极慢的速度。考虑使用异步客户端或者利用LangChain4j的BatchProcessor如果版本支持来批量发送请求但要注意模型的速率限制。5. 超越基础动态结构与流式输出5.1 处理动态字段高级话题有时我们无法在编译时确定所有字段。比如从一份简历中提取技能不同人的技能列表完全不同。虽然LangChain4j的原生StructuredPrompt更适用于固定模式但我们仍有变通方案。方案一使用MapString, Object类型。定义一个字段返回Map并在指令中要求模型将动态内容组织成键值对。但这要求模型对Map结构有很好的理解且后续处理Map的逻辑会变得复杂。StructuredPrompt(“提取信息动态属性放在‘additionalInfo’字段中{{it}}”) public interface DynamicResume { DynamicResume parse(String text); String name(); MapString, Object additionalInfo(); // 用于存放动态字段 }方案二设计可扩展的固定结构。这是更推荐的做法。预先定义好所有可能的字段但允许为空。例如为技能、工作经历、项目经验都定义成列表字段。即使简历中没有返回空列表即可。这牺牲了一点灵活性但换来了极强的类型安全和处理简便性。5.2 流式输出Streaming与结构化LangChain4j也支持流式响应这对于生成长文本非常有用。但对于结构化输出流式的意义在于可以一边生成一边进行初步解析和验证或者在生成完整JSON后立即开始处理而不必等待整个响应文本传输完毕。使用OpenAiStreamingChatModel并配合StreamingResponseHandler你可以在onComplete回调中获得完整的响应文本然后可以将其传递给一个自定义的或框架提供的OutputParser进行解析。不过目前以0.30.0版本为例AiServices对流式结构化输出的直接支持可能不如同步调用那么完善可能需要更多的手动处理。一个常见的模式是使用流式模型获取完整的响应字符串然后使用ObjectMapper如Jackson或LangChain4j的Json.JsonCodec将其反序列化成你的POJO。OpenAiStreamingChatModel streamingModel …; StringBuilder fullResponse new StringBuilder(); streamingModel.generate(userMessage, new StreamingResponseHandlerAiMessage() { Override public void onNext(String token) { fullResponse.append(token); // 可以实时显示token } Override public void onComplete(ResponseAiMessage response) { String jsonString fullResponse.toString(); // 1. 可能需要清洗jsonString提取JSON部分 // 2. 使用Jackson等库解析为你的Java对象 ObjectMapper mapper new ObjectMapper(); MyPojo result mapper.readValue(extractedJson, MyPojo.class); // 处理result } // … 其他方法 });6. 总结与最佳实践心法让Agent返回JSON而不是闲聊本质上是将大模型纳入到确定性软件工程范式中的关键一步。通过LangChain4j的结构化输出能力我们能够构建出可靠、可集成、可维护的AI增强型应用。回顾整个实战过程以下是我总结的几条核心心法始于清晰的定义在写第一行代码之前先用纸笔或文档把你希望得到的JSON结构画出来。明确的输出结构是成功的起点。指令即契约StructuredPrompt里的文字是你与模型之间的契约。写得越清晰、越无歧义模型“履约”的可能性就越高。多花时间打磨指令事半功倍。拥抱强类型充分利用Java的强类型系统。用LocalDate、Enum、ListYourType让编译器和你站在一起在编译期就能发现很多潜在的类型错误。假设输出会出错永远不要假设模型返回的JSON是完美的。一定要有异常处理、日志记录和降级方案。对于关键业务甚至可以考虑引入人工审核环节作为最终保障。测试、测试、再测试准备一个涵盖各种边界情况的测试用例集字段缺失、格式异常、输入荒谬、长度极长等。用这些用例反复测试你的Agent观察其表现并持续优化你的指令和解析逻辑。关注成本与延迟结构化输出通常意味着更长的指令更多Token和可能需要更强大的模型如GPT-4这会增加单次调用的成本和耗时。在产品化时需要评估这些开销是否在可接受范围内。最后记住工具是为人服务的。LangChain4j的结构化输出是一个强大的工具但它不是魔法。它需要你开发者去精心设计数据结构、撰写清晰指令、并构建稳健的错误处理外壳。当你把这些都做到位时你就会得到一个不再是“闲聊伙伴”而是真正能融入生产流水线的“智能数据提取员”。
返回列表