☰
LLM结构化输出实战:用JSON让大模型结果稳定接入业务系统
2026/10/6 3:10:45 网站建设 项目流程

用大模型做简历助手这类项目,最难处理的往往不是文案生成,而是模型返回的内容没法直接使用。你让模型写一段工作经历,它可能会给你一大段带标题、带冒号、带项目符号的混合文本,看起来没问题,但后续想根据公司、时间、成果分别渲染到简历模板里,就得重新解析,而且解析规则会越写越脆弱。这个问题在LLM大模型项目里非常典型,JSON结构化输出就是最直接的解法:让模型严格按照约定好的字段结构返回数据,调用方拿到结果后直接反序列化,不需要猜、不需要正则硬抠、也不需要人工二次整理。

这篇文章不是讲模型原理,也不是讲Prompt花活。我会按一个真实的“简历助手”项目落地顺序,把为什么需要JSON结构化输出、怎么设计实体类、怎么让模型按格式返回、返回之后怎么校验和兜底、实际踩坑时先查哪一层,全部拆开写一遍。适合正在做LLM项目、想把模型输出接入业务系统、或者第一次接触Spring AI结构化输出、前后端分离项目的开发同学。

1. 简历助手这类项目,为什么必须先解决输出格式问题

1.1 模型默认输出长什么样

先做一个最简单的测试。把一段用户粘贴过来的经历描述交给LLM,Prompt里只写“请帮我整理成简历内容”,模型大概率会返回这种内容:

张三,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。真正稳妥的思路有三层:

  1. 在Prompt里定义字段名、字段类型、是否必填和示例。
  2. 在接口层尽量使用模型服务商提供的JSON Mode或结构化输出能力。
  3. 在业务代码里做最终兜底,包括清洗文本、容错解析、字段校验。

不能只依赖任何一层。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 List<String> skills; private List<WorkExperience> experiences; private List<Education> educationList; private String summary; // getter / setter 省略 } public class WorkExperience { private String company; private String position; private String startDate; private String endDate; private List<String> 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"}, temperature=0.3 )

如果使用的是Spring AI,可以看它提供的Structured Output相关能力,例如把输出描述为某个实体类,由框架帮你反序列化。不同版本的API差异比较大,具体以你依赖的版本和模型服务商文档为准,不要在没确认版本的情况下直接抄。

有一点要提醒:JSON Mode不是所有场景都能用。有些服务要求Prompt里必须出现“json”字样,有些服务对输出长度有限制,有些模型在JSON Mode下仍然会漏字段。所以代码兜底仍然要做,不能把接口参数当成银弹。

3. 简历助手实战:从用户输入到结构化结果

3.1 最小闭环流程

我第一次做这类项目时,建议先把最小闭环跑通,不要一上来就设计完整后端工程。最小闭环可以拆成六步:

  1. 用户提交一段原始经历文本。
  2. 后端组装结构化输出Prompt。
  3. 调用LLM接口,拿到字符串返回。
  4. 清洗返回内容,去掉Markdown代码块或多余文字。
  5. 用JSON解析库转成实体对象。
  6. 校验必填字段和字段类型,返回给前端或继续渲染。

全程只需要一个接口、一个实体类、一个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 data

3.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,先按下面的顺序看:

  1. 看现象。是直接报JSON解析错,还是能解析但字段全是null,还是解析成功但业务数据不对。
  2. 看原始返回。打印模型返回的字符串,查看开头和结尾有没有Markdown代码块,有没有多余解释。
  3. 看字段类型。返回的是"yearsOfExperience": "6"还是6,字符串和数字混用会导致反序列化失败。
  4. 看字段名。jobTitle和job_title看起来相似,但JSON库不会帮你自动对齐。
  5. 看实体类。字段是不是基本类型,有没有构造器,嵌套类是否可实例化。

这里最忌直接怀疑模型能力。很多“模型不听话”根本是输入输出边界没清理干净。

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}}这种JSON,B可以直接解析并触发下一步操作。这里的结构化输出是通信协议的一部分,不是可选项。

我见过一些项目把Agent之间的消息全部设计成Markdown文本,结果每次链路一长,上下文就乱。后来统一改成JSON协议,字段增加或减少都通过Schema控制,链路稳定性提升很明显。

6.4 不要把JSON当成万能保障

结构化输出解决的是“格式”问题,不是“事实”问题。模型可能在字段齐全的JSON里编造公司、编造项目成果、编造量化数据。所以在简历助手这类项目里,仍需要有原始内容对照、人工确认、来源标记和审计日志。

把结构化输出理解为“数据契约”更准确。它让模型结果可以被程序处理,但最终是否可信,仍然需要业务层校验和人工兜底。

简历助手这个项目真正落地时,最值得盯住的不是模型能不能生成漂亮文案,而是从模型返回字符串到业务系统拿到可用数据之间,有没有一套稳定的解析、校验、兜底机制。先把单条任务跑稳,再处理批量;先把输出结构固定住,再优化文案效果。JSON结构化输出看着只是格式调整,实际上决定了这个项目能不能从“能演示”走到“能上线”。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询