# 实施方案:校园 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) 的 LRU,TTL 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 回补 est−actual 差额;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. 配置 schema(settings.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 收 SSE,stream=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/requests;h_p = Σin_hit/(Σin_hit+Σin_miss);revenue=Σcharged、 cost=Σupstream_cost、margin=Σmargin;支持 `?since=` 走 idx_ledger_ts。 归档:usage_ledger 超 180 天的行由 T-P8 顺带清理(年度 650 万行会拖慢聚合)。 ### 5.3 错误码 401 无效/注销 key;403 学生 suspended;402 余额或日上限不足;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 单例(keepalive;limits.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 才计分(避免全量比对);分数 = 加权 Jaccard(3-gram 权 2、2-gram 权 1); - ≥ sim_threshold(0.92) 判命中;**L2 语义命中累计 promote_frequency(5) 次后晋升为 L1 精确条目** (复用 v1 cache.py 的提升模式——考试周变体收敛后自动加速)。 **SSE 合成回放(缓存命中的流式客户端兼容)**:命中时按请求 stream 字段同构返回—— stream=true 则用缓存内容合成 SSE(复用缓存 id/format,content 按 ~20 字符分块 + finish_reason=stop + `data: [DONE]`),stream=false 返回标准 JSON。 **禁止**对 stream=true 客户端直返 JSON(OpenAI 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;注销 401;rpm 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/413;singleflight 两并发一次上游(计数 Mock);**stream=true 命中回放为合法 SSE**;**L2 命中 5 次后 L1 直接命中** | **隔离清单**:每测试独立 `tmp_path/proxy.sqlite3`;假时钟注入(禁 sleep);MockTransport 假上游; 不动 `config/settings.json` 真实文件(沿用 v4 事故后的备份/恢复模式)。 --- ## 9. WBS:T-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.py:AsyncClient 单例/超时/流式派发 + 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.py:200 条(重复≥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 |