7.2 Spring AI 与物联网集成
7.2.1 Spring AI 简介与配置
Spring AI 为 Java/Spring 应用提供 ChatModel、ChatClient、Advisor、Chat Memory 和 Tool Calling 等抽象。ChatClient 是面向业务代码的统一入口,底层可以是不同 Provider 的 ChatModel 实现;它不要求所有模型都统一使用 OpenAI Chat Completions 协议。
IoT DC3 当前使用 Spring AI 2.0.0(GA 2026-06),并同时引入 OpenAI、Anthropic 与 JDBC Chat Memory Starter:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-anthropic</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId>
</dependency>模型连接不是只写在 application.yml 中。当前项目用 dc3_model_provider 保存 Provider 类型、端点、密钥、默认与启用状态,用 dc3_model_config 保存具体模型及其能力配置。ChatClientFactory 根据请求中的 model 或默认模型解析配置:OPENAI_COMPATIBLE 构建 OpenAiChatModel,ANTHROPIC 构建 AnthropicChatModel,并缓存对应 ChatClient。部署环境变量还提供一个 OpenAI-compatible fallback,避免数据库配置不可用时完全失去基础对话入口。
业务代码使用的是统一的 ChatClient 调用形态:
String answer = chatClient.prompt()
.user("查询锅炉当前温度")
.call()
.content();统一接口不代表 Provider 行为完全相同。切换模型前仍需验证认证方式、可用参数、流式响应、Tool Calling、上下文窗口和错误语义。请求可选择已启用模型,未指定时使用默认模型;当前没有按成本、复杂度或敏感标签自动路由模型的策略引擎。
7.2.2 ChatClient:统一对话接口
理解 ChatClient 的最佳方式,是从一段能跑起来的代码开始。假设你已经按照上一节的步骤配置好了依赖,现在打开一个 Spring Boot 测试类或者 @Service。
@Autowired
private ChatClient chatClient;
public String askDeviceStatus() {
String question = "请问 A 区三号锅炉的当前温度是多少?请给出数值和单位。";
String answer = chatClient.prompt()
.user(question)
.call()
.content();
return answer;
}这段代码展示了第一个核心设计:调用方式。ChatClient 把整个对话流程拆解成清晰的链式步骤:prompt() 构造消息 → user() 提供用户输入(也可加 system() 设定角色)→ call() 触发模型推理 → .content() 提取纯文本响应。链式风格在 Java 8 之后的生态里很常见,工程团队上手成本低。
同步调用(Sync Call)最简单也最容易调试。请求发出后,当前线程会阻塞在 call() 方法上,直到大模型返回完整结果。对物联网运维来说,一般在“查询一次状态”“解析一条指令”这类不需要实时流式展示的场景中使用。比如操作员说“帮我找一下上次报修的设备编号”,同步模式足够用,代码逻辑也直白。
但物联网很多场景需要实时反馈——读取锅炉温度时,如果模型要逐段生成分析报告,操作员不想等全部生成完才能看到第一行。这时需要 流式调用(Streaming Call),它也是 ChatClient 的内置能力:
public void streamHealthReport() {
Flux<String> reportStream = chatClient.prompt()
.user("生成今天三号锅炉的健康报告,包含温度趋势和异常标记")
.stream()
.content();
reportStream.subscribe(chunk -> {
System.out.print(chunk); // 或通过 WebSocket 推送
});
}stream() 返回一个 Reactor 的 Flux<String>,每次模型生成一个新 token(Token:大模型处理文本的最小单位,可理解为词语或子词片段),subscribe 回调就会触发一次。在实际的运维操作台里,用户看到的内容是一行行刷新出来的,不是等几分钟才刷出全文。这种体验对“告警诊断分析”这类长回复场景尤其重要。
第三个维度是函数调用。 7.2.3 节会专门展开,但这里先提一句:ChatClient 的 tools()、defaultTools() 方法能把带有 @Tool 注解的 Spring Bean 注册为大模型可以自主调用的工具。当用户问“把三号锅炉的温度调到 85 度”时,大模型不是直接写代码,而是调用你注册的 setTemperature 函数,传入参数 deviceId="boiler-03", targetValue=85,然后业务代码执行实际操作并返回结果。这个机制让 ChatClient 从“问答机器”变成了“操作入口”。
典型对话场景。
- 设备状态查询。 用户:“查看厂区所有离线网关。” 模型调用
DeviceTool.listOffline(),返回结果后整理成自然语言:“共有 2 台离线:二号线 PLC(10:23 断电)、仓库温控器(09:15 网络断开)。” - 日志分析。 用户:“昨晚 2:00 到 3:00 之间三号锅炉的压力日志有没有异常?” 模型先调用
PointValueTool.queryHistory()获取数据,再根据上下文中的正常压力范围判断趋势。最终输出:“发现 2:47 压力突升至 1.5 MPa(允许上限 1.2 MPa),持续约 4 分钟后回落。” - 故障诊断。 用户:“报警器一直在响,帮我看看怎么回事。” Agent 可先调用
DeviceTool查询设备状态,再用DriverTool确认所属 Driver 与其下设备的在线汇总,最后区分单设备故障和 Driver 级故障并给出检查步骤。当前 Provider 未注册EventTool,示例不调用它。
每种场景的共性是:ChatClient 充当翻译层——把自然语言翻译成 API 调用,再把 API 返回的结果翻译回自然语言。不需要为每个设备写专门的解析逻辑。
工程上的几点补充。 同步调用虽然直观,但如果模型响应慢(几秒到几十秒),长时间阻塞会耗尽线程池。ChatClient 没有 async() 这样的方法:生产环境中通常把同步调用放进异步执行器或 WebFlux 上下文,用 CompletableFuture 等机制自行包装异步;需要逐步返回内容时,改用上面演示的 stream() 流式调用。流式调用天然适合非阻塞架构,但也需要合理控制背压(Backpressure),避免推送太快导致前端缓冲区溢出。函数调用涉及用户确认和权限检查,一般会在工具执行前加一道拦截,例如 IoT DC3 的 Agentic Center 在 ToolContext 中传递租户和用户身份,业务代码根据 RBAC 判断是否允许写值。
整体设计总结。 ChatClient 的三类调用对应物联网运维的不同需求:
| 调用模式 | 适用场景 | 数据流 | 典型例子 |
|---|---|---|---|
| 同步调用 (Sync) | 快速问答、简单指令 | 请求→阻塞→完整响应 | “查当前室温” |
| 流式调用 (Stream) | 长分析、实时看进展 | 请求→逐段推送 | “分析全天趋势异常” |
| 函数调用 (Function) | 执行操作、写值返回 | 请求→模型决策→调用业务代码→返回结果 | “把风机转速调到 1500 rpm” |
设计上,ChatClient 做了一层巧妙的抽象:它不关心你接的是 GPT-5 还是 DeepSeek,只要模型暴露 OpenAI 兼容的 Chat Completions 端点,调用方式保持一致。这意味着物联网平台在“选模型”这件事上有了自由度——今天用 GPT,明天换成私有化部署的 DeepSeek,上层业务代码通常不需要改动,切换成本主要是改配置;但认证方式、Tool Calling 行为和返回语义仍需按 Provider 逐项验证(7.4.1 节展开),适配不等于“改完配置就毫无差异”。IoT DC3 的 Agentic Center 正是基于这个设计的产物,一条聊天消息变成设备指令,依赖的就是 ChatClient 的同步或流式对话接口与函数调用机制的组合。
掌握了这三种调用方式,接下来就可以看看函数调用具体是怎么定义和注册的——那就是 Spring AI 让大模型“碰”设备的关键机制。
7.2.3 Function Calling:从模型请求到受控工具执行
ChatClient 能回答“锅炉温度是多少”,但运维还需要查询实时状态、创建工单或提出设备写入。Function Calling(也称工具调用,Tool Calling)让 LLM 从纯文本生成扩展到结构化能力请求。它解决的是“模型如何选择能力并填写参数”,不负责权限、审批、状态恢复或物理控制安全;这些职责属于 Tool、Workflow 和 Agent Runtime。
机制原理
Function Calling 的流程并不复杂。应用先向 LLM 注册一组可调用函数(名称、描述、参数结构),模型在推理时会判断用户意图是否匹配某个函数:匹配则输出一个结构化 JSON,包含函数名和参数,而不是直接输出自然语言。应用端拦截到这个 JSON 后,执行对应的后端方法,再把执行结果(通常是成功/失败、返回值)回填给模型,让模型据此生成最终的自然语言回复。整个过程没有魔法——LLM 不执行代码,它只负责“选函数、填参数”。
举个例子。用户问:“把 A 区 3 号锅炉的鼓风机转速调到 1500”,LLM 不会直接转动风机,它只会输出类似 { "function": "setDevicePointValue", "arguments": { "deviceId": "boiler-003", "pointId": "fan-speed", "value": 1500 } } 的候选请求。生产系统必须先校验目标、参数、权限、风险等级和工况,再决定拒绝、等待确认或进入确定性 Workflow。只有执行完成并读取客观回执后,系统才能向用户报告结果。
下面用一个内存中的智能灯示例演示 Function Calling 机制。它只说明 Tool 注册和调用,不代表工业现场应跳过治理平面直接执行。
工具定义:开关灯
在 Spring AI 中定义一个可被 LLM 调用的工具极其简单——只需要在 Bean 方法上添加 @Tool 注解。下面是开关灯工具的实现。
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
@Component
public class LightTool {
private boolean lightOn = false;
private String currentLocation = "A区";
@Tool(description = "开关指定区域的智能灯,返回灯的当前状态")
public String toggleLight(
@ToolParam(description = "区域名称,如A区、B区、C区") String location,
@ToolParam(description = "目标状态:true为开灯,false为关灯") boolean turnOn) {
// 在实际IoT DC3中,这里会调用DeviceTool的写入接口
// 此处为示意逻辑
this.lightOn = turnOn;
this.currentLocation = location;
String status = turnOn ? "已开启" : "已关闭";
return String.format("%s的灯%s", location, status);
}
@Tool(description = "查询指定区域的灯当前是开还是关")
public String getLightStatus(
@ToolParam(description = "区域名称") String location) {
String status = lightOn ? "亮着" : "关着";
return String.format("%s的灯当前%s", location, status);
}
}两个关键点。第一,@Tool 注解的 description 是 LLM 理解该函数的唯一途径——描述越精确,模型越不容易误调用。第二,@ToolParam 的 description 帮助模型正确填充参数,例如 turnOn 参数如果用数字 1/0 而非布尔值,模型仍能通过描述推断出意图。
工具注册与调用
工具定义好后,还需要显式注册到 ChatClient。仅把 LightTool 声明为 Spring Bean,不会让 ChatClient.Builder 自动扫描所有 @Tool 方法。可以用 defaultTools(lightTool) 为同一 Builder 构建的请求注册默认工具,也可以在单次请求上调用 tools(lightTool)。
@Autowired
private LightTool lightTool;
public void demoFunctionCalling() {
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultTools(lightTool)
.build();
String userRequest = "帮我把A区的灯关了";
String response = chatClient.prompt()
.user(userRequest)
.call()
.content();
// 输出:已关闭A区的灯
System.out.println(response);
}实际执行时,ChatClient 内部先向 LLM 发送用户消息加上工具描述(即 LightTool 的两个方法签名),模型判断“关灯”对应 toggleLight(location="A区", turnOn=false),输出函数调用请求。客户端执行该函数,将结果返回给模型,模型最终合成回复。这一切对开发者透明。
如果用户连续提问,比如先问“A区灯什么状态”,再问“把它关了”,两次调用会穿过同一个对话上下文。这就是下一节“对话记忆”的作用——模型记得上一轮查出的状态。
工程风险与控制
当函数调用连接物理设备时,模型生成请求与获准执行之间必须存在确定性边界。
权限校验。 不是所有用户都应该能操作所有设备。每个 @Tool 方法应通过 ToolContext 获取当前认证用户、租户 ID,然后在执行前做 RBAC 校验。IoT DC3 的做法是:AI 的所有动作最终都走平台真实 API,经网关注入主体上下文,再由鉴权中心做权限校验与租户隔离——模型拿不到比对应账号更多的权限。
参数验证与范围约束。 LLM 填充的参数可能超出预期范围,比如把转速设为 100000。工具方法内部必须进行参数合法性校验;@ToolParam 本身只有 description 与 required 两个属性,声明不了取值范围,因此应在 description 中写明单位与量程,并在服务端做强校验(与 7.6.1 CHK-06 一致)。对于高风险写操作,可设计“参数预览+确认”环节,让用户在界面上确认后再执行。
恢复与幂等性。 设备操作未必总能成功:网络中断、设备离线、协议超时都可能造成“是否已经生效”不确定。Tool 应声明超时、重试和幂等语义,Runtime 应保存执行状态与副作用证据;不能把恢复判断交给模型,也不能承诺所有物理动作都能回滚。
避免“误操作”的自然语言陷阱。 用户说“把所有设备都关掉”可能是个玩笑,模型却可能发起批量操作请求。批量和高风险操作必须由服务端策略拒绝或进入审批 Workflow;工具描述中的警告与模型反问只能改善交互,不能构成安全控制。
知道了如何定义工具,下一步自然要问:多轮对话中,模型如何记得上一轮查出的设备 ID 和参数?这就要引入对话记忆机制。
7.2.4 对话记忆:保持上下文连续
对话式运维中,操作员可能先查询历史数据,接着要求对某个异常段执行操作。如果没有记忆机制,模型无法解析第二句话中的指代对象——上一轮提到的“异常段”与第二轮要调整的参数之间没有显式关联。这不是可用性问题,而是无状态 API 与多轮交互之间的结构性矛盾:大语言模型每次请求默认独立处理,上一轮的信息不会自动带入下一轮,应用层必须主动管理会话历史。
无状态设计的工程代价
Chat Completion API 遵循无状态设计,每次请求携带独立的完整消息,模型内部不做跨请求关联。这简化了 API 本身的实现,但将上下文管理的责任完全交给了调用方。在物联网运维中,一个会话可能持续多轮,涉及设备查询、参数解读、命令下发、结果确认。如果每轮都从零开始,指代解析必然失败,“多轮对话”就会退化成单轮问答。这是选择 ChatClient 时必须考虑的第一层代价:你获得了无状态服务的高可用伸缩,就必须用额外内存或存储换回上下文连续性。
三种记忆策略
Spring AI 2.0 把对话记忆收敛为两个抽象:ChatMemory 负责按会话组织消息并决定保留策略,ChatMemoryRepository 负责消息在存储中的读写。现行实现是 MessageWindowChatMemory——按滑动窗口只保留最近若干条消息;把 Repository 换成 7.2.1 引入的 JDBC 实现,消息即可落库。0.x 时代的 InMemoryChatMemory、MessageChatMemoryAdvisor 等 API 已被这套组合取代,网上的旧示例不能照抄。前两种策略为 Spring AI 内置,知识图谱记忆则需自研或引入可选扩展;三种策略在物联网场景中的适用性有明显区别:
| 策略 | 原理 | 运维场景适用性 |
|---|---|---|
| 消息历史 | 将完整消息列表(用户+助手)直接附加到每次请求 | 短轮次对话(通常10轮以内),保留上下文且无信息损失 |
| 摘要记忆 | 将历史压缩为一段摘要,避免token溢出 | 长轮次对话或token预算紧张时使用,但需要保留关键操作结果 |
| 知识图谱记忆(自研/可选扩展) | 维护实体关系,仅检索相关实体获得上下文 | 复杂推理场景,如追溯多台设备的历史操作链 |
运维对话通常围绕有限设备和位号展开,轮数可控,消息历史模式最直接。但当对话拉长或涉及频繁的 Tool Calling 反馈时,摘要记忆做自动压缩是更稳妥的选择。压缩规则需要特别注意:操作类历史必须保留执行结果与状态码,避免模型因上下文丢失而重复执行相同的下发指令。
关键实现:MessageWindowChatMemory 与 conversationId
MessageWindowChatMemory 在每次调用前,自动取出与当前 conversationId 关联的历史消息注入提示词,调用结束后再把本轮消息写回 Repository。conversationId 是会话唯一标识,不同会话使用不同 ID 即可隔离上下文。以下代码展示典型用法(示意,具体方法签名与参数名以 Spring AI 2.0 官方文档为准):
// maxMessages 即滑动窗口大小,取代旧版 advisor 的历史条数配置:
// 只把最近 20 条消息注入提示词,避免长会话撑爆上下文窗口
ChatMemory chatMemory = MessageWindowChatMemory.builder()
.chatMemoryRepository(chatMemoryRepository) // JDBC 实现,由 7.2.1 的 starter 自动装配
.maxMessages(20)
.build();
// 第一轮
String response1 = chatClient.prompt()
.user("昨天三号线平均温度是多少?")
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, "session-line-3"))
.call().content();
// 第二轮:相同 conversationId 即可关联前文
String response2 = chatClient.prompt()
.user("对这个温度区间,风冷参数该怎么调?")
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, "session-line-3"))
.call().content();漏传 conversationId 是多轮对话最常见的接线错误:advisor 拿不到会话标识,历史注入为空,模型表现为“失忆”——答非所问或反复追问已经给过的信息。排查这类问题时,应先检查请求中 advisor 参数是否携带了会话 ID,再怀疑模型本身。
对话长度与 token 预算的工程权衡
全量历史注入存在明显的 token 成本问题。对于上下文较短的模型,保留多轮完整对话会很快用尽预算,留给指令与工具返回的空间所剩无几。Spring AI 2.0 中,这个长度不再由旧版 advisor 的配置项控制,而是在构建 MessageWindowChatMemory 时用 maxMessages 直接声明:窗口内的消息全量注入,窗口外的消息被丢弃,需要长期保留的信息必须由应用层提前压缩成摘要再写回存储。工程实践中,常见的折中是保留最近若干轮全量历史,更早的历史由摘要器生成一段结构化摘要,摘要中必须包含关键操作结果与时间戳,避免模型因信息缺失而重复执行或误判。
IoT DC3 中的会话持久化
IoT DC3 的 Agentic Center 采用的正是 JDBC Repository 路线:ChatMemory 的存储对接平台数据库,对话记录直接写入中心库的表结构,支持会话回放、审计与故障复盘。第 6 章的部署拓扑中没有 Redis,这里也不引入新的中间件——复用平台既有数据库,会话状态的持久化、备份与跨进程共享随数据库一并解决,天然满足审计回放的要求。操作员一句“看看上次对三号线做了什么”,系统即可检索对应会话的完整历史。这种可追溯能力不仅为多轮交互提供上下文连续性,更将每一次运维操作数字化为可审计的记录,是运维合规与事故回溯的基础设施。
对话记忆是 Function Calling 在多轮场景中准确执行的先决条件——模型必须知道上一轮的操作结果,才能判断下一轮应查哪个位号、调哪个参数。没有它,Tool Calling 只能在单轮中生效,应用价值自然大打折扣。