ShoppingX 后端设计增强方案#
定位:在现有「纯 Agent 系统」之上,叠加能体现后端工程能力的基础设施模块。面试 / 作品集导向 —— 每块追求「痛点清晰 + 能画架构图 + 一个关键权衡 + before/after 可量化」,而非生产全量。
原则:做透 1~2 块 > 铺一堆半成品。优先复用本仓库已有基建(Qdrant 召回、
llm.py、FastAPI/WS、usage.py、Langfuse)。本文整合自两份前期调研(
BACKEND_HIGHLIGHTS.md的现状核实 + 市面调研,ShoppingX后端设计亮点调研.md的分模块落地设计),去掉代码细节,只保留设计思想、权衡与叙事。
✅ 落地状态(截至 2026-06-30)#
P0 + P1 已全部落地并合入 main,按推荐执行顺序 C → B → A → D → E → F → I 逐块完成。每块走统一流程:关一(ruff/mypy/pytest)+ 关三(/code-review high effort,发现全部处置)+ 面试向开发文档(docs/milestones/ENH-*.md)+ 单块提交。
| 块 | 内容 | 状态 | 开发文档 |
|---|---|---|---|
| C | 并发背压(任务槽 + fork 级 Semaphore + 429) | ✅ 已合并 | ENH-C-并发背压 |
| B | 韧性工程(断路器 + 退避重试 + 降级链,对准 4 真实外呼) | ✅ 已合并 | ENH-B-韧性工程 |
| A | 可观测性收缩版(Prometheus metrics + structlog) | ✅ 已合并 | ENH-A-可观测性 |
| D | 事件回放(Redis Stream 断线重连补发) | ✅ 已合并 | ENH-D-事件回放 |
| E | 语义缓存(仅 category_insight,精确 + 语义两级) | ✅ 已合并 | ENH-E-缓存 |
| F | FinOps 预算闸(token 从「测」到「控」) | ✅ 已合并 | ENH-F-FinOps预算闸 |
| I | JWT 鉴权(堵 user_id 越权读 Store) | ✅ 已合并 | ENH-I-JWT鉴权 |
未做(维持「讲得出为什么暂不做」的毕业线 / P2),详见 §三优先级矩阵与各模块说明:
- I 块仅堵 user_id 维度:thread 维度资源(
/api/history、/api/files、/ws、/api/task/{tid}/cancel、/api/upload)尚无属主校验,需thread_id → owner(user_id)归属表,列为下一增量(不做半成品授权)。 - I 的 P2(限流 / 版本 / 幂等)、F 的跨 provider fallback(只有一家 provider key)、A 的 OTel system trace、D 多副本事件总线/路由、C 任务持久化/队列、G Guardrails、H 数据管道、J 依赖注入 均按计划暂不做。
下文「现状画像 / 模块清单 / 优先级矩阵」保留为立项时的分析与权衡(解释每块为什么做、怎么取舍),是上面落地状态的依据,不随完成而删。
〇、背景与定位#
ShoppingX 现有骨架已覆盖 Agent 侧 的设计深度:
- Agent 调度层:AgentLoop(Think→Act→Observe→Reflect)+ 同质子 Agent fork
- 检索层:Qdrant 单栈 dense + payload filter,BGE-M3 召回 + BGE-Reranker-v2-m3 精排
- 压缩层:Cache Breakpoint 上下文压缩
- 记忆层:规则偏好 Store
- 事件协议:AGUI 事件 + WebSocket
- 评测闭环:Rubric 离线评测 + 上下文级飞轮(评测 → 定位 bad case → 改 prompt/工具/沉淀 few-shot → 再评测;零 GPU、不做 SFT/RL 训练)
但从「后端工程功底展示」角度看,几块经典的后端基础设施能力当前是空白或只有占位。补上它们,能让项目在面试 / showcase 时显著区别于「demo 级 Agent 项目」。
下面先用代码核实的现状画像摆清家底(哪些已经做到同类项目偏上水平、哪些是真缺口),再给出增强模块清单与优先级取舍。
一、现状画像(对着代码核实,不是文档推断)#
逐条翻源码核实「已对上 vs 真缺」,每条标出代码出处。 图例:✅ 已对上 | 🟡 部分(有雏形 / 缺关键一环)| ❌ 缺
| # | 业界经验点 | 现状(代码证据) | 判定 | 真实缺口 / 下一步 |
|---|---|---|---|---|
| 1 | Agentic loop 必装熔断 / 护栏、防 token 烧光 | fork 安全四层全齐:深度闸 MAX_FORK_DEPTH=1(fork_guard.py)、循环检测 LoopDetector(窗口 6 / 阈值 4)、结果截断 4000 token、超时 + 迭代上限(dispatch_tool)。另有耗尽即收敛——搜满/fork 后/收尾后在执行层回哨兵拦下工具(middleware.py,不摘工具以保 prompt cache 前缀,见 M6.1 / Mperf.1) | ✅ | 比多数博客讲得更狠,面试主推亮点,当文档主线 |
| 2 | 「别停在 Agent 那层」:要有完整后端层 | FastAPI 6+ 接口、WS 事件流、文件接口、偏好 / 历史读口、health;lifespan 预热分词器、safe_join 防穿越、上传限额(server.py) | ✅ | 骨架在了,但 3–6 是骨架里的空洞 |
| 3 | 任务持久化 / 可恢复 | active_tasks: dict[str, asyncio.Task] 纯内存 + asyncio.create_task。有取消、同 thread 防重、按身份摘除防串台 | 🟡 | 进程内生命周期干净,但进程重启即全丢;无持久状态机 / 重试 / 死信 / 优先级。未挂 checkpointer(CLAUDE.md 主动划界) |
| 4 | WS 多副本路由 + 事件回放 | ConnectionManager 进程内 dict + asyncio.Lock、身份比较防误删、推送失败摘连接;connect-first 协议避免早期事件丢失 | 🟡 | 单进程绑死,2 副本即坏(任务在 A、连接在 B 推不到)。无 Redis Pub/Sub 事件总线;无 last_event_id 回放 |
| 5 | 外部依赖弹性:超时 / 重试 / 熔断 / 限流 | LLM timeout=60s + max_retries=2;HTTP 外呼均有超时(reranker 10s、towers 30s、web_search 20s);reranker 远程挂了优雅降级本地打分 | 🟡 | 超时齐、LLM 有重试、有降级;但 HTTP 依赖无退避重试、无熔断器、无全局并发闸 / 限流。fork 并行高并发缺背压 |
| 6 | 成本 / Token 预算治理(FinOps) | 检索预算强:全树计数按 session 聚合(retrieval_budget.py)、ForkBudget。token 用量已测量:carried / peak / cache_read / 命中率聚合发 Langfuse(usage.py) | 🟡 | 「检索次数」是闸、「token 成本」只测不控。缺按 token/成本的预算闸、per-user/session 成本上限与超限熔断 |
| 7 | 可观测性不可省 + OTel trace | Langfuse 接主链路,多 fork 归并同一条 trace、session=thread_id、安静降级(tracing.py);AGUI 8 类事件(monitor.py);health 报 active_tasks | 🟡 | LLM 链路追踪完整;但缺系统级 metrics(Prometheus QPS / 延迟分位 / 错误率)、无贯穿 API→任务→外呼的 OTel system trace、health 过简 |
| 8 | ASGI 调优(uvloop / httptools 5–10x) | uvicorn[standard] —— standard extra 已自动带 uvloop + httptools | ✅(默认吃到) | 已默认享受,但无显式 worker / 并发配置、无压测数据佐证。补一条 benchmark 即可讲 |
| 9 | Guardrails:不可信内容隔离 / 输入校验 | 深度权限闸(子 Agent 无收尾 / 上下文工具授权,middleware.py)、web_search 兜底门、CORS、safe_join、上传限额 | 🟡 | Agent 内部权限边界好;但缺 RAG/web_search 拉回内容当不可信源的注入隔离、工具入参校验、鉴权 / RBAC / 多租户 |
结论(真实画像)#
「Agent 运行时的护栏 / 权限 / 压缩 / 追踪做到同类项目偏上水平(#1/#2/#7/#9 的 Agent 侧、压缩、tracing),但『服务底盘』还是单机内存态 demo(#3/#4),弹性与成本是『测了 / 拦了一半』(#5/#6)。」
- #1 fork 安全四层 + 耗尽夺权:被低估的硬亮点,代码比博客扎实,面试主推。
- 真缺口集中在 #3、#4:任务持久化 + WS 多副本——「内存态单机」硬伤,改造前后对比最鲜明。
- 性价比快赢在 #5、#6、#7:HTTP 依赖加退避重试 + 熔断 + 并发闸(#5)、token 预算从「测」升级到「控」(#6,已有
usage.py雏形)、补 Prometheus(#7)——投入小,把「部分」补成「完整」。
二、增强模块清单#
每块统一给出:解决什么问题 → 落地思路 → 关键权衡 → 设计亮点 / 面试叙事。按主题整合,不含代码。
A. 可观测性补全:Metrics + 结构化日志(OTel system trace 降级到毕业线)#
解决什么问题。 一次 query 涉及主 AgentLoop → 多个 fork 子 Agent → 每个子 Agent 内部 2–3 次工具调用 → Qdrant 检索 → rerank → 合流。延迟从哪来、哪一步卡了、哪个平台拖后腿——没有系统级可观测性就是黑盒(之前定位 295s 延迟回归就是在黑盒里摸)。现状是 Langfuse 已覆盖 LLM 链路 trace,真正缺的是系统级 metrics(QPS / 延迟分位 / 错误率)与结构化日志。
先厘清「trace 已有 vs 真缺」(关系到下面「做什么 / 不做什么」):
- 现有
tracing.py的 Langfuse v4 本身就基于 OpenTelemetry,且已把多 fork 归并同一条 trace、session=thread_id(tracing.py)。调用树级追踪已经在了。- 因此不再自建
opentelemetry-sdk完整栈——那会和 Langfuse 重复造轮子(两套 span 上下文、两份导出)。要补几条非 LLM 的 span(如 Qdrant 检索段),直接用 Langfuse 的 span API 挂在已有 trace 上即可。- 本块真正要补的就两样:Prometheus metrics + structlog。
落地思路。
- Prometheus Metrics(本块核心):暴露
/metrics,关键指标 ——active_tasksgauge、tool_duration_secondshistogram(按tool_name分桶)、fork_depthhistogram、qdrant_latency_secondshistogram、rerank_short_circuit_ratecounter、cache_hit/miss_total。系统级 QPS / 延迟分位 / 错误率从此可看板化。 - 结构化日志:用
structlog替代 print/logging,每条日志自动带thread_id/fork_depth/user_id。 - (可选)补非 LLM span:若想让 Qdrant 检索 / rerank 段也进调用树,用 Langfuse 已有 trace 的 span API 加几条 child span,而非另起 OTel 栈。
关键权衡。 既不上 Jaeger 全套分布式 trace 存储,也不引 opentelemetry-sdk 自建 system trace——Langfuse 已提供调用树追踪,再叠一套 OTel 是性价比最低的一档。完整 OTel system trace 挪到毕业线:真要把 API→任务→外呼的 trace 脱离 Langfuse、对接 Jaeger/Tempo 时再做。
设计亮点 / 叙事。 复用已有的 ContextVar 体系让结构化日志自动携带上下文:set_thread_context 时用 structlog 的 bound logger bind 一次 thread_id / fork_depth / user_id,后续日志自动携带,工具实现者无感。核心叙事:「ContextVar 既做 thread 隔离,又做日志上下文传播——一个机制解决两个问题;trace 这一层不重复造轮子,直接吃 Langfuse 已经基于 OTel 归并好的调用树。」
B. 韧性工程:Circuit Breaker + Retry + 降级链#
解决什么问题。 系统不是在所有依赖健康时能跑就行,而是在部分依赖挂掉时仍能提供有意义的结果。现状超时齐、LLM 有重试、reranker 有本地降级,但 HTTP 依赖无退避重试、无熔断、无全局并发闸。
落地思路。
断路器对准「真实外呼」,不对虚构对象。 现状平台商品数据是本地 CSV / Qdrant,并无真实多平台 API 外呼,所以「每个平台 API 一个断路器」目前没有保护对象。真正会抖动 / 挂掉、值得上断路器的外呼只有 4 个:reranker(siliconflow)、towers(embedding API)、web_search(tavily)、LLM。断路器只对这 4 个建。
-
工具级 Circuit Breaker:对上述 4 个真实外部依赖各建一个断路器,状态机
CLOSED → OPEN → HALF_OPEN,滑动窗口(最近 N 次失败率 > 阈值则 OPEN)。OPEN 时直接走降级路径而非干等超时。 -
降级链(核心架构思考):给依赖这些外呼的工具定义 fallback 链——
工具 / 外呼 正常路径 降级路径 1 降级路径 2 item_search(towers embedding)向量编码 + Qdrant 检索 embedding 外呼断路 → 退化为 BM25 纯文本搜索 返回空 + 低置信度标记 category_insight(reranker)Hybrid + Reranker 精排 reranker 断路 → 跳过精排用粗排 Top-K(已有本地降级短路逻辑) WebSearch 兜底 web_search(tavily)Tavily 外呼 断路 → 返回空 + degraded标记,交还 Reflect—— shipping_calc(汇率,如接实时 API)实时汇率 API 本地缓存汇率表(TTL=24h) 返回估算值 + 标记 estimated=True -
指数退避 + Jitter 重试:区分可重试异常(timeout、5xx)与不可重试异常(4xx、数据格式错误),只对前者退避重试(
tenacity)。
关键权衡。 熔断阈值 / 恢复窗口是经验值,过激会误伤抖动、过缓失去保护意义——配合 metrics(A 块)观测后调参,不拍脑袋。reranker 已有本地兜底降级,是这套降级链最现成的样板,其余三个外呼照此补齐。
设计亮点 / 叙事。 降级结果不是静默吞异常,而是返回带 confidence / degraded / degradation_reason 的结构化结果。主 AgentLoop 在 Reflect 阶段看到 degraded 标记可以决定换策略(如某平台断路了就不再 fork 子 Agent 去搜它)。核心叙事:「降级信息参与决策——比 try-catch 高出一个层次。」
C. 任务持久化 + 背压控制#
解决什么问题。 现状 active_tasks: dict + asyncio.create_task 单实例能跑,但有工程硬伤:进程重启任务全丢、无并发上限、无优先级、无重试。
先厘清两件正交、易混的事(关系到下面「做什么 / 不做什么」):
- 页面刷新后任务继续 = 抗浏览器断线。现状已扛住——
/api/task是后台asyncio.create_task跑、与 WS 连接解耦(server.py),刷新不杀任务。断开窗口的事件会丢,由 D 块事件回放(Redis Stream) 补齐;而整页刷新 / 切换对话这种「冷启动」(前端 JS 上下文全没、连不上原来的重连逻辑)下要重新订阅并重建「正在跑的那一轮」,是后续增强加的一个探口 + 「进入对话即续看」路径补齐的(顺带把「切对话隐式取消任务」改成绝不隐式打断),详见 ENH-D §5。- 任务持久化(durability) = 抗服务端进程重启/崩溃。进程一死,内存里的 asyncio 任务 + dict 一起没,无外部态可恢复。本块「不做」的就是这一件,与刷新无关。
落地思路(单机定稿)。
- ✅ 做 · 并发背压(asyncio.Semaphore):任务级 semaphore 限全局并发;fork 级 semaphore(session 内)限单任务同时 fork 的子 Agent 数——用标准并发原语机械执行 retrieval budget,而非靠 prompt 约束模型遵守。零新依赖、立刻见效。
- ✅ 做 · 背压信号回前端:semaphore 满时
/api/task返回429 Too Many Requests+Retry-After,可附排队位置。 - ❌ 不做(写进毕业线)· 任务持久化 / 队列:单机、无「必须干完」语义(购物 query 中断重提交即可),且恢复半跑的多步 LLM Agent 流本身很难,ROI 为负。毕业线:出现多 worker / 必须干完 → Redis Stream 持久化队列(
XREADGROUP/XACK、状态机持久化、幂等键、死信、超时回收)或 arq;要「节点内部跑一半也能 replay 续跑」→ Temporal(checkpointer 只存节点之间,存不了节点内循环,这是要 Temporal 的理由)。
关键权衡。 CLAUDE.md §2.2 明确把 checkpointer / 持久化划在范围外(理由:一请求一进程内 async task,无跨进程恢复需求)——本块维持这条边界。要做 Durable Execution 就是主动推翻它,把「当初为何不做 / 现在为何值得做」讲成权衡故事,而非无脑上 Temporal 引重依赖。落地次序:Semaphore 背压(现在做,零依赖)→ Redis Stream(毕业线)→ Temporal(仅确有可恢复长任务时)。
设计亮点 / 叙事。 核心叙事:「用标准并发原语(Semaphore)而非 prompt 约束做 retrieval budget 控制——机械执行不依赖模型遵守指令。」(与 #1 fork 安全四层是同一套「机制兜底优于提示词」哲学。)
D. WebSocket 多副本路由 + 事件总线 / 回放#
解决什么问题。 两个层次,单机定稿里拆开处理:
- 事件回放(现在就做):现状 connect-first 协议只解决「任务起步早于首次连接」,不解决「中途断线重连」——用户刷新页面 / 短暂断网,断开窗口里发出的 AGUI 事件就丢了。任务本身在后台继续跑(见 C 块),但用户看到的事件流有个洞。
- 多副本路由(毕业线,不做):
ConnectionManager是进程内thread_id→WebSocket,部署 2 副本后任务在 A、连接在 B → 推不出去。单机不触发,写进毕业线。
落地思路(单机定稿)。
- ✅ 做 · 事件回放(Redis Stream):每个 thread_id 一条 Redis Stream,事件
XADD写入并MAXLEN自动裁剪(如保最近 200 条);stream ID 天然就是last_event_id,前端重连时带上它,服务端补发缺口事件再转直播。选 Redis Stream 而非进程内 ring buffer 的理由:Streams 本就是为「带位置追踪的事件日志」设计,且同一套 Stream 即下面多副本事件总线的底座——一个决定两处收益。 - ❌ 不做(毕业线)· 多副本事件总线:部署 ≥2 副本时,用 Redis Pub/Sub 做跨实例广播(任意 worker
publish(thread_id, event),持连实例subscribe转发,WS 层无状态化)——与上面的回放 Stream 共用一套 Redis 基础设施。 - 备选:SSE / token streaming + 背压做单向事件流(很多团队用 SSE 替代 WS 做单向流),不改 Redis 这一侧的决策。
关键权衡。 现状刻意用 connect-first 协议换掉了事件缓冲——单进程「起步」够用,但「重连」不够,正是事件回放要补的洞。回放选 Redis(已在跑)是因为它本质是「跨重连 / 将来跨进程」的持久事件日志,恰是 Stream 的甜区;多副本路由维持「讲得出为什么暂不做」。
设计亮点 / 叙事。 核心叙事:**「无状态化 + 跨实例消息路由 + 最终一致事件流——把『2 副本即坏』的硬伤改成可水平扩展。」**改造前后对比最鲜明(对应取舍表「次选」档)。
E. 多级缓存 + 语义缓存(本项目独特优势)#
解决什么问题。 已有 Cache Breakpoint 处理 token 成本,但数据层缓存还没系统设计。重复的品类查询、汇率转换、Qdrant 检索可通过缓存显著降低延迟和外部调用。
落地思路。
铁律:强时效数据(价格 / 库存)一律不进缓存。 购物场景缓存的最大事故源是「返回了旧价格 / 已下架商品」。因此
item_search的商品候选(带价格 / 库存)不缓存;只缓存弱时效对象:品类知识(category_insight)、汇率 / 税率表(本身按 TTL 刷新)。
| 缓存层 | 技术 | 缓存对象 | TTL | 失效策略 |
|---|---|---|---|---|
| L1 本地 | cachetools.TTLCache | category_insight 热品类结果(弱时效) | 1h | 自动过期 |
| L1 本地 | cachetools.TTLCache | 汇率表、税率表 | 24h | 定时刷新(不用 lru_cache,无 TTL) |
| —— | 不做:含价格 / 库存,强时效,缓存=返回旧价风险 | |||
| L2 Redis(毕业线) | Redis String + TTL | 仅弱时效对象的跨进程共享 | —— | 部署 ≥2 副本要共享热区时再上(见 §二·五 D) |
| L3 广播失效(毕业线) | Redis Pub/Sub | 知识库灌库后失效通知 | — | Subscriber 清对应 key,不全量 flush |
- 语义缓存(高杠杆,但限定弱时效域):非精确匹配——query 向量化,近似命中直接返回历史结果。业界报告减 30–60% LLM 调用、高重复场景 ~73% 降本,命中毫秒级 vs 几秒推理。本项目已有整套向量召回基建(Qdrant + embedding),做语义缓存几乎零额外依赖——把召回层复用成缓存层。
- 只对
category_insight做语义缓存(知识库内容弱时效、重复查询多、命中即省一整条 Hybrid+Reranker 链)。 item_search绝不做语义缓存:商品候选带价格 / 库存,「近似命中」返回别的 query 的旧结果在购物场景是硬伤。
- 只对
关键权衡。 语义缓存的相似度阈值是双刃剑:太松会返回「貌似相关其实不对」的结果,太紧命中率低。需配缓存命中率 metric(A 块)持续校准。把适用域收窄到 category_insight 这一弱时效对象,正是为了在拿到降本收益的同时绕开价格 / 库存的时效陷阱——先把安全的那块做透、用数据验证命中率与正确性,再谈是否扩域。
设计亮点 / 叙事。 缓存命中率本身是 Prometheus metric(cache_hit_total / cache_miss_total),接入 A 块可观测性体系。核心叙事:「category_insight 缓存命中率稳定在 30%+,省了 30% 的 reranker 调用——用数据说话。」
F. 轻量 LLM Gateway + 成本 / 用量治理(FinOps)#
解决什么问题。 现 llm.py 是 init_chat_model 裸封装,主 + 子 Agent 共享。Agent 是「成本放大器」(fork + 多轮 + 工具链,token 易失控);现状 token 用量只测不控(usage.py 是雏形)。
落地思路(本体 = 成本治理;跨 provider fallback 留毕业线)。
先认清现状再分主次。
llm.py现在是单一 OpenAI 兼容 endpoint(一个base_url/ 一份 key)。所谓「跨 provider fallback」前提是至少配两家 provider 的 key——现状没有第二家,硬写就是叙事倒地。因此本块本体是成本治理(用量归集 + 预算闸),fallback 视是否真有第二家 provider 决定。
- ✅ 本体 · 成本治理(FinOps):
- token metering:按 user / session / 工具维度归集用量与成本、可归因(升级现有
usage.py雏形——它已测 carried / peak / cache_read / 命中率,缺的是「按维度归集 + 落预算」)。 - 预算闸:per-session / per-user token 预算,超了熔断(把 fork 侧「检索次数预算」升级成全链路 token / 成本预算——次数闸已有,成本闸是新增)。
- 模型能力匹配任务难度:planner 便宜模型 / judge 强模型(
get_llm/get_fast_llm/get_judge_llm已是雏形,做实成本最优路由)。
- token metering:按 user / session / 工具维度归集用量与成本、可归因(升级现有
- 🟡 视条件做 · 轻量 LLM Gateway(自研薄层,参考 LiteLLM / Portkey 思想,不引重组件):
- 路由策略:优先级 / 最低延迟 / 成本优化(简单任务走便宜模型)/ 语义路由(按 query 内容选模型)——单 provider 下也成立,因为本质是「在同一家的多个模型间选」,可先做。
- 跨 provider fallback(毕业线):等真接入第二家 provider 的 key 再做(血泪教训:fallback 全指向同一家 = 没做,要跨 provider才有韧性意义)。在那之前
llm.py已有的max_retries=2是同 provider 内的兜底。 - provider cooldown / tpm/rpm 限额:单进程先用进程内令牌桶(见 §二·五 B),多 worker 撞 provider 限额再上 Redis(毕业线)。
关键权衡。 Gateway 自研 vs 引 LiteLLM:自研轻量层(路由 + 用量归集 + 预算闸)与 fork 高频外呼天然契合、可控;引 LiteLLM 省事但多一层重代理、且其核心价值(跨 provider 路由)在只有一家 key 时用不上。本项目规模建议自研轻量层,把「为什么不直接上 LiteLLM」「为什么 fallback 暂留毕业线」一起讲成权衡。
设计亮点 / 叙事。 核心叙事:**「token 预算从『测』升级到『控』——per-session 成本上限 + 超限熔断,FinOps 不是事后看账单而是事前设闸。」**复用 usage.py 现成雏形、几乎不引重依赖,降本数据好量化——当前能立刻落地、性价比最高的一块(跨 provider 那条等第二家 key 到位再点亮)。
G. Guardrails 安全护栏#
解决什么问题。 生产级远不止内容审核。现状 Agent 内部权限边界好(深度权限闸),但缺不可信内容隔离与工具入参校验。本项目有 web_search / category_insight 外部内容入口,做这层很自然。
落地思路。 输入校验(工具 schema / 参数范围)、工具级 RBAC、prompt injection 防御(把 RAG / web_search 拉回的内容当不可信源隔离)、输出约束(JSON schema / 引用)、高危动作人工审批、预算与工具调用次数上限。
关键权衡。 单拎出来后端味略淡,适合顺手做——与 B 块的结构化 ToolResult、F 块的预算闸、I 块的入参校验天然耦合,不必独立成大模块。
设计亮点 / 叙事。 「外部拉回内容默认不可信」是 Agent 普遍短板,补上即与多数 demo 拉开差距。
H. 数据管道 + Schema Evolution#
解决什么问题。 数据侧目前是「Kaggle CSV + 手动灌库」。加一层正式 ETL 展示数据工程功底,也为多平台数据接入(AliExpress 900k、eBay PromptCloud、Shopee)打基础。
落地思路。
- 四阶段标准化管线:
RawProduct →(统一 schema + 清洗)NormalizedProduct →(附加向量)EmbeddedProduct →(写入 Qdrant)IndexedProduct,每阶段用 Pydantic model 做 schema 校验,字段缺失 / 类型错误在入库前拦住。 - 入库门禁(Quality Gate):标题长度、价格合理区间、品类路径非空等规则,脏数据入库前拦截。
- Schema Migration(Qdrant 版):Qdrant 无 Alembic 式工具,自建版本化迁移脚本(如「为存量记录用小模型补
material字段并回写 payload」),记migration_version。 - 数据血缘(Lineage):每条入库商品带
source/ingested_at/pipeline_version/source_file,出问题可追溯来源。
关键权衡。 投入产出比定位在 P1——不是当前痛点,但为「多平台扩展」铺路,且 schema 校验 / 门禁是低成本高收益的源头防线。
设计亮点 / 叙事。 核心叙事:「数据质量决定召回质量,入库门禁是投入产出比最高的一层防线——从源头拦住脏数据,比事后在 prompt 里打补丁强一个数量级。」
I. API 层生产化加固#
解决什么问题。 当前 FastAPI 层接口在了,但无鉴权、限流、版本管理、幂等。这些是后端工程师的「基本功」。
落地思路。
- 🔴 JWT 鉴权中间件(P1,先做):
user_id从 token 解析而非前端传入——堵住伪造 user_id 读取他人 Store 的真实安全漏洞。现状user_id由前端传入、无任何校验(context.py有_user_id_var但无 token 验证),任意改user_id即可读他人长期偏好 Store,这是已存在的越权读漏洞,不是「完整度打磨」。与 CLAUDE.md「鉴权 / RBAC / 多租户留作业」呼应,但越权读这一条应优先堵上。 - 请求级限流(Sliding Window):基于 Redis ZSET /
INCR+EXPIRE的滑动 / 定窗限流,超限429 + Retry-After。 - API 版本管理:
/api/v1/api/v2路由前缀并存,平滑演进事件格式。 - 请求 / 响应 Pydantic Model:
TaskResponse带status/thread_id/estimated_seconds/position_in_queue。 - 幂等性:
idempotency_key命中直接返回上次结果,防重复提交。
关键权衡。 本块拆成两档:JWT 鉴权提到 P1(堵越权读,是安全洞不是打磨),其余(限流 / 版本 / 幂等)维持 P2 收尾。鉴权这一条与 G 块安全护栏、C/F 块 user 维度预算可共享 user_id 主体——一处解析、多处复用。
设计亮点 / 叙事。 核心叙事:「API 层不是 Agent 的附属品,而是独立的后端服务——鉴权、限流、幂等、版本管理一个都不少。」
J. 依赖注入 + 可测试性#
解决什么问题。 当前 store / monitor / get_llm() 都是模块级单例,单元测试很痛——无法 mock 掉 Qdrant / LLM 去测工具逻辑。
落地思路。
- 用 FastAPI 原生
Depends做依赖注入:get_store/get_llm作为可注入依赖。 - 测试时用
app.dependency_overrides替换为 MockStore / AsyncMock LLM。 - 工具实现从「内部直接 import client」改为「从 ContextVar / 参数获取 client」,测试时可替换。
关键权衡。 定位 P2,投入约 1 人天,收益是「可测试性从零到一」。改造面广(触及工具内部 client 获取方式),建议在骨架稳定后做,避免边改边返工。
设计亮点 / 叙事。 核心叙事:「能写出 mock 掉向量库和 LLM 的集成测试,在 demo 项目里几乎看不到——但这是后端工程师的基本素养。」
二·五、单机选型定稿(逐项拍板 + 毕业线)#
在「单机优先」哲学下逐项拍定。口径不是「能不能少碰 Redis」,而是该 Redis 的地方就 Redis、该进程内的地方就进程内,判据是这件事的本质。Redis / Qdrant / OpenSearch 均已在跑,本清单零新增基础设施。
选型三判据:
- 进程内原语在单机下严格更优的(并发闸、L1 缓存、session 内预算)→ 进程内;上 Redis 反而多一次网络往返 + 一个 SPOF。
- **本质是「跨请求 / 跨会话累计或持久」**的(per-user 预算、限流、事件日志)→ Redis 甜区。
- 本质是向量检索的(语义缓存)→ Qdrant;纯版 Redis 无向量能力,这是能力约束不是偏好。
A. 用 Redis(复用已在跑实例,零新基础设施)#
| 关注点 | 选型 | 为什么是 Redis 的活 |
|---|---|---|
| 长期记忆 Store | Redis hash(现状) | 跨会话持久,已在用 |
| per-user 跨会话预算 | Redis INCRBY+EXPIRE(日/月配额) | 跨请求累计、要持久 |
| API 限流 | Redis INCR+EXPIRE 定窗 | 跨请求计数、跨重启不重置 |
| 事件回放 | Redis Stream(XADD+MAXLEN,stream ID 即 last_event_id,重连补发) | Streams 专为事件日志设计;同一套 Stream 即 D 多副本事件总线的底座(一个决定两处收益) |
B. 进程内原语(单机下严格优于 Redis,非「躲 Redis」)#
| 关注点 | 选型 | 为什么进程内更优 |
|---|---|---|
| 任务背压(全局) | asyncio.Semaphore 单例 | 单进程并发闸,Redis 多一跳 |
| fork 背压(session 内) | asyncio.Semaphore 绑 ForkBudget | 复用现有 retrieval_budget |
| 任务编排 | asyncio.create_task + active_tasks dict(现状) | 后台跑、与 WS 解耦,刷新已扛住 |
| per-session 预算闸 | ContextVar(复用 ForkBudget) | session 天然单请求内 |
| L1 缓存(热品类) | cachetools.TTLCache(1h) | 微秒级、无网络往返 |
| L1 缓存(汇率/税率) | cachetools.TTLCache(24h) | 同上;不用 lru_cache(无 TTL) |
| provider cooldown / 限额 | 进程内令牌桶 | 单进程够;毕业线再上 Redis |
C. 走 Qdrant(能力约束)#
| 关注点 | 选型 | 为什么不是 Redis |
|---|---|---|
| 语义缓存 | Qdrant 独立 collection(复用 embedding) | 纯版 Redis 无向量检索;并强化「召回基建复用成缓存」叙事 |
D. 暂不做(写进毕业线,讲得出为什么)#
| 关注点 | 决定 | 触发毕业的信号 → 下一档 |
|---|---|---|
| 任务持久化(抗进程重启) | 不做 | 出现「必须干完」语义 / 多 worker → arq;可恢复长流 → Temporal |
| L2 跨进程缓存 | 不做 | 部署 ≥2 副本、要共享热区 → Redis String+TTL |
| WS 多副本路由 | 不做(进程内 dict) | 部署 ≥2 副本 → Redis Pub/Sub(+ 事件回放的 Stream) |
| provider 限额跨进程协调 | 进程内令牌桶 | 多 worker 并发外呼撞 provider 限额 → Redis |
横切能力(纯 Python 库,无新基础设施)#
降级链 + 结构化 degraded 回 Reflect(自研,B 块真亮点,对准 reranker/towers/web_search/LLM 4 个真实外呼)、断路器(自研或 pybreaker)、退避重试(tenacity,仅 timeout/5xx)、LLM Gateway 路由(纯 Python 薄层,单 provider 下做同家多模型路由;跨 provider fallback 留毕业线)、用量归集 + 预算闸(升级 usage.py)、结构化日志(structlog,ContextVar bind 上下文)、metrics(prometheus-client → /metrics)、鉴权(JWT,user_id 从 token 解析)、依赖注入(FastAPI Depends + dependency_overrides)。系统级 trace 不自建 OTel 栈——Langfuse v4 已基于 OTel 归并调用树;完整 opentelemetry-sdk/Jaeger 对接留毕业线。
新增依赖一览#
cachetools # L1 缓存
tenacity # 退避重试(仅 timeout/5xx)
structlog # 结构化日志
prometheus-client # metrics(/metrics)
pyjwt / python-jose # 鉴权(JWT,user_id 从 token 解析)
pybreaker(可选) # 断路器,也可自研省掉
# opentelemetry-sdk —— 不引:Langfuse v4 已基于 OTel 归并调用树,自建是重复造轮子(毕业线再说)plaintext基础设施零新增:Redis / Qdrant / OpenSearch 均已在 docker-compose 里跑。
三、取舍与优先级#
3.1 按与现有基建契合度分档#
| 档 | 模块 | 为什么适合本项目 |
|---|---|---|
| 首选(复用现有基建,故事独特,降本数据漂亮) | FinOps 预算闸(F 本体)、语义缓存(E,仅 category_insight 弱时效域) | 直接复用 usage.py 雏形与 Qdrant 召回层,几乎不引新重依赖;F 的跨 provider fallback 等第二家 key 再点亮 |
| 次选(填真实缺口,改造前后对比鲜明) | 事件回放(D,= #4 进阶)、任务持久化 / Durable Execution(C 毕业线,= #3 进阶) | 现架构真实单点 / 边界,「内存态单机」硬伤改造对比强烈 |
| 快赢(把「部分」补成「完整」,投入小) | 背压(C,#3 现做档)、韧性工程(B,#5)、成本预算闸(F,#6)、可观测性收缩版(A,#7) | Semaphore 并发闸、4 个真实外呼加退避 + 熔断、token 从测到控、补 Prometheus + structlog(不引 OTel 栈) |
| 加分(补短板,顺手做) | Guardrails(G)、数据管道(H)、API 加固(I)、依赖注入(J) | 补 Agent 安全 / 数据 / 服务完整度,单拎后端味略淡,适合收尾 |
3.2 优先级矩阵(含投入估算)#
「优先级」列的 ✅ = 已落地合入 main(见顶部落地状态表);无标记 = 暂未做。
| 优先级 | 模块 | 投入(人天) | 产出 | 解决的已知问题 |
|---|---|---|---|---|
| P0 ✅ | C 背压(Semaphore) | 1 | 并发控制 + retrieval budget leak | fork 失控 / 无并发上限 |
| P0 ✅ | B 韧性工程(断路器对准 4 个真实外呼) | 2–3 | 可用性从「全挂」到「优雅降级」 | reranker/towers/web_search/LLM 不稳定 |
| P0 ✅ | A 可观测性补全(收缩版:Metrics + structlog) | 1–2 | 系统级延迟/错误率从零到一 | 295s 延迟回归定位 |
| P1 ✅ | F FinOps 预算闸(用量归集 + 成本熔断) | 1–2 | token 从「测」到「控」 | 成本失控 |
| P1 ✅ | E 多级 + 语义缓存(语义缓存仅 category_insight) | 1–2 | 减少重复调用、降延迟 | category_insight 重复查询 |
| P1 ✅ | D 事件回放(Redis Stream) | 1 | 刷新/断线重连无缝续看 | 断开窗口事件丢失 |
| P1 ✅ | I-鉴权(JWT,从 I 块拆出) | 0.5–1 | 堵越权读 | 伪造 user_id 读他人 Store(真实安全洞) |
| P1 | H 数据管道 | 2–3 | 多平台数据接入基础 | 单数据集 / 无 schema 校验 |
| P2 | G Guardrails | 1 | 不可信内容隔离 + 入参校验 | 注入风险 |
| P2 | I-其余 API 加固(限流 / 幂等 / 版本) | 1–1.5 | 服务完整度 | 无限流 / 重复提交 |
| P2 | J 依赖注入 | 1 | 可测试性从零到一 | 无法 mock 测试 |
| 毕业线 | A OTel system trace(对接 Jaeger/Tempo) | 1–2 | trace 脱离 Langfuse | 现 Langfuse 已够,规模上来才换 |
| 毕业线 | F 跨 provider fallback | 0.5 | 单 provider 风险 | 需接入第二家 provider key 才触发 |
| 毕业线 | D WS 多副本路由(Redis Pub/Sub) | 1–2 | 从「2 副本即坏」到可水平扩展 | 部署单点(≥2 副本才触发) |
| 毕业线 | C 任务持久化(Redis Stream / Temporal) | 2–3 | 抗进程重启续跑 | 「必须干完」语义 / 多 worker |
建议执行顺序。 P0 先做(C 背压 → B 韧性 → A 收缩版可观测性,直接解决已知痛点,每块都有明确代码产出可 demo)→ P1 在骨架稳定后加入(F 预算闸 / E 缓存复用现成基建、性价比最高,先做;D 事件回放补刷新/断线 UX 缺口;I-JWT 鉴权堵越权读这一真实安全洞)→ P2 收尾打磨。毕业线档(A 的 OTel system trace、F 的跨 provider fallback、D 多副本路由、C 任务持久化)维持「讲得出为什么暂不做」,触发信号见 §二·五 D 表与各模块说明。整体最「独一份」的优势是已有一整套向量召回基建 + usage.py/Langfuse 现成观测雏形 → FinOps 预算闸 + 语义缓存(仅弱时效域,走 Qdrant) 最能复用、降本数据最漂亮、不引重型外部依赖,构成完整的「高可用 + 低成本 LLM 后端服务」故事。
四、整体架构增强后的分层视图#
┌─────────────────────────────────────────────────────────────────────┐
│ API Gateway Layer │
│ JWT 鉴权 · 限流 · 幂等 · 版本管理 · Pydantic Schema │
├─────────────────────────────────────────────────────────────────────┤
│ Task / Event Layer │
│ Semaphore 背压 · Redis Stream 事件回放 ·(毕业线)持久化队列 │
├─────────────────────────────────────────────────────────────────────┤
│ Agent Orchestration Layer │
│ AgentLoop · fork dispatch · ContextVar 隔离 + 日志上下文传播 │
├─────────────────────────────────────────────────────────────────────┤
│ Tool Execution Layer │
│ 9 工具 · Circuit Breaker · 降级链 · 指数退避重试 · Guardrails │
├───────────────────────┬─────────────────────────────────────────────┤
│ Retrieval Layer │ Caching Layer │
│ Qdrant (BGE-M3) │ L1 TTLCache · 语义缓存(Qdrant,仅弱时效) │
│ Reranker (BGE-v2-m3) │ (毕业线)L2 Redis · Pub/Sub 失效广播 │
├───────────────────────┴─────────────────────────────────────────────┤
│ LLM Gateway Layer │
│ 同家多模型路由 · 用量归集 · 预算闸(FinOps)·(毕业线)跨 provider │
├─────────────────────────────────────────────────────────────────────┤
│ Data Pipeline Layer │
│ Raw → Normalized → Embedded → Indexed · Quality Gate · Migration │
├─────────────────────────────────────────────────────────────────────┤
│ Observability Layer (横切) │
│ Prometheus Metrics · Structlog · Langfuse(已含 OTel 调用树追踪) │
├─────────────────────────────────────────────────────────────────────┤
│ Persistence Layer │
│ Qdrant · Redis · 文件系统 (session_dir) │
└─────────────────────────────────────────────────────────────────────┘plaintext五、面试叙事建议#
对每一块增强,用 「问题 → 方案 → 取舍 → 数据」 四段式讲述(以可观测性为例):
- 问题:「我们的 Agent 一次 query 会 fork 多个子 Agent 并发搜多个平台,定位延迟瓶颈时缺系统级 metrics——调用树 Langfuse 能看,但 QPS / 延迟分位 / 错误率没有看板。」
- 方案:「我没有再叠一套 OpenTelemetry——因为 Langfuse v4 本身基于 OTel、已经把多 fork 归并成一棵调用树。我补的是
prometheus-client暴露/metrics(tool_duration_seconds按工具分桶等)+structlog,复用已有的 ContextVar 机制让每条日志自动带 thread_id / fork_depth。」 - 取舍:「不自建 OTel system trace、不上 Jaeger——当前 Langfuse 的调用树够用,重复造一套 span 上下文是性价比最低的一档;真要把 trace 脱离 Langfuse 对接 Jaeger/Tempo 再说(毕业线)。」
- 数据:「补上 metrics 后看板定位到某外呼 P99 是其他的 3 倍,把它从同步等待改成 timeout + 降级(B 块)后,端到端 P95 从 12s 降到 6s。」
这种叙事展示的不只是「会用工具」,而是工程判断力(包括「什么时候不该造轮子」)。贯穿全文的两条主线值得反复强调:
- 机制兜底优于提示词:fork 安全四层(#1)、Semaphore 背压(C)、降级信息参与决策(B)——都是「用机制保证,不靠模型遵守指令」。
- 一套机制解决多个问题 / 复用现有基建:ContextVar 同时做隔离 + 日志上下文传播(A);Qdrant 召回层复用成语义缓存(E,限弱时效域);
usage.py升级成预算闸(F);Redis Stream 同时做事件回放 + (毕业线)多副本事件总线(D);trace 直接吃 Langfuse 已基于 OTel 归并好的调用树,不重复造轮子(A)。
参考来源#
- Durable Agent Execution in Production 2026 ↗
- Durable Execution in LangGraph ↗
- LangGraph vs Temporal(LangChain 官方) ↗
- LiteLLM Routing & Load Balancing ↗
- LiteLLM Fallbacks / Reliability ↗
- LLM Gateway in Production with LiteLLM ↗
- Semantic Caching for LLMs (Maxim) ↗
- FinOps for LLM Systems ↗
- Agentic AI Systems: Tools, Memory & Guardrails ↗
- LLM Integration: Rate Limiting, Caching & Fallbacks ↗