1. 先搞清楚这场压测到底在测什么
标题里那几个词——“OpenClaw”“千问3.5-9B”“持续运行24小时”——听起来像是某种硬核跑分,但真正动手之前,你得先问自己一个问题:我要的结论是什么?是“这个组合能不能跑”,还是“这个组合能不能连续跑一天不出事”?这两件事的技术难度差了至少一个数量级。我用 OpenClaw 这个开源智能体执行框架挂上本地部署的千问3.5-9B,做了一次完整的24小时长跑压力测试,中途踩了不少坑,也推翻了自己不少想当然的判断。这篇就把整场测试的设计思路、实测数据、排查过程和可复现的配置全摊开讲。
如果你只是想让模型回答几个问题,那随便一台机器装个推理服务就够了,这篇文章对你意义不大。但如果你打算把智能体真正放到生产环境里、让它无人值守地连续处理任务,那你一定会遇到显存缓慢爬升、上下文越滚越大、延迟突然跳变、工具调用陷入死循环这些问题——它们不会在五分钟的演示里暴露,只会在第七个小时、第十三个小时、第二十个小时突然给你来一下。这篇报告针对的就是这些“长跑型”故障。适合有一定部署基础、正在做本地智能体落地的开发者,也适合只是想了解大模型长稳测试该怎么设计指标和埋点的同学,核心计算和脚本我都会写清楚,照着改就能跑。
我先把结论性的判断放在前面,方便你带着问题往下读:这次测试里,纯推理层面的稳定性非常好,24小时零 OOM、零进程崩溃;真正拖垮整体可用性的是智能体侧的上下文管理和重试逻辑。换句话说,模型的锅不大,框架的锅不小。这也是为什么单纯测“模型吞吐”意义有限——真实负载里,模型只占整条链路的一小段。
1. 测试目标和负载模型:别拿短问答冒充压力测试
1.1 为什么“能跑起来”是最没有信息量的结论
任何一次压测,如果负载设计和真实使用场景脱节,那测出来的数字再漂亮也是自欺欺人。我见过太多人做的所谓压力测试,就是拿几个固定 prompt 循环打接口,看服务会不会挂。这种做法只能验证“进程会不会崩”,验证不了“系统会不会退化”。真正的长跑型问题,几乎全部来自状态累积:对话历史越存越长、KV 缓存碎片越来越多、工具调用的中间结果堆在内存里没人清、日志文件把磁盘写满、文件描述符一点点漏光。
所以我给自己定的第一条原则是:压测负载必须保留真实场景里的“脏东西”。真实用户不会每次都问一个干干净净的新问题,他们会追问、会纠正、会让智能体去查资料再汇总、会在同一个会话里聊上三十轮。这些行为叠加起来,才是压力和短跑测试的分水岭。基于这个判断,我把负载拆成了三类请求,按真实比例混流,而不是用单一类型刷数据。
三类请求的具体设计如下,这也是后面所有数据的基础,你要复现的话直接照这个比例配置就行:
- 短问答型(占比 45%):单轮,输入 80-200 token,期望输出 200-400 token。模拟“问个概念、查个定义”这类高频轻负载,主要用来观察基础延迟和吞吐。
- 工具链路型(占比 40%):强制触发 3-5 次工具调用,OpenClaw 会拆任务、调检索、调代码执行沙箱、汇总再输出。输入长度浮动大(300-1500 token),中间结果会反复塞回上下文。这是最容易出问题的一类,也是最接近真实使用的一类。
- 长上下文型(占比 15%):单次输入 6000-8000 token,接近 max_model_len 上限,用来压 KV 缓存和前缀缓存的命中率。
比例定成 45/40/15 不是拍脑袋。我观察了一批真实会话日志,日常使用中轻问答确实占大头,但只要涉及“帮我做件事”,工具链路就会成为耗时和失败的主要来源。如果按 1:1:1 平均分,短问答会被过度放大,整体指标虚高;如果只测工具链路,又会让并发压力失真,因为长上下文的资源占用和前两类完全不是一个量级。
1.2 三档并发的设计逻辑
并发档位我设了 1、4、8 三档,每档跑 8 小时,中间重叠推进,最终拼成完整的 24 小时曲线。为什么是 8 而不是更高?因为这是 24GB 显存单卡在 8K 上下文下的实际上限附近——再往上加,KV 缓存池会被撑爆,测出来的就不是“稳定性”而是“OOM 触发时间”了。
这里有个容易被忽略的点:并发数不等于请求数。我一开始用固定 QPS 去打,结果发现智能体框架本身有排队机制,8 个并发槽位里有 3 个长期被长上下文请求占着,实际打给模型的并发只有 5 左右。后来改成信号量控制 + 请求队列深度监控,才能真正看清“槽位占用”和“模型并发”的差异。如果你也做类似测试,务必把这两层分开埋点,否则你会得到一个“看起来并发很高但模型很闲”的诡异结论。
注意:并发档位切换时不要直接改配置重启服务,那会把显存状态清空,掩盖掉长时间运行才有的碎片化问题。正确做法是保持服务进程不动,只调压测端的并发数,让服务在同一个进程生命周期内经历多档压力。这次测试的三个并发档就是在同一个服务实例上依次跑完的。
1.3 什么叫“持续可用”:指标必须先定义
没有指标定义的压测报告等于没写。我这次盯的核心指标分四层,从外到内依次是:可用性、延迟、资源、失败模式。
| 层级 | 指标 | 采集方式 | 判定标准 |
|---|---|---|---|
| 可用性 | 请求成功率、进程存活时长 | 压测端统计 + 进程守护脚本 | 成功率 ≥ 99%,进程零重启 |
| 延迟 | TTFT、端到端 P50/P95/P99 | 压测端埋点 | P95 不超过基线的 1.5 倍 |
| 资源 | 显存峰值、系统内存、GPU 利用率、温度 | 5 秒一次采样 | 显存不触及上限,内存增长 < 5%/24h |
| 失败模式 | 超时、上下文截断、工具死循环、空返回 | 日志分类打标 | 单类占比 < 1% |
TTFT(首 token 延迟)这个指标必须单独拎出来,因为它和端到端延迟反映的是完全不同的东西。TTFT 主要由 prefill 阶段决定,跟输入长度强相关;端到端还要加上解码时间和工具调用耗时。很多报告只报平均延迟,看到数字没涨就以为一切正常,其实 TTFT 可能已经从 0.3 秒涨到了 2 秒——用户体感上就是“按下回车之后卡住了”,这种退化比吞吐下降更致命。
还有一点:基线必须自己跑。不要拿别人的跑分数据当参照,硬件、量化方案、推理引擎版本、甚至显卡驱动版本都会影响结果。我这次是在服务刚启动、显存最干净的状态下先跑了 200 条混合请求,把结果作为基线,后面所有对比都基于这个基线。
2. 环境与参数:把不确定性提前压掉
2.1 硬件清单与显存预算的算法
先把环境摆清楚。这套配置不是最优解,是“大多数个人开发者和小团队手上能凑出来”的典型配置:
| 项目 | 规格 | 说明 |
|---|---|---|
| GPU | 单卡 24GB 显存 | 消费级,非数据中心卡 |
| 系统内存 | 64GB DDR5 | 智能体框架和工具沙箱都吃内存 |
| CPU | 16 核 | 主要给工具调用和文本处理用 |
| 存储 | 1TB NVMe | 日志和中间文件写入频繁 |
| 系统 | Ubuntu 22.04 | 驱动和推理引擎兼容性最好 |
| 推理引擎 | PagedAttention 系、OpenAI 兼容接口 | 支持连续批处理和前缀缓存 |
显存预算是整个部署里最容易翻车的一环,很多人是“先启动,报 OOM 了再降参数”,纯属浪费时间。我的做法是先算再跑,公式很简单:
总显存 ≈ 权重显存 + KV 缓存 + 激活与框架开销
权重显存按量化精度算:9B 参数,FP16 每参数 2 字节约 18GB,INT8 约 9GB,4-bit 量化约 5.4GB。KV 缓存的公式是:
KV 显存 = 2 × 层数 × KV头数 × head_dim × 序列长度 × 并发数 × 精度字节数
按千问3.5-9B 这个量级的典型结构估:40 层、GQA 8 个 KV 头、head_dim 128,FP16 精度下每 token 的 KV 占用是 2 × 40 × 8 × 128 × 2 = 163840 字节,约 160KB。8K 上下文单序列就是 1.25GB,8 并发就是 10GB。再加上激活值和框架开销 2-3GB,总量就逼近 24GB 红线了。
算完这笔账,结论很清楚:FP16 权重配 8 并发 8K 上下文,单卡 24GB 装不下。于是只能在两个方向压缩——降权重量化精度,或者降并发。我选了前者,因为并发降下来吞吐就没法看了。
2.2 量化档位的取舍:为什么不选最激进的
量化档位这块我做了三组对比测试,数据如下(同一批混合请求,并发 4):
| 量化方案 | 权重显存 | 可用 KV 池 | 平均解码速度 | 短问答准确率 | 工具调用参数正确率 |
|---|---|---|---|---|---|
| FP16 | 18.0GB | 严重不足 | 38 tok/s | 基准 | 基准 |
| INT8 | 9.2GB | 约 9GB | 52 tok/s | -0.4% | -0.7% |
| 4-bit(Q4_K_M) | 5.4GB | 约 14GB | 61 tok/s | -2.1% | -4.8% |
数据很说明问题。4-bit 量化在纯问答上损失还能接受,但工具调用参数正确率掉了接近 5 个百分点——这在智能体场景里是灾难性的,因为一次工具调用参数错误就会引发重试,重试又会让上下文膨胀,形成连锁反应。我最终选了 INT8:显存省了一半,解码速度反而比 FP16 快(因为显存带宽压力小了),精度损失在可接受范围内。
这个选择背后的逻辑值得多说两句。很多人默认“量化越激进越快”,其实在显存受限的单卡场景里,瓶颈往往不是算力而是显存带宽和 KV 池容量。4-bit 权重确实更小,但反量化本身有开销,而且量化后的模型在小 batch 下算力利用率上不去。INT8 在这个规模上是个甜点:权重省一半,精度几乎无损,KV 池也够用。
提示:如果你的场景里工具调用占比很低、主要是问答,4-bit 是划算的;但只要涉及结构化输出、函数参数生成、多步规划,就别省这点显存了。压测报告里我建议同时列出两档数据,让读者自己按场景选。
2.3 服务端关键参数:每个都要说清为什么
参数配置我列了个表,每一项都标注了取值理由,你可以直接抄:
| 参数 | 取值 | 理由 |
|---|---|---|
| max_model_len | 8192 | 覆盖 95% 真实请求,再高 KV 池就不够分 |
| gpu_memory_utilization | 0.85 | 留 15% 给激活值和碎片,0.95 在长跑中必崩 |
| max_num_seqs | 8 | 与压测并发档位上限对齐,避免排队雪崩 |
| enable_prefix_caching | 开启 | 长上下文型请求重复前缀多,实测 TTFT 降 30% |
| swap_space | 4GB | 极少数超长请求的兜底,宁可慢不可崩 |
| max_tokens | 1024 | 硬性截断,防止单条请求无限生成拖垮队列 |
gpu_memory_utilization这一项我要重点说。很多人为了“榨干显卡”会设到 0.92 甚至 0.95,短时间跑确实没事,但连续跑几个小时之后,显存碎片化会让实际可用空间不断缩水,最后在某个不起眼的时刻突然 OOM。0.85 是个经验值,牺牲了大约 8% 的理论吞吐,换来了整个长跑的零 OOM。这笔交易很值。
max_tokens的硬截断也很有必要。智能体框架有个特点:它会在某次工具调用失败后尝试“解释一下为什么失败”,如果模型陷入复读或者过度解释,单条请求可能生成几千 token。没有硬截断的话,一个异常请求就能把整个队列堵死几十秒。
2.4 监控埋点:不埋点等于白测
监控这块我分了四路并行采集,每路 5 秒一次,统一打时间戳后落盘,方便事后对齐:
- GPU 侧:显存已用、显存总量、GPU 利用率、温度、功耗。用
nvidia-smi的查询模式循环采样,别用watch,那个精度不够。 - 系统侧:内存占用(区分 RSS 和 cache)、swap 使用、磁盘写入速率、打开的文件描述符数量。文件描述符这个指标特别容易被忽略,但工具调用频繁的框架最容易在这里漏。
- 服务侧:队列深度、活跃序列数、KV 缓存使用率、前缀缓存命中率。这些通常能从推理引擎的指标接口拿到。
- 应用侧:每次请求的 TTFT、总耗时、输入输出 token 数、工具调用次数、重试次数、失败原因标签。
四路数据要靠统一的时间戳对齐,否则事后根本对不上“延迟飙升的那一分钟,显存到底发生了什么”。我一开始偷懒只采了 GPU 和应用两层,结果第 11 小时出现延迟尖峰时完全无法定位,只能重跑。这个教训很贵,别重复。
3. 24小时实录:曲线里藏着什么
3.1 第 0-2 小时:热身期的假象最危险
前两个小时一切完美得不像话。并发 1 和 4 混跑,P95 端到端延迟稳定在 1.8 秒左右,TTFT 中位数 0.31 秒,显存占用从 15.2GB 缓慢爬到 15.6GB,看起来稳如老狗。
这个阶段最大的陷阱是让人误以为测试已经成功了。我见过太多人跑到这一步就截图发结论了。实际上热身期的数据几乎没有参考价值,因为此时上下文都是新的、KV 缓存是干净的、所有缓存都在冷启动后的最优状态。真正的退化要到四五个小时之后才开始显形。
这两小时我唯一做对的事是建立了基线。200 条混合请求跑完,我记录下了每类请求的 TTFT 分位数、端到端分位数、平均输出 token 数、工具调用平均轮次。后面所有异常判断都是以这份基线为锚点的,比如“P95 超过基线 1.5 倍就告警”。没有基线,你看到延迟从 1.8 涨到 2.4 时根本不知道该不该管。
热身期还暴露了一个小问题:工具调用型请求的首次执行明显偏慢,平均 6.2 秒,而后续同类请求只要 3.8 秒。排查下来是代码执行沙箱首次启动要加载依赖,属于冷启动开销。这个不影响长稳,但会拉高整体 P99,我在统计时把前 20 条工具请求单独剔除了,避免污染基线。
3.2 第 2-8 小时:系统内存的慢性爬坡
第 2 小时开始升到并发 8,问题立刻来了。GPU 显存曲线还算平稳(15.6GB 到 17.1GB 缓慢爬升),但系统内存的曲线是一条几乎笔直向上的斜线:从 18GB 一路爬到 41GB,六小时涨了 23GB,平均每小时接近 4GB。
这个斜率如果不变,24 小时就是 90GB 以上,64GB 内存必爆。但奇怪的是,进程没有崩,延迟也没明显变化。这就很典型了——内存泄漏在早期不会影响性能,只会在某个临界点突然杀死你。
我用tracemalloc抓了两次快照做 diff,定位到增长主要来自两个地方:一是 OpenClaw 的任务历史记录,每次工具调用都会把完整的中间结果追加到会话上下文里,而且从不清理;二是代码执行沙箱每次运行都会创建一个临时目录,目录对象被引用着没释放,虽然文件本身被删了,但 Python 对象还在。
第一个问题是设计问题,不是 bug。智能体需要保留历史才能做多轮推理,但“保留”和“无限保留”是两回事。我的处理是加了个滑动窗口 + 摘要压缩机制:会话超过 12 轮后,把最早的 4 轮用模型自己总结成一段 200 字以内的摘要,替换掉原始消息。这样上下文长度被控制在有界范围内,语义信息也保留了大半。
第二个问题纯粹是资源管理疏忽。修法是给沙箱加了个上下文管理器,退出时强制shutil.rmtree并清空引用,同时限制同时存在的临时目录数量上限为 32,超了就按 LRU 清理。
改完之后重跑这一段,系统内存曲线从“每小时涨 4GB”变成了“稳定在 22-25GB 之间小幅波动”。这个修复的收益是决定性的——没有它,后面 16 小时根本跑不完。
注意:内存排查不要只看总量,要分 RSS、堆内存、缓存三块看。我一开始看到总量涨就以为是泄漏,结果发现有一部分是文件页缓存,属于正常行为,白折腾了半天。用
tracemalloc看 Python 堆,用/proc/meminfo看整体,两个对照着看才准。
3.3 第 8-16 小时:上下文膨胀与显存碎片
这一段是整场测试最难熬的部分,也是最有价值的部分。系统内存的问题解决后,我以为可以安心跑完,结果第 9 小时开始,GPU 显存的锯齿变得非常明显:在 17GB 到 21.5GB 之间反复跳动,而且每次跳动的谷底越来越高——从 17.1GB 升到 18.3GB,再升到 19.6GB。这是典型的显存碎片化特征。
碎片化的成因不复杂。PagedAttention 系引擎虽然用分页管理 KV 缓存,但当请求的序列长度分布极不均匀时(我的负载里 15% 是 8K 长请求,85% 是短请求),块的分配和回收会产生大量不连续的空闲块。长请求进来时找不到连续的块,引擎就会触发缓存驱逐,驱逐本身又会产生新的碎片。
我做的第一件事是验证这个判断:把长上下文型请求的占比从 15% 临时降到 3%,重跑了两小时。显存锯齿明显变缓,谷底从 19.6GB 回落到 17.8GB。基本可以确认是长度分布导致的碎片。
但真实负载不能砍掉长请求,所以只能从两个方向缓解。一是开启前缀缓存,让长请求里重复的系统提示和工具定义部分复用已有块,实测长请求的 prefill 时间从 2.8 秒降到 1.9 秒,KV 占用也降了约 12%。二是把max_num_seqs从 8 降到 6,牺牲一部分并发度换取更充裕的块分配空间。这一手有点反直觉——降并发反而让整体吞吐上升了,因为减少了驱逐带来的重算开销。
还有一个细节值得记下来:第 13 小时左右出现过一次连续 47 秒的“假死”,所有请求都卡住不动,之后又自己恢复了。日志里没有任何报错。我一开始怀疑是死锁,后来对齐监控数据才发现,那 47 秒 GPU 利用率是 100%,显存占用瞬间冲到 22.8GB——是某个长请求触发了大规模缓存驱逐和重新 prefill,把计算资源全占了。这种“非错误型卡顿”是最难排查的,因为它不留任何错误日志,只能靠资源曲线反推。
3.4 第 16-24 小时:长尾延迟与重试风暴
最后 8 小时是最能暴露框架层问题的一段。整体成功率还在 99.2%,看起来不错,但P99 端到端延迟从基线的 8.4 秒涨到了 31.7 秒,P95 从 4.1 秒涨到 6.9 秒。平均值几乎没变——这就是长尾问题的典型特征,平均值会骗人。
拆解这 31.7 秒的构成,发现其中 22 秒花在了工具调用的重试链上。具体机制是这样的:某个检索类工具偶发返回空结果,OpenClaw 的策略是“结果为空就换个查询词重试”,但没有设置重试上限。于是模型不停地改写查询、调用工具、拿到空结果、再改写,最多的一次连续重试了 11 轮才因为上下文长度触顶而终止。
这个问题有两个层面。表层是缺重试上限,直接加一个“同一工具连续失败 3 次就降级”的规则即可。深层是缺少失败感知——框架无法区分“工具真的没找到”和“工具调用参数写错了”,两种情况都走同一套重试逻辑,导致本该快速失败的情况被拖成了长尾。
我的修法分三步:给每类工具单独设重试上限(检索类 3 次、代码执行 2 次、数据读取 1 次);在重试前强制做一次“失败归因”,让模型用一句话说明上次为什么失败,避免无脑重复;超过上限后走降级路径,直接返回“未能完成,原因如下”而不是继续硬刚。改完之后重跑最后 4 小时,P99 从 31.7 秒降到 12.1 秒。
另外一个发现是失败会传染。第 19 小时有一波集中失败,12 分钟内失败了 34 次,但前后时段都正常。查日志发现是某个共享的工具实例进入了异常状态(连接池耗尽),所有走这个实例的请求全部超时,而超时又触发了重试,重试进一步加剧了连接池压力,形成正反馈。这种雪崩在单机测试里不明显,一旦上多实例就是灾难。修法是给工具实例加熔断:连续失败 5 次就快速失败 30 秒,期间请求直接走降级。
4. 数据复盘:瓶颈到底在哪一层
4.1 分阶段指标汇总
把 24 小时的数据按阶段切开,对比看最清楚。下表是修复前(前 16 小时)和修复后(后 8 小时采用新配置)的汇总,注意修复后的并发档位和修复前不完全一样,所以吞吐数据不能直接横比,重点看延迟分位数和失败率:
| 时段 | 并发 | TTFT 中位数 | 端到端 P50 | P95 | P99 | 成功率 | 显存峰值 |
|---|---|---|---|---|---|---|---|
| 0-2h 基线 | 1/4 | 0.31s | 2.1s | 4.1s | 8.4s | 99.8% | 15.6GB |
| 2-8h | 8 | 0.42s | 2.6s | 4.8s | 10.2s | 99.5% | 17.1GB |
| 8-16h | 8 | 0.58s | 3.4s | 6.2s | 18.6s | 99.3% | 21.5GB |
| 16-20h 修复前 | 8 | 0.71s | 4.1s | 6.9s | 31.7s | 99.2% | 22.8GB |
| 20-24h 修复后 | 8 | 0.46s | 2.8s | 5.1s | 12.1s | 99.6% | 19.4GB |
这张表里有两个值得盯的信号。第一,TTFT 的退化幅度远小于端到端延迟。从 0.31 秒到 0.71 秒,涨了 129%;而 P99 从 8.4 秒涨到 31.7 秒,涨了 277%。这说明模型侧的 prefill 能力基本稳定,问题主要出在后端的编排和重试上。第二,显存峰值和延迟呈明显正相关。21.5GB 以上时 P99 开始失控,19.4GB 以下就恢复正常。这给了我们一个很实用的操作阈值:把显存占用控制在总容量的 80% 以下,长尾表现会好很多。
4.2 显存曲线背后的三个真实原因
单看“显存涨了”没有意义,得拆开看是谁涨的。我把 8-16 小时那段曲线拆成三个成分:
| 成分 | 占增量比例 | 成因 | 是否可消除 |
|---|---|---|---|
| KV 缓存块碎片 | 约 52% | 长度分布不均,块不连续 | 部分可缓解,不可完全消除 |
| 会话历史未压缩 | 约 31% | 上下文无限追加 | 可通过摘要压缩消除 |
| 引擎内部缓存池 | 约 17% | 前缀缓存、临时张量 | 属于正常开销,保留 |
这个拆解改变了我的优化优先级。原本我以为主要是历史消息太长,结果碎片才是大头。而碎片这个东西,靠“清理”是清不掉的,只能靠控制长度分布和预留空间来缓解。所以最终方案是“降并发 + 开前缀缓存 + 预留 15% 显存”,而不是“定期清理缓存”——事实上,频繁调用显存清理接口反而会加剧碎片,因为每次清理后重新分配都是从零开始。
4.3 失败模式分类与占比
24 小时里总共产生了约 4.7 万次请求(含重试),失败 318 次,总失败率 0.67%。按原因分类如下:
| 失败类型 | 次数 | 占比 | 典型表现 | 根因 |
|---|---|---|---|---|
| 请求超时 | 121 | 38.1% | 客户端主动断开 | 长请求 + 队列排队叠加 |
| 工具重试耗尽 | 87 | 27.4% | 返回“未能完成” | 缺重试上限与失败归因 |
| 上下文超限截断 | 58 | 18.2% | 输出被截断或答非所问 | 历史未压缩,触达 max_model_len |
| 工具实例异常 | 39 | 12.3% | 连接超时 | 连接池耗尽引发雪崩 |
| 解析错误 | 13 | 4.0% | 结构化输出格式不合法 | 量化后格式稳定性下降 |
这个分布说明一件事:模型的可用性不是瓶颈,编排逻辑才是。五类失败里只有“上下文超限”和“解析错误”跟模型直接相关,加起来不到四分之一。剩下四分之三全是框架层的问题。这也是我给所有做智能体落地的朋友的第一条建议:先把你自己的重试逻辑、上下文管理、资源回收写扎实,再去调模型参数。
5. 踩坑与排查手册
5.1 问题速查表
把这次遇到的所有问题整理成一张速查表,遇到对应现象可以直接对照:
| 现象 | 最可能原因 | 快速验证方法 | 处理动作 |
|---|---|---|---|
| 系统内存线性上涨 | 历史记录或临时对象未释放 | tracemalloc两次快照 diff | 加滑动窗口、用上下文管理器 |
| 显存锯齿且谷底抬升 | KV 块碎片化 | 临时降低长请求占比重测 | 开前缀缓存、降 max_num_seqs |
| 平均延迟正常但 P99 飙高 | 长尾重试链 | 按请求拆解耗时构成 | 设重试上限、加失败归因 |
| 突然全员卡住无报错 | 大规模缓存驱逐占满算力 | 对齐 GPU 利用率与显存曲线 | 提升显存余量、削峰 |
| 集中时段批量失败 | 共享资源进入异常态 | 看失败是否集中于同一工具实例 | 熔断 + 快速失败 + 降级 |
| 输出格式偶发不合法 | 量化精度损失 | 对比不同量化档的解析成功率 | 换 INT8 或加格式校验重试 |
| 文件描述符持续增长 | 工具调用未关闭句柄 | `ls /proc/PID/fd | wc -l` 定时采样 |
5.2 几个文档里不会写的经验
第一,压测要在同一个进程生命周期内完成。中途重启服务会把所有累积状态清空,你测出来的就是“新鲜状态下的性能”,毫无意义。我这次三个并发档位都是在同一个服务进程上跑的,正是因为这样才暴露出碎片化问题。如果你的测试必须重启(比如改了配置),那就在报告里明确标注,别把重启后的数据混进长跑曲线。
第二,日志要按请求 ID 串联,别按时间戳。时间戳在并发场景下极不可靠,几十个请求交织在一起,你根本无法从日志流里还原单条请求的完整链路。我的做法是每个请求生成一个短 ID,所有环节(模型调用、工具调用、重试、失败)都带上这个 ID,事后按 ID 聚合就能看到完整故事。这一步在排查重试风暴时救了命。
第三,别迷信“平均值”和“成功率”。99.2% 的成功率听起来很美,但那 0.8% 里可能包含了所有最糟糕的体验。真正要盯的是 P99 和“最差单条请求耗时”,这两个数字才代表用户最坏情况下的感受。
第四,温度墙是隐形杀手。我这次没遇到严重降频,但在第 14 小时观察到 GPU 温度摸到 78 度时,解码速度有大约 6% 的下滑。如果你的机箱散热一般,长跑时务必把温度也纳入监控,别等性能莫名下降才去查。
第五,测试脚本本身的资源占用要单独评估。压测端如果和被测服务跑在同一台机器上,脚本自己的内存和 CPU 占用会污染数据。我这次是把压测端跑在另一台机器上,通过内网打请求,虽然多了一层网络开销,但数据干净得多。如果只能用单机,至少把压测进程的 CPU 亲和性绑到不同的核上。
第六,准备一个“熔断开关”。长跑测试最怕的是跑到一半系统进入不可恢复状态,然后你只能干等着或者强行杀掉。我的做法是加了一个外部看门狗:当连续 100 次请求失败或显存占用超过 95% 持续 60 秒,就自动把并发降到 1 并记录现场,让系统有时间自我恢复,同时保留证据供事后分析。
6. 可复现的脚本与配置
6.1 压测驱动脚本的核心结构
压测端我用 asyncio 写的,核心是三类请求混流 + 信号量控制并发 + 每请求埋点。下面是最小可用版本,把请求构造部分替换成你自己的场景即可:
import asyncio, time, random, json, uuid import httpx ENDPOINT = "http://127.0.0.1:8000/v1/chat/completions" CONCURRENCY = 8 DURATION = 24 * 3600 sem = asyncio.Semaphore(CONCURRENCY) records = [] def build_short(): return {"messages": [{"role": "user", "content": "解释一下什么是注意力机制,200字以内"}], "max_tokens": 400, "temperature": 0.7} def build_tool_chain(): # 真实场景里这里换成带工具定义的请求,强制触发多轮调用 return {"messages": [{"role": "user", "content": "查一下最近的季度数据并汇总成表格"}], "max_tokens": 1024, "temperature": 0.3} def build_long(): filler = "以下是背景资料。" * 900 # 构造接近 8K 的输入 return {"messages": [{"role": "user", "content": filler + "请总结要点"}], "max_tokens": 512, "temperature": 0.5} TYPE_POOL = [(build_short, 0.45), (build_tool_chain, 0.40), (build_long, 0.15)] def pick_payload(): r = random.random() acc = 0 for fn, w in TYPE_POOL: acc += w if r <= acc: return fn() return build_short() async def one_request(client, req_id): payload = pick_payload() payload["stream"] = True t0 = time.perf_counter() ttft = None tokens = 0 status = "ok" try: async with client.stream("POST", ENDPOINT, json=payload, timeout=120) as resp: async for line in resp.aiter_lines(): if not line.startswith("data:"): continue chunk = line[5:].strip() if chunk == "[DONE]": break if ttft is None: ttft = time.perf_counter() - t0 tokens += 1 except Exception as e: status = type(e).__name__ total = time.perf_counter() - t0 records.append({"id": req_id, "ttft": ttft, "total": total, "tokens": tokens, "status": status}) async def worker(client, deadline): while time.perf_counter() < deadline: async with sem: await one_request(client, uuid.uuid4().hex[:8]) await asyncio.sleep(random.uniform(0.05, 0.3)) # 模拟真实思考间隔 async def main(): deadline = time.perf_counter() + DURATION async with httpx.AsyncClient(limits=httpx.Limits(max_connections=64)) as client: await asyncio.gather(*[worker(client, deadline) for _ in range(CONCURRENCY * 2)]) with open("records.jsonl", "w") as f: for r in records: f.write(json.dumps(r) + "\n") asyncio.run(main())几个实现上的细节值得说。sem控制的是“同时在处理的请求数”,而 worker 数量我设成了并发的 2 倍,这样在某个请求阻塞时还有别的 worker 能顶上,不会出现“并发没打满”的假象。stream=True是为了精确测 TTFT,非流式请求拿不到这个指标。records存在内存里最后一次性落盘,避免频繁 IO 干扰测试,但如果跑很久要注意内存占用,超过 10 万条就分批写。
6.2 长跑配置模板
这是最终稳定运行的配置,24 小时零崩溃的那一版:
model: qwen3.5-9b-int8 max_model_len: 8192 gpu_memory_utilization: 0.85 max_num_seqs: 6 enable_prefix_caching: true swap_space: 4 enforce_eager: false agent: max_tool_rounds: 8 # 单次任务最多 8 轮工具调用 history_window: 12 # 保留最近 12 轮原始消息 summary_trigger: 12 # 超过后触发摘要压缩 summary_keep: 4 # 把最早的 4 轮压成摘要 retry_policy: retrieval: 3 code_exec: 2 data_read: 1 circuit_breaker: fail_threshold: 5 open_seconds: 30 sandbox: max_concurrent_dirs: 32 cleanup_on_exit: true max_exec_seconds: 20max_num_seqs: 6这个值是从 8 降下来的,前面说过原因。history_window和summary_trigger配合,把上下文长度锁死在有界范围内。circuit_breaker那段是防雪崩的关键,参数不用照抄,但机制一定要有。
6.3 告警阈值建议
最后给一份阈值参考,长跑测试时挂上,出问题能第一时间知道:
| 指标 | 告警阈值 | 处置动作 |
|---|---|---|
| 显存占用 | > 90% 持续 60s | 降并发、清理会话 |
| 系统内存 | 24h 增长 > 8% | 抓内存快照分析 |
| P95 端到端延迟 | > 基线 1.5 倍 | 检查重试链和队列深度 |
| 成功率 | 5 分钟内 < 98% | 检查工具实例健康度 |
| 文件描述符 | > 5000 | 排查句柄泄漏 |
| GPU 温度 | > 80 度 | 检查散热、考虑降频 |
这套阈值不是绝对值,你的硬件和场景不同,基线也不同,但判断逻辑是通用的:先有基线,再设相对阈值,别看绝对值。
整场 24 小时跑下来,我最大的体会是硬件和模型其实比想象中靠谱得多——INT8 的千问3.5-9B 在单卡上连跑一天,推理侧没出过任何硬故障,真正的风险全在编排层。那些“跑了 11 个小时突然卡住”“第 19 小时批量失败”的问题,回过头看都是可以在设计阶段避免的:给重试加上限、给上下文加窗口、给共享资源加熔断、给显存留余量。这四件事做完,智能体从“能演示”到“能长跑”之间的距离,比你想象的要短。