项目:interview-platform | 版本:v1.2 | 生成日期:2026-10-06 | 依据:源码分析
架构决策记录(Architecture Decision Record,下文简称 ADR)的价值在于回答一个「事后考古」问题:代码为什么长成这样,而不是那样。interview-platform 没有留下一份正式的 ADR 仓库,但 docs/ 目录的历史设计文档(PRD、功能增强计划、交接文档)、AGENTS.md 的人工校正约定、迁移目录的时间戳序列,以及散落在源码注释里的「教训记录」,共同构成了一份可复原的决策档案。本篇从这些材料中提炼出十二条影响最深远的决策,每条按「背景 → 备选方案 → 决策与源码证据 → 后果」四段展开。与既有篇目的分工是:《01-系统架构》第 4 节用七段短评给出「决策是什么」,本篇则回答「为什么当时会这么选、放弃了什么、今天回头看代价与收益各在哪里」;技术债的整改方案统一收敛在《13-技术债务与演进建议》,本篇只做决策本身的考古,不重复开药方。
需要预先声明两条方法论约束。第一,证据等级:本篇的所有「源码证据」均可直接打开对应文件复核;但「当时为什么这么选」的动机推断,凡无文档或注释直接背书的,均以「推断」字样显式标注,避免把考古写成附会。第二,时间线:迁移目录的时间戳显示项目主体建于 2026 年 6 月(20260607105724_init),经历 7 月的静默期后,在 8 月下旬(RBAC、题集、通知偏好)与 9 月下旬(per-user 状态、热路径索引、评论回复、封禁字段、题目提示)两次集中演进——这解释了为什么代码里同时存在「老风格」(手写校验、裸 {error} 响应)与「新风格」(zod schema、ok()/fail())两代范式,详见本篇 ADR-010。
决策总览如下表(按影响面排序,编号仅为索引):
| 编号 | 决策 | 一句话结论 |
|---|---|---|
| ADR-001 | Next.js 全栈单体而非前后端分离 | 页面、API、AI 编排同进程交付 |
| ADR-002 | Prisma 直连数据访问,不设 Repository 层 | Route Handler 内联类型安全查询 |
| ADR-003 | 进程内限流而非 Redis | 单实例姿态下的零依赖选择 |
| ADR-004 | AI 服务的克制使用(缓存/护栏/本地题库) | 每个 AI 入口都配成本闸门 |
| ADR-005 | 每用户状态独立成表 | 两次连续迁移完成多用户化 |
| ADR-006 | RBAC 数据随迁移种子 + 弱关联 | 权限点结构随 migrate deploy 自动就位 |
| ADR-007 | JWT 会话 + session 回调按需回查 | 无状态令牌 + 有状态校正 |
| ADR-008 | 语音/视频走火山引擎,服务端只做凭证保险箱 | RTC 直连让媒体流绕过应用服务器 |
| ADR-009 | 判题走公共 Piston 沙箱 | 免自建容器,接受可用性风险 |
| ADR-010 | 校验与响应结构的渐进统一 | 新接口 zod + ok/fail,旧接口随迭代迁移 |
| ADR-011 | 外部依赖全部可选、缺配降级 | 最小启动集只有两个环境变量 |
| ADR-012 | 复习调度不落库,实时推导 | 用 PracticeHistory 单一事实源换模型极简 |
背景。项目的第一性约束是团队规模与迭代节奏:从迁移时间线看,这是一个(至少在主要阶段)单人主导、数月内从原型长成全功能平台的项目。功能横跨题库、判题、AI 对话、竞赛、社区、后台,若按前后端分离拆成两个仓库,接口契约、类型定义、部署流水线都要翻倍维护。备选方案:其一是「NestJS/Express 独立后端 + Next.js 前端」,获得接口边界清晰与独立扩容能力,代价是类型共享需要额外的代码生成或 monorepo 包管理;其二是「Next.js 全栈单体」,页面与 API 同仓同进程;其三是「微服务拆分」,在当时的规模下明显过度设计。决策与证据:选择了全栈单体——src/app/api/** 下 101 个 route.ts 与页面共享同一个 Next.js 构建产物,src/lib 服务层被两侧复用(如 request.ts 服务前端、judge.ts 服务判题路由),Dockerfile 以单容器交付全部能力,容器 CMD 一行完成迁移与启动。后果:正面是类型从 Prisma 模型到页面组件全程贯通、新增功能的改动路径固定(schema → lib → api → page,见《00-项目总览》第 4 节)、部署与运维面收敛为一个容器;负面是 API 与页面耦合在同一构建产物里,水平扩展时 AI 流式与判题等长耗时请求和页面流量共享进程资源与连接池(pg.Pool 默认 10 连接),当前只能靠限流缓解——这正是《10-性能设计与优化》第 6 节把「SSE 并发治理」与「重 IO 路由独立部署」列为演进方向的原因。
背景。43 个数据模型(prisma/schema.prisma)如果再叠一层手写 Repository,样板代码量将随模型数线性增长。备选方案:其一是经典的 Repository/DAO 模式,把查询集中封装、便于替换数据源与单测 mock;其二是「Route Handler 内直接使用 Prisma 客户端」;其三是引入 Server Actions 或 tRPC 等端到端类型层。决策与证据:选择了直连——src/lib/prisma.ts 只有 24 行,用 new Pool({ connectionString }) 建连接池、注入 PrismaPg 驱动适配器构造 PrismaClient 单例,非生产环境挂 globalThis 防热重载重复建池;全项目没有任何中间数据访问封装,查询条件内联在各 Route Handler 中。测试策略随之定型:单测 mock 的是「依赖模块」(vi.mock("@/lib/prisma"))而非数据库,《09-测试与质量保障》第 3 节的四步法即建立在这个前提上。后果:正面是数据访问代码量最小、Prisma 的类型安全直达调用点、新模型的接入成本接近零;负面有两个——其一是查询风格缺少强制收敛,N+1、缺 where userId 这类纪律问题只能靠约定与人工复查(AGENTS.md 第 6 节将其列为硬纪律,/api/reminders 的循环查题即违例实例,《13-技术债务与演进建议》2.5);其二是「真库行为从未被自动验证」(复合唯一键、级联删除等 Prisma 层语义在 mock 测试中不可见),这是《09-测试与质量保障》第 8 节指出的集成测试空白。此外值得一提:选 Prisma 7 而非 6,意味着接受了新架构的全部迁移成本——客户端生成物进 src/generated/prisma/(无 postinstall 钩子,克隆后必须手动 generate,《12-FAQ与故障排查》第 2 条)、CLI 由 prisma.config.ts 驱动且只读根目录 .env(《14-配置与环境详解》第 2 节)——换来的是驱动适配器带来的连接池自主权与更快的引擎。
背景:AI 对话、判题、上传、登录等入口都消耗外部配额或有防爆破需求,限流是安全与成本的双重闸门。备选方案:Redis 集中式限流(多实例正确,但引入一个常驻基础设施)、反向代理层限流(nginx limit_req,但无法按 userId 语义计数)、反向代理层限流(nginx limit_req,但无法按 userId 语义计数)、进程内实现(零依赖但单实例有效)。决策与证据:选择进程内实现——src/lib/rate-limit.ts 头部注释开宗明义「适用于单实例部署。多实例部署需替换为 Redis 实现」;实现为模块级 Map<string, {timestamps: number[]}> 滑动窗口,每 5 分钟定期清理过期条目防内存泄漏,清理定时器 unref() 不阻塞进程退出;对外暴露 rateLimit(identifier, limit, windowMs) 与 rateLimitHeaders(),429 响应携带 X-RateLimit-Limit/Remaining/Reset 三头。标识符的选择也有讲究:按用户限流的接口用 requireAuth 之后的 userId(伪造不了),getClientIp() 刻意取 x-forwarded-for 最后一跳(源码注释:第一段是客户端可伪造的值)。后果:正面是零依赖、部署面不扩大、单实例语义完全正确;负面是配额按副本数倍增——4 副本等于 AI 配额 ×4,防爆破同样被稀释,这被《13-技术债务与演进建议》列为 1.1 号 P0 债务,且把「单实例部署」事实上写成了项目的部署硬约束(《06-部署运行》第 10 节)。这条决策与 ADR-001 是同一条主线(单实例优先)的两个切面:只要单体姿态不变,进程内限流就是自洽的;一旦扩容,它就是第一个要拆的墙。
背景:DeepSeek 按 token 计费,而平台的三类 AI 场景(渐进提示、模拟面试、自由对话)中,提示与面试都有「结果可复用」或「可结构化」的空间;无节制的 AI 入口会把边际成本变成运营事故。备选方案:引入 Vercel AI SDK 或 LangChain 获得统一的流式抽象与工具调用;或不用任何框架、原生 fetch 直连并把成本控制做进业务层;或干脆放弃 AI 能力。决策与证据:选择了「不用 AI SDK + 业务层三重护栏」的组合。其一,src/lib/deepseek.ts 仅 116 行,callDeepSeek/callDeepSeekStream/sseFormat 三个函数覆盖全部需求,OpenAI 兼容协议用原生 fetch 直连,SSE 解析复用自写的 src/lib/sse.ts——不引入 SDK 的动机推断是:需要的只是「一条 chat/completions 调用」,SDK 的抽象层与体积(及版本风险)不划算。其二,三层护栏:请求级限流(AI 对话 20 次/分、面试创建 10 次/分、轮次 20 次/分,见《08-安全与权限设计》第 4 节矩阵)、业务级每日护栏(mock-interview/route.ts 手工推算上海时区零点,单用户每日 20 场)、结果级缓存(QuestionHint 以 @@unique([questionId, level]) 按题按级缓存 AI 提示,首个用户触发生成后全站复用,并发撞键时回读兜底,见 src/app/api/questions/[id]/hints/route.ts)。其三,结构化内容不走 AI:行为面试(behavioral)的首题直接取 src/lib/behavioral-questions.ts 的 267 行本地题库并用 setTimeout 模拟流式输出,零 AI 消耗。后果:正面是 AI 成本上界清晰可算(每用户每日 ≤20 场 × ≤5 轮)、无 SDK 版本耦合、SSE 链路完全可控(这直接成就了《18-代码走读实录》里那条值得精读的流式链路);负面是没有获得 SDK 的重试、工具调用与多模型切换能力,换模型供应商需要改多处 fetch(好在调用点已收敛于 deepseek.ts 与 mock-interview 两个路由内联处——后者未复用 lib 是一处可见的不彻底,见《13-技术债务与演进建议》对创建接口回显上游错误的 1.5 条)。
背景:项目的早期形态是单用户原型,题目表上曾有全局共享的 mastery/isBookmarked 字段与全局 Streak 表;转向多用户平台时,「状态挂谁名下」成为必须回答的建模问题。备选方案:在 Question 表上加 per-user 的关联表(即独立状态表);或用 JSON 列把所有用户状态塞进题目行;或维持全局字段并接受「全站共享掌握度」的语义。决策与证据:选择独立状态表,且以两次连续迁移完成切换——prisma/migrations/20260924150000_add_per_user_state 建立 UserQuestionState(@@unique([userId, questionId]),另加 @@index([userId, mastery]) 支撑「按掌握度筛我的题」)与 UserStreak(@@unique([userId, date]))两张新表,20260924160000_drop_global_question_state 随即删除题目表上的全局状态字段。schema 中这两条模型还带着注释自我说明:「用户维度的题目掌握/收藏状态(替代 Question 上的全局 mastery/isBookmarked 字段)」。配合的查询范式是《07-模块详解》第 1.2 节所述的 Map 批量合并:列表接口三次查询(题目、总数、本人状态)替代 N+1。后果:正面是多用户数据天然隔离、掌握度筛选与收藏语义清晰、复合唯一键让并发 upsert 幂等;负面是「游客/登录双形态」成为每个列表接口必须显式处理的分支(游客 mastery 一律 unsolved,前端不得据此渲染徽章,《15-接口全量参考》第 2 节的集成要点),以及状态表与行为表(PracticeHistory)之间的口径需要人工维护(判题通过只把 unsolved 推进为 learning、不回退 mastered 的单向规则,src/app/api/questions/[id]/submit/route.ts 内注释明示)。这条决策是理解全站数据模型的第一把钥匙——它把「题目是公共内容、状态是私人数据」这条领域分界固化进了表结构。
背景:多用户化之后出现两类管理诉求:内容运营(题目/分类/竞赛)与用户管理(封禁/角色)需要不同权限;同时已有用户的 role 字符串字段已在生产中存在,不能因引入权限系统而破坏。备选方案:硬外键的 Role/Permission 三表 + 外键约束的 User.role(数据一致性强,但改字段有破坏风险);或纯代码级角色判断(if role === "admin",无法运营化配置);或「表结构走迁移 + 初始数据也走迁移 + User.role 保持字符串弱关联」的折中。决策与证据:选择了第三种——迁移 20260824010000_add_rbac_models 建 Role/Permission/RolePermission 三表,20260824020000_seed_rbac_data 用 ON CONFLICT DO NOTHING 幂等灌入 admin(7 权限点全量)、editor(6 个内容权限点,含 admin:access 不含 user:manage)、user(无权限点)三角色;schema 注释明确写着设计动机:「用户通过 User.role(字符串,指向 Role.code)关联到角色……设计保持向后兼容:User.role 字段不变,不建立硬外键,避免破坏现有数据」。运行时入口收敛在 src/lib/permission.ts 的 requirePermission(code):双通道识别身份(Bearer JWT 供外部后台、NextAuth session 供主站),角色权限集合带 30 秒 TTL 内存缓存;旧守卫 requireAdmin() 被重定义为 requirePermission("admin:access") 的别名,全部存量调用点零改动完成语义升级。后果:正面是权限数据随 migrate deploy 自动就位(容器 CMD 内建,部署零手工播种)、权限点可以按迁移增补(SOP 见《11-扩展开发指南》SOP 五)、新旧鉴权无缝过渡;负面有三:弱关联下写入不存在的 role 字符串不报错但一切权限点 403(fail-safe 方向正确但排障反直觉)、editor 角色因角色接口白名单只有 admin|user 而无法经 API 授予(《08-安全与权限设计》第 3 节 ⚠️ 待确认是否有意收窄)、30 秒缓存使权限回收存在窗口(《13-技术债务与演进建议》1.2)。另外源码没有「首个管理员」bootstrap 机制——首管理员必须手工改库,这是该决策的配套运维负担(《06-部署运行》第 6.4 节第 6 步)。
背景:多用户平台需要回答两个会话问题:JWT 无法主动吊销,封禁如何即时生效?资料变更(头像/角色)如何绕过 JWT 生命周期实时呈现?备选方案:数据库会话(每次请求查 Session 表,天然可吊销但每请求多一次查询且要维护会话存储);纯 JWT(零查库但封禁只能等过期);或「JWT 策略 + session 回调每次回查用户表」的混合。决策与证据:选择了混合——src/lib/auth.ts 显式 session: { strategy: "jwt" } 与 secret: process.env.AUTH_SECRET(注释解释:NextAuth v4 默认读 NEXTAUTH_SECRET,不显式指定会退回开发默认密钥,导致与 proxy、lib/jwt.ts 三处密钥不一致),session 回调每次拉取会话都 findUnique 回查 image/name/role/banned,查到 banned=true 直接返回 null 会话——源码注释写明「JWT 无法吊销,按请求即时拒签」,getServerSession 拿到 null 后全部 API 401,前端 request.ts 收到 401 自动 signOut。回调的 catch 分支降级用 token 旧值(可用性优先的 fail-open,《08-安全与权限设计》第 9 节第 11 条)。后果:正面是封禁即时踢出、改头像/改角色实时生效、无需维护服务端会话存储(Account/Session 表保留但仅作 NextAuth 四件套兼容);负面是「每个带会话的请求多一次主键查库」——这是全系统最高频查询(《10-性能设计与优化》第 3 节把它单列为请求量 × 1 的隐性成本),以及数据库故障窗口内被封禁用户可能凭旧 token 继续操作。这条决策与 ADR-005 同属「多用户化」主题:状态表解决数据归属,会话回查解决身份时效。
背景:AI 面试的语音/视频形态需要 ASR、TTS、实时语音大模型与 RTC 传输四类能力,自建任何一项都是重资产投入。备选方案:Web Speech API(浏览器原生,但语音质量与稳定性不足且不可控);自建媒体服务器(SFU + 流式识别,工程量量级完全不同);或云厂商托管方案(火山引擎 SeedRealtime「AI 音视频互动」把大模型、TTS 与 RTC 打包成一条链路)。决策与证据:选择火山引擎全托管——src/lib/volcengine.ts 头部注释明确区分四套互不相同的凭证体系(豆包 ASR/TTS 的 X-Api-Key、S2S 的 AppId+Token、RTC 应用的 AppId+AppKey、账号级 AK/SK),全部只在服务端读取;前端永远只拿到会话级产物:/api/voice/asr 返回文本、/api/voice/tts 返回音频 base64、/api/video-interview/token 返回 {appId, roomId, userId, token}。关键架构收益在媒体流拓扑上:浏览器直连火山的 RTC 通道(wss://rtc.volcengineapi.com),应用服务器只承担 Token 签发(src/lib/video-interview.ts 按火山官方二进制协议构造 Token)与 StartVoiceChat/StopVoiceChat 信令(V4 签名,10 秒超时),因此应用服务器的带宽与长连接压力不随面试时长增长(《10-性能设计与优化》第 2 节称之为该架构最聪明的一点)。可用性也是显式设计:前端渲染视频入口前先 HEAD /api/video-interview/token 探测六凭证齐全性,未配置直接隐藏入口而非点击报错。后果:正面是零媒体基础设施、密钥不出服务端、AI 面试官由云端大模型直接驱动(连 TTS 都省了);负面是四套凭证的配置心智负担最重(《14-配置与环境详解》第 4.2 节六项变量缺一即 503)、与单一云厂商深度绑定、RTC Token 算法与 V4 签名属协议级自维护代码(火山 SDK 不覆盖 Token 生成),升级时需对照官方文档回归。Web Speech API 并未完全弃用——SpeakButton 的朗读与 useSpeechRecognition 的浏览器侧识别仍在低成本场景使用,形成「原生 API 兜底、火山引擎增强」的双轨。
背景:代码题判题需要真实执行用户代码,沙箱安全、多语言运行时、资源隔离三者叠加,是典型的「自建重、外购轻」场景。备选方案:Docker-in-Docker 自建沙箱集群(安全与运维成本高);Judge0 等开源判题服务(需部署维护一个新组件);或 Piston 公共执行 API(免费、免部署、无 SLA)。决策与证据:选择 Piston——src/lib/code-runner.ts 硬编码 https://emkc.org/api/v2/piston/execute,维护 5 种语言的版本映射(JS 18.15 / TS 5.0.3 / Python 3.10 / Java 15.0.2 / C++ 10.2.0),不在映射表的语言直接报「不支持的语言」(白名单方向正确);src/lib/judge.ts 在其上做判题编排:wrapCode 按语言注入 runner(JS 正则提取函数名自动调用、Python 反射实例化 Solution、Java/C++ 检测不到 main 时只注入提示文案——名存实亡的包装器是已知局限,《13-技术债务与演进建议》2.7)、逐用例 Promise.race 15 秒超时、stdout trim 后全等比对。后果:正面是判题能力零基建上线、5 语言开箱即用;负面是公共服务的可用性与排队不可控(限流或抖动直接表现为全站判题不可用,time_limit_exceeded 可能是 Piston 排队挤占了 15 秒预算而非算法慢,《12-FAQ与故障排查》第 7 条),且源码未提供端点配置项,内网部署整体不可用——整改方向是抽 PISTON_API_URL 环境变量以便自建即插即用。这条决策与 ADR-003/011 共享同一个底层判断:用「可接受的可用性风险」换「零基础设施」,在单实例、自用/小规模阶段是理性的,但每一处都预留了明确的升级触发条件。
背景:101 个 Route Handler 是近半年内陆续写成的,早期接口用手写 if (!x) return 400 与裸 {error} 响应,后期引入了 zod 与统一封装;一次性重写全部旧接口的成本与风险都不可接受。备选方案:一次性把全部接口迁到统一范式(工作量大且引入全量回归风险);或完全放任两代范式并存;或「新规范只约束新代码,旧代码随迭代迁移」的渐进策略。决策与证据:选择了渐进——src/lib/api-response.ts 的文件头注释把这层意图写得非常直白:ok/fail 提供「供新写的 API 使用」的统一包装,handleApiError 刻意保持与既有 {error: "服务器内部错误"} 结构兼容,从而「替换后不需要改动任何现有前端解析逻辑,纯属后端样板收敛」;src/lib/schemas.ts 集中登记高频写接口的 zod schema(评论 5000 字、题解 20000 字、计划 totalDays 1-365、组卷 1-100 题、资料更新各字段长度),src/lib/validate.ts 的 parseBody 提供三态统一解析(合法/非法 JSON/校验失败取首个 issue)。后果:正面是收敛零风险、新接口有明确范式可循(SOP 一与新接口自检清单把「zod + 长度上限」列为硬性要求)、旧接口的迁移可以搭业务迭代的便车;负面是当前真实存在两套响应结构与两种校验强度(题目创建/编辑至今无 zod 且无长度上限,《13-技术债务与演进建议》1.6),前端 request.ts 被迫做 body.error || body.message 的兼容解析,OpenAPI 化被推迟。对本篇而言,这条决策的特殊价值在于它是一条「关于代码演进方式」的元决策:它承认技术债的存在,同时用制度化手段(SOP、自检清单、注释声明)阻止债务无序扩张。
背景:平台依赖五类外部服务(AI、语音视频、OSS、SMTP、判题),若任一缺失即不可启动,本地开发、演示与最小化部署都会被第三方服务绑架。备选方案:全部依赖强制配置(生产一致性最好);或按功能分级可选、缺配时对应功能降级;或用 mock/stub 模拟外部服务。决策与证据:选择了分级可选——最小启动集只有 DATABASE_URL 与 AUTH_SECRET 两个变量;validateDeepSeekApiKey() 把占位串 sk-your-deepseek-api-key-here 与 fake 一并识别为未配置(防照抄样例值),AI 接口返回 503 并点名缺失变量;src/lib/mailer.ts 未配 SMTP 时把验证码打印到服务端控制台(日志前缀 [mailer]),保证本地无邮件服务也能跑通注册;OSS 三件套缺一即抛「环境变量 xxx 未配置」,上传功能不可用但不影响其余;视频面试六凭证不齐则入口直接隐藏(HEAD 探测 503)。这套语义沉淀为《12-FAQ与故障排查》第 5 节的「503 对照矩阵」与第 0 节「这是故障还是降级」的排障第一问。后果:正面是开发体验极佳(克隆后两个变量即可启动)、演示与 CI 环境零外部依赖、每类降级行为都有确定的信号而非静默错误;负面是「配置遗漏」从部署事故降格为功能缺失,容易被误报为 bug(FAQ 收录的「注册收不到验证码」「视频面试入口不出现」两个高频问题本质都是降级表现),且降级路径本身也需要测试守护(mailer 的控制台分支、token 探测的 503 分支目前依赖人工验证 ⚠️ 待确认——对应测试文件未见覆盖)。
背景:艾宾浩斯间隔复习需要一个「每题下次该何时复习」的账本,直觉做法是建一张复习计划表并在练习时更新。备选方案:建独立的 ReviewSchedule 表(查询快,但引入「计划与事实不一致」的一致性维护成本);或每次从练习历史实时推导(无一致性成本,但查询是全量 groupBy)。决策与证据:选择了实时推导——src/lib/review-scheduler.ts 不维护任何计划表,getDueReviewQuestions() 对 practiceHistory.groupBy({ by: ["questionId"], _max: { attemptedAt }, _count: { _all } }) 取每题最近练习时间与总次数,reviewCount = 总次数 - 1(首次练习不算复习),stage = min(reviewCount, 5) 映射进固定的 EBINGHAUS_INTERVALS = [1, 2, 4, 7, 15, 30] 天间隔表,下次复习时间 = 最近练习日 + 当前阶段间隔,daysOverdue >= 0 者进入待复习清单并按逾期天数降序。UserStreak 同理被定位为「读时物化的快照」而非事实源(GET /api/stats/streak 每次重算并 upsert 当日行)。后果:正面是数据模型极简(零新增表)、练习记录成为行为域唯一事实源(删掉任何快照都能重算,见《05-核心流程》流程五)、间隔规则调整无需数据迁移;负面是复习查询退化为「每用户全量 groupBy」,练习上万条后每次首页加载都要扫描(性能评估与预过滤/冗余列两条优化路径见《10-性能设计与优化》第 6 节建议 6);此外间隔表存在第二份拷贝——/api/reminders 内部的 SPACED_INTERVALS = [1,3,7,15,30] 与艾宾浩斯表口径不一致,导致「提醒了但首页没到期待复习」的用户感知(《13-技术债务与演进建议》2.4,收敛方案已给)。这条决策与 ADR-004 形成有趣的对照:hints 用「落库缓存」控 AI 成本,复习用「不落库推导」控模型复杂度——同一项目里两种相反的取向,各自的判据是「写入频率与一致性敏感度」,这正是决策记录比决策清单更有价值的地方。
把十二条 ADR 摆在一起,可以读出三条贯穿始终的决策语法。其一,单实例优先:ADR-001(单体)、ADR-003(进程内限流)、ADR-012(实时推导)都是同一姿态在不同层的投影,它们的升级触发条件也相互绑定——真正启动水平扩容时,这三处要在同一批次里动(Redis 限流、SSE 并发治理、复习查询降载,《13-技术债务与演进建议》路线图第三阶段)。其二,成本与基建的外购化:ADR-004/008/009 的共同选择是「能不建的基础设施就不建」,把工程精力留给业务编排(wrapCode 的语言包装器、火山四套凭证的保险箱、DeepSeek 的护栏),代价是与外部服务的可用性和定价绑定,升级路径以「抽环境变量」为标准动作(PISTON_API_URL 已在建议清单)。其三,渐进而非革命:ADR-005(两次迁移切换状态归属)、ADR-006(弱关联保旧数据)、ADR-010(新范式只管新代码)都体现了对存量数据的敬畏——这在这个从单用户原型长出来的代码库里不是巧合,而是被迁移时间戳序列反复验证过的工程性格。后续若要做架构评审或大版本规划,建议以本篇为基准清单逐条复核「触发条件是否已到」,而不是凭印象重开讨论。