0752723c3c
讲这套压测是什么、九类故障各守什么、判据为什么全是结构不变量、以及怎么重跑。具体数字 一个都不写——那些是一次实测的结果,放进常青文档等于种下过期条文。 冷读抓到的九条全部采纳:产物那段自相矛盾(谁写了那四个文件)、GovDoc 三个阶段全文一个字 没有而后面两节都建立在它上面、判据的权威出现两个说法、六个核心概念没解释就在用、哨兵那个 例子整句读不懂、四处只写结论没写理由、重跑那节缺环境前提、故障注入为什么没有自己的报告、 以及一步没写出来的因果。 冷读同时报了三段跳读,按它给的事实处置:脱敏那节压掉后半段、九类列表重排成只读粗体也拿得到 每类守什么、打包那两段压成一段。 更新触发点写在文首:这套压测是一次性工具,验收阶段过去之后如果它被删掉,这份文档跟着删—— 一份描述已经不存在的目录的常青文档,比没有更坏。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
389 lines
34 KiB
Markdown
389 lines
34 KiB
Markdown
# 自造压测负载
|
||
|
||
> **更新触发点**:改 `tools/soak/` 下任何一块的职责、判据的构成、故障的类别、或者那四步流程的
|
||
> 顺序时,在同一个提交里改这份文档,不靠事后想起来。这是 `../README.md` 那三档里的第 2 档。
|
||
> 这套压测是一次性工具,验收阶段过去之后如果它被删掉,这份文档跟着删——一份描述已经不存在的
|
||
> 目录的常青文档,比没有更坏。
|
||
>
|
||
> **假设的背景知识**:PolyLoop 的治理单位是一次运行,一次运行有预算、停止原因、逐步轨迹与
|
||
> 崩溃续跑,这几个概念在 `architecture.md` 里有完整说明。另外要知道 AppWorld 是一个 agent
|
||
> benchmark(本机跑一个环境服务器,给它一道题、喂它 Python 代码、它回执行结果并能程序化判分)。
|
||
> 库里另外几个词——意图与结果、重放策略、合成观察、参数快照——在下面第一次用到的地方就地解释。
|
||
> 不需要读过 dissect 或 GovDoc 的代码。
|
||
|
||
## 1. 跑完一次压测,磁盘上多出什么
|
||
|
||
产物落在一个带时间戳的输出目录里,里面有:正常负载那一批的运行目录、故障注入那一批的运行目录、
|
||
GovDoc 三个阶段共用的工作区目录、以及四份 markdown 报告——干跑一份、正常负载一份、记分板两份
|
||
(正常负载判一次,故障注入判一次)。
|
||
|
||
运行目录里,每一次运行留下四个文件。**逐行日志由库自己写**——库的存储实现每走一步就往里追加一条
|
||
记录,压测碰都不碰它。**另外三个由压测入口写**:这次运行返回的结果、事件出口收到的事件流、以及
|
||
一份运行元信息(这次是哪个场景、哪个任务、花了多少次模型调用、如果是故障注入那一批的话是哪一类
|
||
故障)。这三个跟在日志旁边、文件名同前缀的附属文件,下文一律叫 **sidecar**。
|
||
|
||
「压测入口」指的是跑负载的那两个脚本——正常负载一个、故障注入一个,两个都自己写 sidecar,共用同
|
||
一组后缀常量。四个文件的确切名字与字段由 `tools/soak/scoreboard.py` 的模块 docstring 定义,写的
|
||
两处和读的记分板都引它,没有第二份定义。
|
||
|
||
日志一定在——一批产物里有几次运行,就是按它枚举出来的。sidecar 可能缺:进程被杀在半路时来不及写。
|
||
**缺文件本身是一条要报告的观察,既不是崩溃理由,也不算通过。**
|
||
|
||
**故障注入那一批没有自己的 markdown 报告**,所以报告是四份不是五份。它跑的时候把每一类的逐条判定
|
||
打在标准输出上(脚本可以把这份输出重定向进日志目录),有击穿就以非零码退出;它留下的产物随后再交
|
||
给记分板判一次,那次才产出第四份 markdown。
|
||
|
||
这些产物不进版本库。日志里带着模型原文与真实公文片段,体量也大,忽略规则在仓库根的
|
||
`.gitignore` 里。
|
||
|
||
GovDoc 那个工作区目录里还有一份别处没有的东西:一份由环境侧自己追加的审计账,每一次有副作用的
|
||
工具调用留一行。它是第 7 节那条最硬的判据的唯一证据来源。
|
||
|
||
## 2. 为什么这套负载是自己造的
|
||
|
||
按 `README.md` 的阶段清单,⑥ 的验收有两件事,其中一件是自己造负载压。**三个下游一个都还没到
|
||
能用这个库的时候**——dissect 的循环还跑在它自己的实现上,GovDoc-SaaS 整体重建、实现清空,
|
||
CHSAnalyzer 还没写到 agent 那一步。而「从没被任何人用过」是这个库当时最大的未验证项,等下游把
|
||
它用起来再收集问题,是等不来的。
|
||
|
||
环境选 AppWorld,因为它自带评测端点做程序化判分。换成让另一个模型判对错,等于往验收里再塞一个
|
||
非确定源,验收本身就不再可复现。
|
||
|
||
这个环境跑在容器里,压测只用一个薄 HTTP 客户端连它,本进程一行 appworld 代码都不导入——那个包钉在
|
||
旧版 pydantic,和 PolyGateway 要求的版本装不进同一个解释器。**容器是池化的**:环境服务器用一个模块级
|
||
变量存「当前任务」,请求换题会被直接拒绝,也就是一个容器同一时刻只能跑一道题;要并发跑 N 道题就得
|
||
起 N 个容器,各占一个宿主端口,用完归还。取消那一类要验的「资源归还」,指的就是这个租约。
|
||
|
||
**一个场景不够,第二个是 GovDoc**,理由有三条。AppWorld 那一路只压得到一种形态:动作是代码、完成
|
||
由环境报告;而三个下游里 GovDoc-SaaS 将来是直接长在本库上的,它的形态完全不同——JSON 工具调用、
|
||
按阶段收窄工具集、由 agent 自己提交结论——这条路径在压测之前一次真实负载都没跑过。它的「环境」是
|
||
一个工作区目录加一份文件账,**活得过进程的死亡**,所以崩溃续跑那两类和最硬的那条判据只有它给得出
|
||
证据(第 7 节)。它的语料是一批一次运行里根本读不完的长文书,模型得自己检索着一点点读,正好用来压
|
||
提示词规模那一档。代价是那批语料是真实公文,得先脱敏(第 4 节)。
|
||
|
||
压测代码放在仓库根的 `tools/` 下:打包只收 `src/`,所以它不进 wheel、不进 sdist、不会随
|
||
`pip install` 装到下游手里;而测试的四层是按「依赖什么」分的(`CLAUDE.md` §1.9),压测不属于其中
|
||
任何一层。这个位置带来的自由是它可以依赖 `src/polyloop/` 明确拒绝的东西——HTTP 客户端、docker
|
||
命令行、某个具体 benchmark 的数据布局——因为它的失败只影响我们自己。反过来的方向是禁止的,
|
||
`src/polyloop/` 里任何一处都不许 import 它。
|
||
|
||
两个场景的动作协议都**照着下游的代码复刻,一行都不 import**:反向 import 下游是硬约束
|
||
(`CLAUDE.md` §1.2,由 import-linter 断言)。复刻的代价是它会随下游漂移,收益有两层:压出来的
|
||
解析失败率与步数分布对 dissect 有参考价值;而且这反过来证明了一件更强的事——一个不认识 dissect
|
||
的第三方,只用公共 API 就能驱动一个真实环境跑完。
|
||
|
||
## 3. 两个场景压的不是同一件事
|
||
|
||
**AppWorld 那一路**:模型输出一段被代码围栏包住的 Python,解释器把它取出来当动作,执行器把它送
|
||
进容器里执行,观察是执行的输出。一次运行是否完成由**环境报告**——执行器在每次执行之后顺带问环境
|
||
一句「做完没有」。提示词直接用 AppWorld 官方 ReAct baseline 的那份,出处与许可写在
|
||
`tools/soak/prompts/appworld/PROVENANCE.md`。照抄而不自己写,是为了让压出来的步数分布与停止原因
|
||
构成能和 dissect 的历史轨迹对照:提示词换一份,这两样都会跟着变,就分不清是内核的行为变了还是
|
||
提示词变了。
|
||
|
||
**GovDoc 那一路**:一个任务是拿一条**审核点**(一条要判的合规条款,含判定标准和「合规 / 不合规 /
|
||
存疑」这三个允许的结论)去审一批招标文书。模型输出一个 JSON 对象,要么是一次工具调用,要么是一句
|
||
「这一阶段我做完了」。完成有**两条通路**——模型自报最终回答,或者调用一个带完成标记的提交工具。
|
||
|
||
一个任务分三个阶段:**plan** 在文书里检索并读上下文,把候选证据的位置写成一份计划文件;**execute**
|
||
照着那份计划逐条核实原文,把逐字摘录写成一份证据文件;**summarize** 只读证据文件,提交唯一一条结论。
|
||
工具集在最后一个阶段收窄——检索工具被收走,模型只剩阅读与提交。**收窄的作用是把「这一阶段只准依据
|
||
已经落盘的证据下结论」变成机器约束**:写在提示词里模型会违反,而工具清单里没有的工具它根本调不到,
|
||
调了会被库判成动作被拒。下游的编排里最后一个阶段的工具清单就是这么写的,压测照抄这个形态。
|
||
|
||
**「按阶段收窄工具集」在这个库里只能表达成「每个阶段一次运行」。** 治理单位是一次运行,而一次
|
||
运行只有一个工具注册表,中途换不了。所以一个审核任务被拆成三次独立的运行,三次共享同一个工作区
|
||
目录,阶段之间靠工作区里的那两个文件传状态。这不是压测为了凑数据量拆的,是这个约束的直接后果——
|
||
下游要收窄工具集就得这么写,所以这个形态值得压。
|
||
|
||
两个场景一起跑时,任务是**轮流排开**而不是拼接。整批共用一份模型调用预算,拼接的话排在前面的
|
||
场景会把预算吃光,后面那个一个任务都跑不到,而报告看起来只是「因预算停在第 N 个任务」——一次只
|
||
压了一半的跑,长得和一次正常的跑一模一样。
|
||
|
||
### 三条完成通路同时在场
|
||
|
||
加上 GovDoc 的两条,压测里同时存在三条完成通路:环境报告完成、agent 调用带完成标记的工具、模型
|
||
自报最终回答。它们的可信度依次下降——环境说完成了那就是完成了,模型说完成了只是它自己这么说。
|
||
压测正需要这个对照,因为库对三者的停止语义处理是不同的代码路径。
|
||
|
||
第三条通路是补上去的,理由值得记住:GovDoc 的前两个阶段原本没有带完成标记的工具,解释器也不产出
|
||
最终回答,于是模型在写完产出物之后还会继续读文档,直到撞上步数上限。那样整批的停止原因会全是
|
||
撞预算,别的停止路径一条都压不出来,而且白烧掉的调用比批准的额度还多。补上最终回答那一支之后,
|
||
预算也随之下调——上限定得高,只会让模型在活干完之后接着烧。各阶段的取值与它们的来历写在
|
||
`tools/soak/scenarios/govdoc.py` 的阶段一节。
|
||
|
||
## 4. 语料先脱敏,且一个字节不落盘
|
||
|
||
GovDoc 场景用的是真实政府采购文书,里面有工商全称、机关名称、固定电话、统一社会信用代码、邮箱、
|
||
联系人姓名。装配语料时,这些在内存里被替换成明显是假的稳定假名:**同一个原名在整份文档里换成同一个
|
||
假名**,否则模型会以为它们是不同主体,任务的难度就变了。金额、项目编号、日期原样保留——它们是审核
|
||
判断的依据,换掉任务就没得判了。
|
||
|
||
**原文与脱敏后的文本都只在内存里,一个字节不写盘、不入库。** 脱敏后必须再过一遍独立的检出校验,
|
||
检出到残留就抛异常拒绝启动。这条是硬闸不是告警,因为把未脱敏的第三方真实信息发给外部模型服务是
|
||
不可逆的:请求一旦出去就收不回来,而**漏掉一处的表现是「压测正常跑完」**,事后从任何一份产物里
|
||
都看不出来发生过泄漏。宁可拒绝启动。
|
||
|
||
异常消息里的样本一律掩码——拦截泄漏的那条日志本身成了泄漏,就白拦了。
|
||
|
||
**替换与校验共用同一份检出规则,不许各写一套**:各写一套的话,「替换漏了、校验也照样漏」会让这道闸
|
||
看起来一直是绿的。规则本身(哪几类、假名怎么起、为什么要给假名留一个固定前缀)在
|
||
`tools/soak/scenarios/govdoc.py` 的脱敏一节,改它的时候连校验一起改。
|
||
|
||
**已知缺口是门牌级地址。** 中文地址没有可靠的结尾标志,按形态认会把设备规格里的编号一起吃掉。机构名
|
||
与联系方式都换掉之后,剩下的地址指向的是一个已经改了名的主体——这个缺口是被接受的,不是没看见。
|
||
|
||
## 5. 判据全是结构不变量
|
||
|
||
**判据一条都不依赖模型的确定性。** 活模型下「两次运行产出同样的轨迹」本来就不成立,断言它只会随机
|
||
变红,而一个随机变红的判定器很快就没人看了。所有判据用的都是结构不变量:崩溃前那段字节续跑之后变没
|
||
变、步号连不连得上、每条意图有没有对应的结果、环境侧那份账去重前后条数一不一样、停止原因是哪个取值、
|
||
步数与上限的关系、环境自己数的执行次数。这些量与模型这次怎么选路无关。
|
||
|
||
记分板对每一次运行逐条判一批不变量,它们分三类:
|
||
|
||
- **日志自身的完整性**——每一条被换行终结的行都解得出记录;步号从头逐一递增,不重不跳;每条意图都
|
||
能找到它的结果。**意图与结果**是库的预写机制:调模型或执行动作之前先落一条「我要做这件事」的意图,
|
||
做完再落一条结果。所以一条没有结果的悬空意图,说明进程死在这两下之间;日志里至多允许有一条这样的
|
||
意图,而且必须是最后一条,那正是崩溃点。
|
||
- **库对自己的陈述与实际相符**——跨进程读回来的运行结果与日志里内嵌的那份逐字段相等;同一条步记录里
|
||
动作结果与步记录说的是同一件事;事件条数等于本进程真正走完的步数;投递失败计数对得上;并发跑的
|
||
几次运行之间不串台(每条记录的运行标识都属于它所在的那个文件)。
|
||
- **停止语义与轨迹自洽**——报「任务完成」就得有完成证据,报「撞步数上限」步数就得等于上限,报
|
||
「agent 自报做完」最终回答就不能是空的。
|
||
|
||
逐条的名字、说明和判法在 `tools/soak/scoreboard.py` 的不变量表里。每一条都配一个「构造出违反它的
|
||
日志、验它确实报击穿」的用例——**一个永远返回通过的判定器比没有判定器更糟**,它会让人以为验过了。
|
||
|
||
### 判据分两套,各管各的
|
||
|
||
记分板判的是**每一次运行都该成立**的那批不变量。正常负载的产物过它,故障注入留下的产物也过它,判法
|
||
一模一样。
|
||
|
||
故障注入另有一套判据,只对它自己造出来的那一类成立:崩溃前的字节没被改过、续跑之后环境的账一条不多、
|
||
取消之后容器租约还回来了——这些在一次正常跑完的运行上根本无从判起,因为那些事情没发生过。它们的权威
|
||
在 `tools/soak/faults.py`,和记分板那张表互不覆盖,而两套用的是同一套三档判定。
|
||
|
||
### 三档判定,第三档不许折算成通过
|
||
|
||
一条判据的判定有三个取值:**通过、击穿、无法判定**。第三档独立存在,不许折算成前两档中的任何一个。
|
||
缺文件、缺字段导致判不了,和判过了是两回事——压成两档的话,一次什么都没验成的跑会显示成全绿,
|
||
而那正是最需要被看见的情况。这一条在数据结构上是硬的:判定由证据算出来,没有一个可以手填的
|
||
「通过」。
|
||
|
||
击穿必须带具体证据(哪一次运行、哪一行或哪一步、期望什么实际什么),由构造期校验守住。通过也要给
|
||
证据,因为「通过」最常见的坏法是判据根本没跑到该判的东西上——比了零个字节、数了零条审计、看了一个
|
||
空列表,那些情况下证据文本会当场露馅。
|
||
|
||
记分板的退出码把「有击穿」和「只有无法判定」分开,还有一个开关决定后者放不放行。取值与形状见
|
||
`tools/soak/scoreboard.py` 的 `--help`。
|
||
|
||
### 报告里不出现模型原文与文档片段
|
||
|
||
报告是要贴给人看的,而语料里有第三方的真实文档。所以报告只出现计数、枚举取值、运行标识、行号与
|
||
步号,别的一律只报长度不报内容。**工具名也算「别的」**:一次没通过校验的工具调用,会把模型编出来的
|
||
那串原样记进工具名字段,而那串可以是任何东西——包括一整段文档正文。
|
||
|
||
守这条的办法是在测试里往数据中塞一段独一无二的标记串(**哨兵**),跑完断言报告里找不到它。有一版
|
||
判定器打算按字符白名单放行工具名,那段哨兵恰好全是白名单里的字符,于是它出现在了报告里——那一次
|
||
测试变红,证明的正是「外来文本真的漏得出去」。
|
||
|
||
### 与历史轨迹的对照是对照,不是判定
|
||
|
||
记分板报告里有一张表,把本批次的停止原因构成与步数分位数和 dissect 的历史轨迹并排放。**它是对照
|
||
不是判定**:模型不同、任务子集不同,偏了不算击穿。它的用途是让人一眼看出「这批跑出来的形态是不是
|
||
离谱」,而那件事写不成判据。
|
||
|
||
## 6. 故障注入才是这套东西的价值所在
|
||
|
||
**一整批任务顺利跑完什么都证明不了。** 顺利那条路上,库只要不崩就算过。真正能证明东西的是那些
|
||
没有失败现场的路径:进程被杀在半路、调用方中途取消、目标根本完不成、模型的输出一句都解析不了、
|
||
提示词撑到装不下、环境跑到一半不能接着服务了。库给下游的承诺——崩溃前的轨迹逐字节不变、声明绝不
|
||
重放的动作不会执行两次、取消原样穿透且资源归还、撞上限时以正确的停止原因干净收尾、解析失败不碰
|
||
环境、提示词超限时终止而不是静默截断历史、环境坏掉那一步的观察换成合成观察——只有在这些路径上才
|
||
检验得到。
|
||
|
||
注入分九类,每一类的名字与取值形状见 `tools/soak/faults.py` 的 `--help`:
|
||
|
||
- **崩溃续跑,时机 A**——一步完整落地之后杀掉进程。**续跑该真的接着往下跑**,而且崩溃前已经落盘的
|
||
那些字节一个不改。
|
||
- **崩溃续跑,时机 B**——一条声明**绝不重放**的意图已经落盘、结果还没落盘时杀掉进程。每个动作在注册
|
||
时都要声明能不能被重放:只读的检索声明成安全可重放,有副作用的写入声明成绝不重放。**库该判定状态
|
||
未知、干净停下**,不替它猜那次写到底做没做过,所以续跑之后环境的账应该一条都不多。
|
||
- **取消,落在模型调用中途**——那时在飞的是网关连接和一次已经计过费的请求。**取消该原样穿透出来**,
|
||
不被吞成一次普通的失败。
|
||
- **取消,落在动作执行中途**——那时在飞的是一个容器租约。**它该被还回池子**,而不是随着这次运行一起
|
||
烂在那儿。
|
||
- **撞步数上限** 与 **撞动作上限**——给一个根本完不成的目标,**验它以正确的那一维干净收尾**。两类的
|
||
预算刻意让不同的维先耗尽:不然先撞上的是另一维,这一条就什么都没验到。
|
||
- **解析失败连击**——包一层永远判无效的解释器。这一类只花几次调用,但它守的是**解析失败不碰环境**:
|
||
那条错了的表现是环境状态被一批根本没解释出来的动作改掉,而轨迹里每一步都写着解析失败。
|
||
- **撞提示词上限**(走 GovDoc 那一路)——把上限压到刚好装得下第一步、装不下第二步。库该终止运行并
|
||
记下这个停止原因,而**不是静默把历史截掉**:截断会改掉提示词的前缀,后面每一步都重新全价计费,
|
||
而在数据里看起来跟「模型不行」一模一样。
|
||
- **环境故障**(走 AppWorld 那一路)——正常走完一步之后**真的把容器杀掉**。库该把那一步的观察换成
|
||
**合成观察**(这一步没能拿到真实观察时库自己填的一段固定文本,由装配时传进来,所以同一份配置的两次
|
||
运行在模型看来是一样的)、以这个原因收尾,而它之前那些步一步都不受牵连。这一类自己起一个容器池,
|
||
不和别的几类共用:它会把容器打死,共用的话排在它后面的每一类都跑在一个已经没了的环境上,报出来是
|
||
一串「连不上」,看起来像 docker 挂了。
|
||
|
||
崩溃那两类天然会缺 sidecar 文件——被杀的子进程来不及写,记分板会报「无法判定」,**那是对的,不为了
|
||
让它变绿去补假数据**。所以故障注入那一批的记分板才开放行开关,正常负载那一批不开:正常负载不该缺
|
||
文件,判不了就是要人去看一眼。
|
||
|
||
跑一轮故障注入要花真实模型调用,所以它也有一道调用数护栏,但拦的位置和正常负载那道不同:**在每一类
|
||
开跑之前问一次「还够不够跑它最坏的情况」,不够就整类跳过并报无法判定**,跑完之后按日志里数出来的
|
||
调用数记账。**不能拦在模型客户端里**——在那里抛异常的话,库会把它记成一次模型调用失败、换上合成观察
|
||
接着跑,护栏本身就成了一次注入进来的故障,把这一类要验的停止原因搅乱了。
|
||
|
||
### 崩溃为什么改成子进程内部的确定性自杀
|
||
|
||
第一版靠父进程轮询日志尾部、看见形态对上就发 SIGKILL。实跑里时机 A 一次都没命中,每次都报
|
||
「发信号与子进程停笔之间又写进了记录」。原因是库写完一条步记录之后紧接着就写下一条模型调用意图,
|
||
中间只有内存里的装配计算,那个窗口窄到外面的信号挤不进去。
|
||
|
||
**这个观察本身是一条要记住的事实**:自然发生的崩溃几乎总是落在「有意图没结果」那一态上,而不是
|
||
落在两步之间的干净边界上。但要验那个干净边界就不能靠碰运气。
|
||
|
||
改法是在子进程里给存储包一层,在某一次写入落盘返回之后按条件调 `os._exit`。它不跑 `finally`、
|
||
不跑 `atexit`、不 flush 任何缓冲,**对磁盘的效果与 SIGKILL 等价**;而库的逐行存储本来就写完即
|
||
fsync,没有未刷缓冲要指望进程退出时替它写下去,所以「有没有机会清理」在这里不影响磁盘上留下什么。
|
||
外部 SIGKILL 那条路降级成兜底,没有删。
|
||
|
||
包这一层有个约束:它转发内层存储上报的参数,不加自己的键。库把四个接缝各自上报的参数连同预算存成
|
||
一份**参数快照**写进日志,续跑时拿新装配的那份逐字段比对,对不上就报**参数漂移**并拒绝续跑——防的是
|
||
有人拿一套不同的配置接着往同一条轨迹上写。而父进程续跑时用的是一个没包过的存储,包装层要是往快照里
|
||
加一个自己的键,续跑就会报漂移,那是一次假故障。
|
||
|
||
**父子两侧的崩溃条件必须逐字对齐。** 有一版里父进程的兜底条件比子进程的自杀条件松一档,后果是这样
|
||
来的:条件松意味着它更早被满足,父进程一看见日志尾部的形态对上就发信号,而子进程那时还没走到自杀
|
||
那一行,于是每一次都是外部信号先命中,自杀那条路成了永远走不到的死代码,崩溃点又变回碰运气——而且
|
||
崩得太早,最硬的那条判据没有实料可判。对齐的那一项是「审计账已经非空」,即那个声明绝不重放的写入
|
||
真的执行过。它做成必传参数:给默认值等于允许某个调用点静默跳过这一条,而这正是当时出问题的方式。
|
||
|
||
时机 B 的命中条件是「最后一条意图的重放策略是绝不重放」,**这比「最后一条是意图」更严,而且必须
|
||
更严**:只读的检索工具声明的是安全可重放,悬在那种意图上续跑会重放动作接着走,停止原因就不是状态
|
||
未知。放宽这一条,判据会时对时错,而错的那几次看起来只像模型这次走了别的路。
|
||
|
||
### 最后添的那两类,补的是一次全量跑留下的空白
|
||
|
||
撞提示词上限与环境故障这两类是后添的,来历值得记住。一次全量跑完之后,库的停止原因取值里有两个从头
|
||
到尾一次都没出现过,正是这两个。**没出现不等于它们是对的,只等于没验过**——那两条路上的承诺到那一刻
|
||
为止一次都没有被检验过,而它们错了的形态恰好都不会当场炸:静默截断历史在数据里看起来跟「模型不行」
|
||
一模一样,环境故障那一步的观察没被替换掉,则表现成模型收到一段来路不明的文本。
|
||
|
||
这是「一批全绿要先怀疑负载」的一个实例。全绿有两种成因——库守住了承诺,或者负载根本没走到那条路上,
|
||
而报告本身分不开这两者。**分辨的办法是去数枚举取值**:每一个从来没出现过的停止原因,都是一块没被压
|
||
到的地方,把它造出来一次,那一档才算验过。
|
||
|
||
### 撞提示词上限那条判据不能照字面写
|
||
|
||
反直觉的地方在于:**规模判定发生在调模型之前**,命中时不产生步记录、也不写任何意图。所以落进日志的
|
||
每一条步记录都是通过了那一档的,它们的提示词长度必定都在上限之内——**真正超限的那一次装配没有在任何
|
||
地方留下记录**。
|
||
|
||
判据要是照字面写成「最后一步的提示词超过了上限」,它在一个正确的库上永远不成立,断言它等于断言契约
|
||
的反面。能从日志里判的是**再走一步会有多大**——最后一步之后进入历史的是它自己的输出和一段套过模板的
|
||
观察,把这些接在最后一步的提示词后面,就是下一次装配的规模(怎么算写在 `tools/soak/faults.py` 的那条
|
||
判据里)。这个数还没到上限,说明库在还装得下的时候就报了超限;而最后一步自己就超了上限,说明有一次
|
||
超限的装配被放行去调了模型。两头都是击穿。
|
||
|
||
同一次运行还判「至少完整走过一步」。一步都没走,说明撞上的是「上下文本身就比上限大」,那个长度是装配
|
||
决定的、不是循环一步步撑出来的,这一类什么都没验到。那种情况报无法判定而不是击穿——库做的事仍然是
|
||
对的,错的是这一路的上限选得太小。
|
||
|
||
上限**按这次运行的上下文现算**,不写死一个数字:上下文有多大取决于挑中的是哪个审核点、语料清单有多长,
|
||
写死的话它今天够用,换一份数据就要么第一步就撞上限(什么都没验到),要么宽到几步都撞不上(白烧调用)。
|
||
算法与余量的取法在 `tools/soak/faults.py` 里。
|
||
|
||
这一类走 GovDoc 而不是 AppWorld,因为它的观察是整段文档原文,一步就能把提示词撑过线;而它的初始上下文
|
||
比 AppWorld 那份长提示词小得多,上限压到那个量级之后第一步仍然过得去,撞上的是第二步。
|
||
|
||
### 真把容器杀掉,才发现「连不上」那一档没有实现
|
||
|
||
环境故障那一类杀的是真容器,不是一个返回环境故障的测试替身。替身验不到「环境真的不在了之后,在飞的
|
||
连接、会话的关闭、容器池的收尾这一串还能不能干净收场」,而那一串正是这一类跑完要留下的东西。
|
||
|
||
第一次真杀掉容器就撞出一件事:压测自己那个环境客户端抛的是传输层异常,而不是它 docstring 里承诺的那个
|
||
错误类型——**「连不上」那一档当时根本没有实现**。后果是容器一挂,整次运行会以一个未捕获的第三方异常
|
||
炸出去,而正确的行为是记成环境故障、由库换上合成观察、干净收尾。
|
||
|
||
**这是压测自己的缺陷,不是库的缺陷。** 库对动作执行接缝的约定是环境故障要以约定的方式报上来,而没把
|
||
传输层异常翻过去的是压测的环境层。缺口已经补上:那一层现在把 HTTP 客户端抛的异常翻成自己的错误类型
|
||
(`tools/soak/appworld.py` 的 `_post_json`)。这件事值得记住的地方在于:一个只写在 docstring 里、
|
||
没有实现的错误档,除了真把环境弄坏一次之外没有别的办法发现——不弄坏它的时候,那条路一次都不会被走到。
|
||
|
||
## 7. 最硬的那条判据数的是环境侧的账
|
||
|
||
「声明绝不重放的动作没有被执行两次」是这套压测里最硬的一条判据。真正的重放长这样:库在状态未知时
|
||
把一次已经落过盘的写又执行了一遍,于是环境的账上多出一条一模一样的记录,**而轨迹里看不出任何异常**。
|
||
|
||
所以证据取自环境侧自己记的账——GovDoc 工作区里那份审计日志,每次有副作用的工具调用追加一行。
|
||
**不取库报的步数或动作数**:那是库对自己行为的陈述,拿它验库的行为就是我们和我们自己对账。轨迹里
|
||
的步记录记的是「库以为执行了几次」,两者只有在重放判定出错时才分叉,而分叉正是要验的东西。
|
||
|
||
判据是「按(工具、文件名、内容摘要)去重前后条数相等」。这三项就是账上记的全部,取这三项是因为它们
|
||
**恰好是一次重放会原样重现的东西**:重放执行的是同一个工具、写同一个文件、写同样的内容。而账上刻意
|
||
不记步号、不记时刻——那些每次都不一样,把任何一项随运行变化的东西放进键里,重放写下的那条记录就成了
|
||
「另一条不同的记录」,去重前后条数照样相等,判据静默失效。
|
||
|
||
代价是一种已知的假阳性:模型自己把同一份内容原样写了两次,账上也会出现两条一样的记录。分辨的办法是
|
||
回头看轨迹里那两步的步号——重放来自续跑,两条记录会分属崩溃前后。
|
||
|
||
**崩溃续跑这两类用 GovDoc 场景,不用 AppWorld**,理由就在这里。GovDoc 的「环境」是一个工作区目录加
|
||
一份审计日志,它天然活过进程的死亡,而且给得出环境侧自己数的执行次数。AppWorld 的环境活在容器里:
|
||
子进程被杀之后容器的归属与清理会变复杂,而它的执行计数存在会话对象里、会随进程一起消失,于是就只
|
||
剩「库自己报的数」可用了。
|
||
|
||
## 8. 重跑一遍
|
||
|
||
跑之前有三样东西必须已经就位,脚本一样都不会替人准备。**AppWorld 的镜像与数据**:镜像是官方发布的
|
||
现成镜像,不用自己 build,容器也由压测自己起自己停、不用事先起好;数据要先用 AppWorld 官方的下载
|
||
命令拉到本机一个目录里,那个目录就是脚本要的数据根目录,镜像名与目录结构的默认取值在
|
||
`tools/soak/appworld.py`。**GovDoc 的语料与审核点库**:那是一批真实公文和一份审核点数据,**不在这个
|
||
仓库里,也不对外**——手上没有它们的人跑不了 GovDoc 那一路,只能跑 AppWorld 那几类;路径的默认取值在
|
||
`tools/soak/scenarios/govdoc.py`,脚本不给就问它要。**模型凭据**:模型调用一律走 PolyGateway,凭据由
|
||
它的装配入口从环境里读,键的形状见仓库根的 `.env.example`(那份列的是 e2e 那层要的几个模型源键,
|
||
压测读的是同一套;`POLYLOOP_E2E` 那道开关只管测试,与压测无关)。
|
||
|
||
这台开发机上这三样都已经就位。换一台机器就得自己备齐:前两样按各自的来源取,第三样填一份 `.env`。
|
||
|
||
四步:**干跑 → 正常负载 → 故障注入 → 记分板**。记分板实际上跑两次,正常负载之后一次、故障注入之后
|
||
一次,两次的「无法判定放不放行」开关不同,理由见第 6 节。`tools/soak/soak.sh` 把这几步按顺序串起来,
|
||
它需要的全部输入写在自己头部的注释里:两个模型调用预算上限(正常负载一份、故障注入一份)、并发上限、
|
||
每个场景最多跑几个任务、以及 AppWorld 的数据根目录。GovDoc 的两个数据路径不给就问场景模块要,脚本
|
||
里不写第二份。
|
||
|
||
**长跑放 tmux 里跑**(`CLAUDE.md` §4):tmux 会话人和 AI 都能 attach,可以一起看同一份实时输出、
|
||
随时中断。脚本自己不建会话也不 attach,会话名的约定写在脚本头部。跑完不要急着 kill,留着给人复查。
|
||
|
||
脚本的每一步末尾都不接管道:管道的退出码来自最后一节,于是一次失败的跑会报成成功退出。要留日志就
|
||
用重定向,脚本有一个开关做这件事。
|
||
|
||
**预算与并发都没有默认值,缺了直接拒跑。** 一个能跑飞的压测入口迟早会跑飞:模型调用花真钱,容器占
|
||
的是和别人共用的机器,而这两个数字是唯一能拦住它的东西。达到调用上限时停止派发新任务,但**已经在跑
|
||
的让它跑完**——半路砍断会制造一批没有结束记录的日志,而那和崩溃留下的日志长得一模一样,会污染故障
|
||
注入那边的判定。计数在真正发起调用之前加:数的是尝试而不是成功,一次失败的调用同样占了时间、可能也
|
||
已经在网关那边计了费。
|
||
|
||
**干跑是全量之前的必经一步。** 它不打模型,但把每个任务的运行请求真的装配一遍:容器起不起得来、语料
|
||
读不读得进、脱敏闸过不过得了、提示词模板渲不渲染得出来,这几件事全在装配这一步暴露,而在全量里暴露
|
||
的代价是已经花掉的那部分调用费。它刻意不往运行目录写任何东西——写了的话,紧接着的全量会因为这次运行
|
||
的标识已经存在而被库拒绝。
|
||
|
||
环境层还有一个更靠前的入口:一个离线冒烟,起一个容器把整条会话链路走通,一次模型调用都不打。它执行
|
||
的几段代码是写死的,其中一段故意写错,验的是「代码报错不抛异常、错误文本原样回来」这条行为——那是
|
||
上层循环最依赖的一条,而正常路径验不出来。
|
||
|
||
每个脚本的参数形状以它自己的 `--help` 为准。
|
||
|
||
## 9. 这套压测没有 design doc
|
||
|
||
`research-wiki/design/` 下没有一份是关于它的,这是有意的。压测不是公共 API,它的取舍选错了改起来
|
||
也不贵,写一份冻结的决策记录换不回什么。它的理由分散在两处:**`tools/soak/` 各模块的 docstring**
|
||
(为什么这么设计、哪些坑是实测撞出来的),以及**提交正文**(每一次改动的处境与实测结果)。取值、
|
||
参数形状、判据的逐条定义,权威都在那两处。
|
||
|
||
代价是:提交正文会随着历史往后越来越难翻。这个代价是被接受的,因为替代方案——为一个一次性工具维护
|
||
一套冻结的决策记录——的成本更高,而它守的东西在验收阶段结束后就没有读者了。
|