很多同学在接触大模型应用开发时,都会遇到一个绕不开的话题:结构化输出。尤其是最近 AI 简历助手、AI 文档解析、Agent 工具调用这类应用越来越火,大家会发现,同样是让大模型干活,有的人做出来的功能稳定可靠,有的人做出来的功能只能“看运气”。这个差距,很大程度上就来自于是否做好了结构化输出。
我在做简历解析助手这个功能时,就踩过不少坑。最开始是直接把一段候选人的工作经历文本丢给大模型,然后在提示词里写“请帮我提取姓名、电话、工作经历”,模型确实能返回内容,但返回格式非常不稳定——有时候是 Markdown 表格,有时候是一段带星号的文本,还有时候在 JSON 外面额外补一句“以下是提取结果”。为了兼容这些情况,解析层被迫写了一大堆补丁逻辑,代码又乱又难维护。
后来我把方案改成了 JSON 结构化输出,再配合实体类和校验逻辑,整个流程一下子变得清晰可控。这篇文章我会从“为什么需要结构化输出”讲起,解释 JSON 结构化输出的核心概念,然后带你完整实现一个“简历助手”项目实战,帮你理解实体类如何定义、提示词如何设计、返回结果如何验证,最后分享常见报错的排查思路和工程化建议。
本文适合两类读者:一类是刚接触大模型开发,想搞清楚结构化输出到底是怎么回事的新手;另一类是有一定开发经验,准备在项目中落地 LLM 信息抽取、文档解析、报表生成等功能的开发者。读完你不仅能理解结构化输出的原理,还能直接复制代码跑通一个简历解析助手。
1. 为什么需要结构化输出?
1.1 大模型原生输出的痛点
大模型本质上是一个文本生成模型,它接收文本,也输出文本。对于闲聊、问答、写文章这类场景来说,自然语言输出是最合适的。但当你需要把模型输出交给程序继续处理时,自然语言反而是最大的障碍。
举个很简单的例子,你想让模型从一段简历文本中提取出姓名和电话。模型可能输出:
候选人叫张三,联系电话是 13800138000,邮箱是 zhangsan@example.com。这段文字人类一眼就能看懂,但程序想要把“张三”和“13800138000”从这句话里准确切出来,就需要写正则、做关键词匹配,而且一旦模型换了一种表达方式,比如“这位候选人是张三,可以通过 13800138000 联系到他”,正则就很有可能匹配失败。
这就是大模型原生输出的三个典型痛点:
- 格式不统一,同一份输入每次返回的表达方式都可能不同;
- 经常夹带解释性文字,把数据混在自然语言里;
- 程序解析成本高,容易出错,且难以穷尽所有情况。
在项目开发中,我们希望模型“输出的内容能直接被程序消费”,也就是说,程序拿到结果后可以一行代码解析成对象,直接落库、渲染或者传给下游系统。结构化输出就是为了解决这个问题。
1.2 结构化输出是什么
结构化输出,简单来说,就是让大模型按照我们预先定义好的结构和格式来返回内容。最常见的形式是 JSON,也可以是 XML、YAML 或者一段符合特定模板的文本。
在 LLM 应用开发语境下,结构化输出通常包含两层含义:
第一层是“格式约束”,也就是要求模型只输出合法 JSON,不要输出解释性文字,不要使用 Markdown 代码块包裹。
第二层是“结构约束”,也就是我们提前定义好 JSON 里有哪些字段、每个字段的类型是什么、哪些是必填项,模型必须按照这个 Schema 来输出。
只做到第一层,模型偶尔还是会漏字段、填错类型;做到第二层,输出质量才会真正稳定。你可以把结构化输出理解为给大模型发了一张“填空题答题卡”,而不是让它自由发挥写作文。
1.3 典型应用场景
结构化输出在真实项目中的应用非常广,这里列出几个最常见的场景:
- 信息抽取:从简历、合同、发票、物流单等文本中提取关键字段,这是本文实战项目的核心场景;
- 数据转换:把一段非结构化的描述转换成标准格式,再写入数据库或发送给下游系统;
- Agent 工具调用:让模型决定调用哪个函数、传入什么参数,必须严格遵循工具定义的 JSON Schema;
- RAG 查询条件抽取:在知识库问答中,从用户问题里提取查询条件,转成结构化查询参数;
- 报表生成:让模型把统计数据转换成前端可以直接渲染的 JSON 结构。
你会发现,这些场景都有一个共同点:模型只是处理链路中的一环,处理结果要被后续程序继续使用。只要程序需要处理模型输出,结构化输出就不是“可选项”,而是“必选项”。
2. JSON 结构化输出的核心概念
2.1 JSON 格式基础
JSON(JavaScript Object Notation)是一种轻量级的数据交换格式,结构简单,几乎所有编程语言都内置了解析支持,所以它成了大模型结构化输出的事实标准。
一个 JSON 对象由键值对组成,键是字符串,值可以是字符串、数字、布尔值、数组、嵌套对象或者 null。比如:
{ "name": "张三", "age": 28, "skills": ["Java", "Spring Boot"], "address": { "city": "北京", "street": "朝阳区某街道" } }在 Java 中可以用 Jackson、Gson 解析,在 Python 中可以用json模块解析,前端更是原生支持。这也是为什么大家都选择 JSON 作为大模型输出的载体——解析成本几乎为零,且与业务对象可以非常方便地相互转换。
2.2 结构化输出的几种实现方式
在具体实现上,让大模型输出结构化内容主要有三种方式,它们的约束强度依次递增。
第一种是纯提示词约束。你在 system prompt 里写“请只输出 JSON,不要输出其他内容”,然后自己用JSON.parse或ObjectMapper解析。这种方式实现成本最低,任何模型都支持,但稳定性最差,模型偶尔还是会“不听话”,输出一些额外文字。适合原型验证和临时脚本。
第二种是开启 JSON Mode。OpenAI 的 API 支持response_format={"type": "json_object"}参数,强制模型输出合法 JSON;OpenAI 兼容接口的厂商大多也支持这个参数。这种方式能保证输出是合法 JSON,但不能保证 JSON 里面真的有你要的所有字段。
第三种是 Function Calling 或 Structured Output。模型根据你定义的函数参数 Schema 来输出结构化内容,平台会尽可能严格地按 Schema 校验结果。这是目前生产环境最推荐的方式。在 Spring AI 这类框架中,我们直接传一个实体类进去,框架会自动把实体类转换成 Schema 描述,并解析模型返回的内容。
下表对比了三种方式的差异:
| 方式 | 输出稳定性 | 字段约束 | 实现成本 | 推荐场景 |
|---|---|---|---|---|
| 纯提示词 | 较低 | 无 | 低 | 临时脚本、原型验证 |
| JSON Mode | 较高 | 无 | 低 | 信息抽取、数据转换 |
| Function Calling / Structured Output | 高 | 有 | 中 | 生产环境、Agent 工具调用 |
2.3 选型建议
在实际项目中,我的建议是:如果模型平台支持 JSON Mode 或 Structured Output,优先使用,不要把稳定性赌在提示词上。提示词仍然要写,但它是“兜底约束”,不是“唯一约束”。
另外要注意,“结构化输出”不等于“输出格式是 JSON 就行”。模型返回了合法 JSON,但字段名和你定义的不一致,或者数组元素类型不对,程序解析时一样会出错。所以在设计阶段就要把实体类、字段名、字段类型想清楚,这也是后面实战部分要重点讲的内容。
3. 项目实战:简历助手环境准备
3.1 项目需求与功能拆分
本文要实现的“简历助手”功能很简单:用户输入一段纯文本简历,系统调用大模型,自动提取出候选人姓名、联系方式、技能、工作经历、教育经历等信息,并输出结构化的 JSON 数据。
功能拆分如下:
- 接收原始简历文本;
- 调用大模型进行信息抽取;
- 按照预定义的实体类解析模型输出;
- 把解析结果返回给调用方。
整个项目不涉及数据库和前端,聚焦在“结构化输出”这条主线上,方便你只看核心逻辑。
3.2 技术栈与版本说明
本文示例使用 Java + Spring Boot + Spring AI 实现,这套组合在目前的企业级项目中很常见,而且 Spring AI 提供了比较完善的结构化输出支持。
版本说明如下:
- Java 17;
- Spring Boot 3.x;
- Spring AI 1.x(以你实际引入的版本为准);
- Maven 3.8+;
- 支持 OpenAI 兼容协议的大模型接口。
如果你的项目使用的是 Python 技术栈,也不用担心,我在后面会单独给出一段 Python + OpenAI SDK 的参考实现,原理完全一致。实际开发时版本需要根据你的项目情况调整,本文重点演示的是实现思路。
3.3 创建项目并引入依赖
先创建一个 Spring Boot 项目,并在pom.xml中引入 Web 和 Spring AI 的 OpenAI Starter 依赖:
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.3.5</version> <relativePath/> </parent> <groupId>com.example</groupId> <artifactId>resume-assistant</artifactId> <version>1.0.0</version> <name>resume-assistant</name> <properties> <java.version>17</java.version> <spring-ai.version>1.0.0</spring-ai.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> <version>${spring-ai.version}</version> </dependency> </dependencies> </project>Spring AI 的版本迭代比较快,不同版本间的 API 命名可能有差异,引入依赖时建议以官方 Maven 仓库中的最新稳定版本为准。
接着在application.yml中配置模型接口信息:
server: port: 8080 spring: ai: openai: base-url: https://api.openai.com api-key: ${OPENAI_API_KEY} chat: options: model: gpt-4o-mini temperature: 0.2这里需要说明几点:
api-key通过环境变量OPENAI_API_KEY注入,不要把密钥硬编码在配置文件里;- 如果你接入的是阿里云百炼、DeepSeek、智谱、Kimi 等平台,只要它们提供 OpenAI 兼容接口,就把
base-url换成对应地址即可; temperature设置为 0.2,是为了让模型输出更稳定,减少随机性,信息抽取类任务建议保持较低温度。
4. 定义输出契约:实体类设计
4.1 为什么先定义实体而不是先写提示词
很多初学者做结构化输出时,习惯先写提示词,告诉模型“你要输出姓名、电话、邮箱……”,然后再写解析逻辑。这个顺序其实是反的。
更推荐的做法是:先把实体类定义好,让实体类成为“输出契约”。理由有三个:
第一,实体类本身就是 Schema,字段名、字段类型一目了然,模型和程序共用同一份契约;
第二,使用 Spring AI 的entity()方法时,框架会读取实体类结构,自动生成格式说明注入到提示词中,你不需要在提示词里手写一遍 JSON 示例;
第三,实体类可以直接交给 Jackson 解析,模型输出和 Java 对象之间的映射关系完全由注解控制,出错时也容易排查。
4.2 简历实体类完整代码
在src/main/java/com/example/resumeassistant/model目录下创建三个实体类。
首先是简历主类Resume.java:
package com.example.resumeassistant.model; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; @JsonIgnoreProperties(ignoreUnknown = true) public record Resume( @JsonProperty("name") String name, @JsonProperty("phone") String phone, @JsonProperty("email") String email, @JsonProperty("years_of_experience") Integer yearsOfExperience, @JsonProperty("skills") List<String> skills, @JsonProperty("work_experience") List<WorkExperience> workExperience, @JsonProperty("education") List<Education> education ) { }然后是工作经历类WorkExperience.java:
package com.example.resumeassistant.model; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; @JsonIgnoreProperties(ignoreUnknown = true) public record WorkExperience( @JsonProperty("company") String company, @JsonProperty("title") String title, @JsonProperty("start_date") String startDate, @JsonProperty("end_date") String endDate, @JsonProperty("description") String description ) { }最后是教育经历类Education.java:
package com.example.resumeassistant.model; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; @JsonIgnoreProperties(ignoreUnknown = true) public record Education( @JsonProperty("school") String school, @JsonProperty("degree") String degree, @JsonProperty("major") String major, @JsonProperty("start_date") String startDate, @JsonProperty("end_date") String endDate ) { }这里使用 Java record 来定义实体类,代码非常简洁。如果你的项目还在使用传统 JavaBean,效果也是一样的,重点是每个字段上的@JsonProperty注解,它决定了模型输出的 JSON 字段名。
4.3 字段命名与 JSON 映射注意事项
在字段命名上,有一个很常见的坑:你的 Java 实体类字段名通常使用 camelCase,但模型输出的 JSON 字段名如果也使用 camelCase,在中文大模型场景下问题不大。不过,如果你希望 JSON 字段名使用 snake_case(下划线风格),就必须显式地用@JsonProperty声明。
举个例子,如果实体类里写:
public record WorkExperience( String company, String startDate ) { }Jackson 默认会把startDate序列化成startDate,但很多模型在训练数据里更习惯start_date这种表达。如果不对齐,模型输出的start_date就无法正确映射到 Java 字段上,解析结果会是 null。
另外一个和 Jackson 命名有关的经典问题是:如果 JavaBean 的字段以大写字母开头,比如URL、QRCode,Jackson 默认命名策略会把首字母小写,导致 JSON 里变成url、qrCode。遇到这种情况,同样是用@JsonProperty显式指定 JSON 字段名来解决。
所以我建议:字段命名策略一旦确定,就在实体类上用@JsonProperty写清楚,不要让框架猜测。
还有一点要注意,@JsonIgnoreProperties(ignoreUnknown = true)可以忽略模型输出中多出来的字段。模型有时候会自作主张多输出一个字段,如果不加这个注解,Jackson 解析时可能直接报错,加了之后就能安全忽略多余字段,增强兼容性。
5. 核心实现:提示词设计与调用 LLM
5.1 提示词设计
虽然有了实体类作为 Schema,提示词仍然需要精心设计。Spring AI 的entity()方法会自动把实体类格式说明注入到提示词里,但提取规则、缺失值处理、日期格式这些“业务语义”还是需要我们自己写清楚。
看一下本项目使用的 system prompt:
private static final String SYSTEM_PROMPT = """ 你是一名专业的简历解析助手。 用户会提供一段候选人简历文本,你需要从中提取以下字段: 姓名、手机号、邮箱、工作年限、技能列表、工作经历列表、教育经历列表。 要求: 1. 如果字段在原文本中不存在,统一填空字符串或空数组。 2. 只输出 JSON 本身,不要输出 Markdown 代码块,不要输出任何解释文字。 3. 日期格式统一为 yyyy-MM,例如 2020-07;如果只有年份,则补充为 yyyy-01。 """;这段提示词有几个关键点:
- 明确告诉模型“缺失字段填空字符串或空数组”,避免模型编造数据;
- 强调“只输出 JSON 本身”,这是和“纯提示词方案”之间的最后一道防线;
- 对日期格式做了归一化要求,方便下游存储和排序。
注意,System prompt 里不需要重复实体类的每个字段,因为 Spring AI 的entity()会帮我们把字段结构加入提示词。你只需要补充提取规则即可。
5.2 编写 ResumeAnalysisService
在src/main/java/com/example/resumeassistant/service目录下创建ResumeAnalysisService.java:
package com.example.resumeassistant.service; import com.example.resumeassistant.model.Resume; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.ai.chat.client.ChatClient; import org.springframework.stereotype.Service; @Service public class ResumeAnalysisService { private static final String SYSTEM_PROMPT = """ 你是一名专业的简历解析助手。 用户会提供一段候选人简历文本,你需要从中提取以下字段: 姓名、手机号、邮箱、工作年限、技能列表、工作经历列表、教育经历列表。 要求: 1. 如果字段在原文本中不存在,统一填空字符串或空数组。 2. 只输出 JSON 本身,不要输出 Markdown 代码块,不要输出任何解释文字。 3. 日期格式统一为 yyyy-MM,例如 2020-07;如果只有年份,则补充为 yyyy-01。 """; private final ChatClient chatClient; private final ObjectMapper objectMapper; public ResumeAnalysisService(ChatClient.Builder chatClientBuilder, ObjectMapper objectMapper) { this.chatClient = chatClientBuilder.build(); this.objectMapper = objectMapper; } public Resume analyze(String rawResumeText) { return chatClient.prompt() .system(SYSTEM_PROMPT) .user(rawResumeText) .call() .entity(Resume.class); } public String analyzeToJson(String rawResumeText) { Resume resume = analyze(rawResumeText); try { return objectMapper.writeValueAsString(resume); } catch (Exception e) { throw new IllegalStateException("序列化解析结果失败", e); } } }核心代码只有analyze方法这一小段。entity(Resume.class)是 Spring AI 结构化输出的关键方法,它会自动完成两件事:把Resume类的结构描述注入到提示词中,然后把模型返回的 JSON 反序列化成Resume对象。
这里有一个重要的设计细节:analyze返回的是类型安全的Resume对象,而不是一个字符串。这意味着业务代码里不需要再写任何 JSON 解析逻辑,直接调用对象上的 getter(对 record 来说是 accessor 方法)就能拿到数据。
5.3 接入控制器与运行入口
为了让接口可以被外部调用,我们再加一个简单的 REST 控制器。先定义一个请求体 DTO:
package com.example.resumeassistant.model; public record AnalyzeRequest(String text) { }然后创建ResumeAssistantController.java:
package com.example.resumeassistant.controller; import com.example.resumeassistant.model.AnalyzeRequest; import com.example.resumeassistant.model.Resume; import com.example.resumeassistant.service.ResumeAnalysisService; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/resume") public class ResumeAssistantController { private final ResumeAnalysisService resumeAnalysisService; public ResumeAssistantController(ResumeAnalysisService resumeAnalysisService) { this.resumeAnalysisService = resumeAnalysisService; } @PostMapping("/analyze") public Resume analyze(@RequestBody AnalyzeRequest request) { return resumeAnalysisService.analyze(request.text()); } }启动 Spring Boot 应用后,调用POST /api/resume/analyze,请求体是一个 JSON 对象,里面包含待解析的简历文本。
5.4 Python 参考实现
如果你的项目是 Python 技术栈,可以参考下面这段代码。它使用 OpenAI SDK,开启 JSON Mode,并手动解析结果:
import json from openai import OpenAI client = OpenAI() # 自动读取环境变量 OPENAI_API_KEY SYSTEM_PROMPT = """ 你是一名专业的简历解析助手。 用户会提供一段候选人简历文本,你需要从中提取指定字段。 如果字段在原文本中不存在,统一填空字符串或空数组。 只输出 JSON 本身,不要输出 Markdown 代码块,不要输出任何解释文字。 """ def analyze_resume(raw_text: str) -> dict: resp = client.chat.completions.create( model="gpt-4o-mini", temperature=0.2, response_format={"type": "json_object"}, messages=[ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": raw_text}, ], ) content = resp.choices[0].message.content return json.loads(content)两者思路完全一致:先定义输出契约,再设计提示词,最后解析成结构化数据。差别只在于 Java 用实体类定义 Schema,Python 用response_format参数约束输出为 JSON。
6. 运行与验证
6.1 准备测试输入
下面给出一段模拟的简历文本,方便你直接测试:
姓名:张三 手机:13800138000 邮箱:zhangsan@example.com 工作年限:5年 技能:Java、Spring Boot、MySQL、Redis、Kubernetes、Docker 工作经历: 2020.07 - 至今 某互联网科技有限公司 高级后端开发工程师 负责核心交易系统的设计与开发,主导订单服务重构,将接口性能提升 40%。 2017.06 - 2020.06 某某软件有限公司 后端开发工程师 参与企业内部 ERP 系统的开发和维护,负责库存模块与报表模块。 教育经历: 2013.09 - 2017.06 某某大学 本科 计算机科学与技术调用接口的方式如下:
curl -X POST http://localhost:8080/api/resume/analyze \ -H "Content-Type: application/json" \ -d '{"text":"姓名:张三\n手机:13800138000\n邮箱:zhangsan@example.com\n工作年限:5年\n技能:Java、Spring Boot、MySQL、Redis、Kubernetes、Docker\n\n工作经历:\n2020.07 - 至今 某互联网科技有限公司 高级后端开发工程师\n负责核心交易系统的设计与开发,主导订单服务重构,将接口性能提升 40%。\n2017.06 - 2020.06 某某软件有限公司 后端开发工程师\n参与企业内部 ERP 系统的开发和维护,负责库存模块与报表模块。\n\n教育经历:\n2013.09 - 2017.06 某某大学 本科 计算机科学与技术"}'6.2 预期 JSON 输出
正常情况下,接口会返回类似下面的 JSON:
{ "name": "张三", "phone": "13800138000", "email": "zhangsan@example.com", "years_of_experience": 5, "skills": ["Java", "Spring Boot", "MySQL", "Redis", "Kubernetes", "Docker"], "work_experience": [ { "company": "某互联网科技有限公司", "title": "高级后端开发工程师", "start_date": "2020-07", "end_date": "至今", "description": "负责核心交易系统的设计与开发,主导订单服务重构,将接口性能提升 40%。" }, { "company": "某某软件有限公司", "title": "后端开发工程师", "start_date": "2017-06", "end_date": "2020-06", "description": "参与企业内部 ERP 系统的开发和维护,负责库存模块与报表模块。" } ], "education": [ { "school": "某某大学", "degree": "本科", "major": "计算机科学与技术", "start_date": "2013-09", "end_date": "2017-06" } ] }这就是结构化输出的价值:程序收到这串 JSON 后,不需要任何额外解析,直接就能映射到实体对象,存数据库、渲染到前端都可以。
6.3 怎么判断解析质量
判断一次解析是否成功,不能只看“有没有返回 JSON”,还要看字段级质量。建议你做三件事:
第一,检查字段完整性。name、phone是简历的核心字段,缺失率应该接近 0,如果经常缺失,说明提示词或 Schema 设计有问题。
第二,检查字段类型。years_of_experience应该是整数而不是字符串“5年”,这也是为什么实体类里用Integer而不是String。
第三,构建一个包含 20 份不同格式简历的测试集,跑一遍统计每个字段的缺失率和错误率。数据比感觉可靠,如果你准备把简历助手做到生产环境,这一步不能省。
7. 常见问题与排查思路
7.1 高频错误一览
结合我自己的开发经验,结构化输出最常见的几类问题如下:
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 解析 JSON 时报错,提示 Content 不是合法 JSON | 模型输出了 Markdown 代码块,或夹带了说明文字 | 在提示词中强调“只输出 JSON 本身”;开启 JSON Mode;忽略外层 ```json 标记 |
| 某个字段经常为 null | 原文本没有该信息,或字段语义不明确 | 在提示词中规定缺失值处理方式;检查实体类字段名与提示词描述是否一致 |
| 返回字段名和实体类不一致 | 字段名大小写问题、命名策略不一致 | 统一使用@JsonProperty显式声明 JSON 字段名 |
| 数值字段被识别成字符串 | Schema 中未说明字段类型,或模型对文本理解有误 | 实体类中使用Integer、Long等强类型;在提示词中补充格式说明 |
| 中文乱码 | 请求响应编码不一致 | 确保消息请求和响应统一使用 UTF-8 编码 |
| 输出不稳定,相同输入返回不同结果 | temperature 设置过高 | 信息抽取类任务建议temperature设为 0 到 0.2 |
| JavaBean 大写字母开头的字段在 JSON 中变成小写 | Jackson 默认命名策略导致 | 通过@JsonProperty显式指定 JSON 字段名 |
7.2 排查清单
如果你遇到解析失败,可以按下面顺序排查:
- 先看模型原始返回内容。记住一定不要只记录解析失败后的异常信息,要把模型最原始的返回值打到日志里。很多时候,问题一眼就能看出来——返回了 Markdown、字段名不对、多了注释文字。
- 确认 JSON 字段名和实体类注解是否完全一致,特别注意大小写和下划线。
- 确认实体类字段类型是否合理。模型把“5年”识别成字符串,而实体类期望数字,解析就会失败。
- 确认是否开启了 JSON Mode 或 Structured Output。如果只是依赖提示词,稳定性会明显差很多。
- 检查 temperature。太高会让模型“自由发挥”,建议降到 0.2 以下。
8. 最佳实践与工程建议
8.1 输出 Schema 设计
实体类就是输出 Schema,设计时要注意几点:
字段要尽量扁平,不要过度嵌套,嵌套层级越多,模型出错的概率越高。一个招聘系统需要候选人技能列表,直接用List<String>就够,不要设计成List<Skill>加一堆子属性。
字段名要见名知义,避免用a、b、tmp这类无法理解的命名。因为模型会读取字段名来理解“要提取什么”,字段名越清晰,提取越准确。
给字段添加合理的类型约束。固定枚举值尽量用字符串,并可以在提示词里限定取值范围,比如“degree 只允许填写:高中、大专、本科、硕士、博士”。
8.2 提示词工程
不要因为用了 Structured Output 就忽略提示词。框架能保证格式,但不能保证业务规则。以下三条建议非常重要:
第一,把 system prompt 和 user 输入分开。不要让用户输入直接拼接进 system prompt,否则容易引入提示词注入风险。
第二,对缺失值做明确约定。你希望缺失时填空字符串还是 null?希望日期归一化成什么格式?这些都要写清楚,否则模型会自行发挥。
第三,在提示词里加一个“只输出 JSON 本身”的强调。即使有 JSON Mode 兜底,这句话也能减少模型输出说明文字的概率。
8.3 异常处理与重试
结构化输出不是 100% 可靠的,生产环境必须处理解析失败的情况。
解析失败时,建议先捕获异常,记录模型原始输出日志,然后重试一次。重试时可以稍微调整提示词,比如强调“不要输出代码块”。如果连续两次失败,就走人工兜底或返回友好错误提示,不要让调用方直接看到堆栈。
同时,对解析成功的结果也要做业务校验。比如电话字段要符合手机号格式,邮箱要包含@符号,必填字段不能为空。校验失败时同样要记录日志并处理。
8.4 安全与隐私
简历数据属于个人敏感信息,涉及隐私合规,生产环境中必须注意:
- 不要记录完整的原始简历文本日志,必要时对姓名、电话、邮箱做脱敏处理;
- 最小化存储,只保留业务真正需要的字段;
- 调用大模型时,如果使用的是第三方 API,要遵循最小化原则,避免传输与解析任务无关的敏感字段;
- 密钥通过环境变量或配置中心管理,严禁提交到代码仓库。
8.5 性能与成本
信息抽取类任务建议把temperature调低,这样得到稳定结果的概率更高,也减少了重试带来的额外成本。除此之外,还可以考虑以下优化:
- 如果模型支持更小的型号,先用小模型跑通流程,再评估是否需要升级;
- 对相同或相似的输入做缓存,避免重复调用;
- 精简 system prompt,减少输入 token;
- 在业务低峰期做批量解析,而不是实时逐个调用。
9. 下一步可以继续做的事
本文的简历助手只是一个最小可运行版本,跑通之后你可以沿着下面几个方向继续扩展。
首先是接入数据库。把解析出来的Resume对象落库,然后提供查询候选人列表、搜索技能匹配的接口,这就从一个解析工具变成了真正的简历管理系统。
其次是支持文件上传。把接口入参从纯文本改成 PDF、Word 简历文件上传,先用文件解析工具提取文本,再交给大模型做结构化抽取,这个能力在招聘系统里非常实用。
然后是增加多轮追问。如果解析结果缺失了手机号,可以基于已有的结构化结果,向用户追问“请补充手机号”,再做一次补全,而不是直接返回空值。
最后,你还可以把这段经验迁移到其他信息抽取场景,比如合同字段抽取、发票识别、工单解析。原理