ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot+LangChain4j+Ollama:本地大模型智能客服实战

SpringBoot+LangChain4j+Ollama:本地大模型智能客服实战

做客服类项目的同学应该都有同感:业务方一上来就说“给我接个大模型”,方案评审的时候,数据合规的同事先拍桌子了——用户手机号、订单详情、历史会话记录,哪一个都不能发到外部 API。于是方案拐了个弯:把模型放进内网,自己跑。这也正是我今天想聊的这套组合的典型落地场景:SpringBoot + LangChain4j + Ollama,本地大模型接入 Java 应用,快速搭出智能客服。

这个组合适合谁?适合已经用 Java 技术栈做业务的团队,想在不引入 Python 服务、不改造现有数据链路的前提下,低成本验证智能客服的效果;也适合数据敏感、模型并发要求不高、只求“能用且安全”的内部系统。读完你至少能 Get 到三件事:为什么是这三样东西组合在一起、从零到接口跑通需要过哪些关键环节、真正上线时最容易在哪些地方翻车。

1. 先想清楚:本地大模型客服到底解决了什么问题

1.1 为什么放着云端 API 不用,偏要折腾本地部署

先讲一个我实际碰到的取舍过程。接到需求时,第一版方案写的是接云端大模型 API,理由很直接:效果最好、开发最快、不用买卡。结果一圈评审下来,被否掉的原因大家也猜得到——客服会话里会带上用户 ID、收货地址,甚至支付信息,数据一旦发到外部,责任边界就说不清了。后面改成私有化部署,模型跑在公司内网机器上,所有对话原文不出机房,合规这边才点头。

这不是说云端 API 一无是处。如果你做的是公开网站上的智能助手,不涉及敏感数据,直接用云端 API 是最优解,效果强又省运维。但客服场景有一个天然特点:它和业务系统绑得太深。要让模型回答“我的快递到哪了”,必然要把订单数据喂给它;数据一旦穿过边界,后面的合规审计就非常被动。

本地部署的第二个好处是可控性。模型参数、版本、升级节奏全在你手里,回答口径可以通过提示词和检索统一管控。第三个才是成本——客服请求量大、单次问答 token 消耗又多,长期跑云端按量付费并不便宜,自建一台带 GPU 的工作站摊薄下来可能比按量更划算。当然也得诚实说代价:要有人懂模型运维,效果上限比商用大模型低,硬件投入要先出。

1.2 SpringBoot、LangChain4j、Ollama 在链路里各干什么

这套组合选型不是拍脑袋定的,而是各自补位的:

  • SpringBoot:承载对外接口、业务逻辑、会话管理和已有后台系统打通,这是 Java 团队的舒适区。
  • Ollama:把开源大模型装进内网并跑起来的工具。它把模型下载、量化、推理封装成一套命令行和本地 API,不用自己研究模型加载细节。
  • LangChain4j:Java 侧的 LLM 编排框架,负责“怎么和大模型对话”这件事——会话记忆、提示词管理、工具调用、知识库检索都靠它。

用一个餐厅比喻:Ollama 是把厨师请进门的那道管道,负责让大厨(模型)在后厨把菜做出来;LangChain4j 是领班,知道先上什么、后上什么、客人之前说过什么,还知道什么时候让服务员去库房拿东西;SpringBoot 则是前厅的接待系统,客人来了登记、下单、结账,都是它的活。三者职责清晰、边界分明,所以后面出问题时好排查——接口慢看 Ollama,答非所问看记忆和提示词,工具参数错了看注解。

2. 模型挑选与 Ollama 环境准备:这一步决定后面顺不顺

2.1 客服场景模型怎么选:先看显存再看中文能力

模型选错,后面全白搭。客服项目不需要多强的数学和复杂推理,要的是中文理解好、回答稳定、加载快。当前开源模型里,我建议第一台机器从qwen2.5:7b入手。这个模型中文语料训练充分,对订单、物流、售后这类常见业务话术理解准确,7B 参数量化后对显存压力不大,单卡就能跑。

为了让你选型时有参照,我按常见的显存配置给一个粗略表:

模型量化后体积(约)建议显存中文效果适用场景
qwen2.5:7b4.7GB8GB 起步好客服问答、知识库、工具调用,首选
qwen2.5:14b9GB16GB更好回答质量要求高,愿意接受更慢
llama3.1:8b4.7GB8GB一般英文为主的场景可考虑
qwen2.5:32b20GB24GB+接近商用效果预算充足、硬件充足再上

这里要提醒一句:只看模型文件体积还不够,实际显存开销还包括 KV Cache。上下文越长、并发越高,KV Cache 占的显存越多。所以宁可选小一号的模型,给并发和长上下文留出余地。我之前就干过“非得用大模型,结果一并发就爆显存”的事,后来降到 7B,反而稳定了。

2.2 Ollama 安装与模型拉取:几个值得注意的小细节

Ollama 的安装本身不复杂,官网下载对应平台安装包,或者 Linux 上用官方脚本装,装完先确认服务起来了:

ollama pull qwen2.5:7b ollama list ollama ps

ollama pull拉取模型;ollama list查看本地已有模型;ollama ps查看当前正在内存/显存中加载的模型。首次 pull 会下载几个 GB,需要耐心等。

有几个细节值得现在就说:

  • 模型默认存储在系统盘,Windows 会在用户目录下建.ollama文件夹,Linux 也一样。如果系统盘紧张,提前设置环境变量OLLAMA_MODELS指到数据盘,再启动服务,不然十几 GB 的模型很容易把 C 盘塞满。
  • 默认服务只监听127.0.0.1:11434,Java 应用在同一台机器上没问题。如果 SpringBoot 跑在另一台服务器上,要给 Ollama 设置OLLAMA_HOST=0.0.0.0,同时注意防火墙和接口鉴权,别裸奔到内网里。
  • 模型首次调用时会加载到显存,大概要十几秒甚至更久。后面我会讲怎么用OLLAMA_KEEP_ALIVE让它驻留,否则高峰期频繁冷启动会让人崩溃。

2.3 先用 curl 把模型接口调通,再写 Java 代码

很多人装了 Ollama 直接写 Java 代码,一跑就报连接拒绝,然后开始怀疑依赖配错了。其实最快的方式是先用 curl 给 Ollama 发消息,确认模型本身没问题,再排查 Java 侧。

Ollama 原生生成接口:

curl http://localhost:11434/api/generate -d '{ "model": "qwen2.5:7b", "prompt": "你是一个客服助手,请用一句话介绍退货流程。", "stream": false }'

Ollama 同时提供一个 OpenAI 兼容的接口,路径是/v1/chat/completions。LangChain4j 的 Ollama 模块走原生接口,但你知道有这层兼容,后面接其他中间件时更有数。

调通之后顺手测试几个客服高频问题:催发货、改地址、发票抬头变更,看看模型中文回答的口径。这一步是给“模型效果预期”定基线——如果基线连基本话术都不太行,后面别急着写代码,先换模型或调提示词。

3. SpringBoot 工程搭建:最小可跑的问答接口

3.1 依赖与版本:JDK 17 起步,Spring Boot 3 以上

工程建议 JDK 17 起步、Spring Boot 3.2 以上。LangChain4j 在 Java 生态里更新很勤,目前主流用法是引入它的 Spring Boot Starter,Maven 坐标大概是:

<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-ollama-spring-boot-starter</artifactId> <version>1.0.0-beta2</version> </dependency>

版本号你可能需要根据自己项目时间点去 Maven 仓库看最新版。注意一个坑:LangChain4j 的 0.x 和 1.x API 有一些差异,网上老博客里的写法可能不适用于新版本,建议以官方文档的 API 为准。首次下载依赖时如果特别慢,配好 Maven 镜像源,后面会舒服很多。

3.2 配置文件里每个参数的含义

引入依赖后,Spring Boot 的自动配置会读取application.yml里的langchain4j前缀配置。一个可用的最小配置:

langchain4j: ollama: chat-model: base-url: http://localhost:11434 model-name: qwen2.5:7b temperature: 0.3 timeout: 60s log-requests: true log-responses: true

逐个说一下:

  • base-url:Ollama 的地址,Java 应用和 Ollama 同机就用默认地址,跨机改成内网 IP。
  • model-name:必须和本地已拉取的模型名称完全一致,否则 Ollama 会尝试在线拉取,拉不下来就报错。
  • temperature:客服场景建议 0.2 到 0.5。温度越高回答越发散,越低越稳定。客服话术需要稳定,别调高。
  • timeout:本地模型推理比云端慢得多,尤其首次加载时,超时设太短会出现“明明模型在跑,前端却报超时”的假故障。
  • log-requests/log-responses:调试阶段打开,上线记得关掉。否则每个请求的完整 prompt 和响应都打日志,量很大而且可能把敏感信息打出来。

3.3 第一个对话接口:不整花活,先跑通再说

配置好了,直接在任意 Controller 里注入ChatLanguageModel,写一个最简接口:

@RestController @RequestMapping("/api/chat") public class ChatController { private final ChatLanguageModel chatModel; public ChatController(ChatLanguageModel chatModel) { this.chatModel = chatModel; } @PostMapping public Map<String, String> chat(@RequestBody Map<String, String> body) { String message = body.get("message"); String answer = chatModel.generate(message); return Map.of("reply", answer); } }

把服务启动起来,用 curl 测一下:

curl -X POST http://localhost:8080/api/chat \ -H "Content-Type: application/json" \ -d '{"message": "你好,请问退货怎么操作?"}'

能拿到一个像样的文本响应,就说明 SpringBoot、LangChain4j、Ollama 三层已经打通。这个最小闭环是一切的基础,后面的记忆、工具、知识库、流式输出,都是在这个闭环上叠加能力。如果这一步没跑通,大概率在三个层面:Ollama 服务没起来(连接拒绝)、模型名不对(404 或自动拉取)、ChatLanguageModelbean 没注入成功(依赖或配置问题)。先看 Ollama 日志,再看 Spring Boot 启动日志,逐层排查。

4. 从“问一句答一句”到真正的客服:记忆、Prompt 和流式输出

4.1 多轮记忆:窗口机制怎么设,作用域怎么隔离

跑通最小接口后如果你直接拿给业务用,第一个被吐槽的肯定是“没有记忆”。用户说“我昨天退的货到哪了”,模型不认识“昨天”,因为每次请求都是独立的。LangChain4j 里加记忆很简单,用MessageWindowChatMemory包一层窗口,并通过AiServices生成带记忆的客服 Agent:

@Bean CustomerSupportAgent customerSupportAgent(ChatLanguageModel chatModel, OrderTools orderTools) { return AiServices.builder(CustomerSupportAgent.class) .chatLanguageModel(chatModel) .chatMemory(MessageWindowChatMemory.withMaxMessages(20)) .tools(orderTools) .build(); }

withMaxMessages(20)表示保留最近 20 条消息作为上下文。为什么用窗口而不是把所有历史都塞给模型?因为大模型的上下文窗口是有限的,把几个月前的聊天都带上,不仅浪费 token,还会稀释当前意图的注意力,甚至因为信息过载让模型答偏。20 条左右基本能覆盖一次客服会话的完整脉络——从报问题、追问细节、到确认结果,这个量级够用。

比“加不加记忆”更隐蔽的问题是记忆的作用域。如果我把这个 Agent 注册成 Spring 的单例 bean,所有用户共享同一个记忆窗口。A 用户上一句问的是“退货”,B 用户接着发一句“那什么时候退款”,模型会以为 B 在接着 A 的话题聊,回答错乱。客服场景必须按会话维度隔离记忆:要么在每次请求时按 sessionId 从工厂创建独立 Agent,要么按会话 ID 分配独立的ChatMemory。我在项目里用的是后者思路——按会话 ID 维护一个 Map 缓存,会话结束或超过一定时间后清理,避免内存泄漏。

4.2 系统提示词怎么写,模型才不乱说话

记忆解决的是“记得住”,提示词解决的是“不乱说”。客服机器人第一版效果好不好的关键,往往就是系统提示词。我比较推荐直接在接口上用@SystemMessage声明:

public interface CustomerSupportAgent { @SystemMessage(""" 你是内部系统的智能客服助手。你的任务是根据用户问题,结合工具查询结果与已有知识,给出准确、友好、简洁的回答。 必须遵守以下规则: 1. 语气自然,不要用书面套话,不要编造不存在的功能; 2. 凡涉及订单查询、物流状态,必须先调用查询工具获取真实数据,禁止凭想象回答; 3. 如果资料不足或不确定,请直接告知用户“我帮你转人工处理”,并结束对话; 4. 回答控制在150字以内。 """) String chat(String userMessage); }

几个容易被忽略的细节:

  • 规则要写“必须”,而不是“可以”。模型对确定性指令的执行力比语气温和的建议强得多。
  • 明确告诉模型哪些事可以做、哪些事不能做,边界越具体,越不容易翻车。
  • 不要在这里写“你是某某公司的客服”,写“你是内部系统的客服”即可,避免模型被角色描述误导生成夸大宣传内容。

4.3 流式输出:SSE 让客户不用干等

本地 7B 模型在中等配置机器上生成一段 100 字的回答可能需要三五秒甚至更久。如果用普通 HTTP 接口,用户点一下发送,界面转圈几秒才出结果,体验很糟糕。更合理的是流式输出,让用户看到文字一个一个蹦出来,延迟感知从“几秒黑屏”变成“第 0.5 秒就开始出现内容”。Spring Boot 里用SseEmitter实现 SSE 很直接:

@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter stream(@RequestParam String message) { SseEmitter emitter = new SseEmitter(60_000L); streamingChatModel.generate(message, new StreamingResponseHandler<AiMessage>() { @Override public void onNext(String token) { try { emitter.send(SseEmitter.event().data(token)); } catch (Exception e) { emitter.completeWithError(e); } } @Override public void onComplete(Response<AiMessage> response) { emitter.complete(); } @Override public void onError(Throwable error) { emitter.completeWithError(error); } }); return emitter; }

注意这里注入的是StreamingChatLanguageModel,而不是普通的ChatLanguageModel。两者是独立的接口,自动配置分别创建 bean,别混。前端用EventSource或 fetch 的流式读取接口接上即可。

流式接口的第一个坑是超时时间:生成耗时长,SseEmitter的超时要比普通请求放宽,建议 60 秒以上;第二个坑是代理层。某些中间代理服务器对长连接有闲置回收策略,SSE 半天不发数据会被掐断,需要定时发个心跳帧保持链路不断。

5. 客服还得会查自己的业务库:工具调用与知识库问答

5.1 @Tool 工具调用:让模型连上真实业务数据

对话式客服如果只能闲聊,价值有限。业务方真正要的是“我的订单到哪了”“为什么发票还没开”这类需要查真实数据的回答。这里就要用到 LangChain4j 的工具调用能力。

思路很简单:给模型暴露一个或多个 Java 方法,当模型判断用户问题需要查询真实数据时,它会自己决定调用这个方法,并把返回结果组织成回答。这个机制本质上是把模型当成一个“决策器”,数据仍由你的业务代码掌控,模型接触不到数据库连接,安全边界很清晰。

一个订单查询工具的示例:

@Component public class OrderTools { private final OrderService orderService; public OrderTools(OrderService orderService) { this.orderService = orderService; } @Tool("根据订单号查询订单当前状态与物流信息") public String queryOrderStatus(@P("用户提供的完整订单号") String orderNo) { Order order = orderService.findByOrderNo(orderNo); if (order == null) { return "未找到该订单,请让用户核对订单号"; } return "订单状态:" + order.status() + ";物流信息:" + order.expressInfo(); } }

然后通过AiServices.builder(...).tools(new OrderTools())注册给客服 Agent,前面 4.1 的代码就是这么接的。

这个过程的几个关键细节:

  • @Tool的描述要写清楚“这个方法能干什么、什么时候调用”,模型就是靠这段描述决定是否调用工具的。描述含糊,它就会在不需要查库的时候瞎调。比如“查询订单”这种描述就不够,要写成“当用户询问订单状态、物流进度、配送时间时调用”。
  • 方法参数上的@P同样重要,它告诉模型“这个参数应该填什么”。我之前见过一个案例,参数描述只写了“订单号”,模型就把用户说的任何数字都往里面塞;改成“用户提供的完整订单号,通常以字母开头”之后,准确率立刻上来了。
  • 工具返回的文本是给模型读的,不是给用户看的。所以返回内容要结构化、信息完整,模型会自己把它翻译成口语化答复。

5.2 知识库 RAG:把帮助文档变成模型可检索的记忆

业务里还有一大块需求是“回答知识问题”,比如退换货规则、发票政策、会员权益。这类内容会不断变化,也不可能把整本手册塞进提示词。正确做法是 RAG(检索增强生成):先把文档切分成片段、向量化存入向量库,用户提问时先检索最相关的几个片段,再塞给模型作为参考。

LangChain4j 里可以自己搭一个最简流程。首先用 Ollama 拉一个 embedding 模型:

ollama pull bge-m3

然后在 Java 侧配置 embedding 模型和向量库:

EmbeddingModel embeddingModel = OllamaEmbeddingModel.builder() .baseUrl("http://localhost:11434") .modelName("bge-m3") .build(); EmbeddingStore<TextSegment> store = new InMemoryEmbeddingStore<>(); // 建库:把FAQ片段向量化后存入 List<TextSegment> faqSegments = loadFaqSegments(); store.addAll( faqSegments.stream() .map(segment -> embeddingModel.embed(segment).content()) .toList(), faqSegments );

检索阶段用EmbeddingStoreContentRetriever把向量库接到 AiServices 上,模型就能在回答时自动带上相关文档片段。如果你的 FAQ 只有几十条、几百条,内存向量库完全够用;文档量大、需要持久化时再引入专用向量数据库,接口设计是兼容的,以后切换成本不大。

这里要特别提醒:embedding 模型的上下文长度有限,文档切分太大会导致检索精度下降。客服 FAQ 一句一问一答,每段保持在一两百字左右最合适。还有个实际经验:切分时不要硬按固定字数截断,尽量按“问题 + 回答”的完整语义块来切,检索出来的片段才是用户和客服都看得懂的整体。

5.3 边界兜底与转人工:别让模型硬着头皮瞎编

不管模型能力多强,客服系统的底线能力是“知道什么时候认怂”。硬编一个答案比说“我不知道”更害人。我在系统提示词的规则里专门写了:不确定就转人工。同时可以在工具层加一个转人工工具:

@Tool("将用户转接给人工客服,并附上对话小结") public String transferToHuman(@P("转人工原因,便于人工介入") String reason) { return "已为用户创建转人工工单,原因:" + reason; }

这样模型在遇到查不到资料、规则冲突、用户情绪激动等情况时,会主动调用这个工具生成工单,而不是硬着头皮编。这个设计上线后很快就体现出价值了——大部分投诉类会话最终都走了转人工通道,模型只负责第一轮分流和基本信息收集,既保住了体验,又把责任边界划清楚了。

6. 上线前必须处理的事:并发、超时、资源监控与避坑总结

6.1 本地模型的并发能力与排队策略

本地模型不是无限并发的。Ollama 收到请求后,如果模型没有加载,会先完成加载,然后按一定规则排队处理。并发请求多的时候,不是“大家都有响应”,而是“所有人都等着”。所以上线前一定要想清楚:多少个请求同时进来,系统会不会雪崩。

Ollama 侧有几个环境变量可以调:

  • OLLAMA_KEEP_ALIVE:控制模型加载后驻留内存的时间,默认 5 分钟。客服流量有明显高峰低谷,如果频繁冷启动,会把时间浪费在加载上,可以调到 30 分钟或更久。
  • OLLAMA_NUM_PARALLEL:控制同一模型的并行请求数。并行升高会显著增加显存占用,毕竟每个并发请求都有自己的 KV Cache。业务上如果是零散的客服咨询,一般不需要高并行,调成 2 或 4 就够。
  • OLLAMA_MAX_LOADED_MODELS:最多同时驻留几个模型。如果你既跑 chat 模型又跑 embedding 模型,默认配置可能把其中一个挤下去,导致频繁换载。

应用侧我建议再加一个自己的队列或限流。比如用信号量控制最大并发请求数,排队等待的请求给前端一个友好的“当前咨询较多,请稍等”提示,比让所有请求一起挤压 Ollama 更稳。

这里还要说一个硬件相关的经验:7B 模型在纯 CPU 上生成速度可能只有每秒几个 token,一段百字回答要等半分钟,这种体验拿来给内部员工用都会被嫌弃。有条件一定要上 NVIDIA 显卡,哪怕是 8GB 显存的入门卡,体验都会有质的提升。GPU 机器就专心跑模型服务,别让它同时承担桌面渲染或其他任务。

6.2 超时与首次加载的坑

这一节列的坑,基本是我实际踩过一遍的:

  • 首次请求超时。模型冷启动要从磁盘读权重、申请显存、构建 KV Cache,一次可能花十几秒甚至半分钟。如果你把应用超时设成 3 秒,第一次请求必挂。解决方式有两种:要么把超时放宽到 60 秒以上,要么系统启动后主动发一条预热请求,把模型“叫醒”常驻。
  • 连接超时与读取超时要分开设置。Ollama 接口建立连接是很快的,慢的是模型生成。如果你只配一个总超时,会把“连接挺快、生成很慢”这种情况误判成故障。
  • 前端网关超时。就算应用侧设了 60 秒,如果前端还挂着一层代理,而这个代理默认超时只有 30 秒,照样会在中间被切断。SSE 流式输出模式下,要额外确认代理关闭了缓冲、放宽了读写超时。

6.3 质量评估与灰度小技巧:用数据决定上线节奏

模型回答质量怎么评估,是很多团队容易忽略的一环。我的做法是:上线第一周不追求自动化,开日志记录每一轮用户输入和模型输出,每天抽 50 条人工过一遍,按“完全正确 / 可接受 / 错误 / 错误且无兜底”四档打标。跑两周之后,错误集中在哪一类问题、哪个场景需要补知识库、哪个工具调用需要优化,数据自然说话。

日志记录要处理好脱敏:用户 ID、手机号、地址这些字段在入日志前做掩码处理,不然本地部署也白搭。另一个很实用的灰度思路:不要一上来就把所有流量交给模型。先让机器人只处理“退换货流程”“开发票时间”这类规则明确的高频问题,其余问题一律转人工;跑稳定之后再逐步扩大范围。这样即使模型在某些边界场景出错,影响范围也可控。

最后分享一点我个人体会:这套链路真正跑起来之后,最大的感受不是“大模型真神奇”,而是“工程化细节决定了能用不能用”。工具调用描述写清楚一点、超时放宽一点、日志脱敏做好一点,这三点做到位,哪怕模型不是顶配,客服机器人也能在日常高频问题上顶住;反过来,任何一个环节偷懒,上线后接二连三的投诉都会找上门。

如果你也是第一次在 Java 项目里接本地大模型,我建议按这个顺序走:先 curl 验证 Ollama,再跑通最小接口,然后加记忆和提示词,最后才上工具调用和知识库。每一步都确认稳了再往下一层走。硬要说还有什么建议,那就是先选 qwen2.5:7b 做第一个模型,别一上来就挑战大参数,等业务确实需要更好效果时,再评估往上加显存和换模型。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进