CHAPTER 14 · 驾驭

系统设计与面试

系统设计不是背诵标准答案,而是一种结构化的思考方式:从需求和数量级出发,推导出架构,并清楚说明每个选择的代价。这种能力在面试中被考察,在日常中则决定了你能否审查 Agent 给出的设计方案。

⏱ 约 60 分钟原理(估算与权衡)会用(设计框架与答题模板)
读完本章,你应该能够:
  • 按五步框架完成一次系统设计,并控制讨论节奏。
  • 做数量级估算,尤其是 AI 功能的 token 成本估算,并据此发现设计压力。
  • 为 LinkNote 设计一个支撑百万用户的架构,并深入讨论去重、队列、限流、搜索、实时更新。
  • 用要点框架回答 TS 全栈与 AI 方向的高频面试题。

开场:设计 LinkNote,支撑一百万用户

开场问题

面试官说:“设计一个带 AI 摘要的链接收藏服务,支撑一百万注册用户。”你有 45 分钟。第一句话应该说什么?什么东西会成为这个系统最大的设计压力?

答案方向:第一句话应该是提问,而不是画框图:核心功能有哪些?读写比例如何?摘要需要多快出来?然后做一个粗略估算。估算会揭示一个反直觉的结论:在这个系统里,最大的压力不是数据库,而是 LLM 的成本——而最有效的解法是按 URL 去重(很多人收藏的是同一批热门文章)。整个设计会围绕这个发现展开。

五步框架

  1. 澄清需求(5 分钟):功能需求(做什么)+ 非功能需求(规模、延迟、可用性、一致性、成本、安全)。明确“不做什么”。
  2. 估算(5 分钟):用户量 → 请求量(QPS)→ 存储量 → 带宽 → AI 成本。只要数量级。
  3. 高层架构(10 分钟):画出主要组件和数据流,先让整体跑通。
  4. 深入关键点(15 分钟):挑 2~3 个最有挑战的部分深入:数据模型、瓶颈、失败处理。
  5. 权衡与演进(5 分钟):说明主要取舍,以及规模再大 10 倍时会先改哪里。

这个框架同样适用于审查 Agent 的设计方案:它是否澄清了需求?有没有估算?它挑出的“关键点”是不是真正的关键点?

第一步:澄清需求

功能需求

• 保存链接,自动生成摘要和标签
• 列表、按标签筛选、全文与语义搜索
• “问我的收藏”对话
• 不做:社交分享、协作、浏览器插件(本次)

非功能需求

• 100 万注册用户,日活约 10%
• 保存后 1 秒内返回;摘要 30 秒内完成即可
• 列表页 P95 < 300ms
• 用户数据严格隔离;可接受摘要短暂延迟(最终一致)

第二步:估算 原理

指标计算结果
日活用户100 万 × 10%10 万
写入(保存链接)10 万 × 3 次/天 = 30 万/天 ÷ 86400平均约 3.5 次/秒,峰值按 10 倍 ≈ 35 次/秒
读取(列表、搜索)10 万 × 20 次/天 = 200 万/天平均约 23 次/秒,峰值 ≈ 230 次/秒
存储每条约 2KB(含摘要)× 30 万/天 × 365约 220 GB/年(不含网页快照)
LLM 输入30 万次 × 约 3000 token(清洗后的正文)约 9 亿 token/天
LLM 输出30 万次 × 约 300 token约 9000 万 token/天

以某个中档模型的价格为例(输入每百万 token 2 美元、输出每百万 token 10 美元):输入约 1800 美元/天,输出约 900 美元/天,合计每天约 2700 美元、每月约 8 万美元。相比之下,每秒几十次的数据库读写对任何一台像样的 Postgres 都不构成压力。

估算的价值

估算不需要精确,它的作用是找出设计压力在哪里。这里的结论是:数据库是“普通”问题,LLM 成本是“核心”问题。这直接决定了深入讨论的重点——摘要去重、输入清洗、模型选择、批处理。没有估算,你很可能花 15 分钟讨论数据库分片,而那根本不是瓶颈。

第三步:高层架构

客户端Web / 插件 / MCP CDN静态资源 应用服务 SSR 页面 + API 鉴权 · 校验 · 限流 无状态,水平扩展 SSE 推送状态 Postgres主库 + 读副本 · pgvector Redis / KV缓存 · 限流计数 任务队列重试 · 死信队列 摘要 Worker抓取 → 清洗 → 查缓存→ LLM → 嵌入 → 写库 URL 摘要缓存按规范化 URL 去重 LLM 网关限流 · 故障切换用量记录 对象存储网页快照 写入路径:应用服务只写 pending 记录并投递任务(<1 秒返回);耗时工作全部在 Worker 中异步完成。
规模化后的 LinkNote。红色部分是针对“LLM 成本”这一核心压力的设计。

这张图可以对应到第 09 章的任何一条部署路线:在 Cloudflare 上,应用服务是 Worker,队列是 Queues,缓存是 KV,限流可以用 Durable Objects;在 Vercel 上,应用服务是 Functions,数据库和 Redis 来自 Marketplace;自托管时,它们是 Docker 容器。架构的形状不依赖平台,平台只决定每个方块用什么实现。

第四步:深入关键点

① 摘要去重:内容寻址缓存

把 URL 规范化(小写域名、去掉 utm_* 等追踪参数、去掉锚点、统一末尾斜杠),计算哈希作为键。摘要结果存在一张与用户无关的 page_summaries 表中;用户的收藏只引用它。热门文章只需要生成一次摘要。假设 60% 的保存命中缓存,LLM 成本直接降到原来的 40%。

需要讨论的细节:缓存多久失效(网页内容会更新);同一个 URL 同时被 100 人保存时,如何避免生成 100 次(在任务层按 URL 哈希加锁或去重,也叫“请求合并”);个性化需求(例如用户要求英文摘要)如何作为缓存键的一部分。

② 队列与失败处理

③ 限流与滥用

免费用户每天最多保存 N 条、每分钟最多 M 次对话。实现上用令牌桶或滑动窗口算法,计数存在 Redis / KV 或按用户划分的 Durable Object 中。对 AI 功能,限流对象应该是成本(token 数),而不仅是请求次数。

④ 搜索

两种需求:关键词搜索(“react compiler”)和语义搜索(“怎么让组件少渲染”)。Postgres 可以同时承担:全文检索索引做关键词,pgvector 做向量相似度,两者结果融合(混合检索)。所有查询必须带上 user_id 过滤——在向量检索中容易被遗忘,导致跨用户数据泄露。数据规模继续增长时,再考虑专门的搜索服务。

⑤ 实时状态更新

方案原理适用
轮询客户端每隔几秒请求一次状态最简单;对“30 秒内完成”的摘要完全够用
SSE服务器单向推送,基于普通 HTTP状态通知、LLM 流式输出
WebSocket双向、长连接聊天、协作编辑;在 serverless 上需要 Durable Objects 或专门服务

面试中说出“先用轮询,因为需求允许;如果出现 X 情况再换 SSE”,比直接上 WebSocket 更能体现判断力。

第五步:权衡与演进

决策选择代价 / 何时改变
单体 vs 微服务单体应用 + 独立 Worker团队或部署节奏出现明显分化时再拆
SQL vs NoSQLPostgres关系清晰、需要事务和灵活查询;写入量远未到瓶颈
同步 vs 异步摘要异步用户需要处理“生成中”状态;换来稳定的写入延迟和可重试性
摘要一致性最终一致几秒到几十秒的延迟可以接受
模型选择按任务分级:摘要用性价比高的配置,对话用更强的需要评估集保证质量;可以按用户等级区分

规模再大 10 倍时:LLM 成本仍然是第一压力 → 更激进的缓存、批处理接口、模型分级;数据库读压力 → 读副本、列表缓存;搜索 → 独立的搜索服务;多区域 → 数据按区域分布,考虑合规要求。

AI 系统设计的特有问题

高频面试题与答题要点

以下题目按方向分组。点开查看要点——注意它们是要点框架,回答时要结合自己的经历和例子。

JavaScript / TypeScript

事件循环是什么?宏任务和微任务的区别?

单线程执行 JS;同步代码执行完后清空微任务队列(Promise 回调、await 后续),再取一个宏任务(计时器、I/O、事件)。微任务在每个宏任务之后全部清空,因此 Promise 回调总是先于 setTimeout。举一个输出顺序的例子并推演(第 03 章)。

Promise.all、allSettled、race、any 的区别和使用场景?

all:全部成功才成功,任一失败立即失败;allSettled:等全部结束,返回每个的结果;race:第一个结束的决定结果;any:第一个成功的决定结果。批量任务允许部分失败时用 allSettled,并提及并发上限。

TypeScript 的类型在运行时存在吗?这带来什么影响?

不存在,类型被擦除。影响:外部数据必须运行时校验(Zod);as 断言不做任何检查;不能基于类型做运行时分支,需要判别字段。

interface 和 type 的区别?any、unknown、never 的区别?

interface 可声明合并、只能描述对象;type 可以表达联合、元组、映射类型等。any 关闭检查;unknown 需要收窄后使用;never 表示不可能的值,用于穷尽检查。

什么是闭包?在 React 中会引发什么问题?

函数捕获其创建时作用域中的变量。React 中每次渲染的 state 是快照,回调或 Effect 捕获了旧渲染的值,产生“过期闭包”——例如定时器里一直读到旧的 count。解决:函数式更新、正确的依赖数组、useEffectEvent / ref。

React / 前端

React 的渲染流程是怎样的?什么会导致重渲染?

setState → 调度更新 → 重新执行组件函数(render)→ 比对新旧元素树(reconciliation)→ 提交 DOM 变更(commit)。触发原因:自身 state 变化、父组件重渲染、使用的 Context 变化。

useEffect 的正确使用场景?常见误用?

与外部系统同步:订阅、WebSocket、浏览器 API。误用:计算派生数据、响应用户事件、手写数据请求、根据 props 重置 state。依赖数组中不稳定引用导致循环。

SSR、SSG、CSR、RSC 分别是什么?怎么选?

按“在哪里渲染、何时渲染、发送多少 JS”比较;RSC 是组件是否只存在于服务器,与 SSR 互补。按页面特性选:静态内容 SSG、个性化实时数据 SSR/流式、强交互后台 CSR;讨论水合成本和缓存。

如何优化一个首屏很慢的页面?

先测量(LCP、网络瀑布、JS 体积),再对症:服务端渲染 / 流式;代码分割和按需加载;减少客户端组件;并行化数据请求避免瀑布;图片优化;CDN 缓存。强调“先测量,后优化”。

全栈 / 后端

如何实现端到端类型安全?

数据库 schema(Drizzle)推导行类型 → 服务函数返回类型推导 → 服务器函数 / tRPC / Hono RPC 把类型传给客户端;输入用 Zod schema 同时做校验和类型。局限:只覆盖同一次编译内的代码,外部边界仍需运行时校验。

Session 和 JWT 的区别?令牌存在哪里?

Session:服务器存状态,客户端持有 ID,易于撤销;JWT:自包含、无状态校验,但难以提前撤销、体积较大。浏览器端推荐 HttpOnly + Secure + SameSite Cookie,而不是 localStorage;讨论 XSS 与 CSRF 防护。

serverless 和容器部署的区别?各适合什么?

生命周期、状态、冷启动、执行时长、计费模型的差异(第 09 章表格)。serverless 适合波动流量的请求处理;容器适合长任务、WebSocket、重计算;讨论数据库连接问题。

AI / Agent

如何让 LLM 输出稳定的结构化数据?

结构化输出 / 受约束解码 + Zod 校验 + 失败重试或降级;在提示中给示例;简化任务;建立评估集回归测试。

RAG 的流程?什么时候不需要 RAG?

切块 → 嵌入 → 存储 → 查询嵌入 → 检索 top-k → 带引用生成;可加混合检索、重排序。数据量小可以直接放入上下文 + 缓存;Agent 式检索(搜索工具)是另一种选择。

什么是 Agent?如何设计一个可靠的 Agent?

模型在循环中使用工具直到完成目标。可靠性:步数与预算上限、最小权限工具、敏感操作人工确认、沙箱、上下文管理、可观测性、评估集。先考虑固定工作流能否满足需求。

什么是提示注入?如何防御?

数据中夹带指令劫持模型。纵深防御:处理不可信内容时不给危险工具;身份和权限由服务器决定;高风险操作需确认;输出转义;隔离与审计。承认不存在完美防御,重点是限制影响范围。

如何降低 LLM 应用的成本?

先测量每个功能的 token 用量;清洗输入;提示缓存;结果缓存与去重;按任务选择模型和推理强度;批处理;限制输出长度和 Agent 步数;用评估确认质量不下降。

自检:一个小设计练习

为 LinkNote 设计“每周摘要邮件”:每周一给每个活跃用户发一封邮件,总结他上周收藏的内容。给出估算、架构要点和最大的风险。

估算:10 万活跃用户,每人上周约 20 条收藏,每条摘要 200 token → 每封输入约 4000 token,总计约 4 亿输入 token / 周。可以使用批处理接口降低成本,因为不需要实时。

架构:定时任务(Cron)在周一凌晨按用户分批投递到队列 → Worker 读取该用户的已有摘要(复用,不重新抓取网页)→ 调用 LLM 生成周报 → 发送邮件服务 → 记录发送状态(幂等:同一用户同一周只发一次)。

风险:批量任务失败后的重复发送(需要幂等键);邮件送达率与退订合规;成本突增(设置预算上限);某个用户的收藏中存在注入内容(周报生成不给任何工具,输出做转义)。