一个前端被逼着搞Spring Cloud Alibaba的血泪史
坐标杭州,某二线互联网公司,前端开发第三年。最近在研究性能优化,顺便被领导拉去搞了个AI相关的项目,用到了Spring Cloud Alibaba。这篇文章算是我这段时间的踩坑记录,希望能帮到同样被"全栈"的兄弟们。
事情是这样的
上周五快下班的时候,我们技术总监老张把我叫到会议室,说有个新项目要搞。我心想完了,又要加需求了。结果老张说:"这个项目是跟AI相关的,后端人手不够,你前端也懂点Node.js,来帮忙搞搞后端吧。"
我当时内心是崩溃的。我一个写React的,你让我搞Spring Cloud?这不是让厨子去修飞机吗?
但话说回来,这两年杭州这边阿里、网易招人确实卷,不学点后端的东西,简历都不好写。再加上最近AI这么火,什么大模型、Agent、RAG之类的概念满天飞,想着学学也不亏,就硬着头皮接了。
项目大概是这样的:我们要做一个企业级的AI知识问答系统,类似秘塔AI搜索那种,但主要是面向内部员工用的。用户可以上传文档,系统会把文档内容做Embedding处理,存到向量数据库里,然后用户提问的时候,通过Agent工作流去检索相关内容,最后用大模型生成回答。
听起来挺简单对吧?实际上坑多得一批。
技术选型:为什么是Spring Cloud Alibaba
说实话,一开始我是想直接用Spring Boot搞个单体应用的。毕竟我们团队后端就三个人,搞微服务不是给自己找事吗?
但老张说了几个理由,我觉得还挺有道理:
- 业务拆分明确:文档处理、向量化、问答生成,这几个模块天然可以拆分
- 后续扩展性:如果以后要接更多的AI能力,微服务架构更好扩展
- 团队技术栈:公司后端基本都是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需要:
- 理解用户意图
- 从向量数据库中检索相关文档
- 如果检索结果不够,可能需要进一步搜索或者调用其他工具
- 整合信息,生成最终回答
我们用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,主要做几件事:
- 统一鉴权(JWT)
- 路由转发
- 限流(配合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查询慢,最后定位到是索引没建好。
总结
搞了两个月,这个项目总算上线了。虽然中间踩了不少坑,但收获还是很大的。
说几个我的心得体会吧:
不要怕跨领域。我是前端出身,但搞后端也没有想象中那么难。很多概念是相通的,比如服务拆分、缓存、限流,前端也有类似的东西。
AI应用的核心不是模型,而是工程化。模型大家用的都差不多,关键是怎么把模型能力稳定、高效地落地到业务中。
微服务不是银弹。如果团队小、业务简单,单体应用完全够用。不要为了微服务而微服务。
监控和日志太重要了。没有监控的系统就是在裸奔,出了问题你都不知道哪里出的。
最后,如果你也在杭州,也在搞AI相关的项目,欢迎交流。我最近也在看阿里的机会,如果有内推的就更好了,哈哈。
对了,这篇文章的代码都脱敏过了,不能直接跑,但思路可以参考。有问题可以在评论区问我,看到都会回。
写于2024年某个加班的周末,咖啡已经喝了第四杯了...


评论 0