Java 后端同学对 AI 集成的态度,这两年变化挺明显的。前两年大家还觉得大模型是算法团队的事,后端只要把接口留好就行;现在不一样了,业务方张口就是"能不能做个知识库问答""能不能让工单自动分类",需求直接压到后端头上。问题是 Java 生态里能拿得出手的 LLM 编排框架并不多,Python 那边 LangChain 玩得飞起,Java 这边要么自己裸写 HTTP 调 API,要么硬啃一些半成品。LangChain4j 就是在这个背景下进入视野的——它把 Prompt 模板、对话记忆、工具调用、RAG 检索这些能力做成了 Java 开发者熟悉的接口风格,配合 Spring Boot 的自动装配,写起来跟平时写 Service 没太大区别。
这篇内容面向的是有 Java 基础、用过 Spring Boot、但还没系统接触过 LangChain4j 的后端开发。我会从依赖引入讲到 AiService 声明式接口、TokenStream 流式输出、再到 RAG 的落地,把每个环节"为什么这么设计"和"实际会踩什么坑"都摊开说。看完你应该能独立把一个可用的 AI 功能模块塞进现有项目里,而不是停留在跑通 Demo 的阶段。
1. 先搞清楚 LangChain4j 到底替你干了什么
1.1 裸调 API 的痛点在项目变大后才暴露
刚开始接触大模型接口的时候,很多人的做法是直接用一个 HTTP 客户端发 POST 请求,把 JSON 拼好丢过去,拿到响应再解析。单次调用这么干没问题,代码也就二三十行。但一旦项目里出现多个 AI 功能点,问题就来了:每个功能都要重复写请求构造、超时重试、异常处理;Prompt 散落在各个 Service 里,改一句话要全局搜索;多轮对话的上下文管理得自己维护一个 List,还要考虑截断策略;想换个模型供应商,发现调用代码跟某家 SDK 绑死了。
这些问题的本质是:大模型调用不是一次性的工具方法,而是一套需要编排的能力。LangChain4j 的价值就在于把这套编排抽象成了 Java 接口。它做的事情可以类比成 MyBatis 之于 JDBC——你当然可以裸写 JDBC,但没人愿意在业务代码里到处PreparedStatement。
具体来说,它替你处理了这几层:
- 模型接入层:统一了不同供应商的调用协议,切换模型通常只改配置不改代码。
- Prompt 管理层:用注解和模板把 Prompt 从代码里抽出来,支持变量填充。
- 记忆管理层:对话历史、窗口截断、持久化都有现成实现。
- 检索增强层:文档加载、切分、向量化、相似度检索形成完整链路。
- 工具调用层:让模型能触发你定义的 Java 方法,实现"函数调用"。
1.2 它的抽象层次和 Spring 的设计哲学是一脉相承的
用过 Spring 的人对"面向接口 + 自动装配"这套很熟。LangChain4j 的ChatLanguageModel接口就相当于一个可替换的数据源,AiService相当于声明式的 Repository,EmbeddingStore相当于向量版的 DAO。你定义接口,框架给你生成实现,运行时通过配置决定用哪个具体实现。
这种设计带来的直接好处是可测试性。你可以用一个假的ChatLanguageModel实现来跑单元测试,不用真的去调外部服务。我在项目里就干过这事:写一个返回固定字符串的 mock model,把 AiService 的业务逻辑测了个遍,CI 里跑得飞快,也不产生任何调用费用。
提示:LangChain4j 的版本迭代比较快,不同大版本之间 API 有破坏性变更。引入依赖时务必锁定版本号,别用动态版本,否则某天构建突然失败会很难排查。
1.3 什么场景适合用它,什么场景别硬上
不是所有 AI 需求都值得引入框架。我的判断标准是这样的:
| 场景 | 是否推荐 | 原因 |
|---|---|---|
| 单次文本生成,无上下文 | 可不用 | 裸调 API 更轻 |
| 多轮对话、需要记忆 | 推荐 | 记忆管理省事 |
| 知识库问答(RAG) | 强烈推荐 | 检索链路复杂,自己写容易漏 |
| 需要模型调用本地方法 | 推荐 | 工具调用抽象完善 |
| 高并发、极致性能要求 | 谨慎 | 框架有额外开销,需压测 |
| 只做一次性的批处理脚本 | 可不用 | 引入框架反而累赘 |
我见过有团队为了一个"把用户评论翻译成英文"的功能引入整套框架,结果配置文件比业务代码还长。这种就是典型的过度设计。框架是用来解决复杂度的,如果本身没有复杂度,引入框架就是凭空制造复杂度。
2. 环境搭建:依赖、配置和第一个能跑通的调用
2.1 依赖引入的版本对齐问题
LangChain4j 是模块化拆分的,核心包和集成包分开。以 Maven 为例,最基础的组合是这样:
<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> <version>0.35.0</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-open-ai</artifactId> <version>0.35.0</version> </dependency>如果是 Spring Boot 项目,还有专门的 starter:
<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-spring-boot-starter</artifactId> <version>0.35.0</version> </dependency>这里有个坑必须提醒:核心包和集成包的版本号必须一致。我遇到过有人核心包用 0.35.0,open-ai 包用 0.31.0,编译能过,运行时抛NoSuchMethodError,排查了半天。原因是集成包依赖了核心包的内部 API,版本错位就会出问题。建议在dependencyManagement里统一管理版本。
另外,如果你用的是 Spring Boot 3.x,注意 starter 对 Spring Boot 版本有要求。2.x 的项目可能需要降级使用,或者手动配置 Bean 而不走 starter。
2.2 模型配置:把密钥和模型名从代码里赶出去
配置模型实例有两种方式,一种是用 starter 的自动配置,在application.yml里写:
langchain4j: open-ai: chat-model: api-key: ${OPENAI_API_KEY} model-name: gpt-4o-mini temperature: 0.7 timeout: PT60S log-requests: true log-responses: true另一种是手动构建 Bean:
@Configuration public class ModelConfig { @Bean public ChatLanguageModel chatLanguageModel( @Value("${ai.api-key}") String apiKey, @Value("${ai.base-url}") String baseUrl) { return OpenAiChatModel.builder() .apiKey(apiKey) .baseUrl(baseUrl) .modelName("gpt-4o-mini") .temperature(0.7) .timeout(Duration.ofSeconds(60)) .maxRetries(2) .logRequests(true) .logResponses(true) .build(); } }我倾向于手动构建,原因有两个:一是配置项集中,改起来一目了然;二是方便做多模型共存,比如便宜模型处理简单任务、贵模型处理复杂任务,手动建两个 Bean 加@Qualifier区分就行。
关于参数,几个关键项值得说清楚:
- temperature:控制随机性。做知识问答、数据抽取这类要求稳定的任务,建议设 0 到 0.3;做创意文案可以设 0.7 到 1.0。
- timeout:一定要设。大模型响应慢是常态,不设超时可能把 Tomcat 线程池拖垮。我一般设 60 秒,流式场景可以更长。
- maxRetries:网络抖动时自动重试,但要注意重试会重复计费,对成本敏感的场景设 1 到 2 次即可。
- logRequests / logResponses:开发期打开方便调试,生产环境务必关掉,因为日志里会包含完整的 Prompt 和用户数据,有隐私风险。
注意:API Key 绝对不能硬编码进代码提交到仓库。用环境变量或配置中心,并且确保日志脱敏。我见过有人把 Key 写在
application.yml里推到公开仓库,第二天就收到了异常账单。
2.3 第一个调用:从"能跑"到"跑得稳"
最小可运行代码长这样:
ChatLanguageModel model = OpenAiChatModel.builder() .apiKey(System.getenv("OPENAI_API_KEY")) .modelName("gpt-4o-mini") .build(); String answer = model.generate("用一句话解释什么是依赖注入"); System.out.println(answer);这段代码能跑通,但离生产可用还差得远。生产环境要考虑的至少包括:异常分类处理(超时、限流、内容审核拒绝要区别对待)、降级策略(模型不可用时返回什么)、监控埋点(调用耗时、token 消耗、成功率)。我一般会包一层自己的AiClient,把这些横切逻辑收进去,业务代码只依赖这个封装。
关于 token 消耗,这里有个容易忽略的点:输入和输出都计费,而且输入往往是大头。RAG 场景下,检索出来的文档片段会拼进 Prompt,如果检索策略不当,一次请求塞进去几千 token 很常见。所以检索的 topK 和片段长度要控制,不是越多越好。
3. AiService:把 AI 调用写成接口声明
3.1 声明式接口为什么比手动拼 Prompt 更靠谱
手动调model.generate()的时候,Prompt 是字符串拼接出来的。这种方式在 Prompt 简单时没问题,但一旦涉及多轮对话、系统角色设定、变量填充,字符串拼接就会变成灾难——引号转义、换行处理、变量遗漏,全是坑。
AiService的思路是:你定义一个 Java 接口,用注解描述 Prompt 结构,框架在运行时生成代理实现。看个例子:
public interface Assistant { @SystemMessage("你是一个严谨的技术文档助手,回答只基于事实,不确定时明确说明") String chat(@UserMessage String userMessage); }然后这样创建和使用:
Assistant assistant = AiServices.create(Assistant.class, model); String reply = assistant.chat("解释一下什么是幂等性");对比手动拼接,好处很明显:Prompt 结构在接口定义里一目了然,改 Prompt 不用翻业务逻辑;参数通过方法签名传递,类型安全;框架自动处理消息角色(system / user / assistant)的组装。
3.2 多轮对话的记忆是怎么挂上去的
默认情况下,每次调用chat都是独立的,模型不记得上一轮说了什么。要支持多轮对话,需要挂一个ChatMemory:
Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .chatMemory(MessageWindowChatMemory.withMaxMessages(20)) .build();MessageWindowChatMemory保留最近 N 条消息,超出就丢弃最早的。这个策略简单有效,但有个细节要注意:它按消息条数算,不按 token 算。如果某条消息特别长(比如用户粘贴了一大段代码),20 条消息可能就超了模型的上下文窗口。更稳妥的做法是用TokenWindowChatMemory,按 token 数控制,但需要传入一个TokenCountEstimator。
记忆的存储位置也值得考虑。默认是内存,重启就丢。生产环境通常要持久化,LangChain4j 提供了ChatMemoryStore接口,你可以用 Redis 或数据库实现它。我一般用 Redis,key 用会话 ID,value 存序列化后的消息列表,设个过期时间,既解决了多实例部署的共享问题,也自动清理了僵尸会话。
提示:会话 ID 的生成要小心。如果用用户 ID 做 key,同一用户多标签页会串话;如果用随机 UUID,刷新页面就丢上下文。常见做法是前端生成一个会话 ID 存在 sessionStorage 里,随请求带上。
3.3 结构化输出:让模型返回能直接用的对象
让模型返回一段自然语言容易,让它返回结构化的 JSON 并映射成 Java 对象,才是业务真正需要的。LangChain4j 支持这样定义:
public interface SentimentAnalyzer { @UserMessage("判断下面评论的情感倾向:{{it}}") Sentiment analyze(String comment); } public record Sentiment( @Description("情感倾向,只能是 POSITIVE / NEGATIVE / NEUTRAL") String sentiment, @Description("置信度,0 到 1 之间的小数") double confidence ) {}框架会自动把返回内容解析成Sentiment对象。这里的关键是@Description注解——它会被拼进 Prompt,告诉模型每个字段的含义和取值范围。字段描述写得越清楚,解析成功率越高。我踩过的坑是:字段用enum类型时,如果描述里没列出所有枚举值,模型可能返回一个不存在的值导致反序列化失败。所以枚举字段一定要在描述里把可选值列全。
另一个坑是模型偶尔会在 JSON 外面包一层 markdown 代码块标记(json ...),早期版本会解析失败。较新版本做了容错处理,但如果遇到解析异常,先检查返回的原始内容长什么样,再决定是调 Prompt 还是加后处理。
4. TokenStream:流式输出在 Web 场景的完整落地
4.1 为什么流式不是"锦上添花"而是刚需
大模型生成一段 500 字的回答,可能要 10 到 20 秒。如果等全部生成完再返回,用户盯着转圈圈十几秒,体验极差,而且网关和负载均衡器可能因为超时直接断开连接。流式输出让首字节时间缩短到 1 秒以内,用户能边看边等,感知上的等待时间大幅下降。
LangChain4j 的流式接口是StreamingChatLanguageModel,用法和同步模型类似,但返回的是回调:
StreamingChatLanguageModel streamingModel = OpenAiStreamingChatModel.builder() .apiKey(System.getenv("OPENAI_API_KEY")) .modelName("gpt-4o-mini") .build(); streamingModel.generate("写一段关于缓存穿透的说明", new StreamingResponseHandler<AiMessage>() { @Override public void onNext(String token) { // 每收到一个片段就推给前端 } @Override public void onComplete(Response<AiMessage> response) { // 生成结束 } @Override public void onError(Throwable error) { // 出错处理 } });4.2 和 Spring Boot 的 SSE 对接
Web 场景下,流式输出通常走 SSE(Server-Sent Events)。Spring Boot 里用SseEmitter或者 WebFlux 的Flux都能实现。用SseEmitter的话,核心是把onNext里的 token 通过 emitter 发出去:
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter streamChat(@RequestParam String question) { SseEmitter emitter = new SseEmitter(120_000L); streamingModel.generate(question, new StreamingResponseHandler<AiMessage>() { @Override public void onNext(String token) { try { emitter.send(SseEmitter.event().data(token)); } catch (IOException e) { emitter.completeWithError(e); } } @Override public void onComplete(Response<AiMessage> response) { emitter.complete(); } @Override public void onError(Throwable error) { emitter.completeWithError(error); } }); return emitter; }这段代码有几个实战要点。超时时间要设够,SseEmitter构造参数是毫秒,我一般设 120 秒,比模型超时略长。异常要兜住,客户端提前断开时emitter.send会抛IOException,这时候要停止后续推送,否则日志会被刷屏。要考虑背压,如果模型吐字速度远快于网络传输,emitter 的缓冲区会堆积,极端情况下 OOM。生产环境建议加一层限流。
4.3 流式场景下的记忆和中断处理
流式输出和ChatMemory结合时有个细节:记忆是在onComplete时才写入的。如果用户中途关闭页面,这次对话不会进记忆。这通常是符合预期的,但如果你希望"用户看到的部分也算数",就得在onError或连接断开时手动保存已生成的内容。
另一个问题是中断。用户点了"停止生成",后端怎么知道?常见做法是前端断开 SSE 连接,后端在IOException里感知到,然后调用模型的取消方法。LangChain4j 较新版本支持通过CancellationToken取消,但需要模型实现支持。如果用的模型不支持取消,那只能等它生成完,白白消耗 token。这一点在选型时要确认清楚。
注意:流式接口的 token 统计比同步接口麻烦,因为
onComplete拿到的Response里才有完整的 token 用量。如果要做实时计费或限额,得在onComplete里统一记账,别在onNext里累加,那样不准。
5. RAG 落地:从文档到可检索的知识库
5.1 RAG 解决的是"模型不知道你公司的事"这个问题
大模型的训练数据有截止日期,也不包含你内部的文档。RAG(检索增强生成)的思路是:把相关文档片段检索出来,拼进 Prompt,让模型基于这些片段回答。这样既不用微调模型,又能保证答案有据可依。
LangChain4j 的 RAG 链路分两个阶段:索引阶段(文档加载、切分、向量化、存储)和检索阶段(查询向量化、相似度检索、拼装 Prompt)。先看索引:
Document document = FileSystemDocumentLoader.loadDocument( Paths.get("/docs/manual.pdf"), new ApachePdfBoxDocumentParser() ); DocumentSplitter splitter = DocumentSplitters.recursive(500, 50); List<TextSegment> segments = splitter.split(document); EmbeddingModel embeddingModel = OpenAiEmbeddingModel.builder() .apiKey(System.getenv("OPENAI_API_KEY")) .modelName("text-embedding-3-small") .build(); EmbeddingStore<TextSegment> store = new InMemoryEmbeddingStore<>(); EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder() .documentSplitter(splitter) .embeddingModel(embeddingModel) .embeddingStore(store) .build(); ingestor.ingest(document);5.2 切分策略:RAG 效果好坏的分水岭
上面代码里DocumentSplitters.recursive(500, 50)的两个参数是片段最大字符数和重叠字符数。这两个值怎么定,直接决定检索质量。
片段太大,检索出来的内容包含太多无关信息,浪费 token 还干扰模型判断;片段太小,一个完整的语义单元被切碎,检索到的片段缺头少尾,模型看不懂。500 字符是个经验起点,但不同文档类型要调整:技术文档段落短,可以设 300 到 500;法律合同条款长,可能要 800 到 1000。
重叠字符的作用是防止关键信息正好落在切分边界上被割裂。50 字符的重叠意味着相邻片段有 50 字符的重复内容。重叠越大,边界信息越完整,但存储和检索成本也越高。我一般设片段长度的 10% 到 15%。
recursive切分器的逻辑是优先按段落切,段落太长再按句子切,句子还长再按字符切。这个策略比单纯按固定长度切要合理得多,因为它尽量保持了语义完整性。
5.3 检索质量调优:topK、相似度阈值和重排序
检索阶段的核心参数是 topK——返回最相似的 K 个片段。K 太小可能漏掉关键信息,K 太大则 Prompt 膨胀。我一般从 3 到 5 开始试,根据实际效果调整。
相似度阈值是另一道闸门。如果所有片段的相似度都很低,说明知识库里根本没有相关内容,这时候硬塞给模型只会让它胡编。设置一个最低相似度阈值(比如 0.6),低于阈值的片段直接丢弃,模型拿不到上下文就会老实说"不知道"。
更进一步的做法是重排序。向量检索用的是双塔模型,速度快但精度有限。可以先检索出 top 20,再用一个交叉编码器模型对这 20 个做精排,取前 5 个。LangChain4j 支持接入重排序模型,代价是多一次模型调用,延迟增加几百毫秒。对准确率要求高的场景值得上。
| 参数 | 作用 | 调优方向 |
|---|---|---|
| topK | 返回片段数量 | 从 3 起调,观察召回率 |
| 相似度阈值 | 过滤低相关片段 | 0.5 到 0.7 之间试 |
| 片段长度 | 单片段信息量 | 按文档类型调整 |
| 重叠长度 | 边界信息保留 | 片段长度的 10% 到 15% |
| 重排序 | 提升排序精度 | 准确率优先时启用 |
5.4 把 RAG 接进 AiService
检索到的内容要拼进 Prompt,LangChain4j 提供了ContentRetriever抽象,配合AiServices可以自动完成"检索 + 拼装":
ContentRetriever retriever = EmbeddingStoreContentRetriever.builder() .embeddingStore(store) .embeddingModel(embeddingModel) .maxResults(5) .minScore(0.6) .build(); Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .contentRetriever(retriever) .build();这样每次调用assistant.chat(question)时,框架会自动用问题去检索,把结果拼进 Prompt。你不需要手动管理检索逻辑,接口调用方式和普通对话完全一样。
这里有个实战经验:检索到的内容要在 Prompt 里明确标注来源和边界。比如用"以下是与问题相关的资料,请仅基于这些资料回答,资料中没有的信息不要编造"这样的系统提示。否则模型可能把检索内容和自己的训练知识混在一起,给出看似合理实则错误的答案。
6. 工具调用:让模型能操作你的系统
6.1 工具调用的本质是"模型决定调什么,你负责执行"
工具调用(Function Calling)让模型在需要时触发你定义的 Java 方法。比如用户问"帮我查一下订单 12345 的状态",模型识别出需要调用查询订单的方法,框架执行后把结果回传给模型,模型再组织成自然语言回答。
定义工具很简单,用@Tool注解:
public class OrderTools { @Tool("根据订单号查询订单状态") public String queryOrderStatus(@P("订单号") String orderId) { // 实际查询逻辑 return orderService.getStatus(orderId); } }然后注册到 AiService:
Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .tools(new OrderTools()) .build();6.2 工具描述写不好,模型就不会用
@Tool和@P里的描述文字会作为 Prompt 的一部分发给模型,模型据此判断什么时候调用、传什么参数。描述的质量直接决定工具调用的准确率。
我踩过的坑:一开始工具描述写得很简略,比如"查询订单",结果模型经常在用户只是闲聊提到"订单"两个字时就触发调用。后来改成"当用户明确提供了订单号并询问该订单的状态时调用此工具",误触发就少多了。参数描述也一样,"订单号"要写成"订单号,通常是 10 到 20 位的数字字符串",模型才知道该传什么格式。
另一个经验是工具数量不要太多。一次给模型几十个工具,它选择困难,准确率下降。如果确实有很多功能,可以按场景分组,不同场景用不同的 AiService 实例,各自挂载相关的工具子集。
6.3 工具执行的安全边界
工具调用意味着模型能间接操作你的系统,安全边界必须划清楚。几条硬性规则:
- 工具方法只做读操作,或者做有严格校验的写操作。绝不能让模型直接执行删除、转账这类高危操作。
- 参数必须校验。模型可能传进来格式不对甚至恶意的参数,工具方法内部要像对待外部输入一样做校验。
- 加权限控制。工具执行前检查当前用户是否有权限,不能因为调用方是模型就跳过鉴权。
- 记录审计日志。每次工具调用都要留痕,包括谁触发的、传了什么参数、返回了什么。
注意:工具调用的链路比普通对话长,延迟也更高(模型判断 + 工具执行 + 模型组织回答,至少两次模型调用)。对响应时间敏感的场景要评估是否值得。
7. 上线前必须处理的几件事
7.1 异常分类:不是所有错误都该重试
大模型调用可能遇到的异常五花八门,处理方式完全不同:
| 异常类型 | 典型原因 | 处理策略 |
|---|---|---|
| 超时 | 模型响应慢、网络抖动 | 可重试,但要设上限 |
| 限流(429) | 调用频率超限 | 退避重试,降低并发 |
| 认证失败(401) | Key 失效或错误 | 不重试,告警 |
| 内容审核拒绝 | 输入或输出违规 | 不重试,提示用户 |
| 参数错误(400) | Prompt 超长、格式错 | 不重试,记录排查 |
| 服务端错误(5xx) | 供应商故障 | 有限重试,考虑降级 |
我一般会定义一个AiException体系,把上述类型映射成不同的业务异常,上层根据类型决定是重试、降级还是直接报错。无脑重试是最危险的做法,限流场景下重试只会让情况更糟。
7.2 降级方案:模型不可用时系统不能瘫
AI 功能再重要,也不该成为整个系统的单点。降级策略要提前设计:
- 功能降级:模型不可用时,知识问答返回"当前服务繁忙,请稍后再试",而不是抛 500。
- 缓存兜底:高频问题的答案可以缓存,模型挂了先返回缓存。
- 开关控制:通过配置中心控制 AI 功能的开关,出问题能一键关闭,不影响主流程。
- 超时熔断:用 Resilience4j 或 Sentinel 做熔断,连续失败后快速失败,避免线程堆积。
7.3 成本监控:别等账单来了才发现
Token 消耗是要花钱的,而且容易失控。我建议做这几件事:
- 按功能维度统计 token 消耗,知道钱花在哪。
- 设置日限额,超过阈值告警甚至自动降级。
- 监控单次请求的 token 量,异常大的请求要排查是不是 Prompt 拼接出了问题。
- 定期分析低价值调用,比如某些高频但答案固定的问题,考虑改成缓存或规则匹配。
7.4 可观测性:出问题时能快速定位
AI 链路的排查比普通接口难,因为中间多了模型这个黑盒。必要的埋点包括:请求的完整 Prompt(脱敏后)、模型返回的原始内容、检索到的片段及相似度分数、工具调用的入参出参、各阶段耗时。这些信息在排查"为什么答错了"时是救命的。但要注意,这些日志包含用户数据,存储和访问都要做权限控制,并设置合理的保留期限。
8. 一些绕不开的实战体会
LangChain4j 的定位很清晰:让 Java 后端用自己熟悉的方式接入大模型能力。它不追求功能大而全,但在 Java 生态里,它把该抽象的抽象了,该留的口子留了。用下来最大的感受是,它降低了入门门槛,但没有降低做好一个 AI 功能的门槛。框架帮你处理了调用编排,但 Prompt 怎么写、检索怎么调、异常怎么兜、成本怎么控,这些还是得自己琢磨。
几个具体建议给准备上手的同学。第一,先用小模型把链路跑通,别一上来就用最贵的模型,调试阶段 token 消耗很快。第二,把 Prompt 当代码管理,纳入版本控制,改动要有记录,因为 Prompt 的微小改动可能导致输出质量大幅波动。第三,尽早建立评测集,准备一批问题和期望答案,每次改 Prompt 或换模型都跑一遍,用数据说话而不是凭感觉。第四,关注框架版本更新,LangChain4j 迭代快,新版本往往修复了老版本的坑,但升级前务必看 changelog,破坏性变更不少。
最后说个容易被忽略的点:AI 功能的用户体验设计和技术实现同样重要。流式输出要有打字机效果,等待时要有明确的加载状态,答错了要有反馈入口,这些细节决定了用户是觉得"这功能真智能"还是"这什么玩意儿"。技术跑通了只是第一步,打磨体验才是留住用户的关键。