基础设施与算法
Q:LangGraph 定义的搜索节点做不到并发执行吗?怎么做?
来源:AI 工程师面试
新手答:“LangGraph 是顺序执行的,做不了并发。”
高手答:
LangGraph 完全支持并发执行,有三种方式,适用场景不同:
方式一:Fan-out / Fan-in(静态并行)
在 graph 定义时,让多个节点从同一个上游节点出发,形成并行分支,最后汇聚到一个下游节点:
graph.add_edge("plan_node", "search_node_1")
graph.add_edge("plan_node", "search_node_2")
graph.add_edge("plan_node", "search_node_3")
graph.add_edge("search_node_1", "merge_node")
graph.add_edge("search_node_2", "merge_node")
graph.add_edge("search_node_3", "merge_node")三个 search 节点会并发执行,全部完成后进入 merge_node。适合编译时就知道要并行几路的场景。
方式二:Send API(动态并行)
并行数量在运行时才确定时,用 Send API 动态创建并行分支:
from langgraph.types import Send
def plan_node(state):
queries = state["search_queries"] # 运行时才知道有几个查询
return [Send("search_node", {"query": q}) for q in queries]
graph.add_conditional_edges("plan_node", plan_node)每个 Send 创建一个独立的并发执行分支,适合搜索数量取决于规划结果的场景。
方式三:节点内部 asyncio 并发
如果只是一个节点内部需要并发调用多个 API:
import asyncio
async def search_node(state):
queries = state["search_queries"]
results = await asyncio.gather(*[
search_api(q) for q in queries
])
return {"search_results": results}状态合并是关键问题:
并发节点同时写同一个 state 字段会冲突。LangGraph 用 Reducer 函数解决:
from typing import Annotated
import operator
class State(TypedDict):
# operator.add 表示多个并发节点的结果做列表拼接
search_results: Annotated[list, operator.add]每个并发分支返回的 search_results 会被自动拼接成一个大列表,而不是互相覆盖。
差距在哪:新手以为 LangGraph 不支持并发——说明没深入用过。高手给出了三种并发方式(静态 Fan-out、动态 Send、节点内 asyncio),且指出了并发状态合并(Reducer)这个关键问题。面试官考的是你对 LangGraph 并发执行机制的实际掌握程度。
Q:PostgreSQL 的索引结构是什么?索引如何优化查询速度?在 Checkpoint 场景下如何用索引加速?
来源:AI 工程师面试
新手答:“就是 B+ 树,查询快。”
高手答:
PostgreSQL 支持多种索引类型,不同索引适用不同查询模式:
| 索引类型 | 底层结构 | 适用查询 | 典型场景 |
|---|---|---|---|
| B-Tree(默认) | 平衡多叉树 | 等值查询、范围查询、排序 | 绝大多数场景的首选 |
| Hash | 哈希表 | 纯等值查询 | 精确匹配,如 session_id = 'xxx' |
| GIN | 倒排索引 | 包含查询、全文搜索 | JSONB 字段查询、数组包含 |
| GiST | 通用搜索树 | 范围重叠、最近邻 | 地理空间、区间查询 |
| BRIN | 块范围索引 | 大表上的范围查询 | 时间序列、日志表(数据物理有序时) |
B-Tree 索引如何加速查询:
B-Tree 是一棵平衡多叉排序树,每个节点存多个键值和指向子节点的指针。查询时从根节点开始,每一层通过比较快速定位到目标区间,时间复杂度 O(log N):
无索引:全表扫描 100 万行 → 逐行比较 → O(N)
有 B-Tree 索引:树高约 3-4 层 → 3-4 次磁盘 IO → O(log N)关键优化手段:
- 覆盖索引(Covering Index):把查询需要的列都放进索引,查询直接从索引返回,不需要回表
- 复合索引:
CREATE INDEX ON checkpoints (thread_id, checkpoint_id DESC)一个索引同时支持按 thread_id 过滤 + 按 checkpoint_id 排序 - 部分索引(Partial Index):
CREATE INDEX ON checkpoints (thread_id) WHERE is_active = true只索引活跃数据,索引体积更小 - Index-Only Scan:当查询的所有列都在索引中时,PostgreSQL 直接扫描索引,完全跳过堆表
Checkpoint 场景下的索引设计:
Checkpoint 表的核心查询模式是:给定 session_id(thread_id),找到最新的 Checkpoint。
-- 最常见的查询:加载最新 checkpoint
SELECT * FROM checkpoints
WHERE thread_id = 'abc-123'
ORDER BY checkpoint_id DESC
LIMIT 1;最优索引:
-- 复合索引:thread_id 精确匹配 + checkpoint_id 倒序排列
CREATE INDEX idx_checkpoints_thread_latest
ON checkpoints (thread_id, checkpoint_id DESC);这个索引让查询一次 B-Tree 查找就能定位到目标行——先按 thread_id 定位到该会话的所有 checkpoint,B-Tree 内部已按 checkpoint_id 倒序排列,直接取第一条就是最新的。
如果 Checkpoint 表还有 JSONB 类型的 metadata 字段需要查询(如按节点名过滤):
-- GIN 索引支持 JSONB 内部字段查询
CREATE INDEX idx_checkpoints_metadata ON checkpoints USING GIN (metadata);
-- 查询:找所有在 search_node 节点暂停的 checkpoint
SELECT * FROM checkpoints WHERE metadata @> '{"node": "search_node"}';差距在哪:新手只知道 B+ 树一个名字。高手区分了五种索引类型及适用场景,详细说明了 B-Tree 的查询加速原理,且结合 Checkpoint 实际场景给出了复合索引 + GIN 索引的具体设计。面试官考的是你能不能把数据库基础知识应用到 Agent 系统的实际存储设计中。
Q:用 Claude Code 做一个比较长的任务,如果遇到单次 session 跑不完、中间断网怎么办?
来源:AI 工程师面试
新手答:“重新开一个 session 从头做。”
高手答:
长任务中断是 AI Coding 工具的常见问题。解决方案分预防和恢复两个层面:
预防——降低中断的影响:
- 任务拆分:长任务不要一口气交给 Agent。先用 Plan Mode(
/plan)生成整体方案,再按模块逐步执行。每完成一个模块就 commit 一次,确保进度不丢 - CLAUDE.md 持久化上下文:把项目的关键信息、架构约定、已完成的进度写在
CLAUDE.md文件中。新 session 启动时会自动读取,相当于给 Agent 一个“接班备忘录” - TodoList 跟踪进度:用 Claude Code 的任务管理功能(或直接在 CLAUDE.md 中维护 checklist),标记每个子任务的完成状态。中断后可以精确知道“做到哪了”
## 当前任务进度
- [x] 数据库 schema 设计
- [x] API 路由层实现
- [ ] 业务逻辑层实现 ← 断点
- [ ] 单元测试
- [ ] 集成测试- 高频 git commit:每完成一个有意义的改动就 commit,不要攒到最后。即使 session 崩了,代码改动不会丢
恢复——中断后快速接续:
--continue继续上次对话:Claude Code 支持claude --continue直接恢复上一次 session 的对话上下文,从断点继续/resume恢复任务:在新 session 中使用/resume命令,可以加载之前的对话历史和任务状态- Git diff 定位进度:新 session 中让 Claude Code 执行
git diff和git log,快速了解已完成的改动,从断点处继续
最佳实践流程:
核心认知:长任务的可靠性不靠 session 稳定性保证,而靠“频繁保存 + 上下文持久化 + 快速恢复”的工程习惯。这和写代码要频繁 commit 是同一个道理——不要把所有筹码押在“一次跑通”上。
差距在哪:新手遇到中断只会重来。高手从预防(任务拆分 + CLAUDE.md + TodoList + 高频 commit)和恢复(--continue + /resume + git diff)两个层面给出了完整方案。面试官考的是你用 AI Coding 工具做大型任务时的工程化习惯。
Q:分布式限流算法——令牌桶、漏桶、滑动窗口的区别和实现?
来源:快手 AI Agent 开发一面
新手答:“用令牌桶限流,每秒放几个令牌。”
高手答:
三种限流算法各有适用场景,核心差异在于对突发流量的处理方式:
| 算法 | 核心思想 | 允许突发 | 流量整形 | 适用场景 |
|---|---|---|---|---|
| 令牌桶 | 桶以固定速率填充令牌,请求消耗令牌 | ✅ 桶满时可突发 | ❌ | API 限流(允许短时突发) |
| 漏桶 | 请求先入桶,桶以固定速率漏出处理 | ❌ 强制匀速 | ✅ | 流量整形(保护下游) |
| 滑动窗口 | 统计滑动时间窗口内的请求数 | ❌ 窗口内硬上限 | ❌ | 精确的请求频率控制 |
令牌桶算法:
参数:桶容量 capacity、填充速率 rate(个/秒)
状态:当前令牌数 tokens、上次填充时间 last_refill
每次请求:
1. 计算距上次的时间差,补充令牌:tokens += elapsed × rate
2. tokens = min(tokens, capacity) // 不超过桶容量
3. if tokens >= 1: tokens -= 1, 放行
else: 拒绝漏桶算法:
请求入桶(队列),桶以固定速率出队处理。
桶满时新请求直接丢弃或排队等待。
效果:无论入口流量多不均匀,出口始终匀速。滑动窗口算法:
参数:窗口大小 window_ms、最大请求数 max_count
结构体字段:
window_start: int64 // 当前窗口起始时间戳
request_count: int // 窗口内已有请求数
window_size_ms: int // 窗口大小(毫秒)
max_requests: int // 窗口内允许的最大请求数
// 精确版还需要:request_timestamps: list // 每个请求的时间戳
每次请求:
1. 清除窗口外的过期记录
2. 统计窗口内请求数
3. if count < max_count: 记录时间戳, 放行
else: 拒绝令牌桶 vs 滑动窗口:
令牌桶:允许突发(桶满时一次性用完所有令牌),适合"平均速率限制"
滑动窗口:任何时刻窗口内请求数不超限,适合"严格频率限制"
例:限制 100 请求/分钟
令牌桶:可能在第 1 秒就用完 100 个令牌(突发)
滑动窗口:任意 60 秒窗口内最多 100 个请求(平滑)Redis 实现:
| 算法 | Redis 数据结构 | 实现思路 |
|---|---|---|
| 令牌桶 | String + EXPIRY | 存剩余令牌数,用 Lua 脚本原子操作(补充 + 扣减) |
| 滑动窗口 | Sorted Set(ZSET) | 每个请求以时间戳为 score 加入 ZSET,用 ZREMRANGEBYSCORE 清除窗口外记录,ZCARD 统计窗口内数量 |
| 固定窗口 | String + INCR + EXPIRY | key 为“窗口起始时间”,INCR 计数,EXPIRY 到窗口结束 |
分布式场景下,所有操作必须用 Lua 脚本保证原子性,避免并发竞态。
差距在哪:新手只知道令牌桶一种。高手把三种算法的核心差异(是否允许突发、是否整形)讲清楚了,且给出了滑动窗口的结构体设计和 Redis 数据结构选型。面试官考的是你对限流这个经典系统设计问题的工程化理解。
Q:布隆过滤器的原理?会出现什么问题?如何控制误判率?
来源:快手 AI Agent 开发一面
新手答:“用来判断一个元素是否在集合中。”
高手答:
布隆过滤器是一个空间效率极高的概率型数据结构,用于快速判断“某元素是否可能在集合中”。
原理:
数据结构:一个 m 位的 bit 数组(初始全 0)+ k 个独立哈希函数
添加元素:对元素做 k 次哈希,将对应的 k 个位置置 1
查询元素:对元素做 k 次哈希,检查 k 个位置
- 全部为 1 → "可能存在"(可能是误判)
- 任一为 0 → "一定不存在"(绝无漏判)示例:m=10, k=3
添加 "apple":hash1→2, hash2→5, hash3→8
bit: [0,0,1,0,0,1,0,0,1,0]
添加 "banana":hash1→1, hash2→5, hash3→9
bit: [0,1,1,0,0,1,0,0,1,1]
查询 "cherry":hash1→2, hash2→5, hash3→9 → 全部为 1 → "可能存在"(误判!)
查询 "grape":hash1→3, hash2→7, hash3→9 → 位置 3 为 0 → "一定不存在"会出现什么问题:
- 假阳性(False Positive):说“在集合中”但实际不在。多个元素的哈希位重叠导致
- 不支持删除:清除某个位可能影响其他元素(它们的哈希位重叠)。变体 Counting Bloom Filter 用计数器替代 bit 可支持删除
- 无假阴性:说“不在”就一定不在——这是布隆过滤器的核心保证
如何控制误判率:
误判率公式:FPR ≈ (1 - e^(-kn/m))^k
三个可调参数:
m:bit 数组大小 → 越大误判率越低
k:哈希函数数量 → 存在最优值
n:已插入元素数量 → 固定的业务参数
最优 k = (m/n) × ln2 ≈ 0.693 × (m/n)
实际估算:
n = 100 万元素,目标 FPR = 1%
m ≈ -n × ln(FPR) / (ln2)^2 ≈ 958 万 bit ≈ 1.2 MB
k = 7
n = 100 万元素,目标 FPR = 0.1%
m ≈ 1437 万 bit ≈ 1.8 MB
k = 10典型应用场景:缓存穿透防护(快速判断 key 是否存在)、爬虫 URL 去重、垃圾邮件过滤、大规模数据去重。
差距在哪:新手只知道“判断是否存在”。高手讲清了原理(bit 数组 + k 次哈希)、两个核心问题(假阳性 + 不可删除)、误判率控制公式及实际估算。面试官考的是你对概率数据结构的工程化理解——不只是“知道这个东西”,还能算出实际场景需要多大空间。
Q:数据库索引失效的常见场景?LIKE 查询会不会导致索引失效?
来源:快手 AI Agent 开发一面
新手答:“LIKE 查询会导致索引失效。”
高手答:
索引失效不是 LIKE 的专利——有很多常见写法会让优化器放弃使用索引:
索引失效的常见场景:
| 场景 | 示例 | 失效原因 |
|---|---|---|
| 对索引列使用函数 | WHERE YEAR(create_time) = 2024 | 函数运算破坏了索引的有序性 |
| 隐式类型转换 | WHERE varchar_col = 123 | varchar 列和 int 比较,触发隐式转换 |
| LIKE 前缀通配 | WHERE name LIKE '%张三' | 无法利用 B+ 树的有序性 |
| OR 混合非索引列 | WHERE indexed_col = 1 OR non_indexed = 2 | 优化器可能放弃索引走全表扫描 |
| 违反最左前缀原则 | 联合索引 (a,b,c),查 WHERE b=1 | 跳过了最左列 a |
| 索引选择性太低 | WHERE gender = '男'(50% 数据命中) | 优化器判断全表扫描更快 |
| NOT / != / <> | WHERE status != 'deleted' | 部分情况下优化器放弃索引 |
| IS NULL / IS NOT NULL | WHERE col IS NOT NULL | 取决于数据分布和数据库版本 |
LIKE 查询的索引命中情况:
✅ 会走索引:
WHERE name LIKE '张三%' -- 前缀匹配,B+ 树可以范围扫描
WHERE name LIKE '张三%王' -- 前缀固定,仍可利用索引
❌ 不走索引:
WHERE name LIKE '%张三' -- 前缀通配,无法确定扫描起点
WHERE name LIKE '%张三%' -- 前后通配,只能全表扫描本质原因:B+ 树索引是按键值有序排列的。前缀匹配相当于范围查询(“张三”开头的所有记录在一个连续范围内),可以利用有序性。但前缀通配意味着匹配位置不确定,B+ 树无法定位起点,只能全表扫描。
需要模糊匹配怎么办:如果业务必须支持 %关键词% 查询,应该用全文索引(MySQL FULLTEXT)或搜索引擎(Elasticsearch),而不是在关系数据库上硬撑。
差距在哪:新手笼统地说“LIKE 会失效”——其实前缀匹配不会。高手区分了八种索引失效场景,且说清了 LIKE 的前缀匹配 vs 通配的本质区别(B+ 树的有序性)。面试官考的是你对数据库索引原理的理解深度,而不是背条目。
Q:大规模数据处理场景设计——从千条到百万级如何扩展?
来源:快手 AI Agent 开发一面
新手答:“用循环累加就行。”
高手答:
千条数据:单机单线程直接处理,没必要引入复杂架构。一个循环累加,时间复杂度 O(n),毫秒级完成。
百万级数据:核心挑战从计算变成了内存、IO、和故障恢复:
分治并行(MapReduce 思想):
- Map 阶段:将数据按分片键(如 ID 范围)拆分为 N 个子集,每个 Worker 独立计算局部和
- Reduce 阶段:汇总节点收集所有局部和,做最终聚合
- 总耗时 ≈ 单个分片处理时间 + 网络传输时间,而非 N 倍
保证效率和稳定性:
| 挑战 | 解决方案 |
|---|---|
| 内存不够 | 流式处理:逐批读取数据,不一次性加载全量到内存 |
| Worker 失败 | 检查点(Checkpoint):每处理一批保存中间结果,失败后从断点恢复而非从头开始 |
| 数据倾斜 | 均匀分片:避免某个 Worker 分到的数据远多于其他 |
| 结果正确性 | 幂等设计:重试不会导致重复计算(去重或幂等累加) |
| 监控 | 进度追踪:每个 Worker 上报处理进度,整体进度可视化 |
技术选型:
百万级:多线程/多进程 + 内存流式处理即可
千万级:单机多核(Python multiprocessing / Go goroutine)
亿级以上:分布式计算框架(Spark / Flink)差距在哪:新手用循环就结束了——千条数据确实够用,但百万级完全不同。高手用分治并行处理,且考虑了内存、故障恢复、数据倾斜、幂等性四个生产环境必须面对的问题。面试官考的是你从”能跑”到”能在生产环境稳定跑”的工程化思维跃迁。