CHAPTER 14 · 驾驭
系统设计与面试
系统设计不是背诵标准答案,而是一种结构化的思考方式:从需求和数量级出发,推导出架构,并清楚说明每个选择的代价。这种能力在面试中被考察,在日常中则决定了你能否审查 Agent 给出的设计方案。
- 按五步框架完成一次系统设计,并控制讨论节奏。
- 做数量级估算,尤其是 AI 功能的 token 成本估算,并据此发现设计压力。
- 为 LinkNote 设计一个支撑百万用户的架构,并深入讨论去重、队列、限流、搜索、实时更新。
- 用要点框架回答 TS 全栈与 AI 方向的高频面试题。
开场:设计 LinkNote,支撑一百万用户
面试官说:“设计一个带 AI 摘要的链接收藏服务,支撑一百万注册用户。”你有 45 分钟。第一句话应该说什么?什么东西会成为这个系统最大的设计压力?
答案方向:第一句话应该是提问,而不是画框图:核心功能有哪些?读写比例如何?摘要需要多快出来?然后做一个粗略估算。估算会揭示一个反直觉的结论:在这个系统里,最大的压力不是数据库,而是 LLM 的成本——而最有效的解法是按 URL 去重(很多人收藏的是同一批热门文章)。整个设计会围绕这个发现展开。
五步框架
- 澄清需求(5 分钟):功能需求(做什么)+ 非功能需求(规模、延迟、可用性、一致性、成本、安全)。明确“不做什么”。
- 估算(5 分钟):用户量 → 请求量(QPS)→ 存储量 → 带宽 → AI 成本。只要数量级。
- 高层架构(10 分钟):画出主要组件和数据流,先让整体跑通。
- 深入关键点(15 分钟):挑 2~3 个最有挑战的部分深入:数据模型、瓶颈、失败处理。
- 权衡与演进(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 分钟讨论数据库分片,而那根本不是瓶颈。
第三步:高层架构
这张图可以对应到第 09 章的任何一条部署路线:在 Cloudflare 上,应用服务是 Worker,队列是 Queues,缓存是 KV,限流可以用 Durable Objects;在 Vercel 上,应用服务是 Functions,数据库和 Redis 来自 Marketplace;自托管时,它们是 Docker 容器。架构的形状不依赖平台,平台只决定每个方块用什么实现。
第四步:深入关键点
① 摘要去重:内容寻址缓存
把 URL 规范化(小写域名、去掉 utm_* 等追踪参数、去掉锚点、统一末尾斜杠),计算哈希作为键。摘要结果存在一张与用户无关的 page_summaries 表中;用户的收藏只引用它。热门文章只需要生成一次摘要。假设 60% 的保存命中缓存,LLM 成本直接降到原来的 40%。
需要讨论的细节:缓存多久失效(网页内容会更新);同一个 URL 同时被 100 人保存时,如何避免生成 100 次(在任务层按 URL 哈希加锁或去重,也叫“请求合并”);个性化需求(例如用户要求英文摘要)如何作为缓存键的一部分。
② 队列与失败处理
- 至少一次投递 + 幂等消费:队列可能重复投递,Worker 按 link id 和状态判断是否已处理,写入用 upsert。
- 重试策略:网络错误、LLM 429 使用指数退避重试;网页 404、内容为空不重试,直接标记失败。
- 死信队列(DLQ):多次失败的任务进入死信队列,供排查,而不是无限重试消耗成本。
- 背压:LLM 服务商有速率限制。Worker 的并发数应当受限,队列负责缓冲峰值。
③ 限流与滥用
免费用户每天最多保存 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 NoSQL | Postgres | 关系清晰、需要事务和灵活查询;写入量远未到瓶颈 |
| 同步 vs 异步摘要 | 异步 | 用户需要处理“生成中”状态;换来稳定的写入延迟和可重试性 |
| 摘要一致性 | 最终一致 | 几秒到几十秒的延迟可以接受 |
| 模型选择 | 按任务分级:摘要用性价比高的配置,对话用更强的 | 需要评估集保证质量;可以按用户等级区分 |
规模再大 10 倍时:LLM 成本仍然是第一压力 → 更激进的缓存、批处理接口、模型分级;数据库读压力 → 读副本、列表缓存;搜索 → 独立的搜索服务;多区域 → 数据按区域分布,考虑合规要求。
AI 系统设计的特有问题
- 成本模型:每个功能的单次成本 × 调用量,写进设计文档。设置预算告警。
- 延迟预算:同步路径上的 LLM 调用要流式;非必要不放在同步路径上。
- 降级与故障切换:模型服务不可用或限流时,是切换到备用模型、排队,还是返回“摘要暂不可用”?
- 质量保障:评估集、线上抽样、用户反馈(点赞 / 点踩)回流到评估集。
- 安全:提示注入(抓取的网页)、跨用户数据隔离(检索时过滤)、输出转义。
- 数据与隐私:哪些用户数据会发送给第三方模型?是否需要用户同意?保留多久?
高频面试题与答题要点
以下题目按方向分组。点开查看要点——注意它们是要点框架,回答时要结合自己的经历和例子。
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 生成周报 → 发送邮件服务 → 记录发送状态(幂等:同一用户同一周只发一次)。
风险:批量任务失败后的重复发送(需要幂等键);邮件送达率与退订合规;成本突增(设置预算上限);某个用户的收藏中存在注入内容(周报生成不给任何工具,输出做转义)。