跳到主要内容

数据截至 (上游 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);
}

@OutputGuardrailsmaxRetries 默认是 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 类型生成原生 JsonSchemalangchain4j/src/main/java/dev/langchain4j/service/output/JsonSchemas.java
ServiceOutputParser按返回类型分发:要 schema / 要格式说明 / 怎么解析langchain4j/src/main/java/dev/langchain4j/service/output/ServiceOutputParser.java
DefaultOutputParserFactory21 个具体 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,符号 supportsJsonSchemaRESPONSE_FORMAT_JSON_SCHEMACapability 枚举唯一的一个值(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-315ResponseFormat 只是个两字段的值对象,并且在构造时做了一致性校验:jsonSchema != null && type != JSON 直接抛 IllegalStateException(langchain4j-core/src/main/java/dev/langchain4j/model/chat/request/ResponseFormat.java:18-24)。它随 ChatRequestParameters 一路下发给各家集成,由集成自己翻译成厂商的请求字段。


4. 原生路径:从 Java 类型到 JsonSchema

4.1 入口:只有 POJO 才配拿 schema

public static Optional<JsonSchema> jsonSchemaFrom(Type returnType) {
if (!isPojo(returnType) || returnType == void.class) {
return Optional.empty();
}
...
}

langchain4j/src/main/java/dev/langchain4j/service/output/JsonSchemas.java:18-32,符号 jsonSchemaFrom

有意思的是 isPojo 的实现方式——它不做类型判断,而是问解析器工厂:

OutputParser<?> outputParser = new DefaultOutputParserFactory().get(rawClass, typeArgumentClass);
return outputParser instanceof PojoOutputParser;

JsonSchemas.java:50-51,符号 isPojo。"能不能生成 schema"和"用哪个解析器"共用同一张路由表,天然不会两边打架。

4.2 实际走的是 ServiceOutputParser

注意 DefaultAiServices 调的是 serviceOutputParser.jsonSchema(returnType),不是 JsonSchemas。它多做两件事:

  1. Result<T>:Result<List<String>> 先剥成 List<String>(ServiceOutputParser.java:75-78)。
  2. 短路一批"不需要 schema"的类型:StringAiMessageTokenStreamResponseMapvoid/Void(ServiceOutputParser.java:119-127,符号 schemaNotRequired)。

短路后返回 Optional.empty(),于是 §3.3 的第二个 if 命中,走去拼格式说明——而格式说明对这些类型同样返回空串(ServiceOutputParser.java:107-109)。净效果:返回 String 的方法,prompt 一个字都不会被污染。

4.3 schema 元素是一棵类型树

model/chat/request/json/ 下是一组 JsonSchemaElement 实现,拼出的就是标准 JSON Schema 的结构:

元素类型对应
JsonObjectSchema对象 + properties + required
JsonArraySchema数组 + items
JsonStringSchema / JsonIntegerSchema / JsonNumberSchema / JsonBooleanSchema标量
JsonEnumSchema枚举取值列表
JsonReferenceSchema$ref,用于递归类型
JsonAnyOfSchemaanyOf,用于多态(sealed / @JsonSubTypes)
JsonRawSchema原样透传一段 schema

真正做反射遍历的是 JsonSchemaElementUtils.jsonObjectOrReferenceSchemaFrom(langchain4j-core/src/main/java/dev/langchain4j/internal/JsonSchemaElementUtils.java:234)。它先往 visited 表里放一个 JsonReferenceSchema 去遍历字段——这是处理自引用类型(Person.friends: List<Person>)的标准做法:递归回来时命中 visited,拿到的是引用而不是无限展开。

4.4 @Description:给模型的字段说明

// 示意,非源码
record Person(
@Description("姓名,只要中文名") String name,
@Description("年龄,整数岁") int age
) {}

@Description 可以打在 字段类型 上(langchain4j-core/src/main/java/dev/langchain4j/model/output/structured/Description.java:13-15),值是 String[],多行会用空格连起来。

关键是两条路都读它:

  • 原生路径:JsonSchemaElementUtils.java:309(字段)与 :316(类型),符号 descriptionFrom,写进 schema 的 description
  • 降级路径:PojoOutputParser.java:121-128,符号 descriptionFor,拼进给模型看的伪 JSON 结构里。

所以你只标一次,两条路都受益。


5. 降级路径:把说明书塞进 prompt

5.1 思路

模型不认 schema,那就用自然语言告诉它格式,然后在解析时容错。这是"能力弱的模型也要能用"的现实妥协。

5.2 追加位置很讲究

List<Content> contents = new ArrayList<>(userMessage.contents());
boolean appended = false;
for (int i = contents.size() - 1; i >= 0; i--) {
if (contents.get(i) instanceof TextContent lastTextContent) {
String newText = lastTextContent.text() + outputFormatInstructions;
contents.set(i, TextContent.from(newText));
appended = true;
break;
}
}

DefaultAiServices.java:523-545,符号 appendOutputFormatInstructions

倒着找最后一个 TextContent 追加,而不是简单地 contents.add(...)。原因是多模态消息里图片/音频也是 Content;把格式说明单独 append 成一个新块,可能落在图片后面,顺序上不自然。找不到任何文本块时才退化成新增一块(:541-543)。

外层还统一加了前缀:

if (!formatInstructions.startsWith("\nYou must")) {
formatInstructions = "\nYou must answer strictly in the following format: " + formatInstructions;
}

ServiceOutputParser.java:113-115,符号 outputFormatInstructions。已经自带 \nYou must 开头的(比如枚举和 POJO)就不重复加。

5.3 格式说明长什么样

返回类型生成的说明出处
booleanone of [true, false]BooleanOutputParser.java:42-44
List<String>\nYou must put every item on a separate line.StringCollectionOutputParser.java:46-48
枚举\nYou must answer strictly with one of these enums: + 每行一个常量(带 @Description)EnumOutputParser.java:60-82
POJO\nYou must answer strictly in the following JSON format: {"name": (type: string), ...}PojoOutputParser.java:94-98

POJO 那份"伪 JSON"由 jsonStructure 递归生成(PojoOutputParser.java:100-119),字段类型被翻译成人话:java.time.LocalDateTimedate-time string (2023-12-31T23:59:59)(:169-181,符号 simpleTypeName)。

5.4 解析器路由表

拿到文本后,ServiceOutputParser.parseTextDefaultOutputParserFactory 要解析器。这张表的判断顺序是有讲究的——先看结构,再查静态表,最后兜底:

rawClass 是枚举? ──是→ EnumOutputParser
│否
rawClass == List? ──是→ 元素是枚举 → EnumListOutputParser
│ 元素是 String → StringListOutputParser
│ 其它 → PojoListOutputParser
│否
rawClass == Set? ──是→ (同上,Set 版三选一)
│否
静态 Map 里有? ──是→ Boolean/Byte/Short/Integer/Long/BigInteger/
│ Float/Double/BigDecimal/Date/LocalDate/
│否 LocalTime/LocalDateTime(共 13 个)
└────────────────→ PojoOutputParser(兜底)

依据:DefaultOutputParserFactory.java:19-51(静态表)与 :54-90(符号 get)。加上三个抽象集合基类,service/output/ 下共 21 个具体 OutputParser 实现。

5.5 容错抽取一:标量和集合的"两种写法"

ParsingUtils 处理的是一个很实际的问题:同一个返回类型,原生路径下模型会返回 {"value": "ACTIVE"}(因为 schema 把标量包了一层),降级路径下模型会直接返回 ACTIVE。解析器得同时认。

private static boolean isJson(String text) {
return text.trim().startsWith("{");
}

ParsingUtils.java:105-112。就这一行判断走哪条分支:

  • 是 JSON → 反序列化成 Map,取 "value"(标量,:20-41)或 "values"(集合,:43-83)。
  • 不是 JSON → 标量直接解析;集合\n 切行,逐行解析(:74-82)。

坑: isJson 只认 { 开头。模型如果直接返回 ["A","B"] 这种 JSON 数组,会走到"按行切分"分支,然后每一行(包括 [])都被当成一个元素去解析。

失败时抛的 OutputParsingException 带了 base64:

return new OutputParsingException("Failed to parse %s (base64: %s) into %s".formatted(
quoted(text), quoted(toBase64(text)), type), cause);

ParsingUtils.java:130-133,符号 outputParsingException把原文 base64 一起写进异常——因为出错的文本常含不可见字符、错误的换行、BOM,直接看引号里的内容看不出问题。这是很实用的一个小设计。

5.6 容错抽取二:从垃圾话里捞出 JSON

PojoOutputParser 不直接 Json.fromJson,而是走 JsonParsingUtils.extractAndParseJson(langchain4j-core/src/main/java/dev/langchain4j/internal/JsonParsingUtils.java:19-49)。策略是:

  1. 先拿整段文本硬解一次,成功就返回(乐观路径,零开销)。
  2. 失败则从文本末尾开始,lastIndexOf('}') / lastIndexOf(']') 找结束位置(:51-55,符号 findJsonEnd)。
  3. 从结束位置往回做括号配平,找到匹配的开括号(:57-83,符号 findJsonStart)。
  4. 截出这一段试着解析;还失败就把搜索窗口左移,继续找上一个 JSON 块。
  5. 一个都找不到,抛出最初那次的异常(:33:38)——报错信息指向真正的问题,而不是"找不到 JSON"。

配平时会跳过字符串字面量里的括号,并且正确处理转义反斜杠(:63-72:85-93,符号 isEscaped)。这就是为什么下面这种夹带 markdown 围栏的回复也能被吃下去:

好的!结果如下:
```json
{"name": "张三", "age": 30}
```
希望对你有帮助。

5.7 枚举的临时补丁

private String trimAndRemoveBracketsIfPresent(String string) {
string = string.trim();
if (string.startsWith("[") && string.endsWith("]")) {
string = string.substring(1, string.length() - 1);
}
return string.trim();
}

EnumOutputParser.java:109-115。模型偶尔会返回 [ACTIVE] 而不是 ACTIVE。源码注释里明确说这是临时补丁,并挂了 issue 编号(依据:EnumOutputParser.java:103-108 的 javadoc,链接 issue #725)。这类"承认它是补丁"的注释,比装作没问题要健康得多。


6. 护栏体系:三种结局与两种重来

6.1 它要解决的小问题

解析成功 ≠ 结果可用。JSON 合法但内容违规、prompt 被注入、模型答非所问——这些需要业务侧可编程的检查,而且失败时最好能"再问一次"。

6.2 两个接口,一个是另一个的弱化版

InputGuardrailOutputGuardrail
校验对象UserMessageAiMessage / ChatResponse
可用结局success / successWith / failure / fatal上述四种 + retry + reprompt + *WithMessageRemoval
失败后直接抛 InputGuardrailException,没有重试可以重放模型调用

依据:langchain4j-core/src/main/java/dev/langchain4j/guardrail/InputGuardrail.java:19-119OutputGuardrail.java:14-257;"输入侧不支持 retry/reprompt"这一点在注解 javadoc 里写死了(langchain4j/src/main/java/dev/langchain4j/service/guardrail/InputGuardrails.java:17-20)。

两个接口都提供 validate(简单参数)validate(Request) 两个默认方法:后者能拿到 chat memory 和 RAG 增强结果,前者只拿消息。默认实现是"后者转调前者"(InputGuardrail.java:43-46),所以你只需要覆盖其中一个

6.3 结局的真实表示:枚举四态 + Failure 上的两个开关

这里容易被文档误导。GuardrailResult.Result 枚举其实是四个值(GuardrailResult.java:24-41):

枚举值含义
SUCCESS通过,不改动内容
SUCCESS_WITH_RESULT通过,但改写了内容(护栏可以做净化/脱敏)
FAILURE失败,但继续跑后面的护栏,失败会累积
FATAL失败,立即停止整条链

retryreprompt 不是枚举值,是记在 Failure 上的两个附加字段:

default OutputGuardrailResult retry(String message) {
return new OutputGuardrailResult(Arrays.asList(new OutputGuardrailResult.Failure(message, null, true)), true);
}
default OutputGuardrailResult reprompt(String message, String reprompt) {
return new OutputGuardrailResult(
Arrays.asList(new OutputGuardrailResult.Failure(message, null, true, reprompt)), true);
}

OutputGuardrail.java:162-164:188-191。两者都是 fatal=true,区别只在 Failure.reprompt 这个字符串有没有值:

  • retry() —— 原样再问一遍。
  • reprompt(msg, prompt) —— 追加一条新的 UserMessage 再问。

判定方法在 OutputGuardrailResult.java:139-142(isRetry)和 :166-175(getReprompt)。

还有第三组:failureWithMessageRemoval / fatalWithMessageRemoval(OutputGuardrail.java:217-257),额外要求把违规的那条 AiMessage 从 chat memory 里删掉,免得脏数据污染后续对话。

6.4 链的执行:累积、改写、遇 FATAL 就停

for (var guardrail : this.guardrails) {
var result = validate(accumulatedRequest, guardrail);
fireObservabilityEvent(...);
if (result.isFatal()) {
return handleFatalResult(accumulatedResult, result);
}
if (result.hasRewrittenResult()) {
accumulatedRequest = accumulatedRequest.withText(result.successfulText());
}
accumulatedResult = composeResult(accumulatedResult, result);
}

AbstractGuardrailExecutor.java:133-162,符号 executeGuardrails。三个要点:

  1. 护栏是串联的流水线——前一个改写过的文本会传给下一个(accumulatedRequest.withText(...))。
  2. FAILURE 不中断,composeResult 把多个失败原因合并成一个结果(:164-177)。
  3. 每个护栏都被计时并单独发一个事件(:141-146)。

护栏实现里抛出的任何异常都会被包成 GuardrailException(:98-107,符号 validate),不会以原始形态泄漏出去。

6.5 输出侧的重放循环

这是整套护栏最有价值的部分。从上往下是一次尝试的完整判断链:

┌──────────────────────────────────────────────┐
│ 跑完整条护栏链 │
│ │ │
│ 成功? ──是→ 返回结果 │
│ │否 │
│ 是 retry/reprompt? ──否→ 抛 OutputGuardrailException
│ │是 │
│ attempt+1 < maxRetries? ──否→ 抛(达上限) │
│ │是 │
│ 取 memory 消息 + 追加 reprompt 文本 │
│ chatExecutor.execute(messages) 拿新回复 ─────┘
└──────────────────────────────────────────────┘

OutputGuardrailExecutor.java:55-111,符号 execute。对照真实代码看两处细节:

var chatMessages = Optional.ofNullable(accumulatedRequest.requestParams().chatMemory())
.map(ChatMemory::messages)
.orElseGet(ArrayList::new);
result.getReprompt().map(UserMessage::from).ifPresent(chatMessages::add);
var response = accumulatedRequest.chatExecutor().execute(chatMessages);

OutputGuardrailExecutor.java:84-92

  • 重放消息不写回 memory。 源码注释直说了 "We don't want to add intermediary UserMessages to the memory"(:83)。中间那几轮失败的问答不该留在历史里。
  • 新回复要重跑整条链,不是只跑失败的那个护栏——while 循环回到顶部重新 executeGuardrails。注解 javadoc 也强调了这点(OutputGuardrails.java:31-34)。

maxRetries 的归一化在 :59-65:0 当作 1(即只跑一次不重试),负数回退到默认值 2。

重要边界: 上面第一行——没有配 ChatMemory 时,chatMessages 是一个空的 ArrayList。而 AbstractChatExecutor.execute(List) 会用这个列表整体替换原请求的消息(AbstractChatExecutor.java:44-48)。也就是说,无记忆的 AI Service 一旦触发 reprompt,重放出去的请求里只剩那条 reprompt 文本,原始问题没了。

6.6 收尾的两个修正

  • rewriteResult(:135-150):护栏链改写过文本、但最终结果只是普通 SUCCESS 时,把改写后的文本包成 SUCCESS_WITH_RESULT 返回,防止改写被丢掉。
  • handleFatalResult(:177-180):如果之前已经有护栏改写过结果,遇到 fatal 时调 result.blockRetry() —— 不允许在"已被改写"的基础上再重试。

6.7 ChatExecutor:重放机制的可移植性

重放需要"再调一次模型"的能力,但同步和流式的调用方式完全不同。ChatExecutor 就是这层抽象(langchain4j-core/src/main/java/dev/langchain4j/guardrail/ChatExecutor.java:20-54),只有两个方法:execute()execute(List<ChatMessage>)

实现怎么调模型出处
SynchronousChatExecutor直接 chatModel.chat(request)SynchronousChatExecutor.java:30-33
StreamingToSynchronousChatExecutor发起流式调用,用 CountDownLatch 阻塞等 onCompleteResponseStreamingToSynchronousChatExecutor.java:40-46 及内部类 StreamingToSyncResponseHandler

把流式塞回同步是关键一步:护栏必须看到完整回复才能判断,所以流式路径下重放时会退化成阻塞等待。共同的基类 AbstractChatExecutor 负责改消息、发 AiServiceRequestIssuedEvent 事件(AbstractChatExecutor.java:43-67)。

流式路径怎么保证"用户不会先看到违规内容"? 答案是缓冲:检测到有输出护栏时,onPartialResponse 把 token 攒进 responseBuffer 而不是往下发(langchain4j/src/main/java/dev/langchain4j/service/AiServiceStreamingResponseHandler.java:167-178);等护栏全过了,再把缓冲区一次性回放给用户(:431-440)。代价是流式的"逐字出现"效果没了


7. 护栏怎么挂到 AI Service 上

7.1 两个注解

注解可标位置参数
@InputGuardrails类 / 方法护栏类数组,按顺序执行
@OutputGuardrails类 / 方法护栏类数组 + maxRetries(默认 2)

依据:langchain4j/src/main/java/dev/langchain4j/service/guardrail/InputGuardrails.java:29-41OutputGuardrails.java:36-57

7.2 装配时机:构建 AI Service 时一次性算完

GuardrailServiceBuilder 遍历接口的每个方法,为每个方法算出一个 InputGuardrailExecutor 和一个 OutputGuardrailExecutor(GuardrailServiceBuilder.java:184-186)。优先级链条是:

方法上的注解 → 类上的注解 → builder 上代码配置的护栏 → 没有

依据:GuardrailServiceBuilder.java:268-285(输入侧)与 :287-303(输出侧),符号 computeInputGuardrailsForAiServiceMethod / computeOutputGuardrailsForAiServiceMethodmaxRetries 从注解读进 OutputGuardrailsConfig(:236-241)。

注解里写的是,实例由 ClassInstanceLoader.getClassInstance(guardrailClass) 创建(GuardrailServiceBuilder.java:215-217,符号 getGuardrailClassInstance)。这就是 Quarkus / Spring 扩展能把 CDI / Spring bean 注入成护栏的挂钩点。

7.3 运行时只剩查表

AbstractGuardrailService 拿两个 ConcurrentHashMap 存"方法 → 执行器",另外两个存"这个方法有没有护栏"的布尔缓存(AbstractGuardrailService.java:34-39:72-82)。DefaultAiServices 每次调用先问一句 hasInputGuardrails(method),没有就原样返回,零开销(DefaultAiServices.java:569-585,符号 invokeInputGuardrails;输出侧对应 :587-601,符号 invokeOutputGuardrails)。

7.4 输出护栏可以直接给出最终对象(跳过解析)

这是一条容易漏掉但很重要的通路。GuardrailService.executeGuardrails(method, OutputGuardrailRequest) 的返回类型是泛型 T,不是 ChatResponse:

default <MethodKey, T> T executeGuardrails(MethodKey method, OutputGuardrailRequest request) {
return executeOutputGuardrails(method, request).response(request);
}

GuardrailService.java:92-94。而 response(request) 优先返回护栏自己塞进来的对象:

public <T> T response(OutputGuardrailRequest request) {
return (T) Optional.ofNullable(successfulResult).orElseGet(() -> createResponse(request));
}

OutputGuardrailResult.java:207-209。回到 DefaultAiServices:

if (response != null) {
...
if (typeHasRawClass(returnType, response.getClass())) {
return fireEventAndReturn(invocationContext, response);
}
}
var parsedResponse = serviceOutputParser.parse((ChatResponse) response, returnType);

DefaultAiServices.java:424-434如果护栏返回的对象类型正好就是方法返回类型,直接返回,第 ⑤ 关的 OutputParser 根本不跑。 §8.1 的 JsonExtractorOutputGuardrail 就是靠这条通路把"校验"和"解析"合成一次动作。

7.5 带工具时的特殊处理:ToolAwareRepromptExecutor

问题很具体:reprompt 重新问模型,模型可能又要求调工具。这时候拿到的 AiMessage 里只有 tool call、没有正文——直接扔给下一个护栏,护栏会对着一段空文本做判断。

ChatResponse initialResponse = rawChatExecutor.execute(chatMessages);
if (!initialResponse.aiMessage().hasToolExecutionRequests()) {
return initialResponse;
}
return context.toolService
.executeInferenceAndToolsLoop(context, memoryId, initialResponse, parameters,
chatMessages, null, invocationContext, toolServiceContext, chatModelInvoker)
.aggregateResponse();

langchain4j/src/main/java/dev/langchain4j/service/ToolAwareRepromptExecutor.java:46-65,符号 wrap它是一个装饰器:把原始 ChatExecutor 包一层,重放时如果模型又要工具,就把 03-tool-calling.md 那套 round-trip 循环完整跑完,再把聚合后的最终回复交给护栏。

挂载点在 DefaultAiServices.java:408-416(同步路径)与 AiServiceStreamingResponseHandler.java:415-423(流式路径)。两条路径共用同一个包装器,差别只在传进去的 chatModelInvoker:同步传 context.chatModel::chat,流式传一个阻塞版的调用。


8. 两个内置护栏(照着学怎么写)

8.1 JsonExtractorOutputGuardrail:校验即解析

public OutputGuardrailResult validate(AiMessage responseFromLLM) {
var llmResponse = ensureNotNull(responseFromLLM, "responseFromLLM").text();
return deserialize(llmResponse)
.map(r -> successWith(r.json(), r.value()))
.orElseGet(() -> invokeInvalidJson(responseFromLLM, llmResponse));
}

langchain4j-guardrails/src/main/java/dev/langchain4j/guardrails/JsonExtractorOutputGuardrail.java:61-69

妙处在 successWith(r.json(), r.value()) 这两个参数:第一个是清洗后的 JSON 文本,第二个是已经反序列化好的对象。后者顺着 §7.4 那条通路直达方法返回值——校验和解析一次做完,不重复反序列化

反序列化复用了 §5.6 的 extractAndParseJson(:110-118,符号 deserialize),失败就 reprompt("Invalid JSON", "Make sure you return a valid JSON object following the specified format")(:71-74:30-36 的两个常量)。所有消息都是 protected 方法,方便子类覆盖成中文或领域特定的提示。

注意: dev.langchain4j.guardrail.JsonExtractorOutputGuardrail(core 模块里的那个)已在 1.9.0 标记 @Deprecated(forRemoval = true),要求改用 langchain4j-guardrails 模块(langchain4j-core/src/main/java/dev/langchain4j/guardrail/JsonExtractorOutputGuardrail.java:23-27)。两份代码目前完全一致。

8.2 MessageModeratorInputGuardrail:把审核模型接成护栏

public InputGuardrailResult validate(UserMessage userMessage) {
Response<Moderation> response = moderationModel.moderate(userMessage);
if (response.content().flagged()) {
return fatal("User message has been flagged",
new ModerationException("User message has been flagged", response.content()));
} else {
return success();
}
}

langchain4j-guardrails/src/main/java/dev/langchain4j/guardrails/MessageModeratorInputGuardrail.java:54-63。整个实现十行——这就是护栏接口设计得够窄的好处。

它和 @Moderate 注解走的是两套机制:@Moderate异步触发、和主调用并行(DefaultAiServices.java:548-559,符号 triggerModerationIfNeeded),这个护栏是同步阻塞、不通过就根本不发请求。


9. 护栏事件(可观测性)

每个护栏执行完都会发一个事件,含结果和耗时:

request.requestParams().aiservicelistenerregistrar()
.fireEvent(createEmptyObservabilityEventBuilderInstance()
.invocationContext(invocationContext)
.request(accumulatedRequest)
.result(result)
.guardrailClass(guardrail.getClass())
.guardrailName(guardrail.name())
.duration(duration)
.build());

AbstractGuardrailExecutor.java:119-131,符号 fireObservabilityEvent

事件类型是三层结构:

类型作用文件
GuardrailExecutedEvent<P,R,G>公共接口:request() / result() / guardrailClass() / guardrailName() / duration()langchain4j-core/src/main/java/dev/langchain4j/observability/api/event/GuardrailExecutedEvent.java:18-61
InputGuardrailExecutedEvent输入侧特化同目录 InputGuardrailExecutedEvent.java
OutputGuardrailExecutedEvent输出侧特化同目录 OutputGuardrailExecutedEvent.java:17-27

guardrailName() 是个默认方法,未显式设置时回落到 guardrailClass().getSimpleName()(GuardrailExecutedEvent.java:53-55)。

注意事件在每次尝试都会发,所以一次 reprompt 三轮的调用会看到多组事件——这正好让你能在监控里数出"平均重问几次"。


10. 巧妙之处(可以借鉴的)

  • 能力协商用枚举而不是布尔标志。 Capability.RESPONSE_FORMAT_JSON_SCHEMA 让 100+ 个集成各自声明支持什么,高层据此选路,不需要 if-else 罗列厂商名(Capability.java:11-20)。

  • "能不能生成 schema" 复用解析器路由表。 JsonSchemas.isPojo 通过 instanceof PojoOutputParser 判断,而不是自己写一套类型分类逻辑,两边永远一致(JsonSchemas.java:50-51)。

  • 两个 if 而不是 if-else 模型支持 schema、但类型生成不出 schema 时自动落到降级路径,不需要显式写第三种情况(DefaultAiServices.java:255-260)。

  • 异常里带 base64 原文。 解析失败时不可见字符是主要嫌疑人,直接看引号里的内容看不出来(ParsingUtils.java:130-133)。

  • JSON 抽取先乐观后回溯。 大多数时候整段就是合法 JSON,先硬解一次零成本;失败才启动从后往前的括号配平搜索,且报错时抛的是最初的异常(JsonParsingUtils.java:19-49)。

  • 护栏可以返回成品对象,不只是"通过/不通过"。 successWith(text, object) + response() 让校验顺手把解析做了(OutputGuardrailResult.java:207-209)。

  • 重放消息不入 memory。 中间的失败问答是实现细节,不该污染对话历史(OutputGuardrailExecutor.java:83-92)。

  • 装饰器解决工具与护栏的交叉问题。 ToolAwareRepromptExecutor 整个类不到 70 行,却让"reprompt 后又要调工具"这个组合场景自动正确(ToolAwareRepromptExecutor.java:31-67)。


11. 边界与局限

诚实地列出代码里能看到的限制:

  • 流式调用完全不做结构化输出。 !streaming 条件卡在两条路径前面(DefaultAiServices.java:255:258),流式方法既不下发 JSON schema 也不追加格式说明。

  • 流式 + 输出护栏 = 失去逐字流式体验。 token 会被缓冲到护栏通过后才一次性回放(AiServiceStreamingResponseHandler.java:167-178:431-440)。

  • List<POJO> 在不支持 schema 的模型上直接抛异常。 PojoCollectionOutputParser.formatInstructions()throw new IllegalStateException()(PojoCollectionOutputParser.java:68-71),而 PojoListOutputParser / PojoSetOutputParser 都没有覆盖它。这意味着 POJO 集合只有原生 schema 路径可走

  • isJson 只认 { 开头。 顶层 JSON 数组会走"按行切分"分支(ParsingUtils.java:105-112)。

  • ChatMemory 时 reprompt 会丢掉原始问题。 见 §6.5 的边界说明(OutputGuardrailExecutor.java:84-92 + AbstractChatExecutor.java:44-48)。

  • 输入护栏没有重试。 设计如此,注解 javadoc 明确写了失败直接抛给调用方(InputGuardrails.java:17-20)。

  • Map 返回类型两条路都不走。schemaNotRequired 名单里(ServiceOutputParser.java:119-127),既没有 schema 也没有格式说明,完全靠模型自觉。

  • 抽象类/接口作返回类型且无可发现子类型时报错。 PojoOutputParser.jsonSchema() 会抛 UnsupportedFeatureException,提示你把类型设成 sealed 或加 @JsonSubTypes(PojoOutputParser.java:79-85)。


12. 横向对比

关切LangChain4j 的取舍
结构化输出双路径,以模型能力为准自动切换;不像某些框架强制要求模型支持 function calling
解析容错大量投入(21 个解析器 + 括号配平抽取 + base64 诊断),因为它要兼容能力差异极大的 100+ 个集成(见 01-core-abstractions.md)
护栏形态声明式注解 + 编译期类型安全,而非配置文件或运行时字符串;和 Java 生态的习惯一致
重试位置重试逻辑放在护栏执行器里而不是模型客户端里,所以能带上业务语义(reprompt 文本)

护栏的"reprompt 后重跑整条链"和 agentic 编排里的循环收敛是两回事,不要混:前者是单次方法调用内部的纠错,后者是多智能体的编排层,见 06-agentic.md


13. 代码地图(导航索引)

结构化输出

主题文件路径符号名
双路径分岔langchain4j/src/main/java/dev/langchain4j/service/DefaultAiServices.javasupportsJsonSchema, appendOutputFormatInstructions
能力声明langchain4j-core/src/main/java/dev/langchain4j/model/chat/Capability.javaRESPONSE_FORMAT_JSON_SCHEMA
请求侧格式字段langchain4j-core/src/main/java/dev/langchain4j/model/chat/request/ResponseFormat.javaResponseFormat, ResponseFormatType
类型 → schema 入口langchain4j/src/main/java/dev/langchain4j/service/output/JsonSchemas.javajsonSchemaFrom, isPojo
反射生成 schemalangchain4j-core/src/main/java/dev/langchain4j/internal/JsonSchemaElementUtils.javajsonObjectOrReferenceSchemaFrom, descriptionFrom, polymorphicSchemaFrom
schema 元素类型langchain4j-core/src/main/java/dev/langchain4j/model/chat/request/json/JsonObjectSchema, JsonArraySchema, JsonEnumSchema, JsonReferenceSchema, JsonAnyOfSchema
字段说明注解langchain4j-core/src/main/java/dev/langchain4j/model/output/structured/Description.javaDescription
按返回类型分发langchain4j/src/main/java/dev/langchain4j/service/output/ServiceOutputParser.javaparse, jsonSchema, outputFormatInstructions, schemaNotRequired
解析器路由表langchain4j/src/main/java/dev/langchain4j/service/output/DefaultOutputParserFactory.javaget, OUTPUT_PARSERS
POJO 解析与说明生成langchain4j/src/main/java/dev/langchain4j/service/output/PojoOutputParser.javaparse, jsonSchema, jsonStructure, descriptionFor
枚举解析与补丁langchain4j/src/main/java/dev/langchain4j/service/output/EnumOutputParser.javaparseEnum, trimAndRemoveBracketsIfPresent, getEnumDescription
标量/集合两种写法langchain4j/src/main/java/dev/langchain4j/service/output/ParsingUtils.javaparseAsStringOrJson, isJson, outputParsingException
从垃圾话里捞 JSONlangchain4j-core/src/main/java/dev/langchain4j/internal/JsonParsingUtils.javaextractAndParseJson, findJsonStart, findJsonEnd, isEscaped

护栏

主题文件路径符号名
输入护栏接口langchain4j-core/src/main/java/dev/langchain4j/guardrail/InputGuardrail.javavalidate, success, failure, fatal
输出护栏接口langchain4j-core/src/main/java/dev/langchain4j/guardrail/OutputGuardrail.javavalidate, successWith, retry, reprompt, failureWithMessageRemoval
结果三/四态langchain4j-core/src/main/java/dev/langchain4j/guardrail/GuardrailResult.javaResult, isFatal, isSuccess, hasRewrittenResult, validatedBy
输出结果细节langchain4j-core/src/main/java/dev/langchain4j/guardrail/OutputGuardrailResult.javaisRetry, getReprompt, blockRetry, response, shouldRemoveViolatingMessage
链式执行基类langchain4j-core/src/main/java/dev/langchain4j/guardrail/AbstractGuardrailExecutor.javaexecuteGuardrails, composeResult, fireObservabilityEvent
输入执行器langchain4j-core/src/main/java/dev/langchain4j/guardrail/InputGuardrailExecutor.javaexecute
输出重放循环langchain4j-core/src/main/java/dev/langchain4j/guardrail/OutputGuardrailExecutor.javaexecute, rewriteResult, removeViolatingMessageIfRequested, MAX_RETRIES_MESSAGE_TEMPLATE
重试次数配置langchain4j-core/src/main/java/dev/langchain4j/guardrail/config/OutputGuardrailsConfig.javaMAX_RETRIES_DEFAULT, maxRetries
重放调用抽象langchain4j-core/src/main/java/dev/langchain4j/guardrail/ChatExecutor.javaexecute, builder
同步实现langchain4j-core/src/main/java/dev/langchain4j/guardrail/SynchronousChatExecutor.javaexecute
流式转同步langchain4j-core/src/main/java/dev/langchain4j/guardrail/StreamingToSynchronousChatExecutor.javaexecute, StreamingToSyncResponseHandler
挂载注解langchain4j/src/main/java/dev/langchain4j/service/guardrail/InputGuardrails.java / OutputGuardrails.javaInputGuardrails, OutputGuardrails, maxRetries
装配逻辑langchain4j/src/main/java/dev/langchain4j/service/guardrail/GuardrailServiceBuilder.javacomputeInputGuardrailsForAiServiceMethod, computeOutputGuardrailsForAiServiceMethod
运行时查表langchain4j/src/main/java/dev/langchain4j/service/guardrail/AbstractGuardrailService.javahasInputGuardrails, hasOutputGuardrails
服务门面langchain4j/src/main/java/dev/langchain4j/service/guardrail/GuardrailService.javaexecuteGuardrails, executeOutputGuardrails
默认实现langchain4j/src/main/java/dev/langchain4j/service/guardrail/DefaultGuardrailService.javaDefaultGuardrailService
工具感知重放langchain4j/src/main/java/dev/langchain4j/service/ToolAwareRepromptExecutor.javawrap
内置 JSON 护栏langchain4j-guardrails/src/main/java/dev/langchain4j/guardrails/JsonExtractorOutputGuardrail.javavalidate, deserialize, invokeInvalidJson
内置审核护栏langchain4j-guardrails/src/main/java/dev/langchain4j/guardrails/MessageModeratorInputGuardrail.javavalidate
事件公共接口langchain4j-core/src/main/java/dev/langchain4j/observability/api/event/GuardrailExecutedEvent.javaresult, guardrailName, duration
输入/输出事件.../observability/api/event/InputGuardrailExecutedEvent.java / OutputGuardrailExecutedEvent.javaInputGuardrailExecutedEvent, OutputGuardrailExecutedEvent
流式侧缓冲与护栏langchain4j/src/main/java/dev/langchain4j/service/AiServiceStreamingResponseHandler.javaonPartialResponse, hasOutputGuardrails