数据截至 (上游 commit daa7624a2755)
让输出可用:类型化解析与输入输出护栏
30 秒导读: 你声明了
Person extractPerson(String text),模型返回的却是一坨字符串。这一章讲 LangChain4j 怎么把字符串变回Person(两条路:让模型按 JSON schema 说话,或者在 prompt 里教它怎么说), 以及当它就是不听话时,怎么用护栏(guardrail)拦下来、重新问一遍。
1. 这是什么(零基础也能懂)
1.1 缝在哪
LLM 的 HTTP 接口只有一种返回:一段文本。而 Java 的方法签名要的是对象。
// 示意,非源码
interface Extractor {
@UserMessage("从这段话里提取人物信息:{{it}}")
Person extractPerson(String text); // 你要的是 Person
}
// 模型实际吐回来的可能是:好的!这是结果:{"name":"张三","age":30} 希望有帮助。
这中间隔着三件事:
| 缺口 | 具体表现 |
|---|---|
| 格式不确定 | 模型可能加寒暄、加 markdown 代码块、加解释 |
| 结构不确定 | 字段名写错、少字段、多包一层 |
| 内容不可信 | 编造数据、违反业务规则、被 prompt 注入 |
前两件靠类型化解析(structured output)解决,第三件靠护栏(guardrail)解决。这一章讲的就是这两套东西。
1.2 两个补法,一句话各说清
- 类型化解析 = 想办法让模型吐出能反序列化的 JSON,然后
Json.fromJson(...)变成对象。 - 护栏 = 在模型前后各加一道可编程检查;不合格时,输入侧直接拦,输出侧还能改写 prompt 重问。
1.3 用起来什么样
护栏是纯声明式的,挂在 AI Service 接口上(见 02-ai-services.md):
// 示意,非源码
interface Assistant {
@InputGuardrails(PromptInjectionDetector.class) // 进模型前查一遍
@OutputGuardrails(value = JsonMustParse.class, maxRetries = 3) // 出模型后查,失败最多重问 3 次
Person extractPerson(String text);
}
@OutputGuardrails 的 maxRetries 默认是 2(langchain4j-core/src/main/java/dev/langchain4j/guardrail/config/OutputGuardrailsConfig.java:18,常量 MAX_RETRIES_DEFAULT)。
1.4 一句话直觉
把这一章当作一条流水线上的质检站:进料口验一次(输入护栏)、加工时给模具(JSON schema)、出料口验一次(输出护栏)、最后按图纸切成零件(OutputParser)。
2. 顶层全景(一次调用里的五道关卡)
所有编排都发生在 DefaultAiServices 的动态代理里(见 02-ai-services.md)。从上往下读,这是一次同步调用的时间顺序:
aiService.extractPerson("...")
│
▼
① 入护栏 InputGuardrail 链
│ 不合格 → 直接抛 InputGuardrailException(没有重试)
▼
② 岔路口 模型声明支持 JSON schema 吗?
├────────── 是 ──────────┬────────── 否 ──────────┐
▼ │ ▼
③a 原生路径 │ ③b 降级路径
JsonSchemas 生成 JsonSchema │ 把「格式说明」文本拼进 UserMessage 尾部
塞进 ResponseFormat(type=JSON) │
└────────────┬───────────┴────────────────────────┘
▼
模型返回文本(可能夹带工具调用轮次)
▼
④ 出护栏 OutputGuardrail 链 —— 失败可 retry / reprompt,新回复重跑整条 链
▼
⑤ 解析 ServiceOutputParser → OutputParser → 你的 Person
部件职责表
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
DefaultAiServices | 五道关卡的总编排 | langchain4j/src/main/java/dev/langchain4j/service/DefaultAiServices.java |
JsonSchemas | 从 Java 类型生成原生 JsonSchema | langchain4j/src/main/java/dev/langchain4j/service/output/JsonSchemas.java |
ServiceOutputParser | 按返回类型分发:要 schema / 要格式说明 / 怎么解析 | langchain4j/src/main/java/dev/langchain4j/service/output/ServiceOutputParser.java |
DefaultOutputParserFactory | 21 个具体 OutputParser 的路由表 | langchain4j/src/main/java/dev/langchain4j/service/output/DefaultOutputParserFactory.java |
InputGuardrail / OutputGuardrail | 用户实现的检查规则 | langchain4j-core/src/main/java/dev/langchain4j/guardrail/ |
OutputGuardrailExecutor | 跑护栏链 + 重试重放循环 | langchain4j-core/src/main/java/dev/langchain4j/guardrail/OutputGuardrailExecutor.java |
GuardrailService | 把注解上的护栏类装配成每方法一个执行器 | langchain4j/src/main/java/dev/langchain4j/service/guardrail/ |
3. 岔路口:两条路怎么选
3.1 它要解决的小问题
有些模型(OpenAI、Gemini 等)原生支持"我给你一份 JSON schema,你必须严格按它输出";大部分老模型/开源模型不支持。框架不能只押一边。
3.2 判断依据只有一条:模型自己声明的能力
private boolean supportsJsonSchema() {
return context.chatModel != null
&& context.chatModel.supportedCapabilities().contains(RESPONSE_FORMAT_JSON_SCHEMA);
}
DefaultAiServices.java:518-521,符号 supportsJsonSchema。RESPONSE_FORMAT_JSON_SCHEMA 是 Capability 枚举唯一的一个值(langchain4j-core/src/main/java/dev/langchain4j/model/chat/Capability.java:11-20)——这个枚举就是为了这件事存在的,集成方在自己的 ChatModel 实现里声明它。
3.3 分岔的真实代码
boolean supportsJsonSchema = supportsJsonSchema();
Optional<JsonSchema> jsonSchema = Optional.empty();
boolean returnsImage = isImage(returnType);
if (supportsJsonSchema && !streaming && !returnsImage) {
jsonSchema = serviceOutputParser.jsonSchema(returnType);
}
if ((!supportsJsonSchema || jsonSchema.isEmpty()) && !streaming && !returnsImage) {
userMessage = appendOutputFormatInstructions(returnType, userMessage);
}
DefaultAiServices.java:251-260。这九行里藏了三个关键判断,逐条拆开看:
| 判断 | 含义 |
|---|---|
!streaming | 流式调用两条路都不走——既不下发 schema,也不拼格式说明 |
!returnsImage | 返回图片的方法不参与结构化输出 |
jsonSchema.isEmpty() | 模型支持 schema,但这个返回类型生成不出 schema 时,照样降级去拼文本 |
第三条是这段代码最容易被忽略的地方:两个 if 不是互斥的 else,而是"原生路子没走通就补降级路子"。
3.4 原生路径拿到 schema 后怎么用
ResponseFormat responseFormat = null;
if (supportsJsonSchema && jsonSchema.isPresent()) {
responseFormat = ResponseFormat.builder()
.type(JSON)
.jsonSchema(jsonSchema.get())
.build();
}
DefaultAiServices.java:309-315。ResponseFormat 只是个两字段的值对象,并且在构造时做了一致性校验:jsonSchema != null && type != JSON 直接抛 IllegalStateException(langchain4j-core/src/main/java/dev/langchain4j/model/chat/request/ResponseFormat.java:18-24)。它随 ChatRequestParameters 一路下发给各家集成,由集成自己翻译成厂商的请求字段。