字节全栈一面面经:RAG 链路与线程池连环追问
- 轮次
- 一面
- 时间
- 2026-09
- 来源
- 牛客网
《面试题目》
- 自我介绍。
- 实习项目:这段主要问业务场景,没有深挖技术细节。
- RAG 是做什么的?用来解决什么问题?
- RAG 把内容给大模型时是给全文吗,还是怎么给?
- RAG 中 PDF 从上传到最终召回,经历哪些阶段?
- 向量化具体是怎么做的?
- 除了 RAG,还会做基础检索比如关键字检索吗?
- 你觉得 RAG 有哪些缺点?
- 有做过 RAG 效果的评估吗?测试集是自动化执行还是人工测试?
- 你有 Agent 开发相关的实践吗?
- 线程池涉及哪些参数?线程池的线程数如何确定?
- CPU 密集型任务的核心线程数是核数 + 1,那么为什么要 +1?
- 拒绝策略都有哪些?这四种拒绝策略分别适用于哪些场景?
- 拒绝策略中的”主线程执行”这个主线程指的是什么?
- 有哪些典型的场景会用到”主线程”这个拒绝策略?在这种策略下会不会造成主线程被压的问题?
- 相当于把问题从线程池转移到主线程上来了,那具体怎么解决呢?
- Java 方法中的参数是值传递还是引用传递?
- HashMap 在遍历的时候怎么删除元素?如何正确 remove 一个值?
- Java 中用 ”+” 拼接 String 你觉得会有什么问题吗?
- try-finally 中都有 return,执行哪一个?
- 手撕算法:目标和。
- 反问:校招生以后的路线,全栈还是 Agent 开发更好?
《参考解析》
RAG 的完整链路:离线侧是文档解析(PDF 要处理双栏、表格、扫描件 OCR)→ 清洗与分块(chunking)→ 向量化 → 入库;在线侧是 query 改写(口语化提问要先扩写或补全指代)→ 向量检索(常与 BM25 关键字检索混合)→ 重排(rerank)→ 按 token 预算拼进提示词 → 生成并附引用。给模型的不是全文,而是检索回来的若干片段:全文塞不进去,也没必要——检索的价值就在于把”相关内容”筛出来。缺点也很明确:召回不相关时模型会自信地编;分块会切断上下文;多跳问题一次检索解决不了;对表格、图表这类非文本结构效果差;还有知识更新滞后和”检索到旧版本”的问题。评估要分层做:检索侧看召回率 / 命中率(Hit Rate、MRR),生成侧看忠实度(答案能否被检索内容支撑)与答案相关性;前者可以自动化构造”问题-标准片段”对来跑,后者常需要 LLM 打分加人工抽检。
线程池线程数与”核数 + 1”:线程数没有公式解,只有经验起点 + 压测收敛。CPU 密集型任务,线程数取 CPU 核数(或 +1)能让每个核心都忙起来又不产生额外切换;IO 密集型任务,线程数 ≈ 核数 × (1 + 等待时间 / 计算时间),因为线程大部分时间在等,多开能让 CPU 不空转。为什么 CPU 密集型要 +1?因为线程偶尔会因为缺页中断或内存对齐等原因短暂停顿,此时多出来的那一个线程可以顶上去,避免 CPU 出现空档。要注意这只是启发式,真实值必须靠压测看吞吐拐点。
四种拒绝策略:AbortPolicy 直接抛异常(默认,适合必须感知失败的场景,比如核心交易链路,让上游看到并决定重试或降级);CallerRunsPolicy 由提交任务的线程自己执行(形成天然背压,把压力回传给上游,适合不能丢且能接受变慢的场景);DiscardPolicy 静默丢弃(适合可丢的埋点、日志类任务);DiscardOldestPolicy 丢掉队列里最老的一个再尝试提交(适合只关心最新值的场景,比如刷新缓存)。这里的”主线程”指的是调用 execute / submit 的那个线程,也就是调用方所在线程,不一定是 JVM 的 main 线程。它确实会把压力转移到调用方:如果这个线程本身是处理请求的 Web 工作线程,它就变成了”既提交又干活”,吞吐会下降,极端情况下上游会级联阻塞。所以 CallerRunsPolicy 只适合能容忍调用方被拖慢、且不希望丢任务的场景,其他情况下更好的解法是给线程池配有界队列 + 监控队列水位,在源头做限流,而不是把问题后置到拒绝策略上。
Java 是值传递:方法拿到的永远是实参的副本。传对象时,副本是引用的副本——所以你能通过它修改对象的内部状态,但把这个引用重新指向另一个对象,不会影响调用方。这也是”Java 传引用”这个误区一直存在的原因。
HashMap 遍历时删除:用 for-each 或 Iterator 直接调 map.remove(k) 会触发 fail-fast、抛 ConcurrentModificationException,因为在结构被修改后 modCount 与迭代器记录的 expectedModCount 对不上。正确做法是用 Iterator 的 remove()(它会同步修改 expectedModCount),或者 Java 8 之后直接用 map.entrySet().removeIf(...) / map.values().removeIf(...)。在并发场景下还要考虑 ConcurrentHashMap,它允许并发修改但迭代器是弱一致的,不保证一定看到最新状态。
String 用 ”+” 拼接:在循环里写 s += x 会被编译成每次 new StringBuilder 再 toString(),产生 O(n²) 的中间对象,既费内存又拖 GC;编译器只对单条表达式里的连续拼接做优化。正确的写法是显式用一个 StringBuilder 在循环外累加,或者用 String.join。
try-finally 中的 return:finally 一定会执行,且它的 return 会覆盖 try 里的 return——所以函数返回的是 finally 中表达式的值。更危险的是 finally 里抛异常会吞掉 try 中的异常,让真正的错误消失。工程上要避免在 finally 里写 return 或抛异常,finally 只该用来释放资源。
手撕:目标和。这是 0-1 背包的变体:给数组每个数加正负号,使表达式等于 target,求方案数。直接的思路是 DFS 每个数选正或负,复杂度 O(2ⁿ),数组稍大就超时;正解是转成背包——设所有数之和为 sum,选为正号的子集和为 (sum + target) / 2,问题变成”从数组中选若干数使其和为 P 的方案数”,用一维 DP 滚动数组倒序更新即可。两个必须先判的边界:sum + target 必须为偶数(否则无解),且 abs(target) > sum 时无解。