1. 为什么 Java 工程师转大模型开发,第一步不该是背 Prompt
我身边不少做 Java 后端的朋友,最近都在琢磨转大模型开发。大家的路径出奇一致:先看几篇 Prompt 教程,再找个 LangChain 的 Python Demo 跑一跑,然后发现——好像跟自己的技术栈没什么关系。Spring Boot 里那套依赖注入、事务、熔断、链路追踪,到了大模型应用里,突然不知道往哪放。
问题出在定位上。大模型应用开发不是让你去训练模型,也不是让你去写 Prompt 玄学,而是把「非确定性的模型调用」当成一个外部依赖,用你熟悉的工程手段把它管起来。这恰恰是 Java 工程师最擅长的事。你写过 Feign 的超时重试,写过 Redis 的缓存穿透防护,写过 Sentinel 的熔断降级,这些能力在 RAG 场景里一个都不浪费。
所以这篇不聊虚的,直接给一个可交付的最小闭环:用 Spring AI 和 LangChain4j 搭一个 RAG 骨架,把检索、拼 Prompt、调模型、返回答案这条链路跑通,并且通过 TaoToken 的统一 Key/API 通道接入模型,避免在多个厂商的 Key 和 Endpoint 之间来回切换。目标很明确——你跟着做完,手里有一个能启动、能提问、能返回带引用来源的问答服务,而不是一个只能截图发朋友圈的 Demo。
适合谁看:有 Spring Boot 基础、写过 REST 接口、知道什么是 Maven 依赖和 application.yml 的 Java 工程师。不需要你懂向量数据库原理,也不需要你调过模型参数。下面每一步都有可复制的配置和命令。
2. TaoToken 前置:统一 Key 与 API 通道怎么接
在动手写代码之前,先把模型通道这件事定下来。RAG 骨架里最容易被忽略、又最容易在后期返工的就是模型接入层。如果你一开始把某家厂商的 SDK 硬编码进 Service,后面想换模型或者加一个备用通道,就得改一堆代码。
TaoToken 在这里的角色是一个统一的 API 通道:你拿一个 Key,通过一个兼容 OpenAI 协议的 Endpoint 去调用不同模型。对 Spring AI 和 LangChain4j 来说,它们本来就支持 OpenAI 兼容的接口,所以接入成本很低。官网地址是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 基址是 https://taotoken.net/api ,注意这个 API 地址后面不加 UTM 参数,直接用于代码里的 base-url。
你需要提前准备两样东西:一个 API Key,以及确认你要用的模型名称。Key 在控制台的 API Keys 页面创建,地址是 https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。创建之后复制出来,后面配置里会用到。模型名称建议先用一个通用的对话模型跑通链路,等骨架稳定了再换更强的模型做生成。
注意:Key 不要写死在代码里,也不要提交到 Git。下面配置里我会用环境变量占位,本地开发用 IDE 的运行配置注入,线上用配置中心或容器环境变量。
如果你对模型能力还没把握,可以先去模型对话页面手动试几条问题,感受一下响应格式和延迟,地址是 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 。这一步不是必须的,但能帮你在写代码前对返回结构有个预期。
3. 可复制配置:依赖、目录结构与 settings
这一节是整篇的核心,给的是能直接抄的骨架。我按 Maven 项目来写,Gradle 用户把依赖换成对应写法即可。
3.1 Maven 依赖
Spring AI 和 LangChain4j 可以共存,但为了避免版本冲突,建议在骨架阶段二选一作为主链路。我的做法是:用 Spring AI 做 ChatClient 和 Embedding 的抽象,用 LangChain4j 的文档分割和向量存储工具做补充。下面这份依赖是实测能跑通的组合。
<properties> <java.version>17</java.version> <spring-ai.version>1.0.0-M6</spring-ai.version> <langchain4j.version>0.35.0</langchain4j.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> <version>${spring-ai.version}</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> <version>${langchain4j.version}</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-embeddings-all-minilm-l6-v2</artifactId> <version>${langchain4j.version}</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> </dependencies>这里 Embedding 我用的是 LangChain4j 自带的本地 MiniLM 模型,好处是不用额外调 Embedding API,省一层网络开销和费用,适合骨架阶段。等你要上生产,再换成远程 Embedding 服务。
3.2 目录结构
src/main/java/com/example/rag ├── RagApplication.java ├── config │ └── AiConfig.java ├── controller │ └── QaController.java ├── service │ ├── IngestionService.java │ └── RagQaService.java └── store └── InMemoryVectorStore.java src/main/resources ├── application.yml └── docs └── handbook.mddocs目录放你要检索的原始文档,骨架阶段用一个 Markdown 文件就够。InMemoryVectorStore是我自己写的一个简单内存向量存储,避免引入 Redis 或 PGVector 增加启动成本。
3.3 application.yml 配置
server: port: 8080 spring: ai: openai: base-url: https://taotoken.net/api api-key: ${TAOTOKEN_API_KEY} chat: options: model: gpt-4o-mini temperature: 0.2 embedding: enabled: false rag: chunk-size: 500 chunk-overlap: 80 top-k: 3几个关键点说明一下。base-url指向 TaoToken 的 API 地址,api-key从环境变量读。temperature设成 0.2,是因为 RAG 场景要的是事实一致性,不是创意。embedding.enabled设为 false,因为我们用 LangChain4j 的本地 Embedding,不走远程。top-k是检索返回的片段数,骨架阶段 3 就够,太多会撑爆上下文。
3.4 核心配置类
@Configuration public class AiConfig { @Bean public ChatClient chatClient(ChatClient.Builder builder) { return builder .defaultSystem("你是一个严谨的知识库助手,只根据提供的上下文回答,无法回答时明确说不知道。") .build(); } @Bean public EmbeddingModel embeddingModel() { return new AllMiniLmL6V2EmbeddingModel(); } }defaultSystem里那句约束很重要,它决定了模型会不会在检索不到内容时胡编。骨架阶段先把这条底线立住。
4. 验证请求:一次可跑的检索问答调用
配置写完,接下来把链路串起来。分两步:先把文档灌进向量库,再写问答接口。
4.1 文档切分与入库
@Service public class IngestionService { private final EmbeddingModel embeddingModel; private final InMemoryVectorStore vectorStore; public IngestionService(EmbeddingModel embeddingModel, InMemoryVectorStore vectorStore) { this.embeddingModel = embeddingModel; this.vectorStore = vectorStore; } @PostConstruct public void ingest() throws IOException { String raw = Files.readString(Path.of("src/main/resources/docs/handbook.md")); DocumentSplitter splitter = DocumentSplitters.recursive(500, 80); List<TextSegment> segments = splitter.split(Document.from(raw)); for (TextSegment segment : segments) { Embedding embedding = embeddingModel.embed(segment.text()).content(); vectorStore.add(segment.text(), embedding.vector()); } System.out.println("已入库片段数: " + segments.size()); } }DocumentSplitters.recursive(500, 80)表示每段最多 500 字符,相邻段重叠 80 字符。重叠是为了避免一句话被切断后语义丢失。启动时@PostConstruct自动执行,控制台会打印入库片段数,这是第一个可验证的信号。
4.2 检索加生成
@Service public class RagQaService { private final ChatClient chatClient; private final EmbeddingModel embeddingModel; private final InMemoryVectorStore vectorStore; public RagQaService(ChatClient chatClient, EmbeddingModel embeddingModel, InMemoryVectorStore vectorStore) { this.chatClient = chatClient; this.embeddingModel = embeddingModel; this.vectorStore = vectorStore; } public String answer(String question) { Embedding queryEmbedding = embeddingModel.embed(question).content(); List<String> contexts = vectorStore.search(queryEmbedding.vector(), 3); String contextBlock = String.join("\n---\n", contexts); String prompt = """ 请根据以下上下文回答问题。如果上下文没有相关信息,直接回答“知识库中未找到相关内容”。 上下文: %s 问题:%s """.formatted(contextBlock, question); return chatClient.prompt().user(prompt).call().content(); } }vectorStore.search返回的是余弦相似度最高的 3 个片段。拼进 Prompt 时用---分隔,方便模型区分不同来源。最后那句兜底指令和defaultSystem形成双重约束。
4.3 Controller 与验证
@RestController @RequestMapping("/api/qa") public class QaController { private final RagQaService ragQaService; public QaController(RagQaService ragQaService) { this.ragQaService = ragQaService; } @PostMapping public Map<String, String> ask(@RequestBody Map<String, String> body) { String answer = ragQaService.answer(body.get("question")); return Map.of("answer", answer); } }启动项目后,用 curl 验证:
curl -X POST http://localhost:8080/api/qa \ -H "Content-Type: application/json" \ -d '{"question":"手册里提到的部署流程是什么?"}'成功的话你会看到类似这样的返回:
{"answer":"根据上下文,部署流程分为三步:先构建镜像,再推送仓库,最后滚动更新。"}如果问一个手册里没有的问题,应该返回「知识库中未找到相关内容」。这两个结果都出现,说明检索和生成链路都通了。
5. 本篇常见错排查清单
骨架跑起来之后,最容易卡住的地方我列一下,都是实测踩过的。
启动报 401 或 403:先检查TAOTOKEN_API_KEY环境变量有没有真正注入到 IDE 的运行配置里。很多人是在系统环境变量里设了,但 IDE 启动时没继承。最直接的办法是在AiConfig里临时打印一下System.getenv("TAOTOKEN_API_KEY")的前几位,确认非空。
返回内容跟文档无关:大概率是 Embedding 和检索没对上。检查IngestionService里的入库片段数是不是 0,如果是 0,说明文档路径不对或者切分器没读到内容。另外确认top-k不要设成 1,太小容易漏掉相关片段。
中文检索效果差:MiniLM 是英文为主的模型,中文语义匹配会偏弱。骨架阶段可以接受,如果要提升,把 Embedding 换成支持中文的远程模型,在application.yml里把embedding.enabled打开并配置对应模型。
响应特别慢:先看是检索慢还是生成慢。在RagQaService里给embed和chatClient.call()分别打时间戳。如果是生成慢,把model换成更小的模型;如果是检索慢,检查向量库是不是每次请求都重新加载。
Prompt 太长报 context 超限:top-k调小,或者把chunk-size从 500 降到 300。上下文不是越多越好,无关片段反而会干扰模型。
提示:排错时优先看 Actuator 的
/actuator/health和日志里的异常栈,不要靠猜。大模型应用的错误往往藏在网络层和序列化层,不在业务代码里。
6. 从骨架到可交付:下一步怎么走
骨架跑通只是起点。真正要交付,还得补三块:可观测、可回滚、可降级。可观测就是在检索和生成两步埋点,记录耗时和 Token 消耗;可回滚是把 Prompt 模板从代码里抽出来,放到配置或数据库里,改错了能一键切回;可降级是当模型通道抖动时,返回预置的 FAQ 而不是让请求一直挂着。
如果你打算把这条链路长期用下去,尤其是做编码助手或者 Agent 类的应用,建议了解一下 Coding Plan,地址是 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite ,它在长任务和批量调用上的成本结构会更友好。接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,里面有针对 Spring AI 和 LangChain4j 的对接说明,遇到协议细节可以直接查。
最后说一句实在的:Java 转大模型开发,难的不是学新框架,而是接受「同样的输入可能得到不同的输出」这件事。一旦你把它当成一个需要治理的外部依赖,而不是一个需要崇拜的黑盒,你过去写的那些熔断、重试、缓存、监控代码,全都能用上。骨架已经在你手里了,接下来就是把它跑起来,然后一点点加护栏。