写在前面
“AI 助手"要变得有用,光会聊天不够,得能查真数据:这个员工谁带?我还剩几天年假?本月考勤有没有异常?这就是工具调用(tool call)——模型说"我需要调这个工具”,系统替它执行,把结果喂回去。
但工具一接上真数据,安全问题就换了量级。聊天说错话是体验问题,工具查错数据是安全事故——员工 A 通过 AI 查到员工 B 的假期余额,比 AI 回答得不够流畅严重一百倍。这篇记录我的工具执行器怎么在"能力"和"安全"之间划界。
一、路由:一张 switch 就是工具白名单
工具执行器的核心是个显式的路由:
Object result = switch (binding.toolCode()) {
case "ORG_GET_USER_SUMMARY" -> getUserSummary(requiredLong(payload, "userId"));
case "ORG_GET_DEPT_SUMMARY" -> getDeptSummary(requiredLong(payload, "deptId"));
case "ORG_GET_MANAGER_USER_ID" -> Map.of("managerUserId", ...);
case "LEAVE_GET_BALANCES" -> requireLeaveService().getLeaveBalances(...);
case "LEAVE_GET_AVAILABLE_MINUTES" -> Map.of("availableMinutes", ...);
case "ATTENDANCE_GET_DAILY_RESULT" -> requireAttendanceService().getDailyResult(...);
case "KNOWLEDGE_SEARCH_CHUNKS" -> requireKnowledgeSearchService().searchChunks(...);
default -> throw new BizException(400, "暂不支持该" + binding.toolType() + "工具执行");
};
return write(objectMapper, Map.of("status", "SUCCESS", "result", result == null ? Map.of() : result));
工具名是静态登记的,switch 没有兜底的"反射找同名方法"。default 分支永远报错——模型幻觉出一个不存在的工具名,得到的不是"尽力执行",而是一句明确的"暂不支持"。
为什么不用反射或动态分发?因为工具列表是这个系统的攻击面清单:安全评审时我要能指着这七个 case 说"AI 能触达的数据就这些"。反射方案让工具面在运行时漂移,清单变成了猜谜。给模型的能力面必须是枚举的、可打印在一张纸上的。
二、最关键的一关:数据越权的防线
执行任何工具前,先过一段看起来不起眼的校验:
// 载荷里的 userId 只允许查本人或直属下属(与工作流数据源同一规则),
// 防止借工具调用跨用户读取他人假期/考勤等数据。传 null 表示不强制(仅限管理端试运行)。
if (operatorUserId != null) {
JsonNode requested = payload.path("userId");
if (requested.canConvertToLong()) {
Long targetUserId = requested.asLong();
if (!targetUserId.equals(operatorUserId)) {
OrgUserDetailResponse target = orgDirectoryService.getUserDetail(targetUserId);
if (!operatorUserId.equals(target.getManagerUserId())) {
throw new BizException(403, "无权查询该员工信息");
}
}
}
}
规则只有一句:载荷里要查的 userId,必须是调用者本人,或调用者的直属下属——和审批流取数用同一套授权规则。
为什么这道闸如此重要?想象员工对 AI 助手说:"帮我查一下张总的年假余额。"模型不理解什么是隐私,它只会忠实地构造 LEAVE_GET_BALANCES 工具调用、填上它从上下文里知道的 userId。没有这道闸,AI 就是每个员工手里的一次性越权查询器——不用写代码、不用抓包,聊天框里一句话就能拿到全公司的考勤和假期。
两个设计细节:授权规则复用审批流的"直属下属"定义,同一套组织语义只写一遍;管理端试运行时 operatorUserId 传 null 跳过校验——但这条路只存在于管理员调试的入口,员工侧调用永远传真实身份。豁免路径必须显式标注归属,否则它会悄悄变成后门。
三、参数校验:对模型的宽容,对协议的严格
模型返回的工具参数有个著名的坑:数字可能是字符串。协议里定义 userId: 12345,模型有时回的是 "userId": "12345"。我的参数工具函数对此做了宽容:
private Long requiredLong(JsonNode payload, String fieldName) {
JsonNode node = payload.get(fieldName);
if (node == null || node.isNull()) {
throw new BizException(400, fieldName + "不能为空");
}
if (node.canConvertToLong()) {
return node.asLong();
}
if (node.isTextual()) { // 模型经常把数字包成字符串
String text = node.asText().trim();
if (StringUtils.hasText(text)) {
try {
return Long.parseLong(text);
} catch (NumberFormatException ignored) { }
}
}
throw new BizException(400, fieldName + "不能为空");
}
宽容的范围划得很准:类型表达可以宽容,内容缺失不容商量。数字的字符串形态可以转,但字段缺失、空值、无法解析一律 400——因为执行器返回给模型的就是这段错误信息,模型会拿着"userId 不能为空"在下一轮自己修正参数。工具的错误信息是给模型看的提示词,写得越具体,模型的自纠能力越强。
四、返回值:白名单字段 + 统一包装
工具查询到数据后,不是把数据库实体一把序列化:
private Map<String, Object> getUserSummary(Long userId) {
OrgUserSummaryResponse user = orgDirectoryService.getUserSummary(userId);
Map<String, Object> result = new LinkedHashMap<>();
result.put("userId", user.getUserId());
result.put("name", user.getRealName());
result.put("deptName", user.getPrimaryDeptName());
result.put("managerName", user.getManagerName());
result.put("employmentStatus", user.getEmploymentStatus());
...
return result;
}
逐字段的白名单搬运——哪怕响应 DTO 已经是干净的,也要再列一遍要给模型看的字段。这不是偏执:DTO 会演进,今天加一个"手机号"字段,明天模型就开始在回答里念用户的手机号。白名单让"AI 能看到什么"重新变成一行行可评审的代码。
最终返回统一包装成 {"status": "SUCCESS", "result": {...}}——模型读到的每个工具结果都有相同的外壳,它不需要为每个工具学一套响应格式。对模型而言,格式的一致性就是可用性。
五、模块依赖:ObjectProvider 的优雅降级
注入依赖的方式值得注意:
private final ObjectProvider<LeaveService> leaveServiceProvider;
...
private LeaveService requireLeaveService() {
LeaveService service = leaveServiceProvider.getIfAvailable();
if (service == null) {
throw new BizException(501, "假期查询工具未接入");
}
return service;
}
用 ObjectProvider 而不是直接注入——假期模块如果因为配置或部署形态缺席,工具执行器仍然能启动,对应工具调用返回 501"未接入"。工具面和模块可用性解耦后,裁剪部署(比如某客户不买考勤)不会拖垮 AI 助手。
六、小结
三点体会:
- 工具面要枚举,不要反射。 一张 switch 就是 AI 能力的完整清单——它是安全评审材料,也是运维手册,还是能力规划表。
- 授权规则复用业务的规则,不要另起炉灶。 "本人或直属下属"在审批流和工具调用里是同一句话——同一语义两套实现,迟早分叉出漏洞。
- 模型的输出按"不可信输入"处理。 字符串数字要容错、幻觉工具要拒绝、返回字段要白名单——工具执行器本质上是模型和数据库之间的海关,查验的是每一票货物。
AI 助手现在每天替员工查几百次假期、考勤和组织信息,零越权记录。模型依旧会偶尔构造出奇怪的参数,但每一笔调用都过闸——让 AI 有能力,和让能力有边界,是同一件事的两半。
默认评论
Halo系统提供的评论