Spring AI 结构化输出:结构 100% 通过,严格准确率只有 57%

0 阅读17分钟

540 次调用,结构通过率 100%。同一批样本,最简 prompt 的严格准确率只有 56.7%——模型不是不会判断,是不肯用你约定的词。

用 Spring AI 的 .entity() 把模型输出变成强类型对象,一行代码就能接上。但「能解析」和「内容对」是两件事。下面用情感分析接口 POST /api/analyze 把三种 prompt 写法、准确率和评测方法完整走一遍。

先给结论:

  1. entity() 生成的 JSON Schema 只保证形状,字段值是什么它管不了。
  2. 同一批 30 条样本,三种写法(一句话 / 写规则 / 规则 + few-shot)的准确率是 57% / 83% / 97%,而结构通过率三者都是 100% 。P1 的 57% 里混着大量「词不合法」,按语义折算约 90%——两个数一起看才知道它差在哪。
  3. 规则型 prompt 里的兜底条款会被过度使用,模型倾向退回安全值。
  4. few-shot 要选边界样本,不要选典型样本。
  5. 单轮评测不可信,1 条样本翻转在 30 条样本上就是 3.3 个百分点。

实验环境

项值
JDK17
Spring Boot4.1.1
Spring AI2.0.1
模型deepseek-chat(兼容别名,实际路由 deepseek-flash)
temperature0.2(显式固定)
thinkingdisabled
样本量30 条,正面 13 / 负面 9 / 中性 8

一、结构化输出的实现

1.1 DTO

src/main/java/.../dto/AnalyzeResult.java

下文路径省略包名,你的项目按实际包路径找。行号会随代码变动,这里全部以方法名定位。

public record AnalyzeResult(String topic, String sentiment, List<String> keywords) {
}

BeanOutputConverter 会反射这个 record,生成带 required 三个字段、additionalProperties: false 的 JSON Schema,拼进 prompt,并在响应后反序列化。

1.2 调用

ChatService#analyze()

public AnalyzeResult analyze(String text) {
    try {
        AnalyzeResult result = chatClient.prompt()
                .system(analyzeSystemPrompt)
                .user(text)
                .call()
                .entity(AnalyzeResult.class);
        if (result == null) {
            throw new ModelEmptyResponseException("模型返回空分析结果");
        }
        return result;
    } catch (IllegalStateException ex) {
        // BeanOutputConverter 在 JSON 解析失败时抛 IllegalStateException
        throw new ModelOutputParseException("模型输出无法解析为 JSON", ex);
    }
}

异常映射到 502,不向上游暴露原始报文。

本次 540 次调用里这条分支一次没触发过。 结构零失败,所以它在这轮实验里是纯防御性代码,没有实测证据。写在这里是因为接线上服务时要留着,不是因为它被验证过。

1.3 prompt 外置与版本切换

ChatService#loadPrompt()

private static String loadPrompt(String version) {
    String location = "prompts/analyze-" + version + ".txt";
    ClassPathResource resource = new ClassPathResource(location);
    if (!resource.exists()) {
        throw new IllegalStateException("找不到 prompt 文件: " + location);
    }
    try (InputStream input = resource.getInputStream()) {
        String prompt = StreamUtils.copyToString(input, StandardCharsets.UTF_8);
        if (!StringUtils.hasText(prompt)) {
            throw new IllegalStateException("prompt 文件为空: " + location);
        }
        return prompt;
    } catch (IOException ex) {
        throw new UncheckedIOException("读取 prompt 失败: " + location, ex);
    }
}

版本由配置注入:

@Value("${app.analyze.prompt-version:p3}") String promptVersion

对应 application.yml:

app:
  analyze:
    prompt-version: ${ANALYZE_PROMPT_VERSION:p3}

改环境变量就能切换 prompt,不用改代码、不用重新编译。下面三组对比全靠这个机制。

二、约束是分层的

层手段强度失败表现
提示词文字说明取值只能是三个词之一软输出 中性偏正面、mixed
schema字段类型 + required + additionalProperties: false中字段缺失、类型不对被拦下
枚举 / 业务校验enum 类型、下游断言强非法值编译期或运行期直接失败

本例的 sentiment 是 String,所以第二层只保证「这里是个字符串」。这是后面所有问题的根源。

更稳的做法是把 sentiment 定义成 enum。

public enum Sentiment { POSITIVE, NEUTRAL, NEGATIVE }
​
public record AnalyzeResult(String topic, Sentiment sentiment, List<String> keywords) {
}

Spring AI 2.0.1 生成的两版 schema,差别只有这一处(SchemaDumpTest 打印的实际输出)。主实验仍用 String 版;枚举版只用于验证 schema 行为,没有参与评测。

// String 版
"sentiment" : { "type" : "string" }
// enum 版
"sentiment" : { "type" : "string", "enum" : [ "POSITIVE", "NEUTRAL", "NEGATIVE" ] }

之后 中性偏正面 不再是可解析值,会直接走 ModelOutputParseException → 502 那条路。

代价有三个,都不小:

  1. 指标含义变了。 结构通过率会从 100% 掉下来,掉多少取决于 prompt 写得多松。P1 那种写法在 enum 下会大面积解析失败——到那时「结构合格率」才第一次开始有区分度。
  2. 要一层映射。 下游要的是中文标签还是 POSITIVE,得自己写 Sentiment → 业务值 的转换。
  3. 断言还是不能省。 schema 约束的是模型输出的格式,不是你的业务规则。上线前的校验该留还得留。

这一轮故意不换 enum:换了之后 P1 的失败会从「语义失败」变成「结构失败」,三种 prompt 的严格准确率不再是同一量纲,prompt 变量的效果会被 schema 盖住。enum 只做 schema 对照,主实验保持 String——这样三种写法的差异才主要体现在提示词层。

三、三种写法

P1:极简零样本

src/main/resources/prompts/analyze-p1.txt 全文:

分析用户提供的文本,提取主题、情感倾向和关键词,用 JSON 输出,字段为 topic、sentiment、keywords。

结果:结构 30/30,情感 17/30(56.7%)。

结构全过,因为形状是 schema 保的。但 sentiment 出现了 中性偏正面、mixed、negative、积极、困惑——都是字符串,schema 一概放行。

这个 56.7% 低估了 P1 的判断力。 13 条不一致里有 10 条,语义其实是对的,只是用了不合法的词:

P1 实际输出语义映射条数
中性偏正面正面5
中性偏负面负面1
mixed中性2
negative负面1
neutral中性1

按语义映射后重算,P1 是 27/30(90%)。

两套口径回答的是不同问题,必须一起看:

  • 严格口径(56.7%):输出能不能直接进下游程序 → 不能,字段值不可控
  • 宽松口径(90%):判断本身对不对 → 大部分是对的

这说明 P1 的问题不在判断,在不守约定的词。而 schema 层拦不住这个,因为 sentiment 是 String。要根治只能上 enum。

宽松口径的映射表由本文作者构造,未经独立复核,效力弱于严格口径。 它的作用只是把「判错」和「词不对」拆开,不能替代严格口径作为主指标。

P2:规则型

注意 P2 仍然是 zero-shot——一条示例都没给,只是把判定规则写清楚了。它和 P1 的差别在指令的精度,不在样本。

src/main/resources/prompts/analyze-p2.txt 全文:

你是文本分析助手。请分析用户提供的文本,输出:
- topic:一句话概括主题,不超过 20 字
- sentiment:只能是 正面 / 中性 / 负面。
  判定规则:
  1. 以整段文本的主要倾向为准,不以单个词为准;
  2. 正面描述明显多于负面描述时判正面,反之判负面;
  3. 正面与负面数量相当,或确实无法区分时判中性;
  4. 存在明确的单一倾向总体结论时以结论为准;结论本身含转折时回到规则 2;
  5. 极短文本按字面情感判断。
  sentiment 字段只填写 正面 / 中性 / 负面 三个词之一,不要附加括号或解释。
- keywords:最多 5 个,按重要性排序。
  只输出文本中出现过的名词性主题词;
  不要输出泛化词(如 店铺、东西、事情),
  不要输出评价词(如 好、贵、新鲜),不要重复。
只依据文本内容,不要编造。

结果:结构 30/30,情感 25/30(83.3%)。

自由文本消失,输出稳定落在三个合法值内。

但 5 条错误里有 3–4 条同向:明明是正面,判了中性。问题出在规则 3 的兜底——「确实无法区分时判中性」。规则覆盖不到的边界一旦出现,模型就退回这个安全值。

这不是随机噪声。两轮完整重跑都复现了同一个方向。

P3:规则 + few-shot

严格说是「带规则的 few-shot」——规则不是被替换,而是加了示例。src/main/resources/prompts/analyze-p3.txt 在 P2 基础上增加一段:

  判断示例:
  - "好。" → 正面
  - "续航不错,拍照清晰,就是充电慢。" → 正面(2 正 vs 1 负)
  - "键盘好、屏幕准、续航长,但风扇吵、偏重。" → 正面(3 正 vs 2 负)
  - "天气不错,但交通很糟。" → 中性(1 正 vs 1 负)

结果:结构 30/30,情感 29/30(96.7%)。

四条示例全是边界样本,后三条用括号标出正负计数,把判定方法演示出来,而不是再写一条抽象规则。

示例要选边界,不要选典型。 典型的正面评价,模型本来就会判,写进 prompt 只是消耗 token。

四、为什么是 few-shot 而不是继续加规则

在 P 系列之前还有一轮独立实验,同样是 10 条样本,改了三次:

版本改动情感一致
v1抽象规则9/10
v2加「总体结论优先」7/10(回退 2 条)
v3改为 few-shot10/10

v2 的动机是修好一条判错的样本:原文「键盘手感很好……缺点是风扇吵、重量大。总体来说适合办公,不适合经常背着出门」,模型判中性,人工标正面。

加了「存在明确总体结论时以结论为准」之后:

  • 目标样本没修好——它的结论本身是转折句,内部仍有正负两面;
  • 另一条极短文本「好。」被带偏,从正面变成中性,因为新规则让它去找「总体结论」,找不到就退回兜底。

规则之间会打架。这是转向 few-shot 的直接原因。

v1/v2/v3 三个 prompt 文件没有归档,只有实验记录,这轮无法重跑。P1/P2/P3 是后来重做的受控版本,prompt 文件完整保留。

五、评测设计

5.1 样本

scripts/analyze_samples.json,30 条,结构:

{
  "id": "mix-1",
  "text": "手机续航不错,拍照也清晰,就是充电速度有点慢。",
  "expectedSentiment": "正面",
  "rationale": "2 正 vs 1 负,规则 3"
}

每条必须带 rationale。没有依据的标注,和没有标注一样。

类分布:正面 13 / 负面 9 / 中性 8。多数类基线 43.3% ——一律猜「正面」的准确率。任何低于它的结果都不如什么都不做。

5.2 多次运行取众数

scripts/analyze_eval.py:

RUNS = max(1, int(os.environ.get("RUNS", "1")))

for _ in range(RUNS):
    sentiments.append(parsed["sentiment"])
top, top_count = majority(sentiments)      # 众数
consistency = top_count / len(sentiments)  # 内部一致率

温度设成 0.2 也不意味着确定性(采样、批处理、MoE 路由都会引入波动)。跑三次取众数,同时记录「三次里有几次一样」。

5.3 两个指标

指标算法回答什么
准确率多数判断 == 人工标注对不对
内部一致率众数次数 / 有效次数稳不稳

组合起来才有信息量:

一致率高一致率低
准确率高稳定且正确运气好,下次可能错
准确率低系统性偏差,可复现、好查任务太难或 prompt 太模糊

失败调用不计入一致率分母。所以「一致率 100% + 结构 27/30」意味着有 3 次压根没产出结果,两个数必须一起看。(本文 540 次调用零失败,这条从未触发。)

5.4 CI 卡点

MIN_SUCCESS_RATE = float(os.environ.get("MIN_SUCCESS_RATE", "0.9"))

结构成功率低于阈值时脚本退出码为 1,可以挂在 CI 上。结构崩了比准确率掉了更紧急。

5.5 复现

# 分别以 p1 / p2 / p3 启动,每次重启后跑评测
MCP_ENABLED=false ANALYZE_PROMPT_VERSION=p1 DEEPSEEK_TEMPERATURE=0.2 \
  DEEPSEEK_THINKING=disabled ./mvnw spring-boot:run \
  -Dspring-boot.run.arguments=--server.port=8081

BASE_URL=http://127.0.0.1:8081 RUNS=3 python3 scripts/analyze_eval.py

用 8081 是为了不影响 8080 上的开发实例。

六、结果

6.1 30 条样本,完整跑两轮

指标P1P2P3
结构合格90/9090/9090/90
情感一致 · 第一轮17/30(56.7%)25/30(83.3%)29/30(96.7%)
情感一致 · 第二轮18/30(60.0%)25/30(83.3%)28/30(93.3%)
宽松一致(语义映射)27/30(90.0%)83.3%96.7% / 93.3%
平均内部一致率(30 条均值)94% / 92%96% / 98%97% / 99%
平均耗时0.88s0.86s0.84s

每轮每版本 90 次调用(30 × RUNS=3),两轮共 540 次。结构合格在 540 次里零例外。

P1 严格口径 56.7% 仍高于多数类基线 43.3%,说明它的判断力在,问题在词不合法。

6.2 已见 vs 未见

原 10 条是 prompt 调过的样本,新增 20 条模型没见过:

子集P1P2P3
原 10 条(两轮一致)50%80%100%
新增 20 条 · 第一轮60%85%95%
新增 20 条 · 第二轮65%85%90%

P3 在未见样本上有 1–2 条的下降,但样本量下不足以判定为过拟合。注意这两批样本由不同人、在不同日期撰写,难度分布未必一致,这个对比的效力有限。

6.3 一个跨版本的共性错误

30 条里有一条是 哈哈哈哈哈。

我标的是中性——纯语气词不指向任何评价对象。这个标签本身可以争,不少人会觉得「哈哈哈」就是好评。

但标签怎么定不影响下面这件事:三个版本、两轮、每轮三次调用,共 18 次调用,全部判成正面,无一例外。 P1 输出 积极,P2 和 P3 输出 正面。

没有一次犹豫,也没有一次给出「无法判断」。

这是本轮唯一零分歧的系统性错误。不管「哈哈哈哈哈」该标什么,三个版本都没有意识到这里可能压根没有语义——它们太急着给一个答案了。

七、两轮重跑的稳定性

下表为第一轮。第二轮的 3/3 样本数未单列,平均一致率见 6.1。

版本3/3 样本多数票变化非 3/3 样本多数票变化
P127031
P226042
P327031

三个版本各 30 条,合计 90 个「版本 × 样本」单元:三次调用完全一致的 80 个,重跑零翻转;三次不一致的 10 个,重跑后多数票结果变了 4 个(40%)。

两个指标要分清:三次全一致的单元占比是 80/90(88.9%),平均内部一致率是 94%/96%/97%——后者对每条样本按「众数次数 ÷ 3」计分再平均,2/3 的样本贡献 66.7%,不是 0。

这不是巧合。三次全一致说明样本落在模型的稳定区;三次不一致说明它本来就在边界上,那个多数票是勉强的。

实践建议:内部一致率不足 3/3 的样本要单独看。 它们最可能变成线上 badcase,比全局提高 RUNS 更划算。

八、两条方法学教训

8.1 先定标注,再调 prompt

第一轮实验里,同一条样本前后被标了两个不同结果。拿这种样本去比 prompt 版本,比出来的是标注噪声。

后来先写标注规范再重标,之前算出的 90% vs 70% 的差距,在统一口径下变成 8/10 vs 8/10——打平。

规范本身也有坑。规则写「比较正负描述的数量和具体程度」,但只把「数量」操作化了,「具体程度」留空。结果这条:

包装完好,物流两天就到了,就是东西比图上看的小一圈。

按 2 正 vs 1 负判正面。但包装、物流属于周边环节,「东西小一圈」属于核心属性,两者不在同一层级,做数量抵消没有依据。补一条「跨层级不抵消」之后,改判中性。

改判 1 条的影响:

标正面标中性
P1 第一轮16/30(53.3%)17/30(56.7%)
P2 第一轮24/30(80.0%)25/30(83.3%)
P3 第一轮28/30(93.3%)29/30(96.7%)

三个版本在同一轮里各 +1 条。因为它们在 mix-5 上判的都是中性——按旧标注算错,按新标注算对。

这是 8.2 那件事的极端形式:30 条样本上,1 条标注 = 3.3 个百分点。而版本之间的差距常常也就 3–4 条。当标注本身的不确定性能吃掉和被测差距同量级的分数时,这个对比就失去判定力。

实践上只有两条做法:

  1. 标注规范先于 prompt 对比,而且要细到「数量怎么数、跨层级怎么处理」这种程度,不能只写一句「比较数量和具体程度」;
  2. 报告结果时同时给出样本量和「1 条样本对应几个百分点」,让读者自己判断差多少才算差。

8.2 单轮结果不可信

同一 prompt、同一温度、同一批样本重跑,mix-1 两次分别给出「正面」和「中性」。

n=30 时,1 条翻转 = 3.3 个百分点。所以:

  • 看版本之间的排序,别看作差;
  • P3 > P2 的方向可信(两轮分别差 4 条和 3 条样本,大于观测到的标注噪声);
  • 但「好 10 个百分点」这种表述不可信。

九、三个不报错的配置坑

1. thinking.type 必须是嵌套属性。 写成扁平的 thinking: disabled,应用启动失败。

2. v4 系列默认开思考,temperature 直接失效。 要么显式设 DEEPSEEK_THINKING=disabled,要么不要指望温度能控制它。

3. 8080 端口上有开发实例时,评测必须换端口。 否则请求打到的是另一个 prompt 版本,结果全错且不报错(完整命令见 5.5)。

三个都不会给一条友好的错误提示。

十、版本相关行为一览

下面每一条都只在括号里那个版本上验证过。换版本先自己跑一遍最小复现,或者运行仓库里的 SchemaDumpTest,别直接套用。

行为版本依据
BeanOutputConverter 生成的 schema 含 required 与 additionalProperties: falseSpring AI 2.0.1SchemaDumpTest 打印实际输出
sentiment 为 String 时,schema 里只有 "type": "string"Spring AI 2.0.1同上
换成 enum 后 schema 追加 "enum": [...]Spring AI 2.0.1同上(对照用,未参与评测)
JSON 解析失败抛 IllegalStateExceptionSpring AI 2.0.1代码分支实测;本次 540 次调用未触发
thinking.type 必须是嵌套属性,扁平写法启动失败DeepSeek API,截至 2026-11启动失败复现
v4 系列默认开思考,temperature 失效DeepSeek API,截至 2026-11调温无效果

十一、局限

  • 样本量 30 条,单一模型、单一任务。 结论可以迁移的是方法,不是具体数字。
  • 标注不确定。 盲标复核显示,在 10 条边界样本上,两位标注者严格一致 8/10、宽松一致 9/10。这个量级的不确定性,足以让 30 条样本上 3 个百分点的差异失去意义。
  • v1/v2/v3 那轮不可复现。 prompt 文件未归档,只能引用记录。
  • 没有用 enum 约束 sentiment。 这是下一步要做的(把 String 换成 Sentiment 枚举),做了之后 P1 类的失败模式会变成结构失败而不是语义失败,指标口径要重新设计。

十二、总结

写法适用场景代价
P1 一句话探索、结果给人看字段值不可控,不能进下游程序
P2 规则下游是程序,需要合法值兜底规则会被滥用,边界判断偏保守
P3 规则 + 例子边界样本多,要求高准确率prompt 变长,维护成本高,可能记住例子而非方法

最重要的两条:schema 管形状,prompt 管语义;在比 prompt 之前,先比标注口径。

如果你们线上已经有防非法值的做法——enum、下游断言,还是别的——欢迎在评论区说说。enum 这条路我只验了 schema,没跑完整评测,很想知道实际用起来怎么样。


三个 prompt(src/main/resources/prompts/analyze-p{1,2,3}.txt)、评测脚本与样本(scripts/analyze_eval.py、scripts/analyze_samples.json)、两轮原始输出都在同一个仓库里。