Files
PolyLoop/research-wiki/migrations/dissect.md
T
iomgaa 4f8812fa82 docs(design): 落成边界、续跑、公共 API 形状与停止语义四份决策
第 ② 阶段需求对齐与第 ③ 阶段架构的产出,代码尚未开始。

design/0001 定边界判据:三道测试(时机 / 信息 / 性质)全过才在界内,
外加「只认接缝、不认接缝后面是什么」与不夺走下游实验因子的排除条款。
design/0002 定步级续跑:不承诺原子性,承诺绝不静默丢失与不替工具猜幂等性;
先写意图再执行、结果 ID 预分配、重放策略由工具声明且默认绝不重放。
design/0003 定公共 API 形状:单一入口两个动词、五个接缝、三个伪接缝的排除理由、
分层与九条依赖规则。design/0004 定停止判定顺序、十个停止原因取值与步记录字段表。
0003 与 0004 需过 CLAUDE.md §2 人类门,已由项目负责人确认,状态转为已接受。

explanation/scope.md 与 explanation/architecture.md 是这四份决策的常青回写,
分层与模块边界的权威在 architecture.md,将来由 import-linter 契约机器断言。
migrations/ 下 dissect 是唯一的硬迁移验收,govdoc-saas 只做设计级对齐。

三道闸都过了:14 agent 对抗辩论定骨架,两轮硕士生阅读报的 30 余条已修完,
Codex 对抗审查抓出的两条致命问题(提交型完成被误判成环境故障、
崩溃恢复漏一个状态)已修,修完的形状还没送 Codex 复审。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:48:33 -04:00

195 lines
13 KiB
Markdown
Raw 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.
# 迁移: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.py``ReActAgent._loop``_take_step` | ~130 | 继任 |
| `harness/agent/loop.py``_make_step` | ~35 | 继任 |
| `harness/agent/loop.py``_observe``_is_done` | ~40 | 继任(成为动作执行接缝的语义) |
| `harness/agent/loop.py``ReActAgent.run` 装配部分 | ~75 | 留下(组装 Rollout 头、传项目 metadata |
| `harness/agent/memory.py``StopReason` | ~25 | 继任 |
| `harness/agent/memory.py``Step` | ~65 | 继任(字段是库的下界,见需求条目) |
| `harness/agent/memory.py``Rollout` | ~90 | 留下 |
| `harness/agent/memory.py``render_messages` | ~40 | 继任 |
| `harness/agent/context.py``ArtifactView` | ~20 | 继任(成为 Skill 注入的输入形态) |
| `harness/agent/context.py``build_prefix` 段顺序约束 | ~15 | 继任(约束本身,不含渲染格式) |
| `harness/agent/context.py``PromptTemplate``_split_by_role` | ~95 | 留下 |
| `harness/agent/parser.py` 全部 | 182 | 留下 |
| `harness/agent/config.py` 的预算字段 | ~10 | 继任 |
| `harness/agent/config.py` 的 YAML 加载与校验 | ~140 | 留下 |
| `harness/envs/protocol.py``Episode` | ~60 | 继任(成为动作执行接缝) |
| `harness/envs/protocol.py``ScoredEpisode` / `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 的解析器会把第一个代码围栏之后的文字丢掉,所以「模型说了什么」和「下一轮模型看见
什么」是两个不同的量。混用会让统计口径出错。
**八、可见回复与推理段的长度按字符记,不依赖上游上报。**
实测中转网关会用本地分词器补算并整体替换用量对象,把明细一起吃掉——某次标定里 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` 的四个预算字段 | 请求上的预算 | |
| `StopReason` | 停止原因 | dissect 现有六个取值是库的下界 |
| `Step` | 逐步轨迹的一步 | dissect 现有十三个字段是库的下界 |
具体类型名与签名按 `../../CLAUDE.md` §2 要过人类门,还没定;定了之后本表补上英文名。
## 缺口登记
**轨迹文件的格式是反思模型的唯一输入界面,迁移后要由 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) -> str``is_done() -> bool`
而库这边要的是一个返回结构体(状态、观察、是否合成、完成信号、截断字符数)的方法。
所以不能直接把 `Episode` 传进去,每个 benchmark 的适配器要多一层薄包装。
**续跑路径要改。** dissect 的 runner 现在把「没有结果行的尝试」重新排进待办,用完全相同
的六维主键第二次调用循环。迁移后这条路要改成调「接着跑」而不是「再跑一次」——按
`../design/0003` 的前置条件,用同一个运行标识第二次调「跑一次」会直接报错。
**要选一个存储实现并给它一个目录。** 存储接缝是必填的,不传就装配不起来。dissect 如果暂时
不要恢复能力,要显式装配那个明确命名的、不提供恢复的内存实现——「我不要恢复」是一次
看得见的选择,不是一个可以忘记传的参数。
**模型绑定要从关键字参数还原。** dissect 现在给每次调用传五个关键字参数(账本、轮次、
阶段、题目、尝试序号)。库这边接的是一个字符串映射,所以适配器要做一次还原(把账本那个
字符串转回枚举、把轮次和尝试序号转回整数)。这是适配器该干的活,收益是这五维能原样进
参数快照。