找回密码
 立即注册
首页 业界区 业界 AI工程师跑路了-SpringAi来帮忙

AI工程师跑路了-SpringAi来帮忙

寿爹座 4 天前
 从雪山飞狐到百年孤独

百无聊赖中翻开了又一本金庸的小说《雪山飞狐》,江湖侠气,快意恩仇瞬间跃然纸上,唯有最后胡斐那一刀才让读者回到了现实。之前刚读了《明朝那些事儿》,最后重墨了李闯王,也不知道是不是世界太小了,还是巧合,《雪山飞狐》的背景就是李闯王的4大护卫家的百年爱恨情仇,翻完《雪山飞狐》,又巧合的拿起了《百年孤独》,又是百年的孤独与爱恨... 这也许就是读书的另类乐趣吧。
Ai风吹进了公司

这两年好像出了这样一个论点:不搞ai的公司,好像都抬不起头。我们也不例外,来了一个老板,来了一个Ai工程师开始捣鼓起了Ai。对公司有这样的看法,自然视乎对我们这样的程序员也有同样的看法。于是我也一咬牙,一跺脚净身来到新的团队充数。想着边打杂跑腿,边抱大腿噌点Ai知识。之前都是弄几个demo自嗨,不得劲儿,想出来看看真的Ai项目长啥样。
哪成想,新来的Ai工程师用的Go语言,看不懂。于是吭哧吭哧学习了解了下Go,配合Cusor总算是马马虎虎看明白代码了。每个月140元包月的费用总算是没白瞎了。
 
大腿跑了我怎么办

本以为我在这个里从一个Javaer变一个Goer,可以在简历上写上精通GO,没成想,意外来得这么突然,Ai工程师跑了,大腿没有了,似乎短时间我要摇身一变成“大腿”了。但是留给我的时间不多,只有3周,其中还包括一个5.1长假。我陷入了深深的思索中,心中多个声音不断博弈:继续在原来的go上开发,能看和能写还是不是一会事儿,不可控;用Python实现Mult Agent,会demo到会写项目中间还间隔一个筋斗云,不可控;用java实现,自己是个熟练工,但没有一个成熟的框架可以用。
辗转反侧,夜不能寐,上下求索,各种尝试,进展微乎其微,觉得我的一只脚已经在公司外了。何以解忧,百年孤独。也许是巧合吧,当时的那种心情,与无人理解老何赛的科学追求,与无人理解到老乌尔苏拉如何维持大家庭的艰辛,与无人理解奥雷里亚诺上校战后的无奈,真可谓同病相连。
显然马尔克斯是懂人性的,是懂峰终定律的,所以几乎大部分布恩迪亚家的人,无论生前如何荒诞,生命的最后一刻都顿悟了,沐浴着最朴素的亲情。所以看是阴沉的书,给人的确实温暖。骆驼祥子,或者...整本书都是阴沉,阴沉,更阴沉,所有短暂的希望只是为了演绎更多失望。看完后只有一个感觉:绝望。
一边孤独,一边尝试,一边面试(除了招人,还希望能在面试过程中有所收获),终于看到了Spring Ai,欣喜若狂,突然感觉有了一根稻草。还没有等朝阳升起,一片乌云飘过 -- Srping Ai现在还只有里程碑版本 - M6。不幸中的万幸是 ,乌云不可怕,只要风够大 -- release版本会在5月发布。于是开始下定决心沿着javaer的路走下去。
 
有Sping Ai也不容易

Sping Ai要求JDK17,Spring boot 3.x 一个简单的要求差点让人崩溃。一开始想着用现有spring boot 2.3的项目直接升级到Spring boot 3.x,毕竟Spring boot 一直以向下兼容著称,但是架不住原来项目依赖不太规范,一顿操作后,无奈放弃。最后从朋友那里弄来一个Spring boot 3项目,总算是跑起来了,但是这只是刚刚开始。
因为不是relase版本, 不同版本 artifactId,包名,类名都都在变化,导致很多文章,甚至官方文档都是针对某个过去的特定版本编写的demo,这一切就如同面对马孔多南边一望无际的沼泽一般,老何赛终其一生也没能走出去,5.1长假鏖战数天,也没有完整跑起来一个dmeo。怎么都无法找到这个包。
  1. <dependency>
  2.    <groupId>org.springframework.ai</groupId>
  3.    spring-ai-openai-spring-boot-starter</artifactId>
  4. </dependency>
复制代码
 
1.gif
秒针嘀嗒响个不停,时间如舟山的沙子从指间的飞快漏下,怎么都抓不住。漫长的煎熬下,甚至想再换个方向呢,换个思路呢,然而出门四顾心枉然,敢为路在何方!漫无目的的在Spring Ai 官方文档溜达(建议大家别看中文文档, 感觉是没有感情的机器翻译的),一个升级日志就默默的写那里,没有激动,没有喜悦,没有懊恼,靜靜换了Artifact ID ,demo自然的跑起来了,就像本该如何这般。
Artifact ID Changes

The naming pattern for Spring AI starter artifacts has changed. You’ll need to update your dependencies according to the following patterns:

  • Model starters: spring-ai-{model}-spring-boot-starter → spring-ai-starter-model-{model}
  • Vector Store starters: spring-ai-{store}-store-spring-boot-starter → spring-ai-starter-vector-store-{store}
  • MCP starters: spring-ai-mcp-{type}-spring-boot-starter → spring-ai-starter-mcp-{type}
 

Demo让时间流慢了

撑开拳头,沙子掉得慢了,时间仿佛也通了人性,尽可能的的速度缓慢流逝。虽然时间来到了5月7号,还有两周时间,但感觉有了更多的时间去丰富功能了。虽然不直接支持腾讯向量库(不建议使用腾讯向量库,特别是对图的处理,有点难受),但为自定义实现提供了良好的基础。如果恰好也有用这个向量库的,可以参考下。
  1. /**
  2. * @Author: JJ
  3. * @CreateTime: 2025-05-07  14:21
  4. * @Description: 腾讯向量存储
  5. */
  6. @Slf4j
  7. @Component
  8. public class TencentVectorStore implements VectorStore {
  9.     private final VectorDBClient client;
  10.     public TencentVectorStore() {
  11.         log.info("tencentVectorStore  init");
  12.         log.info("tencentVectorStore  init end");
  13.     }
  14.     @Override
  15.     public void add(List<Document> documents) {
  16.         log.info("Adding " + documents.size() + " documents");
  17.     }
  18.     @Override
  19.     public void delete(List<String> idList) {
  20.         log.info("Deleting " + idList.size() + " documents");
  21.     }
  22.     @Override
  23.     public void delete(Filter.Expression filterExpression) {
  24.         log.info("Deleting filter " + filterExpression);
  25.     }
  26.     @Override
  27.     public List<Document> similaritySearch(SearchRequest request) {
  28.         AIDatabase db = client.aiDatabase("db");
  29.         CollectionView collection = db.describeCollectionView("cv1");
  30.         SearchOption searchOption = SearchOption.newBuilder()
  31.                 .withChunkExpand(Arrays.asList(1,1))
  32.                 .withRerank(new RerankOption(true, 2.5))  // 启用重排序,设置合理的召回倍率
  33.                 .build();
  34.         SearchByContentsParam searchByContentsParam = SearchByContentsParam.newBuilder()
  35.                 .withLimit(request.getTopK())
  36.                 .withSearchContentOption(searchOption)
  37.                 .withContent(request.getQuery())
  38.                 .build();
  39.         log.info("searchByContentsParam {}", JSONUtil.toJsonStr(searchByContentsParam));
  40.         List<SearchContentInfo> searchRes = collection.search(searchByContentsParam);
  41.         collection = db.describeCollectionView("cv2");
  42.         searchOption = SearchOption.newBuilder()
  43.                 .withChunkExpand(Arrays.asList(1,1))
  44.                 .withRerank(new RerankOption(true, 2.5))  // 启用重排序,设置合理的召回倍率
  45.                 .build();
  46.         searchByContentsParam = SearchByContentsParam.newBuilder()
  47.                 .withLimit(request.getTopK())
  48.                 .withSearchContentOption(searchOption)
  49.                 .withContent(request.getQuery())
  50.                 .build();
  51.         log.info("searchByContentsParam-healthcare_with_img {}", JSONUtil.toJsonStr(searchByContentsParam));
  52.         List<SearchContentInfo> searchResWithImg = collection.search(searchByContentsParam);
  53.         searchRes.addAll(searchResWithImg);
  54.         return searchRes.stream().filter((rowRecord) -> rowRecord.getScore() >= request.getSimilarityThreshold()).map((rowRecord) -> {
  55.             String docId = rowRecord.getDocumentSet().getDocumentSetId();
  56.             JsonObject metadata = new JsonObject();
  57.             StringBuilder knowledge = new StringBuilder();
  58.             rowRecord.getData().getPre().forEach(a->{
  59.                 knowledge.append(a+"\n");
  60.             });
  61.             knowledge.append(rowRecord.getData().getText()+"\n");
  62.             rowRecord.getData().getNext().forEach(a->{
  63.                 knowledge.append(a+"\n");
  64.             });
  65.             String content = knowledge.toString();
  66.             log.debug(JSONUtil.toJsonStr(rowRecord));
  67.             metadata = new JsonObject();
  68.             metadata.addProperty("documentSetName", rowRecord.getDocumentSet().getDocumentSetName());
  69.             metadata.addProperty(DocumentMetadata.DISTANCE.value(), 1.0F - rowRecord.getScore());
  70.             Gson gson = new Gson();
  71.             Type type = (new TypeToken<Map<String, Object>>() {
  72.             }).getType();
  73.             return Document.builder().id(docId).text(content).metadata(metadata != null ? (Map)gson.fromJson(metadata, type) : Map.of()).score(rowRecord.getScore()).build();
  74.         }).toList();
  75.     }
  76. }
复制代码
 
2.gif
想自定义历史消息查询,也非常简单,毕竟默认的JDBC目前支持的数据库类型有限。只需要实现add 与  get 方法就可以了。 恰好有想自定义实现的同学也可以参靠下。
  1. /**
  2. * @Author: jijunjian
  3. * @CreateTime: 2025-05-08  13:53
  4. * @Description: 聊天记录
  5. */
  6. @Slf4j
  7. @Component
  8. public class BellaChatMemory implements ChatMemory {
  9.     @Resource
  10.     private ChatMessageV1Mapper chatMessageV1Mapper;
  11.     @Resource
  12.     RedisService redisService;
  13.     @Override
  14.     public void add(String conversationId, List<Message> messages) {
  15.         log.info("Saving " + messages.size() + " messages to conversation " + conversationId);
  16.         MemberUserVo currentUser = MemberContextHolder.getCurrentUser();
  17.         String lastedMessageKey = RedisKey.lastedMessageKey(conversationId);
  18.         //用户的消息,手动添加,这里只添加系统的
  19.         messages.forEach(message -> {
  20.             String userId = "0";
  21.             if (currentUser != null) {
  22.                 userId = currentUser.getUserCode().toString();
  23.             }
  24.              if (!message.getMessageType().equals(MessageType.USER)){
  25.                  chatMessageV1Mapper.insert(chatMessageV1SaveReqVO);
  26.              }
  27.         });
  28.     }
  29.     /**
  30.      * @param conversationId
  31.      * @param lastN
  32.      * @deprecated
  33.      */
  34.     @Override
  35.     public List<Message> get(String conversationId) {
  36.        int lastN = 20;
  37.         log.debug("findByConversationId {}", conversationId);
  38.         LambdaQueryWrapper<ChatMessageV1DO> queryWrapperX = new LambdaQueryWrapperX<ChatMessageV1DO>()
  39.                 .eq(ChatMessageV1DO::getConversationId, conversationId)
  40.                 .eq(ChatMessageV1DO::getDeleted, 0)
  41.                 .orderByDesc(ChatMessageV1DO::getId)
  42.                  .last( "limit " + lastN);
  43.         List<ChatMessageV1DO> chatMessageV1DOS = chatMessageV1Mapper.selectList(queryWrapperX);
  44.         //列表根据 id 升序
  45.         chatMessageV1DOS.sort((o1, o2) -> {
  46.             if (o1.getId() > o2.getId()) {
  47.                 return 1;
  48.             } else if (o1.getId() < o2.getId()) {
  49.                 return -1;
  50.             } else {
  51.                 return 0;
  52.             }
  53.         });
  54.         List<Message> messageList = new ArrayList<>();
  55.         chatMessageV1DOS.forEach(messageDo -> {
  56.             var type = ChatRoleEnum.getByCode(messageDo.getAuthorRole());
  57.             switch (type) {
  58.                 case USER:
  59.                     String content = messageDo.getContent();
  60.                     if(!Strings.isNullOrEmpty(messageDo.getImgs())){
  61.                         content = content + "\n 用户图片:" + messageDo.getImgs();
  62.                     }
  63.                     messageList.add(new UserMessage(content));
  64.                     break;
  65.                 case ASSISTANT:
  66.                     messageList.add(new AssistantMessage(messageDo.getContent()));
  67.                     break;
  68.                 default:
  69.                     log.error("Unknown chat role " + messageDo.getAuthorRole());
  70.             };
  71.         });
  72.         return messageList;
  73.     }
  74.     @Override
  75.     public void clear(String conversationId) {
  76.         log.debug("deleteByConversationId {}", conversationId);
  77.     }
  78. }
复制代码
 
3.gif
两个特别留意的地方

Tool Calling 尝试100次都是无法调用,官方文档上的demo也无法运行,后来看到需要一个特别的参数配置才可以,于是101次成功了,但是102次又失败了,因为有些模型不支持Toos,于是103次成功了,也持续成功了。
  1.        ChatOptions chatOptions = ToolCallingChatOptions.builder()
  2.                 .internalToolExecutionEnabled(true)
复制代码
 
4.gif
ContextualQueryAugmenter 一定要重写 PromptTemplate 否则知识库中没有内容的话,总是回答不知道。还是直接在sping-ai原码中搜索才到这个提示,然后再针对性的解决了。原码中默认的PromptTemplate是这样的配置的。
  1. private static final PromptTemplate DEFAULT_PROMPT_TEMPLATE = new PromptTemplate("""
  2.             Context information is below.
  3.             ---------------------
  4.             {context}
  5.             ---------------------
  6.             Given the context information and no prior knowledge, answer the query.
  7.             Follow these rules:
  8.             1. If the answer is not in the context, just say that you don't know.
  9.             2. Avoid statements like "Based on the context..." or "The provided information...".
  10.             Query: {query}
  11.             Answer:
  12.             """);
复制代码
 
5.gif
于是这样初始化RetrievalAugmentationAdvisor问题就解决了。
  1.         Advisor retrievalAugmentationAdvisor = RetrievalAugmentationAdvisor.builder()
  2.                 .queryTransformers(RewriteQueryTransformer.builder()
  3.                         .chatClientBuilder(queryTransformerClient.mutate())
  4.                         .build())
  5.                 .documentRetriever(VectorStoreDocumentRetriever.builder()
  6.                         .similarityThreshold(0.50)
  7.                         .vectorStore(tencentVectorStore)
  8.                         .build())
  9.                 .queryAugmenter(ContextualQueryAugmenter.builder()
  10.                         .allowEmptyContext(true)
  11.                         .promptTemplate(contextualPrompt)
  12.                         .build())
  13.                 .build();
复制代码
 
6.gif
 

我也来践行峰终定律

有了上面的基础,522版本基本可控了,截至此时, MVP2.0算上基本上发布了,算是实现了阶段性目标, 那条悬在公司外的腿,又坚强的收了回来了,很明显,腿还小着,系统中定义了多个Agent去执行不同场景的任务,舌诊Agent完成图片的收集与实别,问卷Agent完成相关用户数据收集的场景... 再有意图识别route到具体worker Agent。实现了一个简单的chatBot不是啥大成果,但是有了迭代的基础,也因此有了希望。
 
后记

此版本语音模式体验不是太理想,一个字:慢。目前的方案是LLM输出后,才用miniMax转语音,这样是快不了的。也许接下来是时候去看看openai的 realtime 的框架了,因为目前只有Python SDK , 不知道有没有哪个朋友也给一个Python web 的项目。 想想就是一件高兴的事儿。
 
因为app还在 testflight 内测,我想想看看如何放到小程序让大家体验下。
官方不让放二维码,只能放一个链接了。
 
 



来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
您需要登录后才可以回帖 登录 | 立即注册