# 迁移: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 的解析器会把第一个代码围栏之后的文字丢掉,所以「模型说了什么」和「下一轮模型看见 什么」是两个不同的量。混用会让统计口径出错。 **「分开记」记的是一段文本加一个数字,不是两段文本。** 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_steps`、`max_consecutive_parse_failures`、`max_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) -> str` 与 `is_done() -> bool`, 而库这边要的是一个返回结构体(状态、观察、是否合成、完成信号、截断字符数)的方法。 所以不能直接把 `Episode` 传进去,每个 benchmark 的适配器要多一层薄包装。 **续跑路径要改。** dissect 的 runner 现在把「没有结果行的尝试」重新排进待办,用完全相同 的六维主键第二次调用循环。迁移后这条路要改成调「接着跑」而不是「再跑一次」——按 `../design/0003` 的前置条件,用同一个运行标识第二次调「跑一次」会直接报错。 **要选一个存储实现并给它一个目录。** 存储接缝是必填的,不传就装配不起来。dissect 要恢复 能力,所以装 `JsonlRunStore`,给它一个目录。 **「不提供恢复的内存实现」叫 `VolatileRunStore`。** `../design/0003` 的否决方案那一节定过: 存储接缝必填,另外给一个明确命名的、不提供恢复的实现,好让「我不要恢复」成为一次看得见的 选择而不是一个可以忘记传的参数。它现在和 `JsonlRunStore` 一起住在 `polyloop.stores`,两个 实现跑的是同一套契约套件。它不提供的是**跨进程恢复**:日志随进程一起消失,选它就是选「我 不要跨进程恢复」。名字为什么落在「易失」而不是「不提供恢复」上,见 `../design/0014-contract-suite-distribution.md` 决策五。这一条对 dissect 不构成阻塞——它本来 就要恢复。 **模型绑定要从关键字参数还原。** dissect 现在给每次调用传五个关键字参数(账本、轮次、 阶段、题目、尝试序号)。库这边接的是一个字符串映射,所以适配器要做一次还原(把账本那个 字符串转回枚举、把轮次和尝试序号转回整数)。这是适配器该干的活,收益是这五维能原样进 参数快照。