别让大模型直接碰业务:我在 Spring Boot 里给 AI 操作加了一道“可拒绝的闸门”
前阵子我给一个内部运营工具接了 AI:同事把一段自然语言需求丢进来,系统要识别意图、补齐参数,再决定是否调用“创建优惠券”“修改订单备注”之类的动作。
第一版写得很快:提示词要求模型输出 JSON,Jackson 反序列化,字段不为空就执行。演示很丝滑。上线前压测了一轮,我把输入改成“给用户发一张 100 元券,订单号随便填”,模型仍然给了一个看上去完整的 JSON;再把金额写成“1000”,它也照填。问题不在模型笨,而在我把语言模型的建议误当成了业务命令。
这篇不讲“怎么把 ChatClient 调通”。我想分享一个更实用的边界:模型只负责生成动作提案(proposal),真正的业务动作必须经过确定性的校验、风控和幂等层。这样即使模型输出错了、被提示词注入了,最多也是提案被拒绝,而不是把错写进数据库。
本文示例使用 Java 21、Spring Boot 3.x、Spring AI 2.0 的
ChatClient。Spring AI 2.0 的结构化输出支持entity(...),并可通过validateSchema()对不符合 Schema 的结果进行带错误信息的重试;这个能力适合做“解析层可靠性”,但不能替代业务校验。
先定规则:模型没有执行权
我后来把链路拆成五步:
用户输入
→ LLM 生成 ActionProposal
→ Schema/类型校验
→ 业务规则校验(金额、权限、状态)
→ 幂等检查 + 人工确认策略
→ 执行受控的领域服务
其中最容易被省掉的是最后两层。可一旦动作会改钱、改库存、发消息,恰恰是这两层决定系统会不会翻车。
动作提案应该是一个很“瘦”的 DTO,不要让模型直接组装领域实体,更不要让它传入 SQL、URL 或任意方法名:
public enum ActionType {
CREATE_COUPON, UPDATE_ORDER_NOTE
}
public record ActionProposal(
ActionType action,
String orderNo,
Integer couponAmount,
String reason,
boolean requiresConfirmation
) {}
模型能选择的 action 是枚举,金额是整数,订单号是普通字段。它表达的是“我建议做什么”,不是“请执行我这段代码”。
用结构化输出解决“能不能读懂”,不用它替业务背书
Spring AI 的 entity 可以把模型返回映射为 record。我会把系统指令写得明确,但不会把它当安全措施:
@Service
public class ProposalService {
private final ChatClient chatClient;
public ProposalService(ChatClient.Builder builder) {
this.chatClient = builder.build();
}
public ActionProposal propose(String userText) {
return chatClient.prompt()
.system("""
你是业务动作解析器。只根据用户意图生成一个动作提案。
不得编造订单号;无法确定的字段填 null。
涉及发券、退款、删除、通知外部用户时,requiresConfirmation 必须为 true。
""")
.user(userText)
.call()
.entity(ActionProposal.class, spec -> spec.validateSchema());
}
}
这里有两个容易误判的点:
validateSchema()解决的是“JSON 少字段、类型不对、夹了说明文字”一类解析问题;它不可能知道“100 元券是否超过当前活动上限”。- 结构化输出通常需要完整响应,不能把流式 token 一边吐给前端、一边当成可执行对象。先完成提案,再进入校验与确认。
所以我的 Controller 从不直接调 couponService.create(...),而是只返回审批结果。用户界面看到“建议创建 100 元券,等待确认”,点击确认后才会走执行接口。
真正的闸门:确定性的 Policy
下面是一个可运行的最小策略。注意:它不相信 proposal 的 requiresConfirmation。高风险与否必须由服务端规则重新判定。
@Component
public class ActionPolicy {
public ValidationResult validate(ActionProposal p, Set<String> scopes) {
if (p == null || p.action() == null) {
return ValidationResult.reject("无法识别可执行动作");
}
if (!scopes.contains("ops:write")) {
return ValidationResult.reject("当前账号没有操作权限");
}
if (p.action() == ActionType.CREATE_COUPON) {
if (p.orderNo() == null || !p.orderNo().matches("ORD-\\d{8}")) {
return ValidationResult.reject("订单号格式不合法");
}
if (p.couponAmount() == null || p.couponAmount() < 1 || p.couponAmount() > 200) {
return ValidationResult.reject("优惠券金额必须在 1 到 200 元之间");
}
// 金额类和对外副作用动作,服务端强制确认
return ValidationResult.confirm("发券属于高风险动作,需要用户确认");
}
return ValidationResult.allow();
}
public record ValidationResult(Decision decision, String message) {
static ValidationResult allow() { return new ValidationResult(Decision.ALLOW, "通过"); }
static ValidationResult confirm(String message) { return new ValidationResult(Decision.CONFIRM, message); }
static ValidationResult reject(String message) { return new ValidationResult(Decision.REJECT, message); }
}
public enum Decision { ALLOW, CONFIRM, REJECT }
}
这段代码的价值不在正则,而在责任分界:LLM 可以把“把这单补偿一下”翻译成提案;是否有权限、订单是否存在、金额是否超限,只能由应用自己的数据库和规则回答。
实际项目里,我还会在这里补三类检查:
- 对象状态:订单是否属于当前租户,是否已取消,是否已补偿过;
- 限额与频率:单次、单日、单用户累计金额;
- 输入隔离:模型生成的文本只能进备注等受限字段,不能成为 SQL、模板表达式或 HTTP 目标地址。
确认之后仍可能重复:给执行接口加幂等键
很多人做到“二次确认”就停了,但网络超时、浏览器重试、消息重复投递都会让确认请求进来两次。发两张券时,用户可不会因为这是 AI 的锅就原谅你。
我的执行接口会要求前端携带一个 requestId,数据库用唯一索引兜底:
@Transactional
public CouponResult executeConfirmed(ActionProposal proposal, String requestId) {
if (couponOperationRepository.existsByRequestId(requestId)) {
return couponOperationRepository.findByRequestId(requestId)
.map(CouponOperation::toResult)
.orElseThrow();
}
Order order = orderRepository.findByOrderNoForUpdate(proposal.orderNo())
.orElseThrow(() -> new IllegalArgumentException("订单不存在"));
if (order.compensated()) {
throw new IllegalStateException("该订单已补偿");
}
Coupon coupon = couponService.create(order.userId(), proposal.couponAmount());
couponOperationRepository.save(CouponOperation.success(requestId, order.id(), coupon.id()));
order.markCompensated();
return CouponResult.created(coupon.id());
}
existsByRequestId 只能减少大部分重复,真正的并发兜底应该是 coupon_operation.request_id 的唯一约束;插入冲突时再读取已有结果返回。订单查询使用悲观锁只是示例,生产里要结合吞吐量选择乐观锁、条件更新或队列串行化。
把策略写成测试,而不是写进脑子里
AI 接入后的回归,不能只测“模型能不能答对”。模型输出本身会波动,最应该稳定的是闸门。下面的测试不调用模型,跑得快,能在每次改规则时守住底线:
class ActionPolicyTest {
private final ActionPolicy policy = new ActionPolicy();
private final Set<String> writeScope = Set.of("ops:write");
@Test
void coupon_over_limit_must_be_rejected() {
var p = new ActionProposal(ActionType.CREATE_COUPON,
"ORD-20260802", 201, "补偿", false);
var result = policy.validate(p, writeScope);
assertThat(result.decision()).isEqualTo(ActionPolicy.Decision.REJECT);
}
@Test
void valid_coupon_must_require_confirmation() {
var p = new ActionProposal(ActionType.CREATE_COUPON,
"ORD-20260802", 100, "物流延误补偿", false);
var result = policy.validate(p, writeScope);
assertThat(result.decision()).isEqualTo(ActionPolicy.Decision.CONFIRM);
}
}
这也是我现在区分“AI 功能可演示”和“AI 功能可上线”的一个标准:前者只要模型能产出看着对的答案;后者必须证明错误答案不会越过关键边界。
观测要盯拒绝率,而不是只盯成功率
最后补一个常被忽略的实践:记录 proposal、策略决策、拒绝原因、确认耗时和最终执行结果,但要脱敏。没有这些数据,出了问题只能翻一长串聊天文本。
我会特别看三个指标:
proposal_parse_failure_rate:结构化输出或重试后仍无法解析的比例;policy_reject_rate:被业务规则拒绝的比例,突然升高通常意味着提示词、模型或上游输入变了;confirmed_to_executed_rate:用户确认后真正成功执行的比例,用来发现权限、并发或下游服务故障。
写到这里,核心其实很朴素:大模型适合处理模糊输入、生成候选和解释结果;数据库写入、资金、权限和外部副作用,仍然应该由传统软件工程负责。
别让模型直接碰业务。把它放在“提案者”的位置,再给它后面接上类型、策略、确认、幂等和审计这几道闸门,AI 才会从一个好看的 Demo,变成能安心放进 Spring Boot 服务里的能力。
参考
- Spring AI 结构化输出说明:spring.io/blog/2026/0…
- Spring AI Structured Output Converter:docs.spring.io/spring-ai/r…