尧图精选

LLM结构化输出实战:用JSON让大模型结果稳定接入业务系统

🕒 发布时间:2026/9/3 11:31:03 📁 来源:尧图网络
用大模型做简历助手这类项目最难处理的往往不是文案生成而是模型返回的内容没法直接使用。你让模型写一段工作经历它可能会给你一大段带标题、带冒号、带项目符号的混合文本看起来没问题但后续想根据公司、时间、成果分别渲染到简历模板里就得重新解析而且解析规则会越写越脆弱。这个问题在LLM大模型项目里非常典型JSON结构化输出就是最直接的解法让模型严格按照约定好的字段结构返回数据调用方拿到结果后直接反序列化不需要猜、不需要正则硬抠、也不需要人工二次整理。这篇文章不是讲模型原理也不是讲Prompt花活。我会按一个真实的“简历助手”项目落地顺序把为什么需要JSON结构化输出、怎么设计实体类、怎么让模型按格式返回、返回之后怎么校验和兜底、实际踩坑时先查哪一层全部拆开写一遍。适合正在做LLM项目、想把模型输出接入业务系统、或者第一次接触Spring AI结构化输出、前后端分离项目的开发同学。1. 简历助手这类项目为什么必须先解决输出格式问题1.1 模型默认输出长什么样先做一个最简单的测试。把一段用户粘贴过来的经历描述交给LLMPrompt里只写“请帮我整理成简历内容”模型大概率会返回这种内容张三5年后端开发经验熟悉Java、Spring Cloud、MySQL、Redis。 2019.07 - 2021.08 在某科技公司担任Java开发工程师负责订单系统的开发和维护 主要工作包括1. 订单模块接口开发2. 数据库表设计3. 线上问题排查。 项目亮点通过优化缓存将订单查询接口的响应时间降低了40%。这段内容看起来很好。问题在于它是“一段文本”不是“一份数据”。当前端要把“订单系统”“Java开发工程师”“2019.07 - 2021.08”“降低了40%”分别渲染到简历模板的对应位置时代码不知道该从哪里切。这种自由文本回到前端只能整块展示。如果业务要求把工作经历做成可折叠列表把技能做成标签把项目成果做成带时间轴的条目自由文本就是灾难。1.2 非结构化输出带来的四个连锁问题第一解析规则脆弱。有人会写正则去匹配日期、公司名、成果但LLM的措辞每次都不一样今天可能是“2019年7月至2021年8月”明天可能是“2019.7-2021.8”后天可能是“July 2019 - Aug 2021”正则规则会越写越多最后根本维护不动。第二字段缺失无法确认。自由文本里没有“意向岗位”时你很难判断模型是真没提取到还是输入里本来就没有。没有固定字段就没有校验边界。第三数据无法入库和检索。简历投递系统通常要把数据写入数据库后续按技能、年限、岗位筛选。一段杂乱文案很难做条件查询更别说对接推荐系统。第四无法模板化。同一个候选人简历助手要能生成适合投A公司的版本、适合投B公司的版本。如果结果不是结构化数据每次生成都是重新写一段文案而不是把数据字段重新排列组合。1.3 结构化输出对工作效率的直接提升让模型输出JSON之后调用链路会变成用户输入原始经历文本 - LLM提取并生成结构化JSON - 后端反序列化成对象 - 校验后落库或返回前端 - 前端按字段渲染。这个链路里模型只负责“从自然语言里提取信息并排版成JSON”业务系统不再需要理解自然语言只处理JSON数据。好处很明显前端可以针对字段单独做展示和空状态处理。数据库表结构可以和JSON结构一一对应。同一份结构化数据可以生成多种简历模板。后续做筛选、搜索、导出PDF都方便。测试时可以直接校验字段类型和取值范围。一句话结构化输出不是“让输出更好看”而是让大模型结果真正接入业务系统。2. 让模型按JSON返回核心思路和接口设计2.1 结构化输出不是“加一句话”那么简单有些同学会写这样的Prompt“请用JSON格式返回”。然后发现模型偶尔返回Markdown代码块偶尔在JSON后面加一句“以上是整理结果”偶尔把字段名从jobTitle改成job_title。原因是模型对“JSON格式”的理解是概率性的它不是数据库不会天然遵守你的Schema。真正稳妥的思路有三层在Prompt里定义字段名、字段类型、是否必填和示例。在接口层尽量使用模型服务商提供的JSON Mode或结构化输出能力。在业务代码里做最终兜底包括清洗文本、容错解析、字段校验。不能只依赖任何一层。Prompt再详细模型也可能跑偏JSON Mode也不是所有模型服务都支持代码兜底则能处理剩余的不一致场景。2.2 定义简历信息的实体结构在设计实体类之前先想清楚简历助手到底需要哪些字段。一般可以这样划分模块字段类型说明基本信息namestring姓名基本信息jobTitlestring意向岗位基本信息yearsOfExperiencenumber工作年限技能skillsarray技能标签列表工作经历experiencesarray每段公司、职位、时间、成果教育经历educationListarray学校、专业、学历、时间个人总结summarystring简短自我介绍Java实体类可以这样设计public class ResumeProfile { private String name; private String jobTitle; private Integer yearsOfExperience; private ListString skills; private ListWorkExperience experiences; private ListEducation educationList; private String summary; // getter / setter 省略 } public class WorkExperience { private String company; private String position; private String startDate; private String endDate; private ListString achievements; } public class Education { private String school; private String major; private String degree; private String startDate; private String endDate; }这里要注意一个细节如果是Java Bean字段名尽量用统一的驼峰命名尤其是不要用大写字母开头。许多JSON库在序列化和反序列化时对“FName”这种大写开头字段的处理不一致容易出现字段变小写、解析不到值的问题。热词里提到的“Java Bean大写字母开头的变量JSON时就变成小写了”就是这个场景。建议直接用name、jobTitle这种标准命名必要时可以使用JsonProperty(jobTitle)显式指定。2.3 Prompt设计把约束写清楚实体类定义好后要把结构翻译成Prompt。我的做法是直接在Prompt里放一个JSON示例并把“只输出JSON、不要Markdown、不要解释”写到最前面。你是简历整理助手。请从用户提供的经历描述中提取以下结构 { name: 姓名未提供则为null, jobTitle: 意向岗位未提供则为null, yearsOfExperience: 6, skills: [Java, Spring Cloud, MySQL], experiences: [ { company: 公司名, position: 职位, startDate: 2020-01, endDate: 2023-06, achievements: [主要成果1, 主要成果2] } ], educationList: [ { school: 学校, major: 专业, degree: 本科, startDate: 2015-09, endDate: 2019-06 } ], summary: 不超过50字的个人总结 } 要求 1. 只输出JSON不要输出Markdown代码块。 2. 不要添加任何解释文字。 3. 字段名完全按照上面的定义。 4. 时间统一使用yyyy-MM格式信息缺失时使用null。 5. achievements必须是字符串数组不要使用纯文本或用序号拼接。 用户内容 {prompt}关键点在于“信息缺失时使用null”以及“achievements必须是字符串数组”。这两个约束能避免很多解析问题。2.4 通过API参数约束模型的输出格式如果模型服务商支持JSON Response Format建议在接口层直接开启。以兼容OpenAI格式的接口为例大致是这样client.chat.completions.create( model你使用的模型, messages[ {role: user, content: prompt} ], response_format{type: json_object}, temperature0.3 )如果使用的是Spring AI可以看它提供的Structured Output相关能力例如把输出描述为某个实体类由框架帮你反序列化。不同版本的API差异比较大具体以你依赖的版本和模型服务商文档为准不要在没确认版本的情况下直接抄。有一点要提醒JSON Mode不是所有场景都能用。有些服务要求Prompt里必须出现“json”字样有些服务对输出长度有限制有些模型在JSON Mode下仍然会漏字段。所以代码兜底仍然要做不能把接口参数当成银弹。3. 简历助手实战从用户输入到结构化结果3.1 最小闭环流程我第一次做这类项目时建议先把最小闭环跑通不要一上来就设计完整后端工程。最小闭环可以拆成六步用户提交一段原始经历文本。后端组装结构化输出Prompt。调用LLM接口拿到字符串返回。清洗返回内容去掉Markdown代码块或多余文字。用JSON解析库转成实体对象。校验必填字段和字段类型返回给前端或继续渲染。全程只需要一个接口、一个实体类、一个Service。3.2 后端调用代码示例用Spring Boot风格写一个简化版本Service public class ResumeService { private final ChatClient chatClient; private final ObjectMapper objectMapper new ObjectMapper(); public ResumeService(ChatClient chatClient) { this.chatClient chatClient; } public ResumeProfile buildProfile(String rawText) throws Exception { String prompt buildPrompt(rawText); String content chatClient.prompt(prompt).call().content(); String json normalizeJson(content); ResumeProfile profile objectMapper.readValue(json, ResumeProfile.class); validateProfile(profile); return profile; } private String buildPrompt(String rawText) { return 你是简历整理助手。请从用户提供的经历描述中提取JSON字段 只输出JSON不要输出Markdown代码块不要添加解释。 字段结构如下 { name: 姓名未提供则为null, jobTitle: 意向岗位未提供则为null, yearsOfExperience: 6, skills: [Java, Spring Cloud], experiences: [ { company: 公司名, position: 职位, startDate: 2020-01, endDate: 2023-06, achievements: [成果1, 成果2] } ], educationList: [], summary: 个人总结 } 时间统一使用yyyy-MM格式缺失时使用null。 用户内容 %s .formatted(rawText); } private String normalizeJson(String content) { String trimmed content.trim(); if (trimmed.startsWith(json)) { trimmed trimmed.substring(7); } else if (trimmed.startsWith()) { trimmed trimmed.substring(3); } if (trimmed.endsWith()) { trimmed trimmed.substring(0, trimmed.length() - 3); } return trimmed.trim(); } private void validateProfile(ResumeProfile profile) { if (profile.getName() null profile.getJobTitle() null) { throw new IllegalArgumentException(模型返回内容缺少关键字段); } if (profile.getExperiences() null) { profile.setExperiences(new ArrayList()); } if (profile.getSkills() null) { profile.setSkills(new ArrayList()); } } }这段代码的核心是normalizeJson。模型经常把JSON包在Markdown代码块里不处理就会出现“Expected BEGIN_OBJECT but was STRING”这类解析报错。如果不用Spring AI用Python直接调兼容OpenAI接口也可以import json import re def normalize_json(text: str) - str: text text.strip() if text.startswith(json): text text[7:] elif text.startswith(): text text[3:] if text.endswith(): text text[:-3] return text.strip() def parse_profile(text: str) - dict: normalized normalize_json(text) try: data json.loads(normalized) except json.JSONDecodeError as e: # 这里把原始内容打印出来方便排查 print(原始返回:, text) raise e if not isinstance(data.get(experiences), list): data[experiences] [] if not isinstance(data.get(skills), list): data[skills] [] return data3.3 校验返回结果和异常兜底校验模型返回时不要只判断“能不能被JSON解析”。更要关注业务字段是否合理。常见校验点包括时间字段是否满足yyyy-MM格式月份是否在1到12之间。工作年限是否大于等于0且是否是一个数字。experiences里的时间段是否有重叠结束时间是否早于开始时间。achievements数组里每条是否过短或过长。skills数组里是否有明显不是技能的内容。这些校验不是用来卡业务而是防止模型幻觉数据进入数据库。比如模型可能把一个“负责客服机器人开发”的人写成“负责AI大模型平台架构设计”格式上完全没问题但内容不符合真实输入。这种事实性错误很难从JSON层面拦截只能靠后续人工确认或增加“来源字段”来提示用户核对。更稳妥的做法是产品上增加一个“AI提取结果确认页”。用户提交原始经历后先把解析出来的JSON展示成表单让用户确认或修改再保存。这样既保留了LLM的效率也留了人工兜底。3.4 把结构化数据落到简历模板拿到ResumeProfile后前端渲染就很直接。工作经历部分可以遍历experiences技能部分遍历skills项目成果遍历achievements。如果某个字段为空前端显示“待补充”而不是整块空白。后端如果需要导出PDF也可以把同一个JSON结构映射到多种模板。简历助手的价值在于“一份数据多份简历”这个能力只有结构化输出能支撑。4. 实际落地时最容易踩的坑4.1 模型返回了Markdown代码块这是最频繁的问题。模型即使看到“只输出JSON”也可能把内容包装成{ name: 张三 }原因一方面是模型训练习惯另一方面是Prompt位置和强调程度不够。我的建议是服务端统一清洗而不是每次改Prompt。因为即使不是这个模型换一个模型服务商也可能会包装。清洗逻辑就是先去掉开头的json或再去掉结尾的再做一次trim。4.2 字段缺失和值变成null当用户输入的原始文本里没有公司、没有日期模型可能返回null。如果实体字段是基本类型比如int反序列化时可能报错或变成默认值0。所以字段类型建议用包装类型比如Integer并且要有默认值和空列表兜底。另一个情况是模型会把“未提及”和“不存在”搞混。Prompt里要明确写“未提供则为null”而不是让模型自己脑补。4.3 数组里混入说明文字模型可能返回achievements: [ 1. 负责订单系统开发, 2. 通过优化使接口性能提升 ]这还算正常。更麻烦的是模型直接把一段话当成一个数组元素achievements: [ 负责订单系统开发主要包含订单模块、支付模块同时处理线上问题通过优化缓存降低响应时间 ]这在格式上没错但业务上你得到的是一个没法拆分的长字符串。处理办法是把“achievements必须是字符串数组每条不超过30字不要带序号”写进Prompt同时在后端校验每个元素的长度。如果超过阈值可以提示“该条成果可能未拆分请人工确认”。4.4 中文和特殊字符转义问题用户输入里经常有换行、全角引号、反斜杠、表情符号。模型输出JSON时这些字符可能没有被正确转义导致json.loads或ObjectMapper报错。排查时先打印原始返回内容不要盯着解析异常堆栈猜。很多情况下是JSON字符串里混了不可见字符或者多了一个逗号。建议所有解析都使用成熟的JSON库不要自己用正则拼接。4.5 低配置和并发问题如果LLM是本地部署的批量生成简历时要注意显存和内存。低配置机器能跑单条不代表能并发跑我之前见过四张卡跑模型前端一开批量任务连续请求直接把单条响应时间拉到几十秒。我的经验是先跑单条看耗时和资源占用再决定要不要开并发批量任务一定要有队列不要一次性把任务全部打给模型服务。如果用的是外部API也要注意并发上限、超时时间和错误重试。常见做法是单条任务设置30到60秒超时失败后重试1到2次重试间隔递增仍然失败就写入失败任务表。4.6 实体类字段命名不一致Java Bean里如果字段名使用大写字母开头很多JSON库序列化时会变成小写导致解析不到值。比如public class Company { private String cName; }序列化后可能是cname而Prompt里要求的是cName两边对不上。这类问题不会每次都出现但一旦出现就很隐蔽因为编译不报错、启动不报错只是字段值全是null。处理方式是尽早统一命名或者用JsonProperty显式指定JSON字段名。5. 排查流程和验收标准5.1 出现JSON解析异常时按什么顺序排查不要一上来就改Prompt先按下面的顺序看看现象。是直接报JSON解析错还是能解析但字段全是null还是解析成功但业务数据不对。看原始返回。打印模型返回的字符串查看开头和结尾有没有Markdown代码块有没有多余解释。看字段类型。返回的是yearsOfExperience: 6还是6字符串和数字混用会导致反序列化失败。看字段名。jobTitle和job_title看起来相似但JSON库不会帮你自动对齐。看实体类。字段是不是基本类型有没有构造器嵌套类是否可实例化。这里最忌直接怀疑模型能力。很多“模型不听话”根本是输入输出边界没清理干净。5.2 结构化输出的验收标准我把简历助手的输出质量分成几个验收项检查项验收标准JSON可解析返回结果能直接被JSON库反序列化字段名稳定多次调用字段名一致不出现大小写变化字段类型稳定数字是数字数组是数组不存在字符串包数字必填字段校验关键信息缺失时能识别并提示时间格式统一都是yyyy-MM不存在多种格式混用数组语义正确achievements是独立条目不是一段长文本可重复性相同输入跑多次结构一致数值和日期波动可接受要特别注意“可重复性”。结构化输出不是要求模型每次都生成一模一样的内容而是要求字段结构、字段类型、枚举范围这些稳定。如果两次生成的yearsOfExperience一个是6一个是8这是模型对原始描述的理解差异不是格式问题但也需要通过Prompt和校验去控制。5.3 批量生成简历时的稳定策略批量场景一定要比单条场景更保守先拿3到5份样例数据跑通确认Prompt和解析逻辑。再拿几十份数据小批量验证观察成功率和失败原因。最后再上队列和并发并且限制最大并发数。批量任务还需要考虑输出命名。比如一次处理100份简历返回的JSON文件怎么命名、失败任务怎么标记、日志里怎么关联原始输入都要提前设计。否则任务一多你会分不清哪些成功、哪些失败、失败在哪一步。我自己会这样设计任务状态待处理、处理中、成功、失败、待人工确认。凡是字段校验不通过或解析两次都失败的任务都进入“待人工确认”不要把坏数据直接落库。6. 结构化输出的扩展场景6.1 文档信息抽取简历助手只是结构化输出的一个例子。同样的思路可以用于合同信息抽取、发票字段识别、公告信息整理、用户反馈分类。凡是“一段非结构化文本变成结构化字段”的需求都是这个套路定义Schema、写Prompt、调用LLM、清洗JSON、校验字段。6.2 数据清洗与入库很多团队用LLM做数据清洗比如从不同格式的Excel文本里抽取统一字段。LLM在这个场景里相当于一个可编程的转换层。输入是乱七八糟的人名、日期、地址输出是标准JSON记录再写入数据库。但一定要有一条规则LLM返回的JSON只能作为待确认数据不能直接作为最终数据使用。6.3 多Agent协作多个Agent协作时结构化输出价值更大。Agent A的结论直接作为Agent B的输入如果A返回的是大段文本B很难稳定理解。如果A返回的是{task: query_user, params: {userId: 123}}这种JSONB可以直接解析并触发下一步操作。这里的结构化输出是通信协议的一部分不是可选项。我见过一些项目把Agent之间的消息全部设计成Markdown文本结果每次链路一长上下文就乱。后来统一改成JSON协议字段增加或减少都通过Schema控制链路稳定性提升很明显。6.4 不要把JSON当成万能保障结构化输出解决的是“格式”问题不是“事实”问题。模型可能在字段齐全的JSON里编造公司、编造项目成果、编造量化数据。所以在简历助手这类项目里仍需要有原始内容对照、人工确认、来源标记和审计日志。把结构化输出理解为“数据契约”更准确。它让模型结果可以被程序处理但最终是否可信仍然需要业务层校验和人工兜底。简历助手这个项目真正落地时最值得盯住的不是模型能不能生成漂亮文案而是从模型返回字符串到业务系统拿到可用数据之间有没有一套稳定的解析、校验、兜底机制。先把单条任务跑稳再处理批量先把输出结构固定住再优化文案效果。JSON结构化输出看着只是格式调整实际上决定了这个项目能不能从“能演示”走到“能上线”。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →