Spring AI Advisor 与安全 Tool Calling:给 AI 调用链加追踪、权限和硬边界
前面的学习已经完成了 ChatClient、Memory、RAG 和 Tool Calling。
但把这些能力串起来以后,新的问题也出现了:
- 一次 AI 调用怎么生成统一的
traceId; - Prompt 版本怎么跟着请求进入调用链;
- 模型调用耗时在哪里统计;
- 多个会话怎么真正隔离;
- 模型决定调用高风险工具时,后端如何阻止越权;
- 为什么只在 System Prompt 里写“禁止审批”还不够。
这一章主要学习 Spring AI 的两个工程化能力:
Advisor:统一拦截和增强 ChatClient 调用链 ToolContext:把后端可信上下文传入工具方法一、Advisor 可以理解成 AI 调用链拦截器
Spring AI 的ChatClient最终要完成一次模型调用。
Advisor 可以在这次调用的前后执行公共逻辑,例如:
- 注入上下文;
- 记录请求和响应;
- 管理 Chat Memory;
- 执行 RAG 检索;
- 统计耗时;
- 生成链路追踪信息。
本项目实现了一个自定义TraceAdvisor:
publicclassTraceAdvisorimplementsCallAdvisor{privatefinalAiTracePropertiesaiTraceProperties;publicTraceAdvisor(AiTracePropertiesaiTraceProperties){this.aiTraceProperties=aiTraceProperties;}@OverridepublicChatClientResponseadviseCall(ChatClientRequestrequest,CallAdvisorChainchain){// 调用链增强逻辑}}它实现的是CallAdvisor,对应同步调用:
.call()二、ChatClientRequest 和 ChatClientResponse 是什么
ChatClientRequest表示当前即将进入模型调用链的请求。
它不只是用户输入,还包含:
- Prompt;
- ChatOptions;
- Advisor context;
- Tool 等调用配置。
ChatClientResponse表示经过调用链之后的响应。
它主要包含:
- 模型返回的
ChatResponse; - Advisor context;
- 调用链补充的元数据。
这里的context不是模型聊天记忆,而是 Advisor 在一次调用链中传递的键值数据。
例如:
traceId promptVersion latencyMs这些字段不一定发送给大模型,它们主要用于 Java 侧的调用链协作。
三、在请求进入模型前注入追踪信息
StringtraceId=UUID.randomUUID().toString();longstart=System.currentTimeMillis();StringpromptVersion=request.context().getOrDefault("promptVersion",aiTraceProperties.getDefaultPromptVersion()).toString();然后通过mutate()基于原请求创建一个新请求:
ChatClientRequesttracedRequest=request.mutate().context("traceId",traceId).context("promptVersion",promptVersion).build();这里没有直接修改原对象,而是构建一个带追踪信息的新请求。
再把它传给下一个 Advisor 或最终模型调用:
ChatClientResponseresponse=chain.nextCall(tracedRequest);chain.nextCall()是关键。如果不调用它,请求会停在当前 Advisor,模型也不会真正执行。
四、在响应返回后补充耗时
longlatencyMs=System.currentTimeMillis()-start;returnresponse.mutate().context("traceId",traceId).context("promptVersion",promptVersion).context("latencyMs",latencyMs).build();这样 Controller 可以从ChatClientResponse.context()读取调用链元数据:
ChatClientResponseresponse=chatClient.prompt().system("你是一个企业 AI 助手").advisors(a->a.param("promptVersion","advisor-demo-v1")).user(message).call().chatClientResponse();Map<String,Object>result=newLinkedHashMap<>();result.put("answer",response.chatResponse().getResult().getOutput().getText());result.put("traceId",response.context().get("traceId"));result.put("promptVersion",response.context().get("promptVersion"));result.put("latencyMs",response.context().get("latencyMs"));返回结果类似:
{"answer":"......","traceId":"1ee03d3e-...","promptVersion":"advisor-demo-v1","latencyMs":862}五、Advisor 的开关和默认配置
@Component@ConfigurationProperties(prefix="app.ai.trace")@DatapublicclassAiTraceProperties{privatebooleanenabled=true;privateStringdefaultPromptVersion="spring-ai-learning-v1";}Advisor 内部先判断开关:
if(!aiTraceProperties.isEnabled()){returnchain.nextCall(request);}关闭后仍然继续调用模型,只是不再执行追踪增强。
这个设计比把开关写死在代码里更适合后续扩展。
六、Advisor 顺序为什么重要
@OverridepublicintgetOrder(){returnOrdered.HIGHEST_PRECEDENCE+100;}一个ChatClient可以挂多个 Advisor:
builder.defaultAdvisors(newTraceAdvisor(aiTraceProperties),newSimpleLoggerAdvisor()).build();顺序决定谁先处理请求、谁最后处理响应。
如果以后同时使用:
- TraceAdvisor;
- MessageChatMemoryAdvisor;
- QuestionAnswerAdvisor;
- 安全审计 Advisor;
就必须明确各自的职责和顺序,否则日志、记忆或检索上下文可能不符合预期。
七、会话隔离不要只使用一个 chatId
项目还尝试了更明确的会话键:
StringmemoryKey=userId+":"+conversationId;调用时:
returnchatClient.prompt().user(message).advisors(a->a.param(ChatMemory.CONVERSATION_ID,memoryKey)).call().content();这样可以区分:
user-01:conversation-01 user-01:conversation-02 user-02:conversation-01同一个用户可以有多个窗口,不同用户也不会共享记忆。
清理会话时直接使用同一个 key:
chatMemory.clear(memoryKey);当前仍然是内存级MessageWindowChatMemory,应用重启会丢失。这里学习的是会话标识设计,不是持久化方案。
八、Prompt 权限为什么不是安全边界
早期代码在 System Prompt 中写:
普通巡检员无权审批预算,必须拒绝。这可以影响模型行为,但不能保证安全。
原因是:
- Prompt 可能被绕过;
- 模型可能误判用户角色;
- 工具可能被其他入口直接调用;
- Prompt Injection 可能改变模型决策;
- 最终执行动作的是 Java 方法,不是 Prompt。
所以真正的权限校验必须进入工具方法。
九、用 ToolContext 传递可信后端上下文
Controller 根据当前学习示例确定用户角色:
StringuserRole="boss-01".equals(chatId)?"董事长":"普通巡检员";注册工具时传入toolContext:
returnchatClient.prompt().system(""" 你是企业 AI 调度助手。 当前用户角色是:%s。 如果工具返回 allowed=false,必须告诉用户拒绝原因。 高风险工具即使 allowed=true,也只能说生成草案。 """.formatted(userRole)).user(message).tools(newSecureAgentTools()).toolContext(Map.of("chatId",chatId,"userRole",userRole)).call().content();模型负责决定是否调用工具并生成业务参数。
ToolContext中的角色由 Java 后端注入,不需要模型自己生成。
十、工具方法内部做硬校验
高风险预算工具:
@Tool(name="approveMaintenanceBudgetSecure",description="高风险工具:申请紧急维修预算草案")publicStringapproveMaintenanceBudget(@ToolParam(description="设备编号,例如 A-01")StringdeviceId,@ToolParam(description="预算金额")Integeramount,ToolContexttoolContext){StringuserRole=String.valueOf(toolContext.getContext().getOrDefault("userRole","UNKNOWN"));if(!isValidDeviceId(deviceId)){returndenied("approveMaintenanceBudgetSecure","HIGH","设备编号格式不合法");}if(amount==null||amount<=0||amount>50000){returndenied("approveMaintenanceBudgetSecure","HIGH","预算金额必须在 1 到 50000 之间");}if(!BUDGET_APPROVERS.contains(userRole)){returndenied("approveMaintenanceBudgetSecure","HIGH","当前角色无权申请紧急维修预算");}returnallowed("approveMaintenanceBudgetSecure","HIGH","已生成维修预算草案,等待人工最终确认");}这里同时校验了三件事:
- 设备编号格式;
- 金额范围;
- 当前角色是否有权限。
即使模型坚持调用工具,Java 代码仍然可以拒绝。
十一、工具返回值也要结构化
允许时返回:
{"toolName":"approveMaintenanceBudgetSecure","allowed":true,"riskLevel":"HIGH","reason":"校验通过","data":"已生成维修预算草案,等待人工最终确认"}拒绝时返回:
{"toolName":"approveMaintenanceBudgetSecure","allowed":false,"riskLevel":"HIGH","reason":"当前角色普通巡检员无权申请紧急维修预算","data":""}模型只负责把工具结果组织成人能理解的答案,不能自行把allowed=false改成成功。
高风险操作即使允许,也只生成草案,不直接完成最终审批。这就是 Human-in-the-loop 的最小雏形。
十二、低风险工具和高风险工具要分级
只读查询工具:
@Tool(name="getDeviceStatusSecure",description="低风险只读工具:根据设备编号查询设备状态")预算工具:
@Tool(name="approveMaintenanceBudgetSecure",description="高风险工具:申请紧急维修预算草案")真实系统中可以进一步把工具分成:
| 风险等级 | 示例 | 建议 |
|---|---|---|
| LOW | 查询设备、查询文档 | 校验参数后直接执行 |
| MEDIUM | 创建工单、生成邮件草稿 | 记录审计,必要时确认 |
| HIGH | 审批、删除、转账、发正式通知 | 强权限、人工确认、可回滚 |
十三、测试 URL
Advisor:
http://localhost:8080/api/v1/advisor/chat?message=请说明A-01设备风险普通用户查询设备:
http://localhost:8080/api/v1/secure-tools/chat?chatId=user-01&message=查询A-01设备当前状态普通用户申请预算:
http://localhost:8080/api/v1/secure-tools/chat?chatId=user-01&message=给A-01申请50000元紧急维修预算董事长申请预算草案:
http://localhost:8080/api/v1/secure-tools/chat?chatId=boss-01&message=给A-01申请50000元紧急维修预算草案测试时除了看最终文本,还应该在工具方法设置断点,确认:
- 模型是否真的调用了工具;
ToolContext是否拿到了正确角色;- 普通用户是否在 Java 方法内被拒绝;
- 高风险动作是否只返回草案。
十四、本章总结
这一章把两个容易混淆的“上下文”区分开了:
Chat Memory:保存用户和模型的对话上下文 Advisor context:在一次 AI 调用链中传递系统元数据 ToolContext:把后端可信信息传给工具方法同时也明确了 AI 工具调用的职责边界:
模型选择工具和生成参数 Advisor 负责调用链增强和观测 Java 工具负责参数、权限和风险校验 高风险动作交给人工最终确认Tool Calling 的安全不能靠一句 Prompt。真正可靠的边界必须写在后端代码里。
下一章继续升级 RAG:不再让 Advisor 隐藏检索过程,而是显式返回来源、相似度分数、过滤条件和拒答原因。