选型指南 架构设计 含可复制代码

AI 导航站从 0 到 1:Next.js + FastAPI 的架构怎么切

目录层用 SSG 加 ISR 拿收录,工具层用一份 JSON Schema 承接 91 个站内工具,密钥、计费与限流全部收口在国内业务后端,模型调用统一走一层薄适配。这篇把我们真实的目录结构、Schema 字段定义、路由闸门、预扣结算伪代码和合规清单完整摊开。

陈
陈默架构组
2026-09-28 · 14 分钟阅读 · 8,432 次浏览

过去一年,我们把 云枢 AI导航 从一张 Figma 稿做到了 115 个收录工具,其中 91 个可以在站内直接跑完。整个过程最大的教训不是「该选哪个框架」,而是:目录层和工具层压根不是一类东西,绝不该用同一套渲染策略。前者是给搜索引擎和赶时间的人看的文档,后者是会花钱、会失败、会被刷的在线应用。把这两件事混在一起,项目一定会在第 50 个工具前后崩掉。

一、为什么必须把目录层与工具层切开

先给个定义。目录层指的是工具介绍页:一个工具一段原创描述、一个官方入口、几张截图,本质是结构极其稳定的内容页,一个月可能只改一次。工具层指的是站内可运行的工具:用户填表单、后端拼提示词、调用模型、流式回显结果,本质是带状态、有成本、需要鉴权的应用。它们在缓存、并发、权限和账单上的要求完全相反。

两条链路的真实差异

  • 目录层读多写少,内容七天变一次,可以被 CDN 和爬虫随意缓存,匿名也能访问。
  • 工具层一次请求对应一次真实成本,必须登录、必须限流、必须落流水,任何一层都不允许缓存结果。
  • 目录层追求的是收录量和首屏速度,工具层追求的是首字节延迟和成功率。
  • 目录层挂掉只损失一个页面的流量,工具层挂掉会直接把用户的积分吞掉,属于事故。

这也是我们最终没有选择 pbootcms、织梦这类传统 PHP CMS 起手的原因。它们擅长的是把栏目、文章、TAG 组织成列表页和详情页,模板标签几行就能铺满全站;但站内工具需要的能力——多租户表单、模型调用、SSE 流式响应、积分冻结与退还——全都要绕开 CMS 主流程另写一套。写到最后就变成「CMS 里塞了一个应用」:模板引擎和业务代码互相污染,运营改一句话可能影响计费逻辑,目录页自身的增量构建和边缘缓存还得在 Nginx 层再补规则。我们最后的切法是:目录层交给 Next.js App Router,工具层交给 FastAPI,两边只共享一份 PostgreSQL,通过内网只读账号通信。

一句话判断标准:如果这个页面「用户能操作并产生成本」,它就不属于 CMS;如果这个页面「只用来被读和被搜」,它就不该进业务服务。

二、ISR / SSG:让 115 个目录页被搜索引擎吃下去

目录页我们走全量 SSG。构建时从 PostgreSQL 拉一份工具元数据(115 条,序列化后不到 3 MB),用 generateStaticParams 生成 /tools/[slug] 的静态路径,产物随镜像一起发布,首屏直接命中 CDN,回源率接近零。工具说明、适用人群、免费额度这类文案全部存在 CMS 的结构化字段里,不允许前端硬编码。

真正需要 ISR 的是聚合页:分类页、TAG 页、排行榜、最新收录。这些页面每天都会变,但没必要每次请求都回源。我们把 revalidate 统一设成 1800 秒,同时在 CMS 保存工具时通过路由处理器主动调用 revalidatePath,做到「改完几秒内生效,平时完全不打扰数据库」。对于浏览量这类实时数字,宁可放在客户端异步加载,也不要把整页变成动态渲染。

目录页的 SEO 清单

  • 每个工具页必须有唯一 H1 和一段 80 字以上的原创描述,绝不能直接复制官方卖点,重复描述会被判成低质聚合。
  • canonical 指向自身;分页页用 rel="next" 与 rel="prev" 串联,避免筛选参数组合出成千上万个重复 URL。
  • sitemap 分片输出,每 5,000 条一个文件,并在 sitemap index 里登记 lastmod,方便爬虫增量抓取。
  • 结构化数据只用 SoftwareApplication 与 BreadcrumbList 两个类型,别为了凑字段堆砌评分、价格等无法自证的数据。
  • 内链固定成两条:每个工具页挂 3 个同分类工具和 1 篇相关教程,教程再反向链接回工具,形成闭环。

这套组合做完,新页面的收录周期从最早的 11 天压到了 2 天左右,TAG 聚合页目前贡献了大约 34% 的自然搜索入口。它的前提是:目录页是纯静态的 HTML,不需要 JS 就能读到主要内容。

三、动态工具引擎:一份 JSON Schema 承接全部表单

61 个工具如果每个都手写一张前端表单,前端团队会被彻底拖死,而且每加一个字段就要发一次版。我们的做法是后端下发 Schema,前端只实现一次渲染器。这份 Schema 同时承担四件事:字段渲染、前端即时校验、提示词变量绑定、计费口径声明。

// app/tools/[slug]/page.tsx
import { ToolRunner } from "@/components/tool-runner";
import { getToolSchema } from "@/lib/cms";

export const revalidate = 1800;

export default async function ToolPage({ params }: { params: { slug: string } }) {
  // getToolSchema 内部带 60 秒内存缓存,避免构建期与运行期重复查库
  const tool = await getToolSchema(params.slug);

  return (
    <ToolRunner
      slug={tool.slug}
      fields={tool.fields}          // 渲染器据此产出 input / select / range / switch / file
      creditCost={tool.creditCost}  // 按钮上直接显示「本次消耗 8 积分」
      model={tool.model}
      rateLimit={tool.rateLimit}    // 前端只做提示,真正的限流在网关
    />
  );
}

字段类型目前只保留六种:text、textarea、select、range、switch、file。类型越少,渲染器和校验逻辑越可控。我们踩过最大的坑是早期支持了「动态联动下拉」——上游选 A 时下游换成另一组选项——结果表单状态机复杂度直接翻倍,跨字段校验、默认值回填、Schema 缓存全部要重写,最后砍掉,改成两组静态 select 加一次后端校验。

Schema 设计的三条硬规矩

  • 每个字段必须有 key、type、label;key 只允许小写字母与连字符,它会直接作为提示词模板里的变量名,命名不规范会在渲染期直接报错。
  • 校验规则写在 Schema 里(min / max / maxLength / pattern),前端只负责即时反馈,后端必须按同一份规则再校验一次,绝不信任客户端。
  • 计费声明写在工具级而不是字段级:credit_cost 固定,另加 max_tokens 上限,避免用户拖一个滑块把单次成本顶穿。
  • 文件类字段只接受白名单 MIME 与体积上限,大于 20 MB 的输入一律转异步队列,同步接口直接拒收。

四、模型调用的工程约束

站内每个工具都要调模型。把这些调用散在各处是最常见的欠债:密钥、重试、超时、账单会散落在一百多个地方,任何一次轮换密钥都是一场灾难。我们的做法是把它收成一层薄薄的调用层 —— 业务代码只表达「做什么」,不关心「调谁、怎么重试」。

统一协议,别让差异外溢

主流厂商大多提供了彼此兼容的调用形式。把内部契约统一到 chat/completions,业务代码就不需要为每家写分支;新接入一家厂商时,差异只收敛在适配层里。反过来,一旦供应商差异渗进业务代码,往后每加一家都要动一遍所有工具 —— 这是「新增工具几乎零成本」这个目标最早的破坏者。

密钥与出网边界

  • 凭据只存在服务端的密钥管理里,数据库只存引用(形如 env:XXX_API_KEY),后台界面永远只返回掩码,从不回传明文。
  • 业务容器出网只允许白名单域名,其余出站直接拒绝;前端拿不到任何密钥,包括构建期变量。
  • 每个请求带 tool_id 与 request_id,落一条账本记录;上游返回的 request-id 也透传回来存下,否则对账时无法举证任何一笔调用。
  • 不同代次的模型对参数的要求不一样(长度参数名、是否接受采样温度)。这张兼容矩阵必须由适配层吸收,不能让每个业务模块自己记。

三个最容易翻车的细节

  • 反向代理必须关缓冲。proxy_buffering off; 加上 X-Accel-Buffering: no。不关的话流式响应会被攒成一次性返回,用户盯着转圈等到最后才出结果 —— 你会以为是模型慢,其实是自己的 Nginx。这是这类架构最高频的翻车点。
  • HTTP 连接池要全局复用。每请求新建客户端会不断重做 TLS 握手,直接吃掉首 token 预算。在应用启动时建一次,配好 keepalive 与 HTTP/2。
  • 重试要分阶段。建连失败可以退避重试;已发出但未收到首字节,重试必须带幂等键;已经进入流式之后一律不许重试,否则就是双倍计费。参数类错误(400/401/403)绝不重试,直接告警。

还有一个「静默失败」值得单独防:部分推理模型会把输出预算花在内部思考上,长度上限给得太小就会返回 HTTP 200 但正文为空。这类响应绝不能被当成结果写库 —— 适配层要能识别它、放大上限重试一次,仍为空则告警并退还积分。

最后是稳定性的一件事:模型供应商会抖动,所以工具与模型必须解耦。工具只声明它需要什么能力(上下文长度、是否需要视觉、是否用函数调用),由路由层去挑当下可用的模型;某个供应商不可用时能够降级,但降级必须显式告知用户,不能悄悄换掉。用户为某个模型付了积分,被静默替换是直接的信任损伤。

五、积分预扣、结算与 Redis 限流

积分系统的核心矛盾很简单:模型按 token 计费,用户按次感知。如果不预扣,用户可以并发发起五十个请求,在余额校验和真实扣费之间把额度刷穿;如果预扣不退还,生成失败就会变成投诉来源。我们最终的口径是「预扣、结算、幂等」三段式。

三段式计费

  • 预扣:请求进入时按工具定价冻结对应积分(reserve),余额不足直接返回 402,不做任何排队。
  • 结算:拿到厂商返回的 usage 后换算真实成本,多冻结的部分原路退还,每次结算写一条不可变流水。
  • 幂等:request_id 全链路唯一,网关与积分服务各保存一份 24 小时幂等键,重放请求只回放首次结果,只扣一次。
  • 兜底:中途断流按已产出内容结算;厂商 5xx 或超时未产出,全额退还并记一条平台侧损耗。
# api/billing.py
async def reserve_and_run(user_id: str, tool: ToolSpec, body: ChatBody, request_id: str):
    # 1) 幂等:同一 request_id 直接回放首次结果,绝不重复扣费
    cached = await idem.get(request_id)
    if cached is not None:
        return cached

    # 2) 预扣:按工具定价冻结,而不是按 token 上限猜
    estimate = tool.credit_cost
    if not await ledger.reserve(user_id, estimate, request_id):
        raise HTTPException(402, "积分不足,请先充值")

    charged = 0
    try:
        async for chunk in upstream.stream(body):        # 统一转发到具体厂商
            if chunk.usage is not None:
                charged = to_credits(chunk.usage)        # token -> 积分
            yield chunk
    except UpstreamError:
        await ledger.refund(user_id, estimate, request_id)   # 厂商故障全额退
        raise
    finally:
        # 3) 结算:多冻部分原路退还,流水不可变
        await ledger.settle(user_id, estimate, charged, request_id)
        await idem.set(request_id, charged, ttl=86400)

Redis 在这条链路里承担三件事:限流、幂等键、余额热点缓存。限流我们用滑动窗口而不是固定窗口——固定窗口在交界处会放过接近两倍的流量,我们早期就被这个坑刷过一次,凌晨两点被迫给一位用户手工回滚了 4,000 积分。现在的粒度是「用户 × 工具 × 分钟」,默认 3 次/分钟,出图类工具降到 2 次/分钟。

  • 令牌桶判定写成 Lua 脚本,滑动窗口用 ZSET 存时间戳,一次 round-trip 完成判定与写入,避免多次 INCR 造成竞态。
  • 余额是两层结构:Redis 缓存加 PostgreSQL 权威。读走缓存,写必须过数据库事务并同步失效缓存,冲突时以数据库为准。
  • 流水中台每天与厂商账单对账一次,差额超过 0.5% 就告警,通常意味着某类工具的 token 估算偏离了实际。

想直接跑一遍这套网关?

注册 云枢 AI导航 即送 200 积分,够跑 25 次合同审查或 33 张 Flux 出图。站内 91 个工具全部走同一套预扣结算链路,每一次调用的 token 数、扣减积分与耗时都能在用户中心查到流水,方便你对照本文核对自己的实现。

先看看工具库

六、部署、灰度与合规注意事项

部署形态是「静态目录 + 应用服务」两段式。Next.js 产出的目录页作为纯静态资源走 CDN,几乎不吃计算;FastAPI 网关与工具服务跑在容器里,按 CPU 与并发双指标扩容,出图、转写这类长任务单独拆成 worker 队列,避免拖垮同步接口。发布用镜像 tag 灰度:先放 5% 流量,观察 15 分钟内的首字节延迟、错误率与积分结算差异,确认无异常再全量。

两种技术路线的对比

维度 传统 PHP CMS(pbootcms 等) Next.js + FastAPI 分层
目录页渲染 模板渲染加 Nginx 缓存,改版要动模板文件 SSG 全量预渲染,ISR 增量更新,改版只动组件
站内工具承载 需绕开主流程另写应用,模板与业务互相污染 工具层独立服务,Schema 驱动,新增工具不动前端
模型密钥 易随插件或模板泄露到公网 密钥只在网关,业务走内网短时凭证
计费与限流 无原生支持,需自行补一套 网关统一预扣结算,Redis 滑动窗口限流
上线一个新工具 1 至 2 天(写页面并配置栏目) 约 10 分钟(填 Schema 并保存)
收录与结构化数据 依赖插件,JSON-LD 需手写 generateMetadata 与 sitemap 分片统一生成
长期运维 单一 PHP 进程,横向扩容受限 静态资源与业务服务分别扩容,故障域隔离

合规与备案要点

  • 面向境内公众提供生成式 AI 服务,需要完成算法备案与安全评估,不能只靠「调用已备案的云厂商模型」自我豁免,责任主体仍是服务提供者。
  • 用户输入与模型输出按最小必要留存,敏感场景做内容审核前置;日志落盘前先脱敏,删除手机号、身份证等字段。
  • 页面上必须提供举报入口、未成年人保护说明与投诉响应时限,最好写明工作日内的处理承诺。
  • 涉及数据出境时先做合规判断:能留在境内处理的数据就不要出去,确需出境的要有单独同意与留痕。
  • 涉及医疗、金融、法律等专业结论的工具,统一挂免责声明与「不构成专业建议」提示,并保留人工复核入口。

如果只能给这套架构留一句话,那就是:把「被读的部分」做到极致便宜,把「能花钱的部分」全部收口到网关。目录层可以随时重建成静态文件,甚至换一套前端框架也不会伤到业务;工具层只要守住 Schema、密钥、计费这三条线,就能在不惊动前端的前提下把工具数从 91 加到 1,000。

陈
陈默 架构组负责人 已发表 26 篇

负责 云枢 AI导航 的网关、计费与工具引擎。写过三年 PHP CMS 二次开发,现在专职劝人别再用模板标签承载业务逻辑。

术语速查 · 本页出现的 12 个词展开看中文名与一句话解释,点术语看完整条目
  • token词元:模型处理文本的最小单位,中文约 1–2 个汉字;计费与上下文长度都按它算。
  • 转写 / 字幕Transcription:把音视频人声逐句变成带时间戳的文字稿,便于做字幕与章节切分。
  • SSE服务器发送事件:服务器通过长连接持续推送增量文本,是对白逐字出现的常见实现。
  • API应用编程接口:程序之间约定的调用方式;站内工具由服务端调用,浏览器里不出现密钥。
  • CDN内容分发网络:把静态资源缓存到离用户更近的节点,缩短加载时间。
  • 缓存Cache:把结果暂存复用,复用得越多平均耗时越低;需要约定过期与刷新策略。
  • 限流Rate Limit:限制单位时间内的请求次数,防止单用户或异常流量打满服务端。
  • SSG静态站点生成:构建阶段就生成 HTML,访问时直接返回,不必每次请求都渲染。
  • ISR增量静态再生:先返回静态页,再在后台按需重新生成,兼顾速度与更新时效。
  • 灰度发布Canary Release:先让一小部分用户用新版本,确认无误再放量,出问题的爆炸半径最小。
  • 提示词Prompt:交给模型的指令文本;写清角色、任务、输入与输出格式比反复试更省。
  • 积分Credits:站内计费单位:按工具单价预扣,调用失败自动退还;单价以定价页为准。

完整解释与站内出现处见 AI 术语表(40 条)。