Skip to content

第 21 周 · Day 7:压测验收与周复盘——这周加的每一道闸,数字说了算 ​

对应手册任务:学习「周复盘 + 整理」,动手「压测 100 次并发对话,观察延迟和成本,写周记」,当日产出「压测报告 + 周记」。本篇只解决一个问题:这六天往系统里加了追踪、计量、路由、限流、死循环防御、语义缓存六样东西,每样单看都有理,合在一起到底有没有用、会不会互相拖后腿,得用一次真实压力把答案压出来。

今日目标 ​

  1. 用 30 行 asyncio 脚本跑 100 并发对话(30% 缓存命中题 + 70% 新题),拿到四象限数据
  2. 跑两轮对照(无路由无缓存 vs 全开),填出对比表,走一遍「压测→定位→优化」闭环
  3. 按四段模板写 300 字周记,过 10 题自检清单

概念讲解:为什么压测是这周改造的期末考试 ​

先看一个残酷的事实:第 1 周的复盘日,验收标准是「合上教程画得出图、答得出题」。这周不行。这周做的全是生产化改造,它们的价值不在脑子里,在系统里,而「功能跑得通」和「系统在压力下还行」是两回事。压测 50 个并发全绿,不代表 100 个并发不塌;单请求成本 3 分钱,不代表一百个并发冲进来账单不吓人。

单元测试验证的是功能对不对,压测验证的是改造值不值。计量、路由、缓存、限流,这周每一天都声称自己省了钱或保了命,但谁也没看过它们同台表演。路由把简单题分给小模型,语义缓存把重复题挡在模型外面,限流把突发流量关在门外,这三样同时工作,延迟是降是升、成本省了几成、闸门拦了多少,只有压测知道。

所以今天的观测不是一条曲线,是四象限:

象限回答的问题数据来源
延迟分布(P50/P95/P99)快不快压测脚本 + Grafana 面板
成功率稳不稳压测脚本状态码统计
成本(总 token/总费用)贵不贵Day 2 的 llm_usage 表
限流触发次数闸门灵不灵429 计数 + 面板

四个缺一不可。只看 P50,长尾用户被平均数抹掉;只看成功率,全对但烧穿预算也判不出;只看成本,可能是拿延迟换的。四象限一起看,才是一份完整的体检单。

压测脚本是资产,不是一次性用品

这份脚本写完存进仓库,以后每次大改动(换模型、调阈值、改提示词)都跑一遍,对比表多一列。今天它验收这周,以后它看守每个版本。

核心知识 ​

1. 30 行并发脚本:httpx + asyncio ​

工具两条路。想要 Web UI、分布式压测、实时图表,上 Locust:pip install locust,写个 locustfile,浏览器打开 :8089 点开始。但验收今天的场景,第 9 周练过的 httpx + asyncio 功力足够,一个文件的事,还不用装东西。

场景设计是重点。100 个请求不能全是新题,那样语义缓存毫无用武之地;也不能全是重复题,那样测的是缓存不是系统。按真实流量画像配比:30 道固定题反复问(模拟高频常见问题,给缓存机会),70 道模板变形生成的新题(模拟长尾)。

python
# bench.py
import asyncio, random, time
import httpx

URL = "http://localhost:8000/chat"

CACHE_POOL = [                       # 30 道固定题:高频常见问题,测缓存
    "怎么重置密码", "退款流程是什么", "营业时间几点", "发票怎么开",
    "会员怎么取消", "怎么联系人工客服", "你们地址在哪", "怎么改绑定手机",
] * 4
TEMPLATES = [                        # 新题模板:随机填充,每次都不同
    "帮我分析订单 {n} 为什么还没发货",
    "对比方案 A{n} 和方案 B{n} 哪个更适合我",
    "订单 {n} 下单三天没有物流更新,帮我排查",
]

def make_questions():
    news = [random.choice(TEMPLATES).format(n=random.randint(1000, 9999))
            for _ in range(70)]
    return CACHE_POOL[:30] + news

async def one(client, i, q):
    start = time.perf_counter()
    try:                              # 每个虚拟用户独立 user_id,租户统一
        r = await client.post(URL, json={"question": q}, headers={
            "x-user-id": f"bench-{i}", "x-tenant-id": "bench",
            "x-trace-id": f"bench-{i}",   # 预埋 trace_id,Day 5 留的后门
        })
        return start, r.status_code
    except Exception:
        return start, 0

async def main():
    qs = make_questions()
    async with httpx.AsyncClient(timeout=60) as c:
        rs = await asyncio.gather(*[one(c, i, q) for i, q in enumerate(qs)])
    lat = sorted(time.perf_counter() - s for s, _ in rs)
    pct = lambda p: lat[min(int(len(lat) * p), len(lat) - 1)]
    codes = {}
    for _, code in rs: codes[code] = codes.get(code, 0) + 1
    print(f"n={len(lat)}  P50={pct(.5):.2f}s  P95={pct(.95):.2f}s  P99={pct(.99):.2f}s")
    print(f"状态码={codes}  成功率={codes.get(200, 0) / len(lat):.1%}")

asyncio.run(main())

三个设计决定要讲清楚。第一,x-user-id 每个请求独立:Day 3 的用户级闸门是 10 次/分,同一个 user_id 打 100 并发,90 个会吃 429,测出来的不是性能是限流。独立 user_id 模拟 100 个真实用户,两轮合计 200 次也在租户级 1000 次/时之内,闸门不干扰读数。想专门验收闸门,另用 Day 3 的 curl 循环单测。第二,x-trace-id 预埋 bench- 前缀:压测中出错的请求,直接拿这个 ID 去 Langfuse 搜,不用对时间戳。第三,分位数自己算,sorted 加索引,100 个样本的 P99 就是倒数第二个,够用。

2. 前后对比表:这周工作的价值证明 ​

优化的一切收益都是相对量,没有基线就没有发言权。跑两轮:基线轮关掉路由和缓存(全部请求走旗舰模型直答),全开轮路由、缓存、限流全上。两轮之间一个变量都不许动,题库同一份、并发同一档、环境同一个。

指标基线(无路由无缓存)全开(路由+缓存+限流)变化
P50 / P95 / P99
成功率(2xx 占比)
限流触发(429 次数)
缓存命中率不适用
总 token(入 / 出)
总成本(USD)

前四行脚本和面板直接给。后两行一条 SQL,压测开始前记一下时间:

sql
SELECT COUNT(*) AS calls, SUM(prompt_tokens + completion_tokens) AS tokens,
       ROUND(SUM(cost), 4) AS cost
  FROM llm_usage
 WHERE created_at >= '压测开始时间';

填表时看什么。成本列是Day 2那笔账的总验收:旗舰和小模型单价差 16 倍,七成简单题被路由分走,再叠加缓存命中题零模型调用,全开轮成本掉一个量级是正常预期,掉不到就要查。延迟列别只盯 P50:缓存命中题应该把 P50 拉得很低,但 P99 由最慢的新题决定(复杂题走旗舰还可能触发级联重答,一次等于两次调用),P95/P99 才是长尾用户的真相。命中率列对照预期:30% 的固定题配比,命中率明显低于 30%,先查语义缓存的相似度阈值是不是设太紧。

3. 瓶颈定位:把「压测→定位→优化」走成闭环 ​

压测暴露问题不是终点,是起点。四象限各对应一条排障路径,全部用的这周已造好的轮子:

P99 高,先看慢在哪环。 打开 Langfuse,按耗时排序挑最慢的几棵 trace,逐层看 span:embedding、检索、LLM 各占几秒。慢在 LLM,看这条请求是不是复杂题被路由错分给小模型、触发级联升旗舰重答(树上有两次 LLM 调用就是它),或者深度推理任务没走 deep 档的 120 秒超时。慢在检索或 embedding,那是向量库的事,跟模型无关。定位错层,优化就白做。

错误率高,上Day 5的 trace_id 三跳法。 第一跳 Grafana:错误率面板圈出时间窗;第二跳日志:压测请求的 trace_id 预埋了 bench- 前缀,Loki 里直接搜,BFF 和 FastAPI 两段日志一次串起;第三跳 Langfuse:同一个 ID 贴进搜索框,打开树看哪个 span 红着。先排除口径问题:429 是闸门正常工作,别计进失败,剩下的才是真错。

成本高,回Day 2的三条聚合 SQL。 看谁在烧钱:某模型占比异常,查路由命中率;级联升级次数多,查分类规则漏了什么模式;缓存命中率低,查阈值。

限流触发次数,判断闸门拦的是谁。 全开轮 100 并发瞬时打进去,拦下爆发的那部分是设计行为;如果温和流量也大量被拦,阈值配错了。这个象限零触发未必是好事,可能是压测根本没打出突发。

闭环的纪律只有一条:一次只改一个变量。改完再压一轮,对比表加一列,收益归因清清楚楚。改了三处再压,数字变好了也不知道该谢谁。压测报告就是这套东西的落档:对比表 + 一段结论 + 定位到的瓶颈和处理方式,存进仓库。

4. 300 字周记模板 ​

模板和第 1 周同一套,四段缺一不可:最大收获、卡得最久、还含糊、下周前补什么。第 21 周示例,照这个密度写:

text
① 本周最大收获:生产化不是一个功能,是一组互相咬合的闸门。计量让成本
可见,路由让大部分请求花小钱,缓存让重复问题不花钱,限流和步数上限兜
住最坏情况,一个 trace_id 把三支柱缝成一件衣服。压测对比表出来那一刻,
六天的东西第一次变成了同一个系统。
② 卡得最久:语义缓存阈值。放到 0.85 命中率漂亮,但"退款流程"命中了
"退货流程"的缓存,答案驴唇不对马嘴;升到 0.93 才稳。明白了阈值是业务
容错度的函数,不是拍出来的技术参数。
③ 还含糊:OTel GenAI 规范只看了个大概;令牌桶的速率 r 和桶容量 b 怎么
按业务流量画像定,没有手感。
④ 下周前补:回读 Day 1 的 OTel 部分;拿 100 题评估集实测路由分类准确率,
给级联率一个基准。

动手任务:完成压测验收与周复盘 ​

按顺序五步,预计 90 分钟。

第一步:备环境(10 分钟)。 确认全套在跑:BFF、FastAPI、Redis、Langfuse、Prometheus。压测前在 Grafana 上看一眼四块面板是活的,压测时曲线会实时动,这是 Day 5 的面板第一次接真活。

第二步:跑基线轮(15 分钟)。 关路由(全走旗舰)、关缓存,记录开始时间,跑 python bench.py,抄下四象限数据。同时看 Grafana 的成本曲线和 P95 面板,和脚本数字互相印证。

第三步:跑全开轮(15 分钟)。 路由、缓存、限流全开,等一分钟让上一轮的滑动窗口滑干净,同一份题库再跑。补一小轮闸门验收:同一个 user_id 连打 12 次 /chat,看第 11 次起变 429、响应头带 Retry-After。

第四步:填对比表、写定位(25 分钟)。 成本 SQL 跑两轮的区间,填表,算变化幅度。挑一个压测暴露的最刺眼问题(P99 高或命中率低),走一遍上面的定位路径,把结论写进报告,改一项,再压一轮验证。这份对比表加结论就是当日产出「压测报告」。

第五步:周记加自检(25 分钟)。 按模板写 300 字周记,再做下面的 10 题自检,答不上的回读对应 Day。

第 21 周自检清单 ​

规则同第 1 周:先口头答,说完整了再点开对照。

问题 1:可观测性三支柱靠什么缝合?三跳排障的接力棒是什么?(Day 5)

答案

靠一个贯穿全链路的 trace_id:它随 AsyncLocalStorage 进 BFF 的每行 pino 日志,随 header 透传 FastAPI 进 contextvars,最后直接当 Langfuse 的 trace id。三跳是 Grafana 尖刺圈时间窗、日志捞 trace_id 串证据、Langfuse 搜同一 ID 看调用树,接力棒从头到尾是同一个 ID。断一环,链条全断。

问题 2:Langfuse 和 LangSmith 怎么取舍?(Day 1)

答案

LangSmith 接入最省,三个环境变量零代码,但国内访问不稳,且每条 trace 含用户原始数据全部出境,公司项目一票否决。Langfuse 是 MIT 开源、可自托管(PostgreSQL + ClickHouse + Redis + MinIO 全家桶),数据模型与 LangSmith 基本同构,数据不出门。个人学习用 LangSmith 起步快,生产选自托管 Langfuse;OTel GenAI 规范的意义是埋点一次、后端可换,别把自己锁死在任何一家。

问题 3:模型路由的策略是什么?级联兜底什么时候触发?(Day 2)

答案

规则先行:正则把问题分类(简单走小模型、复杂走旗舰、超长问题升旗舰),判断本身不用 LLM,必须便宜。级联兜底补规则的漏网之鱼:小模型答完后,分类置信度低于 0.7、或答案带拒答气味、或问得多答得少,就升旗舰重答。路由省钱、级联保质量,每笔调用都入 llm_usage,账是实的。

问题 4:限流四算法怎么选型?(Day 3)

答案

口诀:要精确计数选滑动窗口,要容忍突发选令牌桶(业界默认,云厂商 API 大多用它),要强行匀速保护脆弱下游选漏桶,图省事且不怕突刺才用固定窗口。固定窗口有临界突刺,交界处 2 秒能塞进双倍流量,只配管签到这类粗粒度场景。

问题 5:双维度限流各防什么?(Day 3)

答案

用户级(10 次/分)防单账号刷接口;租户级(1000 次/时)防「每个用户都合规、加起来爆账单」:500 人各打 9 次/分,合计 4500 次/分,没有任何用户触发限流,只有租户闸门拦得住。先撞哪道拦哪道,本质是速率风险的聚合单位不只是单个用户。

问题 6:recursion_limit 默认值是多少?生产上怎么定?(Day 4)

答案

LangGraph 默认 25。生产上别裸依赖默认值:显式声明(本周设的就是 25),更关键的是撞限后的优雅终止,捕获超限异常返回兜底回复而不是把堆栈甩给用户,再配一条 P95 步数逼近上限的告警,在用户投诉前看到有任务在硬撞天花板。具体值按任务步数分布调,显式声明、优雅终止、步数告警三件套比数字本身重要。

问题 7:死循环的四层防御是哪四层?(Day 3-4)

答案

由外到内:BFF 限流管人发起的速率;LangGraph recursion_limit 是图级硬顶,防止节点间无限跳转;Agent 会话步数预算(Redis 计数,上限 50)防不走 HTTP 的内部循环调用,限流 Guard 看不见这一层;分级超时(5s/30s/120s)加重试上限(2 次封顶、指数退避)管单次调用的最坏情况。四层各防一段,删任何一层剩下的都会被绕过。

问题 8:语义缓存的相似度阈值怎么权衡?(Day 6)

答案

阈值高(0.95 上下):误命中少、答案准,但命中率降、省钱少;阈值低(0.85 上下):省钱多,但「意思相近不等于同义」的误命中会把相近问题答错。它是业务容错度的函数:闲聊客服可以松一点,金融、医疗、法务宁高勿低。上线后从 trace 里抽查误命中案例再微调,别拍一次就不管。

问题 9:缓存失效有哪些触发条件?(Day 6)

答案

三个维度:TTL 到期管时间保鲜;知识库或文档更新管内容失效,重新做向量化时旧缓存必须跟着清;模型或提示词换版本管答案来源变化,把版本号写进缓存键,换版本天然全量失效。核心原则一句话:答案的来源变了,缓存必须跟着失效,否则省的是小钱,错的是答案。

问题 10:压测四象限是什么?为什么缺一不可?(Day 7)

答案

延迟分布(P50/P95/P99)、成功率、成本(总 token 和总费用)、限流触发次数,各回答一问:快不快、稳不稳、贵不贵、闸门灵不灵。只看 P50 会抹掉长尾,只看成功率看不出烧钱,只看成本可能是拿延迟换的。四象限一起看才是完整体检单,且必须和基线轮对比着看。

10 题全对也别飘

全对说明这周及格。对比表、压测报告、周记三样落档,本周才算收口。

常见踩坑 ​

拿同一个 user_id 打 100 并发。 90 个请求吃 429,还以为系统崩了,其实是 Day 3 的闸门在尽职工作。压性能给每个虚拟用户独立 user_id,验闸门单独用循环 curl 打单用户,两件事别混。

只报平均延迟。 LLM 应用的延迟天生双峰:缓存命中几百毫秒,深度推理几十秒,平均数把两者搅成一锅没意义的糊。必须报分位数,Day 5 的告警线设在 P99 而不是均值,就是承认长尾才是体验的真相。

两轮压测不控变量。 边压边改配置,或者两轮题库不一样,对比表立刻作废。同一份题库、同一档并发,两轮之间一个变量都不动,收益才归因得了。

把 429 记成失败。 成功率象限统计 2xx 占比,429 单独计数进限流象限。闸门拦住突发是设计行为不是故障,混在一起会把正常工作读成事故,反之大量 429 落在温和流量上才是配置问题。

压完不落档。 数字看完关终端,下周改动又从零开始猜。对比表和结论写进仓库,它同时是下次压测的基线和面试时「你的 Agent 怎么控制性能和成本」的现成答案。

延伸阅读 ​

写完周记第四段的补课动作清掉,第 21 周到此收口。