21-结构化输出与业务校验
模型输出 JSON 就能入库吗?结构约束与业务校验
输出已经是合法 JSON,金额却为负,来源编号也不存在。这说明语法、结构和业务语义是不同层次。结构化输出让结果更容易处理,但不能把模型变成可信数据库写入者。
Spring AI 接入片段使用 Java 17、Spring Boot 3.5.3 与 Spring AI 1.1.8;协议与第三方平台示例另行标明适用范围。
1. 三种结构约束各有什么保证
提示、转换器与模型原生约束需要分别解释。
三种约束方式有什么区别?
| 方式 | 主要机制 | 仍需处理的问题 |
|---|---|---|
| 提示词要求 JSON | 在自然语言中描述格式 | 额外说明、缺字段、类型错误 |
| Output Converter | 生成格式说明并转换对象 | 转换失败、业务规则错误 |
| 提供方原生结构化输出 | 在模型 API 中提供结构约束 | 模型与 Schema 支持范围、拒绝、截断 |
BeanOutputConverter 可以根据 Java 类型生成结构说明,再进行反序列化。原生结构化功能需要对应提供方和模型支持,不能把任意 OpenAI 兼容接口都看成具有同样能力。Spring AI 1.1.8 输出转换
用明确类型表达结果
以技术文章标签为例,结果包含标题和标签数组,而不是任意 Map。下面的接入类使用Java 服务变慢怎样定位?从 CPU、GC 到内存保留链的依赖和 ChatClient,适用于 Spring AI 1.1.8。
package demo;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.springframework.ai.chat.client.ChatClient;
public final class ArticleTagger {
public record Tags(String title, List<String> tags) {}
private static final Set<String> ALLOWED = Set.of("Java", "Spring", "MySQL", "Redis", "AI");
private final ChatClient client;
public ArticleTagger(ChatClient client) {
this.client = client;
}
public Tags classify(String article) {
if (article == null || article.isBlank() || article.length() > 10_000) {
throw new IllegalArgumentException("invalid article");
}
Tags result = client.prompt()
.system("给输入文章生成简短标题,并从 Java、Spring、MySQL、Redis、AI 中选择一至三个标签。")
.user(article).call().entity(Tags.class);
validate(result);
return result;
}
public static void validate(Tags result) {
if (result == null || result.title() == null || result.title().isBlank()
|| result.title().length() > 60) {
throw new IllegalArgumentException("invalid title");
}
if (result.tags() == null || result.tags().isEmpty() || result.tags().size() > 3) {
throw new IllegalArgumentException("invalid tag count");
}
Set<String> seen = new HashSet<>();
for (String tag : result.tags()) {
if (tag == null || !ALLOWED.contains(tag) || !seen.add(tag)) {
throw new IllegalArgumentException("invalid or duplicate tag");
}
}
}
}
预期合法结果通过校验;标签不存在、重复或数量超限时拒绝。实际生成内容不固定。这里限制的是字符长度,生产还要计算 Token 与上下文预算。
entity 转换不是业务验证入口。即使使用校验注解,也需要明确调用 Validator,不能认为对象反序列化后自动完成所有约束。
2. 业务规则在解析以后执行
可解析仍要验证范围、枚举、权限与来源。
Schema 与业务规则的边界
Schema 可以表达类型、必填字段、枚举和部分取值范围;提供方可能只支持其中的子集。Java 的 primitive 字段还可能掩盖缺失,例如缺少数量字段被转换成默认零,因此关键字段应明确验证存在性。
跨字段关系通常还需业务检查:开始时间不能晚于结束时间,币种需要与金额单位一致,引用 ID 必须属于本次提供的证据,订单 ID 必须在当前用户范围内。
一个合法的“批准=true” JSON,也不能直接批准报销单。模型输出是建议或待验证数据,真正写入要经过身份、状态和业务授权。
截断、拒绝与解析失败要分别处理
达到输出长度上限可能留下半个 JSON;模型拒绝可能返回专门状态而非目标对象;网络中断可能让内容不完整。应保留足够元数据判断结束原因,而不是一律通过字符串补一个右括号继续入库。
可以在受控次数内要求模型修正格式,但重试需要费用预算,且不能顺便重复执行写工具。记录版本、错误类型和脱敏样本,才能区分 Schema 配置问题与偶发生成失败。
直接删除 Markdown 围栏或用正则“提取第一对大括号”容易误判嵌套对象和字符串内容。格式容错必须有明确边界,解析成功后仍要完整验证。
流式结构化结果怎么处理?
完整 JSON 在生成结束前通常还不完整。界面可以展示进度或经过约定的增量协议,但不应每收到一个片段就把它当成完整实体写入业务表。
聚合全文后解析需要控制最大响应体积,不能无限收集。若要增量生成多条记录,应定义每条记录的完成边界、编号和失败恢复规则,而不是仅把 stream 文本拆行。
3. 引用也需要结构和证据校验
把主张与来源连接起来,避免只检查存在一个引用。
让回答表达逐条主张与来源
可以要求模型返回状态和若干 claim,每条主张列出使用的 chunkId;真正显示的来源由服务端从本次证据映射生成。
{
"status": "ANSWERED",
"claims": [
{
"text": "演示规则允许七天内申请退货,但已激活数字商品除外。",
"sourceIds": ["guide-v1-0"]
}
]
}
这是虚构规则的协议示意。状态还可以有 INSUFFICIENT 和 NEEDS_CLARIFICATION;没有依据时不应为了凑结构填入假引用。
结构化输出帮助程序检查引用关联,仍不能自动证明语义上蕴含。模型可能引用一个存在的片段,却说出原文没有支持的结论。
一个最低限度的引用验证器
下面 Java 17 类只检查结构和来源集合,不执行语义事实判断。
import java.util.List;
import java.util.Set;
public final class CitationValidator {
public record Claim(String text, List<String> sourceIds) {}
public record Answer(String status, List<Claim> claims) {}
public static void validate(Answer answer, Set<String> suppliedEvidenceIds) {
if (answer == null || answer.claims() == null || suppliedEvidenceIds == null) {
throw new IllegalArgumentException("missing answer or evidence");
}
if (answer.status() == null || !Set.of("ANSWERED", "INSUFFICIENT", "NEEDS_CLARIFICATION")
.contains(answer.status())) {
throw new IllegalArgumentException("invalid status");
}
if (!"ANSWERED".equals(answer.status())) {
if (!answer.claims().isEmpty()) {
throw new IllegalArgumentException("non-answer must not include claims");
}
return;
}
if (answer.claims().isEmpty() || answer.claims().size() > 10) {
throw new IllegalArgumentException("invalid claims");
}
for (Claim claim : answer.claims()) {
if (claim == null || claim.text() == null || claim.text().isBlank()
|| claim.text().length() > 1000 || claim.sourceIds() == null
|| claim.sourceIds().isEmpty() || claim.sourceIds().size() > 5) {
throw new IllegalArgumentException("invalid claim");
}
for (String id : claim.sourceIds()) {
if (id == null || !suppliedEvidenceIds.contains(id)) {
throw new IllegalArgumentException("unknown citation");
}
}
}
}
}
预期引用本次证据之外的编号时拒绝;合法编号仅通过结构检查。生产可继续做规则校验、语义支持检查或人工审核,不能把 validate 成功命名为“事实已验证”。
非回答状态的澄清问题可以作为独立字段处理,也需要长度和内容校验。示例简化为不包含主张,避免把猜测混入拒答结果。
4. 设计判断与适用边界
先定义输出 DTO 与字段边界,再选择模型支持的约束方式。校验失败分为截断、拒绝、格式错误和业务不成立,处理方法不同。重试次数与费用受同一预算限制,不把反复要求重新输出当作无限修复机制。
写入前使用受控业务服务,模型没有直接 SQL 权限。引用标识存在不代表原文支持主张,关键金额、日期与例外需要对应证据。流式 JSON 在完整结束和校验之前,只作为未完成内容,不逐个片段提交业务事实。
5. 问答
问:合法 JSON 等于正确答案吗?
答:只证明表达可以解析,还需要结构、业务规则和事实依据校验。
问:entity 会自动执行业务校验吗?
答:不能这样假设。需要在转换后显式检查规则,必要时调用校验器。
问:有检索结果就不应该拒答吗?
答:结果可能只是主题相近,要看是否完整支持当前问题。
问:引用编号存在就证明主张成立吗?
答:只能证明引用属于候选集合,还需检查原文是否支持对应主张。