news 2026/10/6 11:05:02

Spring AI Function Calling 实战:Java 后端从零跑通大模型工具调用链路

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring AI Function Calling 实战:Java 后端从零跑通大模型工具调用链路

1. 为什么 Function Calling 值得花时间吃透

Function Calling 这个词这两年被聊得很多,但真正在 Java 后端项目里把它跑通、跑稳的人其实没那么多。我身边不少做 Spring Boot 的朋友,模型对话接得挺顺,一到"让模型去调用我系统里的真实接口"这一步就卡住了:要么模型死活不触发工具,要么参数拼得乱七八糟,要么返回结果对不上。说到底,Function Calling 不是一个"调个 API 就完事"的功能,它是一套让大模型和你的业务系统握手的协议。

我这次做的事情很明确:用 Spring AI 搭一个完整的 Function Calling 实战项目,从零把整条链路走通——模型识别意图、生成结构化调用参数、后端执行真实业务逻辑(查 MySQL、算数据、调服务)、再把结果回灌给模型生成自然语言回答。技术栈就是大家最熟的那套:Spring Boot + Spring AI + MySQL + Java。适合谁看?只要你会写 Spring Boot 的 Controller 和 Service,能看懂 MyBatis 的 Mapper,这篇就能直接抄作业。哪怕你之前没接触过 Spring AI,我也会把每一步为什么这么做讲清楚。

我踩过的坑不少,比如工具描述写得含糊导致模型不调用、参数类型对不上导致反序列化失败、多工具场景下模型选错工具等等。这些在官方文档里基本一笔带过,但在真实项目里天天遇到。所以这篇不只是"怎么跑通",更是"怎么跑稳"。

2. 整体设计与技术选型拆解

2.1 Function Calling 到底解决了什么问题

先把这个概念说透。大模型本身是个"语言天才 + 数据瞎子"——它知道怎么组织语言,但它不知道你数据库里今天有多少订单、不知道某个用户的会员等级、更不知道实时库存。传统做法是把这些数据塞进 Prompt 里喂给模型,问题是数据量大、时效性差、还容易超上下文。

Function Calling 的思路反过来:不让模型"知道"数据,而是让模型"知道去哪里拿数据"。你提前把系统里可用的能力(函数)注册给模型,模型在对话时判断"这个问题需要调用某个函数",然后输出一个结构化的调用请求(函数名 + 参数),你的后端拿到请求去执行真实逻辑,把结果返回给模型,模型再基于结果组织回答。

打个比方:模型是个很聪明的客服,但它没有系统权限。Function Calling 就是给它配了一本"操作手册",手册上写着"查订单找谁、算价格找谁",它需要的时候按手册发起请求,真正干活的是后台系统。这样模型负责理解和表达,业务系统负责执行和兜底,职责清晰。

2.2 为什么选 Spring AI 而不是自己手撸

有人会问,Function Calling 本质就是拼 JSON、解析 JSON,我自己用 HTTP 客户端调模型接口不就行了?理论上可以,但实际做起来你会发现要处理的东西非常多:不同模型厂商的工具描述格式不一样、流式响应下的工具调用分片、多轮工具调用的状态管理、参数 schema 的生成和校验……这些 Spring AI 都帮你封装好了。

Spring AI 的核心价值在于它提供了一套统一的抽象:@Tool注解声明工具、ChatClient统一对话入口、ToolCallback管理工具注册。你写业务逻辑,框架处理协议细节。而且它和 Spring Boot 的生态无缝衔接——你的 Service、Repository 直接就能变成模型可调用的工具,不用额外搭一层。

选型上我做了几个取舍,列个表更清楚:

方案优势劣势适用场景
Spring AI与 Spring 生态无缝、注解式声明、统一抽象版本迭代快、部分高级特性文档少Spring Boot 项目首选
手撸 HTTP 调用完全可控、无框架依赖协议细节全自己处理、维护成本高极简场景或特殊定制
其他语言 SDK生态成熟与 Java 后端割裂、需跨服务调用多语言架构

我的判断是:只要你是 Spring Boot 项目,Spring AI 就是当前最省心的选择。它把"模型调用"这件事变成了 Spring 风格的编程,学习成本主要花在理解 Function Calling 的机制上,而不是框架本身。

2.3 项目整体架构设计

整个项目的分层我设计得很清晰,避免把模型调用和业务逻辑搅在一起:

  • Controller 层:对外暴露对话接口,接收用户消息,返回模型回答。
  • ChatService 层:封装 ChatClient,负责组织对话、注册工具、处理多轮调用。
  • Tool 层:用@Tool注解声明的工具方法,每个方法对应一个业务能力。
  • Business Service 层:真正的业务逻辑,比如订单查询、库存计算。
  • Repository 层:MyBatis Mapper,对接 MySQL。

这样分层的好处是:工具层只是"薄薄的一层适配",把业务能力暴露给模型,真正的逻辑还在原来的 Service 里。以后要加新工具,只需要在 Tool 层加个方法,业务代码完全不用动。这也是我一直强调的——Function Calling 不应该侵入你的业务架构,它只是一个"对外接口"。

3. 核心细节解析与实操要点

3.1 环境准备与依赖配置

先把地基打好。我用的是 Spring Boot 3.x + Spring AI,JDK 要求 17 以上,这点要注意,Spring Boot 3 已经不支持 JDK 8 了。MySQL 用 8.0 就行,本地装或者 Docker 起一个都可以。

Maven 依赖核心是这几块:

<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-openai</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>3.0.3</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> </dependency>

注意:Spring AI 的 starter 命名在不同版本间有过调整,早期是spring-ai-openai-spring-boot-starter,新版本改成了spring-ai-starter-model-openai。如果你照着老教程配依赖报找不到,八成是命名变了,去官方仓库确认当前版本的正确 artifactId。

配置文件里配好模型连接信息和数据库:

spring: ai: openai: api-key: ${AI_API_KEY} base-url: ${AI_BASE_URL} chat: options: model: qwen-plus temperature: 0.7 datasource: url: jdbc:mysql://localhost:3306/demo?useSSL=false&serverTimezone=Asia/Shanghai username: root password: ${DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver

这里base-url和model是可配置的,方便你切换不同的模型服务。temperature我建议 Function Calling 场景下别设太高,0.7 以下比较稳,太高了模型容易"自由发挥",参数拼错。

3.2 工具声明:@Tool 注解的正确打开方式

这是整个项目最核心的部分。工具声明写得好不好,直接决定模型能不能正确调用。先看一个我实际用的例子——订单查询工具:

@Component public class OrderTools { private final OrderService orderService; public OrderTools(OrderService orderService) { this.orderService = orderService; } @Tool(description = "根据订单号查询订单详情,返回订单状态、金额、下单时间。订单号格式为ORD开头加12位数字") public OrderDetail queryOrder( @ToolParam(description = "订单号,例如 ORD202401010001") String orderNo) { return orderService.getByOrderNo(orderNo); } }

看起来简单,但每个细节都有讲究。description是给模型看的,不是给人看的。模型完全靠这段描述判断"什么时候该调用这个工具"。所以描述里必须包含三要素:这个工具做什么、输入长什么样、返回什么。

我一开始把描述写成"查询订单",结果模型经常不调用,或者用户问"我的包裹到哪了"它也不触发。后来改成"根据订单号查询订单详情,返回订单状态、金额、下单时间",命中率立刻上来了。因为"包裹到哪了"本质是问订单状态,描述里点明了"订单状态",模型就能关联上。

@ToolParam的参数描述同样重要。模型要根据描述生成参数值,如果只写"订单号",模型可能生成"12345"这种不符合格式的值。写上"订单号格式为ORD开头加12位数字"并给个示例,模型生成的参数就规范多了。

3.3 参数类型与返回值的设计陷阱

这里有个很多人踩的坑:工具方法的参数和返回值类型,直接影响序列化和反序列化的成败。

参数类型上,尽量用简单类型(String、Integer、Long、Boolean)或者结构清晰的 POJO。别用 Map、List 这种泛型嵌套太深的,模型生成参数时容易出错,反序列化也容易失败。如果确实需要复杂参数,拆成多个简单参数更稳。

返回值类型上,我强烈建议返回结构化的 POJO 而不是裸字符串。原因有两个:一是模型能更好地理解结构化数据,生成更准确的回答;二是方便你后续做日志和调试。但要注意,返回的 POJO 字段别太多,字段名要语义清晰,否则模型读起来费劲。

public record OrderDetail( String orderNo, String status, BigDecimal amount, LocalDateTime createTime ) {}

用 record 是个好选择,简洁且不可变。字段名用英文,但可以在@Tool描述里说明每个字段的含义,帮助模型理解。

实操心得:工具返回值里千万别塞敏感信息,比如用户手机号、身份证、完整地址。模型会把这些原样吐给用户,造成隐私泄露。需要展示时做脱敏处理,比如手机号中间四位打码。

3.4 多工具场景下的注册与选择

真实项目里不可能只有一个工具。我这次做了三个:订单查询、库存查询、价格计算。多工具注册时,模型需要从一堆工具里选对的那个,这时候工具描述的"区分度"就特别关键。

如果两个工具描述太像,模型就会选错。比如"查询订单"和"查询订单物流",描述里如果不点明区别,模型很容易混。我的做法是在描述里明确边界:"查询订单详情(不含物流信息)"和"查询订单物流轨迹(仅物流状态)",把各自的职责划清楚。

注册多个工具的方式:

@Configuration public class ToolConfig { @Bean public ChatClient chatClient(ChatClient.Builder builder, OrderTools orderTools, InventoryTools inventoryTools, PriceTools priceTools) { return builder .defaultTools(orderTools, inventoryTools, priceTools) .build(); } }

defaultTools会把这三个工具注册到每次对话中。如果工具特别多(比如几十个),全量注册会拖慢响应、增加模型选择难度,这时候可以考虑按场景动态注册,或者用工具分组。我一般控制在 10 个以内,超过就说明该拆服务了。

4. 实操过程与核心环节实现

4.1 从一次对话看完整调用链路

光讲概念太虚,我带你走一遍真实的调用过程。假设用户问:"帮我查一下订单 ORD202401010001 现在什么状态?"

第一步,模型识别意图。用户消息连同所有工具的描述一起发给模型。模型分析后发现"查订单状态"匹配到了queryOrder工具,于是不直接回答,而是生成一个工具调用请求:

{ "tool": "queryOrder", "arguments": { "orderNo": "ORD202401010001" } }

第二步,后端执行工具。Spring AI 拦截到这个请求,找到对应的queryOrder方法,把参数反序列化后调用。方法内部走OrderService→OrderMapper→ MySQL,拿到真实数据。

第三步,结果回灌。工具执行结果被序列化后,作为一条"工具消息"追加到对话历史里,再次发给模型。模型这次拿到了真实数据,组织成自然语言回答:"订单 ORD202401010001 当前状态为已发货,金额 299.00 元,下单时间是 2024 年 1 月 1 日。"

第四步,返回用户。最终回答通过 Controller 返回给前端。

整个链路里,模型被调用了两次(一次决定调工具,一次生成回答),工具被执行了一次。理解这个"两次模型调用"很关键,因为它直接关系到你的接口耗时和成本——一次对话可能产生两次 token 消耗。

4.2 ChatClient 的对话组织代码

核心的对话代码其实不长,但每行都有讲究:

@Service public class ChatService { private final ChatClient chatClient; public ChatService(ChatClient chatClient) { this.chatClient = chatClient; } public String chat(String userMessage) { return chatClient.prompt() .user(userMessage) .call() .content(); } }

prompt()开启一次对话,user()传入用户消息,call()触发同步调用,content()取出文本结果。工具调用对这段代码是透明的——你不需要手动处理工具请求,Spring AI 在call()内部自动完成了"识别工具 → 执行 → 回灌 → 再生成"的全过程。

如果要支持多轮对话(带上下文),需要维护ChatMemory:

@Bean public ChatMemory chatMemory() { return new InMemoryChatMemory(); } @Bean public ChatClient chatClient(ChatClient.Builder builder, ChatMemory chatMemory, OrderTools orderTools) { return builder .defaultSystem("你是一个电商客服助手,回答要简洁准确") .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory)) .defaultTools(orderTools) .build(); }

defaultSystem设置系统提示词,给模型定角色。MessageChatMemoryAdvisor负责把历史消息带上,实现多轮记忆。注意InMemoryChatMemory是内存实现,重启就丢,生产环境要换成基于 Redis 或数据库的实现。

4.3 数据库层与 MyBatis 对接

工具最终要落到真实数据上,我用 MyBatis 对接 MySQL。表结构很简单:

CREATE TABLE `t_order` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL UNIQUE, `status` VARCHAR(16) NOT NULL, `amount` DECIMAL(10,2) NOT NULL, `create_time` DATETIME NOT NULL, KEY `idx_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

order_no上建了唯一索引,因为查询都是按订单号来的,索引能显著提升查询速度。Mapper 接口:

@Mapper public interface OrderMapper { @Select("SELECT order_no, status, amount, create_time " + "FROM t_order WHERE order_no = #{orderNo}") OrderDetail selectByOrderNo(@Param("orderNo") String orderNo); }

这里用注解式 SQL 是为了演示简洁,实际项目里复杂查询还是建议用 XML。要注意字段名和 POJO 属性的映射,order_no到orderNo需要开启驼峰映射:

mybatis: configuration: map-underscore-to-camel-case: true

注意:工具方法里查数据库一定要考虑异常情况。订单不存在时返回 null,模型拿到 null 可能会说"查询失败",体验不好。更好的做法是返回一个明确的"未找到"结构,或者在工具描述里说明"订单不存在时返回空",让模型知道怎么处理。

4.4 参数校验与容错处理

模型生成的参数不一定靠谱,必须做校验。我在工具方法入口加了防御:

@Tool(description = "根据订单号查询订单详情...") public OrderDetail queryOrder( @ToolParam(description = "订单号,例如 ORD202401010001") String orderNo) { if (orderNo == null || !orderNo.matches("^ORD\\d{12}$")) { return OrderDetail.notFound(orderNo); } OrderDetail detail = orderService.getByOrderNo(orderNo); return detail != null ? detail : OrderDetail.notFound(orderNo); }

正则校验订单号格式,不符合的直接返回"未找到",避免拿脏参数去查库。这种防御在 Function Calling 场景下特别重要,因为参数是模型生成的,不是用户直接输入的,你没法在前端拦。

另一个容错点是超时。工具方法如果调用了外部服务,一定要设超时,否则模型那边一直等,整个对话就卡死了。我一般给工具方法设 3 到 5 秒超时,超时后返回一个"服务繁忙"的结果,让模型告诉用户稍后再试。

5. 常见问题与排查技巧实录

5.1 模型不调用工具怎么办

这是最高频的问题。用户明明问的是订单,模型却直接瞎编一个回答,根本不调工具。排查思路按优先级来:

第一,检查工具描述。九成的情况是描述写得太模糊。把描述改成"动词 + 对象 + 返回内容"的格式,比如"查询订单详情,返回状态、金额、时间"。描述里最好带上用户可能用的关键词,用户说"包裹",描述里就提一句"订单(包裹)"。

第二,检查系统提示词。如果系统提示词里写了"你只能回答电商相关问题",而用户问的稍微偏一点,模型可能就不调工具了。系统提示词要引导模型"遇到需要真实数据的问题优先调用工具"。

第三,检查模型能力。不是所有模型都擅长 Function Calling。有些小模型对工具调用的支持很弱,换个能力强的模型试试。这个用排除法很快能定位。

第四,看日志。Spring AI 会打印工具调用的请求和响应,把日志级别调到 DEBUG,能看到模型到底有没有发起工具调用请求。如果发了但没执行,那是注册问题;如果压根没发,那是描述或模型问题。

5.2 参数反序列化失败

报错通常是Cannot deserialize value of type ...。原因一般是模型生成的参数类型和你的方法签名对不上。比如你方法参数是Integer,模型生成了"123"字符串。

解决办法有两个:一是把参数类型放宽,用 String 接收再自己转换;二是在@ToolParam描述里明确类型,比如"数量,整数类型"。我倾向于第一种,更稳。因为模型生成参数时对类型的把控没那么精确,用 String 接收再校验转换,容错性更好。

还有一种情况是参数名对不上。模型生成的 JSON key 和你的参数名不一致,比如你叫orderNo,模型生成了order_no。这时候在@ToolParam里明确参数名,或者在描述里强调命名格式。

5.3 多轮对话中工具调用丢失

带上下文的多轮对话里,有时候第二轮模型就"忘了"有工具可用。这通常是 ChatMemory 配置的问题——历史消息里包含了工具调用记录,但格式不对,导致模型困惑。

排查方法是把完整的对话历史打出来看。如果发现工具调用的消息没有被正确保存,检查MessageChatMemoryAdvisor的配置。另外,历史消息太长也会稀释工具描述的影响力,必要时做历史截断,只保留最近几轮。

5.4 常见问题速查表

现象可能原因排查方向解决方式
模型不调工具描述模糊看工具描述补充动词、对象、返回内容
参数反序列化失败类型不匹配看报错类型放宽参数类型,加校验
选错工具描述区分度低对比工具描述明确各工具边界
多轮后工具失效记忆配置问题打印对话历史检查 Memory 配置,截断历史
响应特别慢工具超时看工具耗时加超时,异步化
结果不准确返回值结构差看返回 POJO结构化返回,字段语义清晰

5.5 几个我踩过的坑

第一个坑是工具方法用了@Transactional。工具执行和模型调用不在一个事务里,给工具方法加事务注解没意义,反而可能因为事务边界问题导致连接不释放。工具方法就老老实实做查询,别掺事务。

第二个坑是返回值里带了LocalDateTime,序列化时格式不对,模型读不懂。后来统一格式化成字符串再返回,问题解决。涉及时间、金额这类格式敏感的数据,返回前都做一次格式化。

第三个坑是工具太多导致模型"选择困难"。我一度注册了十几个工具,结果模型经常选错。后来按业务域拆成几个 ChatClient,每个只注册相关工具,准确率立刻回升。工具不是越多越好,聚焦才是王道。

6. 性能优化与生产落地建议

6.1 减少不必要的模型调用

前面说过,一次带工具的对话至少两次模型调用。如果工具内部还要再调模型(比如做二次总结),成本会翻倍。优化思路是:能在工具里用代码算出来的,就别再让模型算。模型只负责"理解和表达",计算和查询交给代码。

另外,简单问题可以走"快速通道"——先用规则或关键词判断,如果明显不需要工具,直接走普通对话,省掉工具识别那一次调用。这个优化在高并发场景下能省不少成本。

6.2 工具执行的异步化与缓存

工具方法如果查的是变化不频繁的数据(比如商品信息、配置项),加一层缓存能显著降低数据库压力。我用 Spring Cache 给查询方法加了缓存,命中率挺高。

对于耗时较长的工具,可以考虑异步执行,但要注意 Function Calling 的链路是同步等待的,异步化需要配合超时和回调,复杂度会上升。我的建议是:先保证功能正确,性能问题等有真实压力了再优化,别过早设计。

6.3 监控与日志

生产环境必须能观测到工具调用情况。我记录了几个关键指标:工具调用次数、成功率、平均耗时、模型调用 token 消耗。这些数据能帮你快速定位问题——比如某个工具成功率突然下降,可能是下游服务挂了;token 消耗暴涨,可能是工具描述太长或者对话历史没截断。

日志方面,把每次工具调用的入参和出参都记下来(注意脱敏),出问题时能完整复现。Spring AI 本身有日志,但建议在工具层再加一层业务日志,记录业务语义的信息。

6.4 安全边界

Function Calling 本质是让模型触发你的后端逻辑,安全边界必须划清楚。几条硬规矩:工具方法只做查询和幂等操作,绝不做删除、扣款这类不可逆操作;参数必须校验,不能直接拼 SQL;返回值脱敏,不泄露隐私;工具描述里不暴露内部实现细节,比如表名、字段名。

如果确实需要模型触发写操作,一定要加二次确认机制——模型发起请求后,先返回给用户确认,用户确认了再执行。别让模型直接改数据,这个风险太大。

7. 后续可以怎么扩展

跑通基础链路后,这个项目还有不少可以深挖的方向。比如把工具调用和 RAG 结合——先用向量检索找到相关文档,再让模型基于文档调用工具,适合知识库问答场景。再比如做工具的动态注册,根据用户权限决定哪些工具可见,实现细粒度的权限控制。

还有一个我觉得很有意思的方向:把工具调用链路可视化。每次对话把"用户问题 → 模型识别 → 工具调用 → 结果回灌 → 最终回答"整个链路画出来,方便调试也方便给非技术人员解释。这个用简单的日志加前端展示就能做。

我个人在实际操作中的体会是,Function Calling 的难点从来不在代码本身,代码量其实很少,难的是"让模型稳定地按你的预期工作"。工具描述怎么写、参数怎么设计、异常怎么兜底,这些才是真正花时间的地方。多测、多看日志、多调整描述,跑个几十次对话,你就能摸到门道了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/6 11:04:44

FFmpeg调用NVIDIA显卡加速视频转码:从驱动检查到NVENC实战

如果你搜过“FFmpeg调用NVIDIA显卡加速视频转码”&#xff0c;大概率已经见过一堆看起来可以直接复制的命令。但我建议你先别急着复制&#xff0c;因为同样一条命令&#xff0c;在你那台NVIDIA显卡上到底能不能跑起来&#xff0c;先取决于驱动、显卡架构、FFmpeg编译版本这三件…

作者头像 李华
网站建设 2026/10/6 11:04:29

Agent生产落地三阶架构:Harness、Loop、Graph工程实践

1. 这不是又一个“架构图”&#xff0c;而是Agent落地时每天要面对的真实战场 你有没有过这样的经历&#xff1a;花两周搭好一个Agent流程&#xff0c;本地跑通、demo惊艳&#xff0c;结果一上生产环境就卡在第三步——不是LLM响应慢&#xff0c;不是工具调用失败&#xff0c;而…

作者头像 李华
网站建设 2026/10/6 11:03:56

政务AI的克制哲学:删减73%模块的Agent设计实践

1. 当所有人都在堆砌Agent功能时&#xff0c;我们删掉了73%的模块 “读完大厂几百页 Agent 白皮书&#xff0c;我们为什么选择走向「极度克制」&#xff1f;”——这个标题不是修辞&#xff0c;是我们在三个月内真实经历的决策现场。去年Q4&#xff0c;团队接到一个明确需求&am…

作者头像 李华
网站建设 2026/10/6 11:03:06

Linux下VCS与Synopsys AXI VIP环境搭建实战与避坑指南

刚拿到VCS 2022.06和Synopsys AXI VIP 2020.12的时候&#xff0c;我以为安装就是解压、export环境变量、跑一个example三步搞定。真正动手之后才发现&#xff0c;这套组合的坑比想象中多&#xff1a;GCC版本、license feature、synopsys_sim.setup映射、UVM版本选择、transacti…

作者头像 李华
网站建设 2026/10/6 11:02:35

大模型API比价目录实战:统一诡异计费规则的建模之道

大概半年多前&#xff0c;我在折腾一个给内部团队用的模型选型评估工具&#xff0c;遇到了一个让我极其烦躁的问题&#xff1a;各家大模型 API 的定价规则完全不统一&#xff0c;同一个模型&#xff0c;官网写着“每千 token 0.002 元”&#xff0c;换个入口变成“每百万 token…

作者头像 李华
网站建设 2026/10/6 11:01:53

用LangChain搭建开箱即用的RAG知识库问答系统实战

前阵子我们团队整理了一套内部技术文档&#xff0c;零零散散小一百篇&#xff0c;散落在共享盘、语雀和Notion里。新人入职要翻一天&#xff0c;老人回答重复问题翻到崩溃。我花了一个周末&#xff0c;用 LangChain 拼了一个开箱即用的 RAG 问答库&#xff0c;起名 langchain-r…

作者头像 李华