Files
projectAIpopular/AI代理功能开发/实施方案_代理层与缓存层.md
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

303 lines
19 KiB
Markdown
Raw Permalink 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.
# 实施方案:校园 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.py``app.include_router`,受 `proxy.enabled` 门控,≤5 行);
`webapp/src/App.vue`NAV 加"代理"项);`config/model_pool.json`(条目加 provider/in_hit_price);
`任务拆解与执行计划.md``AGENTS.md`(登记)。
---
## 3. 数据模型(SQLite DDL,库文件 `data/proxy.sqlite3`WAL 模式)
```sql
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` 段)
```jsonc
"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/completions``Authorization: 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-Key``settings.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. 模块接口签名(实现按此,不改名)
```python
# 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) > 2`system 之外 >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 广播同一结果
```
singleflight`dict[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;流中失败=aborted**usage 注入且客户端未要求时被过滤** |
| test_proxy_routes | 端到端:缓存命中账目(cost=0,charged>0,cached=1);未命中全程;402/429/413singleflight 两并发一次上游(计数 Mock);**stream=true 命中回放为合法 SSE****L2 命中 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 |