18.1 容量与架构评审清单
前 17 章,我们一节一节地把 ResearchCopilot 从骨架搭成服务:配置、鉴权、数据层、缓存、队列、并发、记忆、评测、可观测、安全、容器化、发布。可当有人问「这套系统能上线了吗」,你没法指着一堆章节说「都写了」。上线需要一个可判定的答案:哪些项必须过、过的标准是什么、撑得住多少量。
本节把 ResearchCopilot 推进到「可评审」:把全卷的工程决策收成五组可执行清单,每条给出通过标准;再用一个真跑的脚本,从日活一路推出每日 token、成本、DB 增速、Redis 内存与 worker 数。
18.1.1 清单的用法:把「感觉没问题」变成「逐条打勾」
评审清单最怕写成空话——「要有缓存」「要能观测」谁都会说,但没人能判定过没过。所以本节每条都配一个通过标准:一个可观测、可执行、可判定的条件。判据的写法有三条铁律:
- 能指向具体产物:不是「要有缓存」,而是「检索缓存命中率有监控,key 含
workspace_id与语料版本,TTL 有依据」。 - 能指向具体动作:不是「要能回滚」,而是「回滚脚本演练过,从触发到恢复 ≤ 5 分钟」。
- 能指向具体数字:不是「要限流」,而是「按租户 token 配额,超限返回 429 且带
retry_after」。
一句话:每一条都必须能在评审会上被追问「怎么证明」,并且答得出来。下面五组共三十条,逐条打勾,全绿才算可以进入 18.3 的上线流程。
清单还有个常被忽略的用法:它不是一次性检查,而是回归资产。把它存进仓库、跟代码一起版本化,每次大改动(换模型、加缓存、改队列)都重跑一遍——上一版全绿、这一版冒红,红的那条就是你改动引入的风险。这和 12.3 的质量门是同一个思路:把「对不对」变成「有没有变差」。
18.1.2 清单分三级:阻断、警告、建议
不是每条都一样重。上线评审最忌讳「一票全否」——把「没接 OpenTelemetry」和「密钥写死在代码里」当成同等严重,评审就会沦为扯皮。所以每条检查项还要带一个级别,决定它在评审会上是什么声音:
| 级别 | 含义 | 处理 |
|---|---|---|
| 阻断(P0) | 不满足不许上线 | 必须当场给出修复计划与时间 |
| 警告(P1) | 可以上,但限期内补 | 记入待办,下次评审复查 |
| 建议(P2) | 锦上添花 | 排进常规迭代 |
判定 P0 的标准很朴素:出事了会不会伤到钱、伤到数据、伤到用户信任。密钥落盘、租户隔离失效、没有超时保护、迁移不可回滚——这些是 P0;看板不够漂亮、日志字段不全——这些是 P1 或 P2。下面五组表里的检查项,默认按「能伤到什么」自行定级,评审时逐条标注。
18.1.3 第一组:接口与契约
| 检查项 | 通过标准 |
|---|---|
| 接口 schema 版本化 | 对外接口与事件都有 schema,新增字段可选、删除走 deprecation 周期,CI 有兼容性检查 |
| 错误契约统一 | 所有非 2xx 返回 code / message / details / request_id 四字段,不泄露栈或内部路径 |
| 幂等键 | 写接口接受 Idempotency-Key,重复提交返回同一结果,不重复扣费 |
| 流式可续传 | SSE 事件带 id,支持 Last-Event-ID 断线续传,不丢不重 |
| 内部边界清晰 | MCP 工具、记忆服务以契约描述,跨进程不共享内存对象 |
| 时间与分页 | 时间统一 UTC 带时区,列表接口用游标分页,不用 offset |
契约是最先冻住的东西。第 3 章的 REST 与 SSE、第 9 章的事件协议、第 10 章的 MCP 工具,都是契约。契约一变,前端、SDK、评测夹具全要跟着动——所以评审时对契约最严,宁可现在加字段,也别上线后改形状。
18.1.4 第二组:数据与一致性
| 检查项 | 通过标准 |
|---|---|
| 迁移不中断 | expand-contract 两步走,回滚脚本可用,且演练过(17.3) |
| 事务边界明确 | 一次 run 的落库在单事务或明确的补偿流程内,无跨服务长事务 |
| 租户隔离 | 所有查询带 workspace_id,应用层与行级策略双重保证(4.2) |
| 缓存一致性 | 缓存 key 含 workspace_id 与语料版本,TTL 有依据,失效路径明确 |
| 幂等写 | 消息/运行写入用唯一约束防重,重放不产生重复行 |
| 备份可恢复 | 有备份策略,且做过恢复演练,RPO/RTO 已知 |
这一组的关键词是「双重保证」和「演练过」。租户隔离不能只靠应用层 SQL 拼接,还要有数据库行级策略兜底——一层写错,另一层拦住。迁移与备份同理:没演练过的迁移脚本和没演练过的备份,等于没有。
18.1.5 第三组:并发与资源
| 检查项 | 通过标准 |
|---|---|
| 下游独立限并发 | 模型/DB/检索各有独立 Semaphore,上限按 QPS × 耗时 估且留余量(8.2) |
| 连接池匹配 | pool.max_size × 副本数 < 数据库 max_connections,留安全余量 |
| 超时与取消 | 每次模型/工具调用有超时,超时触发取消并走降级链(8.3) |
| 背压 | 生产-消费之间是有界队列,积压转阻塞而非 OOM |
| 无资源泄漏 | 超时后连接/任务/文件句柄被回收,压测后内存与连接数不增长 |
| 优雅退出 | SIGTERM 后停止取新任务,等在途完成再退出(16.3) |
并发这一组是最容易出事、也最难事后补的。第 8 章讲透了原理,评审时只需验证「有没有落地」:每一类下游是不是各有一个闸门、超时是不是配了、队列是不是有界。这三问答不上来,压测必崩。
18.1.6 第四组:可观测与告警
| 检查项 | 通过标准 |
|---|---|
| 每次 run 可追踪 | 有 run_id,span 树覆盖模型调用与工具调用(13.1) |
| 关键指标有看板 | 延迟分位、成功率、队列深度、成本、缓存命中率同屏可见(13.2) |
| 告警可行动 | 每条告警有阈值、负责人、runbook,不留「无主告警」(13.3) |
| 成本可归因 | token/成本按 run/租户/工具三维可查(14.2) |
| 日志结构化 | JSON 日志带 trace_id,可按租户与 run 检索 |
| 探针分离 | liveness 与 readiness 分开,readiness 依赖 DB/Redis 真实连通 |
可观测的价值不在「有数据」,而在「能回答一个具体问题」:这次 run 为什么慢、这个租户为什么超预算、这条告警该谁处理。三条 run_id 串起来的链路(追踪 → 日志 → 成本)就是排查的骨架,评审时验证它是否闭环。
18.1.7 第五组:安全与合规
| 检查项 | 通过标准 |
|---|---|
| 输入护栏 | 提示注入与越权工具调用被拦截或降权(15.1) |
| 输出护栏 | 敏感信息与幻觉引用被检出并标注(15.2) |
| 密钥不落盘 | 密钥只从环境注入,日志与错误信息脱敏 |
| 高危工具审批 | 写操作类工具需策略或人工审批(4.3) |
| 审计留痕 | 谁在何时调用了什么工具、参数是什么,可审计(15.3) |
| 数据可删除 | 按用户删除端到端可达,含缓存与向量索引(11.3) |
| 配额与限流 | 按租户 token 配额,超限明确拒绝且不静默失败(6.3) |
安全这一组的底线是**「密钥永不出现」**。本卷从头到尾正文里没有一处密钥字面量,代码里只有 os.environ["ANTHROPIC_API_KEY"] 这一个引用——评审时这条要当硬指标查:翻代码、翻配置、翻日志,确认没有密钥泄漏。
18.1.8 容量估算:从日活推出基础设施
清单回答「做没做对」,容量估算回答「撑不撑得住」。估算的链条很短:日活 → run 数 → 模型调用数 → token 与成本 → 存储增速 → 缓存内存 → worker 数。每一环都是上一环乘一个系数,唯一需要你填的是假设:
| 输入假设 | 取值 | 依据 |
|---|---|---|
| 日活用户 DAU | 2000 | 产品目标 |
| 每用户每天 run 数 | 10 | 运营观察 |
| 每 run 模型调用次数 | 5 | 多跳检索 + 工具 + 收尾 |
| 每次调用 input/output token | 2500 / 300 | 含历史累积 |
| 单价(input/output) | $3 / $15 每百万 | 示例价,以实际为准 |
| 单 run 落库 | 30 KB | 消息 + 步骤 + 索引 |
| 数据保留天数 | 90 | 合规要求 |
把假设填进脚本,就能推出一张基础设施账。链条的每一环都是一个乘法,值得单独看清:
- 每日 run 数 = 日活 × 每用户 run 数 = 2000 × 10 = 20000。
- 每日模型调用 = run 数 × 每 run 调用数 = 20000 × 5 = 100000。
- 每日 token = 调用数 × 单次 token;成本 = token / 百万 × 单价。
- DB 日增 = run 数 × 单 run 落库大小;累计 = 日增 × 保留天数。
- Redis 内存 = 每小时新增缓存条数 × TTL 小时 × 单条大小(稳态在途量)。
- 峰时并发 = 峰时 QPS × 单 run 耗时(利特尔法则,8.2 已推过)。
下面这段是真跑的估算脚本。
18.1.9 真跑:一份容量估算表
"""18.1 容量估算:从日活一路推出每日 token、存储增速、Redis 内存与 worker 数。"""
import math
DAU, RUNS_PER_USER, CALLS_PER_RUN = 2000, 10, 5 # 日活 / 每用户 run 数 / 每 run 调用数
IN_TOK, OUT_TOK = 2500, 300 # 每次调用 token
PRICE_IN, PRICE_OUT = 3.0, 15.0 # 美元 / 百万 token
KB_PER_RUN, RETENTION_DAYS = 30, 90 # 单 run 落库 KB / 保留天数
CACHE_KB, CACHE_TTL_H = 4, 6 # 缓存单条 KB / TTL 小时
QUEUE_KB = 0.5 # 队列单条 KB
PEAK_HOURS, PEAK_FACTOR, RUN_SEC, PER_WORKER = 10, 3.0, 12, 8
runs_day = DAU * RUNS_PER_USER
calls_day = runs_day * CALLS_PER_RUN
in_tok, out_tok = calls_day * IN_TOK, calls_day * OUT_TOK
cost_day = in_tok / 1e6 * PRICE_IN + out_tok / 1e6 * PRICE_OUT
db_mb_day = runs_day * KB_PER_RUN / 1024
db_gb = db_mb_day * RETENTION_DAYS / 1024
redis_mb = (runs_day / 24 * CACHE_TTL_H * CACHE_KB
+ runs_day / PEAK_HOURS * 0.1 * QUEUE_KB) / 1024
peak_qps = runs_day / PEAK_HOURS / 3600 * PEAK_FACTOR
concurrent = peak_qps * RUN_SEC
workers = math.ceil(concurrent / PER_WORKER) + 1 # +1 冗余
rows = [
("每日 run 数", f"{runs_day:,}"),
("每日模型调用", f"{calls_day:,}"),
("每日 input token", f"{in_tok/1e6:,.1f} M"),
("每日 output token", f"{out_tok/1e6:,.1f} M"),
("每日模型成本", f"${cost_day:,.2f}"),
("每月模型成本", f"${cost_day*30:,.0f}"),
("DB 日增", f"{db_mb_day:,.0f} MB"),
(f"DB {RETENTION_DAYS} 天累计", f"{db_gb:,.1f} GB"),
("Redis 内存合计", f"{redis_mb:,.1f} MB"),
("峰时 QPS", f"{peak_qps:,.2f} run/s"),
("峰时并发 run", f"{concurrent:,.1f}"),
("worker 数(含 1 冗余)", f"{workers}"),
]
print(f"{'指标':<22}{'值':>16}")
print("-" * 38)
for k, v in rows:
print(f"{k:<22}{v:>16}")
用 venv 里的 Python 3.14.6 跑出来(参数是假设的,计算过程是真实的):
指标 值
--------------------------------------
每日 run 数 20,000
每日模型调用 100,000
每日 input token 250.0 M
每日 output token 30.0 M
每日模型成本 $1,200.00
每月模型成本 $36,000
DB 日增 586 MB
DB 90 天累计 51.5 GB
Redis 内存合计 19.6 MB
峰时 QPS 1.67 run/s
峰时并发 run 20.0
worker 数(含 1 冗余) 4
这张表一眼读出四个决策点:
- 成本是第一约束:每月 3.6 万美元几乎全在 token 上(output token 单价是 input 的 5 倍,30 M output 就占了 45% 的钱)。要降本,第 14.2 章的缓存与路由才是主战场,而不是省存储。
- 存储很便宜:90 天才 51.5 GB,一张 Postgres 卷绰绰有余。所以别为了省存储去删运行历史——它正是评测回放(12.2)与故障复盘的原料。
- Redis 内存小得意外:稳态常驻不到 20 MB。这说明「加缓存」的成本瓶颈从来不是内存,而是命中率——缓存不命中,省下的内存换不来任何延迟收益。
- worker 只需 4 台(含冗余):峰时并发 20 个 run,每台扛 8 个并发,3 台就够,多留 1 台冗余。真实上线时按这个数的 2 倍开,把拐点(14.3)留出余量。
18.1.10 从估算到动作:把数字变成预案
估算表本身不会扩容,能扩容的是写进预案的触发条件。每个数字都要配一条「到多少就做什么」:
| 指标 | 阈值 | 动作 |
|---|---|---|
| 峰时并发 run | > 12(估算 20 的六成) | worker 从 4 扩到 6,观察 30 分钟 |
| 队列深度 | > 500(持续 2 分钟) | 触发 18.2 的积压告警,人工判断是否扩 worker |
| DB 磁盘 | > 70% | 归档超过保留期的运行数据,或扩卷 |
| 月成本 | > 预算的 80% | 收紧配额、提高缓存命中率、下调模型档位(14.2) |
| Redis 内存 | > 60% | 缩短缓存 TTL 或清理冷 key |
这张表是容量估算与 13.3 告警的接缝:估算给出「正常应该是多少」,告警负责「偏离了多少」。没有估算,你不知道该把阈值定在哪;没有告警,估算就只是一张纸。两者合起来,才叫「容量有数」。
18.1.11 验收清单与真实环境
本节的验收:
- 五组清单逐条打勾,每条能答出「怎么证明」
- 容量估算的假设写进文档,可随运营数据更新
- 估算出的成本、存储、worker 数有对应的监控与扩容预案
- 每个数字都能追到公式,而不是拍脑袋
- 清单已版本化入库,大改动后重跑一遍
真实环境里会怎样:估算表的每一个系数都会漂移。日活可能翻倍,单 run 调用次数会因为模型「更爱多跳」而涨到 8 次,input token 会因为记忆注入而膨胀。所以这份表的价值不在那几个数字,而在公式本身——运营数据一变,改几个系数就能重算。另外,真实的峰谷比远不止 3 倍,突发流量(一次营销、一条爆款)能在几分钟内把并发推到均值的十倍,这正是 18.2 要演练的「队列积压」场景。
还有一处估算会低估:token 单价会变。模型降价、缓存命中(prompt caching)打折、路由到小模型,都会让实际成本低于按标准价算出的数。所以容量表要写「按当前价目」并定期复核,而不是当成永久承诺。宁可先按贵价估、留出预算余量,也别乐观低估——超支比浪费难处理得多。
清单与容量都过了,下一节开始动手:主动把系统弄坏,看它会不会优雅地坏。先看 18.2 故障演练:模型超时/DB 宕机/队列积压 。
阅读导航:上一节:17.3 版本与迁移:不中断 schema 迁移演练 · 下一节:18.2 故障演练:模型超时/DB 宕机/队列积压 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。