540 次调用,结构通过率 100%。同一批样本,最简 prompt 的严格准确率只有 56.7%------模型不是不会判断,是不肯用你约定的词。
用 Spring AI 的 .entity() 把模型输出变成强类型对象,一行代码就能接上。但「能解析」和「内容对」是两件事。下面用情感分析接口 POST /api/analyze 把三种 prompt 写法、准确率和评测方法完整走一遍。
先给结论:
entity()生成的 JSON Schema 只保证形状,字段值是什么它管不了。- 同一批 30 条样本,三种写法(一句话 / 写规则 / 规则 + few-shot)的准确率是 57% / 83% / 97%,而结构通过率三者都是 100% 。P1 的 57% 里混着大量「词不合法」,按语义折算约 90%------两个数一起看才知道它差在哪。
- 规则型 prompt 里的兜底条款会被过度使用,模型倾向退回安全值。
- few-shot 要选边界样本,不要选典型样本。
- 单轮评测不可信,1 条样本翻转在 30 条样本上就是 3.3 个百分点。
实验环境
| 项 | 值 |
|---|---|
| JDK | 17 |
| Spring Boot | 4.1.1 |
| Spring AI | 2.0.1 |
| 模型 | deepseek-chat(兼容别名,实际路由 deepseek-flash) |
| temperature | 0.2(显式固定) |
| thinking | disabled |
| 样本量 | 30 条,正面 13 / 负面 9 / 中性 8 |
一、结构化输出的实现
1.1 DTO
src/main/java/.../dto/AnalyzeResult.java
下文路径省略包名,你的项目按实际包路径找。行号会随代码变动,这里全部以方法名定位。
arduino
public record AnalyzeResult(String topic, String sentiment, List<String> keywords) {
}
BeanOutputConverter 会反射这个 record,生成带 required 三个字段、additionalProperties: false 的 JSON Schema,拼进 prompt,并在响应后反序列化。
1.2 调用
ChatService#analyze()
arduino
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()
typescript
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);
}
}
版本由配置注入:
kotlin
@Value("${app.analyze.prompt-version:p3}") String promptVersion
对应 application.yml:
yaml
app:
analyze:
prompt-version: ${ANALYZE_PROMPT_VERSION:p3}
改环境变量就能切换 prompt,不用改代码、不用重新编译。下面三组对比全靠这个机制。
二、约束是分层的
| 层 | 手段 | 强度 | 失败表现 |
|---|---|---|---|
| 提示词 | 文字说明取值只能是三个词之一 | 软 | 输出 中性偏正面、mixed |
| schema | 字段类型 + required + additionalProperties: false |
中 | 字段缺失、类型不对被拦下 |
| 枚举 / 业务校验 | enum 类型、下游断言 |
强 | 非法值编译期或运行期直接失败 |
本例的 sentiment 是 String,所以第二层只保证「这里是个字符串」。这是后面所有问题的根源。
更稳的做法是把 sentiment 定义成 enum。
arduino
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 行为,没有参与评测。
json
// String 版
"sentiment" : { "type" : "string" }
// enum 版
"sentiment" : { "type" : "string", "enum" : [ "POSITIVE", "NEUTRAL", "NEGATIVE" ] }
之后 中性偏正面 不再是可解析值,会直接走 ModelOutputParseException → 502 那条路。
代价有三个,都不小:
- 指标含义变了。 结构通过率会从 100% 掉下来,掉多少取决于 prompt 写得多松。P1 那种写法在 enum 下会大面积解析失败------到那时「结构合格率」才第一次开始有区分度。
- 要一层映射。 下游要的是中文标签还是
POSITIVE,得自己写Sentiment → 业务值的转换。 - 断言还是不能省。 schema 约束的是模型输出的格式,不是你的业务规则。上线前的校验该留还得留。
这一轮故意不换 enum:换了之后 P1 的失败会从「语义失败」变成「结构失败」,三种 prompt 的严格准确率不再是同一量纲,prompt 变量的效果会被 schema 盖住。enum 只做 schema 对照,主实验保持 String------这样三种写法的差异才主要体现在提示词层。
三、三种写法
P1:极简零样本
src/main/resources/prompts/analyze-p1.txt 全文:
javascript
分析用户提供的文本,提取主题、情感倾向和关键词,用 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 全文:
markdown
你是文本分析助手。请分析用户提供的文本,输出:
- 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 基础上增加一段:
arduino
判断示例:
- "好。" → 正面
- "续航不错,拍照清晰,就是充电慢。" → 正面(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-shot | 10/10 |
v2 的动机是修好一条判错的样本:原文「键盘手感很好......缺点是风扇吵、重量大。总体来说适合办公,不适合经常背着出门」,模型判中性,人工标正面。
加了「存在明确总体结论时以结论为准」之后:
- 目标样本没修好------它的结论本身是转折句,内部仍有正负两面;
- 另一条极短文本「好。」被带偏,从正面变成中性,因为新规则让它去找「总体结论」,找不到就退回兜底。
规则之间会打架。这是转向 few-shot 的直接原因。
v1/v2/v3 三个 prompt 文件没有归档,只有实验记录,这轮无法重跑。P1/P2/P3 是后来重做的受控版本,prompt 文件完整保留。
五、评测设计
5.1 样本
scripts/analyze_samples.json,30 条,结构:
json
{
"id": "mix-1",
"text": "手机续航不错,拍照也清晰,就是充电速度有点慢。",
"expectedSentiment": "正面",
"rationale": "2 正 vs 1 负,规则 3"
}
每条必须带 rationale。没有依据的标注,和没有标注一样。
类分布:正面 13 / 负面 9 / 中性 8。多数类基线 43.3% ------一律猜「正面」的准确率。任何低于它的结果都不如什么都不做。
5.2 多次运行取众数
scripts/analyze_eval.py:
ini
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 卡点
csharp
MIN_SUCCESS_RATE = float(os.environ.get("MIN_SUCCESS_RATE", "0.9"))
结构成功率低于阈值时脚本退出码为 1,可以挂在 CI 上。结构崩了比准确率掉了更紧急。
5.5 复现
ini
# 分别以 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 条样本,完整跑两轮
| 指标 | P1 | P2 | P3 |
|---|---|---|---|
| 结构合格 | 90/90 | 90/90 | 90/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.88s | 0.86s | 0.84s |
每轮每版本 90 次调用(30 × RUNS=3),两轮共 540 次。结构合格在 540 次里零例外。
P1 严格口径 56.7% 仍高于多数类基线 43.3%,说明它的判断力在,问题在词不合法。
6.2 已见 vs 未见
原 10 条是 prompt 调过的样本,新增 20 条模型没见过:
| 子集 | P1 | P2 | P3 |
|---|---|---|---|
| 原 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 样本 | 多数票变化 |
|---|---|---|---|---|
| P1 | 27 | 0 | 3 | 1 |
| P2 | 26 | 0 | 4 | 2 |
| P3 | 27 | 0 | 3 | 1 |
三个版本各 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 条。当标注本身的不确定性能吃掉和被测差距同量级的分数时,这个对比就失去判定力。
实践上只有两条做法:
- 标注规范先于 prompt 对比,而且要细到「数量怎么数、跨层级怎么处理」这种程度,不能只写一句「比较数量和具体程度」;
- 报告结果时同时给出样本量和「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: false |
Spring AI 2.0.1 | SchemaDumpTest 打印实际输出 |
sentiment 为 String 时,schema 里只有 "type": "string" |
Spring AI 2.0.1 | 同上 |
换成 enum 后 schema 追加 "enum": [...] |
Spring AI 2.0.1 | 同上(对照用,未参与评测) |
JSON 解析失败抛 IllegalStateException |
Spring 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)、两轮原始输出都在同一个仓库里。