一个前端被逼着搞Spring Cloud Alibaba的血泪史

张刚
2026-07-26 21:53
阅读 654

坐标杭州,某二线互联网公司,前端开发第三年。最近在研究性能优化,顺便被领导拉去搞了个AI相关的项目,用到了Spring Cloud Alibaba。这篇文章算是我这段时间的踩坑记录,希望能帮到同样被"全栈"的兄弟们。


事情是这样的

上周五快下班的时候,我们技术总监老张把我叫到会议室,说有个新项目要搞。我心想完了,又要加需求了。结果老张说:"这个项目是跟AI相关的,后端人手不够,你前端也懂点Node.js,来帮忙搞搞后端吧。"

我当时内心是崩溃的。我一个写React的,你让我搞Spring Cloud?这不是让厨子去修飞机吗?

但话说回来,这两年杭州这边阿里、网易招人确实卷,不学点后端的东西,简历都不好写。再加上最近AI这么火,什么大模型、Agent、RAG之类的概念满天飞,想着学学也不亏,就硬着头皮接了。

项目大概是这样的:我们要做一个企业级的AI知识问答系统,类似秘塔AI搜索那种,但主要是面向内部员工用的。用户可以上传文档,系统会把文档内容做Embedding处理,存到向量数据库里,然后用户提问的时候,通过Agent工作流去检索相关内容,最后用大模型生成回答。

听起来挺简单对吧?实际上坑多得一批。

技术选型:为什么是Spring Cloud Alibaba

说实话,一开始我是想直接用Spring Boot搞个单体应用的。毕竟我们团队后端就三个人,搞微服务不是给自己找事吗?

但老张说了几个理由,我觉得还挺有道理:

  1. 业务拆分明确:文档处理、向量化、问答生成,这几个模块天然可以拆分
  2. 后续扩展性:如果以后要接更多的AI能力,微服务架构更好扩展
  3. 团队技术栈:公司后端基本都是Java出身,Spring Cloud Alibaba他们熟

行吧,那就Spring Cloud Alibaba。具体用的组件如下:

组件 版本 用途
Nacos 2.2.3 服务注册与配置中心
Sentinel 1.8.6 流量控制和熔断降级
Seata 1.7.0 分布式事务(后来发现用不上)
Gateway 4.0.0 API网关
OpenFeign 4.0.0 服务间调用

这里要吐槽一下Seata,老张一开始非要用,说万一有分布式事务的场景呢。结果搞了半天,发现我们的业务根本不需要分布式事务——文档上传和向量化是异步的,问答生成也是独立的,哪来的分布式事务?白白浪费了一天时间配Seata,最后直接删了。

血泪教训:不要为了用技术而用技术,按需选择!

项目架构设计

先画个大概的架构图(用文字描述,别打我):

用户请求 -> Gateway -> 各个微服务
                         |
                         ├── document-service(文档处理服务)
                         ├── embedding-service(向量化服务)
                         ├── agent-service(Agent工作流服务)
                         └── search-service(搜索服务)

文档处理服务(document-service)

这个服务主要负责文档的上传、解析和存储。支持的格式有PDF、Word、Markdown等。

这里有个坑:一开始我们用Apache POI解析Word文档,结果遇到一些格式复杂的文档就直接OOM了。后来换成了Aspose,虽然要花钱,但稳定性好太多。

@Service
@Slf4j
public class DocumentParseService {
    
    @Autowired
    private VectorStoreClient vectorStoreClient;
    
    /**
     * 解析文档并切分成chunk
     * 这里有个细节:chunk的大小直接影响Embedding的效果
     * 太大了语义不聚焦,太小了上下文丢失
     * 我们测试下来512个token比较合适
     */
    public List<DocumentChunk> parseAndChunk(MultipartFile file) {
        String fileType = getFileType(file.getOriginalFilename());
        String content = "";
        
        switch (fileType) {
            case "pdf":
                content = parsePdf(file);
                break;
            case "docx":
                content = parseDocx(file);
                break;
            case "md":
                content = parseMarkdown(file);
                break;
            default:
                throw new UnsupportedFileTypeException("不支持的文件类型: " + fileType);
        }
        
        // 按512 token切分,overlap设置64保证上下文连贯
        return TextSplitter.split(content, 512, 64);
    }
    
    private String parseDocx(MultipartFile file) {
        try {
            // 用Aspose解析,虽然要license但真的稳
            Document doc = new Document(file.getInputStream());
            return doc.getText();
        } catch (Exception e) {
            log.error("Word文档解析失败", e);
            throw new DocumentParseException("文档解析失败");
        }
    }
}

向量化服务(embedding-service)

这个服务是核心中的核心。我们用的是阿里的text-embedding-v2模型,效果还不错,中文支持比较好。

这里有个性能优化的点:Embedding的调用是耗时的,如果用户一次上传100个chunk,串行调用API要等好久。我们用了线程池+批量处理的方式:

@Service
@Slf4j
public class EmbeddingService {
    
    @Value("${embedding.model}")
    private String modelName;
    
    @Autowired
    private EmbeddingApiClient apiClient;
    
    @Autowired
    private VectorStoreClient vectorStoreClient;
    
    // 线程池配置:核心线程数根据CPU核数来,最大线程数别设太大
    // 因为我们主要是IO密集型,不是CPU密集型
    private final ExecutorService executor = new ThreadPoolExecutor(
        Runtime.getRuntime().availableProcessors() * 2,
        Runtime.getRuntime().availableProcessors() * 4,
        60L, TimeUnit.SECONDS,
        new LinkedBlockingQueue<>(1000),
        new ThreadFactoryBuilder().setNameFormat("embedding-pool-%d").build(),
        new ThreadPoolExecutor.CallerRunsPolicy()
    );
    
    /**
     * 批量生成Embedding并存入向量数据库
     * 这里用了CompletableFuture做并行处理
     */
    public void batchEmbed(List<DocumentChunk> chunks) {
        // 每批最多处理25个,API有限制
        List<List<DocumentChunk>> batches = Lists.partition(chunks, 25);
        
        List<CompletableFuture<Void>> futures = batches.stream()
            .map(batch -> CompletableFuture.runAsync(() -> {
                try {
                    // 调用Embedding API
                    List<float[]> embeddings = apiClient.embed(
                        modelName,
                        batch.stream().map(DocumentChunk::getContent).collect(Collectors.toList())
                    );
                    
                    // 存入向量数据库(我们用的Milvus)
                    for (int i = 0; i < batch.size(); i++) {
                        vectorStoreClient.insert(
                            batch.get(i).getId(),
                            embeddings.get(i),
                            batch.get(i).getMetadata()
                        );
                    }
                    
                    log.info("批次处理完成,大小: {}", batch.size());
                } catch (Exception e) {
                    log.error("Embedding批次处理失败", e);
                    // 这里要做重试或者记录失败队列
                    // 我们用的是RocketMQ做失败重试
                }
            }, executor))
            .collect(Collectors.toList());
        
        // 等待所有批次完成
        CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
    }
}

说到向量数据库,我们选的是Milvus。为什么不用Elasticsearch?因为ES的向量检索性能确实不如专业的向量数据库。当然,如果你们的向量数据量不大(几十万以内),ES也够用了。

Milvus的部署这里也踩了坑。一开始我们直接用Docker跑的单机版,结果数据量上来之后查询越来越慢。后来改成了分布式部署,用了3个QueryNode,性能提升了不少。

部署方式 数据量 查询延迟(P99) 备注
Docker单机 10万向量 50ms 开发测试用
分布式3节点 100万向量 20ms 生产环境
分布式5节点 500万向量 15ms 后续扩容方案

Agent工作流服务(agent-service)

这个服务是最有意思的,也是我觉得最能体现AI应用价值的地方。

Agent工作流,说白了就是让AI按照一定的流程去完成任务。比如用户问"我们公司的报销流程是什么?",Agent需要:

  1. 理解用户意图
  2. 从向量数据库中检索相关文档
  3. 如果检索结果不够,可能需要进一步搜索或者调用其他工具
  4. 整合信息,生成最终回答

我们用LangChain4j来实现这个工作流。这个库是Java版的LangChain,虽然不如Python版功能全,但基本的都能用。

@Service
@Slf4j
public class AgentWorkflowService {
    
    @Autowired
    private VectorStoreClient vectorStore;
    
    @Autowired
    private LLMClient llmClient;
    
    @Autowired
    private List<AgentTool> tools; // 各种工具,比如搜索、计算等
    
    /**
     * Agent工作流主流程
     * 这里用了ReAct模式:Reasoning + Acting
     */
    public AgentResponse process(String userQuery, String userId) {
        log.info("开始处理用户查询: {}, 用户: {}", userQuery, userId);
        
        // 1. 意图识别
        Intent intent = recognizeIntent(userQuery);
        log.info("识别到意图: {}", intent);
        
        // 2. 构建Agent
        Agent agent = Agent.builder()
            .llm(llmClient)
            .tools(tools)
            .memory(buildMemory(userId)) // 对话历史
            .maxIterations(5) // 最多迭代5次,防止死循环
            .build();
        
        // 3. 执行Agent
        AgentResult result = agent.run(userQuery);
        
        // 4. 后处理:敏感词过滤、格式化等
        return postProcess(result);
    }
    
    /**
     * 意图识别
     * 这里其实可以做成一个独立的微服务,但考虑到调用频繁,先放在这里
     */
    private Intent recognizeIntent(String query) {
        // 简单的规则匹配 + 模型分类
        if (query.contains("报销") || query.contains("费用")) {
            return Intent.EXPENSE_RELATED;
        } else if (query.contains("请假") || query.contains("休假")) {
            return Intent.LEAVE_RELATED;
        }
        // 复杂的情况用模型判断
        return llmClient.classify(query, Intent.values());
    }
    
    private Memory buildMemory(String userId) {
        // 从Redis获取用户最近的对话历史
        List<ChatMessage> history = redisTemplate.opsForList()
            .range("chat:history:" + userId, -10, -1);
        return new WindowMemory(history, 10);
    }
}

这里有个坑要提醒大家:Agent的迭代次数一定要设上限!我们一开始没设,结果有一次Agent陷入了死循环,一直在调用工具,最后API调用费用直接爆了。幸好我们配了Sentinel做限流,不然真要出事。

搜索服务(search-service)

这个服务主要负责混合检索:向量检索 + 关键词检索。

为什么要混合检索?因为纯向量检索有时候会漏掉一些精确匹配的内容,而纯关键词检索又理解不了语义。两者结合效果最好。

@Service
@Slf4j
public class HybridSearchService {
    
    @Autowired
    private VectorStoreClient vectorStore;
    
    @Autowired
    private ElasticsearchClient esClient;
    
    /**
     * 混合检索
     * 向量检索负责语义匹配,ES负责关键词精确匹配
     * 最后用RRF(Reciprocal Rank Fusion)算法融合结果
     */
    public List<SearchResult> hybridSearch(String query, int topK) {
        // 1. 向量检索
        float[] queryEmbedding = embeddingService.embed(query);
        List<VectorSearchResult> vectorResults = vectorStore.search(queryEmbedding, topK * 2);
        
        // 2. 关键词检索(用ES的BM25)
        List<EsSearchResult> esResults = esClient.search(query, topK * 2);
        
        // 3. RRF融合
        Map<String, Double> scores = new HashMap<>();
        
        // 向量检索结果打分
        for (int i = 0; i < vectorResults.size(); i++) {
            String id = vectorResults.get(i).getId();
            double score = 1.0 / (60 + i + 1); // RRF公式
            scores.merge(id, score, Double::sum);
        }
        
        // ES检索结果打分
        for (int i = 0; i < esResults.size(); i++) {
            String id = esResults.get(i).getId();
            double score = 1.0 / (60 + i + 1);
            scores.merge(id, score, Double::sum);
        }
        
        // 4. 排序并返回topK
        return scores.entrySet().stream()
            .sorted(Map.Entry.<String, Double>comparingByValue().reversed())
            .limit(topK)
            .map(e -> buildSearchResult(e.getKey(), e.getValue()))
            .collect(Collectors.toList());
    }
}

RRF算法的参数k=60是经验值,你们可以根据自己的业务调整。我们的测试结果是60左右效果最好。

网关和限流

Gateway用的是Spring Cloud Gateway,主要做几件事:

  1. 统一鉴权(JWT)
  2. 路由转发
  3. 限流(配合Sentinel)

限流这块要重点说一下。因为大模型API调用是很贵的,如果不做限流,万一被恶意刷接口,费用直接上天。

spring:
  cloud:
    gateway:
      routes:
        - id: agent-service
          uri: lb://agent-service
          predicates:
            - Path=/api/agent/**
          filters:
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 10  # 每秒10个请求
                redis-rate-limiter.burstCapacity: 20  # 突发20个
                key-resolver: "#{@userKeyResolver}"
@Configuration
public class RateLimiterConfig {
    
    @Bean
    public KeyResolver userKeyResolver() {
        // 按用户ID限流,防止单个用户刷接口
        return exchange -> {
            String userId = exchange.getRequest().getHeaders().getFirst("X-User-Id");
            return Mono.just(userId != null ? userId : "anonymous");
        };
    }
}

另外,Sentinel还配了熔断规则,当大模型API响应时间超过10秒,或者错误率超过50%,就自动熔断,返回一个兜底的回答。这个在线上救了我们好几次。

生产环境踩坑记录

坑1:Nacos配置中心的热更新问题

我们有个配置是embedding.batch.size,用来控制批量Embedding的大小。一开始改了这个配置,发现没有生效,重启服务才生效。

后来查了文档,发现是@Value注解不支持热更新,要用@ConfigurationProperties或者@NacosValue(autoRefreshed = true)

// 错误写法
@Value("${embedding.batch.size}")
private int batchSize;

// 正确写法
@ConfigurationProperties(prefix = "embedding")
@Data
public class EmbeddingProperties {
    private int batchSize = 25;
}

坑2:Feign调用的超时配置

服务间调用用的是OpenFeign,默认的超时时间太短了,特别是调用Embedding API的时候,经常超时。

feign:
  client:
    config:
      default:
        connectTimeout: 5000
        readTimeout: 60000  # Embedding调用比较慢,给60秒
  compression:
    request:
      enabled: true
    response:
      enabled: true

坑3:Milvus连接池耗尽

有一次线上突然报No available connection,查了半天发现是Milvus的连接池配置有问题。默认的连接池大小太小了,高并发的时候不够用。

@Configuration
public class MilvusConfig {
    
    @Bean
    public MilvusServiceClient milvusClient() {
        ConnectParam connectParam = ConnectParam.newBuilder()
            .withHost("milvus-host")
            .withPort(19530)
            .build();
        
        // 关键:配置连接池
        PoolConfig poolConfig = PoolConfig.newBuilder()
            .withMaxPoolSize(50)  // 最大连接数
            .withMinPoolSize(10)  // 最小连接数
            .build();
        
        return new MilvusServiceClient(connectParam, poolConfig);
    }
}

坑4:日志太多导致磁盘爆满

AI相关的服务日志特别多,特别是Debug级别的,一不小心磁盘就满了。我们配了日志滚动策略,并且把Debug日志单独输出到另一个文件:

<configuration>
    <appender name="DEBUG_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>logs/debug.log</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <fileNamePattern>logs/debug-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
            <maxFileSize>100MB</maxFileSize>
            <maxHistory>3</maxHistory> <!-- 只保留3天 -->
            <totalSizeCap>1GB</totalSizeCap>
        </rollingPolicy>
        <filter class="ch.qos.logback.classic.filter.LevelFilter">
            <level>DEBUG</level>
            <onMatch>ACCEPT</onMatch>
            <onMismatch>DENY</onMismatch>
        </filter>
    </appender>
</configuration>

性能优化的一些思考

虽然我是前端出身,但这段时间搞后端也积累了一些性能优化的经验,跟大家分享一下:

1. 异步处理是王道

文档上传和Embedding处理一定要异步。用户传完文档,直接返回"处理中",后台慢慢处理。我们用RocketMQ做消息队列,效果很好。

2. 缓存很重要

一些高频查询的结果要缓存。比如热门问题的回答,可以直接缓存到Redis,不用每次都走一遍Agent工作流。

@Cacheable(value = "qa", key = "#query", unless = "#result == null")
public AgentResponse getCachedAnswer(String query) {
    return agentWorkflowService.process(query, "system");
}

3. 数据库索引要搞好

Milvus的索引类型选择很重要。我们用的是IVF_FLAT,对于百万级数据量,查询速度和召回率都比较平衡。

索引类型 构建时间 查询速度 内存占用 适用场景
FLAT 数据量小(<10万)
IVF_FLAT 百万级数据
HNSW 最快 对延迟要求极高

4. 监控不能少

我们用了SkyWalking做链路追踪,Prometheus + Grafana做指标监控。有一次线上响应变慢,就是通过链路追踪发现是Milvus查询慢,最后定位到是索引没建好。

总结

搞了两个月,这个项目总算上线了。虽然中间踩了不少坑,但收获还是很大的。

说几个我的心得体会吧:

  1. 不要怕跨领域。我是前端出身,但搞后端也没有想象中那么难。很多概念是相通的,比如服务拆分、缓存、限流,前端也有类似的东西。

  2. AI应用的核心不是模型,而是工程化。模型大家用的都差不多,关键是怎么把模型能力稳定、高效地落地到业务中。

  3. 微服务不是银弹。如果团队小、业务简单,单体应用完全够用。不要为了微服务而微服务。

  4. 监控和日志太重要了。没有监控的系统就是在裸奔,出了问题你都不知道哪里出的。

最后,如果你也在杭州,也在搞AI相关的项目,欢迎交流。我最近也在看阿里的机会,如果有内推的就更好了,哈哈。

对了,这篇文章的代码都脱敏过了,不能直接跑,但思路可以参考。有问题可以在评论区问我,看到都会回。


写于2024年某个加班的周末,咖啡已经喝了第四杯了...

评论 0

最热最新
暂无评论
张刚Lv.1
0
影响力
0
文章
0
粉丝