MCP

何为MCP,对MCP有清晰的认识

MCP 起源于 2024 年 11 月 25 日 Anthropic 发布的文章:Introducing the Model Context Protocol

Model Context Protocol,也就是模型上下文协议,定义了应用程序和 AI 模型之间交换上下文信息的方式。这使得开发者能够以一致的方式将各种数据源、工具和功能连接到 AI 模型(一个中间协议层),就像 USB-C 让不同设备能够通过相同的接口连接一样。MCP 的目标是创建一个通用标准,使 AI 应用程序的开发和集成变得更加简单和统一。实际上,MCP 就是以更标准的方式让 LLM Chat 使用不同工具。

MCP 由三个核心组件构成:Host、Client 和 Server。

img
  • Host:Host是AI模型运行并与之交互的用户端应用程序。它是用户感知到的那个AI软件。基本上就是加载并运行大语言模型,Host 不直接实现具体的工具功能,它通过内置的Client来外包具体执行。
  • Client:Client是Host内部的一个模块,是Host与外部Server通信的唯一桥梁。它不是独立的进程,而是Host的一部分。它负责发现、连接和维持与一个或多个 MCP Server 的持久连接,当 Host 的模型决定调用某个工具时,Client 负责将工具调用请求精确地封装成MCP协议消息,发送给对应的Server。
  • Server:Server暴露出特定的能力供AI模型调用。是一个轻量级的、独立的服务进程,一般情况下它只做一件事。通过MCP协议标准,向外界公布自己提供的三类主要能力,当收到Client的调用请求时,Server执行真正的业务逻辑

这种架构设计使得 LLM 可以在不同场景下灵活调用各种工具和数据源,而开发者只需专注于开发对应的 MCP Server,无需关心 Host 和 Client 的实现细节。

那么,模型是如何确定工具的选用的?模型是在什么时候确定使用哪些工具的呢

当用户提出一个问题时:客户端将你的问题发送给 LLM。LLM 分析可用的工具,并决定使用哪一个或多个。客户端通过 MCP Server 执行所选的工具。工具的执行结果被送回给 LLM。LLM 结合执行结果构造最终的 prompt 并生成自然语言的回应。回应最终展示给用户!

所以这其中是有一个工具发现阶段的,Client 向每个 Server 发送 tools/list 请求,Server会返回它提供的所有工具列表,格式严格遵循协议定义。Client 收到所有工具列表后,会遍历每一个工具,并将它们转换为对人类(主要是LLM)可读的文本描述。

当用户发起提问时,真正的魔法就开始了。

Client会构造一个系统消息,就像下属内容的这样

“You are a helpful assistant with access to these tools: … Choose the appropriate tool based on the user’s question. If no tool is needed, reply directly.”

这个系统消息结合了所有工具的格式化描述,相当于一份可用工具目录和使用说明书。

也就是说,模型是通过 prompt engineering,即提供所有工具的结构化描述和 few-shot 的 example 来确定该使用哪些工具

一旦决定使用工具,模型会严格遵循系统消息中规定的格式,输出一个结构化的JSON对象。最后,工具执行的结果 result 会和 system prompt 和用户消息一起重新发送给模型,请求模型生成最终回复。

LangChain4j 中的 MCP

LangChain4j 支持 MCP,用于与兼容 MCP 的服务器通信,这些服务器可以提供并执行工具。

该协议规定了两种传输方式,LangChain4j 都支持:

  • 可流式 HTTP
    • 客户端发送 HTTP 请求,服务器可以返回常规响应,或者在需要持续发送多个响应时打开 SSE 流
  • stdio
    • 客户端可以作为本地子进程运行 MCP 服务器,并通过标准输入/输出与其通信。

这两种方式的对比如下

特性 Stdio Transport Streamable HTTP Transport
通信方式 标准输入/输出 HTTP 协议
进程模型 MCP Server 作为子进程 MCP Server 独立运行
适用场景 本地工具、CLI 工具 远程服务、微服务架构
网络要求 不需要网络 需要 HTTP 连接

LangChain4j 将 MCP 的三大核心组件依旧分离

要让你的聊天模型或 AI 服务运行 MCP 服务器提供的工具,需要创建一个 MCP 工具提供者实例,然后从 MCP 传输创建 MCP 客户端,再从客户端创建 MCP 工具提供者

MCP 协议的三个核心能力如下

能力 说明 示例
Tools(工具) LLM 可以调用的函数 add(1,2), echo("hello"), getWeather("北京")
Resources(资源) 只读数据源 文件内容、数据库记录、API 响应
Prompts(提示词模板) 预定义的提示词 代码审查模板、翻译模板这种

基本上,编写 MCP 的内容就是围绕着这四层架构来的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
┌─────────────────────────────────────────────────────────┐
│ AI Service 层 │
│ 定义聊天接口,自动处理工具调用循环(ReAct 模式) │
│ .toolProvider(mcpToolProvider) │
└────────────────────────┬────────────────────────────────┘

┌────────────────────────▼────────────────────────────────┐
│ Tool Provider 层 │
│ McpToolProvider 实现 ToolProvider 接口 │
│ - 每次请求时动态获取工具列表 │
│ - 支持工具过滤和名称映射 │
│ - 支持多 MCP Server 容错 │
└────────────────────────┬────────────────────────────────┘

┌────────────────────────▼────────────────────────────────┐
│ MCP Client 层 │
│ DefaultMcpClient — 管理 MCP 连接 │
│ - listTools():发现工具 │
│ - executeTool():执行工具 │
│ - listResources():列出资源 │
│ - listPrompts():列出提示词 │
└────────────────────────┬────────────────────────────────┘

┌────────────────────────▼────────────────────────────────┐
│ Transport 层(传输层) │
│ ┌─────────────────┐ ┌──────────────────────────┐ │
│ │ StdioMcpTransport│ │StreamableHttpMcpTransport│ │
│ │ (本地子进程) │ │ (HTTP 远程通信) │ │
│ │ 通过 stdin/stdout│ │ 通过 HTTP 协议 │ │
│ └─────────────────┘ └──────────────────────────┘ │

LangChain4j 中实现 MCP

首先,需要一个 MCP Transport 实例,我们使用 stdio,示例展示如何通过 NPM 包以子进程方式启动服务器:

1
2
3
4
5
6
7
8
9
10
11
@Bean
public McpTransport mcpTransport() {
boolean isWindows = System.getProperty("os.name").toLowerCase().contains("win");
String npxCommand = isWindows ? "npx.cmd" : "npx";

return new StdioMcpTransport.Builder()
.command(List.of(npxCommand, "-y", // 自动确认安装
"@modelcontextprotocol/server-everything"))
.logEvents(true) // 开启事件日志
.build();
}
  • StdioMcpTransport 会启动一个子进程运行 MCP Server,通过 stdin/stdout 进行 JSON-RPC 通信
  • .logEvents(true) 开启后可以在日志中看到完整的 MCP 协议交互

然后从 tranport 创建 MCP 客户端,这个客户端就是给 MCP 工具进行协议通信的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Bean
public McpClient mcpClient(McpTransport mcpTransport) {
McpClient client = new DefaultMcpClient.Builder()
.key("demo-mcp-server") // 客户端唯一标识
.transport(mcpTransport)
.build();

// 启动时列出所有可用工具
var tools = client.listTools();
for (var tool : tools) {
log.info("工具: {} —— {}", tool.name(), tool.description());
}
return client;
}
  • .key("demo-mcp-server") 为客户端设置唯一标识
  • listTools() 获取 MCP Server 提供的所有工具
  • 多个 McpClient 可以连接不同的 MCP Server

再从客户端创建 MCP 工具提供者:

1
2
3
4
5
6
7
@Bean
public ToolProvider mcpToolProvider(McpClient mcpClient) {
return McpToolProvider.builder()
.mcpClients(List.of(mcpClient)) // 绑定 MCP Client
.failIfOneServerFails(false) // 容错模式
.build();
}
  • McpToolProvider 实现了 ToolProvider 接口.mcpClients() 可以绑定多个 MCP Client

  • .failIfOneServerFails(false) 容错模式(默认),某个 Server 失败不中断

    一个 MCP 工具提供者可以同时使用多个客户端。在这种情况下,可以指定容错策略:

    1
    .builder().failIfOneServerFails(boolean)
    • 默认为 false → 忽略某个服务器的错误,继续使用其他服务器。
    • 设置为 true → 任意服务器出错都会导致抛出异常。
  • 支持 .filterToolNames(...) 过滤工具

    此外,MCP 服务器可能提供大量工具,而某个 AI 服务只需要少数几个。 可以使用工具过滤机制来避免调用不需要的工具,并减少幻觉风险:

    1
    2
    3
    4
    5
    6
    7
    @Bean("filteredMcpToolProvider")
    public ToolProvider filteredMcpToolProvider(McpClient mcpClient) {
    return McpToolProvider.builder()
    .mcpClients(List.of(mcpClient))
    .filterToolNames("echo", "add") // 只暴露这两个工具
    .build();
    }
  • 支持 .toolNameMapper(...) 自定义工具命名

然后,就是对 MCP 的内容绑定到 AI Service 中

1
2
3
4
5
6
7
8
@Bean
public McpAssistant mcpAssistant(ChatModel chatModel, ToolProvider mcpToolProvider) {
return AiServices.builder(McpAssistant.class)
.chatModel(chatModel)
.toolProvider(mcpToolProvider) // ← 使用 MCP ToolProvider
.chatMemoryProvider(...)
.build();
}

别看是 toolProvider 就认为实际上 mcp 和 tool 差不多,只不过,与 @Tool 方式的区别是

1
2
3
4
5
// @Tool 方式:直接绑定 Java 对象
.tools(calculator, weatherTool)

// MCP 方式:绑定 ToolProvider
.toolProvider(mcpToolProvider)

实际上,之前在 Tool Calling 那边提到的内容在这边依旧生效,返回 Result<T>,查看详细信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// AI Service 接口返回 Result<T>
public interface McpInspectorAssistant {
Result<String> chat(@MemoryId String sessionId, @UserMessage String message);
}

// 使用
Result<String> result = assistant.chat("session-1", "计算 1+1");
String answer = result.content(); // LLM 的最终回答
List<ToolExecution> tools = result.toolExecutions(); // 工具调用详情
for (var te : tools) {
System.out.println("调用了: " + te.request().name());
System.out.println("参数: " + te.request().arguments());
System.out.println("结果: " + te.result());
}

对于资源,资源的使用有两种方式:

  1. 编程式访问:直接调用 MCP 客户端方法。
  2. 自动暴露为工具:通过合成工具,让 LLM 自主调用。

MCP 协议还定义了服务器向客户端发送日志消息的方式。默认情况下,客户端会将这些日志转换为 SLF4J 日志。如果需要自定义,可以实现 dev.langchain4j.mcp.client.logging.McpLogMessageHandler,并在构建 MCP 客户端时传入

1
2
3
4
McpClient mcpClient = new DefaultMcpClient.Builder()
.transport(transport)
.logMessageHandler(new MyLogMessageHandler())
.build();

测试一下,就是这样的效果

image-20260731172847473

那么在控制台这边也可能看到 mcp 的调用 JSON

image-20260731173037249

除了高层 API,也可以通过底层 API 手动调用 MCP,这个其实和 Tool 那边的底层中的设计差不多。但是它有两种方式

1
2
3
4
5
6
7
8
9
10
11
// 方式 1: 通过 McpClient
var request = ToolExecutionRequest.builder()
.name("echo")
.arguments("{\"message\": \"Hello MCP!\"}")
.build();
String result = mcpClient.executeTool(request);

// 方式 2: 通过 McpToolExecutor
ToolSpecification spec = mcpClient.listTools().get(0);
McpToolExecutor executor = new McpToolExecutor(mcpClient, spec);
String result = executor.execute(request, memoryId);

而且对于 LangChain4j 对 MCP 工具的处理是有缓存的,是DefaultMcpClient 内部维护一个工具缓存。默认情况下,工具列表在首次获取后不会再次请求,除非服务器发通知更新。

1
2
3
4
5
McpClient mcpClient = new DefaultMcpClient.Builder()
.key("MyMCPClient")
.transport(transport)
.cacheToolList(false)
.build();

资源和提示词

资源是什么??提示词又是什么??

资源就是 MCP 的只读数据,由 MCP Server 通过 URI 提供,通过 URI 访问,类似文件系统

提示词就是对 MCP 也有提示词,一般是预定义的提示词模板,也能参数化

但是,他们的调用和 Tool 什么的不太一样, Tools 是 LLM 主动调用,而 Resources 和 Prompts 一般是 API 访问和通过合成工具让 LLM 调用,其中后者涉及到McpResourcesAsToolsPresenter 机制

实际上,工具,资源和提示词,这三者都可以通过 McpClient 进行编程式访问,而资源和工具还可以通过 McpToolProvider 自动暴露给LLM。

那么,编程式访问就是这样访问

image-20260805164842499

他们会返回 McpResourceMcpResourceTemplate 对象,包含 URI 等元数据。

  • 列出资源:client.listResources()
  • 列出资源模板:client.listResourceTemplates()

那么效果就是这样

image-20260805165014192

返回 McpResourceMcpResourceTemplate 对象,包含 URI 等元数据。

对于自动暴露为合成工具的内容,就是如果在构建 McpToolProvider 时设置了 McpResourcesAsToolsPresenter,工具提供者会自动增加两个合成工具,一般情况下,使用 LangChain4j 提供的默认实现 DefaultMcpResourcesAsToolsPresenter

当配置了 McpResourcesAsToolsPresenter McpToolProvider 会自动创建两个合成工具

image-20260805165805904

然后,注入到 McpToolProvider

1
2
3
4
5
6
7
8
@Bean
public ToolProvider mcpToolProvider(McpClient mcpClient,
McpResourcesAsToolsPresenter presenter) {
return McpToolProvider.builder()
.mcpClients(List.of(mcpClient))
.resourcesAsToolsPresenter(presenter) // ← 启用合成工具!
.build();
}

合成工具和 MCP 原生工具不同的是,合成工具由 McpToolProvider在每次请求时自动注入

那么,资源都讲完了,提示词也是类似,基本上,提示词的内容会更少一些,其中,需要获取服务器提示词列表client.listPrompts() ,返回 List<McpPrompt>,这个 API 是要用的最多的

提示词也可也进行编程式访问

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 列出所有提示词模板
var prompts = mcpClient.listPrompts();
for (var prompt : prompts) {
prompt.name(); // 提示词名称
prompt.description(); // 用途说明
prompt.arguments(); // 可接受的参数
for (var arg : prompt.arguments()) {
arg.name(); // 参数名
arg.description(); // 参数说明
arg.required(); // 是否必填
}
}

// 渲染提示词,可以传入名称和参数
Map<String, Object> args = Map.of("topic", "Java", "style", "detailed");
var promptResult = mcpClient.getPrompt("sampleLLM", args);

// 处理渲染后的消息
for (var msg : promptResult.messages()) {
McpRole role = msg.role(); // SYSTEM / USER / ASSISTANT

// McpPromptMessage.content() 返回单个 McpPromptContent
McpPromptContent content = msg.content();

if (content.toContent() instanceof TextContent text) {
System.out.println(text.text());
}

// 直接转为 LangChain4j 的 ChatMessage!
ChatMessage chatMsg = msg.toChatMessage();
// 这样就可以直接用于构建对话上下文
}
image-20260805170259121

效果基本上就是这样

image-20260805170331243

护栏机制 Guardrails

这其实是一个比较单独的内容,所以我放到了比较后面的地方来说

介绍护栏机制

Guardrails 是一种机制,可以让你验证 LLM(大语言模型)的输入和输出,确保其符合预期。

通过 Guardrails,你可以完成以下等操作:

  • 验证用户输入是否不在允许范围内
  • 确保输入在调用 LLM 前满足特定条件,例如设置敏感词这种
  • 确保输出格式正确(例如是符合正确模式的 JSON 文档)
  • 确保 LLM 输出符合业务规则和约束(例如,如果这是 X 公司的聊天机器人,回答中不能包含对竞争对手 Y 的引用)
  • 检测幻觉(hallucinations)

在 LangChain4j 中,Guardrail 本质上是一种:

对 AI Service 输入和输出进行拦截、检查、修改或者拒绝的机制。

它分为输入护栏和输出护栏

  • 输入 Guardrails 是在调用 LLM 之前执行的函数。如果输入 Guardrail 失败,将阻止 LLM 被调用。输入 Guardrails 是调用 LLM 之前的最后一步,且在任何 RAG 操作完成后执行。

基本情况上,就是在传统软件中,我们依靠参数校验,类型检查,权限控制,数据库约束等这种内容保证系统稳定。LangChain4j 的 Guardrails 就是用于构建这层控制机制。

理想情况下,Guardrail 的实现应遵循单一职责原则,即每个 Guardrail 类只验证一件事情。然后将多个 Guardrail 串联起来,以防护多个方面。

多个 Guardrail 会组成链,Guardrail 链中的顺序很重要。第一个失败的 Guardrail 会触发整体失败。应确保最容易捕获错误的 Guardrail 排在链的前面,而那些仅在极少情况下才会失败的 Guardrail 放在链的后面。

另外请记住,Guardrail 本身可以调用其他服务,甚至触发其他 LLM 交互。如果这些 Guardrail 执行有延迟或会带来额外的成本,请务必考虑这个因素。对于更昂贵的代价更大的 Guardrail,可以将其放在链的末尾。

实际上,Guardrail 不只是校验,其实更强。它可以:

  • 修改输入
  • 调用数据库
  • 调用另一个LLM
  • 幻觉检测 Guardrail

在 LangChain4j 中使用 Guardrail

在 LangChain4j 中,一个 Guardrail 通常实现 InputGuardrail,或者 OutputGuardrail 接口。分别编写输入和输出护栏。

输入 Guardrails

实现输入 Guardrails 需要实现 InputGuardrail 接口。该接口提供两种 validate 方法的变体

1
2
InputGuardrailResult validate(UserMessage userMessage);
InputGuardrailResult validate(InputGuardrailRequest params);
  • 第一种适用于简单的 Guardrail,或只需要访问 UserMessage 的场景。
  • 第二种适用于需要更多信息的复杂 Guardrail,对于其中的 InputGuardrailRequest,你可以理解为提供整个 AI Service 调用上下文。

然后,输入 Guardrail 的结果类型如下

结果 InputGuardrail 接口的辅助方法 描述
success success() - 输入有效 - 执行链中的下一个 Guardrail - 如果最后一个 Guardrail 通过,则调用 LLM
success with alternate result successWith(String) 类似 success,但用户消息会在继续下一步(下一个 Guardrail 或 LLM 调用)前被修改
failure failure(String)failure(String, Throwable) - 输入无效,但仍继续执行链中的其他 Guardrails 以收集所有验证问题 - LLM 不会被调用
fatal fatal(String)fatal(String, Throwable) - 输入无效,立即停止执行并抛出 InputGuardrailException - LLM 不会被调用

声明输入 Guardrails 的方式有以下几种,按优先级排序:

  1. 在 AiServices 构建器上直接设置 InputGuardrail 实现类或实例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public interface Assistant {
    String chat(String question);
    String doSomethingElse(String question);
    }

    var assistant = AiServices.builder(Assistant.class)
    .chatModel(chatModel)
    .inputGuardrails(new FirstInputGuardrail(), new SecondInputGuardrail())
    .build();
  2. 在单个 AI Service 方法上使用 @InputGuardrails 注解

    1
    2
    3
    4
    5
    6
    7
    8
    public interface Assistant {
    @InputGuardrails({ FirstInputGuardrail.class, SecondInputGuardrail.class })
    String chat(String question);

    String doSomethingElse(String question);
    }

    var assistant = AiServices.create(Assistant.class, chatModel);
  3. 在 AI Service 类上使用 @InputGuardrails 注解

    1
    2
    3
    4
    5
    6
    7
    @InputGuardrails({ FirstInputGuardrail.class, SecondInputGuardrail.class })
    public interface Assistant {
    String chat(String question);
    String doSomethingElse(String question);
    }

    var assistant = AiServices.create(Assistant.class, chatModel);
  • Input Guardrail 示例

    假设:我们开发一个 AI 客服。要求禁止用户输入一些可能有害的信息

    我们就可以定义这样的一个 Guardrail

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    public class SensitiveWordInputGuardrail implements InputGuardrail {

    private static final List<String> WORDS =
    List.of(
    "攻击",
    "破解",
    "病毒"
    );

    @Override
    public InputGuardrailResult validate(UserMessage userMessage) {

    String text = userMessage.singleText();

    for(String word : WORDS){
    if(text.contains(word)){
    return InputGuardrailResult.failure(
    "您的输入包含非法内容"
    );
    }
    }
    return InputGuardrailResult.success();
    }
    }
  • Output Guardrail 示例

    假设,你的公司 AI,禁止提及竞争公司 A,那么,你就需要对输出进行过滤

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public class CompetitorOutputGuardrail implements OutputGuardrail {
    @Override
    public OutputGuardrailResult validate(String response) {
    if(response.contains("竞争公司A")){
    return OutputGuardrailResult.failure(
    "输出包含禁止内容"
    );
    }
    return OutputGuardrailResult.success();
    }
    }

基本上,Guardrail 是会被 AI Service 集成的,如果你在 AI Service 中添加了这些护栏,它们就会被调用

纠正,Guardrails 只能作用于 AI Services,不能直接作用于 ChatModelStreamingChatModel

1
2
3
4
5
6
7
8
9
10
Assistant assistant =
AiServices.builder(Assistant.class)
.chatModel(model)
.inputGuardrails(
new SensitiveWordInputGuardrail()
)
.outputGuardrails(
new CompetitorOutputGuardrail()
)
.build();

输出 Guardrails

输出 Guardrails 是在 LLM 生成结果后执行的函数。如果输出 Guardrail 失败,可以支持更高级的操作,例如重试或重新提示,以改善响应。它们在所有其他操作(包括函数/工具调用)之后执行。

实现输出 Guardrails 需要实现OutputGuardrail接口。该接口提供两种 validate 方法,必须至少实现其中一种:

1
2
OutputGuardrailResult validate(AiMessage responseFromLLM);
OutputGuardrailResult validate(OutputGuardrailRequest params);

第一种适用于简单的 Guardrail,或只需访问生成的 AiMessage

第二种适用于需要更多上下文信息的复杂 Guardrail,例如完整的聊天响应、聊天记录、用户消息模板或传递给模板的变量。详细信息请参见 OutputGuardrailRequest

输出 Guardrail 的结果类型如下:

结果 OutputGuardrail 接口的辅助方法 描述
success success() - 输出有效 - 执行链中的下一个 Guardrail,如果最后一个通过,则将输出返回给调用方
success with rewrite successWith(String)successWith(String, Object) - 类似 success,但输出在原始形式下无效,需要被重写后再进行下一步 - 下一个 Guardrail 将基于重写后的输出执行。如果最后一个 Guardrail 通过,则返回修改后的输出
failure failure(String)failure(String, Throwable) - 输出无效,但继续执行链中的其他 Guardrail 以收集所有问题 - 最终会抛出 OutputGuardrailException
fatal fatal(String)fatal(String, Throwable) - 输出无效,立即停止执行并抛出 OutputGuardrailException
fatal with retry retry(String)retry(String, Throwable) - 类似 fatal,但会用相同的提示和聊天历史重新调用 LLM - 如果在可配置的重试次数后仍失败,则抛出 OutputGuardrailException - 如果重试后通过,Guardrail 链会从头重新执行
fatal with reprompt reprompt(String, String)reprompt(String, Throwable, String) - 类似 fatal with retry,但会用 Guardrail 提供的新提示重新调用 LLM - 新请求将附加额外的消息并保持原始聊天历史 - 如果在可配置的重试次数后仍失败,则抛出 OutputGuardrailException - 如果通过,Guardrail 链会从头重新执行

声明方式与输入 Guardrails 类似,优先级顺序如下:

  1. AiServices 构建器上直接设置 OutputGuardrail 实现类或实例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public interface Assistant {
    String chat(String question);
    String doSomethingElse(String question);
    }

    var assistant = AiServices.builder(Assistant.class)
    .chatModel(chatModel)
    .outputGuardrails(new FirstOutputGuardrail(), new SecondOutputGuardrail())
    .build();
  2. 在单个 AI Service 方法上使用 @OutputGuardrails 注解

    1
    2
    3
    4
    5
    6
    7
    8
    public interface Assistant {
    @OutputGuardrails({ FirstOutputGuardrail.class, SecondOutputGuardrail.class })
    String chat(String question);

    String doSomethingElse(String question);
    }

    var assistant = AiServices.create(Assistant.class, chatModel);
  3. 在 AI Service 类上使用 @OutputGuardrails 注解

    1
    2
    3
    4
    5
    6
    7
    @OutputGuardrails({ FirstOutputGuardrail.class, SecondOutputGuardrail.class })
    public interface Assistant {
    String chat(String question);
    String doSomethingElse(String question);
    }

    var assistant = AiServices.create(Assistant.class, chatModel);

输出护栏可以提供以下附加配置:

配置项 描述
maxRetries 输出护栏在执行重试或重新提示时的最大重试次数。默认值为 2。设置为 0 是禁用重试。

LangChain4j 提供了一些常见用例的输出护栏实现:

  • JsonExtractorOutputGuardrail

    一个输出护栏,用于检查响应是否能成功从 JSON 反序列化为特定类型对象。

    • 使用 Jackson ObjectMapper 尝试反序列化对象。
    • 如果响应无法反序列化为预期对象类型,则会重新提示 LLM。
    • 可直接使用,也可通过扩展和自定义(有多个 protected 方法可重写以定制行为)。

实际上,输入输出护栏是可以极高自由度的组合的,他们可以跟类似过滤器那样的方式极高自由度的组合

Guardrail 的实际例子

我们实现这样的几个护栏

  • 输入长度护栏

    演示如何限制用户输入的 token 长度,防止过高的 Token 成本或者超出模型上下文窗口

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    public class PromptLengthGuardrail implements InputGuardrail {

    private static final int MAX_ESTIMATED_TOKENS = 2000;

    @Override
    public InputGuardrailResult validate(UserMessage userMessage) {
    String text = userMessage.singleText();
    int charCount = text.length();

    // 简单 token 估算(实际应使用 tokenizer)
    int estimatedTokens = (int) (charCount * 0.4); // 平均 1 token ≈ 2.5 字符

    if (estimatedTokens > MAX_ESTIMATED_TOKENS) {
    return failure(String.format(
    "您的输入过长(约 %d tokens),超过了 %d tokens 的上限。请精简后重试。",
    estimatedTokens, MAX_ESTIMATED_TOKENS
    ));
    }
    return success();
    }
    }
  • JSON 格式验证输出护栏

    验证 LLM 输出是否为合法的 JSON。如果格式无效,使用 reprompt 机制告诉 LLM 正确的 JSON 格式要求,让 LLM 重新生成。这是输出护栏在结构化输出场景中最经典的应用。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    public class JsonFormatOutputGuardrail implements OutputGuardrail {
    private static final ObjectMapper objectMapper = new ObjectMapper();

    private static final String CORRECT_JSON_FORMAT = """
    请只返回合法的 JSON 格式,不要包含任何额外的说明文字或 markdown 代码块标记。
    确保:
    1. 所有键名和字符串值用双引号
    2. 不要有尾随逗号
    3. 大括号和方括号正确配对
    请重新以纯 JSON 格式回答:""";

    @Override
    public OutputGuardrailResult validate(AiMessage responseFromLLM) {
    String text = responseFromLLM.text().trim();

    // 尝试提取 JSON(去掉可能的 markdown 代码块标记)
    String jsonCandidate = extractJson(text);

    try {
    objectMapper.readTree(jsonCandidate); // 验证 JSON 格式
    return success(); // JSON 有效!
    } catch (Exception e) {
    return reprompt(
    "LLM 输出不是合法的 JSON 格式: " + e.getMessage(),
    CORRECT_JSON_FORMAT
    );
    }
    }
    }

最后,我们把这些护栏,装载到 AI Service 中,就可以使用了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Bean
public GuardedAssistant guardedAssistant(
@org.springframework.beans.factory.annotation.Qualifier("guardrailChatModel") ChatModel chatModel) {

log.info("=== 创建 GuardedAssistant(输入护栏 × 2 + 输出护栏 × 1)===");

return AiServices.builder(GuardedAssistant.class)
.chatModel(chatModel)
// 输入护栏:内容安全 + 长度检查(链式执行)
.inputGuardrails(
new ContentSafetyInputGuardrail(),
new PromptLengthGuardrail()
)
// 输出护栏:专业语气检查(带 reprompt,默认最多重试 2 次)
.outputGuardrails(
new JsonFormatOutputGuardrail(),
new ProfessionalToneOutputGuardrail()
)
// reprompt/retry 需要 ChatMemory
.chatMemory(MessageWindowChatMemory.withMaxMessages(10))
.build();
}

多个 InputGuardrail 按声明顺序依次执行,任何一个返回 failure/fatal 都会阻止 LLM 被调用。这样可以构建多层防护体系。

可观测性 Observability

聊天模型可观测性

可观测性是指通过收集、分析和暴露系统内部状态的数据,在LangChain4j中,可观测性主要通过监听器(Listener)模式实现。

部分 ChatModelStreamingChatModel 的实现允许配置 ChatModelListener 来监听如下事件:

  • 对 LLM 的请求
    • 消息,模型,温度,Top P,Tokens 情况,Tools等等内容
  • 来自 LLM 的响应
    • 响应格式,ID,模型,Token 使用情况,结束原因等等内容
  • 错误

LangChain4j的ChatModelListener是一个接口,允许你在聊天模型请求的生命周期中的关键节点插入自定义逻辑,一般,可以在对应请求的三个阶段进行监听

  • onRequest(ChatModelRequestContext requestContext)
    • 在向LLM提供商API发送请求之前被调用。
    • 可以从requestContext.chatRequest()中获取完整的ChatRequest对象,包含所有消息和参数。
  • onResponse(ChatModelResponseContext responseContext)
    • 在从LLM提供商成功收到响应之后立即被调用。
    • 记录响应内容、Token使用情况、结束原因等。可以从responseContext.chatResponse()中获取完整的ChatResponse对象。
  • onError(ChatModelErrorContext errorContext)
    • 在请求LLM提供商API发生错误时被调用。
    • 可以从errorContext.error()中获取异常对象。

attributes是一个Map<Object, Object>,它是在请求生命周期内传递数据的核心工具。

你可以在onRequest()方法中向attributes放入数据,然后在同一个请求的onResponse()onError()方法中读取。

1
2
3
4
5
6
7
8
// 在 onRequest 中
Map<Object, Object> attributes = requestContext.attributes();
attributes.put("startTime", System.currentTimeMillis());

// 在 onResponse 中
Map<Object, Object> attributes = responseContext.attributes();
Long startTime = (Long) attributes.get("startTime");
long duration = System.currentTimeMillis() - startTime;

这个也可以编程式配置,但是一般使用 Spring Boot 的自动配置

对于编程式配置,在创建ChatModel实例时,通过.listeners(List.of(listener))方法添加一个或多个监听器。

1
2
3
4
5
6
7
8
9
10
11
ChatModelListener listener = new ChatModelListener() {
// ... 实现上述三个方法 ...
};

ChatModel model = OpenAiChatModel.builder()
.apiKey(System.getenv("OPENAI_API_KEY"))
.modelName("gpt-4o-mini")
.listeners(List.of(listener)) // 添加监听器
.build();

model.chat("Tell me a joke about Java.");

而 Spring Boot 自动配置中,只需要将ChatModelListener声明为一个Spring Bean,它就会自动被注入到由Spring Boot Starter 创建的ChatModelStreamingChatModel中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Configuration
class MyConfiguration {

@Bean
ChatModelListener chatModelListener() {
return new ChatModelListener() {
private static final Logger log = ...;

@Override
public void onRequest(ChatModelRequestContext requestContext) {
log.info("请求: {}", requestContext.chatRequest());
}

@Override
public void onResponse(ChatModelResponseContext responseContext) {
log.info("响应: {}", responseContext.chatResponse());
}

@Override
public void onError(ChatModelErrorContext errorContext) {
log.error("错误: ", errorContext.error());
}
};
}
}

实际例子

我们写一个日志记录 ChatModelListener,按照上述的内容,我们实现 ChatModelListener 接口,然后实现对应的方法就可以了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
public class LoggingChatModelListener implements ChatModelListener {

private static final Logger log = LoggerFactory.getLogger(LoggingChatModelListener.class);

@Override
public void onRequest(ChatModelRequestContext requestContext) {
// 生成 traceId 并存入 attributes,在 onResponse/onError 中取出
String traceId = UUID.randomUUID().toString().substring(0, 8);
requestContext.attributes().put("traceId", traceId);
requestContext.attributes().put("startTime", System.currentTimeMillis());

var request = requestContext.chatRequest();
var params = request.parameters();

log.info("""

╔══════════════════════════════════════════════════════
║ [{}] LLM REQUEST
╠══════════════════════════════════════════════════════
║ Model: {}
║ Temperature:{}
║ Max Tokens: {}
║ Messages: {} 条
║ Last Msg: {}
╚══════════════════════════════════════════════════════""",
traceId,
params.modelName(),
params.temperature(),
params.maxOutputTokens(),
request.messages().size(),
request.messages().isEmpty() ? "(empty)" :
request.messages().get(request.messages().size() - 1)
);
}

@Override
public void onResponse(ChatModelResponseContext responseContext) {
// 从 attributes 中取出 onRequest 阶段存储的上下文
String traceId = (String) responseContext.attributes().get("traceId");
Long startTime = (Long) responseContext.attributes().get("startTime");
long elapsed = startTime != null ? System.currentTimeMillis() - startTime : -1;

var response = responseContext.chatResponse();
var tokenUsage = response.metadata().tokenUsage();

log.info("""

╔══════════════════════════════════════════════════════
║ [{}] LLM RESPONSE (耗时 {}ms)
╠══════════════════════════════════════════════════════
║ Input Tokens: {}
║ Output Tokens: {}
║ Total Tokens: {}
║ Response: {}
╚══════════════════════════════════════════════════════""",
traceId, elapsed,
tokenUsage != null ? tokenUsage.inputTokenCount() : "N/A",
tokenUsage != null ? tokenUsage.outputTokenCount() : "N/A",
tokenUsage != null ? tokenUsage.totalTokenCount() : "N/A",
response.aiMessage().text().length() > 200
? response.aiMessage().text().substring(0, 200) + "..."
: response.aiMessage().text()
);
}

@Override
public void onError(ChatModelErrorContext errorContext) {
String traceId = (String) errorContext.attributes().get("traceId");

log.error("""

╔══════════════════════════════════════════════════════
║ [{}] LLM ERROR
╠══════════════════════════════════════════════════════
║ Error: {}
╚══════════════════════════════════════════════════════""",
traceId,
errorContext.error().getMessage(),
errorContext.error()
);
}
}

这其中使用了 attributes 上下文传递

  • 每个 Context 对象都包含一个 attributes() 的 Map,在 onRequest 方法中 put 的值,可以在 onResponse 和 onError 中 get 到,这让你可以跨生命周期阶段传递上下文。

最后,给我们的这些监听器装到 AI Service 中,就可以进行监听了,这里我用了手动注入,实际上,你把监听器加上一个 @Component,让 Spring Boot 自动注册就行了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Bean("observableChatModel")
public ChatModel observableChatModel(List<ChatModelListener> listeners) {
log.info("=== 创建 Observable ChatModel,已注册 {} 个 Listener ===", listeners.size());
for (ChatModelListener listener : listeners) {
log.info(" - {}", listener.getClass().getSimpleName());
}

return OpenAiChatModel.builder()
.baseUrl(baseUrl)
.apiKey(apiKey)
.modelName(modelName)
.temperature(0.7)
.maxTokens(4096)
.listeners(listeners) // 注册所有 Listener
.logRequests(true) // 也可以同时用 logRequests
.logResponses(true)
.build();
}