- 入库历史遗漏源码/测试: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
19 KiB
实施方案:校园 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 模式)
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 段)
"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=<ts> 走 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. 模块接口签名(实现按此,不改名)
# 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,决定缓存命中率的代码,测试最重):
- messages 序列化:role 与 content 交替拼接为
role\u0001content\u0002; - 剔除易变字段:
temperature/frequency_penalty/seed/request_id/时间戳类内容行; - 系统模板与资料前缀不参与 L1 哈希(桶+doc_version 已表达),只参与上游整形;
- 整形后消息顺序固定:
[canonical_system] → [doc_prefix] → [原 messages]; - 多轮判定:
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 |