Files
projectAIpopular/AI代理功能开发/实施方案_代理层与缓存层.md
T
tzt ce0f6170d3 chore: T-P-1 工作区收敛——并行会话成果与历史未入库文件整理入库
- 入库历史遗漏源码/测试:router_system 9 模块(agent/executors/inference/knowledge/
  memory/planner/skills/trace)、tests 11 个测试文件、config/knowledge 领域知识
- 入库根目录方案文档(v2/v3/可行性×2)、references 文献(arxiv 14-18/cnki_open/
  参考文献清单)、research 论文素材(routerarena/paper/中文文献 PDF)
- 前端构建产物刷新(新 hash);webapp 误写文档删除
- gitignore 增补:deepseek-harness、research/_refs、.mimosa/.zcode、网关日志/pid、
  临时调试脚本、tests/e2e/node_modules、AI代理功能开发/prefix
- 基线确认:318 passed
2026-09-05 08:28:25 +08:00

19 KiB
Raw Blame History

实施方案:校园 AI 代理层与缓存层(T-P0…T-P8 逐任务执行版)

编写日期:2026-09-05 状态:执行版(设计依据见《方案_校园AI代理层.md》;两者冲突时以本文为准) 预期读者:负责实现的 AI Agent。实现前必读:第 1 节锁定决策、第 9 节测试纪律。 总工期估算:约 13 个工作日;里程碑 M1(计费代理)→ M2(缓存栈)→ M3(管理面+验收)。


0. 范围与非目标

范围:现有 FastAPI app 内新增代理面(/proxy/v1/*)+学生 key 鉴权计费+两级缓存(精确→n-gram 语义) +管理端点与前端页+压测报告。

MVP 非目标(做了算超纲,除非另有指示):多上游 key 轮换/前缀亲和、embedding 向量检索 (预留 embedder 接口,M3 可选)、空闲时段自动调度(仅实现 is_offpeak(ts) 判定)、流式中途 failover、内容审核、多币种。

不改动router_system/(只允许 import router_system.cache);现有 /chat/agent/review 行为与全部既有测试。


1. 锁定决策(实现中不得擅改)

  • D-P1 货币:账本一律毫元整数(1 元 = 1000 毫元),禁止浮点;展示层格式化。
  • D-P2 桶来源:请求头 X-Campus-Bucket(缺省 default);model→bucket 可在配置映射。
  • D-P3 上游条目:复用 config/model_pool.json,条目新增可选字段 "provider": "deepseek|openai|anthropic"usage 归一化用)与 "in_hit_price"(可选,缺省 = in_miss×1/30)。
  • D-P4 failover:仅在上游首 token 返回前允许切换;流中失败 = aborted,按已收 usage 计费 (无 usage 按字符估算),不写缓存
  • D-P5 缓存准入:只缓存 finish_reason=="stop"完整单轮响应;错误/超时/断连/多轮一律不缓存。
  • D-P6 依赖纪律M1/M2 零新第三方依赖(语义缓存用 stdlib 倒排 n-gram);embedding + numpy 属 M3 可选项,启用须登记 requirements.txt 并说明理由。
  • D-P7 开关proxy.enabled == false 时不注册任何 /proxy 路由,行为与现状逐字节一致。
  • D-P8 凭据:上游主 key 只从 env / config/settings.json(已 gitignore)读取,代码/测试零字面量。
  • D-P9 进程模型uvicorn 单进程(workers=1)是正确性前提——singleflight、令牌桶、内存 LRU 均为进程内状态,多 worker 会静默失效(限流失效/重复打上游)。扩容路径只有性能预算触发的 数据面手术(方案 §3.5),不存在"加 worker"这个选项。
  • D-P10 DB 访问纪律sqlite3 是同步库,所有 DB 调用必须经 asyncio.to_thread(写路径可进一步 收敛为单写线程+队列);连接 check_same_thread=False + threading.Lock 串行化写。 热路径读(key→学生上下文)走进程内缓存:哈希→(student_id,status,caps) 的 LRUTTL 30s,写后失效。 违反此条,T-P8 的 P99 ≤50ms 会被同步 DB 卡死。
  • D-P11 计费两阶段(防并发超扣):预扣用原子 UPDATE UPDATE students SET balance_milli=balance_milli-? WHERE id=? AND balance_milli>=?rowcount=0 即 402), est = in_miss 价×字符估算 + out 价×min(max_tokens,4096)(宁可高估);settle 按真实 usage 回补 estactual 差额;void 全额退。不用 holds 表,一条 SQL 解决竞态。

2. 文件清单

新建

gateway/proxy/
├── __init__.py        # build_proxy_router(settings, pool) -> APIRouter;唯一组装点
├── config.py          # ProxyConfig:从 settings 读取 + 校验 + 默认值
├── errors.py          # ProxyAuthError(401)/QuotaError(429)/BalanceError(402)/UpstreamError(502)
├── auth.py            # key 签发/校验/注销 + 令牌桶 + 日限额
├── ledger.py          # DDL 初始化 + students/keys CRUD + hold/settle/void + 日志写入
├── pricing.py         # PriceTable + is_offpeak(ts) + compute(usage, model, ts) -> CostBreakdown
├── normalizer.py      # bucket 解析 + 规范化哈希 + PrefixShaper(整形后请求体)
├── semcache.py        # 精确 L1 + n-gram 倒排 L2 + TTL/版本失效 + singleflight
├── upstream.py        # httpx 流式派发 + usage 三家归一化 + 首 token 前 failover
└── routes.py          # /proxy/v1/chat/completions、/proxy/v1/models、/proxy/admin/*
scripts/bench_proxy.py # E-P1/E-P2 数据管道:200 条校园模拟请求 → 命中率/毛利报告
webapp/src/views/ProxyView.vue
tests/test_proxy_{auth,ledger,pricing,normalizer,semcache,upstream,routes}.py

修改gateway/api.pyapp.include_router,受 proxy.enabled 门控,≤5 行); webapp/src/App.vueNAV 加"代理"项);config/model_pool.json(条目加 provider/in_hit_price); 任务拆解与执行计划.mdAGENTS.md(登记)。


3. 数据模型(SQLite DDL,库文件 data/proxy.sqlite3WAL 模式)

CREATE TABLE students(
  id INTEGER PRIMARY KEY, name TEXT NOT NULL, class TEXT DEFAULT '',
  status TEXT NOT NULL DEFAULT 'active',            -- active|suspended
  balance_milli INTEGER NOT NULL DEFAULT 0,
  daily_cap_milli INTEGER NOT NULL DEFAULT 5000,
  spent_today_milli INTEGER NOT NULL DEFAULT 0, spent_date TEXT DEFAULT '');

CREATE TABLE proxy_keys(
  id INTEGER PRIMARY KEY, key_hash TEXT UNIQUE NOT NULL, key_prefix TEXT NOT NULL,
  student_id INTEGER NOT NULL REFERENCES students(id),
  created_ts INTEGER NOT NULL, revoked INTEGER NOT NULL DEFAULT 0,
  rpm_cap INTEGER NOT NULL DEFAULT 10, day_cap_req INTEGER NOT NULL DEFAULT 200,
  req_today INTEGER NOT NULL DEFAULT 0, req_date TEXT DEFAULT '');

CREATE TABLE usage_ledger(
  request_id TEXT PRIMARY KEY, ts INTEGER NOT NULL, key_id INTEGER NOT NULL,
  model TEXT NOT NULL, bucket TEXT NOT NULL DEFAULT 'default',
  in_miss_tok INTEGER NOT NULL DEFAULT 0, in_hit_tok INTEGER NOT NULL DEFAULT 0,
  out_tok INTEGER NOT NULL DEFAULT 0, gateway_cached INTEGER NOT NULL DEFAULT 0,
  upstream_cost_milli INTEGER NOT NULL DEFAULT 0, charged_milli INTEGER NOT NULL DEFAULT 0,
  margin_milli INTEGER NOT NULL DEFAULT 0, ttfb_ms INTEGER, total_ms INTEGER,
  status TEXT NOT NULL);                            -- ok|cached|error|aborted|insufficient

CREATE TABLE semcache(
  cache_key TEXT PRIMARY KEY,                       -- bucket + '|' + sha256(norm_q)
  bucket TEXT NOT NULL, q_norm TEXT NOT NULL, answer TEXT NOT NULL, model TEXT NOT NULL,
  created_ts INTEGER NOT NULL, ttl_ts INTEGER NOT NULL,
  doc_version INTEGER NOT NULL DEFAULT 1, hits INTEGER NOT NULL DEFAULT 0);
CREATE INDEX idx_semcache_bucket ON semcache(bucket, ttl_ts);
CREATE INDEX idx_ledger_ts ON usage_ledger(ts);            -- stats 按时间范围聚合用

前缀资料文件(doc_prefix_file 指向的 txt)不入库不入 git(内容属课程方),gitignore 加 AI代理功能开发/prefix/


4. 配置 schemasettings.json 的 proxy 段)

"proxy": {
  "enabled": true,
  "admin_key": "",
  "buckets": {
    "default": {"system_template": "你是校园学习助手。", "doc_prefix_file": null,
                 "doc_version": 1, "ttl_hours": 72},
    "course_python": {"system_template": "你是 Python 课程助教。", 
                       "doc_prefix_file": "AI代理功能开发/prefix/python24.txt",
                       "doc_version": 1, "ttl_hours": 168}
  },
  "pricing": {   // 上游成本价:元/1M tokens;示例为 Flash 档高峰价,以官网价目为准
    "deepseek-chat": {"in_miss": 3.0, "in_hit": 0.1, "out": 9.0},
    "peak_window": {"start": "08:30", "end": "23:59"},   // 窗口外按 off-peak 系数 0.5
    "offpeak_factor": 0.5,
    "sale_discount": {"in": 0.5, "out": 0.5}             // 学生售价折扣
  },
  "limits": {"rpm_per_key": 10, "day_req_cap": 200, "concurrent_per_key": 2,
              "max_body_chars": 60000},
  "semcache": {"enabled": true, "sim_threshold": 0.92, "max_entries": 300000,
                "promote_frequency": 5}
}

峰值/空闲窗口与官方"高峰时段"定义对齐,上线前按官网核对一次。


5. API 契约

5.1 学生面(OpenAI 兼容)

  • POST /proxy/v1/chat/completionsAuthorization: Bearer sk-campus-… body = OpenAI chat 格式 + 可选头 X-Campus-Bucket计量不依赖客户端行为 代理向上游始终注入 stream_options: {"include_usage": true},客户端未要求 usage 时 过滤该 chunk 不下发。响应与客户端请求同构:stream=true 收 SSEstream=false 收 JSON 缓存命中也按此回放SSE 合成,见 §6)。
  • GET /proxy/v1/models → 池内允许的模型名列表。

5.2 管理面(X-Admin-Key 鉴权,见本节末)

端点 说明
POST /proxy/admin/students 建学生 {name, class, balance_yuan, caps}
POST /proxy/admin/students/{id}/topup {amount_yuan} 充值
POST /proxy/admin/keys {student_id, rpm_cap?, day_cap_req?} → 明文 key 只返回一次
POST /proxy/admin/keys/{id}/revoke 注销
GET /proxy/admin/stats {requests, h_g, h_p, revenue, cost, margin, by_bucket}
GET /proxy/admin/ledger?student_id=&limit=&offset= 流水分页

管理面鉴权:请求头 X-Admin-Keysettings.proxy.admin_key 比对 hmac.compare_digest,防时序侧信道);未配置 admin_key 时仅放行 loopback 来源。 stats 口径h_g = cached/requestsh_p = Σin_hit/(Σin_hit+Σin_miss)revenue=Σcharged、 cost=Σupstream_cost、margin=Σmargin;支持 ?since=<ts> 走 idx_ledger_ts。 归档:usage_ledger 超 180 天的行由 T-P8 顺带清理(年度 650 万行会拖慢聚合)。

5.3 错误码

401 无效/注销 key403 学生 suspended402 余额或日上限不足;429 限流; 502 上游失败(首 token 前 failover 均失败);body 超 max_body_chars → 413。


6. 模块接口签名(实现按此,不改名)

# auth.py
def issue_key(ledger, student_id, rpm_cap=None, day_cap_req=None) -> str  # 明文仅此一次
def authenticate(authorization: str, ledger, limits, now) -> AuthContext  # raises ProxyAuth/Quota
class RateLimiter: allow(key_id, rpm_cap) -> bool

# ledger.py
class Ledger:
    def init_db(cls, path) -> Ledger
    def upsert_student / topup / set_status(...)
    def check_and_count(key_id, student_id, now) -> None        # 日限额双检
    def try_hold(student_id, est_milli) -> bool                  # 原子预扣(D-P11),False=402
    def settle(request_id, actual_milli, ...) -> None            # 结算 + 回补 est-actual 差额
    def void(request_id) -> None                                  # 全额退(上游失败)
    def record(UsageRow) -> None                                  # 幂等:request_id 主键

# pricing.py
def is_offpeak(ts, window) -> bool
def compute(usage: Usage, model: str, ts: int, cfg) -> CostBreakdown
# CostBreakdown: upstream_cost_milli, charged_milli, margin_milli(全整数毫元)

# normalizer.py
def resolve_bucket(body, headers, cfg) -> BucketCfg
def canonical_hash(bucket, doc_version, body) -> str             # 稳定序列化→sha256
def shape(body, bucket_cfg) -> dict                              # 整形后上游请求体

# semcache.py
class SemanticCache:
    def lookup(bucket, doc_version, norm_hash, norm_text, now) -> Hit|None
    def put(bucket, doc_version, norm_hash, norm_text, answer, model, now) -> None
    def stats() -> {entries, hits, h_g}

# upstream.py —— 模块级 httpx.AsyncClient 单例(keepalivelimits.max_connections=100),
# 超时 connect=10s / read=120s(流式整段)/ write=10s / pool=30s;始终注入 include_usage(§5.1)。
async def stream(body, entry, usage_sink) -> AsyncIterator[bytes]
def normalize_usage(provider, usage_dict) -> Usage               # 三家字段→统一 Usage

规范化规则(normalizer,决定缓存命中率的代码,测试最重)

  1. messages 序列化:role 与 content 交替拼接为 role\u0001content\u0002
  2. 剔除易变字段:temperature/frequency_penalty/seed/request_id/时间戳类内容行
  3. 系统模板与资料前缀不参与 L1 哈希(桶+doc_version 已表达),只参与上游整形;
  4. 整形后消息顺序固定:[canonical_system] → [doc_prefix] → [原 messages]
  5. 多轮判定:len(messages) > 2system 之外 >1 条)→ cacheable=False

语义相似度精确定义(semcache L2,误命中=返回错答案,此节测试最重)

  • q_norm 取字符 2-gram + 3-gram 集合(中文天然适配,无需分词);
  • 倒排索引驻内存(gram→cache_key 列表),启动时由 semcache 表 q_norm 重建
  • 候选门限:共享 gram ≥3 才计分(避免全量比对);分数 = 加权 Jaccard3-gram 权 2、2-gram 权 1);
  • ≥ sim_threshold(0.92) 判命中;L2 语义命中累计 promote_frequency(5) 次后晋升为 L1 精确条目 (复用 v1 cache.py 的提升模式——考试周变体收敛后自动加速)。

SSE 合成回放(缓存命中的流式客户端兼容):命中时按请求 stream 字段同构返回—— stream=true 则用缓存内容合成 SSE(复用缓存 id/formatcontent 按 ~20 字符分块 + finish_reason=stop + data: [DONE]),stream=false 返回标准 JSON。 禁止对 stream=true 客户端直返 JSONOpenAI SDK 会解析失败)。


7. 请求处理时序(routes.py 主流程)

auth(热路径缓存) → 限流/日额 → body 校验(413) → bucket/规范化 → L1 精确查 → L2 n-gram 查
 ├命中: settle(cached, cost=0, charged=按售价) → SSE/JSON 同构回放(§6
 └未命中: singleflight 登记 → try_hold(est 原子预扣,不足 402) → shape(body) → upstream.stream
     → tee: 逐块转发客户端 + 累积(含被过滤的 usage chunk
     → 结束: usage 归一化 → compute → settle(actual,回补差额) → stop 且单轮)semcache.put
     → 异常: void 退预扣 / aborted 按已收 usage 记账;均不缓存;singleflight 广播同一结果

singleflightdict[norm_hash → asyncio.Future]上限 256 条(超出旁路不合并,防考试周 变体洪峰撑爆内存);第二个到达者 await 同一 Future;等待方 60s 超时自行降级直连。


8. 测试计划

文件 必测用例
test_proxy_auth 签发→鉴权通过;错 key 401;注销 401rpm 429;日请求上限 429
test_proxy_ledger hold/settle/void 全链条(预扣-结算-回补一致);request_id 幂等;日限额跨日重置(注入日期);并发 N 路同时预扣,余额不足者精确拒绝、不超扣
test_proxy_pricing 峰谷边界(假时钟 ±1 分钟);毫元取整不漂移(≥10 组黄金用例);margin = charged cost
test_proxy_normalizer 同语义输入同哈希;易变字段剔除;整形顺序固定;doc_version+1 后旧缓存不可见;多轮 cacheable=False
test_proxy_semcache 精确命中;n-gram 阈值上命中/下未中;TTL 过期(假时钟);失败不缓存;entries 上限 LRU
test_proxy_upstream 三家 usage 形状归一;SSE 透传逐字节一致;首 token 前 failover;流中失败=abortedusage 注入且客户端未要求时被过滤
test_proxy_routes 端到端:缓存命中账目(cost=0,charged>0,cached=1);未命中全程;402/429/413singleflight 两并发一次上游(计数 Mock);stream=true 命中回放为合法 SSEL2 命中 5 次后 L1 直接命中

隔离清单:每测试独立 tmp_path/proxy.sqlite3;假时钟注入(禁 sleep);MockTransport 假上游; 不动 config/settings.json 真实文件(沿用 v4 事故后的备份/恢复模式)。


9. WBST-P0…T-P8(依赖顺序执行,每任务一 commit feat(proxy): T-Pn 描述

# 任务 产出/步骤 验收(可执行命令级) 估时
T-P0 骨架 gateway/proxy/ 六文件空实现 + DDL + 挂路由(enabled 门控) pytest tests -q 全绿(318→新增骨架测试);开关关闭时 /proxy/* 404 0.5d
T-P1 鉴权+账本 auth.py + ledger.py(原子预扣 try_hold/settle/void + 热路径缓存)+ 管理端点 test_proxy_auth/ledger 全绿;并发预扣不超扣;curl 用签发 key 过 401 2d
T-P2 上游客户端 upstream.pyAsyncClient 单例/超时/流式派发 + usage 注入与过滤 + 三家归一化 + failover test_proxy_upstream 全绿;curl 流式可见逐块输出 2d
T-P3 计价+结算 pricing.py + 账本结算接线 test_proxy_pricing 全绿(≥10 黄金用例) 1d
T-P4 路由端到端 routes.py 主时序(不含缓存分支) OpenAI SDK 指 /proxy/v1 对话成功;账本三值一致;402/429/413 正确 1.5d
T-P5 规范化+桶 normalizer.py + 配置桶 test_proxy_normalizer 全绿 1d
T-P6 语义缓存 semcache.py(含倒排索引重建/晋升/LRU+ SSE 合成回放 + singleflight + 接线 + scripts/warm_prefix.py(桶前缀预热,max_tokens=1 test_proxy_semcache 全绿;两并发同请求上游仅 1 次;stream=true 命中回放合法 SSE 2.5d
T-P7 管理面+前端 stats/ledger 端点 + ProxyView.vue(三卡片) 看板真实数据渲染;npm run build 产物更新 2d
T-P8 压测+预算 scripts/bench_proxy.py200 条(重复≥50%+200 并发;--live 模式(真实 key 50 条子集测真实 h_p,mock 模式 h_p 为可配置常数仅供联调);顺带 ledger >180d 归档清理 P99 附加延迟≤50ms;内存≤1GB;账目零不一致;报告入 AI代理功能开发/bench/ 1.5d

依赖链:T-P0 → T-P1 → T-P2 → T-P3 → T-P4 → (T-P5 → T-P6) → T-P7 → T-P8。 总验收 = M1(T-P0..4) + M2(T-P5..6) + M3(T-P7..8)E-P1 报告显示 h_g+h_p ≥ 50% 且统一 5 折毛利为正 (h_p 须以 --live 实测为准——mock 上游给不出真实的 prompt_cache_hit_tokens)。


10. 风险与回滚

风险 对策
n-gram 语义误命中(相似≠可答) 阈值 0.92 起步 + 桶隔离 + TTL;E-P3 抽检正确率,误答率高先调阈值再考虑 M3 embedding
单进程瓶颈 性能预算压测(T-P8)触发才议数据面手术(见方案 §3.5)
上游调价 价格表纯配置热改;model_pool 多供应商条目
账目漂移 毫元整数(D-P1+ request_id 幂等 + T-P8 账目零不一致验收
测试污染真实配置 隔离清单 + settings 备份/恢复模式(v4 事故先例)
误改多 worker 部署 D-P9 写入部署文档与 serve.py 启动参数校验(workers>1 时拒绝启动并提示)
同步 DB 卡事件循环 D-P10 强制 to_thread + 代码评审清单项;T-P8 压测显式测 DB 路径 P99