Files
iomgaa 0752723c3c docs(explanation): 写自造压测负载这份常青文档,过了一轮硕士生冷读
讲这套压测是什么、九类故障各守什么、判据为什么全是结构不变量、以及怎么重跑。具体数字
一个都不写——那些是一次实测的结果,放进常青文档等于种下过期条文。

冷读抓到的九条全部采纳:产物那段自相矛盾(谁写了那四个文件)、GovDoc 三个阶段全文一个字
没有而后面两节都建立在它上面、判据的权威出现两个说法、六个核心概念没解释就在用、哨兵那个
例子整句读不懂、四处只写结论没写理由、重跑那节缺环境前提、故障注入为什么没有自己的报告、
以及一步没写出来的因果。

冷读同时报了三段跳读,按它给的事实处置:脱敏那节压掉后半段、九类列表重排成只读粗体也拿得到
每类守什么、打包那两段压成一段。

更新触发点写在文首:这套压测是一次性工具,验收阶段过去之后如果它被删掉,这份文档跟着删——
一份描述已经不存在的目录的常青文档,比没有更坏。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 11:54:15 -04:00

389 lines
34 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 自造压测负载
> **更新触发点**:改 `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**
(为什么这么设计、哪些坑是实测撞出来的),以及**提交正文**(每一次改动的处境与实测结果)。取值、
参数形状、判据的逐条定义,权威都在那两处。
代价是:提交正文会随着历史往后越来越难翻。这个代价是被接受的,因为替代方案——为一个一次性工具维护
一套冻结的决策记录——的成本更高,而它守的东西在验收阶段结束后就没有读者了。