Spring Boot + Spring AI 实战:从聊天接口到 Function Calling,支撑日均千万级智能客服的架构演进
Spring Boot + Spring AI 实战:从聊天接口到 Function Calling,支撑日均千万级智能客服的架构演进
很多团队第一次把大模型接进客服系统时,都会先做一个最小闭环:前端把用户问题发到/chat,后端调用模型,模型返回一段自然语言答案。这个方案在演示阶段几乎总是成立,因为它足够快、足够直观,也最容易让业务方看到“AI 已经能说话了”。
但只要一上真实业务,这个方案就会立刻暴露边界。
某电商客服场景里,日均会话量已经超过 1200 万次,用户问的不只是“退货规则是什么”,还会问:
- “我尾号 3321 的订单发货了吗?”
- “帮我给 ORD20231201 申请退款。”
- “这张优惠券为什么不能用?”
- “今天退款工单积压了多少?”
这类问题有一个共同点:答案不在模型里,而在订单中心、售后中心、优惠券中心和运营后台里。模型如果拿不到真实数据,就只能猜。对客服系统来说,猜错不是体验问题,而是业务风险。
所以这篇文章不打算把 Spring AI 写成一个“接模型很方便”的入门 Demo,而是聚焦一个更实际的问题:
一个最初只会聊天的 Spring Boot 智能客服,为什么最终必须演进到 Function Calling,以及这个演进过程中哪些设计是不能省的。
我会重点展开 5 件事:
- 为什么 Prompt 拼 JSON 在生产里很快会失效
- Spring AI 的 Function Calling 到底把哪一段链路结构化了
- 智能客服是怎样从“单接口聊天”一步步演进成“受控工具调用”的
- 写操作、超时、越权、历史膨胀这些异常分支该怎么处理
- 什么阶段值得引入这套方案,什么阶段其实没必要
一、先说结论:客服系统的问题,从来不是“不会回答”,而是“不会办事”
在规则引擎时代,客服机器人的主要问题是理解力不够;换成大模型之后,主要问题就变成了执行力不够。
这两个阶段的差别非常大。
第一阶段,系统要解决的是“用户在说什么”。
第二阶段,系统要解决的是“理解之后到底该调哪个系统、带什么参数、以什么权限执行、失败了怎么收口”。
也就是说,智能客服一旦从 FAQ 场景进入真实交易场景,核心矛盾就不再是回答生成,而是下面这几个工程问题:
- 模型如何拿到可信的业务数据
- 模型如何触发受控的后端动作
- 后端如何限制模型只能在允许的能力边界内工作
- 写操作如何防重、审计和补偿
- 会话规模上来之后,Token、线程、下游依赖谁先成为瓶颈
如果没有 Function Calling,你仍然可以做一个“很像客服”的机器人;但你做不出一个“真的能处理订单与售后”的客服系统。
二、这套架构不是一步到位长出来的,而是被线上问题逼出来的
这类系统通常会经历四个很典型的阶段。
阶段一:只有/chat,模型负责一切
最初的实现通常非常简单:
- 接收用户消息
- 拼接 system prompt 和历史消息
- 调模型
- 返回答案
这个阶段适合验证两件事:
- 用户是否愿意和 AI 客服交互
- 模型在咨询类问题上的表达是否足够自然
但它天然有三个限制:
- 模型无法访问订单、物流、售后等实时数据
- 模型无法安全执行退款、改单、查券等动作
- 模型为了“保持有帮助”,会在不知道答案时编造答案
这也是很多团队第一次上线后最直观的感受:它很像一个懂话术的话务员,但不像一个真正接入了业务系统的客服。
阶段二:开始用 Prompt 约束 JSON 输出
不少团队会尝试一个过渡方案:在 Prompt 里要求模型输出固定 JSON,例如:
{"intent":"QUERY_ORDER","orderId":"ORD20231201"}然后应用解析 JSON,再去调用订单服务。
这个方案比“纯文本聊天”更进一步,但通常撑不过生产:
- 模型并不总能稳定输出合法 JSON
- 多意图问题会把单一 JSON 结构挤爆
- Prompt 中会混入越来越多格式约束,真正留给业务语义的上下文越来越少
- 一旦需要把能力开放给多个工具,靠文本约束维护会迅速失控
它不是完全不能用,但更像一个过渡层,而不是长期架构。
阶段三:引入 Function Calling,把“决定调用什么”与“真正执行什么”分开
这就是 Spring AI 开始发挥价值的阶段。
Function Calling 的关键不是“让模型能调函数”,而是把原本杂糅在 Prompt 里的几件事拆开:
- 模型负责意图理解和参数组织
- 应用负责权限校验、参数校验和执行
- 下游系统只暴露被允许的业务能力
- 最终回复仍然由模型组织,但素材来自真实执行结果
一旦边界这样拆开,系统的可靠性就会比“Prompt + JSON”高一个层级。
阶段四:函数能调了,但新的复杂度也来了
Function Calling 并不是终点,它只是让系统进入了“真正工程化”的下一阶段。接下来你很快会碰到这些问题:
- 模型选错函数
- 模型传错参数
- 函数调用太慢,把会话线程拖死
- 写操作被重复触发
- 长会话里函数结果太多,Token 爆掉
- 下游服务失败后,模型用自然语言把失败“粉饰”成成功
所以架构演进的真正逻辑不是:
聊天接口 -> Function Calling -> 大功告成
而是:
聊天接口 -> 结构化工具调用 -> 权限与幂等 -> 超时与降级 -> 审计与观测 -> 会话与成本治理
三、Spring AI 的 Function Calling,究竟解决了什么问题
3.1 它解决的不是“能不能调用接口”,而是“如何稳定地调用接口”
从能力上说,自己解析 JSON 也能调用接口;但从工程上说,Function Calling 解决的是“结构化契约”问题。
它至少把下面四件事做对了:
- 把可调用能力显式列出来,而不是藏在 Prompt 说明文字里
- 把参数结构交给 Schema 描述,而不是交给自然语言约束
- 把执行权保留在应用侧,而不是让模型直接碰业务系统
- 把函数结果重新回灌给模型,让最终答复基于真实数据而不是自由发挥
3.2 在 Spring AI 里,这条链路是怎么跑起来的
以“查询订单状态”为例,一次完整调用通常会经历下面几个步骤:
- 用户发来“帮我查一下 ORD20231201 发货了吗”
- 应用把用户消息、对话历史、当前可用函数元信息一起交给模型
- 模型不直接回答,而是返回函数调用意图,例如
queryOrder(orderId=ORD20231201) - Spring AI 在本地匹配到对应 Bean 并执行
- Bean 内部再去调用订单服务,并完成权限校验、异常兜底、日志审计
- 执行结果以结构化内容回传给模型
- 模型基于真实结果生成最终自然语言回复
这条链路里最重要的一点是:
模型只参与“理解”和“组织语言”,真正的业务可信性仍由应用保证。
3.3 为什么它比 Prompt 拼 JSON 更适合生产
因为它把不稳定的部分限制在了模型擅长的区域,把必须稳定的部分留在了代码里。
模型擅长的:
- 理解用户意图
- 从上下文里提取候选参数
- 把结果翻译成自然语言
代码必须兜底的:
- 用户身份与数据归属校验
- 参数合法性检查
- 幂等、超时、熔断、补偿
- 调用审计、链路追踪、错误分类
这条边界如果不划清,系统上线后一定会在“看起来像成功,实际上没成功”这个坑里摔跟头。
四、适合客服系统的,不是“开放所有函数”,而是“按意图暴露最小能力集”
很多刚接入 Function Calling 的项目容易犯一个错:把所有工具一股脑注册进去,然后交给模型自己选。
这在演示里很酷,在生产里很危险。
原因很简单:
- 工具越多,模型误选概率越高
- 工具描述越相近,模型区分难度越大
- 不同用户、不同上下文,本来就不该看到相同的能力集合
- 写操作与读操作的风险级别完全不同,不能被同一套策略放行
客服场景里更稳妥的做法通常是两层控制。
第一层:先做轻量意图裁剪,再决定本轮暴露哪些函数
例如:
- 规则说明类问题:只开放知识检索函数
- 商品咨询类问题:只开放商品查询函数
- 订单状态类问题:开放订单查询,不开放退款
- 明确的售后申请:开放退款或售后创建函数,但需要更严格校验
这样做的收益很直接:
- 缩小模型选择空间
- 降低误调用不存在函数的概率
- 让高风险函数只在必要时进入上下文
- 减少函数描述占用的 Token
第二层:即使函数被暴露,执行时仍然要再次校验
也就是说,“模型有资格提出调用请求”,不等于“调用一定会被执行”。
例如退款函数至少要验证:
- 当前用户是否为订单归属人
- 订单状态是否允许退款
- 是否已存在处理中或已完成的售后工单
- 本次请求是否重复提交
这一层不能依赖 Prompt,也不能依赖模型自觉。
五、一个更贴近生产的实现方式
下面这套实现思路,适合大部分基于 Spring Boot + Spring AI 的客服场景。重点不是代码写法有多炫,而是哪些边界不能省略。
5.1 先定义输入输出模型,而不是直接让函数接收一堆字符串
publicrecordOrderQueryRequest(StringorderId){}publicrecordOrderQueryResponse(StringorderId,Stringstatus,Stringmessage){}publicrecordRefundRequest(StringorderId,Stringreason){}publicrecordRefundResponse(Stringstatus,StringrefundId,Stringmessage){}这样做的意义不是“代码更优雅”,而是:
- Schema 更清晰,模型更容易生成稳定参数
- 后续扩展字段时兼容性更好
- 参数校验、日志脱敏、错误映射都更容易落地
5.2 Function Bean 的关键,不是能调通,而是把安全和失败分支写在里面
@ConfigurationpublicclassCustomerServiceFunctionConfig{@Bean@Description("根据订单号查询订单状态。仅支持查询当前登录用户自己的订单。")publicFunction<OrderQueryRequest,OrderQueryResponse>queryOrder(OrderServiceFeignClientorderClient){returnrequest->{StringuserId=SecurityContextHolder.getContext().getAuthentication().getName();if(request==null||request.orderId()==null||request.orderId().isBlank()){returnnewOrderQueryResponse(null,"INVALID_ARGUMENT","缺少有效订单号");}try{if(!orderClient.verifyOwner(request.orderId(),userId)){returnnewOrderQueryResponse(request.orderId(),"FORBIDDEN","无权查询该订单");}returnorderClient.queryOrder(request.orderId());}catch(Exceptionex){returnnewOrderQueryResponse(request.orderId(),"SYSTEM_ERROR","订单系统繁忙,请稍后重试");}};}@Bean@Description("为当前用户的订单申请退款。仅在订单满足售后条件时允许调用。")publicFunction<RefundRequest,RefundResponse>applyRefund(RefundServicerefundService){returnrequest->{StringuserId=SecurityContextHolder.getContext().getAuthentication().getName();if(request==null||request.orderId()==null||request.orderId().isBlank()){returnnewRefundResponse("INVALID_ARGUMENT",null,"缺少有效订单号");}returnrefundService.submit(userId,request.orderId(),request.reason());};}}这里真正不能省的有四件事:
- 参数校验要在函数里做,不能假设模型一定传对
- 权限校验要在函数里做,不能假设模型不会越权
- 失败返回要结构化,不能把异常栈直接抛回模型
- 写操作要交给领域服务处理幂等和状态流转,不能在聊天层直接“改库”
5.3 聊天服务层要做的,不只是调用模型,还要决定本轮开放哪些函数
@ServicepublicclassIntelligentChatService{privatefinalChatClientchatClient;privatefinalConversationMemoryServicememoryService;publicIntelligentChatService(ChatClient.Builderbuilder,ConversationMemoryServicememoryService){this.chatClient=builder.build();this.memoryService=memoryService;}publicStringchat(StringsessionId,StringuserId,StringuserMessage){List<String>candidateFunctions=routeFunctions(userMessage);List<Message>history=memoryService.load(sessionId);Stringanswer=chatClient.prompt().system(""" 你是电商智能客服。 若问题涉及订单、售后、优惠券等系统能力,只能通过已提供的函数获取事实,不允许自行编造结果。 若函数返回失败,必须如实告知用户当前无法完成。 """).messages(history).user(userMessage).functions(candidateFunctions.toArray(String[]::new)).