Files
PolyLoop/research-wiki/migrations/dissect.md
T
iomgaa c4e5732587 docs: 回写全仓库对契约套件的指向,以及 README、架构与 CHANGELOG
套件从 tests/contract/ 搬进 polyloop.testing 之后,全仓库 28 处引用要重新指过。修了 12 处,
其余在 design/(只增不改)与 scratch/(由人清理)里。

**CLAUDE.md 改了四处事实**:§0 权威表里行为契约的权威、§0 那句依赖规则的条数、§5 目录树与
模块数、§1.8 那句「谁断言公共 Protocol 的签名」。§1 的其余硬约束与 §2 的人类门一条没动。

**architecture.md**:分层图第 4 层加一格,装配层从三个变四个;代码地图加一行;第十节按代码
逐项重写——那笔「工具段渲染样式」的欠账**没有被数字对上盖掉**,加了 fingerprints 之后请求
的字段数恰好还是十一,而组成已经换过,所以那一节正面写着它仍然欠着;新增第十条依赖规则
(pytest 只在 testing 那个 extra 里,别处 import 它会让下游的生产环境一 import 本库就
ModuleNotFoundError),带静态与运行时两半;删掉「src/ 下一行代码都没有」那段过期状态说明;
决策索引补齐 0008 到 0016,其中四行原描述说的不是那份文档真正定的东西。

**migrations/dissect.md** 那笔「内存实现不存在」的欠账还掉了。

**压测那边**三条测试守的是一条已经撤销的公共契约,改名并写清它们现在守的是场景自己的选择。
AppWorld 那处刻意的偏离(不补三个反引号)留着不恢复——那条路径要模型输出被 stop 序列截断才
触发,而压测不配 stop 序列,恢复的收益不抵重跑一次压测的成本。但注释的理由改对了:它现在是
一笔有出处的欠账,不是一个决定。

CHANGELOG 攒在「未发布」段,版本号不提前写(§1.10)。
2026-08-27 03:59:39 -04:00

16 KiB
Raw Permalink Blame History

迁移:dissect

更新触发点:第 2 档(同提交同改)。 公共类型或接缝语义变更时,在同一个提交里核对 本文的组件映射与需求条目;dissect 侧真正搬走某一块代码时,在同一个提交里勾掉删除清单 对应行。

当前状态:一行代码都还没搬。 组件映射一栏填的是接缝名而不是类名,因为 src/ 还不 存在;等第 ③ 阶段架构定了再补具体类型名。

dissect 是 PolyLoop 唯一做硬迁移的消费者,也是唯一有跑着的代码、有测试、有真实实验在依赖 的消费者。它的循环在 reference/dissect/harness/agent/,五个模块加起来 1158 行,配套测试 (reference/dissect/tests/agent/1278 行。

验收口径:把那套循环搬到 PolyLoop 上之后,dissect 现有测试全绿,且真实实验跑出来的轨迹 与迁移前逐字段可比。 这是 PolyLoop 唯一的硬验收标准,另外两个消费者走设计级验收 (见 govdoc-saas.md)。

dissect 的项目术语

这些词是 dissect 的,不是 PolyLoop 的——库不认识它们,本文和 dissect 侧的代码注释里会用到。 PolyLoop 自己的词表在 ../explanation/architecture.md 第二节。

rollout——被试 agent 在一道题上跑完的一次完整过程。它正好对应 PolyLoop 的「一次运行」, 所以迁移之后 dissect 的一个 rollout 就是一次 run

三本账——dissect 把模型调用按用途分成三类分别记账:做题的(生成)、跑验证集的(评估)、 让模型反思改进的(反思)。分开记是它的实验纪律要求的——比较两个配置时,双方的总花费必须 对等,而混在一本账里就算不清谁在哪一类上多花了。每次调用记哪一本由调用方指定,循环本身 不选。

best-of-N——同一道题独立重做 N 次、取最好的那次。它是一个「同预算基线」:如果一个花哨 的方法效果好,得先证明它比「同样的钱拿去重做 N 次」更好。

因子——dissect 要测的设计变量,十来个,每个能独立扫几档取值。它整个项目就是在测每个因子 的效应量,所以任何被库写死、又会影响成绩的取值,都可能污染某个因子的测量。

AppWorld——一个交互式 benchmark(给 agent 一批模拟的 app 和一个任务,看它能不能用代码 操作那些 app 完成)。它是 dissect 的第一个任务,dissect 的提示词格式和多代码块处理策略都跟 它的官方实现对齐,为的是成绩可比。

缓存成本校正的锚点——供应商对「命中缓存的前缀」按折扣价计费。要事后算清一次运行真实 花了多少,得知道哪些前缀被缓存了;而一次上下文截断会把前缀打断,之后每一步重新全价, 原来那个推算的参照就没了。

dissect 是什么,为什么它的约束特别硬

dissect 是一个论文项目,研究「agent 的文本自我进化为什么有效」。它把自我进化拆成十来个可以 独立操纵的设计因子,用受控实验测每个因子的效应量。被试 agent 的每一次运行都是一个实验 数据点,轨迹文件是论文的原始数据。

这带来一批别的项目没有的约束。轨迹里少记一个字段,事后补不回来——那次运行已经发生过了。 一个本该被记录的失败被静默吞掉,会以「效应」的形式进入统计。所以下面「需求条目」那一节里 的每一条,都是从这个性质推出来的,不是偏好。

删除清单

「继任」指这块代码的职责由 PolyLoop 承担,dissect 侧删掉。「留下」指它是 dissect 的项目 资产,PolyLoop 只提供它要实现的接缝。

dissect 侧 行数 去向
harness/agent/loop.pyReActAgent._loop_take_step ~130 继任
harness/agent/loop.py_make_step ~35 继任
harness/agent/loop.py_observe_is_done ~40 继任(成为动作执行接缝的语义)
harness/agent/loop.pyReActAgent.run 装配部分 ~75 留下(组装 Rollout 头、传项目 metadata
harness/agent/memory.pyStopReason ~25 继任
harness/agent/memory.pyStep ~65 继任(字段是库的下界,见需求条目)
harness/agent/memory.pyRollout ~90 留下
harness/agent/memory.pyrender_messages ~40 继任
harness/agent/context.pyArtifactView ~20 继任(成为 Skill 注入的输入形态)
harness/agent/context.pybuild_prefix 段顺序约束 ~15 继任(约束本身,不含渲染格式)
harness/agent/context.pyPromptTemplate_split_by_role ~95 留下
harness/agent/parser.py 全部 182 留下
harness/agent/config.py 的预算字段 ~10 继任
harness/agent/config.py 的 YAML 加载与校验 ~140 留下
harness/envs/protocol.pyEpisode ~60 继任(成为动作执行接缝)
harness/envs/protocol.pyScoredEpisode / TaskEnv / TaskItem / ItemScore ~120 留下

标「继任」的十行相加,净删除约 440 行,其中循环本体和轨迹结构占大头。

三个「留下」值得解释

Rollout 留下,因为它的二十个头字段绝大多数是项目 metadata——运行标识、进化轮次、 题目标识、场景标识、数据划分、用途、随机种子、尝试序号这些,PolyLoop 一个都不认识。 库只返回它自己知道的事实,不反向吸收这些字段——吸收了就等于替另外两个下游做了它们不需要 的假设。

parser.py 留下,因为它是 dissect 的动作语言。它把模型输出里的 Python 代码围栏抽出来, 处理未闭合围栏、空围栏、多围栏这些情况,并且刻意把第一个围栏之后的文字截掉(模型常在 代码块后编造「执行结果」)。这套规则是 dissect 和 AppWorld 官方对齐的口径,不是通用的。 PolyLoop 提供的是「把模型响应解释成动作、最终回答或无效决策」这条接缝,parser.py 是它的 一个实现。

config.py 的加载与校验留下,因为 dissect 有一条硬纪律:所有实验参数从 YAML 读入、 没有任何默认值、多余的键也要报错。这套「配置双模式」是它自己的规矩。PolyLoop 只接受 已经组装好的预算值。

需求条目

这些是 dissect 的代码和它的实验有效性对 PolyLoop 施加的硬约束。每一条都能指出一个具体的 失败后果。

一、停止原因必须是枚举,而且要能区分「恰好在最后一步做完」与「预算耗尽」。 从轨迹长度反推不出来,两种情况的长度一模一样。dissect 的崩坏判据要按停机原因分层,这一档 分不开,整批数据的分层就塌了。

二、上下文超限必须显式终止,不许静默截断。 截断是一次前缀破坏操作,会让后续每一步 重新按全价计费,并把缓存成本校正的锚点搞丢。而且被截断的运行会表现成一批低分,看起来像 模型能力不足。

三、三类没碰环境的步也必须留痕:解析失败、模型调用失败、环境故障。 前者消耗了一次 模型调用,不计的话预算对等不成立。后两者的情形是钱已经花了、账已经记了,丢掉那一步会让 账目与轨迹对不上,而且会丢掉模型在出故障那一步说了什么——排查「是环境坏了还是模型写了 危险代码」最需要的就是这段原文。

四、模型调用标识是轨迹与账目之间唯一的连接键,它可以是「没有」,但不能是空串。 空串是个看起来合法的键,连表时静默匹配不上;显式的「没有」至少能被筛出来。它为空的合法 含义只有一个:调用在记账之前就失败了。

五、消息段必须按变化频率从低到高装配。 稳定前缀在前、逐题变化的在后。排错了不会报错, 只会让供应商的 prompt cache 静默失效,而多付的幅度随注入内容的规模变化——于是缓存伪影会 精确地伪装成因子效应。

六、历史只能追加,不能改写或重排。 理由同上:第 n 步的提示词正好是第 n-1 步加一段尾巴, 任何中途截断、摘要、重排都会让后续每一步重新全价计费。

七、模型原文与真正进入历史的文本必须分开记,环境观察同理。 dissect 的解析器会把第一个代码围栏之后的文字丢掉,所以「模型说了什么」和「下一轮模型看见 什么」是两个不同的量。混用会让统计口径出错。

「分开记」记的是一段文本加一个数字,不是两段文本。 dissect 两侧都这么做:模型那侧存 raw_output(解析器截断之后的,正是回填进历史的那段)加 content_chars(模型可见输出的 全长);观察那侧存 observation(进历史的那段)加 observation_truncated_chars。照「两段 文本」读会得出「库少了一个字段」的结论,那个结论是错的,见 ../design/0005-storage-atomicity-and-record-fields.md 决策三。

八、可见回复与推理段的长度按字符记,不依赖上游上报。 实测中转网关会用本地分词器补算并整体替换用量对象,把明细一起吃掉——某次标定里 24 次调用 的推理 token 全部没上报。字符数直接数,不受上报与否影响。

九、一个 agent 定义可以被并发驱动跑不同的题,任何实例级可变状态都不允许。 dissect 有一档实验要在同一时刻用不同的注入内容跑同一批题,实例级状态会让并发的候选互相污染。

十、循环拿不到分数。 让被试拿得到真值等于开后门,实验当场作废。评分必须在会话关闭前由 外层用宽接口完成,PolyLoop 从始至终只见窄接口。

组件映射

接缝的完整清单与各自职责在 ../explanation/architecture.md 第九节,本表只做映射。

dissect 侧 PolyLoop 侧 备注
LedgerClient.chat 模型调用接缝 dissect 的三本账记账继续作为绑定上下文的实现装配进来
parse_code_action 决策解释接缝 三分支:动作 / 最终回答 / 无效决策
Episode.execute 动作执行接缝 dissect 侧只有「返回文本」和「抛异常」两种结果,状态恒为「已执行」
Episode.is_done 动作执行接缝返回值上的完成信号字段 不是独立接缝,见 architecture.md 第九节
ArtifactView(通道、条目标识、内容三字段) Skill 注入的输入形态
build_prefix 的段顺序约束 不是接缝,是库内 _assembly 的纯函数 渲染格式留在 dissect,理由是排除条款(见 ../explanation/scope.md
AgentConfig 的三个数值上限 请求上的预算 max_stepsmax_consecutive_parse_failuresmax_prompt_chars;库的预算比它多一个已执行动作数上限
StopReason 停止原因 dissect 现有六个取值是库的下界
Step 逐步轨迹的一步 dissect 现有十三个字段是库的下界

具体类型名与签名按 ../../CLAUDE.md §2 要过人类门,还没定;定了之后本表补上英文名。

缺口登记

运行标识要带齐现在文件名里那五维,否则会重演一次静默覆盖。 现在的轨迹文件名是 r{轮次}__{阶段}__{题目}__s{种子}__a{尝试}.jsonl_trajectory_path 的 docstring 记着一个 踩过的坑:阶段那一维原先漏了,导致同一次 run 先跑 train 再跑 gate 时后者静默覆盖前者。 PolyLoop 的日志按运行标识分文件(../design/0011-jsonl-run-store.md 决策一),所以那五维要 拼进运行标识;少一维的表现不再是覆盖,而是「日志里有别人的记录」,恢复会判成日志损坏。

题目那一维不保证是安全字符,编码方式由 dissect 自己定。 PolyLoop 自带的那份存储要求运行 标识只含字母、数字、点、下划线与连字符——它同时是文件名,而库不做转义(转义之后文件名就不再 等于标识,按标识去目录里找文件这个用法就断了)。所以 item_id 里要是有中文、空格或标点,得 由 dissect 选一种单射的编码把它变过去(哈希、百分号编码、自己维护一张映射表都行)。 选哪种归 dissect,因为只有它知道那份标识事后要怎么被人认出来——而它的分析方式是让模型去翻 文件,所以「一眼认得出是哪一次」这件事对它有实际价值,哈希成一串十六进制未必合适。

这条日志和 Rollout.to_jsonl 那份轨迹是两样东西,不要混。 前者是意图日志,记的是「准备 做什么、做完了没有」,为的是崩了能续;后者是产物,记的是逐步轨迹,给反思模型读。前者由库写, 后者迁移后由 dissect 自己从 RunResult.steps 重组(见下一条)。

轨迹文件的格式是反思模型的唯一输入界面,迁移后要由 dissect 自己重组。 Rollout.to_jsonl 现在把项目 metadata 头和逐步轨迹写在同一个文件里,第一行是头、后面每行 一步。PolyLoop 只返回运行标识和步序列,所以 dissect 要自己把两者拼回那个格式,而且格式 不能变——现有的分析代码和不变量检查器都在读它。这是一笔真实的迁移成本,不是设计缺陷。

模型是正常收尾还是被输出长度上限砍断,现在拿不到。 dissect 的 Step 里没有这个字段, 原因是 PolyGateway 的响应类型不透出它。这个信息有实际价值——输出被砍断时代码只写了一半, 表现成解析失败或语法错误,会与「模型不会做」混淆。迁移后仍然拿不到,要补得给 PolyGateway 提 PR。登记在这里是为了迁移时不要误以为是 PolyLoop 弄丢的。

动作被拒绝这个概念 dissect 侧没有。 它的环境执行只有两种结果:返回文本(代码报错也算 正常观察)或者抛异常(环境故障)。PolyLoop 的动作执行接缝要区分「已执行」和「未执行」, 是因为另一个消费者需要。dissect 侧的实现状态恒为「已执行」,这一点要在迁移时确认不会改变 它的步数统计口径。

../design/0003-public-api-shape.md 新产生的四项成本

每个 episode 要写一个包装类。 dissect 现有的 Episode 协议与 PolyLoop 的动作执行接缝 不构成结构化子类型:那边是 execute(action: str) -> stris_done() -> bool, 而库这边要的是一个返回结构体(状态、观察、是否合成、完成信号、截断字符数)的方法。 所以不能直接把 Episode 传进去,每个 benchmark 的适配器要多一层薄包装。

续跑路径要改。 dissect 的 runner 现在把「没有结果行的尝试」重新排进待办,用完全相同 的六维主键第二次调用循环。迁移后这条路要改成调「接着跑」而不是「再跑一次」——按 ../design/0003 的前置条件,用同一个运行标识第二次调「跑一次」会直接报错。

要选一个存储实现并给它一个目录。 存储接缝是必填的,不传就装配不起来。dissect 要恢复 能力,所以装 JsonlRunStore,给它一个目录。

「不提供恢复的内存实现」叫 VolatileRunStore ../design/0003 的否决方案那一节定过: 存储接缝必填,另外给一个明确命名的、不提供恢复的实现,好让「我不要恢复」成为一次看得见的 选择而不是一个可以忘记传的参数。它现在和 JsonlRunStore 一起住在 polyloop.stores,两个 实现跑的是同一套契约套件。它不提供的是跨进程恢复:日志随进程一起消失,选它就是选「我 不要跨进程恢复」。名字为什么落在「易失」而不是「不提供恢复」上,见 ../design/0014-contract-suite-distribution.md 决策五。这一条对 dissect 不构成阻塞——它本来 就要恢复。

模型绑定要从关键字参数还原。 dissect 现在给每次调用传五个关键字参数(账本、轮次、 阶段、题目、尝试序号)。库这边接的是一个字符串映射,所以适配器要做一次还原(把账本那个 字符串转回枚举、把轮次和尝试序号转回整数)。这是适配器该干的活,收益是这五维能原样进 参数快照。