1. 这不是“Java面试题合集”,而是一份大模型应用落地的实战检查清单
你刷到这个标题时,大概率正处在两种状态之一:要么是Java后端干了3-5年,手握Spring Boot、MyBatis、Redis全套技能,但看到招聘JD里突然冒出“熟悉RAG”“了解Agent架构”“能配置SpringAI系统提示词”时,脑子嗡的一声——这些词我每个字都认识,连起来却像天书;要么是刚用LangChain写了个问答demo,兴奋地发朋友圈,结果被同事一句“你这知识库没做chunking吧?embedding用的什么模型?向量检索延迟多少?并发扛得住吗?”直接问哑火。别慌,这不是你技术不行,而是整个行业正在经历一次静默但剧烈的范式迁移:后端工程师的职责边界,正从“把业务逻辑跑通”悄然扩展到“让大模型稳定、可控、可解释地完成任务”。
这45个问题,没有一个是凭空编出来的。它们全部来自我过去8个月参与的7个真实Java大模型项目交付现场——从某省政务智能问答平台(要求99.95% SLA、审计日志全链路可追溯),到某头部电商的客服Agent重构(日均调用量2300万+,峰值QPS 1.2万),再到某金融风控知识助手(需严格隔离敏感字段、支持结构化数据与非结构化文档混合检索)。每一个问题背后,都对应着一个踩过坑、改过三次代码、重搭过两次向量库的真实场景。比如“RAG知识库能存储图片吗?”这个问题,表面看是技术可行性,实际在政务项目里,它直接关联到“如何让基层工作人员上传的扫描件PDF中的公章、手写批注、表格数据,都能被准确召回并用于生成合规答复”。再比如“Agent怎么扛并发”,在电商项目里,我们最终不是靠堆机器解决的,而是把Agent的决策树拆解成状态机+轻量级规则引擎,把LLM调用压缩到每轮对话仅1次关键推理。
所以,这不是一份用来背诵的题库,而是一张Java工程师切入大模型应用开发的路线图+避坑地图+能力校准表。它不教你怎么调用OpenAI API,而是告诉你:当你的Spring Boot服务要集成RAG时,Embedding模型选型不是看谁参数量大,而是看它对中文法律条文的语义保真度是否足够支撑“相似法条召回”;当你配置SpringAI的System Prompt时,真正决定效果的不是华丽的指令模板,而是Prompt中是否嵌入了可被下游向量库检索的结构化锚点;当你评估一个Agent框架时,核心指标不是它支持多少工具,而是它的Observation解析模块能否在毫秒级内完成JSON Schema校验与字段映射。接下来的内容,我会带你一层层剥开这些看似高大上的概念,落到Java代码、Spring配置、向量库SQL、线程池参数这些你每天打交道的东西上。准备好了吗?我们从最基础也最容易被忽视的环节开始。
2. 技术选型不是拼参数,而是匹配业务约束的精密计算
2.1 SpringAI:不是“Spring版LangChain”,而是Java生态的生产级胶水
很多人第一次接触SpringAI,下意识把它当成“Java版LangChain”,这是最大的认知偏差。LangChain是实验性框架,设计哲学是“快速验证想法”,而SpringAI的设计目标非常明确:让Java后端工程师能在现有Spring Boot工程里,以最小侵入方式接入大模型能力,并满足企业级应用的可观测性、事务一致性、安全审计要求。这意味着它的API设计、异常处理、配置加载机制,全部遵循Spring的约定优于配置原则。
举个最典型的例子:系统提示词(System Prompt)配置。在LangChain里,你可能这样写:
ChatPromptTemplate prompt = ChatPromptTemplate.fromMessages( List.of( new SystemMessage("你是一个严谨的法律助手,只根据提供的法条回答问题,不臆测,不补充。"), new HumanMessage("{input}") ) );而在SpringAI里,你必须通过application.yml或@ConfigurationProperties来管理:
spring: ai: openai: chat: options: system-prompt: "你是一个严谨的法律助手,只根据提供的法条回答问题,不臆测,不补充。"为什么强制这么做?因为企业级应用要求提示词变更必须可灰度、可回滚、可审计。如果提示词硬编码在Java类里,每次修改都要走完整发布流程;而放在配置中心(如Nacos、Apollo),运维人员可以随时调整,且所有变更都有操作日志。我见过一个项目,因提示词里漏掉“不提供医疗建议”的免责声明,导致用户误操作后投诉,最后靠配置中心的历史版本一键回滚,避免了重大舆情风险。
再看它的核心抽象AiResponse和StreamingAiResponse。LangChain返回的是AIMessage对象,字段松散;SpringAI则强制定义了AiResponse接口,要求实现类必须提供getMetadata()方法,里面包含model,tokenUsage,finishReason等标准字段。这直接对接了企业监控体系——你可以轻松把tokenUsage.totalTokens打点到Prometheus,设置告警阈值;把finishReason分类统计,发现大量length意味着需要优化prompt长度或调整maxTokens。
提示:SpringAI 1.0正式版已放弃对旧版OpenAI API的兼容,强制要求使用v1/chat/completions端点。如果你还在用
/v1/engines/{model}/completions这种老接口,升级时会遇到404 Not Found错误。这不是Bug,是SpringAI主动切断了对非标准API的支持,确保所有接入方都走统一、可审计的路径。
2.2 RAG:知识库不是“扔文档进去就完事”,而是分层构建的工程系统
RAG(Retrieval-Augmented Generation)常被简化为“检索+生成”,但真实项目里,它是一个由数据预处理层、向量化层、检索层、重排序层、生成层组成的流水线。每个环节的选型,都直接影响最终效果和运维成本。
先说最常被忽略的数据预处理层。很多团队直接用Apache Tika解析PDF,结果发现扫描件里的文字识别率极低,或者表格变成乱码。正确的做法是分三类处理:
- 纯文本文件(TXT, MD):直接读取,按段落切分(
\n\n分割),保留原始格式。 - 办公文档(DOCX, XLSX):用Apache POI,特别注意XLSX要提取单元格内容而非渲染样式,避免把“加粗”“红色字体”等无关信息喂给Embedding模型。
- 扫描PDF:必须引入OCR引擎。Tesseract精度不够,我们实测PaddleOCR在中文场景下F1-score高出23%,且支持GPU加速。关键技巧:对PDF先按页分割,每页OCR后,用规则提取标题层级(如“一、”“1.”“1.1”),构建带层级的chunk,这样检索时能精准定位到“第三章第二节”。
然后是向量化层。Embedding模型选型不是比谁开源、谁免费。我们做过对比测试:在相同硬件(A10 GPU)上,对10万份法律文书做向量化:
| 模型 | 单文档平均耗时 | 中文语义相似度(人工评测) | 显存占用 |
|---|---|---|---|
| text-embedding-ada-002 | 120ms | 78% | 1.2GB |
| bge-small-zh-v1.5 | 85ms | 89% | 0.9GB |
| m3e-base | 65ms | 82% | 0.7GB |
结论很清晰:bge-small-zh-v1.5是当前中文场景的性价比之王。它由智谱AI开源,专为中文优化,在法律、政务等垂直领域表现稳定。而text-embedding-ada-002虽然通用性强,但对中文长尾词汇(如“行政复议”“羁押必要性审查”)表征能力弱,导致相关法条召回率下降。
最关键的是检索层与重排序层的协同。很多团队只用向量库做ANN(近似最近邻)检索,top-k=5,然后直接喂给LLM。这在简单问答还行,一旦涉及多跳推理(如“根据《XX条例》第5条,结合《实施细则》第12款,分析该行为是否构成违法?”),就会失败。我们的方案是:先用向量库召回top-20,再用Cross-Encoder(如bge-reranker-base)做精排,选出top-5。Cross-Encoder虽慢(单次200ms),但它能建模Query与Document的深层交互,对复杂语义关系捕捉更准。实测在政务问答场景,精排后准确率提升37%。
注意:向量库选型必须考虑Java生态兼容性。Weaviate的Java SDK文档稀疏,且其GraphQL查询语法与Spring Data习惯不符;Milvus 2.x的Java客户端对Spring Boot 3.x支持有坑;最终我们选择Qdrant——它的REST API极简(GET /collections/{name}/points?limit=5),官方Java SDK成熟,且支持Payload过滤(如
{"filter": {"must": [{"key": "doc_type", "value": "law"}]}}),能天然对接Spring Security的权限控制。
2.3 Agent:不是“让LLM调用API”,而是构建可中断、可审计、可降级的状态机
Agent常被误解为“LLM + 函数调用”,但生产环境里,它必须是一个具备明确状态、可预测行为、可人工干预的系统。我们交付的电商客服Agent,核心不是它能调用多少API,而是它在以下场景的表现:
- 用户突然说“等等,我要换种问法”,Agent必须能立即中断当前执行链,清空临时状态,重新开始。
- 当库存查询API超时,Agent不能卡死,而应降级为“正在为您核实,请稍候”,并触发异步重试。
- 所有工具调用必须记录完整输入输出,供后续质检与模型微调。
因此,我们摒弃了LangChain的AgentExecutor,自研了基于状态机(State Machine)的Agent Core。每个Agent实例对应一个AgentContext对象,包含:
currentState: ENUM(IDLE, PLANNING, EXECUTING_TOOL, WAITING_FOR_USER, FAILED)currentPlan: List (当前执行计划)toolResults: Map<String, Object>(已执行工具的结果缓存)userInputHistory: List (用户历史输入,用于上下文感知)
状态流转由AgentOrchestrator控制,它接收UserMessage,根据currentState和userInput决定下一步动作。例如,当currentState == EXECUTING_TOOL且收到新UserMessage,它不会盲目追加到历史,而是先发送InterruptCommand给正在执行的工具线程,再将新消息标记为UrgentOverride,触发Plan重生成。
工具调用层,我们采用标准化的Tool Interface:
public interface Tool { String getName(); // 工具唯一标识,用于Prompt中引用 String getDescription(); // 工具功能描述,供LLM理解 ToolSchema getSchema(); // JSON Schema,定义输入参数结构 Mono<Object> execute(Map<String, Object> input); // 异步执行,返回Mono适配WebFlux }所有工具实现都注入Spring容器,通过@Qualifier("inventoryCheckTool")注入。这样,当需要替换库存查询服务(如从内部RPC切换到外部HTTP API),只需新增一个InventoryCheckToolV2实现类,修改Bean名称,无需改动Agent核心逻辑。
实操心得:Agent的并发瓶颈往往不在LLM本身,而在Observation解析。LLM返回的JSON可能格式错误、字段缺失、类型错乱。我们强制要求所有Tool返回的JSON必须通过
JsonSchemaValidator校验,校验失败则触发Fallback策略(如返回默认值或抛出特定异常),避免Agent因解析失败而陷入死循环。这个校验器我们用Jackson Schema Module实现,耗时仅3ms,但避免了90%的线上故障。
2.4 向量库与知识库:不是“数据库替代品”,而是语义索引的专用引擎
把向量库当成“高级版MySQL”是常见误区。向量库的核心价值是在高维空间中,以亚秒级响应,找到语义最相近的向量,它不擅长事务、不保证强一致、不支持复杂JOIN。因此,知识库架构必须是向量库 + 关系型数据库的混合体。
我们的标准架构是:
- Qdrant:存储向量、元数据(doc_id, chunk_id, source_url)、全文检索关键词(用于Hybrid Search)。
- PostgreSQL:存储原始文档全文、作者、创建时间、审批状态、访问权限策略(行级权限RLS)。
- Redis:缓存高频检索结果(如“最新政策解读”Top10),降低向量库压力。
三者如何协同?以一次典型检索为例:
- 用户提问:“2024年新能源汽车补贴政策有哪些变化?”
- SpringAI调用
QdrantClient.search(),传入Embedding向量 + Filter({"must": [{"key": "category", "value": "policy"}, {"key": "year", "value": "2024"}]}),返回top-5 chunk的chunk_id。 - 后端服务用
chunk_id批量查询PostgreSQL,获取原始段落、文档标题、生效日期,并根据用户角色(@PreAuthorize("hasRole('ADMIN')"))过滤敏感字段。 - 将结构化结果(标题、日期、关键条款)和原始段落,组装成Prompt的Context部分,交由LLM生成最终回复。
这个架构的关键在于Filter的精确性。Qdrant的Filter支持布尔表达式,但性能取决于索引。我们为category和year字段建立了Scalar Index,使Filter耗时从800ms降至12ms。而source_url这种长字符串字段,我们只做Keyword Index,用于精确匹配(如{"must": [{"key": "source_url", "match": {"value": "http://gov.cn/xx/2024-03/01/content_XXXXX.htm"}}]})。
常见陷阱:很多人试图在向量库中存储图片。Qdrant确实支持
image数据类型,但它的本质是把图片转为Embedding向量存储。问题在于:图片的语义Embedding与文本Embedding不在同一向量空间,无法直接混合检索。正确方案是:用CLIP模型分别提取图片和文本Embedding,存入不同Collection,再通过Application Layer做结果融合。但更务实的做法是——图片不进知识库,只存URL,让LLM在生成回复时,用Markdown语法插入。我们实测,用户对“看到图片”和“看到图片链接”的满意度无显著差异,但系统复杂度降低80%。
3. 核心环节实现:从代码片段到可交付系统的完整链条
3.1 SpringAI系统提示词配置:不是写作文,而是设计可执行的指令协议
SpringAI的system-prompt配置,常被当作“给AI写作文”,这是致命错误。一个生产级的System Prompt,本质是一份面向LLM的、可被程序解析的指令协议。它必须包含三个刚性要素:角色定义、约束条件、输出契约。
以政务问答场景为例,我们最终确定的Prompt模板:
spring: ai: openai: chat: options: system-prompt: | 你是一名省级政务服务中心的智能助理,严格依据《中华人民共和国行政许可法》及本省《政务服务事项清单》提供咨询。 【约束条件】 - 仅回答与政务服务相关的具体问题,拒绝回答政治、宗教、医疗建议类问题。 - 所有答案必须标注法条来源,格式为【《法规名称》第X条第X款】。 - 若问题超出知识库范围,回答“根据现行规定,该事项暂未纳入本省政务服务清单,建议您咨询主管部门。” 【输出契约】 - 回答必须为纯文本,禁止使用Markdown、HTML、代码块。 - 每个回答开头必须包含状态码:[OK]表示正常回答,[REFUSE]表示拒绝回答,[UNKNOWN]表示知识库无覆盖。 - 答案长度严格控制在300字以内。这个Prompt的设计逻辑是:
- 角色定义锚定了LLM的“身份认知”,避免它以“通用AI”身份胡说八道。
- 约束条件是硬性规则,其中“标注法条来源”直接服务于审计需求——所有回答都可溯源到具体法规条款。
- 输出契约是关键!
[OK]/[REFUSE]/[UNKNOWN]状态码,让下游服务能自动分流:[OK]走正常流程,[REFUSE]触发人工审核队列,[UNKNOWN]自动加入知识库待扩充队列。这实现了LLM输出与业务流程的无缝对接。
实操中,我们发现Prompt的微小变动会引发连锁反应。曾有一次,把“300字以内”改成“不超过300字”,LLM开始在回答末尾加省略号(...),导致前端渲染错位。后来我们强制要求:“若超限,截断至300字,不加任何标点或符号”。这说明,Prompt不是自然语言,而是需要反复调试的程序接口。
配置技巧:SpringAI支持
spring.ai.openai.chat.options.system-prompt和spring.ai.openai.chat.options.user-prompt双配置。我们把角色定义、约束条件放system-prompt,把本次对话的上下文(如用户历史提问、当前会话ID)动态注入user-prompt。这样既保证了系统级规则稳定,又赋予了单次对话灵活性。
3.2 RAG知识库构建:从文档到向量的全链路代码实现
RAG知识库的构建,绝非“把文件丢进脚本”。它是一个需要精细控制的流水线。以下是我们在政务项目中使用的、经过压测验证的Java实现:
Step 1:文档解析与Chunking
@Component public class DocumentProcessor { private final PaddleOCRService ocrService; // 封装PaddleOCR调用 private final TextSplitter textSplitter; public List<Chunk> processDocument(File document) { String rawText = extractText(document); // 按标题层级切分,保留层级信息 List<Chunk> chunks = textSplitter.splitByHeading(rawText); // 为每个chunk添加元数据 return chunks.stream() .map(chunk -> Chunk.builder() .content(chunk.getContent()) .metadata(Map.of( "doc_id", document.getName(), "chunk_id", UUID.randomUUID().toString(), "heading_level", chunk.getLevel(), "page_number", chunk.getPageNumber() )) .build()) .collect(Collectors.toList()); } private String extractText(File file) { if (isScannedPdf(file)) { return ocrService.recognize(file); // 调用PaddleOCR } else if (file.getName().endsWith(".docx")) { return ApachePOIService.extractText(file); // POI提取 } else { return Files.readString(file.toPath(), StandardCharsets.UTF_8); } } }Step 2:向量化与入库
@Service public class VectorIngestionService { private final QdrantClient qdrantClient; private final BgeEmbeddingModel embeddingModel; // 封装bge-small-zh-v1.5调用 @Transactional // 确保向量与元数据原子性写入 public void ingestChunks(List<Chunk> chunks) { List<PointStruct> points = chunks.stream() .map(chunk -> { // 生成Embedding float[] vector = embeddingModel.embed(chunk.getContent()); // 构建Qdrant Point return PointStruct.newBuilder() .setId(UUID.randomUUID().toString()) .setVectors(VectorizerFactory.createVector(vector)) .setPayload(Payload.newBuilder() .putAllFields(chunk.getMetadata()) .put("content", chunk.getContent()) // 存储原文,用于Hybrid Search .build()) .build(); }) .collect(Collectors.toList()); // 批量写入,Qdrant支持upsert qdrantClient.upsert(PointsUpsertRequest.newBuilder() .setCollectionName("gov_policy") .setPoints(points) .build()); } }Step 3:检索与重排序
@Service public class RAGService { private final QdrantClient qdrantClient; private final CrossEncoderReranker reranker; // 封装bge-reranker-base public List<Chunk> retrieveAndRerank(String query, int topK) { // Step 1: 向量检索 SearchPoints searchPoints = SearchPoints.newBuilder() .setCollectionName("gov_policy") .setVector(embeddingModel.embed(query)) .setLimit(topK * 4) // 取更多候选,供重排序 .setFilter(Filter.newBuilder() .addMust(Condition.newBuilder() .setKey("category") .setValueMatch(ValueMatch.newBuilder() .setStringValue("policy") .build()) .build()) .build()) .build(); List<ScoredPoint> scoredPoints = qdrantClient.search(searchPoints).getResultList(); // Step 2: Cross-Encoder重排序 List<RerankPair> pairs = scoredPoints.stream() .map(point -> new RerankPair(query, (String) point.getPayloadMap().get("content"))) .collect(Collectors.toList()); List<RerankResult> reranked = reranker.rerank(pairs); // Step 3: 组装结果 return reranked.subList(0, topK).stream() .map(result -> { ScoredPoint point = scoredPoints.get(result.getIndex()); return Chunk.builder() .content((String) point.getPayloadMap().get("content")) .metadata(point.getPayloadMap()) .score(result.getScore()) .build(); }) .collect(Collectors.toList()); } }这个实现的关键细节:
- Chunking策略:不是简单按字符数切分,而是识别标题层级,确保“第一章”“第一节”这样的逻辑单元不被割裂。
- 事务性:
@Transactional保证向量写入与PostgreSQL元数据写入的原子性,避免出现“向量存在但找不到原文”的脏数据。 - Hybrid Search:Qdrant的Payload中存储了
content字段,并为其建立Keyword Index,使得search请求可同时进行向量相似度检索和关键词精确匹配,大幅提升召回率。
3.3 Agent状态机实现:用Java State Machine构建可管控的智能体
Agent的状态机实现,是我们项目中最体现Java工程师优势的部分——用成熟的、可调试的、符合JVM特性的代码,替代不可控的LLM黑盒决策。
我们基于Spring Statemachine构建了核心状态机:
@Configuration @EnableStateMachine public class AgentStateMachineConfig extends StateMachineConfigurerAdapter<String, String> { @Override public void configure(StateMachineStateConfigurer<String, String> states) throws Exception { states .withStates() .initial("IDLE") .state("PLANNING") .state("EXECUTING_TOOL") .state("WAITING_FOR_USER") .state("FAILED") .end("COMPLETED"); } @Override public void configure(StateMachineTransitionConfigurer<String, String> transitions) throws Exception { transitions .withExternal() .source("IDLE").target("PLANNING").event("RECEIVE_QUERY") .and() .withExternal() .source("PLANNING").target("EXECUTING_TOOL").event("PLAN_APPROVED") .and() .withExternal() .source("EXECUTING_TOOL").target("WAITING_FOR_USER").event("TOOL_COMPLETED") .and() .withExternal() .source("WAITING_FOR_USER").target("PLANNING").event("RECEIVE_NEW_QUERY") // 支持用户中途修改 .and() .withExternal() .source("EXECUTING_TOOL").target("FAILED").event("TOOL_FAILED") .and() .withExternal() .source("FAILED").target("IDLE").event("USER_RETRY"); } }Agent Orchestrator的核心逻辑:
@Service public class AgentOrchestrator { private final StateMachine<String, String> stateMachine; private final ToolRegistry toolRegistry; public Mono<AgentResponse> handleUserMessage(AgentContext context, String userInput) { return Mono.just(context) .flatMap(ctx -> { // 根据当前状态和事件,触发状态流转 Message<String> message = MessageBuilder.withPayload("RECEIVE_QUERY") .setHeader("userInput", userInput) .setHeader("context", ctx) .build(); return stateMachine.send(Mono.just(message)).thenReturn(context); }) .flatMap(ctx -> { switch (ctx.getCurrentState()) { case "PLANNING": return generatePlan(ctx, userInput); case "EXECUTING_TOOL": return executeTool(ctx); case "WAITING_FOR_USER": return waitForUser(ctx, userInput); default: return Mono.error(new IllegalStateException("Invalid state: " + ctx.getCurrentState())); } }); } private Mono<AgentContext> generatePlan(AgentContext context, String userInput) { // 调用LLM生成Tool Call Plan return llmClient.generatePlan(userInput, context.getAvailableTools()) .map(plan -> { context.setCurrentPlan(plan); context.setCurrentState("EXECUTING_TOOL"); return context; }); } private Mono<AgentContext> executeTool(AgentContext context) { ToolCall nextCall = context.getCurrentPlan().get(0); Tool tool = toolRegistry.getTool(nextCall.getName()); return tool.execute(nextCall.getParameters()) .doOnSuccess(result -> { context.getToolResults().put(nextCall.getId(), result); context.setCurrentState("WAITING_FOR_USER"); }) .onErrorResume(error -> { context.setCurrentState("FAILED"); context.setLastError(error.getMessage()); return Mono.empty(); }) .thenReturn(context); } }这个设计带来的收益:
- 可调试性:每个状态转换都有日志,可清晰追踪Agent为何卡在
EXECUTING_TOOL。 - 可干预性:运维人员可通过Admin API,强制将某个Agent实例状态设为
IDLE,立即终止其所有操作。 - 可降级性:当LLM服务不可用时,状态机可直接跳转到
FAILED,返回预设的兜底话术,而非无限等待。
3.4 向量库与关系库协同:Qdrant + PostgreSQL的混合查询实践
混合查询不是简单的“先查向量,再查PG”,而是要设计一套数据一致性保障与性能平衡的协议。
我们的协同方案:
写入一致性:所有文档入库,必须通过
IngestionService统一入口。它先将原始文档存入PostgreSQL,获取doc_id,再将chunk及其doc_id作为元数据写入Qdrant。PostgreSQL的INSERT与Qdrant的upsert放在同一个@Transactional中,利用Spring的JDBC事务传播,确保二者原子性。读取优化:Qdrant的
search返回的是ScoredPoint,其中payload只包含轻量元数据(doc_id,chunk_id,heading)。真正的全文内容、作者、审批状态等,通过doc_id批量查询PostgreSQL。这里的关键是批量查询的性能:
@Repository public class DocumentRepository { private final JdbcTemplate jdbcTemplate; public List<Document> findDocumentsByIds(List<String> docIds) { // 使用IN查询,但限制数量防止SQL过长 if (docIds.size() > 100) { throw new IllegalArgumentException("Too many docIds"); } String placeholders = String.join(",", Collections.nCopies(docIds.size(), "?")); String sql = "SELECT id, title, content, author, status FROM documents WHERE id IN (" + placeholders + ")"; return jdbcTemplate.query(sql, new DocumentRowMapper(), docIds.toArray()); } }- 权限控制:PostgreSQL启用Row Level Security (RLS),为不同角色定义策略:
-- 为普通用户创建策略 CREATE POLICY user_document_policy ON documents FOR SELECT USING (current_user = 'user' AND status = 'published'); -- 为管理员创建策略 CREATE POLICY admin_document_policy ON documents FOR SELECT USING (current_user = 'admin');这样,即使Qdrant返回了所有doc_id,DocumentRepository查询时,PostgreSQL会自动过滤掉用户无权访问的记录,实现真正的数据隔离。
实操心得:Qdrant的
filter功能虽强大,但不要在Filter中做复杂计算。例如,{"must": [{"key": "created_at", "range": {"gte": "2024-01-01"}}]}是高效的,但{"must": [{"key": "age", "range": {"gte": "timestampdiff(day, created_at, now())"}}]}会导致全表扫描。所有时间计算、业务逻辑判断,必须在Application Layer完成,Qdrant只做精确匹配或简单范围查询。
4. 常见问题与排查技巧实录:那些只有踩过坑才懂的真相
4.1 “RAG知识库能存储图片吗?”——关于多模态的务实回答
这个问题背后,往往藏着一个更实际的需求:“用户上传了带图表的PDF,如何让LLM理解图表内容并回答?”答案是:不要试图让向量库存储图片,而要让图片内容可被文本化、可被检索。
我们的解决方案是“OCR+Captioning双通道”:
- OCR通道:对PDF每页调用PaddleOCR,提取文字、表格、公式,生成结构化文本(含坐标信息)。
- Captioning通道:对PDF中检测到的图片区域(通过OpenCV轮廓检测),调用BLIP-2模型生成描述性文本(Caption),如“图1:2023年各省份新能源汽车销量柱状图,广东销量最高,达12.5万辆”。
这两部分文本,都作为独立的Chunk存入Qdrant。当用户问“广东销量是多少?”,向量检索会同时召回OCR文本(含“广东”“12.5万辆”)和Caption(含“广东销量最高”),LLM综合两者生成答案。
排查技巧:如果发现图片相关问题召回率低,先检查Captioning模型的中文描述质量。我们曾遇到BLIP-2对中文图表描述过于笼统(只说“一张销量图表”),后来切换到Qwen-VL,它能精确识别“柱状图”“折线图”,并提取坐标轴标签,召回率提升52%。
4.2 “Agent怎么扛并发?”——从线程模型到资源隔离的全栈优化
Agent的并发瓶颈,90%不在LLM API,而在本地资源争抢。我们电商项目的峰值QPS 1.2万,初期用@Async+线程池,结果OOM频发。根本原因是:每个Agent实例都持有大量中间状态(currentPlan,toolResults),而线程池共享内存,导致GC压力剧增。
解决方案是三层隔离:
- 线程隔离:每个Agent请求分配独立线程(
new Thread()),但受ThreadPoolExecutor管理,避免线程爆炸。核心参数:corePoolSize=200,maxPoolSize=500,queueCapacity=1000。 - 内存隔离:
AgentContext对象不存于ThreadLocal,而是作为参数在各方法间传递,确保GC能及时回收。 - 资源隔离:为不同业务线(售前、售后、物流)配置独立的
ToolRegistry和LLMClient实例,避免一个业务线的API抖动影响全局。
最关键的优化是LLM调用的连接池化。我们用OkHttpClient的ConnectionPool,设置maxIdleConnections=20,keepAliveDuration=5, TimeUnit.MINUTES,使LLM API的TCP连接复用率从35%提升至92%,平均RT降低180ms。
常见问题速查表:
现象 可能原因 排查命令 Agent响应延迟突增 Qdrant向量检索慢 qdrant_client.search(...)打点,看耗时是否>200msLLM返回格式错误 System Prompt未强制输出契约 检查Prompt中是否有 [OK]/[REFUSE]状态码要求知识库召回结果不相关 Embedding模型未针对领域微调 对比 bge-small-zh-v1.5与text-embedding-ada-002的余弦相似度Agent状态机卡死 状态流转缺少 error分支检查 StateMachineConfig中是否为所有FAILED事件配置了target
4.3 “SpringAI系统提示词怎么配置?”——配置失效的5个隐藏雷区
SpringAI的Prompt配置看似简单,实则暗藏多个“配置失效”陷阱:
YAML缩进错误:
system-prompt是字符串,必须顶格写,或严格缩进。错误写法:spring: ai: openai: chat: options: system-prompt: | 你是一个...正确写法(顶格):
spring: ai: openai: chat: options: system-prompt: "你是一个..."Profile覆盖:
application-dev.yml中的配置,会被application.yml中同名属性覆盖。务必检查spring.profiles.active。Spring Boot 3.x兼容性:SpringAI 0.8.x不兼容Spring Boot 3.2+,必须升级到1.0.0-M1及以上。
IDE缓存:IntelliJ IDEA有时不刷新