timeline:
9.16 一面
9.18 二面
9.22 三面
9.23 HR面
9.24 录用评估
9.26 Offer

腾讯一面（1h）

1. 介绍实习需求（K8S 和 Casbin RBAC 相关）
2. 为啥初创实习两个月离职
## 3. Go 为什么支持高并发
核心在于 **goroutine + GMP 调度模型**，不是单纯"语言支持并发"这么简单。
**几个关键点：**
- **goroutine 很轻**：初始栈 2-4KB，可动态扩缩，几十万个 goroutine 开着没问题；线程是 MB 级别，差了两三个数量级
- **GMP 调度是用户态的**：G（goroutine）、M（OS线程）、P（逻辑处理器），调度在用户空间完成，避免频繁的内核态切换，上下文切换成本低很多
- **M:N 模型**：多个 goroutine 映射到少量 OS 线程，P 的数量默认等于 CPU 核数，充分利用多核
- **network poller 集成**：IO 等待时 goroutine 会被挂起，M 可以去跑其他 goroutine，不会因为 IO 阻塞线程
**踩过的坑：**
goroutine 轻归轻，泄漏问题很隐蔽——channel 没人读、context 没传导致 goroutine 一直挂着。我们线上出过一次 goroutine 数量涨到几十万的事故，最后用 `runtime.NumGoroutine()` 配合 pprof 才定位到。
**和 Java 对比：**
Java 虚拟线程（Project Loom）思路类似，但 Go 这套从语言层面原生支持，心智负担小很多，不需要理解 Executor/线程池这套。
本质上 Go 的高并发是**调度器设计 + 运行时配合**的结果，不是魔法。

## 1. GMP模型原理
**三个角色：**
- **G（Goroutine）**：待执行的任务单元，包含栈、状态、函数指针
- **M（Machine）**：真实的 OS 线程，执行 G 的载体
- **P（Processor）**：逻辑处理器，持有本地 G 队列，M 必须绑定 P 才能执行 G
**调度流程：**
```
G 创建 → 放入 P 的本地队列（或全局队列）
M 绑定 P → 从本地队列取 G 执行
本地队列空 → work stealing，从其他 P 偷一半
```
**几个关键机制：**
- **work stealing**：P 本地队列空时去偷其他 P 的，减少全局锁竞争
- **handoff**：M 因系统调用阻塞时，P 会脱离这个 M，转交给其他 M 继续跑，不让 P 空转
- **抢占调度**：Go 1.14 之前是协作式（依赖函数调用时插入检查点），之后改成**信号抢占**，能打断死循环的 G，这个改动很重要
**全局队列的作用：**
本地队列满了（容量256）会放全局队列；P 每隔一段时间也会主动从全局队列取，防止饥饿。
**一个细节：**
M 和 P 不是固定绑定的，M 阻塞时 P 会"甩掉"它。所以 `GOMAXPROCS` 控制的是 P 的数量，不是线程数——线程数是动态的，可能超过 GOMAXPROCS。
这块我在做性能调优时看过 runtime 源码，`schedule()` 函数是调度的核心入口，逻辑清晰但细节很多。

## 2. Goroutine Work-Stealing 的目的
**一句话：负载均衡，让所有 P 都有活干，避免资源浪费。**
**具体场景：**
假设有 4 个 P，某个 P 的 G 队列跑完了，其他三个 P 还有大量 G 积压——如果不 stealing，这个空闲 P 就白白浪费了 CPU。
**stealing 的策略：**
- 从其他 P 的本地队列**偷一半**（不是全部），减少被偷方的影响
- 优先偷队列尾部的 G（被偷方从头取，减少竞争）
- 偷不到再去全局队列拿
- 全局队列也空，才考虑进入休眠
**为什么不用全局队列统一调度？**
全局队列需要加锁，高并发下锁竞争很激烈。本地队列 + work stealing 是**无锁优先**的设计，只有在 stealing 时才需要对目标 P 加锁，粒度小很多。
**实际效果：**
我们压测时观察过，开了多个 P 之后 CPU 利用率明显更均匀，不会出现某个核跑满、其他核闲着的情况。这个机制对**任务分布不均匀**的场景尤其有价值，比如某些 G 执行很快、某些很慢。
本质上是用**局部性**（本地队列减少竞争）换**全局均衡**（stealing 补偿倾斜），两者都要。

## 3. P的角色的作用，如果在M上维护Goroutine队列有什么不好
**先说如果队列在 M 上会有什么问题：**
M 是 OS 线程，当它发生**系统调用阻塞**时，整个 M 被卡住——队列里的 G 没人执行，只能干等。要解决这个问题，就得把队列迁移到其他 M，但迁移本身代价很高，而且时机难把握。
本质矛盾：**M 的生命周期和 G 队列的生命周期耦合在一起了。**
**P 的引入就是解耦：**
- M 阻塞 → P 直接甩掉这个 M，绑到其他空闲 M 上继续跑
- 队列跟着 P 走，不跟着 M 走
- 被唤醒的 M 重新找一个空闲 P 绑定，或者没有 P 就休眠
P 本质上是一个**可迁移的执行上下文**，把"任务队列"和"执行载体"分离开。
**P 的其他作用：**
- 持有 `mcache`（内存分配缓存），避免每次分配都竞争全局堆锁
- 控制并发度——`GOMAXPROCS` 就是 P 的数量，而不是线程数
- work stealing 的粒度是 P，而不是 M，逻辑更清晰
**一句话总结：**
P 是为了让调度器在 M 阻塞时**不停摆**，同时顺手承担了内存和并发度管理的职责。没有 P 这层，GMP 就退化成简单的 M:N 模型，灵活性差很多。

## 4. GMP对CPU密集型任务能提高并发么
**不能显著提升，这是 GMP 的局限。**
CPU 密集型任务的特点是 goroutine 一直占着 CPU 跑，不会主动让出，P 的数量又限制在 `GOMAXPROCS`（默认等于核数）。所以并发度上限就是 CPU 核数，GMP 再怎么调度也突破不了硬件限制。
**GMP 真正发挥作用的场景是 IO 密集型：**
IO 等待时 goroutine 挂起，M 可以去跑其他 G，一个 M 可以服务大量 goroutine，吞吐上去了。CPU 密集型没有等待，这套机制用不上。
**CPU 密集型的问题：**
- Go 1.14 前没有抢占，一个死循环的 G 能把整个 P 卡死
- 1.14 加了信号抢占才解决，但调度开销本身也是额外成本
- 大量 CPU 密集型 goroutine 反而会因为频繁上下文切换**降低**性能
**实践建议：**
CPU 密集型任务，goroutine 数量控制在核数附近就够了，开太多适得其反。我们有个图像处理的服务，worker pool 大小设成 `runtime.NumCPU()` 反而比无限开 goroutine 快不少。
所以 GMP 解决的核心问题是**IO 并发**，不是计算并行。

## 5. IO操作需要CPU么，什么时候需要，磁盘IO和网络IO的区别
**IO 操作需要 CPU，但不是全程占用。**
大致分两个阶段：
- **发起阶段**：需要 CPU 准备请求、调用系统调用，时间很短
- **等待阶段**：数据在传输，CPU 不参与，goroutine 挂起
真正"烧 CPU"的是数据到达后的**处理阶段**，比如解析、序列化、加密。
**网络 IO vs 磁盘 IO 的核心区别：**
网络 IO 在 Go 里是**完全异步**的，runtime 内置了 netpoller（基于 epoll/kqueue），goroutine 等待网络数据时会被挂起，M 去跑其他 G，等数据来了再唤醒。这套是 Go 原生支持的，效率很高。
磁盘 IO 就麻烦了——**标准的文件读写是阻塞系统调用**，M 会被卡住，P 会执行 handoff 找新 M 来接管。本质上是用新线程来"补位"，线程数可能因此暴涨。Go 没有像网络那样对磁盘 IO 做异步封装。
**实际踩过的坑：**
有个服务大量读本地文件，压测时发现线程数涨到几百，就是这个原因。后来改成用 `io_uring`（通过 cgo）或者提前把文件内容 load 进内存，才解决。
所以 GMP 对网络 IO 友好，对磁盘 IO 的支持其实是"凑合"的。

## 6. Channel的作用和底层实现
**作用：goroutine 间通信 + 同步，Go 并发哲学"用通信共享内存"的核心载体。**
**底层结构 `hchan`：**
```go
type hchan struct {
    qcount   uint          // 当前元素数
    dataqsiz uint          // 环形缓冲区容量
    buf      unsafe.Pointer // 环形缓冲区
    sendx    uint          // 发送索引
    recvx    uint          // 接收索引
    recvq    waitq         // 阻塞的接收者队列
    sendq    waitq         // 阻塞的发送者队列
    lock     mutex
}
```
核心是**环形缓冲区 + 两个等待队列**。
**发送/接收逻辑：**
- buf 有空间 → 直接写入缓冲区，不阻塞
- buf 满了 → 发送方 goroutine 进入 `sendq`，挂起
- buf 空了 → 接收方进入 `recvq`，挂起
- 有等待的接收方时，发送可以**直接拷贝到对方栈**，跳过缓冲区，减少一次内存拷贝
**无缓冲 channel 的特殊性：**
没有 buf，发送和接收必须同时就绪，本质是**同步握手**，常用来做事件通知。
**踩过的坑：**
- 向 closed channel 发送会 panic，但读 closed channel 会返回零值，这个不对称性坑过我一次
- channel 不是银弹，高频小消息场景锁竞争反而比 `sync.Mutex` 高，我们有个计数器场景换成 atomic 之后性能提升明显
**无缓冲 vs 有缓冲选择：**
需要严格同步用无缓冲，解耦生产消费速率用有缓冲，容量设多少要根据实际压测，拍脑袋容易出问题。

## 7. Channel的缓冲区在用户态还是内核态
**用户态。**
`hchan` 整个结构体包括 buf 缓冲区，都是 Go runtime 在堆上分配的普通内存，和内核没有关系。goroutine 之间的数据拷贝也完全在用户空间完成，不涉及系统调用。
这也是 channel 比 pipe 快的原因之一——pipe 是内核对象，读写都要经过内核态，有上下文切换成本。channel 就是用户态的内存拷贝，轻很多。
**goroutine 挂起时呢？**
goroutine 进入 `sendq`/`recvq` 等待，这个挂起是 Go 调度器做的，也是用户态行为，不是 OS 的 sleep/block。M 不会被卡住，可以继续跑其他 G。
只有当 M 执行了真正的阻塞系统调用（比如磁盘 IO），才会陷入内核态阻塞。channel 等待不会触发这个。

## 8. Goroutine阻塞等待的时候由谁来唤醒，需要额外的goroutine来遍历所有的channel么
**不需要额外的 goroutine 来轮询，是发送/接收方直接唤醒的。**
**唤醒机制：**
goroutine 阻塞在 channel 上时，会把自己包装成 `sudog` 挂到 `sendq` 或 `recvq` 里，然后调用 `gopark` 让出 P，进入等待状态。
唤醒发生在**对端操作时**：
- 有人往 channel 发数据 → runtime 从 `recvq` 取出等待的 G，调用 `goready` 把它放回 P 的运行队列
- 有人从 channel 取数据 → 从 `sendq` 取出等待的发送方唤醒
整个过程是**点对点的直接唤醒**，不存在后台轮询。
**select 的情况稍复杂：**
select 多个 channel 同时等待时，goroutine 会把自己的 `sudog` 挂到每个 channel 的等待队列上。哪个 channel 先就绪，就由那个 channel 的操作方来唤醒，同时把这个 G 从其他 channel 的队列里摘除。
**网络 IO 的唤醒不一样：**
网络等待是 netpoller 负责，runtime 有个后台线程跑 epoll，数据到了才唤醒对应的 goroutine。这是 channel 唤醒和网络 IO 唤醒的本质区别——前者是对端触发，后者是 poller 触发。

## 9. M上的G0是干嘛的
**每个 M 都有一个特殊的 G0，专门用来执行调度逻辑，不跑用户代码。**
普通 G 运行用户代码，用的是自己的栈。但调度器本身也需要执行代码——比如 `schedule()`、goroutine 的创建销毁、栈扩容等——这些不能在用户 G 的栈上跑，否则栈管理会乱掉。
G0 的栈直接用的是 M 对应 OS 线程的栈，容量比普通 G 大得多，固定不扩缩。
**G0 干的事：**
- 执行 `schedule()` 找下一个可运行的 G
- goroutine 切换时的现场保存和恢复
- 系统调用前后的处理
- 栈扩容（`morestack`）
- GC 相关的一些操作
**切换流程：**
```
用户 G 运行 → 触发调度（主动让出/抢占/系统调用）
→ 切换到 G0 栈
→ G0 执行 schedule() 找下一个 G
→ 切换回新的用户 G
```
本质上 G0 是 M 的"管理员"，每次调度都要经过它中转一次。理解 G0 对看 runtime 源码很关键，很多函数开头你会看到 `getg().m.g0` 这种写法。

## 10. 介绍select/poll/epoll
**三者都是 IO 多路复用，解决"一个线程监听多个 fd"的问题，核心差异在扩展性。**

**select：**

- 传入 fd_set（bitmap），内核遍历所有 fd 检查就绪状态
- 限制：fd 数量上限 1024，每次调用都要把 fd_set 从用户态拷贝到内核态
- 返回后还要自己遍历找哪个 fd 就绪，O(n) 复杂度

**poll：**

- 用链表替代 bitmap，突破了 1024 限制
- 但本质和 select 一样，每次调用仍然是全量拷贝 + 内核全量遍历
- O(n) 没变，fd 多了一样慢

**epoll：**

- 用 `epoll_create` 在内核创建一个事件表，`epoll_ctl` 增删 fd，fd 信息常驻内核，不用每次拷贝
- 就绪时内核通过回调把事件放入就绪链表，`epoll_wait` 只返回就绪的 fd
- O(1) 获取就绪事件，fd 数量对性能影响很小

**epoll 两个触发模式：**

- **LT（水平触发）**：fd 就绪没处理，下次 wait 还会通知，默认模式，不容易漏事件
- **ET（边缘触发）**：只通知一次，必须一次性读完，性能更高但实现复杂，Nginx 用这个

**和 Go 的关系：**

Go netpoller 底层在 Linux 上就是 epoll，`epoll_ctl` 注册网络 fd，有事件时唤醒对应 goroutine。这也是为什么 Go 网络 IO 能支撑大量并发连接的底层原因。

## 11. 网络IO的流程
**以 Go 服务收到一个 TCP 请求为例，从上到下：**

**应用层发起 read：**

- goroutine 调用 `conn.Read()`
- 底层 fd 是非阻塞的，数据没到就返回 `EAGAIN`
- goroutine 把自己挂到 netpoller，调用 `gopark` 让出 P

**内核层等待数据：**

- 网卡收到数据包，DMA 直接写入内核缓冲区，不经过 CPU
- 内核协议栈处理：物理帧 → IP 包 → TCP 段 → 放入 socket 接收缓冲区
- epoll 的回调触发，把这个 fd 标记为就绪

**netpoller 唤醒：**

- Go runtime 后台有个线程轮询 `epoll_wait`
- 发现 fd 就绪 → 找到对应挂起的 goroutine → 调用 `goready` 放回运行队列

**goroutine 恢复执行：**

- 从内核 socket 缓冲区把数据拷贝到用户态 buf（这里有一次内存拷贝）
- `conn.Read()` 返回，继续跑业务逻辑

**整个流程的关键点：**

- 网卡到内核是 **DMA**，不占 CPU
- 内核到用户态有**一次拷贝**，零拷贝技术（sendfile、mmap）就是在优化这一步
- goroutine 等待期间 M 不阻塞，去跑其他 G，这是 Go 高并发的关键

**write 方向类似**，只是反过来——用户态数据拷贝到内核发送缓冲区，内核负责实际发送，应用不用等发送完成。

## 12. 了解过Go Runtime么
了解，前面聊的 GMP、netpoller、channel 其实都是 Runtime 的一部分，这里系统说一下我理解的几个核心模块。

**调度器（Scheduler）** 就是 GMP 那套，前面聊得比较深了，不展开。

**内存分配器** 基于 tcmalloc 思路，按对象大小分 tiny/small/large 三类。P 持有 `mcache`，小对象分配无锁直接从 mcache 拿，mcache 不够再从 `mcentral` 补，再不够从 `mheap` 申请。这套设计核心就是**减少锁竞争**，高并发下分配性能很好。

**GC（垃圾回收）** 三色标记 + 混合写屏障，Go 1.14 之后 STW 压缩到亚毫秒级。我们有个低延迟服务对 GC 比较敏感，调过 `GOGC` 和 `GOMEMLIMIT`，也做过对象池来减少 GC 压力。

**netpoller** 前面刚聊，基于 epoll/kqueue 封装，是 Go 网络高性能的基础。

**栈管理** goroutine 栈初始 2-8KB，不够时触发 `morestack` 进行**连续栈拷贝扩容**，早期是分段栈，切换开销大，后来换成连续栈。

**坦白说：**

Runtime 源码我主要看过调度器和内存分配这两块，GC 的具体实现细节（比如写屏障的精确时机）我了解的不算特别深，停留在原理层面。

## 算法：求两个数的最大公约数

腾讯二面（1h）

1. 介绍实习需求，最有挑战的部分
## 2. RocksDB了解么，说一下LsmTree
了解，用过 RocksDB 做过嵌入式存储，也研究过它的存储引擎设计。

**LSM Tree 核心思路：**

把随机写转成顺序写，用读性能换写性能。

**写入流程：**

```
写入 WAL（顺序写，保证持久化）
→ 写入内存 MemTable（跳表结构）
→ MemTable 满了 → 冻结成 Immutable MemTable
→ 后台线程 flush 到磁盘，生成 L0 SST 文件
→ 后台 Compaction 合并各层 SST
```

**分层结构：**

- L0：直接从内存 flush，文件间 key 范围可能重叠，读需要查多个文件
- L1 以下：Compaction 后保证同层文件 key 不重叠，有序
- 每层容量是上一层的倍数（默认10倍），数据逐层下沉

**读的代价：**

最坏要从 MemTable → L0 → L1 → ... 逐层查，所以引入了 **Bloom Filter** 快速排除不存在的 key，**Block Cache** 缓存热数据。

**Compaction 的问题：**

写放大是 LSM 的硬伤，一条数据可能被反复合并写多次。RocksDB 提供 Level Compaction 和 Universal Compaction 两种策略，前者读性能好，后者写放大小，需要根据场景选。

**对比 B+ Tree：**

B+ Tree 随机写多，适合读多写少；LSM 顺序写，适合写多场景，Kafka、TiKV 底层都是这个思路。

我们用 RocksDB 时踩过 L0 文件堆积导致写停顿的问题，本质是 Compaction 速度跟不上写入速度，最后调了 `max_write_buffer_number` 和 Compaction 线程数才稳住。

## 3. 详细介绍一下Raft协议
**Raft 解决的问题：分布式系统中多节点如何就"同一份数据"达成一致。**

**核心角色：**

- **Leader**：唯一接受写请求，负责日志复制
- **Follower**：被动接收，响应 Leader 和 Candidate
- **Candidate**：选举中间态

**Leader 选举：**

Follower 超过 `election timeout` 没收到心跳 → 变 Candidate → 自增 term，发起投票请求。

投票规则：

- 每个节点每个 term 只投一票
- Candidate 的日志必须比投票方**至少一样新**（term 更大，或 term 相同但 index 更大）
- 获得多数票 → 成为 Leader

这个日志新旧比较是关键，保证选出来的 Leader 一定有最完整的日志。

**日志复制：**

```
Client 写请求 → Leader 追加日志（未提交）
→ 并行发 AppendEntries 给所有 Follower
→ 多数节点写入成功 → Leader commit
→ 返回客户端成功
→ 下次心跳通知 Follower commit
```

**几个关键细节：**

- **term** 是逻辑时钟，节点收到更大 term 立刻退回 Follower
- commit 只需要**多数派**，不需要全部节点，少数节点挂掉不影响可用性
- Leader 只能提交**当前 term 的日志**，老 term 的日志靠新日志捎带提交，这个规则很绕但很重要，避免已提交日志被覆盖

**脑裂处理：**

网络分区后可能出现两个 Leader，但旧 Leader 所在分区节点数少，无法获得多数派确认，写入不会 commit。分区恢复后旧 Leader 发现更大 term，自动退位，未提交的日志会被覆盖。

**和 Paxos 对比：**

Paxos 理论上更通用，但工程实现极其复杂，各家实现差异大。Raft 限制了 Leader 唯一性、日志只能从 Leader 流向 Follower，大幅简化了理解和实现。etcd、TiKV 都是 Raft，没听说谁在生产用 Paxos 原版。

**实践中的坑：**

`election timeout` 设置很关键，太小频繁选举，太大故障恢复慢。我们用 etcd 时遇到过网络抖动引发反复选举导致服务不可用，最后调大了 timeout 并优化了网络才稳定。

## 4. Raft协议和Paxos协议的区别，有哪些优化
**核心区别：**

Paxos 解决的是单值共识（Single Decree Paxos），Multi-Paxos 才能做日志复制，但论文里没有完整描述，各家实现差异极大。Raft 从设计之初就面向工程，限制更多但实现更统一。

几个具体差异：

- **Leader 限制**：Raft 强 Leader，日志只从 Leader 流向 Follower；Paxos 允许任意节点提 Proposal，灵活但复杂
- **日志连续性**：Raft 要求日志必须连续提交，不能有空洞；Paxos 允许乱序，需要额外机制填洞
- **选举限制**：Raft 要求 Leader 必须有最新日志才能当选；Paxos 选出 Leader 后还需要 prepare 阶段补齐日志
- **可理解性**：Raft 论文标题就叫"In Search of an Understandable Consensus Algorithm"，这不是噱头，工程实现上确实差很多

**Raft 常见优化：**

**PreVote** 解决网络分区恢复后，孤立节点 term 疯涨导致重新加入时触发不必要选举的问题。正式投票前先问一轮"我有没有资格参选"，term 不增加。

**ReadIndex / LeaseRead** 读请求走 Leader 也需要确认自己还是 Leader，ReadIndex 要求 Leader 发一轮心跳确认多数派，LeaseRead 基于时钟租约跳过这轮确认，延迟更低但依赖时钟精度。

**Pipeline 和批量提交** AppendEntries 不等上一条 ACK 就发下一条，日志打包批量发送，提升吞吐。

**Joint Consensus 成员变更** 直接切换成员配置可能出现两个多数派，Joint Consensus 用两阶段过渡，先进入新旧配置共存状态再切换，etcd 实现了这个。

**Raft 的硬伤：**

强 Leader 是双刃剑——简化了实现，但 Leader 是瓶颈，所有写都过一点。Multi-Raft（TiKV 的做法）把数据分 region，每个 region 一个 Raft 组，绕开了单 Leader 的吞吐限制，这是目前工程上比较成熟的解法。

## 5. 介绍一下React Agent
**ReAct = Reasoning + Acting，核心思路是让 LLM 交替输出"思考"和"行动"，而不是一次性生成答案。**
**基本循环：**

```
Thought: 分析当前情况，决定下一步
Action: 调用工具（搜索、查数据库、执行代码...）
Observation: 获取工具返回结果
Thought: 基于结果继续推理
... 循环直到得出最终答案
```

这个循环让模型能处理需要多步推理和外部信息的复杂任务，单次推理搞不定的问题拆成多步来做。
**和纯 Chain-of-Thought 的区别：**
CoT 只是让模型"想清楚再回答"，但信息来源还是训练数据。ReAct 加入了 Action，能实时获取外部信息，解决知识截止和实时性问题。
**工程实现的关键点：**
- **工具定义要清晰**：工具描述写得差，模型选错工具是最常见问题，我们花了不少时间打磨 tool description
- **终止条件**：循环必须有最大步数限制，模型有时会陷入无意义的重复调用
- **Observation 长度控制**：工具返回内容太长直接塞进 context 很快撑爆，需要截断或摘要
- **错误处理**：工具调用失败要告诉模型让它重试或换策略，而不是直接崩掉
**实践中的问题：**
延迟是硬伤，每次 Action 都要等模型推理 + 工具执行，多轮下来 latency 很高。我们做过并行工具调用的优化——如果当前 Thought 判断多个工具互不依赖，同时发起调用，能省不少时间。
另外模型能力对 ReAct 效果影响很大，小模型经常出现 Thought 和 Action 格式乱掉、工具选择混乱的问题，7B 以下基本不好用。

## 6. LangChain 和 LangGraph 的区别
**一句话：LangChain 是线性链，LangGraph 是有状态的图，解决的复杂度不在一个量级。**
**LangChain 的定位：**
早期做 LLM 应用的胶水层，Chain 就是把 prompt → LLM → 输出解析 → 下一步 串起来。简单场景够用，但流程是固定的有向无环图，没法做条件分支、循环、回退。
**LangGraph 解决什么：**
本质是个状态机框架，节点是函数，边可以是条件判断，整个流程是有环图。
核心概念：
- **State**：贯穿整个流程的共享状态，每个节点读写它
- **Node**：处理单元，可以是 LLM 调用、工具执行、任意函数
- **Edge**：普通边或条件边，条件边根据 State 决定走哪个分支
这样就能做 ReAct 循环、Human-in-the-loop、多 Agent 协作这些 LangChain 实现起来很别扭的场景。
**实际体验：**
LangChain 抽象层太厚，早期版本 API 变动频繁，debug 很痛苦，出了问题不知道哪层出的。我们后来复杂流程直接用 LangGraph 或者干脆自己写状态机，反而更可控。
LangGraph 的问题是学习曲线陡，State 设计不好后期很难维护，节点间依赖不清晰容易变成"意大利面图"。
**选型建议：**
简单 RAG、单轮问答用 LangChain 够了。需要多步决策、循环、条件分支、多 Agent 就上 LangGraph，或者考虑 AutoGen、CrewAI 这些更面向 Multi-Agent 的框架。

7. Agent 和 LLM 的区别
**LLM 是大脑，Agent 是能行动的完整个体。**
LLM 本质是个函数：输入 token，输出 token，无状态，没有记忆，不能主动做任何事。它只能"说"，不能"做"。
Agent 是在 LLM 外面套了一圈工程能力：
- **感知**：能接收外部输入（用户消息、工具返回、环境状态）
- **规划**：用 LLM 推理下一步该做什么
- **行动**：调用工具、执行代码、操作系统
- **记忆**：短期（context window）+ 长期（向量数据库/外部存储）
- **循环**：能持续运行直到任务完成，不是一次性的
**类比：**
LLM 是一个知识渊博但被关在房间里的专家，只能回答你传纸条的问题。Agent 是这个专家 + 手脚 + 工具箱，能自己去查资料、打电话、写文件。
**工程视角的本质差异：**
LLM 调用是无状态的单次 inference，Agent 是有状态的持续运行进程，需要管理状态流转、错误重试、工具调用生命周期。
复杂度完全不在一个量级，这也是为什么 Agent 系统难落地——LLM 幻觉、工具调用失败、状态管理混乱，每一个都是工程难题。

## 8. Function Call 和 MCP 的区别
**Function Call 是模型能力，MCP 是工具生态协议，层次不同。**
**Function Call：**
LLM 原生支持的能力，模型输出结构化的工具调用意图（JSON格式），由调用方解析后执行，结果再喂回模型。本质是约定了一种"模型表达调用意图"的格式。
每家模型格式不一样，OpenAI、Claude、Gemini 的 function call schema 都有差异，工具定义和执行逻辑都耦合在业务代码里。
**MCP（Model Context Protocol）：**
Anthropic 推的开放协议，解决的是**工具标准化和复用**问题。核心思路是把工具实现抽成独立的 MCP Server，通过标准协议暴露，任何支持 MCP 的客户端都能接入。
```
Client（Agent框架）← MCP协议 → MCP Server（工具实现）
```
工具不再散落在各个业务代码里，而是独立服务，可以被不同 Agent、不同模型复用。
**核心区别：**
- Function Call 解决"模型怎么表达调用意图"，MCP 解决"工具怎么标准化暴露和复用"
- 两者不互斥，MCP Server 里的工具最终还是通过 Function Call 机制被模型调用
- MCP 更像是工具层的 REST API 标准化，降低了工具开发和接入成本
**实践感受：**
MCP 理念很好，但生态还早期，稳定性和安全性都有问题——MCP Server 权限控制基本靠自觉，工具描述质量参差不齐。我们内部工具还是自己封装 Function Call，MCP 在观望。

9. RPC的全流程
**RPC 目标：让远程调用看起来像本地函数调用，屏蔽网络细节。**
**完整流程：**
```
调用方（Client）
  → Stub 序列化参数
  → 建立连接 / 从连接池取连接
  → 发送请求（网络传输）
  → 服务端 Stub 反序列化
  → 调用实际函数
  → 序列化返回值
  → 网络回传
  → 客户端反序列化
  → 返回给调用方
```
**几个关键环节：**
**序列化** 选型影响很大，JSON 可读性好但体积大、性能差；Protobuf 二进制紧凑、schema 强约束，是 gRPC 默认选择；Thrift 类似。我们从 JSON 换 Protobuf 之后，序列化耗时降了 60% 左右。
**连接管理** 每次调用建新连接代价太高，生产上必须连接池。gRPC 基于 HTTP/2，单连接多路复用，天然解决了连接数问题。
**服务发现** Client 怎么知道调哪个实例？一般接注册中心（etcd/Consul/Nacos），Client 拿到实例列表后本地做负载均衡，常见策略是轮询、一致性哈希、最小连接数。
**超时和重试** 超时必须端到端设计，不能只设单次调用超时。重试要注意幂等性，非幂等接口盲目重试会出数据问题，我们踩过一次支付接口重试导致重复扣款。
**拦截器/中间件** 日志、链路追踪、限流、鉴权都在这层做，gRPC 的 interceptor 机制很适合做这些横切关注点。
**和 HTTP 的本质区别：**
HTTP 是文本协议面向资源，RPC 面向方法调用，性能更高，强类型约束，适合内部服务间通信。但 HTTP/REST 跨语言跨团队更友好，对外暴露接口还是 REST 居多。

## 10. 负载均衡算法有哪些
**常见算法：**

**轮询（Round Robin）** 最简单，依次分发。适合请求处理时间相近、机器配置均匀的场景。

**加权轮询** 给不同机器设权重，配置高的多分流量。Nginx 默认用平滑加权轮询，避免某个节点短时间内接太多请求。

**随机 / 加权随机** 比轮询实现更简单，大量请求下统计上趋于均匀，够用。

**最少连接数（Least Connections）** 选当前活跃连接最少的节点，适合请求处理时间差异大的场景，比轮询更"公平"。

**一致性哈希** 按 key（用户ID、请求特征）哈希到固定节点，同一个 key 始终打到同一台机器。适合有状态服务、缓存亲和性场景。虚拟节点解决节点数变化时的数据倾斜问题。

**P2C（Pick of 2 Choices）** 随机选两个节点，取负载更低的那个。比最少连接数实现简单，统计上效果接近，gRPC 默认用这个思路。

**自适应负载均衡** 结合延迟、错误率、CPU 等指标动态调整权重，B站开源的 Kratos 框架里有实现，适合机器配置差异大或负载不均匀的场景。

**实践经验：**

大多数场景轮询够用，引入复杂算法前先确认瓶颈在哪。我们有个服务请求处理时间方差很大（从 10ms 到 5s 都有），换成最少连接数之后热点问题明显改善。

一致性哈希要小心哈希环倾斜，不加虚拟节点在节点少的时候分布很不均匀，踩过这个坑。

## 11. 介绍一致性Hash算法，服务扩缩容之后有什么影响
**核心思路：**
把哈希空间构成一个首尾相连的环（0 到 2^32-1），节点和请求都哈希到环上，请求顺时针找最近的节点。
```
        Node A (hash: 100)
       /
环 → 请求(hash: 80) → 顺时针 → Node A
       \
        Node B (hash: 200)
```
**扩缩容影响：**
这是一致性哈希最大的优势——**只影响相邻节点的数据，不影响全局。**
加入新节点 C，插入到 A 和 B 之间：
- 原本打到 B 的部分请求，现在打到 C
- A 和其他节点完全不受影响
- 迁移数据量是 1/N，普通哈希是全量重新分布
删除节点 B：
- B 的请求全部顺时针转移到下一个节点
- 其他节点不受影响
**虚拟节点解决数据倾斜：**
真实节点少时，哈希环上分布不均匀，某些节点压力远大于其他节点。解决方案是每个物理节点映射多个虚拟节点（一般 100-200 个），让分布更均匀。
节点权重也可以通过虚拟节点数量来体现，配置高的机器多分虚拟节点。
**实际问题：**
扩容时新节点刚上线，数据还没预热，缓存命中率会有一段时间下降，对下游数据库有冲击。我们处理方式是**慢启动**——新节点先接少量流量，逐步提权重，给缓存预热时间。
另外一致性哈希适合无状态 + 缓存亲和性场景，真正有状态的数据分片（比如分库分表）还是要配合数据迁移方案，不能只靠路由层解决。


12. 网络编程
13. 介绍一下TCP和UDP
**核心区别一句话：TCP 可靠有序有连接，UDP 不可靠无连接，性能换可靠性。**
**TCP：**
三次握手建连，四次挥手断连，保证：
- **可靠传输**：ACK 确认 + 超时重传
- **有序**：seq 序号保证乱序重排
- **流量控制**：滑动窗口，避免发太快把接收方打爆
- **拥塞控制**：慢启动 + 拥塞避免，感知网络状况动态调整发送速率
代价是连接状态维护、握手延迟、重传开销。
**UDP：**
无连接，直接发包，没有确认、重传、排序。优点是延迟低、无队头阻塞、无连接建立开销。
适合场景：实时音视频（丢帧比卡顿好）、DNS 查询、游戏同步。
**一个值得聊的点——QUIC：**
HTTP/3 底层用 QUIC，本质是 UDP 上自己实现了可靠传输，但解决了 TCP 的几个问题：
- **队头阻塞**：TCP 一个包丢了整条连接阻塞，QUIC 多路复用流之间互不影响
- **连接迁移**：TCP 连接和四元组绑定，切网络就断；QUIC 用 Connection ID，切网络连接不断
- **0-RTT 握手**：复用之前的连接信息，首次之后建连更快
我们有个移动端业务弱网环境下体验差，接入 QUIC 之后请求成功率提升明显，队头阻塞问题确实改善很多。
**选型简单原则：**
需要可靠性用 TCP，需要低延迟且能容忍丢包用 UDP，需要两者兼顾考虑 QUIC。

14. 介绍一下HTTP各个版本及实现
**HTTP/1.0**
每次请求独立 TCP 连接，请求完就断。并发全靠多开连接，overhead 极大。
**HTTP/1.1**
主要改进：
- **Keep-Alive**：默认长连接，复用 TCP 连接
- **Pipeline**：可以连续发多个请求不等响应，但响应必须按顺序返回，有**队头阻塞**问题，实际浏览器基本没启用
- **分块传输**：支持流式响应，不用提前知道 Content-Length
队头阻塞是核心硬伤，浏览器绕过方式是对同一域名开 6 个并行 TCP 连接。
**HTTP/2**
解决 1.1 的性能问题：
- **多路复用**：单连接上多个 stream 并行，解决了应用层队头阻塞
- **头部压缩（HPACK）**：HTTP 头重复字段多，压缩后体积减小明显
- **Server Push**：服务端主动推资源，实际用得少
- **二进制分帧**：不再是文本协议，解析更高效
但 TCP 层队头阻塞没解决——一个包丢了，所有 stream 都要等重传。
**HTTP/3**
底层换成 QUIC（UDP），彻底解决队头阻塞：
- stream 之间独立，一个 stream 丢包不影响其他
- 连接迁移，切网络不断连
- 0-RTT 复用历史连接信息
代价是 QUIC 实现复杂，很多中间件（防火墙、负载均衡）对 UDP 支持不好，落地有阻力。
**实践感受：**
HTTP/2 在我们内部 RPC 场景收益明显，gRPC 默认就是 HTTP/2。HTTP/3 在移动端弱网收益大，PC 端强网环境提升有限，我们只在面向移动端的接入层启用了。

算法：
1. 编辑距离
2. 两两交换链表中的节点

腾讯三面（30min）
1. 介绍实习，你做了什么
2. 介绍项目
3. 实习时长，到岗时间，推HR面

腾讯HR面（15min）
1. 离职原因
2. 实习时长，到岗时间
3. 聊聊天