字节|后端开发（Agent中台）一面凉经
1自我介绍
2Agent项目是实习项目还是个人项目？有没有上线?
3拷打实习（10min)
## 大模型微调，你的训练数据集是如何构建的？数据量有多大?
**背景**

我们做的是垂直领域的指令微调（SFT），场景是内部知识问答 + 特定格式输出，不是通用能力增强。

**数据构建方式**

**1. 人工标注 + 模板生成**

- 业务侧整理了几百条高质量种子数据（问题+答案对）
- 用 GPT-4 做数据增强，一条种子扩展出 10~20 条变体
- 人工复核筛掉明显错误的

**2. 从业务日志挖掘**

- 线上用户真实提问 + 人工审核的回答
- 质量高但获取慢，大概占总量 30%

**3. 合成数据**

```
文档 → GPT-4 生成 QA 对 → 人工抽样验证
```

用于知识覆盖，但质量参差不齐，筛选成本不低

**数据量**

最终用了大概 **2~3 万条**指令数据，领域比较窄，数据量不算大。

说实话开始以为要几十万才够，后来发现高质量数据 1~2 万条在垂直场景下效果已经不错，数据质量远比数量重要。

**踩过的坑**

- 早期数据格式不统一，prompt 模板和 base model 训练时用的不一致，导致微调后输出格式乱——后来统一用模型原始 chat template
- 数据里混入了矛盾样本（同一问题不同答案），模型输出不稳定，加了去重和一致性校验才解决


## 在构建数据集的过程中，遇到了哪些挑战？花了多长时间?
**主要挑战**

**1. 数据质量参差不齐**

GPT-4 生成的增强数据看起来流畅，但细节经常有偏差：

- 业务术语用错
- 回答格式不符合我们要求
- 偶尔出现幻觉内容混入

早期没有严格校验，直接训练，模型输出一塌糊涂，才意识到要加过滤流程。

**2. 标注一致性难保证**

多人标注同一类问题，答案风格差异很大——有人回答简洁，有人喜欢展开，模型学完之后输出风格飘忽。

后来写了详细的标注规范 + 做了标注员对齐，还加了 **inter-annotator agreement** 检查，一致性低的样本打回重标。

**3. 数据分布不均衡**

某些高频场景数据多，长尾场景几乎没有，模型对长尾问题表现很差。

解决方式是主动补采样，但长尾场景本身数据难获取，是个持续的问题。

**4. 数据和模型的 prompt 格式对齐**

前面提到过，base model 用的 chat template 和我们构建数据时的格式对不上，这个坑花了将近一周才定位到根因。

**时间**

整个数据集从零到可用大概花了 **6~8 周**：

- 前两周：定规范、搭标注流程、种子数据整理
- 中间三周：批量生成 + 人工审核迭代
- 后两周：发现质量问题、清洗、补数据

说实话比预期长了不少，最初估的是一个月。数据工程这块往往是被低估最严重的部分，真正训练模型的时间反而短。

## 6你之前的实习经历偏后端工程，你未来的职业规划更倾向于纯后端开发，还是希望从事与AI/大模型结合的工作？
明确倾向于 **AI 工程化方向**，但不是放弃后端，而是以后端为底座做 AI 落地。

**我的判断**

纯后端的技术边界越来越清晰，大多数问题都有成熟解法。但 AI 工程化还处于早期——推理优化、Agent 可靠性、RAG 效果、成本控制，这些问题在工业界还没有标准答案，对我来说更有挑战性。

**为什么不转算法**

我对模型训练、论文创新没有特别强的热情，更喜欢解决系统性问题——怎么把模型能力稳定高效地服务化，怎么在延迟和成本之间找平衡，怎么让 Agent 在复杂业务流程里跑得住。这些本质上还是工程问题，后端背景反而是优势。

**短期目标**

在推理服务和 Agent 框架这层深挖，具体想做的方向：

- 推理侧：调度优化、KV Cache 管理、投机采样这些
- 应用侧：多步 Agent 的可靠性、工具调用的容错

**长期**

希望能参与到 AI 基础设施的建设，而不只是在应用层调 API。字节在这块的投入规模，是我投这个岗位的核心原因之一。

后端是我的根，AI 工程化是我想走的路，两者对我来说不矛盾。

## 7详细讲一下Golang中Channel的概念和作用，它是否是并发安全的？
**Channel 是什么**

Go 并发通信的核心原语，本质是一个**带锁的环形队列**，用于 goroutine 之间传递数据。

> "Don't communicate by sharing memory, share memory by communicating"

**基本用法**

```go
ch := make(chan int, 10)  // 有缓冲
ch := make(chan int)      // 无缓冲，同步阻塞

ch <- 1      // 发送
v := <-ch    // 接收
close(ch)    // 关闭
```

**有缓冲 vs 无缓冲**

- 无缓冲：发送方阻塞直到接收方ready，强同步语义
- 有缓冲：队列未满可以直接写入，解耦生产消费速度

**底层结构**

```go
type hchan struct {
    buf      unsafe.Pointer  // 环形队列
    sendx    uint            // 发送索引
    recvx    uint            // 接收索引
    sendq    waitq           // 阻塞的发送者队列
    recvq    waitq           // 阻塞的接收者队列
    lock     mutex           // 互斥锁
}
```

**是否并发安全**

**是的**，channel 内部有 mutex 保护，多个 goroutine 同时读写是安全的。

但有几个坑：

**向已关闭的 channel 发送会 panic**

```go
close(ch)
ch <- 1  // panic: send on closed channel
```

**重复关闭会 panic**

```go
close(ch)
close(ch)  // panic
```

**读已关闭的 channel 不会 panic**

```go
v, ok := <-ch  // ok=false 表示已关闭且空
```

**常见使用模式**

```go
// 退出信号
done := make(chan struct{})
go func() {
    <-done  // 等待退出
}()
close(done)  // 广播退出

// pipeline
func gen(nums ...int) <-chan int { ... }
func sq(in <-chan int) <-chan int { ... }
```

**踩过的坑**

- goroutine 泄漏：channel 没人消费，发送方永久阻塞
- 关闭时机不明确，多个发送方时用 `sync.WaitGroup` + 单独的 closer goroutine 管理
- 有缓冲 channel 设置过大当消息队列用，丢失了背压能力，服务过载时内存暴涨

实际上我更倾向于：**简单同步用 channel，复杂共享状态用 mutex**，不要为了"Go风格"强行用 channel。

## Channel和传统的锁（Mutex）在实现并发控制时有什么区别？各自的适用场景是什么？
**本质区别**

- Mutex：保护共享数据，同一时刻只有一个 goroutine 访问
- Channel：传递数据所有权，通过通信来同步

**实现机制对比**

```go
// Mutex：保护共享状态
type Counter struct {
    mu    sync.Mutex
    count int
}
func (c *Counter) Inc() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.count++
}

// Channel：转移数据所有权
ch := make(chan int, 1)
ch <- 0  // 初始值
val := <-ch
val++
ch <- val
```

**性能对比**

Mutex 比 Channel 快，Channel 底层也有锁，还有额外的调度开销：

```
Mutex:   ~20ns
Channel: ~100ns
```

高频操作（计数器、缓存）用 Mutex 更合适。

**适用场景**

**用 Mutex：**

- 保护简单共享状态（计数、map、缓存）
- 临界区很小、持锁时间短
- 性能敏感路径

**用 Channel：**

- goroutine 间传递任务、结果
- 生产者消费者模型
- 超时控制、退出信号
- pipeline 数据流

```go
// Channel 天然适合的场景
select {
case result := <-ch:
    // 处理结果
case <-time.After(3 * time.Second):
    // 超时处理
case <-ctx.Done():
    // 取消
}
```

**我的实际判断**

不要教条，看场景：

- 并发安全的 map → `sync.Map` 或 Mutex，不要用 channel 模拟
- worker pool → channel 分发任务，Mutex 保护统计数据
- 退出广播 → `close(done)` channel，比 Mutex + bool 优雅得多

踩过的坑：早期团队里有人用 channel 实现了一个"并发安全 map"，用单个 goroutine 串行处理所有读写请求，正确但性能很差——这种场景直接用 `sync.RWMutex` 即可，读多写少时性能差距很大。

## 讲一下GMP模型
上面已经聊过 GMP 了，简单补充几个没展开的点：

**调度循环细节**

M 执行 G 的完整循环：

```
schedule() → execute(g) → goexit → schedule()
```

G 执行完不是销毁，是放回 P 的本地队列或全局队列复用。

**全局队列 vs 本地队列**

```
本地队列：每个 P 最多 256 个 G
全局队列：溢出时放这里，或者 go 关键字创建时负载均衡
```

M 优先从本地队列取，减少锁竞争。

**几个容易忽略的点**

`runtime.Gosched()`：主动让出 P，当前 G 放回队列，让其他 G 运行——CPU 密集型任务里偶尔需要手动插入。

`LockOSThread()`：绑定 G 到固定 M，CGO 调用或需要线程局部存储时用，用完记得 `UnlockOSThread`。

**P 数量的影响**

```bash
GOMAXPROCS=1   # 单线程，无并行
GOMAXPROCS=N   # N个P并行，一般等于CPU核数
```

容器环境要用 `automaxprocs`，否则读到宿主机核数，P 过多反而增加调度开销。

之前说过的 work stealing、handoff、抢占调度这些就不重复了，有哪个点想深入聊？

## 当P的本地队列为空或者不为空时，它会怎么去调度G（协程）?TTRedis支持哪些数据结构
两个问题一起答。

**P 的调度策略**

**本地队列不为空** 直接从队头取 G 执行，最常见路径，无锁操作。

**本地队列为空，按优先级依次尝试：**

```
1. 每61次调度强制从全局队列取一个G（避免全局队列饥饿）
2. 从本地队列取
3. 本地队列空 → 从全局队列取一批
4. 全局队列也空 → netpoller 检查就绪的网络G
5. 都没有 → work stealing，随机偷其他P队列的一半
6. 偷不到 → M 休眠，P 放回空闲池
```

第1步的61次是源码里写死的魔法数字，保证全局队列不会被饿死。

## Redis 支持的数据结构

| 类型          | 底层实现                       | 典型场景      |
| ----------- | -------------------------- | --------- |
| String      | SDS                        | 缓存、计数器    |
| List        | quicklist                  | 消息队列、最新列表 |
| Hash        | ziplist/hashtable          | 对象存储      |
| Set         | intset/hashtable           | 去重、交并差    |
| ZSet        | ziplist/skiplist+hashtable | 排行榜、延迟队列  |
| Stream      | rax树+listpack              | 消息流       |
| HyperLogLog | 概率结构                       | UV统计      |
| Bitmap      | 位数组                        | 签到、布隆过滤器  |
| Geo         | ZSet封装                     | 地理位置      |

**底层实现会自动切换**：以 ZSet 为例，元素少且值短时用 ziplist 节省内存，超过阈值（默认128个元素或64字节）自动转 skiplist。

跳表这块值得多说一句——Redis 选跳表而不是红黑树，原因是跳表实现简单、范围查询友好、并发改造容易，插入删除平均 O(logN) 和红黑树一样但常数更小。

## 为什么Redis的速度这么快
**几个核心原因**
**1. 纯内存操作** 所有数据在内存，读写不涉及磁盘 IO，内存访问纳秒级 vs 磁盘毫秒级，量级差距。
**2. 单线程处理命令**
```
所有命令串行执行 → 无锁竞争 → 无上下文切换开销
```
单线程不是瓶颈，因为瓶颈在网络 IO 而不是 CPU。
Redis 6.0+ 引入多线程处理网络 IO，但命令执行仍是单线程。
**3. IO 多路复用**
```
epoll 监听所有客户端连接
→ 有事件才处理，不阻塞等待
→ 单线程处理数千并发连接
```
这是单线程能扛高并发的关键。
**4. 高效数据结构**
- SDS 比 C 字符串获取长度 O(1)
- ziplist 内存紧凑，CPU cache 友好
- 跳表范围查询高效
- 数据结构随数据量自动切换，小数据用紧凑结构
**5. 非阻塞持久化**
```
RDB：fork子进程做快照，主进程不阻塞
AOF：异步刷盘，不阻塞主流程
```
**6. 简单的网络协议 RESP** 文本协议，解析极简，序列化开销小。
**一句话总结**
内存 + 单线程无锁 + epoll + 精心设计的数据结构，几个优化叠加的结果，缺一不可。
踩过的坑：以为单线程就不会有性能问题，结果一个 `KEYS *` 扫全库直接把 Redis 卡死几秒——单线程意味着一个慢命令会阻塞所有请求，生产环境禁用 KEYS，用 SCAN 替代。

## 如何实现一个类似淘宝搜索框的实时商品名称模糊搜索功能？
**整体方案**

实时搜索框本质是：**低延迟 + 前缀/模糊匹配 + 高并发读**，核心用 ES 做搜索引擎。

**架构分层**

```
用户输入 → 前端防抖(200ms) → 网关限流 
→ 搜索服务 → ES集群
                ↑
         商品数据同步
```

**ES 索引设计**

```json
{
  "mappings": {
    "properties": {
      "title": {
        "type": "text",
        "analyzer": "ik_max_word",
        "search_analyzer": "ik_smart"
      },
      "title_suggest": {
        "type": "completion"  
      },
      "title_pinyin": {
        "type": "text",
        "analyzer": "pinyin"
      }
    }
  }
}
```

- `ik` 分词器处理中文
- `completion` 类型专门做前缀补全，底层是 FST，速度极快
- 拼音字段支持拼音搜索

**前缀补全 vs 模糊搜索**

```
用户输入"耐克" → completion suggest → 毫秒级返回候选词
用户输入完整词 → match/multi_match → 全文检索
```

搜索框补全用 completion，真正搜索结果用 match。

**数据同步**

```
MySQL商品表 → binlog → Canal → MQ → 消费写ES
```

增量同步，延迟秒级可接受。

**关键优化点**

- 前端防抖 200ms，减少请求量
- 热门搜索词 Redis 缓存，不打 ES
- ES 搜索结果 Redis 短暂缓存（TTL 5s）
- 搜索词权重：销量、点击率影响排序

**踩过的坑**

ES 的 `wildcard` 查询（`*耐克*`）性能极差，全表扫描，千万级数据直接超时——要用分词 + match，不要用 wildcard 做模糊搜索。

## 实时输入联想与输入完成后点击搜索在技术实现上有什么本质区别？
**本质区别：目标不同**

- 联想补全：猜你想输入什么，返回**候选词列表**
- 点击搜索：找匹配的**商品结果集**

**联想补全**

```
目标：快、准、少
返回：5~10个候选词
延迟要求：< 50ms
```

技术实现：

- ES `completion` 字段，FST 数据结构，前缀匹配极快
- Redis `ZRANGEBYLEX` 也能做前缀补全
- 只匹配词，不做复杂相关性计算

```json
GET /products/_search
{
  "suggest": {
    "title_suggest": {
      "prefix": "耐克",
      "completion": { "field": "title_suggest", "size": 8 }
    }
  }
}
```

**点击搜索**

```
目标：全、准、排序好
返回：分页商品列表（标题、图片、价格、销量）
延迟要求：< 200ms
```

技术实现：

- ES `multi_match` 多字段全文检索
- 相关性评分 + 业务权重（销量、转化率）综合排序
- 需要过滤（价格区间、品类、库存）
- 分页、高亮关键词

```json
{
  "query": {
    "function_score": {
      "query": { "multi_match": { "query": "耐克运动鞋", "fields": ["title^3", "brand", "category"] }},
      "functions": [
        { "field_value_factor": { "field": "sales_count", "modifier": "log1p" }}
      ]
    }
  }
}
```

**两者核心差异对比**

||联想补全|点击搜索|
|---|---|---|
|数据结构|FST/Trie|倒排索引|
|匹配方式|前缀匹配|分词全文检索|
|排序依据|搜索热度|相关性+业务权重|
|返回内容|词条|完整商品文档|
|缓存策略|激进缓存|短暂缓存或不缓存|

**联想的特殊处理**

个性化联想：结合用户历史搜索，Redis 存用户近期搜索词，和热门词合并排序。

踩过的坑：联想和搜索共用同一个 ES 查询，联想被复杂的 function_score 拖慢到 200ms+，用户体验很差——后来联想单独走轻量接口，甚至部分热词直接走 Redis，搜索走完整 ES 查询，彻底分开。

## 实时搜索通常使用什么网络协议（如WebSocket）？你了解或有使用过吗？讲一下
**实时搜索用 WebSocket 其实是误区**

搜索框联想本质是**请求-响应**模型，不需要服务端主动推送，用普通 HTTP 就够了，加上前端防抖控制请求频率。

WebSocket 适合的是**服务端需要主动推送**的场景。

**HTTP vs WebSocket 选择**

||HTTP|WebSocket|
|---|---|---|
|连接模型|短连接/长连接|持久双向连接|
|发起方|客户端|双向均可|
|适合搜索联想|✅|❌ 杀鸡用牛刀|
|适合实时推送|❌|✅|

**WebSocket 原理**

基于 HTTP 握手升级：

```
GET /ws HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: xxx

← 101 Switching Protocols
```

握手后连接升级为全双工 TCP 连接，后续帧格式是二进制的 WebSocket 帧，不再是 HTTP。

**Go 实现关键片段**

```go
// 服务端用 gorilla/websocket
upgrader := websocket.Upgrader{
    CheckOrigin: func(r *http.Request) bool { return true },
}

func wsHandler(w http.ResponseWriter, r *http.Request) {
    conn, _ := upgrader.Upgrade(w, r, nil)
    defer conn.Close()
    
    for {
        _, msg, err := conn.ReadMessage()
        if err != nil { break }
        conn.WriteMessage(websocket.TextMessage, msg)
    }
}
```

**我实际用过的场景**

- **消息推送**：用户收到新订单、新消息实时通知
- **大模型流式输出**：SSE 和 WebSocket 都用过，LLM 场景更多用 SSE

**SSE vs WebSocket**

LLM 输出场景我更推荐 SSE：

```
服务端单向推流，实现简单
自动重连，HTTP 协议天然穿透代理
不需要双向通信就别用 WebSocket
```

```go
// SSE 实现极简
w.Header().Set("Content-Type", "text/event-stream")
fmt.Fprintf(w, "data: %s\n\n", chunk)
flusher.Flush()
```

踩过的坑：WebSocket 连接数暴涨时内存压力大，每个连接在 Go 里至少两个 goroutine（读/写），连接数到几万时 goroutine 数量可观——要做连接数限制和心跳超时清理，否则僵尸连接会把内存撑爆。

## 请详细说明微信扫码登录的完整流程和背后发生的原理
**核心思路**

手机端已登录态，PC 端借助二维码完成身份传递，本质是**已认证设备为新设备授权**。

**完整流程**

**第一步：PC 端生成二维码**

```
PC打开登录页 → 请求服务端生成 ticket
服务端：ticket = UUID，存 Redis，TTL 120s，状态=待扫描
返回二维码（内容是携带ticket的URL）
PC 端轮询或 SSE 监听 ticket 状态
```

**第二步：手机扫码**

```
微信扫描二维码 → 解析出 ticket URL
→ 请求服务端：上报 ticket + 手机登录态 token
服务端：验证手机 token 有效
→ ticket 状态改为"已扫描，待确认"
→ PC 端轮询拿到状态变更，展示"已扫描，请在手机确认"
```

**第三步：手机确认**

```
用户点击"确认登录"
→ 手机上报 ticket + 用户授权
服务端：
  生成 PC 端 session token
  ticket 状态改为"已确认"
  ticket 绑定 PC session
```

**第四步：PC 端完成登录**

```
PC 轮询发现状态=已确认
→ 用 ticket 换取 PC session token
→ 登录成功，ticket 作废
```

**状态机**

```
待扫描 → 已扫描 → 已确认 → 已使用/已过期
```

**PC 端如何感知状态变化**

三种方案：

**短轮询**：每2秒请求一次，简单但浪费

```go
// 前端
setInterval(() => fetch('/check?ticket=xxx'), 2000)
```

**长轮询**：服务端 hold 住请求直到状态变化或超时，减少无效请求

**SSE**：服务端主动推，更优雅

```go
w.Header().Set("Content-Type", "text/event-stream")
// 状态变化时推送
fmt.Fprintf(w, "data: {\"status\":\"confirmed\"}\n\n")
flusher.Flush()
```

微信实际用的是长轮询，不是 WebSocket。

**安全关键点**

- ticket 一次性，用完即废
- ticket 有效期短（120s），过期重新生成
- 手机确认步骤防止二维码被恶意截图后登录
- HTTPS 防止 ticket 被中间人截获
- ticket 和 PC session 绑定，防止 ticket 被其他设备使用

**Redis 数据结构**

```
key: qr:ticket:{uuid}
value: {status, uid, pc_session, create_time}
TTL: 120s
```

踩过的坑：早期实现忘记让 ticket 在换取 session 后立即失效，同一个 ticket 可以换多次 session——要确保换取操作原子性，用 Redis lua 脚本做 check-and-delete。

## 在微服务架构中，服务发现和负载均衡是如何实现的？
**服务发现**

解决的问题：服务实例动态增减，调用方如何知道当前有哪些可用节点。

**两种模式**

**客户端发现**：调用方自己查注册中心，自己做负载均衡

```
服务A → 查 Consul/Etcd → 拿到实例列表 → 自己选一个
```

**服务端发现**：调用方只请求 LB，LB 去查注册中心

```
服务A → Nginx/Envoy → 注册中心 → 转发
```

我们用的是客户端发现，配合 gRPC + Etcd。

**注册与发现流程**

```
服务启动 → 注册到 Etcd（key=/services/order/实例ID, value=IP:Port）
          → 设置 TTL，定期续约心跳
服务下线 → 主动注销 or TTL 到期自动删除

调用方   → Watch /services/order 前缀
          → 实例变化时实时更新本地缓存
```

**负载均衡策略**

```go
// 常见策略
轮询（Round Robin）      // 均匀分配，简单
加权轮询                 // 按实例配置权重
最少连接                 // 选当前连接数最少的
一致性哈希              // 同一请求打同一节点，适合有状态服务
随机                    // 简单场景够用
```

gRPC 内置了轮询，自定义策略实现 `balancer.Builder` 接口即可。

**健康检查**

光靠心跳不够，实例可能假活（进程在但服务异常）：

```
主动探测：注册中心定期 HTTP/TCP 探活
被动探测：调用失败率超阈值，熔断器介入
```

我们结合两者：Etcd 心跳 + 客户端调用失败自动摘除节点，摘除后定期重试恢复。

**结合熔断限流**

```
调用链：服务A → 本地实例列表 → 熔断器判断 → 负载均衡选节点 → 发请求
```

熔断器（我们用 go-resilience）在负载均衡之前，避免把请求打到已知故障节点。

**踩过的坑**

Etcd 续约心跳间隔设太长，实例宕机后 TTL 还没到期，流量继续打过去报错——心跳间隔和 TTL 要合理配比，我们用 TTL=15s，心跳=5s，宕机最多15秒才被摘除，业务侧要有重试兜底。

## 服务注册中心（如Nacos,Consul）是如何工作的？服务实例如何注册和保活（如通过心跳机制）？
**注册中心核心职责**

服务实例的"电话簿"——记录谁在线、在哪里、健不健康。

**注册流程**

```
服务启动 → 发送注册请求（IP、Port、服务名、元数据）
→ 注册中心写入存储
→ 通知订阅该服务的调用方
```

**保活机制**

**心跳模式（Consul / Nacos默认）**

```
实例定期发心跳 → 注册中心更新最后存活时间
超过 TTL 未收到心跳 → 标记为不健康 → 从可用列表摘除
```

```go
// 客户端心跳伪代码
ticker := time.NewTicker(5 * time.Second)
for range ticker.C {
    err := registry.Heartbeat(instanceID)
    if err != nil {
        // 重新注册
        registry.Register(instance)
    }
}
```

**租约模式（Etcd）**

```
注册时创建 Lease（TTL=15s）
key 绑定到 Lease
客户端定期 KeepAlive 续约
Lease 过期 → 绑定的 key 自动删除
```

Etcd 租约比心跳更原子，key 和存活状态强绑定。

**Nacos vs Consul 对比**

||Nacos|Consul|
|---|---|---|
|一致性协议|AP（临时实例）/ CP（持久实例）|Raft（CP）|
|健康检查|客户端心跳|客户端心跳 + 服务端主动探测|
|配置中心|内置|需要额外方案|
|生态|Java/Spring Cloud 友好|云原生/多语言友好|

Nacos 临时实例默认 AP，注册中心宕机部分节点时仍可用但可能读到旧数据。Consul 强 CP，宕机超半数节点则不可用但数据一致。

**服务发现推送机制**

调用方如何感知实例变化：

**推模式**：注册中心主动推变更，实时性好

- Nacos：长连接推送
- Etcd：Watch 机制

**拉模式**：调用方定期拉取，简单但有延迟

- Consul：定期轮询或 blocking query

**脑裂问题**

网络分区时注册中心节点互相认为对方挂了：

- Consul Raft 要求多数派，少数派节点拒绝服务
- Nacos AP 模式允许分区继续服务，合并时以最新版本为准

**踩过的坑**

心跳超时设置不合理，网络抖动导致实例被误摘除，流量瞬间集中到剩余节点打挂——后来加了摘除保护：单次摘除比例超过 20% 时触发告警不自动摘除，人工介入。Nacos 有内置的自我保护模式处理这个问题。

## 讲一下Agent中的“长短期记忆”
**为什么需要长短期记忆**

LLM 本身无状态，每次调用独立。Agent 要完成多步任务、跨会话记住用户，必须自己管理记忆。

**短期记忆**

本质是 **context window 内的对话历史**：

```python
messages = [
    {"role": "system", "content": "你是xxx助手"},
    {"role": "user", "content": "我叫张三"},
    {"role": "assistant", "content": "好的张三"},
    {"role": "user", "content": "我叫什么"},  # 能回答，在窗口内
]
```

问题是 context 有长度限制，对话长了要做截断或压缩：

- **滑动窗口**：只保留最近 N 轮
- **摘要压缩**：用 LLM 把早期对话压缩成摘要塞回 context
- **重要信息提取**：只保留关键事实，丢弃闲聊

**长期记忆**

跨会话持久化，存在外部存储：

```
用户说"我对Go比较熟" 
→ 提取实体：用户偏好=Go语言
→ 写入长期记忆存储
→ 下次会话召回注入 context
```

存储方案：

- 结构化事实 → 直接存 DB（用户画像、偏好）
- 非结构化内容 → 向量数据库（历史对话、文档）

**召回机制**

长期记忆不能全量塞进 context，要按需检索：

```
当前输入 → embedding → 向量检索相关记忆
→ 排序筛选 → 注入 context
```

```python
# 伪代码
relevant_memories = vector_store.search(
    query=current_input,
    top_k=5,
    filter={"user_id": uid}
)
context = system_prompt + relevant_memories + recent_history
```

**两者结合的实践**

```
长期记忆：用户基本信息、偏好、历史结论（持久化）
短期记忆：当前任务上下文、最近对话（会话级）
工作记忆：当前步骤的中间结果（单次推理内）
```

**踩过的坑**

- 记忆召回不准：embedding 模型和业务语义不匹配，检索出不相关记忆反而干扰模型——换了领域适配的 embedding 模型改善明显
- 记忆污染：用户说的错误信息被当成事实存入长期记忆，后续一直用错——写入前要做置信度过滤，不确定的信息不持久化
- context 拼装顺序影响效果：长期记忆放太前容易被遗忘，一般放在 system prompt 紧接着的位置效果最好

## 什么样的信息应该放在长期记忆，什么样的信息放在短期记忆？
**核心判断标准**

> 这条信息在下一次会话还有用吗？跨任务还有价值吗？

有 → 长期记忆，没有 → 短期记忆。

**短期记忆放什么**

当前任务的上下文，会话结束就没价值的：

- 当前对话的问答历史
- 本次任务的中间推理步骤
- 临时的用户指令（"这次回答简短一点"）
- 正在处理的文档内容

**长期记忆放什么**

跨会话仍然有价值的稳定信息：

```
用户维度：
- 基本信息（姓名、职业、语言偏好）
- 技术栈偏好（"用户熟悉Go，不熟悉Rust"）
- 沟通风格（"喜欢简洁回答，不要废话"）

任务维度：
- 已完成任务的结论和决策
- 用户明确表达过的长期目标
- 项目背景、团队约定
```

**判断边界的几个原则**

**时效性**：

```
"今天天气怎么样" → 短期，明天就过期
"我不喜欢冗长的回答" → 长期，每次都适用
```

**普适性**：

```
"这个函数帮我用递归实现" → 短期，仅限本次
"用户偏好迭代而非递归" → 长期，影响后续所有代码建议
```

**确定性**：

```
用户猜测性的话 → 不存长期（"我可能会用Python"）
用户明确表达的 → 存长期（"我们团队统一用Python"）
```

**实践中的模糊地带**

有些信息介于两者之间，我们的处理方式：

- 设置**衰减机制**：长期记忆加时间戳，超过30天未被引用的降权或清理
- **用户确认**：不确定是否值得长期存储时，直接问用户
- **分级存储**：高置信长期记忆 vs 低置信待验证记忆，召回时区分使用

踩过的坑：早期什么都往长期记忆塞，导致召回时噪音太多，反而干扰模型判断。后来加了写入门槛——只有明确的事实性陈述才写入，推测性内容一律不存。

## 当对话轮数很多，上下文窗口不足时，有哪些处理策略？（如截断、压缩）
**核心问题**

context 有限，但有效信息可能分散在很长的对话历史里，简单截断会丢关键信息。

**几种策略**

**1. 滑动窗口截断**

最简单，只保留最近 N 轮：

```python
messages = messages[-20:]  # 只保留最近20轮
```

优点：实现零成本 缺点：早期重要信息直接丢失，用户说过的偏好、背景全忘了

**2. 摘要压缩**

定期把早期对话压缩成摘要：

```python
# 超过阈值时触发
if token_count > threshold:
    summary = llm.summarize(old_messages)
    messages = [{"role": "system", "content": f"历史摘要:{summary}"}] 
               + recent_messages
```

保留语义，丢弃细节。问题是摘要本身有信息损失，且摘要质量依赖模型能力。

**3. 重要信息提取**

不做摘要，主动提取关键事实存入长期记忆：

```
对话中识别出 → 用户偏好、决策结论、任务背景
→ 结构化存储
→ 每次会话开始时召回注入 system prompt
```

比摘要更精准，但需要额外的提取步骤。

**4. 向量检索相关历史**

不是把所有历史塞进去，而是按当前问题检索最相关的片段：

```python
relevant_history = vector_store.search(
    query=current_input,
    top_k=5
)
```

适合历史很长但当前问题只需要部分上下文的场景。

**5. 分层管理**

几种策略组合使用：

```
最近5轮    → 完整保留（短期记忆）
5~20轮     → 摘要压缩
20轮以上   → 关键事实提取到长期记忆
超长文档   → 向量化，按需检索
```

**实践中的选择**

- 简单问答机器人 → 滑动窗口够用
- 需要记住用户偏好 → 关键信息提取 + 长期记忆
- 长文档分析任务 → 向量检索
- 复杂多步 Agent → 分层管理

**踩过的坑**

摘要压缩时机不对——每轮都触发摘要开销太大，攒够一定轮数再压缩更合理。另外摘要模型用的太小，摘要质量差，关键细节丢失导致后续回答出错，后来摘要也用主力模型来做。还有就是压缩后 token 确实省了，但模型推理质量明显下降——context 压缩是有损的，要在成本和效果之间找平衡点。

## 如果要进行记忆压缩，通常有哪些方法？
上面刚聊过一部分，补充展开更细的维度。

**压缩方法分类**

**1. 抽取式压缩（Extractive）**

不改写，直接从原文挑重要句子保留：

```
原对话10句 → 选出3句关键句 → 拼接作为压缩结果
```

优点：无信息失真，原话保留 缺点：压缩率有限，上下文可能不连贯

实现方式：TF-IDF 打分、或让模型给每句话打重要性分数。

**2. 生成式压缩（Abstractive）**

让 LLM 重新表述，生成更紧凑的摘要：

```python
prompt = """
对话历史：{history}
请提取以下信息：
1. 用户的核心目标
2. 已确认的关键事实
3. 未解决的问题
输出JSON格式
"""
```

压缩率高，但有幻觉风险——模型可能改变原意。

**3. 结构化提取**

不做自然语言摘要，直接抽取实体和关系：

```json
{
  "user_profile": {"name": "张三", "role": "后端工程师"},
  "decisions": ["使用Go语言", "数据库选PostgreSQL"],
  "pending": ["部署方案待确定"],
  "preferences": ["回答要简洁"]
}
```

召回时直接用结构化数据拼 prompt，比自然语言摘要更可控。

**4. 层级压缩**

对长对话做多级摘要：

```
每5轮 → 小摘要
每5个小摘要 → 中摘要  
中摘要积累 → 大摘要
```

类似文章的段落→章节→全书摘要，保留不同粒度的信息。

**5. 遗忘机制**

模拟人类记忆衰减，给每条记忆加权重：

```python
importance = base_score * decay_factor(time) * access_frequency
# 低于阈值的记忆降权或删除
```

最近访问、高频引用的记忆权重高，冷记忆自然淘汰。

**实践中怎么选**

```
对话轮数不多（<50轮）→ 生成式摘要够用
需要精确召回事实    → 结构化提取
超长会话（>100轮）  → 层级压缩 + 遗忘机制
用户偏好类信息      → 结构化提取写长期记忆，不做摘要
```

**踩过的坑**

生成式摘要最大的问题是**摘要本身消耗 token**，压缩一次可能花掉几百 token，频繁压缩反而不划算——要设合理触发阈值，不能每轮都压缩。另外结构化提取依赖 prompt 质量，字段定义不清晰时模型乱填，加了 JSON Schema 校验 + 失败重试才稳定下来。

## 了解过Agent的设计范式吗？有哪些?
**主流 Agent 设计范式**

**1. ReAct（Reasoning + Acting）**

最经典，交替进行推理和行动：

```
Thought: 我需要查用户订单
Action: query_db(user_id=123)
Observation: 返回订单列表
Thought: 需要进一步过滤
Action: filter(status=pending)
Observation: 3条待处理订单
Answer: 用户有3条待处理订单
```

优点：透明可解释，每步有据可查 缺点：步骤多时延迟高，中间某步出错容易雪崩

**2. Plan and Execute**

先规划所有步骤，再逐步执行：

```
Planning阶段：
  1. 查用户信息
  2. 查订单
  3. 汇总报告

Execute阶段：按计划逐步执行
```

适合任务明确、步骤固定的场景，比 ReAct 更稳定，但计划阶段对复杂任务容易规划不准。

**3. Reflection**

执行后自我反思，发现问题重试：

```
执行 → 输出结果 → 自我评估 
→ 发现问题 → 修正重试
```

常和其他范式组合，单独用很少。

**4. Multi-Agent**

多个专职 Agent 协作：

```
Orchestrator（主控）
  ├── ResearchAgent（信息收集）
  ├── CodeAgent（代码生成）
  └── ReviewAgent（结果审核）
```

两种拓扑：

- **Pipeline**：串行，上一个输出是下一个输入
- **协作网络**：Agent 间互相调用，更灵活但更难调试

**5. Memory-Augmented Agent**

上面聊过的长短期记忆体系，本质是增强 Agent 的状态管理能力，不是独立范式，常作为其他范式的补充。

**实践中怎么选**

```
单步任务、工具调用明确  → ReAct 够用
复杂多步、任务结构固定  → Plan and Execute
需要高质量输出          → 加 Reflection
任务需要并行、专业分工  → Multi-Agent
```

**我们实际用的**

做内部知识问答 Agent 用的是 ReAct + Memory：

- ReAct 控制推理和工具调用
- 每次会话结束提取关键信息写长期记忆
- 工具集包括：RAG检索、SQL查询、API调用

踩过的坑：ReAct 链路长时模型容易在中间步骤"跑偏"，比如本来要查订单，推理几步后开始查无关内容——加了步骤数上限 + 每步结果校验，超限直接截断返回已有结论。Multi-Agent 调试很痛苦，某个子 Agent 输出格式不对会静默传递错误，最后主控拿到的是垃圾数据，排查链路很长。

## 你设计的Agent是怎么实现ReAct模式的？详细讲讲
**整体架构**

```
用户输入 → Orchestrator → ReAct循环 → 工具执行 → 最终输出
```

**ReAct 核心循环**

```go
func (a *Agent) Run(ctx context.Context, input string) (string, error) {
    messages := a.buildInitialMessages(input)
    
    for step := 0; step < a.maxSteps; step++ {
        resp, err := a.llm.Chat(ctx, messages)
        
        // 解析模型输出
        action := parseAction(resp)
        
        // 纯文本回答，结束循环
        if action.Type == "finish" {
            return action.Answer, nil
        }
        
        // 执行工具
        observation, err := a.executeTool(ctx, action)
        
        // 把工具结果追加回消息
        messages = append(messages, 
            assistantMsg(resp),
            toolResultMsg(observation),
        )
    }
    return "超过最大步骤", nil
}
```

**Prompt 设计是核心**

```
System:
你是一个助手，可以使用以下工具：
- search_knowledge(query): 搜索知识库
- query_order(user_id): 查询订单
- calculate(expr): 计算

输出格式严格按照：
Thought: <推理过程>
Action: <工具名>
Action Input: <JSON参数>

或者任务完成时：
Thought: <推理>
Final Answer: <答案>
```

格式约束是稳定性的关键，模型乱输出格式会导致解析失败。

**工具注册与执行**

```go
type Tool interface {
    Name() string
    Description() string
    Execute(ctx context.Context, input string) (string, error)
}

// 注册
agent.RegisterTool(&SearchTool{vectorStore: vs})
agent.RegisterTool(&OrderQueryTool{db: db})

// 执行时按名字路由
func (a *Agent) executeTool(ctx context.Context, action Action) (string, error) {
    tool, ok := a.tools[action.ToolName]
    if !ok {
        return "工具不存在", nil  // 返回给模型，让它修正
    }
    return tool.Execute(ctx, action.Input)
}
```

**解析模型输出**

```go
func parseAction(output string) Action {
    // 正则提取 Action 和 Action Input
    if strings.Contains(output, "Final Answer:") {
        return Action{Type: "finish", Answer: extractFinalAnswer(output)}
    }
    toolName := extractAction(output)
    toolInput := extractActionInput(output)
    return Action{Type: "tool", ToolName: toolName, Input: toolInput}
}
```

解析这块要做容错，模型输出格式偶尔不规范，加了模糊匹配兜底。

**稳定性保障**

几个实际加的机制：

**步骤上限 + 强制终止**

```go
if step >= maxSteps {
    // 把已有 observation 喂给模型，强制要它总结
    return a.forceFinish(ctx, messages)
}
```

**工具执行超时**

```go
toolCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
result, err := tool.Execute(toolCtx, input)
```

**循环检测**

```go
// 连续两步调用相同工具+相同参数，判定为循环
if isDuplicateAction(currentAction, lastAction) {
    messages = append(messages, systemMsg("你已经执行过这个操作，请换个思路"))
}
```

**踩过的坑**

模型输出 JSON 参数时经常带单引号或多余字段，硬解析直接崩——改成先尝试严格解析，失败了让模型重新输出一次，最多重试2次。

工具返回内容太长时直接塞回 context 会撑爆窗口，加了工具输出截断：超过 500 token 的结果先摘要再返回给模型。

最难排查的是**模型推理跑偏**：用户问A，模型推理几步后开始解决B，中间没有明显错误但最终答非所问。后来在每步加了一个轻量校验——把原始问题和当前步骤一起喂给模型问"是否还在解决原问题"，偏离时强制拉回。这个额外开销不小，只在高价值场景开启。


## 手撕：实现一个并发任务处理器：给定一个包含100个任务ID的列表，要求控制最大并发数为3，模拟并
直接给代码，这是个经典的 worker pool 模式：

**方案一：channel 做信号量**

```go
package main

import (
    "fmt"
    "sync"
    "time"
)

func main() {
    taskIDs := make([]int, 100)
    for i := range taskIDs {
        taskIDs[i] = i + 1
    }

    sem := make(chan struct{}, 3) // 控制并发数
    var wg sync.WaitGroup

    for _, id := range taskIDs {
        wg.Add(1)
        sem <- struct{}{} // 占一个槽，满了阻塞

        go func(taskID int) {
            defer wg.Done()
            defer func() { <-sem }() // 释放槽

            processTask(taskID)
        }(id)
    }

    wg.Wait()
    fmt.Println("all done")
}

func processTask(id int) {
    fmt.Printf("processing task %d\n", id)
    time.Sleep(100 * time.Millisecond) // 模拟外部调用
}
```

**方案二：固定 worker pool（更适合生产）**

```go
func workerPool(taskIDs []int, concurrency int) {
    taskCh := make(chan int, len(taskIDs))
    var wg sync.WaitGroup

    // 启动固定数量 worker
    for i := 0; i < concurrency; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for id := range taskCh {
                processTask(id)
            }
        }()
    }

    // 投递任务
    for _, id := range taskIDs {
        taskCh <- id
    }
    close(taskCh) // 关闭后 worker 自然退出

    wg.Wait()
}
```

**两种方案对比**

||信号量|Worker Pool|
|---|---|---|
|goroutine 数量|最多100个|固定3个|
|实现复杂度|简单|稍复杂|
|适合场景|任务量不大|长期运行、任务量大|

**生产环境补充**

```go
// 加错误收集
type Result struct {
    ID  int
    Err error
}

errCh := make(chan Result, len(taskIDs))

go func(taskID int) {
    defer wg.Done()
    defer func() { <-sem }()
    
    if err := processTask(taskID); err != nil {
        errCh <- Result{ID: taskID, Err: err}
    }
}(id)
```

实际项目里我更倾向 worker pool 方案——goroutine 数量可控，不会因为任务量暴增把内存撑爆。信号量方案简单但本质上还是创建了 N 个 goroutine，任务量大时内存开销不可忽视。
