结构化输出
大约 3 分钟
第十章:结构化输出:JSON 约束、解析兜底与重试
10.1 为什么“让模型输出 JSON”这么难
你会遇到这些典型失败:
- 模型输出了 Markdown 代码块 ```json
- JSON 前后夹杂自然语言解释
- 字段缺失、类型不对、引号不闭合
如果你不做兜底,业务就会随机炸掉;如果你做得太粗暴(比如直接字符串截取),又会引入新的 bug。
10.2 推荐的工程策略(从稳到强)
建议按这个梯度来:
- 强约束 prompt:只输出 JSON,不要多余文本
- 边界提取:只解析第一个
{...}或[...] - 失败修复重试:用“修复提示”再问一次
- Schema 约束(可选):当模型/provider 支持时,用 JSON Schema 更稳
本章给一个可直接复制的“稳健解析器”。
10.3 强约束 prompt(模板)
你是结构化抽取器。
只输出 JSON,不要输出多余文本,不要使用 Markdown 代码块。
字段要求:
- intent: string,可选值 REFUND|INVOICE|LOGISTICS|OTHER
- confidence: number,0~1
- keywords: string[]10.4 兜底解析:提取 JSON 边界 + Jackson 解析
package com.example.langchain4j.structured;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Map;
public class JsonExtractor {
private final ObjectMapper mapper = new ObjectMapper();
public Map<String, Object> extractAndParse(String text) {
String json = extractJson(text);
try {
return mapper.readValue(json, new TypeReference<>() {});
} catch (Exception ex) {
throw new IllegalArgumentException("JSON 解析失败,raw=" + safeSnippet(text), ex);
}
}
private String extractJson(String text) {
if (text == null) {
throw new IllegalArgumentException("text 不能为空");
}
int objStart = text.indexOf('{');
int arrStart = text.indexOf('[');
if (objStart == -1 && arrStart == -1) {
throw new IllegalArgumentException("未找到 JSON 起始符号");
}
if (objStart != -1 && (arrStart == -1 || objStart < arrStart)) {
int end = findMatching(text, objStart, '{', '}');
return text.substring(objStart, end + 1);
}
int end = findMatching(text, arrStart, '[', ']');
return text.substring(arrStart, end + 1);
}
private int findMatching(String text, int start, char open, char close) {
int depth = 0;
for (int i = start; i < text.length(); i++) {
char c = text.charAt(i);
if (c == open) {
depth++;
} else if (c == close) {
depth--;
if (depth == 0) {
return i;
}
}
}
throw new IllegalArgumentException("JSON 边界不闭合");
}
private String safeSnippet(String text) {
String trimmed = text.replaceAll("\\s+", " ").trim();
if (trimmed.length() <= 200) {
return trimmed;
}
return trimmed.substring(0, 200);
}
}单元测试示例(边界:带噪声、代码块、不闭合)
package com.example.langchain4j.structured;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.List;
import org.junit.jupiter.api.Test;
public class JsonExtractorTest {
@Test
void should_parseObject_when_textContainsNoise() {
JsonExtractor extractor = new JsonExtractor();
var map = extractor.extractAndParse("这里是解释 {\"intent\":\"REFUND\",\"confidence\":0.9,\"keywords\":[\"退款\"]} 结束");
assertEquals("REFUND", map.get("intent"));
}
@Test
void should_parseArray_when_textStartsWithArray() {
JsonExtractor extractor = new JsonExtractor();
var map = extractor.extractAndParse("{\"keywords\":[\"a\",\"b\"]}");
assertEquals(List.of("a", "b"), map.get("keywords"));
}
@Test
void should_throw_when_jsonNotClosed() {
JsonExtractor extractor = new JsonExtractor();
assertThrows(IllegalArgumentException.class, () -> extractor.extractAndParse("{\"a\":1"));
}
}10.5 失败修复重试(建议策略)
当解析失败时,不要无限重试。推荐只重试一次,并把失败原因反馈给模型:
你的输出无法解析为合法 JSON,原因:引号不闭合/字段缺失/类型错误。
请只输出修复后的 JSON,不要输出其他内容。10.6 本章小结
你现在可以把“结构化输出”做成工程可依赖的能力:强约束 + 解析兜底 + 单次修复重试。下一章我们进入企业落地最核心的能力:Embedding 与 RAG,把知识库接进来,显著减少幻觉。
