AI工程师跑路了-怎么办?

从雪山飞狐到百年孤独

百无聊赖中翻开了又一本金庸的小说《雪山飞狐》,江湖侠气,快意恩仇瞬间跃然纸上,唯有最后胡斐那一刀才让读者回到了现实。之前刚读了《明朝那些事儿》,最后重墨了李闯王,也不知道是不是世界太小了,还是巧合,《雪山飞狐》的背景就是李闯王的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。怎么都无法找到这个包。

复制代码
<dependency>
   <groupId>org.springframework.ai</groupId>
   <artifactId>spring-ai-openai-spring-boot-starter</artifactId>
</dependency>
点击并拖拽以移动

秒针嘀嗒响个不停,时间如舟山的沙子从指间的飞快漏下,怎么都抓不住。漫长的煎熬下,甚至想再换个方向呢,换个思路呢,然而出门四顾心枉然,敢为路在何方!漫无目的的在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-starterspring-ai-starter-model-{model}

  • Vector Store starters: spring-ai-{store}-store-spring-boot-starterspring-ai-starter-vector-store-{store}

  • MCP starters: spring-ai-mcp-{type}-spring-boot-starterspring-ai-starter-mcp-{type}

Demo让时间流慢了

撑开拳头,沙子掉得慢了,时间仿佛也通了人性,尽可能的的速度缓慢流逝。虽然时间来到了5月7号,还有两周时间,但感觉有了更多的时间去丰富功能了。虽然不直接支持腾讯向量库(不建议使用腾讯向量库,特别是对图的处理,有点难受),但为自定义实现提供了良好的基础。如果恰好也有用这个向量库的,可以参考下。

复制代码
/**
 * @Author: JJ
 * @CreateTime: 2025-05-07  14:21
 * @Description: 腾讯向量存储
 */
@Slf4j
@Component
public class TencentVectorStore implements VectorStore {

    private final VectorDBClient client;

    public TencentVectorStore() {

        log.info("tencentVectorStore  init");
        log.info("tencentVectorStore  init end");
    }

    @Override
    public void add(List<Document> documents) {
        log.info("Adding " + documents.size() + " documents");
    }

    @Override
    public void delete(List<String> idList) {
        log.info("Deleting " + idList.size() + " documents");
    }

    @Override
    public void delete(Filter.Expression filterExpression) {
        log.info("Deleting filter " + filterExpression);
    }

    @Override
    public List<Document> similaritySearch(SearchRequest request) {

        AIDatabase db = client.aiDatabase("db");
        CollectionView collection = db.describeCollectionView("cv1");

        SearchOption searchOption = SearchOption.newBuilder()
                .withChunkExpand(Arrays.asList(1,1))
                .withRerank(new RerankOption(true, 2.5))  // 启用重排序,设置合理的召回倍率
                .build();
        SearchByContentsParam searchByContentsParam = SearchByContentsParam.newBuilder()
                .withLimit(request.getTopK())
                .withSearchContentOption(searchOption)
                .withContent(request.getQuery())
                .build();
        log.info("searchByContentsParam {}", JSONUtil.toJsonStr(searchByContentsParam));
        List<SearchContentInfo> searchRes = collection.search(searchByContentsParam);


        collection = db.describeCollectionView("cv2");
        searchOption = SearchOption.newBuilder()
                .withChunkExpand(Arrays.asList(1,1))
                .withRerank(new RerankOption(true, 2.5))  // 启用重排序,设置合理的召回倍率
                .build();
        searchByContentsParam = SearchByContentsParam.newBuilder()
                .withLimit(request.getTopK())
                .withSearchContentOption(searchOption)
                .withContent(request.getQuery())
                .build();
        log.info("searchByContentsParam-healthcare_with_img {}", JSONUtil.toJsonStr(searchByContentsParam));
        List<SearchContentInfo> searchResWithImg = collection.search(searchByContentsParam);
        searchRes.addAll(searchResWithImg);

        return searchRes.stream().filter((rowRecord) -> rowRecord.getScore() >= request.getSimilarityThreshold()).map((rowRecord) -> {
            String docId = rowRecord.getDocumentSet().getDocumentSetId();

            JsonObject metadata = new JsonObject();

            StringBuilder knowledge = new StringBuilder();
            rowRecord.getData().getPre().forEach(a->{
                knowledge.append(a+"\n");
            });
            knowledge.append(rowRecord.getData().getText()+"\n");
            rowRecord.getData().getNext().forEach(a->{
                knowledge.append(a+"\n");
            });
            String content = knowledge.toString();

            log.debug(JSONUtil.toJsonStr(rowRecord));

            metadata = new JsonObject();
            metadata.addProperty("documentSetName", rowRecord.getDocumentSet().getDocumentSetName());
            metadata.addProperty(DocumentMetadata.DISTANCE.value(), 1.0F - rowRecord.getScore());

            Gson gson = new Gson();
            Type type = (new TypeToken<Map<String, Object>>() {
            }).getType();


            return Document.builder().id(docId).text(content).metadata(metadata != null ? (Map)gson.fromJson(metadata, type) : Map.of()).score(rowRecord.getScore()).build();
        }).toList();
    }
}
点击并拖拽以移动

想自定义历史消息查询,也非常简单,毕竟默认的JDBC目前支持的数据库类型有限。只需要实现add 与 get 方法就可以了。 恰好有想自定义实现的同学也可以参靠下。

复制代码
/**
 * @Author: jijunjian
 * @CreateTime: 2025-05-08  13:53
 * @Description: 聊天记录
 */
@Slf4j
@Component
public class BellaChatMemory implements ChatMemory {

    @Resource
    private ChatMessageV1Mapper chatMessageV1Mapper;

    @Resource
    RedisService redisService;

    @Override
    public void add(String conversationId, List<Message> messages) {
        log.info("Saving " + messages.size() + " messages to conversation " + conversationId);

        MemberUserVo currentUser = MemberContextHolder.getCurrentUser();

        String lastedMessageKey = RedisKey.lastedMessageKey(conversationId);
        //用户的消息,手动添加,这里只添加系统的
        messages.forEach(message -> {

            String userId = "0";
            if (currentUser != null) {
                userId = currentUser.getUserCode().toString();
            }

             if (!message.getMessageType().equals(MessageType.USER)){
                 chatMessageV1Mapper.insert(chatMessageV1SaveReqVO);
             }
        });


    }

    /**
     * @param conversationId
     * @param lastN
     * @deprecated
     */
    @Override
    public List<Message> get(String conversationId) {
       int lastN = 20;
        log.debug("findByConversationId {}", conversationId);
        LambdaQueryWrapper<ChatMessageV1DO> queryWrapperX = new LambdaQueryWrapperX<ChatMessageV1DO>()
                .eq(ChatMessageV1DO::getConversationId, conversationId)
                .eq(ChatMessageV1DO::getDeleted, 0)
                .orderByDesc(ChatMessageV1DO::getId)
                 .last( "limit " + lastN);

        List<ChatMessageV1DO> chatMessageV1DOS = chatMessageV1Mapper.selectList(queryWrapperX);
        //列表根据 id 升序
        chatMessageV1DOS.sort((o1, o2) -> {
            if (o1.getId() > o2.getId()) {
                return 1;
            } else if (o1.getId() < o2.getId()) {
                return -1;
            } else {
                return 0;
            }
        });
        List<Message> messageList = new ArrayList<>();

        chatMessageV1DOS.forEach(messageDo -> {
            var type = ChatRoleEnum.getByCode(messageDo.getAuthorRole());
            switch (type) {
                case USER:
                    String content = messageDo.getContent();
                    if(!Strings.isNullOrEmpty(messageDo.getImgs())){
                        content = content + "\n 用户图片:" + messageDo.getImgs();
                    }
                    messageList.add(new UserMessage(content));
                    break;
                case ASSISTANT:
                    messageList.add(new AssistantMessage(messageDo.getContent()));
                    break;
                default:
                    log.error("Unknown chat role " + messageDo.getAuthorRole());
            };
        });
        return messageList;
    }

    @Override
    public void clear(String conversationId) {
        log.debug("deleteByConversationId {}", conversationId);
    }
}
点击并拖拽以移动

两个特别留意的地方

Tool Calling 尝试100次都是无法调用,官方文档上的demo也无法运行,后来看到需要一个特别的参数配置才可以,于是101次成功了,但是102次又失败了,因为有些模型不支持Toos,于是103次成功了,也持续成功了。

复制代码
       ChatOptions chatOptions = ToolCallingChatOptions.builder()
                .internalToolExecutionEnabled(true)
点击并拖拽以移动

ContextualQueryAugmenter 一定要重写 PromptTemplate 否则知识库中没有内容的话,总是回答不知道。还是直接在sping-ai原码中搜索才到这个提示,然后再针对性的解决了。原码中默认的PromptTemplate是这样的配置的。

复制代码
private static final PromptTemplate DEFAULT_PROMPT_TEMPLATE = new PromptTemplate("""
            Context information is below.

            ---------------------
            {context}
            ---------------------

            Given the context information and no prior knowledge, answer the query.

            Follow these rules:

            1. If the answer is not in the context, just say that you don't know.
            2. Avoid statements like "Based on the context..." or "The provided information...".

            Query: {query}

            Answer:
            """);
点击并拖拽以移动

于是这样初始化RetrievalAugmentationAdvisor问题就解决了。

复制代码
        Advisor retrievalAugmentationAdvisor = RetrievalAugmentationAdvisor.builder()
                .queryTransformers(RewriteQueryTransformer.builder()
                        .chatClientBuilder(queryTransformerClient.mutate())
                        .build())
                .documentRetriever(VectorStoreDocumentRetriever.builder()
                        .similarityThreshold(0.50)
                        .vectorStore(tencentVectorStore)
                        .build())
                .queryAugmenter(ContextualQueryAugmenter.builder()
                        .allowEmptyContext(true)
                        .promptTemplate(contextualPrompt)
                        .build())
                .build();
点击并拖拽以移动

我也来践行峰终定律

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

后记

此版本语音模式体验不是太理想,一个字:慢。目前的方案是LLM输出后,才用miniMax转语音,这样是快不了的。也许接下来是时候去看看openai的 realtime 的框架了,因为目前只有Python SDK , 不知道有没有哪个朋友也给一个Python web 的项目。 想想就是一件高兴的事儿。

因为app还在 testflight 内测,我想想看看如何放到小程序让大家体验下。
官方不让放二维码,只能放一个链接了

相关推荐
奇舞精选6 小时前
Prompt 工程实用技巧:掌握高效 AI 交互核心
前端·openai
新智元8 小时前
DeepSeek-R1 编程问鼎,媲美 Claude 4!2025 AI 上半场战报来袭
人工智能·openai
新智元13 小时前
刚刚,谷歌 AI 路线图曝光:竟要抛弃注意力机制?Transformer 有致命缺陷!
人工智能·openai
新智元14 小时前
CVPR 史上首次!中国车厂主讲 AI 大模型,自动驾驶也玩 Scaling Law?
人工智能·openai
用户4307994547671 天前
保姆级教程教你用ai实现labubu自由
openai
新智元1 天前
13 年死磕一个真理,这家中国 AI 黑马冲刺 IPO
人工智能·openai
新智元1 天前
12 年博士研究,AI 两天爆肝完成!科研效率狂飙 3000 倍,惊动学术圈
人工智能·openai
新智元1 天前
陶哲轩 3 小时对话流出:AI 抢攻菲尔兹奖倒计时
人工智能·openai
得帆云低代码2 天前
AI万能接口MCP,如何赋能企业级智能集成
openai·mcp
何似在人间5752 天前
SpringAI+DeepSeek大模型应用开发——6基于MongDB持久化对话
java·ai·大模型·springai