大厂AI工程师面试全攻略:真题+解题思路+高分回答模板
大厂AI工程师面试全攻略:真题+解题思路+高分回答模板
小赵的失败与逆转
2025年春天,小赵来找我复盘他的字节跳动AI工程师一面。
他在某电商公司做了3年Java,2024年下半年开始转型,自学了RAG,在公司做了个内部知识库小项目,觉得自己准备得差不多了。
一面结果:挂了。
"面试官上来就问RAG的召回率怎么优化,我说了一大堆,什么调整chunk size啊,embedding模型换一个啊。然后他问:除了这些,还有什么方法?我当时脑子一片空白。"
他停顿了一下,接着说:"其实我在做项目的时候遇到过这个问题,我用了混合检索,确实提升了很多。但面试时太紧张,完全没想到这个点。而且他问我混合检索的原理,我说不清楚。"
这是最典型的AI面试失败模式:做过,但没复盘;用过,但不理解原理。
小赵花了三个月重新准备,不只是看教程,而是把每一个用过的技术都深挖原理、整理成自己能流利讲清楚的语言。
三个月后,他用同样的方式投了字节,二面通过,三面聊完系统设计后,当场收到口头offer。
今天这篇文章,就是帮你把这三个月的准备,高效地压缩到一个系统化的框架里。
第一部分:大厂AI工程师面试流程解析
在准备之前,先了解你要面对的是什么。
标准面试流程(以字节、阿里、腾讯为例)
各轮重点:
| 轮次 | 主考官 | 重点内容 | 难度 |
|---|---|---|---|
| HR初筛 | HR | 基本背景、薪资期望、入职时间 | 低 |
| 技术一面 | 同级工程师 | 编码能力、AI基础知识 | 中 |
| 技术二面 | 高级工程师 | 项目深度、技术细节、踩坑经历 | 高 |
| 系统设计面 | 技术专家/架构师 | 系统设计、架构思维、技术判断力 | 很高 |
| 交叉面 | 其他组负责人 | 综合能力、团队协作 | 中高 |
| HR面 | HR | 文化匹配、职业规划 | 低 |
关键准备重心: 技术二面和系统设计面是决定性轮次,要把70%的准备时间投入在这里。
第二部分:高频技术题TOP20(含标准答案)
以下题目来自2024-2026年大厂AI工程师真实面试题,由星球成员面试后整理,并经过我的补充和完善。
RAG相关(8题)
题目1:RAG的完整工作流程是什么?
高分回答框架:
RAG(检索增强生成)的核心流程分为两个阶段:离线索引和在线查询。
离线索引阶段:
- 文档加载:支持PDF、Word、HTML等多种格式
- 文档切分(Chunking):将长文档切分为合适大小的块(通常512-1024 token)
- 向量化(Embedding):用Embedding模型将每个块转化为向量
- 存储:将向量和原文本存入向量数据库(如Milvus、ChromaDB)
在线查询阶段:
- 查询理解:可选的意图识别和查询改写
- 检索:将用户问题向量化后,在向量库中做ANN(近似最近邻)搜索
- 重排序(可选):用ReRanker对检索结果二次排序
- 生成:将检索到的上下文 + 用户问题组装成Prompt,调用LLM生成答案
实际生产中还需要考虑:检索结果过滤(去除无关结果)、Prompt长度管理(context窗口限制)、以及结果的可解释性(来源引用)。
题目2:召回率低怎么优化?
高分回答框架(结构化):
召回率低通常有三个层面的原因,我从这三个层面来说优化方案:
层面一:Embedding和检索策略
- 换更好的Embedding模型(BGE系列、text-embedding-3在中文上通常比OpenAI的ada更好)
- 引入混合检索:向量检索召回语义相关,BM25召回关键词匹配,用RRF算法融合
- 查询扩展:用LLM对原始查询生成多个变体(HyDE或MultiQuery),提升查询覆盖面
层面二:文档分块质量
- 调整chunk size:chunk太小丢失上下文,chunk太大噪声多,需要实验调整
- 使用语义分块而非固定窗口分块:在段落/标题边界分块,保证语义完整性
- 对表格、代码等特殊内容单独处理(通用分块策略对这些内容效果差)
层面三:索引和检索参数
- Milvus的HNSW索引:增大ef参数(提升召回率,但降低速度)
- 增加top-k数量(从5增到20),然后用ReRanker缩减到最终数量
优化时要建立评估集,每次改动后跑评估,用数据说话。
题目3:向量数据库的选型依据是什么?
| 产品 | 适用场景 | 优势 | 劣势 |
|---|---|---|---|
| Milvus | 中大规模生产(百万级+向量) | 性能强、功能完善、开源 | 运维复杂、资源消耗大 |
| ChromaDB | 开发/测试/小规模应用 | 简单易用、轻量 | 性能有限、生产稳定性弱 |
| pgvector | 已有PostgreSQL基础设施的团队 | 无需新组件、ACID事务 | 大规模下性能不如专用向量DB |
| Qdrant | 对Rust性能有要求 | 内存效率高 | 中文社区资源少 |
| Weaviate | 需要图谱+向量结合的场景 | 支持GraphQL | 学习曲线陡 |
高分补充: 选型不只看产品,还要考虑:团队的运维能力、现有基础设施(避免引入太多新组件)、数据规模和增长预测。对于大多数企业内部应用(百万级以下向量),pgvector完全够用且最简单。
题目4:什么是HNSW?为什么向量数据库用它做近似最近邻搜索?
HNSW(Hierarchical Navigable Small World)是一种用于高效近似最近邻搜索的数据结构,其核心思想是构建一个多层的图结构:
- 底层(Layer 0)包含所有节点,每个节点连接若干近邻
- 上层节点数量逐层减少,形成"高速公路"
- 查询时从顶层开始,沿着图的连接快速跳转,逐层下降,最终在底层找到近邻
为什么用HNSW而不是暴力搜索?暴力搜索的时间复杂度是O(n),百万级向量下延迟不可接受。HNSW的查询复杂度接近O(log n),以轻微的召回率损失换取数量级的速度提升。
关键参数:
- M:每层每个节点的最大连接数(越大,召回率越高,内存越多)
- ef_construction:索引构建时的候选集大小(越大,索引质量越好,构建越慢)
- ef(search ef):查询时的候选集大小(越大,召回率越高,查询越慢)
题目5:Prompt Engineering中,如何减少LLM的幻觉?
幻觉的根本原因是模型在知识边界模糊时"猜答案"。减少幻觉的策略:
1. 给模型提供充足的上下文(RAG) 不要让模型凭记忆回答,而是显式地把答案所需的信息放在Prompt里,要求模型"只根据以下材料回答"。
2. 在Prompt中明确要求不确定时拒绝回答 加入指令:"如果以上材料中没有足够信息,请直接说'我不知道',不要猜测。"
3. 要求引用来源 要求模型在答案后面标注"依据材料第X段",这会迫使模型只使用真实检索到的内容。
4. 降低temperature Temperature越低,模型越保守,越不容易"发挥创意"(即幻觉)。生产环境的问答场景,通常把temperature设为0或0.1。
5. 结果验证(LLM-as-judge) 用另一个LLM检查生成结果是否与上下文一致(Faithfulness检验)。
题目6:RAG和Fine-tuning的区别,分别适合什么场景?
这是一个很好的对比题,本质上是两种让LLM拥有特定知识的方法:
RAG(检索增强):
- 知识存在外部数据库,查询时实时检索
- 知识可以实时更新(加新文档即可)
- 适合:知识库问答、文档检索、企业内部FAQ
- 成本:中等(向量数据库+LLM API调用)
Fine-tuning(微调):
- 知识"烧入"模型权重,成为模型本身的一部分
- 知识更新需要重新训练
- 适合:统一输出风格、特定领域的专业术语学习、特定任务格式(如特定的JSON输出)
- 成本:高(需要GPU资源、标注数据、训练时间)
实际工程建议: 大多数企业应用优先选RAG,原因是更新灵活、成本可控。Fine-tuning用于解决RAG解决不了的问题,比如需要特定输出格式的场景。两者也可以结合(先Fine-tuning调整风格,再RAG补充知识)。
题目7:如何评估一个RAG系统的质量?
评估RAG系统有两个维度:检索质量和生成质量。
检索质量指标:
- Recall@k:在前k个结果中找到相关文档的比例
- Precision@k:前k个结果中相关文档的比例
- MRR(Mean Reciprocal Rank):相关文档排名的倒数均值
生成质量指标(RAGAS框架):
- Faithfulness(忠实度):答案是否完全基于检索内容,没有幻觉
- Answer Relevancy(答案相关性):答案是否切题
- Context Precision / Context Recall:上下文的精确率和召回率
实践建议: 建立一个标注好的"问题-标准答案-相关文档"三元组测试集(通常300-500条),用RAGAS框架自动化评估,在每次修改系统参数后跑回归测试,用数据驱动优化决策。
题目8:多轮对话的RAG系统如何设计?
多轮对话RAG的核心挑战是:用户的追问往往是代词性的("上面提到的那个方案"),如果直接用追问内容去检索,会找不到相关内容。
解决方案——查询改写(Query Rewriting): 在执行检索之前,先用LLM将当前问题结合对话历史改写成独立的查询。
示例:
- 历史对话:用户:"Milvus的HNSW索引有什么参数?" 助手:"M和ef_construction..."
- 当前问题:"ef参数如何影响性能?"
- 改写后:"Milvus HNSW索引的ef参数如何影响搜索性能?"
其他关键设计点:
- 对话历史的管理:超过一定轮数后,用摘要替代完整历史(避免context溢出)
- 会话隔离:多用户场景下,确保每个会话的历史互不干扰
- Redis存储会话历史,设置过期时间
Agent相关(4题)
题目9:什么是ReAct模式的Agent?
ReAct(Reasoning + Acting)是Agent的一种核心模式,让LLM在思考和行动之间交替进行:
Thought: 我需要查询用户的订单信息 Action: query_order(user_id=123) Observation: 订单#456,状态:已发货,预计明天到达 Thought: 我现在可以回答用户的问题了 Answer: 您的订单#456已发货,预计明天到达。优点: 中间的思考过程透明,便于调试;模型可以根据工具结果调整下一步行动。
缺点: 多次LLM调用,延迟高;复杂任务下token消耗大。
工程实现(Spring AI / LangChain4j): 通过@Tool注解定义工具,框架自动处理Tool Call循环。
题目10:Agent系统的稳定性如何保障?
AI Agent天然不稳定,LLM的随机性会导致不可预期的行为。工程层面的稳定性保障:
1. 工具调用的幂等性 工具要设计成幂等的(多次调用结果相同),避免LLM重试时产生副作用。
2. 最大步骤限制 设置Agent的最大执行步骤数(如max_iterations=10),防止无限循环。
3. 高风险操作需要人工确认节点 数据写入、邮件发送、外部API调用等高风险操作,在执行前触发人工确认。
4. 超时控制 每次LLM调用和工具调用都要设置超时,配合重试策略(指数退避)。
5. 完整的日志链路 记录每一步的Thought/Action/Observation,方便问题复现和调试。
6. 降级策略 Agent失败时,提供人工介入的降级路径,而不是直接返回错误给用户。
题目11:如何设计多Agent协作系统?
多Agent系统适用于复杂任务分解的场景。核心设计模式:
Supervisor模式(推荐): 一个Supervisor Agent负责任务分解和结果汇总,多个Worker Agent负责执行具体子任务。
Supervisor: 分析用户需求 → 拆分为子任务 → 分配给Worker → 汇总结果 Worker A(数据查询Agent): 执行数据库查询 Worker B(分析Agent): 对数据进行统计分析 Worker C(报告Agent): 生成可读的报告设计要点:
- 每个Agent的职责要单一、边界清晰
- Agent间通信通过消息队列(Kafka)实现,避免紧耦合
- 每个Agent独立有状态,Supervisor无状态(更容易扩展)
- 引入超时机制:Worker超时未响应,Supervisor触发重试或降级
题目12:Function Calling的原理和使用注意事项
Function Calling(现在叫Tool Use)是让LLM能够主动调用外部工具的能力。
原理:
- 开发者定义工具的JSON Schema(名称、描述、参数类型)
- 将Schema和用户消息一起发给LLM
- LLM判断是否需要调用工具,如需要则输出结构化的工具调用指令(JSON格式)
- 应用层解析指令,实际执行工具
- 将工具执行结果返回给LLM,LLM基于结果生成最终回答
使用注意事项:
- 工具描述要清晰精确:LLM根据description决定是否调用,描述模糊会导致误调用或漏调用
- 参数校验不能省略:LLM可能传入格式错误的参数,工具层必须做校验
- 避免工具过多:超过20个工具时,LLM的选择准确率会下降,建议工具分组
- 敏感工具要有权限控制:LLM调用删除、修改类工具时,应有人工审核环节
向量数据库相关(2题)
题目13:Milvus的Collection如何设计?
在Milvus中,Collection相当于关系数据库的表。设计时的关键决策:
1. Schema设计
# 典型的RAG Collection Schema fields = [ FieldSchema("id", DataType.INT64, is_primary=True, auto_id=True), FieldSchema("doc_id", DataType.VARCHAR, max_length=100), # 来源文档ID FieldSchema("content", DataType.VARCHAR, max_length=2000), # 原文内容 FieldSchema("embedding", DataType.FLOAT_VECTOR, dim=1536), # 向量 FieldSchema("metadata", DataType.JSON), # 扩展元数据 ]2. 分区策略 按业务维度分区(如按部门、按文档类别),在查询时指定分区可以大幅减少搜索范围,提升性能。
3. 索引类型选择
- 数据量 < 100万:FLAT(精确搜索,无索引误差)
- 数据量 100万-1亿:HNSW(平衡性能和召回率)
- 数据量 > 1亿:IVF_PQ(牺牲一定召回率换取极高压缩比)
4. 一致性级别 Milvus支持多种一致性级别(Strong/Session/Bounded/Eventually),生产环境大多数场景选Bounded(最终一致,性能好)。
题目14:Embedding模型如何选择?
选Embedding模型需要考虑:
模型 维度 语言 适用场景 text-embedding-3-small 1536 多语言 OpenAI API,通用场景 text-embedding-3-large 3072 多语言 高质量要求,成本可接受 BGE-large-zh 1024 中文优化 中文语料,本地部署 BGE-m3 1024 多语言 中英混合语料,本地部署 Qwen2-Embedding 1536 中文优化 阿里系场景,中文效果好 工程建议:
- 优先考虑本地部署模型(BGE/Qwen2-Embedding),避免API依赖和数据出境风险
- 换Embedding模型后,需要对已有向量库重新向量化(成本较高,提前评估)
- 用你自己的评估集测试,不要只看公开Benchmark(领域数据上的表现可能差异很大)
LLM原理相关(3题)
题目15:Attention机制的直观理解
Attention机制的核心思想:在处理序列时,不把所有位置的信息等权重对待,而是根据相关性动态分配注意力权重。
类比:你在读一篇文章时,不会对每个字都同等关注,而是根据问题重点关注相关段落。
Self-Attention的三个矩阵:
- Query(Q):当前token在"问什么"
- Key(K):每个token在"表示什么"
- Value(V):每个token的实际内容
注意力权重 = softmax(QK^T / √d_k),然后对V加权求和。
工程视角: 对于AI工程师,更重要的是理解Attention的计算复杂度是O(n²)(n是序列长度),这就是为什么context窗口有限制,以及为什么处理长文档时需要特别设计。
题目16:为什么LLM会产生幻觉?(原理层面)
从原理层面理解幻觉:LLM是一个"预测下一个token概率最大"的模型,它并不"知道"自己知道什么、不知道什么。
当被问到训练数据中没有明确答案的问题时,模型仍然会按照"下一个token最可能是什么"来生成,这就导致了听起来流畅但实际上是捏造的答案。
几类典型幻觉:
- 事实性错误:捏造不存在的人名、日期、事件
- 混淆记忆:将A的信息安插到B身上
- 过度自信:对不确定的信息用肯定语气表达
工程对策: RAG(提供实际知识)+ 低temperature + 明确拒绝未知的Prompt指令 + 结果验证。
题目17:Token和上下文窗口的概念
Token 不是字,也不是词,是LLM处理文本的基本单位。对于中文,大约每个字1-2个token;英文单词平均约0.75个token。
上下文窗口(Context Window) 是LLM在一次对话中能"看到"的最大token数量。GPT-4o是128K tokens,Claude是200K tokens,这大约对应10-20万汉字。
工程中的影响:
- 当检索内容 + 对话历史 + 系统Prompt + 用户问题超过context限制时,需要截断,这会丢失信息
- 更长的context意味着更高的推理成本(Attention的O(n²)复杂度)
- "Lost in the Middle"现象:模型对context中间部分的注意力弱于开头和结尾,重要信息要放在开头或结尾
实践建议: 用tiktoken(OpenAI)或tokenize API(其他模型)精确计算token数,不要靠字数估算。
Java工程能力在AI面试中的考察(3题)
题目18:如何实现高并发下的AI服务稳定性?
这是AI工程师面试中最能体现Java背景优势的题目。
// 关键:LLM调用的超时控制 + 熔断 @Component public class LlmServiceWithResilience { // 使用Resilience4j的熔断器 @CircuitBreaker(name = "llm-service", fallbackMethod = "fallback") @TimeLimiter(name = "llm-service") @Retry(name = "llm-service") public `CompletableFuture<String>` callLlm(String prompt) { return CompletableFuture.supplyAsync(() -> { // LLM API调用(通常耗时3-30秒) return openAiClient.chat(prompt); }); } // 降级方法:返回兜底答案 public `CompletableFuture<String>` fallback(String prompt, Exception e) { log.warn("LLM service circuit opened, using fallback. Error: {}", e.getMessage()); return CompletableFuture.completedFuture("当前服务繁忙,请稍后重试。"); } } // 关键:请求队列 + 并发限制,防止LLM API过载 @Component public class LlmRateLimiter { private final Semaphore semaphore = new Semaphore(20); // 最大并发20个LLM调用 private final RateLimiter rateLimiter = RateLimiter.create(10); // 每秒最多10个请求 public String callWithLimit(String prompt) throws InterruptedException { rateLimiter.acquire(); // 速率限制 if (!semaphore.tryAcquire(5, TimeUnit.SECONDS)) { throw new ServiceUnavailableException("System is busy, please retry"); } try { return callLlm(prompt); } finally { semaphore.release(); } } }答题要点: 超时控制(LLM调用可能几十秒)、熔断降级(Resilience4j)、并发限制(Semaphore)、请求队列(Kafka异步化)。
题目19:LLM的流式响应(Streaming)如何在Spring Boot里实现?
@RestController
@RequestMapping("/api/chat")
public class ChatController {
private final ChatClient chatClient;
// SSE流式返回
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamChat(@RequestParam String message) {
return chatClient.prompt()
.user(message)
.stream()
.content()
.map(chunk -> chunk) // 每个chunk是一个token片段
.doOnError(e -> log.error("Stream error", e))
.onErrorReturn("[ERROR: 生成失败,请重试]");
}
// WebSocket流式返回(适合双向通信场景)
@MessageMapping("/chat.stream")
public void streamChatWs(String message, SimpMessageHeaderAccessor headerAccessor) {
String sessionId = headerAccessor.getSessionId();
chatClient.prompt()
.user(message)
.stream()
.content()
.subscribe(
chunk -> messagingTemplate.convertAndSend(
"/topic/chat/" + sessionId, chunk),
error -> messagingTemplate.convertAndSend(
"/topic/chat/" + sessionId, "[ERROR]"),
() -> messagingTemplate.convertAndSend(
"/topic/chat/" + sessionId, "[DONE]")
);
}
}答题要点: SSE(Server-Sent Events)适合单向流式输出;WebSocket适合双向实时通信;Spring AI的Flux支持响应式流式调用;前端要处理好连接中断的重连逻辑。
题目20:向量检索和缓存策略如何结合?
@Service
public class CachedRagService {
private final RedisTemplate<String, Object> redisTemplate;
private final VectorStore vectorStore;
private final ChatClient chatClient;
// 策略1:精确匹配缓存(用于高频重复查询)
public String queryWithExactCache(String question) {
String cacheKey = "rag:exact:" + DigestUtils.md5Hex(question);
String cached = (String) redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return cached; // 缓存命中,直接返回
}
String result = performRagQuery(question);
redisTemplate.opsForValue().set(cacheKey, result, Duration.ofHours(2));
return result;
}
// 策略2:语义相似缓存(相似问题复用答案)
public String queryWithSemanticCache(String question) {
// 将问题向量化后,在缓存向量库中搜索相似问题
List<Document> similarCachedQuestions = vectorStore.similaritySearch(
SearchRequest.query(question)
.withTopK(1)
.withSimilarityThreshold(0.95f) // 相似度超过95%才复用
.withFilterExpression("type == 'cache'")
);
if (!similarCachedQuestions.isEmpty()) {
String cachedAnswer = similarCachedQuestions.get(0)
.getMetadata().get("answer").toString();
log.info("Semantic cache hit for question: {}", question);
return cachedAnswer;
}
String result = performRagQuery(question);
// 将问题+答案存入语义缓存库
storeToSemanticCache(question, result);
return result;
}
private void storeToSemanticCache(String question, String answer) {
Document cacheDoc = new Document(question,
Map.of("answer", answer, "type", "cache",
"created_at", System.currentTimeMillis()));
vectorStore.add(List.of(cacheDoc));
}
}第三部分:系统设计题实战
题目:设计一个企业级AI问答系统
这是最高频的系统设计题,以下是完整答题框架。
第一步:澄清需求(面试时必做)
先提问,不要急着画架构图:
- "这个系统的用户规模大约是多少?是内部员工还是面向C端?"
- "知识库的数据量大概是多大?文档类型有哪些?"
- "对响应时间的要求是什么?"
- "是否需要多轮对话?是否需要对话历史持久化?"
- "是否有数据隐私要求?文档内容能否发给第三方API?"
假设澄清后的需求:内部员工使用,100人规模,10万份文档(含PDF、Word),响应时间 < 3秒,支持多轮对话,文档敏感需本地部署LLM。
第二步:架构设计
第三步:关键技术决策说明
为什么用本地LLM(Ollama)? 文档敏感,不能发给OpenAI等第三方API。Ollama支持本地部署Qwen2.5-7B,在普通GPU服务器上即可运行,满足数据合规要求。
为什么用Kafka做文档处理队列? 文档上传是异步任务(解析+向量化可能需要几分钟),用Kafka解耦上传和处理,避免阻塞用户请求。同时支持失败重试和任务监控。
会话管理如何设计? 用Redis存储会话历史(key=sessionId,value=消息列表,TTL=24小时)。超过10轮对话时,用摘要替换早期对话,避免context溢出。
如何处理并发? LLM推理是GPU密集型,同时处理的请求数有限。用请求队列(Kafka)+ 固定并发池,保证系统稳定性。超出队列容量时,快速返回"排队中"状态,前端轮询结果。
第四步:说明可扩展性和容灾
- 文档处理服务水平扩展:Kafka消费者组,增加实例即可提升吞吐
- LLM服务:多实例负载均衡(如果有多台GPU机器)
- Milvus集群模式:支持数据分片,横向扩展
- 多活容灾:关键服务跨可用区部署
第四部分:代码题实战(3道真题)
代码题1:实现一个带缓存的RAG服务(Spring AI)
@Service
@Slf4j
public class RagService {
private final ChatClient chatClient;
private final VectorStore vectorStore;
private final StringRedisTemplate redisTemplate;
private static final String SYSTEM_PROMPT = """
你是一个专业的企业知识助手。
请仅根据以下参考资料回答用户的问题。
如果参考资料中没有相关信息,请明确说明"根据现有资料,我无法回答这个问题"。
回答时请引用相关资料的来源。
参考资料:
{context}
""";
public String query(String question, String sessionId) {
// 1. 检查缓存
String cacheKey = "rag:q:" + DigestUtils.md5Hex(question.trim().toLowerCase());
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
log.info("Cache hit for question: {}", question);
return cached;
}
// 2. 加载会话历史
List<Message> history = loadSessionHistory(sessionId);
// 3. 查询改写(结合历史消息)
String rewrittenQuery = rewriteQuery(question, history);
// 4. 向量检索
List<Document> docs = vectorStore.similaritySearch(
SearchRequest.query(rewrittenQuery)
.withTopK(5)
.withSimilarityThreshold(0.7f)
);
if (docs.isEmpty()) {
return "根据现有知识库,暂无相关信息。请联系相关负责人获取帮助。";
}
// 5. 构建context
String context = docs.stream()
.map(doc -> String.format("【来源:%s】\n%s",
doc.getMetadata().getOrDefault("source", "未知"),
doc.getContent()))
.collect(Collectors.joining("\n\n---\n\n"));
// 6. 调用LLM生成答案
String answer = chatClient.prompt()
.system(sp -> sp.text(SYSTEM_PROMPT).param("context", context))
.messages(history)
.user(question)
.call()
.content();
// 7. 更新会话历史
updateSessionHistory(sessionId, question, answer);
// 8. 写缓存(重复性问题缓存2小时)
redisTemplate.opsForValue().set(cacheKey, answer, Duration.ofHours(2));
return answer;
}
private String rewriteQuery(String question, List<Message> history) {
if (history.isEmpty()) {
return question; // 无历史时直接使用原始问题
}
// 用LLM改写查询,结合历史上下文
String rewritePrompt = """
根据以下对话历史,将当前问题改写为独立的搜索查询(不依赖上下文即可理解)。
只返回改写后的查询,不要解释。
历史对话:%s
当前问题:%s
""".formatted(formatHistory(history), question);
return chatClient.prompt()
.user(rewritePrompt)
.call()
.content();
}
private List<Message> loadSessionHistory(String sessionId) {
String key = "session:" + sessionId;
List<String> rawHistory = redisTemplate.opsForList().range(key, 0, -1);
if (rawHistory == null || rawHistory.isEmpty()) {
return new ArrayList<>();
}
// 最多保留最近10条消息(5轮对话)
List<String> recentHistory = rawHistory.subList(
Math.max(0, rawHistory.size() - 10), rawHistory.size());
return parseMessages(recentHistory);
}
private void updateSessionHistory(String sessionId, String question, String answer) {
String key = "session:" + sessionId;
redisTemplate.opsForList().rightPush(key,
"USER:" + question + "|||ASSISTANT:" + answer);
redisTemplate.expire(key, Duration.ofHours(24));
// 限制历史长度
redisTemplate.opsForList().trim(key, -20, -1);
}
}代码题2:实现一个工具调用Agent
@Component
@Slf4j
public class DataAnalysisAgent {
private final ChatClient chatClient;
// 定义Agent可以调用的工具
@Bean
public List<FunctionCallback> agentTools() {
return List.of(
FunctionCallback.builder()
.function("query_database", this::queryDatabase)
.description("查询数据库,获取业务数据。输入SQL查询语句,返回JSON格式结果")
.inputType(QueryInput.class)
.build(),
FunctionCallback.builder()
.function("send_email", this::sendEmail)
.description("发送邮件报告。需要收件人、主题和正文")
.inputType(EmailInput.class)
.build(),
FunctionCallback.builder()
.function("generate_chart", this::generateChart)
.description("根据数据生成图表,返回图表的URL")
.inputType(ChartInput.class)
.build()
);
}
public AgentResult executeTask(String userInstruction) {
log.info("Agent executing task: {}", userInstruction);
String systemPrompt = """
你是一个数据分析助手。你可以使用以下工具来完成分析任务:
- query_database:查询业务数据库
- generate_chart:生成可视化图表
- send_email:发送分析报告
注意:
1. 在发送邮件之前,必须先向用户确认邮件内容
2. SQL查询只能使用SELECT语句,不能执行任何修改操作
3. 每次任务最多执行15步,避免无限循环
""";
// Spring AI的Tool Call Chain
String result = chatClient.prompt()
.system(systemPrompt)
.user(userInstruction)
.functions("query_database", "send_email", "generate_chart") // 注册工具
.call()
.content();
return AgentResult.success(result);
}
// 工具实现(带安全控制)
private String queryDatabase(QueryInput input) {
// 安全检查:只允许SELECT语句
String sql = input.getSql().trim().toUpperCase();
if (!sql.startsWith("SELECT")) {
throw new SecurityException("Only SELECT queries are allowed");
}
// 执行查询并返回JSON结果
return jdbcTemplate.queryForList(input.getSql()).toString();
}
private String sendEmail(EmailInput input) {
// 邮件发送实现
emailService.send(input.getTo(), input.getSubject(), input.getBody());
return "邮件已成功发送给 " + input.getTo();
}
record QueryInput(String sql) {}
record EmailInput(String to, String subject, String body) {}
record ChartInput(String data, String chartType, String title) {}
}代码题3:实现向量检索的混合检索(BM25 + 向量)
@Service
public class HybridSearchService {
private final MilvusServiceClient milvusClient;
private final ElasticsearchClient esClient;
private final EmbeddingModel embeddingModel;
/**
* 混合检索:向量检索 + BM25关键词检索,使用RRF算法融合
*/
public List<SearchResult> hybridSearch(String query, int topK) {
// 并行执行两路检索
CompletableFuture<List<SearchResult>> vectorFuture =
CompletableFuture.supplyAsync(() -> vectorSearch(query, topK * 2));
CompletableFuture<List<SearchResult>> bm25Future =
CompletableFuture.supplyAsync(() -> bm25Search(query, topK * 2));
CompletableFuture.allOf(vectorFuture, bm25Future).join();
List<SearchResult> vectorResults = vectorFuture.join();
List<SearchResult> bm25Results = bm25Future.join();
// RRF融合算法
return rrfFusion(vectorResults, bm25Results, topK);
}
private List<SearchResult> vectorSearch(String query, int topK) {
// 1. 将查询向量化
float[] queryEmbedding = embeddingModel.embed(query);
// 2. Milvus向量搜索
SearchParam searchParam = SearchParam.newBuilder()
.withCollectionName("knowledge_base")
.withMetricType(MetricType.COSINE)
.withVectors(List.of(Arrays.asList(toFloatList(queryEmbedding))))
.withVectorFieldName("embedding")
.withTopK(topK)
.withOutFields(List.of("id", "content", "source"))
.build();
R<SearchResults> response = milvusClient.search(searchParam);
return parseVectorResults(response.getData());
}
private List<SearchResult> bm25Search(String query, int topK) {
// Elasticsearch BM25全文检索
SearchResponse<Map> response = esClient.search(s -> s
.index("knowledge_base")
.query(q -> q
.multiMatch(mm -> mm
.query(query)
.fields("content", "title^2") // title字段权重加倍
.type(TextQueryType.BestFields)
)
)
.size(topK),
Map.class);
return parseBm25Results(response);
}
/**
* RRF (Reciprocal Rank Fusion) 融合算法
* 公式:score(d) = Σ 1 / (k + rank(d))
* k通常取60
*/
private List<SearchResult> rrfFusion(
List<SearchResult> vectorResults,
List<SearchResult> bm25Results,
int topK) {
Map<String, Double> scores = new HashMap<>();
int k = 60;
// 向量检索结果打分
for (int i = 0; i < vectorResults.size(); i++) {
String docId = vectorResults.get(i).getDocId();
scores.merge(docId, 1.0 / (k + i + 1), Double::sum);
}
// BM25检索结果打分
for (int i = 0; i < bm25Results.size(); i++) {
String docId = bm25Results.get(i).getDocId();
scores.merge(docId, 1.0 / (k + i + 1), Double::sum);
}
// 按RRF分数排序
Map<String, SearchResult> allResults = new HashMap<>();
vectorResults.forEach(r -> allResults.put(r.getDocId(), r));
bm25Results.forEach(r -> allResults.putIfAbsent(r.getDocId(), r));
return scores.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.limit(topK)
.map(entry -> {
SearchResult result = allResults.get(entry.getKey());
result.setRrfScore(entry.getValue());
return result;
})
.collect(Collectors.toList());
}
}第五部分:行为面试STAR模板
题目:说一个你在AI项目中遇到的最大挑战
高分回答模板:
S(背景): 去年Q3,我们的智能客服系统上线后,用户反馈答案质量不稳定——同样的问题,有时候答得很好,有时候答非所问。产品经理给我们一个月时间解决,否则考虑下线。
T(任务): 作为负责RAG核心模块的工程师,我需要找出根本原因并系统性地解决答案质量问题。
A(行动): 第一步,我建立了评估体系。收集了200条真实用户问题,手工标注了正确答案,用RAGAS跑了基线评估,发现Faithfulness分数只有0.61(意味着约40%的答案有幻觉成分)。
第二步,我做了错误分析。抽取了50个Faithfulness低的case,发现主要问题是:检索到的文档和问题不够相关,导致LLM在知识不足的情况下"编造"了答案。
第三步,我从检索侧入手。引入混合检索(BM25 + 向量),用RRF融合,并且在检索结果后加了Cohere的ReRanker做二次排序。另外,把相似度阈值从0.6提高到0.75,过滤掉低相关性的检索结果,宁可说"不知道"也不乱答。
第四步,优化Prompt,明确要求模型"只基于检索内容回答,无信息时拒绝回答"。
R(结果): 三周后,Faithfulness从0.61提升到0.87,用户满意度调研分数从3.2/5提升到4.1/5,系统保留下来了,后来又陆续上线了更多功能。
第六部分:反问环节——5个有水平的问题
很多人在面试最后的"你有什么问题"环节直接说"没有了",这是一个错失印象分的机会。
问题1(了解技术栈真实情况):
"你们目前的RAG系统是用什么向量数据库?有没有遇到过大规模下的性能挑战?是怎么解决的?"
问题2(了解团队氛围):
"团队里有多少人是做过完整AI项目落地的?目前AI工程和传统后端研发的比例大概是多少?"
问题3(了解成长空间):
"如果我加入,3-6个月内我最可能参与的项目是什么?你们对这个方向的技术规划是什么?"
问题4(了解技术挑战):
"在你们目前的AI系统中,你觉得最难解决的工程问题是什么?"
问题5(了解业务价值):
"AI项目在公司内部的重视程度怎么样?有没有清晰的业务价值指标来衡量AI团队的工作成果?"
FAQ
Q1:面试前要刷LeetCode算法题吗? A:AI工程师岗位通常不会有硬核算法题(LeetCode Hard),但会有和AI相关的代码题。重点练习:字符串处理(Prompt模板)、集合操作(检索结果处理)、并发编程(高并发AI服务)。
Q2:不会某个技术时,面试中怎么回答? A:诚实说"这个我了解不多,但我了解类似的XXX,原理应该是...",然后展示你的学习能力和类比推理能力。切忌胡编,面试官一追问就完全暴露。
Q3:面试官问"你用过哪些大模型",怎么回答? A:不要只说模型名字,要说用在什么场景、对比过什么、为什么选这个。示例:"我们项目用了Qwen2.5-72B做本地部署,主要是数据合规要求。我也对比过Llama3-70B,在我们的中文场景下Qwen2.5的效果明显更好,特别是在长文本理解上。"
Q4:被问到不熟悉的底层原理(如Transformer架构)怎么办? A:AI工程师不需要100%掌握算法研究级别的知识。可以说:"Transformer的Self-Attention原理我理解,QKV的计算方式清楚。至于更深入的数学推导,我平时研究得不多,更专注在应用工程层面。"这个回答是诚实且可以接受的。
Q5:面试后多久催进度合适? A:面试结束后,等3-5个工作日。如果没有消息,可以发一封简短的感谢信 + 询问进度的邮件(或者通过HR联系)。不要催太频繁,一次就够。
面试后:感谢信模板
主题:感谢 [公司名] AI工程师面试 - [姓名]
您好,[面试官姓名],
非常感谢今天花时间和我进行面试。我非常享受我们关于[具体技术话题,如"RAG系统优化"和"多Agent架构设计"]的讨论,
这让我对贵团队的技术方向和工程文化有了更深入的了解。
面试中您提到的[某个具体技术点或业务挑战],让我回来后又深入研究了一下,有了一些新的想法:
[简短说明你的新想法,1-2句话]
期待有机会加入贵团队,继续在AI工程领域深耕。如有任何补充材料需要,请随时告知。
祝好,
[姓名]
[电话]感谢信的关键: 提到一个具体的讨论内容,体现你认真对待了这次面试;如果能提出一个延伸想法,更能加分。
