Files
python-agent/docs/07-阶段七-工程化部署与运维/01-Agent工程化最佳实践.md
T
2026-09-22 15:42:25 +08:00

268 lines
19 KiB
Markdown
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 阶段七 · 小点 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)与固定窗口计数在限流里的区别。