本篇对应《项目原理.md》§1–§2 与仓库整体结构。架构设计的本质是"把变化关进笼子"——先想清楚什么会变,再决定什么不变。
一、全局架构图
先把整个系统装进一张图(对应 README 的 mermaid 架构图):
浏览器(原生 JS SPA,无构建工具)
│ HTTP / SSE(Bearer Token)
▼
FastAPI 路由层(auth / chat / kb / workflows / admin / integrations / mock)
│
├── LLM 网关(重试退避 · 失败降级 · 用户限流 · token 审计)
│ └── Provider 层(可插拔:Mock · OpenAI 兼容真流式)
│ └── OpenAI / DeepSeek / vLLM / Ollama…
├── 混合检索(TF-IDF / BM25 / 加权 RRF)
├── 工作流引擎(DAG 校验 · AST 白名单沙箱 · 模板渲染)
│ ├── 调用 LLM 网关
│ └── 调用知识库检索
└── SQLite (WAL):业务数据 + llm_calls 审计
一句话读图:所有能力收拢到一个后端,前端只做展示。这样将来接企业微信、ERP 时,它们调的是同一批 /api/* 接口,不需要重写逻辑。
二、核心设计决策:三层解耦
需求里有三个会各自变化的维度(01 篇埋下的伏笔在这里兑现):
- 模型会换:Mock → DeepSeek → 私有网关
- 助手会加:3 个内置岗位助手 → 供应链助手、财务助手……
- 知识库会建:SOP 库、产品资料库、供应商库……
如果把它们写死在一起("运营助手 = 调 DeepSeek + 查 SOP 库"),每加一个助手就要改一次代码。司南的做法是把三者拆成独立层次,运行时通过配置装配:
ModelProvider(模型接入抽象) ←—— 换模型动这里
岗位助手(= 一条数据库配置) ←—— 加助手 = 加一行配置
知识库(独立于助手存在) ←—— 建库传文档动这里
助手的全部"人格"就是 assistants 表里的几个字段:system_prompt(人设)、kb_id(绑哪个库)、provider/model(用哪个模型)、departments(哪些部门可见)。"助手"不是代码,是数据——这是整个平台"可扩展"的根。
三、技术选型:每个选择的理由与代价
后端:FastAPI + 同步风格
- 为什么 FastAPI:自动请求校验(pydantic)、原生支持 SSE
StreamingResponse、依赖注入天然适合做权限(05 篇会看到Depends(user_dep)的妙用)、自带/docs交互文档。 - 为什么多数路由用同步
def而不是async def:这是本项目最精妙的一个取舍。FastAPI 会把同步路由放进线程池执行;工作流引擎内部的http_api节点需要回调本服务 8005 端口的 mock 路由。如果执行路由是async def+ 同步 httpx,事件循环会被阻塞,自己等自己,死锁。同步def让线程池干活、事件循环保持空闲,回环请求自然被处理(《项目原理.md》§4.4"自锁规避")。 - 代价:单机线程模型,横向扩容要换 Redis 限流 + 迁移数据库(Roadmap 已明示)。这是有意接受的——平台定位是单机内部工具。
数据库:SQLite 标准库,无 ORM
- 为什么敢用 SQLite:单机部署、读写比高(读多写少)、零运维。开启 WAL 模式后"读写不互斥",对话/工作流并发时读不再被写阻塞。
busy_timeout=5000让并发写"等锁"而不是立刻抛database is locked。 - 为什么不用 SQLAlchemy:手写 SQL(
Row工厂 + 参数绑定)让每一行数据访问都可见、可审计——安全加固时能逐条确认没有拼接注入。代价是换 PostgreSQL 时要改 SQL 方言(Roadmap 中的已知项)。 - 为什么不用第三方鉴权库(passlib/jose):密码哈希用
hashlib.pbkdf2_hmac,会话令牌用secrets.token_hex,标准库足够且实现总共不到百行——供应链越少,部署越稳,审计面越小。
RAG:纯 numpy 哈希 TF-IDF,不上向量库
- 为什么:需求是"基于 SOP/产品资料这类关键词密集的中文文档回答"。哈希 TF-IDF(512 维固定桶)离线可用、零外部模型依赖、每次入库都是确定性的(测试友好)。
- 为什么这不算偷懒:检索接口
search_kb(conn, kb_id, query, top_k, mode)签名固定。将来要上语义向量,把实现换成 sentence-transformers 或外接 Milvus/pgvector,上层对话代码零改动(作者在 Roadmap 里也留了这条)。 - 怎么证明效果:
scripts/eval_rag.py用 30 份确定性合成语料 + 24 条 golden 查询量化对比——BM25 hit@1 75%,TF-IDF 只有 50%,混合 RRF 与 BM25 持平且保留第二路兜底。参数是测出来的,不是拍的(07 篇展开)。
前端:原生 JS SPA,无构建工具
- 为什么:目标用户是"要一个能用的内部系统",不是"要一个前端工程"。省掉 node_modules、webpack、npm audit 的全部复杂度;
static/三个文件(index.html / app.js / style.css)直接由 FastAPI 挂载。CSS 变量让"5 套主题"只是 5 个变量块。 - 代价:没有组件复用框架,页面复杂后 JS 会变长(本仓库 1356 行,尚可控)。
依赖哲学:requirements.txt 一共 8 个锁定版本
fastapi / starlette / python-multipart / uvicorn / httpx / numpy / python-docx / pytest——requirements.txt 头部注释注明这些版本是 2026-09 经 pip-audit 审计后整体升级的(清除了多条 PYSEC 公告)。每加一个依赖都是一次供应链决策,这个意识比任何具体选型都重要。
四、目录结构:按"职责"而非"类型"组织
sinan-ai-platform/
├── app/
│ ├── main.py # create_app 工厂 + 中间件(安全头/请求体上限)
│ ├── config.py # 全部配置与环境变量(端口/路径/限流/上传上限)
│ ├── db.py # SQLite 连接、建表、迁移、种子数据
│ ├── auth.py # 口令哈希 + 会话(纯标准库)
│ ├── access.py # 可见性判定(唯一真相源)
│ ├── providers.py # Provider 抽象:Mock / OpenAI 兼容
│ ├── llm_gateway.py # 网关:重试/降级/限流/审计
│ ├── knowledge.py # 分块 + 三模式混合检索
│ ├── workflows.py # DAG 校验/模板渲染/执行器
│ ├── safe_eval.py # code 节点 AST 白名单求值器
│ ├── uploads.py # 上传分块限量读取
│ ├── secretbox.py # API Key 静态加密
│ └── routers/ # 薄路由层:参数校验+权限+编排,7 个文件
├── static/ # 前端三件套
├── tests/ # pytest(conftest 夹具 + 13 个测试文件)
├── scripts/eval_rag.py # RAG 评估(CI 回归护栏)
├── eval/report.md # 评估报告
├── Dockerfile / docker-compose.yml / .github/workflows/ci.yml
└── 项目原理.md # 设计文档(含每轮"症状→根因→修复"记录)
三条组织原则:
- 路由薄、领域厚。
routers/只做参数校验、权限检查、调用编排;业务规则在领域模块(knowledge.py、workflows.py、llm_gateway.py)里,独立于 HTTP 可直接被测试驱动。 - 配置集中。所有"可能变"的数字(端口 8005、上传上限 2MB/5MB、限流 30/分钟、会话 7 天)集中在
config.py,且每个都有环境变量开关——部署时不用改代码。 - 安全规则唯一真相源。谁能看哪个助手/知识库,只写在
access.py一处(assistant_accessible/kb_accessible),对话、检索、工作流三处调用同一个判定——堵死"列表校验、使用处漏校验"。
五、从需求到架构的推演(心法)
- 先列变化维度,再定分层:模型/助手/知识库三个独立变化 → 三层解耦。
- 架构跟着部署形态走:单机内部工具 → SQLite + 进程内限流都是"对的",不是妥协;若第一天就按"百万用户"设计,你永远交付不了。
- 每个"不用 X"都要能说出为什么:无 ORM(审计面)、无鉴权库(供应链)、无前端框架(交付成本)、无向量库(需求匹配 + 接口留了升级点)。
- 风险点提前指认:用户写表达式 = 注入面 → 沙箱(08 篇);模型调用 = 成本与可用性 → 网关(06 篇);上传 = 内存 DoS → 限量读取(09 篇)。
动手清单
- 在纸上画出你自己的架构图(对照第一节,画完再看仓库的)
- 写下你的选型表:每个组件选了什么、为什么、放弃了什么、代价是什么
- 把目录结构建出来(下篇我们会让第一个文件真正跑起来)
下一篇进入动手环节:装环境、建骨架,让第一个 /api/health 接口在浏览器里返回 {"status":"ok"}。