21-结构化输出与业务校验

O泡李华 5

模型输出 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 会自动执行业务校验吗?

答:不能这样假设。需要在转换后显式检查规则,必要时调用校验器。

问:有检索结果就不应该拒答吗?

答:结果可能只是主题相近,要看是否完整支持当前问题。

问:引用编号存在就证明主张成立吗?

答:只能证明引用属于候选集合,还需检查原文是否支持对应主张。

参考资料