This commit is contained in:
2026-09-22 15:42:25 +08:00
commit 8e86665096
60 changed files with 22339 additions and 0 deletions
@@ -0,0 +1,268 @@
# 阶段七 · 小点 1:Agent 工程化最佳实践
> 所属:阶段七 工程化、部署与运维
> 定位:最大坑——Demo 跑得很好,上线生产大量异常。**Agent 本质是状态机服务,不是简单接口。** 这一讲给出生产落地必须的四件事:四层架构、状态持久化、容错降级、成本优化。
## 精简大纲
1. 四层架构与插件化工具注册中心(含跨层关注点模块)
2. 状态持久化(临时 vs 持久)
3. 容错降级:重试 / 降级 / 熔断
4. 配置管理与成本优化(Token 统计 / 语义缓存 / 步骤限流)
## 学习内容详情
> 核心认知:把 Agent 当「一次请求一个答案的接口」是致命的——它有状态、会循环、调用外部资源,必须按**服务**工程化。
### 1. 四层架构(每层可独立测试替换)
```mermaid
graph LR
A[接入层<br/>FastAPI路由/鉴权/限流] --> B[编排层<br/>LangGraph图]
B --> C[工具服务层<br/>各业务工具]
C --> D[存储层<br/>Redis/向量库/PG/MinIO]
```
1. **API 接入层**(FastAPI 路由 / 鉴权 / 限流)
2. **Agent 编排层**(LangGraph 图)
3. **工具服务层**(各业务工具)
4. **存储层**(Redis / 向量库 / PG / MinIO)
- **MinIO(对象存储)**:放**原始文档、产物文件(图表 / 报告)、大附件**——这类二进制大文件不进 PG 也不进向量库,向量库里只存切片 embedding + MinIO 文件地址。本项目 `app/config.py` 已预置 `minio_endpoint / minio_access_key / minio_secret_key / minio_bucket` 四项配置,启动时从 `.env` 读取即可接上。
- **价值**:换模型不动工具、加工具不改编排——各层依赖单向、可独立替换与测试。
#### 插件化工具注册中心
- 工具自注册进注册表,编排层按权限动态取用——**新增工具零改动核心代码**(开闭原则落地)。
```python
# 插件化注册中心: 工具自注册, 编排层按需取用
class ToolRegistry:
_tools = {}
@classmethod
def register(cls, name, fn, permission="user"):
"""工具自注册: 新增工具只需调用一行, 不动核心编排代码"""
cls._tools[name] = {"fn": fn, "permission": permission}
@classmethod
def get(cls, user_role, name):
"""编排层按权限动态取用: 权限不够取不到, 天然的门禁"""
tool = cls._tools.get(name)
if tool is None:
return None
if role_level(user_role) < role_level(tool["permission"]): # role_level 为示意函数(比较权限级别), 需自行定义
return None # 权限拦截
return tool["fn"]
# 新增业务工具 = 在模块加载处注册一行, 核心代码零改动
ToolRegistry.register("search", search_impl, permission="user")
ToolRegistry.register("transfer", transfer_impl, permission="admin") # 高危需admin
```
> ⏳ **短期可不深究**:上面 `permission="user"/"admin"` + `role_level()` 比较只是"示意版"权限门禁——真实生产的权限体系(RBAC 角色、细粒度字段级/动作级权限、密钥脱敏)复杂度不小,第一遍只要理解"注册表天然能做门禁"即可,具体权限模型后续按需再补。
#### 跨层关注点模块:schemas / config / errors / dependencies
四层之外还有一组**横切模块**——不属于任何一层,却被所有层引用,且**不依赖任何业务层**(依赖方向永远指向内,谁都能 import 它们、它们不 import 谁)。本项目 `app/` 的实际落地正是这个结构:`api/ agents/ tools/ storage/` 四个业务层目录 + 下面四个横切模块。
| 模块 | 职责 | 本项目落地 |
|-|-|-|
| `schemas/` | 跨层共享的**类型契约** | 请求 / 响应模型、SSE 协议模型(`event: token / done / error`)——api 层出口与 agents 层内部共用同一套模型,序列化格式永不漂移 |
| `config.py` | 全应用唯一配置入口 | pydantic-settings 读 `.env`(模型密钥 / PG / Redis / MinIO / Qdrant),业务模块禁止散落 `os.getenv` |
| `errors.py` | 统一错误语义 | `AppError` 基类 + `biz_code` 业务码 + 全局异常处理器——任何层 `raise AppError`,api 层出口自动转成统一 JSON(`code / message / trace_id`) |
| `dependencies.py` | Depends 注入 | `get_settings`(配置单例)、`get_trace_id`(链路 ID)——需要什么就声明注入什么,不在路由里手动 new |
```python
# app/errors.py —— 任何层 raise, api 层全局处理器统一转 JSON
class AppError(Exception):
biz_code = BizCode.INTERNAL_ERROR # 业务码: 前端据此提示
http_status = 500
# app/dependencies.py —— 横切能力一律 Depends 注入
async def get_trace_id(request: Request) -> str:
return request.state.trace_id # 中间件预置, 全链路同一 ID
```
> 判断一个模块该不该进这组横切层的标准:**被所有层引用、不依赖任何业务层**。若某模块开始 import `agents/` 或 `tools/`,它就不属于横切层,应下沉到对应业务层。
### 2. 状态持久化
- **临时状态** vs **持久化状态**:
- 临时 = 单次请求内中间变量(随请求销毁);
- 持久 = 跨请求必须存活的(会话历史 / 任务进度 / 记忆)。
- **判断标准:进程重启丢了会不会坏事——会就落库。**
```python
def decide_persist(state: dict) -> bool:
"""判断某状态是否该持久化: 重启丢了会不会坏事? 会就落库"""
PERSIST_KEYS = {"conversation", "task_progress", "memory"} # 跨请求存活
return any(k in state for k in PERSIST_KEYS) # True → 应落 Redis/PG
```
### 3. 容错降级(三大保护机制)
#### 3.1 三者的关系
```mermaid
graph TD
A[三大保护] --> B[重试<br/>救偶发抖动]
A --> C[降级<br/>依赖挂了给兜底]
A --> D[熔断<br/>防持续故障雪崩]
```
- **超时重试**:救**偶发抖动**。
- **降级(Degradation):** 依赖故障时返回兜底而非报错:知识库挂了 →「暂时无法查询,已记录您的问题」;搜索超时 → 跳过搜索直接答。原则:**核心链路(回答用户)永不为辅助能力的故障而中断**。
- **熔断器(Circuit Breaker)**:连续 N 次失败后**快速失败**一段时间(不再真的调用),避免雪崩——故障工具拖死所有请求线程;半开状态试探恢复。
> 与「重试」方向相反:重试救偶发抖动,熔断防持续故障——别对持续故障的工具傻傻重试,那会把它越拖越死。
```python
class CircuitBreaker:
"""极简熔断器: 连续失败N次 → 开(快速失败) → 半开试探 → 恢复/保持"""
def __init__(self, fail_threshold=3, cooldown=10.0):
self.fails, self.state, self.opened_at = 0, "closed", None
self.fail_threshold, self.cooldown = fail_threshold, cooldown
def allow(self) -> bool:
if self.state == "closed":
return True
if self.state == "open" and time.time() - self.opened_at > self.cooldown:
self.state = "half_open" # 半开试探, 只放一个请求试水
return True
return False # open / 半开失败态: 快速失败不真调
def record(self, ok: bool):
if ok:
self.state, self.fails = "closed", 0 # 成功 → 关闭
else:
self.fails += 1
if self.state == "half_open" or self.fails >= self.fail_threshold:
self.state = "open"; self.opened_at = time.time() # 打开
```
> ⏳ **短期可不深究**:熔断器的成败计数与窗口(`fail_threshold`/`cooldown`/`half_open`)这个极简实现只是原理示意——生产里直接引 resilient / tenacity 等现成库(它们往往用"时间窗内的失败率"而非简单计数),第一遍知道"连续失败就快速失败、半开试探、成功恢复"这件事即可。
```python
def call_with_cb(cb: CircuitBreaker, fn, fallback):
"""熔断 + 降级合一: 熔断开→不真调, 失败→给兜底而非报错"""
if not cb.allow():
return fallback("服务临时不可用") # 熔断: 快速失败 + 降级兜底
try:
r = fn(); cb.record(True); return r
except Exception:
cb.record(False)
return fallback("服务异常, 已记录了您的问题") # 降级(降级≠报错)
```
> 🔬 **深度理解 · 重试 / 降级 / 熔断三件套**:
> 1. **本质**:三者处理"外部依赖故障"的同一问题,但分工不同——**重试假设故障是短时抖动(值得再试)**,**熔断假设故障是持续恶化的(先别试)**,**降级则承认失败、但给主反馈一个兜底而非硬报错**。
> 2. **机制**:重试要看"操作是否幂等 + 退避策略"(指数退避 + 随机抖动 jitter 避免恢复瞬间惊群);熔断用失败率/失败次数驱动 `closed → open → half-open` 三态迁移,open 期间不真调、half-open 放少量请求试探恢复;降级是"旁路",在超时或熔断触发时返回预先设计的兜底路径。
> 3. **为什么重要**:没有重试,一次瞬间抖动就误伤成功率;只有重试没有熔断,持续故障的工具会被所有请求反复打爆以致雪崩;没有降级,任何一个辅助工具(搜索/知识库/百科)挂掉都会终结"给出回答"这条主链路。三者按"抖动→持续→兜底"组合,才能在保可用性的同时不加重故障。
> 4. **易错点**:① 对**非幂等**操作盲目重试,造成重复副作用(转账扣两次);② 重试时不退避,故障刚恢复就被并发冲垮;③ 把熔断与降级混为一谈——熔断管"我不再调用它",降级管"我最终给用户什么";④ **别对持续故障的工具无限重试**,那会把它越拖越死,熔断正是为限制这一点而生(呼应上一行正文)。
### 4. 配置管理与成本优化
- **配置管理**:开发 / 测试 / 生产多环境隔离;密钥不入库不入 git;配置变更可审计(Apollo / Nacos 或最小版 `.env` + 启动参数)。
| 项 | 手段 |
|-|-|
| 多环境隔离 | 按环境读不同配置 / `.env` |
| 密钥安全 | 不入库不入 git,走 Secret / 环境变量 |
| 变更可审计 | 配置版本化,变更留痕 |
- **Token 消耗统计**:每次 LLM 调用记录 usage(`prompt_tokens` / `completion_tokens`)按会话 / 用户 / 工具聚合——**不知道钱花在哪,就谈不上优化**。
```python
def log_usage(session: str, user: str, prompt_tk: int, completion_tk: int):
"""Token 统计: 按会话/用户聚合, 找不到钱在哪就没法优化"""
entry = {"session": session, "user": user,
"prompt_tokens": prompt_tk, "completion_tokens": completion_tk}
agg = AGG[user] = AGG.get(user, {"prompt": 0, "completion": 0})
agg["prompt"] += prompt_tk; agg["completion"] += completion_tk
return entry
```
- **模型缓存(语义缓存)**:相同(或语义相近)问题直接返回缓存答案,省完整一轮 LLM 调用。**注意 TTL 与业务容忍度:时效性问题(天气 / 库存)不可缓存。**
```python
def semantic_cache(key: str, ttl: int, cache) -> callable:
"""语义缓存装饰: 命中即复用上次答案, 省一轮 LLM 调用。
一定要配 TTL —— 时效性问题(天气/库存)不可缓存, 应设 ttl=0。"""
def deco(fn):
def wrapper(*a, **kw):
val = cache.get(key, ttl)
if val is not None:
return val # 命中 → 返回缓存
val = fn(*a, **kw)
if ttl > 0:
cache.set(key, val, ttl) # 写入带 TTL
return val
return wrapper
return deco
```
> 🔬 **深度理解 · 语义缓存**:
> 1. **本质**:用 LLM embedding 把"问题"映射成高维向量,命中"语义相近"的旧答案时直接返回,省掉一整轮"prompt 编排 + LLM 在线推理(可能还带工具调用)",是对成本和延迟的双重复用。
> 2. **机制**:它不是完全匹配的 KV,而是存 `(问题向量 → 答案)`:查询时对候选做向量相似度匹配(常用余弦相似度),超过阈值即命中共用旧答案;未命中才真正调用 LLM,再写回缓存并带 TTL。命中后通常还要校验内容的版本 / 时效属性。
> 3. **为什么重要**:LLM 生成的 token 贵且慢,语义缓存把"算过一次的答案"复用,能直接降成本、降 P95;**边界**在于它绕过了真实推理——对时效性与个性化场景(天气、库存、带用户上下文的回答)会返回"看起来合理但已过期/不适用"的结果,所以必须有 TTL 与业务容忍度约束。
> 4. **易错点**:① 只配缓存不配 TTL/版本失效,缓存悄悄"撒谎"给过期答案;② 相似度阈值设太松(把"明天会下雨吗"误命中成"后天会下雨吗")返回错误答案,设太紧又基本不命中;③ key 忘了带会话/用户上下文,导致跨用户串结果;④ 不与下文 Token 统计联动,不知道缓存到底省没省钱、阈值合不合理。
- **步骤限流(per-user rate limit)**:按用户限制 Agent 执行步数 / 轮次——**防恶意用户构造「无限循环问题」烧钱**,成本侧的最后闸门。
```python
def step_budget(user: str, budget: dict, limit=20) -> bool:
"""步骤限流: 每用户每会话限制执行轮次, 超限强制停止"""
used = budget.get(user, 0)
if used >= limit:
print(f"[限流] 用户 {user} 本轮次已达上限 {limit}, 强制停止")
return False
budget[user] = used + 1
return True
```
> 🔬 **深度理解 · 步骤限流(rate limit / step budget)**:
> 1. **本质**:把"单个用户/会话可消耗的计算额度"显式设上限,是**成本与安全侧的最后闸门**——因为 Agent 是循环的,坏 prompt 一旦进来就可能在一次请求内在多个"LLM 调用 + 工具执行"间无限自旋,把预算烧光。
> 2. **机制**:通常按 user+session 各维护一个计数器,Agent 每执行一步就 `used+1`,超过阈值即强制终止本轮并给出"本轮已用完"的结算信息;进阶做法用**令牌桶(token bucket)**按时间窗口放行,而非常用的"会话累计"粗粒度判断。
> 3. **为什么重要**:语义缓存是"省"、Token 统计是"看",而步骤限流是"断"——它能真正防住恶意/病态请求耗尽预算;**边界**在于它保护的是"额度",不是"瞬时高并发",两者目标不同要分开设计(并发保护靠限流器/队列,额度保护靠步数上限)。
> 4. **易错点**:① 只限 LLM 调用次数、不限工具执行,攻击者转向在工具循环里烧资源;② 粒度选错——全局计数误伤正常用户、仅按用户又漏掉多开刷量,通常用 user+session;③ 超限时直接裸报错而不是优雅降级,应返回"本轮使用已达上限"并可引导续用。
## 本节自检
- [ ] 能说清四层架构与各自职责
- [ ] 能实现熔断 + 降级 + Token 统计 + 步骤限流的工程化骨架
- [ ] 能判断一个状态该不该持久化(重启丢了会不会坏)
- [ ] 能说清重试 / 降级 / 熔断三者的适用场景
---
## 本节常见面试题(深度解析)
> 针对本节核心知识的面试高频点,配合"本质+机制"式理解,能让你答得既有深度又有广度。
### 面试题 1:重试(Retry)、降级(Degradation)、熔断(Circuit Breaker)三者的区别?怎样配合使用?
- **面试官想考察**:是真理解了三种容错手段各自解决什么问题,还是只会背名词;是否踩过"无限重试把依赖打爆"这类坑。
- **专业作答(含深度)**:
1. **分本质**:重试假设故障是短时抖动、值得再试;熔断假设故障持续恶化、先别试;降级承认失败但保住主链路,给兜底而不报错。
2. **分机制**:重试要配指数退避+jitter 且只对幂等操作重试;熔断在 closed/open/half-open 三态迁移,控制"要不要真去调用";降级由超时或熔断触发,返回预设兜底路径。
3. **配合链路**:偶发抖动→重试;重试仍失败→计入熔断器;熔断打开→不再真调、转快速失败→触发降级兜底;冷却后 half-open 试水,成功后熔断关闭。
4. **边界**:三者不可互相替代——无重试误伤成功率,无熔断会雪崩,无降级则任一辅助工具故障都会终结主回答链路。
- **加分亮点 / 深度追问**:可主动补一句"持续故障的工具绝不要无限重试,那会把它越拖越死,熔断正是为了限住它";被追问"非幂等操作(如转账)如何安全重试"时,答"引入幂等键或先查后写,避免重复扣款"。
### 面试题 2:语义缓存该怎么做?它主要的坑在哪?
- **面试官想考察**:是否有缓存的实际工程经验,能否识别"缓存命中"与"业务正确性"之间的冲突。
- **专业作答(含深度)**:
1. **做法**:embedding 把问题向量化→与缓存候选做相似度匹配→超阈值即命中、直接复用答案;未命中调用 LLM 后再按 `(相似问题→答案)` 写回并带 TTL。
2. **关键权衡**:相似度阈值决定"命中率 vs 正确率",需靠评测调参;TTL 决定时效性,天气/库存这类必须 `ttl=0` 不缓存。
3. **与 Token 统计配合**:先统计同类问题重复度和高昂成本,判断缓存值不值;再用"省了多少 token"验证阈值与命中质量是否合理。
- **加分亮点 / 深度追问**:可提"key 要带版本号/会话上下文,避免跨用户串答案";被追问"语义相近但答案必须不同(含用户上下文)"时,答"对这类请求强制 miss 或把上下文纳入向量,从根上避免误命中"。
### 面试题 3:Agent 服务为什么要做"步骤限流"?限流键怎么选?
- **面试官想考察**:是否意识到 Agent 相对普通接口的独特风险(循环、多步调用),以及成本与安全的工程素养。
- **专业作答(含深度)**:
1. **为什么**:Agent 是循环的,一次请求可触发多次 LLM+工具调用;恶意/病态请求能构造"无限循环问题"把预算烧光,步骤限流是成本安全的最后闸门。
2. **与缓存/统计的分工**:缓存是"省"、Token 统计是"看"、步骤限流是"断",三者职责清晰缺一不可。
3. **键的选择**:常取 user+session 双粒度——纯全局会误伤正常用户、纯按用户又拦不住多开;超限用"本轮已用完可续用"的优雅降级而非裸报错。
- **加分亮点 / 深度追问**:可主动区分"额度保护(step budget)"与"瞬时并发保护(rate limiting)"是两回事要分开设计;被追问时再展开令牌桶(token bucket)与固定窗口计数在限流里的区别。
@@ -0,0 +1,187 @@
# 阶段七 · 小点 2:部署与伸缩
> 所属:阶段七 工程化、部署与运维
> 定位:怎么把 Agent 从「我本机能跑」一步步扩展到「团队共享」再到「生产可扩」。这一讲走一遍容器化 → 云原生 → 部署演进路线,并讲清任务队列与优雅停机两个关键机制。
## 精简大纲
1. 容器化:Docker / Compose 本地整套环境
2. 云原生:K8s 部署、HPA、任务队列、优雅停机
3. 部署演进路线(量力而行)
4. 边缘轻量方案
## 学习内容详情
> 核心认知:部署的宗旨是「量力而行」。内部场景多数停在 Compose / 单机 Docker 就够,**别为 3 个用户上 K8s 集群**。
### 1. 容器化(Compose 本地全家桶)
`docker-compose.yml` 一键拉起「应用 + worker + Redis(队列 broker / 会话缓存)+ PostgreSQL(Checkpointer)+ Qdrant(向量库)」。
```yaml
# docker-compose.yml (最小全家桶: 应用 + worker + 三个存储)
services:
api: # ① API 接入层
image: my-agent:1.0
command: uvicorn app.main:app --host 0.0.0.0 --port 8000
ports: ["8000:8000"] # 仅 api 对外暴露
depends_on: [redis, pg, qdrant]
environment:
REDIS_URL: redis://redis:6379
DATABASE_URL: postgres://agent:agent@pg/agent
worker: # ② 长任务 worker: 同镜像不同入口
image: my-agent:1.0
command: arq app.worker.main.WorkerSettings # 换 arq 入口即可
depends_on: [redis, pg, qdrant]
deploy:
replicas: 3 # 可水平加 worker 扩吞吐(普通 `docker compose` 会忽略 replicas, 需 swarm 或 --compatibility)
redis:
image: redis:7 # 队列 broker / 会话缓存
pg:
image: postgres:16 # Checkpointer 状态落库
qdrant:
image: qdrant/qdrant # 向量库
```
> ⏳ **短期可不深究**:上面 worker 服务里的 `deploy.replicas: 3` 在**普通 `docker compose` 下会被忽略**(只有 swarm 模式或加 `--compatibility` 才生效)——第一遍只要知道"worker 想水平扩展需要 swarm/真正的集群工具",不用纠结这个 yaml 字段在单机 compose 下的行为差异。
> 🔬 **深度理解 · 容器化与环境隔离**:
> 1. **本质**:把"应用 + 它运行所需的完整依赖"打包成一个可移植的标准镜像,从根本上把"环境差异"从部署问题里剥离——同一份镜像在本地、CI、与生产跑出的行为一致。
> 2. **机制**:Docker 借助 Linux 内核的 namespace(进程/网络/文件系统隔离)与 cgroup(CPU/内存限额)做轻量虚拟化;Compose 再用一条 yaml 把"应用 + worker + 存储"声明成服务编排,容器间靠内部 DNS 名互相访问,只把对外端口暴露出去。
> 3. **为什么重要**:Agent 服务依赖众多(模型密钥、PG、Redis、Qdrant、MinIO),不做环境隔离最容易犯"我本机能跑、别人不行"的系统性错误;容器化正好对应阶段七"状态机服务"的复杂度——依赖封闭后,搬家常驻环境都比裸进程简单。
> 4. **易错点**:① 只容器化却不挂数据卷(volume),容器重建即丢数据(这里 PG/Redis/Qdrant 必须持久化);② 用 `latest` 标签不锁版本,环境不可复现;③ 密钥写死在镜像里(应走环境变量 / Secret);④ 误以为"容器化 = 生产就绪",真正的隔离还有网络策略、资源限额与可观测三件事要补。
- **worker 与 API 同镜像不同入口**(command 换 arq 入口),生产省略 ports(不对外暴露)。
### 2. 云原生(K8s 生产部署)
```mermaid
graph LR
A[Deployment<br/>声明副本数] --> B[Service<br/>稳定访问入口]
B --> C[HPA<br/>自动扩缩容]
C --> D[按CPU/QPS<br/>增减 Pod]
```
#### 2.1 K8s 最小集
- **Deployment**(声明副本数)+ **Service**(稳定访问入口)+ **HPA**(自动扩缩容)。
- **Pod**:最小部署单元;需要本地模型时可「应用容器 + 模型 sidecar 容器」同 Pod 共享网络。
> ⏳ **短期可不深究**:这个「应用容器 + 模型 sidecar 容器」共处一个 Pod 的做法,是**需要在本地跑私有/离线模型**时才用到的偏冷场景——大多数调用云端 API 的 Agent 用不到,第一遍知道有 sidecar 这个模式即可。
- **HPA**:按指标(CPU / 自定义 QPS)自动增减 Pod。**注意:扩容解决「请求多」,解决不了「单请求慢」**(那要靠异步任务 + 队列)。
> 🔬 **深度理解 · 无状态伸缩与存储粘性**:
> 1. **本质**:**"能水平扩缩"的前提是服务无状态**——任意一个副本都必须能平等地接请求、处理完不依赖"我这台机器"上的私有记忆;一旦副本里存了只有自己知道的会话/进度,扩容出的新副本接不了活,缩容又可能丢状态。
> 2. **机制**:HPA 按 CPU / 自定义 QPS 等指标增减 Pod,这就要求把**可变状态外置**到共享存储——会话历史进 Redis、Checkpointer 进 PG、向量切片进 Qdrant、文件进 MinIO,副本退化成"无脑执行"的计算单元。
> 3. **为什么重要**:Agent 比普通服务更易踩"粘性"坑——若把会话上下文放**内存**,负载均衡随机转发,用户请求漂移到别的副本就会"追问丢了上文、上下文断了";这在单机演示时完全看不出来,一上 K8s + HPA 就露馅。这正是 Compose 里把 Redis/PG/Qdrant 单独起成服务的本质原因。
> 4. **易错点**:① 把"无状态的模型推理"误当有状态——它是可无限扩的,有状态的只是上下文/进度这类可变数据;② 会话缓存做在**内存级**且负载均衡无亲和性(sticky session),用户漂移即丢上下文,要用共享的 Redis 解决;③ 只给计算侧扩容、存储侧(Redis 单点 / PG 无主备)没跟上,照样成为新瓶颈——"计算可扩,存储要跟上"。
#### 2.2 任务队列(Task Queue)
```mermaid
graph LR
A[API 入队<br/>立即返回 task_id] --> B[(队列)]
B --> C[worker 进程池消费执行]
C --> D[结果回调]
```
- **作用**:长耗时 Agent 任务的缓冲带——**API 只入队即返回 `task_id`**,worker 进程池消费执行(Celery / Arq / RQ)。
- **价值**:
1. **削峰**:突发流量排队而非打垮服务;
2. **隔离**:慢任务不占 API 资源;
3. **可水平加 worker 扩吞吐**。
```python
# API 侧: 入队即返回, 不阻塞 (Arq 示意)
async def submit_task(request, queue):
job_id = await queue.enqueue_job("run_agent", request.model.table_dump())
return {"task_id": job_id.id, "status": "queued"} # 立即返回, 不等结果
```
#### 2.3 优雅停机(Graceful Shutdown)
```mermaid
graph TD
A[收到下线信号] --> B[停止接新请求]
B --> C[等存量请求处理完]
C --> D[状态已由 Checkpointer 落盘]
D --> E[再退出]
```
- 收到下线信号后**停止接新请求 → 等存量请求处理完 → 状态已由 Checkpointer 落盘 → 再退出**。没有它,每次发布都会切断在途 Agent 任务。
> 🔬 **深度理解 · 优雅停机(Graceful Shutdown)**:
> 1. **本质**:在进程/副本被终止前,给"在途请求"处理善后的机会——先停接新流量,把正在跑的 Agent 任务跑完或安全落盘再退出;否则每次发布、缩容、节点驱逐都会硬切断在途任务。
> 2. **机制**:收到下线信号(SIGTERM)后:① 把自己从负载均衡/服务发现中摘除,不再接着新请求;② 在**宽限期**内等存量请求处理完(超过宽限期仍会强制终止,防止拖死);③ 把 Agent 关键状态交给 Checkpointer 落盘(会话/任务进度写 PG / Redis);④ 处理完毕再 exit。K8s 侧由 `preStop` hook、`terminationGracePeriodSeconds` 与 readiness/startup probe 配合完成。
> 3. **为什么重要**:Agent 任务往往长耗时(一次对话几十秒到几分钟),发布又是常态;没有优雅停机,每次发布都批量打断在途 Agent 请求、丢进度——用户侧表现为"回答到一半断了"。这也是把 Agent 当"服务"而非"脚本"的关键差别之一。
> 4. **易错点**:① 收到 SIGTERM 但不真正等待存量请求(等于没做);② 宽限期设太短,长任务没跑完就被强杀;③ 只"等"却不落盘——停机后再拉起仍需靠 Checkpointer 恢复,光等也白等;④ 忽略强杀(KILL)之外的兜底,重负载下对已在途的操作仍需幂等重试。
#### 2.4 其他要点
- **滚动发布**:`rollingUpdate` 策略 + `readinessProbe`(就绪探针,没好不接流量)。
- 镜像带版本号禁用 `latest`;密钥走 K8s Secret。
### 3. 部署演进路线(量力而行)
| 阶段 | 方案 |
|-|-|
| 阶段 0 本地 | `uvicorn --reload` 直接跑 |
| 阶段 1 团队 | Compose 全家桶——一键起,环境一致 |
| 阶段 2 小生产 | 单机 Docker + systemd 自启 + Nginx 反代(K8s 前不必上) |
| 阶段 3 规模化 | K8s + HPA + worker 池 + Secret 管理密钥 |
| 阶段 4 补充形态 | Serverless / 云函数——低并发、事件驱动、突发流量(如定时批处理、webhook 触发的 Agent 任务);AWS Lambda / 阿里云函数计算 / Cloudflare Workers |
```mermaid
graph LR
A[本地 --reload] --> B[Compose 全家桶]
B --> C[单机 Docker+systemd+Nginx]
C --> D[K8s+HPA+worker池]
```
- **务实建议**:大多数内部 Agent 服务停在**阶段 2** 就够了——**别为 3 个用户上 K8s 集群**。
- **Serverless / 云函数(补充形态,不替代常驻服务)**:适合低并发、事件驱动、突发流量的任务型 Agent(定时批处理、webhook 触发的报告生成 / 通知),零闲置按调用计费;主流平台 AWS Lambda / 阿里云函数计算 / Cloudflare Workers。
> ⚠️ **两个硬限制**:① **冷启动延迟**——实例按需拉起,首次调用多出秒级甚至更久的冷启,**不适合对话首响**这类延迟敏感链路;② **流式长连接支持受限**——Agent 的 SSE 流式输出需要平台支持响应流(部分函数平台不支持或不完整),不支持就得退化成「一次性返回 / 轮询」。
### 4. 边缘轻量方案
- 用 1~4B 小模型(Qwen2.5-1.5B 级)跑端侧(手机 / 盒子)原型:**隐私好、离线可用**。
- 代价是**能力断崖**——只适合窄场景固定流程,别指望复杂工具调用。
> ⏳ **短期可不深究**:端侧小模型(1~4B 跑在手机/盒子)属边缘/偏门场景,模型能力迭代又快——第一遍了解"有这条路:隐私好、离线可用、能力断崖"即可,真要做端侧原型时再按当时的最新小模型评估选型,不必现在深挖。
## 本节自检
- [ ] 能编写 Compose 全家桶并一键拉起整套环境
- [ ] 能说清任务队列解决什么问题、优雅停机为什么必要
- [ ] 能画出部署演进四个阶段并判断当前该停在哪
---
## 本节常见面试题(深度解析)
> 针对本节核心知识的面试高频点,配合"本质+机制"式理解,能让你答得既有深度又有广度。
### 面试题 1:为什么"扩容 / 加机器"解决不了"单请求慢"?任务队列怎么补上这一块?
- **面试官想考察**:是否真正理解水平扩缩的本质与"异步化"的用途,而非只会搬 HPA 名词。
- **专业作答(含深度)**:
1. **根本区别**:扩容提升的是**并发吞吐**(单请求内部的 LLM 调用延迟是固有的,加副本也改变不了单个请求要花几十秒);"单请求慢"要靠**延迟优化**(缓存、小模型、精简 prompt)与**异步化**处理。
2. **任务队列的作用**:API 入队即返回 `task_id` → worker 池消费执行 → 结果回调;提供**削峰**(突发排队不把服务打垮)、**隔离**(慢任务不占 API 资源)、**可水平加 worker 扩吞吐**三条价值。
3. **两者配合**:把长耗时的 Agent 任务从同步请求里拆出去,用队列做缓冲带,再用加 worker 的方式扩消费;这样"并发高"与"单请求慢"两类问题分开解决。
- **加分亮点 / 深度追问**:可主动点明"HPA 解决并发容量、队列解决长任务、缓存/小模型解决延迟,三者解决的是不同维度的问题";被追问"为什么不让 API 直接同步做"时,答"长任务会占满 API 工作线程,把简单快请求也拖垮,且无超时兜底"。
### 面试题 2:优雅停机(Graceful Shutdown)为什么必要?一个 Agent 服务如何做到?
- **面试官想考察**:是否有服务化发布的成熟度,是否踩过"发布切流打断请求"的坑。
- **专业作答(含深度)**:
1. **必要性**:Agent 任务长耗时,且常驻服务要频繁滚动发布;无优雅停机,每次发布/缩容都会硬切断在途 Agent 请求、丢进度。
2. **四步做法**:收到 SIGTERM → 摘除流量、不再接新请求 → 宽限期内等存量跑完 → Checkpointer 把关键状态落盘 → 再退出;K8s 用 `preStop` + `terminationGracePeriodSeconds` + readiness probe 配好。
3. **与 Checkpointer 的关系**:停机前必须把进行中的会话/进度落盘,否则重启无法恢复——"等存量"与"落盘"两者缺一不可。
- **加分亮点 / 深度追问**:可补一句"滚动发布要配就绪探针,新副本 readiness 通过前不接流量";被追问"宽限期设多少"时,答"参考 P95/P99 长尾定,超过宽限期强杀后依赖幂等重试兜底"。
### 面试题 3:Agent 服务为什么必须"无状态"才能水平扩缩?Redis 会话缓存又为何会造成"粘性"?
- **面试官想考察**:是否理解状态与伸缩的关系、分布式里的粘性问题,以及"共享存储外置"的动机。
- **专业作答(含深度)**:
1. **无状态的前提**:任意副本都能平等接请求、不依赖本机私有记忆;一旦有私有状态,扩容新副本接不了活、缩容丢状态,水平扩缩就失去意义。
2. **外置原则**:会话历史进 Redis、Checkpointer 进 PG、向量进 Qdrant、文件进 MinIO,副本只做无脑计算。
3. **粘性怎么来**:若把会话缓存放**单实例内存**,负载均衡随机转发时用户请求漂移到别的副本就丢上下文(产生粘性依赖);共享的 Redis 正是为了打破这种"状态长在某台机器上"。
- **加分亮点 / 深度追问**:可主动补充"计算侧无状态解决了伸缩,但存储侧(Redis/PG 自身)的扩容与一致性是下一个瓶颈";被追问"Redis 会话如何避免多副本写冲突"时,答"靠会话 ID 分片 / 单写多读或引入事务边界,需求不同权衡不同"。
@@ -0,0 +1,224 @@
# 阶段七 · 小点 3:监控与运维
> 所属:阶段七 工程化、部署与运维
> 定位:上线只是开始,能不能持续观察到健康度、及时告警、闭环迭代,决定了 Agent 能不能长期跑稳。这一讲讲清三大监控支柱、黄金四指标、告警分级与上线迭代闭环。
## 精简大纲
1. 三大监控支柱:Logs / Metrics / Traces
2. 黄金四指标(Agent 版)
3. 告警阈值与分级
4. 观测平台:LangSmith / LangFuse / 自建 / OpenTelemetry
5. 上线迭代闭环(Bad-case 收集 → 归类 → 修复 → 回归)
## 学习内容详情
> 核心认知:Agent 运维三样都要——日志、指标、追踪,缺一样排障就"盲"。
### 1. 三大监控支柱(Logs / Metrics / Traces)
```mermaid
graph LR
A[三大支柱] --> B[日志 Logs<br/>单次事件明细]
A --> C[指标 Metrics<br/>可聚合数值序列]
A --> D[链路追踪 Traces<br/>跨节点完整路径]
```
- **日志**:单次事件的明细记录(谁、何时、发生了什么)。
- **指标**:可聚合的数值时间序列(QPS、成功率、P95 耗时)。
- **链路追踪**:一次请求跨节点 / 跨步骤的完整路径。
- **Agent 运维三样都要**,缺一样排障就盲。
- 链路追踪:Agent 每一步完整日志;
- **工具调用审计日志**——谁调用、入参、返回值(安全与排障关键)。
> 🔬 **深度理解 · 分布式追踪与 trace_id**:
> 1. **本质**:trace_id 是一次请求在整个分布式系统里"横穿多个节点 / 多次调用"的公共身份证——只要每步埋点都带上同一个 ID,就能把一次 Agent 会话里零散的日志、多个 span、多条工具调用串成一条完整路径。
> 2. **机制**:入口(网关 / API)生成 trace_id(常见随机 / ULID),通过 HTTP header 或消息元数据层层透传给下游;每段执行被打包成一个 span(含开始/结束、父子关系、语义属性),所有 span 凭同一 trace_id 归并成一条 trace;落到 OTel 即 `trace_id + span_id + parent_span_id` 的三级结构。
> 3. **为什么重要**:Agent 一次回答往往跨"LLM 调用 + 多个工具 + 检索",排障时要先回答"用户报的问题对应哪次请求、走到哪一步挂的"——没有 trace_id,日志是散的、跨服务也无法关联;它也是下文 Bad-case 闭环里"带完整 trace_id 进队列"的前提,能一键回到根因。
> 4. **易错点**:① 只在 API 层打点、不把 ID 透传给下游/worker,跨进程链就断了——**透传是追踪的灵魂**;② 异步任务(队列 consumer)里忘了从任务元数据带 trace_id,导致"后台任务对不上会话";③ 日志记了 ID,却在指标/归一化层不关联,三支柱"对不起来";④ 用手工拼接而非标准的上下文传播(Context Propagation)传 ID,下游读不到。
```python
def audit_tool_call(user, tool, params, result):
"""工具调用审计日志: 谁调用 / 入参 / 返回值——排障与安全审计都需要"""
print(f"[AUDIT] user={user} tool={tool} "
f"params={str(params)[:100]} -> {str(result)[:100]}") # 生产写日志系统
```
### 2. 黄金四指标(Google SRE,Agent 版)
| 黄金四指标 | 含义 |
|-|-|
| **P95 延迟** | 接口延迟第 95 百分位 |
| **QPS** | 流量 |
| **LLM 调用失败率** | 错误率 |
| **token 消耗速率** | 饱和度 |
- **P95 意义**:95% 的请求快于该值,比平均值更真实——LLM 延迟长尾严重(偶发 30s+ 拖高均值),看 P95/P99 才知道真实体感。
- **记住**:任何告警规则先从这四个里挑。
```python
def p95(values: list) -> float:
"""P95: 排序后取第95百分位的值, 比平均更能反映 LLM 长尾的真实体感"""
s = sorted(values)
idx = int(len(s) * 0.95)
return 0.0 if not s else s[min(idx, len(s) - 1)]
lats = [1.2, 1.5, 2.1, 3.0, 20.5, 1.8, 1.4] # 有一个 20s 的长尾偶发
print(f"均值≈{round(sum(lats)/len(lats),1)}s P95≈{p95(lats)}s")
# 均值被长尾拉高但 P95 更能反映大多数用户体验
```
> 🔬 **深度理解 · 百分位指标(P95 / P99)与长尾**:
> 1. **本质**:P95 是"把耗时排序后第 95 百分位的值",回答"95% 的请求比它快",是**对大多数用户体感**的分位数刻画,而非平均——LLM 延迟有显著长尾(偶发 30s+),均值会被少数极端值剧烈拖夸而失真。
> 2. **机制**:生产里不存全量明细去精确排序,而是按耗时**分桶直方图(Histogram)**累计计数(Prometheus 用 `le` 桶),查询时由累计分位数估算出 P95;它天然是"可聚合数值序列"(Metrics),无需保留每条请求的原始日志。
> 3. **为什么重要**:容量判断与告警要用"代表性"而非"平均"——均值 2s 完全掩盖了"偶尔有 15% 的人等了 8s"的糟糕体感;把 P50 / P95 / P99 横着看,能同时定位"多数有多快、典型慢、极端长尾",信息量远大于单一均值。
> 4. **易错点**:① 误以为生产 P95 是精确排序——它来自直方图分桶的**近似估算**,不同后端精度有差异;② 只看 P95 不看 P99,最尾端的极端用户被忽略;③ 把 P95 与"最大值"混谈;④ 用一个全局 P95 阈值套所有业务——客服对话与批处理报表的 SLO 天然不同(呼应下文"分场景基线")。
- **第五个维度——成本指标(Agent 特有,也最易失控)**:黄金四指标之外必须加盯 **token 消耗速率与单次会话成本**——前者看「烧得多快」(饱和度),后者看「一次对话花多少钱」(乘以会话量就是日账单)。模型换代、prompt 变长、循环失控都会让成本悄悄翻倍,而延迟与成功率可能纹丝不动——**四指标都绿、账单爆了,只有成本指标能第一时间暴露**。
### 3. 告警阈值与分级
- 阈值要「**可行动**」——告了没人处理的告警等于噪音。
| 级别 | 触发条件 | 处理 |
|-|-|-|
| **P0** | 成功率跌破 90% | 立即电话 |
| **P1** | 失败率 > 5% | 工单 |
| **P2** | P95 变慢 | 次日关注 |
- **分场景基线**:阈值来自**各自场景的 SLO**,不是全局统一值——客服对话**首响 < 3s 是硬指标**(首响应慢一秒用户就流失),离线批处理报表跑 10 分钟也无可厚非。先给每类场景定 SLO,再由 SLO 推导各自的阈值与分级,别拿一条 P95 阈值套所有业务。
> 🔬 **深度理解 · SLI / SLO 与"可行动"告警**:
> 1. **本质**:SLI(服务等级指标)回答"你到底在测什么"——如首响应耗时、成功率;SLO(服务等级目标)回答"你承诺它要达标到什么程度"——如"客服首响 P95 < 3s、月度达标 99%";告警阈值 = 从 SLO **反推**出的"该反应了"红线。
> 2. **机制**:先定 SLI(要可量化、可采集、且与用户体感相关)→ 再定 SLO(目标值 + 观测窗口)→ 由 SLO 推导告警阈值与分级(P0 是破 SLO 红线,P1 / P2 是红线前的预警档)→ 用 SLO 达成情况做迭代反馈。SLO 常配"错误预算(error budget)":一个月内允许若干未达标,预算耗尽前预警而非瞬间刷屏。
> 3. **为什么重要**:没有 SLO 的告警是"拍脑袋的阈值",告了也说不清"到底紧不紧急";有了 SLO,告警才有"是否危及契约"的语义,也才能做到"阈值可行动、不刷屏"的分级。不同业务 SLO 不同,这正是"别拿一条 P95 套所有业务"的根源。
> 4. **易错点**:① 把 SLI 定义成"好测但与用户体验无关"的指标(测进程存活却不测首响延迟);② 不设观测窗口 /错误预算,把 SLO 当瞬时值,导致告警抖动刷屏;③ 先拍阈值、再回头"圆"SLO,而不是由 SLO 推导阈值;④ 不分业务场景、全站共用一个 SLO。
```python
def alert(metric: str, value: float):
"""告警分级: 阈值可行动, 别刷屏"""
rules = {
"success_rate": ((0.90, 0.05), "P0", "立即电话"),
"failure_rate": ((0.05, 0.01), "P1", "工单"),
"p95_latency": ((2.0, 1.0), "P2", "次日关注"),
}
for name, ((high, low), level, action) in rules.items():
pass # 示意: 按 metric 匹配阈值并分级告警
```
### 4. 观测平台
- **LangSmith**:官方 SaaS;**LangFuse**:开源自托管(数据不出内网时首选):记录每次 run 的 LLM 输入输出、工具调用、token 成本,自带看板与评测。
```mermaid
graph LR
A[LangSmith] -->|官方 SaaS 省事| B[数据可能出境]
C[LangFuse] -->|开源自托管| D[数据不出内网 ★内网首选]
```
- **自建最小观测体系**:metrics(计数器 + 直方图)→ 内存时序 → 告警判定 → Bad-case 队列;生产等价物 Prometheus + Grafana。
#### OpenTelemetry:可观测性标准协议
- **OTel 是 2025-2026 可观测性事实标准**:CNCF 开源项目,一套 API / SDK + OTLP 传输协议统一 Logs / Metrics / Traces 三支柱的采集与上报——不必再为每个观测后端各写一套埋点。
- **GenAI 语义约定(Semantic Conventions)**:OTel 的 GenAI SIG 已为 LLM / Agent 定义标准 span 属性——`gen_ai.operation.name`(`chat` / `embeddings` / `invoke_agent` / `execute_tool`)、`gen_ai.model.name`、token 用量属性(`gen_ai.usage.input_tokens` / `gen_ai.usage.output_tokens`)。按约定命名属性,任何后端都能「读懂」你的 Agent 链路,成本与耗时可直接聚合。
- **Agent / 框架层 span 约定已发布**:GenAI SIG 已含《GenAI agent/framework spans》规范(`invoke_agent` / `execute_tool` / `invoke_workflow` 等操作名),落地时按此约定命名即可,自定义属性留扩展位。
> ⏳ **短期可不深究**:OTel GenAI 的 agent/framework spans 语义约定还在随社区快速迭代,具体操作名可能调整——第一遍只要知道"有标准约定、落地时按规范命名字段"即可,那几个 `invoke_agent / execute_tool` 的具体名在你真要埋点时再看最新规范,不必现在记。
- **价值:一次埋点、多后端**——Langfuse / LangSmith 均支持 OTLP 接入,同一份埋点也可发 Jaeger(traces)/ Prometheus(metrics)。换观测平台不重写一行代码,只改 exporter 的 endpoint。
最小接入(`pip install opentelemetry-sdk opentelemetry-exporter-otlp`):
```python
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
# 一次埋点 → OTLP 发给任意支持的后端(Langfuse / LangSmith / Jaeger ...)
provider = TracerProvider()
provider.add_span_processor( # BatchSpanProcessor: 批量异步导出
BatchSpanProcessor(OTLPSpanExporter(endpoint="http://localhost:4317"))
)
trace.set_tracer_provider(provider)
tracer = trace.get_tracer(__name__)
with tracer.start_as_current_span("agent_run") as span: # Agent 一步 = 一个 span
span.set_attribute("gen_ai.operation.name", "invoke_agent") # GenAI 语义约定
span.set_attribute("gen_ai.model.name", "deepseek-chat")
span.set_attribute("gen_ai.usage.input_tokens", 1200) # token 用量进 span
... # Agent 主流程
```
- **框架自动埋点**:不想手写 span,可用 Traceloop 的 **openllmetry**——OTel 生态的 LLM 框架 instrumentation,支持 LangGraph / CrewAI / LlamaIndex 等主流框架,装包 + 配 OTLP endpoint,框架内部调用自动成 trace。
> ⏳ **短期可不深究**:openllmetry 这类"框架自动埋点"工具迭代很快、对各框架支持度参差,第一遍不必深挖——上面手写 span 的接入原理已经够用,等真要为项目上链路追踪时再评估选型、查其当时支持哪些框架。
### 5. 上线迭代闭环(Bad-case 闭环)
```mermaid
graph TD
A[线上问题自动进 Bad-case 队列<br/>低分会话/点踩/解析失败] --> B[周一 过队列四分类归档]
B --> C[挑 Top3 高频]
C --> D[周三 修复<br/>改prompt/修工具/调检索]
D --> E[补进评测集]
E --> F[周五 回归测试]
F -->|通过| G[灰度10% 观察]
G -->|指标向好| H[全量]
```
- **Bad-case 上报通道**:线上问题自动进队列:低分会话 / 用户点踩 / 解析失败异常 → **带完整 `trace_id`** 进 Bad-case 表。
- **失败四分类**:prompt / 模型 / 工具 / 检索(对照阶段五评测)。
- **每周迭代 SOP**:
- **周一**:过 Bad-case 队列按四分类归类 → 挑 Top3 高频问题;
- **周三**:修复(改 prompt / 修工具 / 调检索)→ 补进评测集;
- **周五**:回归测试通过 → 灰度 10% 流量观察指标 → 全量。
> **指标趋势向上(成功率 / 成本 / 延迟)= 闭环健康;原地打转 = 回头查根因**,别在同一个根因上反复打补丁。
```python
def badcase_close(opened: list, who: str, fix: str, eval_ok: bool) -> str:
"""一条 Bad-case 的完整闭环: 归类→修复→回归通过才关闭"""
if not eval_ok:
return f"[{who}] 修复后回归未通过, 不关闭, 进入复查"
return f"[{who}] 已修复({fix})并通过评测回归, 关闭"
```
## 本节自检
- [ ] 能说清日志 / 指标 / 追踪三支柱与黄金四指标(+ 成本第五维)
- [ ] 能说清 OpenTelemetry 的价值(一次埋点多后端)与 GenAI 语义约定
- [ ] 已实现至少一项业务监控告警(任务成功率 / LLM 失败率)
- [ ] 已跑通一次 Bad-case 收集 → 归类 → 修复 → 评测回归的闭环
- [ ] 能画出并执行「周一归类 → 周三修复 → 周五回归」的每周 SOP
---
## 本节常见面试题(深度解析)
> 针对本节核心知识的面试高频点,配合"本质+机制"式理解,能让你答得既有深度又有广度。
### 面试题 1:为什么监控要看 P95 / P99 而不是只看平均值?
- **面试官想考察**:是有统计素养还是只会背"分位数"名词,是否理解长尾与容量决策的关系。
- **专业作答(含深度)**:
1. **本质**:均值被少数极端值(长尾)剧烈拖夸,既说不清"大多数用户多快"、也没暴露"究竟有多少人很慢",信息量最差;百分位回答"有 X% 的请求比它快",直面体感。
2. **机制**:LLM 延迟天然长尾(偶发 30s+、重试放大、排队),均值失真严重;生产用**直方图分桶**估算百分位,无需保留全量明细。
3. **成组看**:把 P50 / P95 / P99 放一起,区分"多数有多快、典型慢、极端长尾",比单个均值信息量大得多;P95 明显慢于 P50、P99 又远大于 P95,说明长尾重,应优先优化慢请求。
4. **与告警/容量结合**:阈值用 P95/P99 按场景定,容量与排障都更有代表性。
- **加分亮点 / 深度追问**:可主动说"生产 P95 是直方图近似、不是精确排序第 95 位"体现原理深度;被追问"长尾怎么来的、怎么排掉"时,答"来自偶发慢调用+重试放大+排队,可用超时上限、缓存、小模型、异步化削减"。
### 面试题 2:trace_id 是如何在分布式 / Agent 链路里贯穿的?为什么说它是排障与 Bad-case 闭环的基石?
- **面试官想考察**:是否真正落地过分布式追踪,而不只是知道"有个 ID"。
- **专业作答(含深度)**:
1. **贯穿路径**:入口生成 trace_id → 通过 HTTP header / 消息元数据**逐层透传**给下游 → 每段执行打包成 span → 凭同一 trace_id 归并成一条 trace(OTel 即 trace_id + span_id + parent_span_id 三级)。
2. **异步续传**:队列 consumer / worker 必须从任务元数据里带出 trace_id,否则"后台任务对不上会话"。
3. **基石作用**:Agent 一次回答跨 LLM + 工具 + 检索,排障要先回答"对应哪次请求、走到哪步挂的";日志带 trace_id 才能与 span、指标对起来;Bad-case 带完整 trace_id 进队列才能一键回到根因。
- **加分亮点 / 深度追问**:可提"透传是追踪的灵魂、context propagation 规范"体现严谨;被追问"三支柱怎么才算对起来"时,答"指标异常→用 trace_id 定位 trace→再拉同一 trace_id 的日志明细"。
### 面试题 3:P0 / P1 / P2 告警分级应该怎么定?它和 SLI / SLO 是什么关系?
- **面试官想考察**:告警是否"可行动、不刷屏",有没有 SLI/SLO 工程素养,而非只会背几个级别。
- **专业作答(含深度)**:
1. **由 SLO 反推**:先定 SLI(可量化且与用户体感相关)→ 再定 SLO(目标值 + 观测窗口)→ 由 SLO 推导阈值与分级:P0 破红线(如成功率 < 90%)立即处理,P1/P2 是红线前的预警档。
2. **可行动**:告警要能被"分级处理"对应(电话 / 工单 / 次日关注),告了没人处理等于噪音。
3. **分场景**:不同业务 SLO 不同(客服首响 < 3s vs 批处理跑 10 分钟),不能用一条全局阈值套所有场景。
- **加分亮点 / 深度追问**:可主动提"错误预算(error budget)与告警抖动抑制,避免瞬时抖动刷屏";被追问"SLO 定了 99% 怎么算 error budget"时,答"月度可容忍未达标时间 = (1−99%) × 当月总时长,预算逼近 0 时预警而非立即报警"。
@@ -0,0 +1,187 @@
# 阶段七 · 综合实战:Agent 观测台 + 告警 + Bad-case 闭环(离线可运行)
> 所属:阶段七 工程化、部署与运维
> 定位:把阶段七第 3 小点「监控与运维」的三大支柱、黄金四指标、告警分级、Bad-case 闭环,落成一份**离线可运行**的最小观测体系——模拟一批 Agent 请求,观察健康度、触发告警、把低分会话归类修复。
## 一句话看懂本 Demo
`monitoring_agent.py` 用纯 Python 标准库模拟一个 Agent 服务的"观测台":
```mermaid
graph TD
A["阶段七综合实战 monitoring_agent.py"] --> B[Part1 三大支柱<br/>日志/指标/追踪]
A --> C[Part2 黄金四指标 + 告警<br/>成功率/QPS/P95/token]
A --> D[Part3 Bad-case 闭环<br/>周一归类→周三修复→回归关闭]
```
> 核心一句话:**上线只是开始,能不能持续看到健康度、及时告警、闭环迭代,决定 Agent 能不能长期跑稳**。这份 Demo 就是最小可用的观测闭环。
## 运行方式
```
python3 code/阶段七/monitoring_agent.py
```
不需要 API Key、不需要外网、不需要装任何第三方库(只依赖标准库)。
## 完整代码位置
[code/阶段七/monitoring_agent.py](../../code/阶段七/monitoring_agent.py)
---
## 每个 Part 在讲什么
### Part 1 · 三大支柱(Logs / Metrics / Traces)
三个收集器分别对应监控三支柱:
| 支柱 | 类 | 记录什么 |
|-|-|-|
| 日志 Logs | `LogCollector` | 谁、何时、发生了什么(`agent start / done / failed`) |
| 指标 Metrics | `MetricsCollector` | 可聚合数值(耗时、成败、token) |
| 追踪 Traces | `TraceCollector` | 一次请求的跨步骤链路(`agent start` → `llm` span) |
```python
def simulate_traffic(mat, logs, traces, n=60):
"""模拟 60 次请求, 产生日志/指标/追踪 + 一些失败样本"""
# 用函数属性自增计数生成全局唯一 trace_id(等价真实环境的 UUID)
simulate_traffic.i = getattr(simulate_traffic, "i", 0) + 1
tid = f"t{simulate_traffic.i:03d}"
logs.info(tid, "agent start", step="receive")
traces.start(tid, "llm")
ok = random.random() > 0.28 # 约两成失败
lat = random.uniform(0.4, 3.4) if ok else random.uniform(2.0, 8.0)
mat.record_call(lat, ok, prompt_tk=..., completion_tk=...)
```
> ⏳ **短期可不深究**:里面 `simulate_traffic.i = getattr(...)` 用"函数属性自增"生成全局唯一 `trace_id` 只是离线 Demo 省依赖的**教学技巧**——真实生产是在网关/中间件用 UUID/ULID 生成并经 header 透传,第一遍不必在这点抠细节。
> ⏳ **短期可不深究**:`random.random()>0.28`(成败比)和 `random.uniform` 的延迟区间只是**随机模拟参数**,为了让例子出现告警而设——第一遍理解"它造出失败/长尾样本供演示告警与闭环"即可,不用纠结这些概率/区间取值是否贴合真实分布。
> 运维铁律:**一次请求可以从日志看到明细、从追踪看到路径、从指标看到聚合**,三样对起来才能排障,缺一样就"盲"。
> 🔬 **深度理解 · 三支柱为什么要"对起来"排障**:
> 1. **本质**:日志、指标、追踪是同一个问题的三种"视图"——日志给"这一次发生了什么"的明细,指标给"整体长什么样"的可聚合数字,追踪给"请求跨了哪些步骤"的路径;单独看任何一个都是"盲人摸象",只有用同一个 `trace_id` 把三者串起来,才能完成"现象 → 定位 → 根因"的闭环。
> 2. **机制**:真实生产里 Prometheus 扛指标(每秒 QPS / 成功率 / 延迟分位)、Jaeger / LangFuse 扛 span(一次 Agent 会话的调用树)、日志库每行带 `trace_id`;排障动作为"看指标发现异常 → 用 trace_id 追到那一次 trace 看走到哪一步 → 再拉同一 trace_id 的日志明细",三者在同一请求 / 同一时间窗口上对齐。
> 3. **为什么重要**:Demo 里三个 Collectors 都在内存、天然"对得起来";到生产一分家就断——若不强制带 `trace_id`、不打通三套系统,你会看到"QPS 掉了(指标)"却不知是模型超时还是工具报错,因为追踪和日志无从翻起。这正是"缺一样就盲"的真正含义。
> 4. **易错点**:① 三套系统各自埋点却不用同一个 `trace_id`,跨系统对不上号;② 只存指标不存日志明细,异常窗口无法复盘;③ 把 span 当日志、把日志当 span,单一视图信息残缺;④ 忘了给 Bad-case 补 `trace_id`,导致后续闭环无从定位。
### Part 2 · 黄金四指标 + 告警(Google SRE · Agent 版)
`evaluate_golden()` 从指标收集器里取数,逐条做可行动告警:
```python
def evaluate_golden(mat) -> dict:
r = mat.report()
out = {"QPS": r["QPS"], "token累积": r["token累积"]}
sr = r["成功率"]; fr = 1 - sr; p95 = r["P95延迟(s)"]
alerts = []
if sr < 0.90:
alerts.append(("P0", "成功率跌破90%", "立即电话"))
elif fr > 0.05:
alerts.append(("P1", "失败率>5%", "工单"))
if p95 > 2.0:
alerts.append(("P2", "P95变慢", "次日"))
return out # 阈值"可行动", 告了不处理的等于噪音
```
Demo 里 `p95` vs `mean` 同时打印,能直观看到 LLM 长尾——均值(2.77s)反映大多数请求较快,而 P95(6.76s)提示确有相当比例的慢请求;只看均值会低估极端延迟,这正是要盯 P95/P99 的原因。
> 🔬 **深度理解 · 为什么"只看均值会低估极端延迟"**:
> 1. **本质**:均值把所有请求耗时代数平均,一个极端长尾就足以把整体均值抬高,既不能告诉"大多数用户多快"、也没暴露"究竟有多少人很慢",是信息量最差的一类汇总;分位数(P95 / P99)回答"有 X% 的请求比它快",直击体感。
> 2. **机制**:LLM 服务延迟几乎必然长尾(偶发慢调用、重试放大、排队使后面请求等更久),均值被右尾剧烈拖动;只有 `min/P50/P95/P99/max` 成组看,才能同时看清轮廓(P50)、普遍体感(P95)、极端长尾(P99 / max)。
> 3. **为什么重要**:它直接决定扩容 / 加超时 / 做缓存这类决策的质量——均值 2.9s 看着还行、P95 6.7s 说明确有相当比例的慢请求,若只看均值会误判"还不错"、漏掉该被优化的长尾用户;这也是容量告警要按分位数定的原因。
> 4. **易错点**:① 把生产 P95 当成精确排序第 95 位——它来自直方图分桶的**近似估算**;② 观察窗口混入非典型批次(上线一批重型任务)让 P95 失真;③ 单看均值或单看 P95 都片面,要**成组看趋势**。
### Part 3 · Bad-case 闭环(周一归类 → 周三修复 → 回归关闭)
`BadCaseLoop` 实现每周迭代 SOP:
```python
def weekly_sop(self):
"""周一: 过队列四分类归档 → 挑 Top3 高频"""
cats = {}
for item in self.queue:
c = classify(item["case"]) # prompt / 模型 / 工具 / 检索
cats[c] = cats.get(c, 0) + 1
return sorted(cats.items(), key=lambda x: x[1], reverse=True)[:3]
```
闭环逻辑:问题进队列 → 四分类归档 → 挑 Top3 → 修复 → 补评测集 → 回归通过才关闭;回归不通过不关闭。
> 🔬 **深度理解 · 失败四分类与 Bad-case 闭环**:
> 1. **本质**:把"线上用户觉得不对"的坏例,先归到 **prompt / 模型 / 工具 / 检索** 四类根因之一,再按频率挑 Top 修复——本质上是一套"用错误样本驱动迭代"的 RAG / Agent 质量方法论,让改进有方向、而非凭感觉乱调。
> 2. **机制**:低分会话 / 点踩 / 解析失败自动进队列(带 `trace_id`)→ 按四分类归类(prompt 措辞歧义 / 模型能力不足 / 工具报错、参数错、超时 / 检索召回或排序不对)→ 统计频次挑 Top3 → 针对性修复(改 prompt / 修工具 / 调检索)→ 把修复样本**补进评测集** → 回归通过才 close,不过进复查。
> 3. **为什么重要**:没有分类维度,"迭代"就退化成"反复改、无方向";四分类恰好对照阶段五评测的三个环节,让"线上坏例"与"离线评测"用同一套语言,坏例还能反哺评测集,形成"评测越来越准、线上越来越好"的正循环。
> 4. **易错点**:① 用"症状"当"根因"分类(模型答错其实可能是检索没喂对上下文,却归成模型类);② 只看数量不按影响力排,高频但影响小 vs 低频但致命要分开加权;③ 修复了不补评测集,回归只能靠感觉、无法防回归;④ 回归不通过也 close,导致闭环"假关闭"。
```
[周一归类] 失败四分类: {'tool': 17} → Top3: [('tool', 17)]
[修复] tool 类 17 条, fix=给工具加超时+重试, 回归通过, 已关闭
```
---
## 运行结果预览
```
>>> Part 1 三大支柱
[追踪] 最新一次请求 spans: ['llm']
[日志] 该请求日志: [('info','agent start'), ('info','agent done')]
>>> Part 2 黄金四指标 + 告警
QPS: 1.0 token累积: 43967 成功率: 0.717
告警: [('P0','成功率跌破90%','立即电话'), ('P2','P95变慢','次日')]
P95 vs 均值: 约 7.0 vs 约 2.9 (示例值, 数值随随机延迟分布而变)
>>> Part 3 Bad-case 闭环
[周一归类] 失败四分类: {'tool': 17} → Top3: [('tool', 17)]
[修复] tool 类 17 条, fix=给工具加超时+重试, 回归通过, 已关闭
```
## 换真实生产只需对号入座
| 教学实现 | 生产替换 |
|-|-|
| `MetricsCollector` 内存计数器 + 直方图 | Prometheus(counter / histogram) |
| `evaluate_golden` 逐条告警判定 | Prometheus 告警规则 / Alertmanager 分级通知 |
| `TraceCollector` 内存 span | LangFuse / LangSmith / Jaeger |
| `BadCaseLoop` 内存队列 | 线上低分会话自动入库(带 trace_id) |
| 每周 SOP 手写 | 结合阶段五的 LLM-as-judge 批量回归 |
## 本节自检
- [ ] 能说清日志 / 指标 / 追踪三支柱,知道排障为什么三者要对起来
- [ ] 能默写黄金四指标,并解释为什么看 P95 而不是只看均值
- [ ] 已实现至少一项可行动告警(成功率 / LLM 失败率)
- [ ] 已跑通一次「Bad-case 收集 → 归类 → 修复 → 评测回归」闭环
---
## 本节常见面试题(深度解析)
> 针对本节核心知识的面试高频点,配合"本质+机制"式理解,能让你答得既有深度又有广度。
### 面试题 1:这份"内存版"观测 Demo 换到生产,要替换成什么?为什么能"一条线对应替换"?
- **面试官想考察**:能否把教学抽象正确映射到真实技术栈,是否理解"抽象 vs 实现"的关系,而不只是会跑 Demo。
- **专业作答(含深度)**:
1. **逐项替换**:`MetricsCollector`(内存计数器+直方图)→ Prometheus(counter / histogram);`evaluate_golden`(逐条告警判定)→ Prometheus 告警规则 / Alertmanager 分级通知;`TraceCollector`(内存 span)→ OpenTelemetry + Jaeger / LangFuse / LangSmith;`BadCaseLoop`(内存队列)→ 线上低分会话自动入库(带 trace_id);每周 SOP 手写 → 用 LLM-as-judge 批量回归。
2. **为何能对应**:因为教学里的"类"本就是真实组件的共性抽象——Counter / Histogram、告警规则、span、闭环流程是同一套概念,只是从"内存实现"换成"生产实现"。
3. **生产要点**:三支柱要用同一个 `trace_id` 打通、指标与追踪走 OpenTelemetry 标准协议(一次埋点多后端),否则"对起来排障"在生产里会失效。
- **加分亮点 / 深度追问**:可主动点明"用 OTLP 标准协议是为了避免锁死某个厂商观测后端";被追问"Prometheus histogram 的 bucket 该怎么设计"时,答"按 LLM 延迟的典型区间(0.5s/1s/2s/5s/10s...)设计,桶太粗精度差、太细成本高"。
### 面试题 2:为什么说"只看均值会低估极端延迟"?你在实际观测里看什么?
- **面试官想考察**:对 LLM 长尾与分位数指标是否有实践层面理解,能否落地到决策。
- **专业作答(含深度)**:
1. **本质**:均值被长尾剧烈拖夸、又掩盖了"有多少人很慢",是失真最严重的汇总;回到本 Demo 示例——均值 2.9s 但 P95 6.7s,正是长尾的诊断信号。
2. **看什么**:成组看 `P50 / P95 / P99 / max`,P99 远超 P95 说明长尾重,就该去优化慢请求(缓存、小模型、超时上限、异步化),而不是盲目加机器。
3. **生产注意**:P95 来自直方图分桶的近似估算;观察窗口要避开刚上线的非典型批次,否则 P95 会失真。
- **加分亮点 / 深度追问**:可补"长尾常来自偶发慢调用 + 重试放大 + 排队效应,P99 更适合做容量规划";被追问"长尾怎么量化判断是否改善"时,答"比较同口径同窗口的 P99 趋势,而不是均值"。
### 面试题 3:Bad-case 闭环里的"失败四分类"为什么重要?如何防止闭环"假关闭"?
- **面试官想考察**:是否有质量数据闭环意识,是否理解"用坏例驱动迭代、且用评测防回归"。
- **专业作答(含深度)**:
1. **四分类为何重要**:prompt / 模型 / 工具 / 检索提供了改进的"根因定向",避免无方向乱改;它对照阶段五评测环节,让线上坏例与离线评测用同一套语言。
2. **闭环流程**:坏例自动进队列(带 trace_id)→ 四分类归档 → 按频次×影响挑 Top → 修复 → **补进评测集** → 回归通过才 close。
3. **防"假关闭"**:回归不通过绝不 close、用症状当根因要纠正、先看影响再看频次——"补评测集 + 回归门禁"是防止假关闭的关键。
- **加分亮点 / 深度追问**:可主动讲"坏例反哺评测集形成正循环、用 LLM-as-judge 批量回归扩大覆盖";被追问"四分类边界模糊、某类内部还要细分"时,答"可在类内再拆子类,如工具类分超时/报错/参数错误,便于精准修复"。